From 6921329fdca58f94bdb6f86a4d0b0ed4c7fffed2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 12 Aug 2026 15:12:08 +0800 Subject: [PATCH 001/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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 zcmXV1byyVb*PdM#Sb71G5M%`r=@RKiLL{UcB$W<9dY2NER3ro>1?fhb1*8-t1f&~8 z>CTOBd4JzuJJ&VO?40M+bDwkWd84DHNzXX8O zbaiC~eZQIQHb-yf$rL_6Er*nF4(2wxw$$9aKg|8Fc6VO2T=^>ZWkm0O6hp0@2z-pb z5IkyceJ(Otw_VVx_W1b2-!@`(Si6qFM+xU_$uAx{5E+F$Wc`g|;rnUAgY`dWoK@eZ zdEHgd^UNiDkxsO$Yp~Zn@qjWc$Ez|sysv)QIC}42ue})m#*$mp!W#_S}VcGLeyXx$bH5;emE3Pk6nY4QCGJ0e`Q>}kM5#ZgEXT6S=SRv@s zje`Vu%$ENhCv&m0WnU?NDG#$~OK|8zja@H9j4GN?>+hV&to^a|C^)i8jBJG=rC z0~F+4_PiB3?#JiJ^lrP-L)COf1X5Fui9Jvd!r;dFgssD=_h{ZZ{*|xh0xOpc@#mh| zN+d+USQt9AL**;~Rw|X-x)!T-kO>q)eY+W!O6DO!m3qX{(sy$E`!-*bVBeVi#J2?y{m6`h?t<2GBaejg{c{LQ>0s);f6r*3=Sy7?Tcn+T-gVbPDc z6Yu{pgA*F|Tv=^mi923H{4P{nU@c*5!xNKUN9`dIJMhd*h@n&B<+0_CK7ElO+!_$H zxV%&l{ir>V6ny`{@B5$B)lCuclvPh2&%k#a7dOo+hJPpTn`Ci9ZSK`EqHijGUr4#x zWspf$z~n(QIlLquIqT>_*%lJDS)wn!o|&N|&;cwJpPcT!`=^nz_;%OUE{2Ib9B59h!e+2w?!Ef5%$&o_bVM_YE{+Gx-iQzAie5VE$a4Cpx#ELKI+U zHy9UqB$&STA4Q;L2VtpGNEseI}d7&okew?Q210PAN|mBO=0z$mQ{d3gz7;GD=ag!J}Yv6 z=rg~eIwj`n2tCZZc5a<8g-)AOjMRFjET$x^U8WiB(oeKUUo3PTw3aZP98c*Yx?Qs2 z(7fCdFx@OuA{WS$=|gcSi@371_}@=g-fnxHE=RDDTJ?E3lVVksi!zSAfYysyPYfT= zDcP3FQ{BqS;p^vZ0t^Y~nk4nUj7r9OqbW025#1+NJzT|??bL*x_EptynI4id+p!_;7cM!77CF>pZP z+2z)veChU{p~2I|hs>-?e(DD00UxgwIEa3WG^W_=I^c@W*7uoQ~ged+rmSsL_)&g(I3sIVM@8qB~Aip8S!N@^N@{_?xmR6 zGj9hwQFwZ18}_ZhVMjG!B({3!+B)jKdRe>a+aT+J#ygFOKxcU+6!P%EjGtIMmv|0d z=;qzQPV9MABS4c~JOjvl10(y1L~VC9yMk-ppFL`ALfF<~*L}0(hGX%%3ZnxV%*+2p z6MSac$1Qt_`Pq$Y@O%3bxJ1$+|4r{zHV#?^V8oTj8%R?3mMAcM6_q-)2Dx;@25&#( z-A*Ww8$8q|T0@e%-=pZ!1NL}5w+n7y?%L5Xyrq-*q8X4;nnQnXU~PUM*S4q0%(~J( z>Dgpx5Q%6AmMRapj+^=k55ez~6|3TJ<>fOWa-9dsV?ygpnsD$@RO$<+McqJ&l~N&d`Km{T3Mn<`O{JkFY*CHJ*e1+8ck__ zgz~U(iL9L9OX69LHUXuDamb!V`ivSblw9`o>@^s!?~w=hZhk$kp2TZfLAvN+$D-tY zTVza!D5};<02$S-xnFm;fM2Nojgt~D-7ks6sx3DzK$Mn-YO9uuZ`gN6kvS_By8+*fN4NFrFalx`RYkPuDfe)$W8t|&= zwte!R$MA-670HIi5_~zoto|d8q%YC&_JbW!&{+EY@-AWLH}i>d{Mb_F^Yjnc)Zf7x zDeHJ&hT4z{etb4(>hTmbcb8`!N05r!<-74?IY52-t0)1Ico6M6y!c2@rqPeAPi_e- zR{p7yXY@nS;kBHFjZClrJ+Mh&Afb5i`~rV?sV{tM&o$z){DU|VTx_jnT}nIdrvpxS zW%B7+q21Khf13ls)WyGy-7B(Ksw`M4!>QgRovyUEm$a5N1n0fTyy#0or!KG9N-d(= z+@7#kQ!@-;ulJXfMHAh&Q~f@z{TUBPX^EejILmtsG*(Rm(?qRVIc_s#w^|GTIU5$o z8fw-ZPxuhyZKog|mm)7iEaRw3v=ROd>kh-d&~Rgf5Uw|W2NMB4l!$n=(X{^ns;~M{ z+^jIsUBtCI(jlPYbmQcqX}J9M=2fRX0@CoQ$jnfTj~f)w?02Q{a;;5%WfiyRd39E@ z`H$7B{W>aj;#TS5JE6A1jK~2TSp;Hm_MUMTWeY*xi?w|tcEnuDd)#92CXZbqn zwOc7X5kn!zGiVYYcLXepo1ccxVl~5n)W2H9eH~x;vc?Q|_P}8V?=M!**>%XJ?p*FqYy&Z)cO|S>Zf8N~Pf$qI7^O3)cK@9l5}u-0U_&(;_D9A00I3shB;YC`;GP`o>{Aq<<#4wBwYKs`!;#%uRqMQc>h?>d zjeBz4y#3zfH)P|pKi`A<{h~yKxOdN7z5TX`tGG9Gp~`!8EtAM+mL<;FnvPvu#{tTO z@@2nqe5MH7z+e;Ge z$K-@?FI_~`E?`d(RNt>{koLkhOm@TVEAx5az#Hu#Nq9gQCF0Q@iCZmC@M0bIJD0|P zj>LON2HOp!4Z1(;BCtHQTu6}h!PfdX!uLMsDapFRrNA-?F3!B<4NvfkJ&kJ#ULKxF zRE~MAJmR`qomvM21D}##mjh~RY}!9soC)0!v7Bh?3W1Xt!vDEX$~L81t8a(yva%V; z-=2d~x}PzA{I30g{15uyYw&7&TvKCW8IHxkK;iPKj@o}=pkN2rwc$DpKprh)+*ricJ5%$rvfC@?u$Lf`eUWD9H~`nF zFF~lHCw^qLWwmr^sw{%&D3ER{RR4p)CvB+BS3;01@xa{<_X2my3h9I`IdT3Ti-S5U zU^5dt=PUQdX`wI4?$Su(Y^xC9iIuJr(-X7Lc5Z2aIpN+O@0CYpijp09ml@_wNv*qCGrq2;N*Vq)lR$A62rFwVUOLKhc0 znsd8-W!J`q@BaMm%p3LAM5os165+BHo?k2|QB{DWdfMzql{!xj;FmyGLcvJ4Wu5(h z(0H=OV6>;%I&nhradtq%qy8!q#Q9c9^Rj^LBg(fLA^)we&l}O78TLf#{XVmP}oj|Fx%wH3}erMX+(>G0U>yvhlL%Nt!8WG(X`g zVfbn_!3-ILsy@QbXP#ZBxM!VcjTsdC0XcR+B%Rt|C3-@+%ORT>hfXJ3X2e8{;X!a%aZ$w zKHI#Y9bm1Y=yQ5B@xR2iu8;KCc{tGa3(Qb!8%i}t3cX!(jL%lL(T8WACj`viTep?| z^Xuq7cG8!y3XFWCk+`3*TUHGqKaN;9dGuGcHMCTqc(V{iN&x)lZ0~^_o2FBaWy|HI z?~NDL#K6$O@DR%nsA6eJ^9|STb>=fcsigk}OLQMSBzr zL1ox1n~EPL%A+N{@lWdi6s1QG73ZyE=DkI5)b(={!~zZ{ECa_E&P2;2nc)%yn+-P8tD!&RVi;^vhp&PzXYqB9}q}arb$;ZRDa$ z1(pu&%}=Y&{#9M_BwSn4_O2u_jqDiM?zO6%#;7-P1my9T`yaa4L$lB%mO(; zn&MN_(amM2)Q(IV~!f7h__m)>L?-G9>UW}kbyX|k+c+C|}fh``+h&q3S^ z%1DnBw!PZa1$8pm{1)*{GKy!sy9x0YhgZd};`y#FjbPy4sB(rz_uyz&g~70djP#;Y zu}r=>ij}>SFanFS#y4~T4|dG=Ujl@3r?HtXRoHUG@GKa8mjJ%!Sby+N z1M7`Lncb^($M3F&gwcU@V2Eo94`o`ByS zB_nF!l=J(DO#{D$R#*I>rmq!7CFy14Gh?Y@VuOez4zmygBz#lc#LrCpiZ|ZM<2Z0G zYmN^{TS7bX0A^*K@<|w2R2hJe-?`t5Vubn$-N~FI-0`uovCC>Is;YF97W}EAc_4{@ zh0ksjP{#+!R+5URr)PvK<|fu|%vZH{Ern4)pg{ys6c{#ZXHkRGYrH{;TI&U^i)=Q4 z@>))4_9?tQs|Q1%^HiMIXkq27CM2=_v|pC-_U+$2HbULGZu89r{8sH{0fB)AR#w?v zlON^;3NYjECBQ%=Apa4_(b4(z=~KyN%}XZ@;gn0_!dtLa#?~6@-}WYCkS`4?gHi<+pe&kG|Z+M+PHS;NLF+J5eUP$|PG|~z`m3v%|L$B9qm-!pA?Jm58WHS}IJ>fE z-HLPd^0{z}ym|6R#2uWd)7*ss8{Y4PV&V*gau?wd!6eZrLeqw$J*jafu5ZwYw$J!JmK|#I+X1`VO{$kQD)MEx+bS4iaK0gjGufMK(z9yx$o;*T z>M5Hk7&oH*T{Os9t2;Q+ByxpT=M~e052q>ljg{@VZPrCAVY%<(wIsDMo=7GpreCTF zx03@!TwPt&H8f)M!QAhPs1N=IoXOi5Y7v|tVUHrmWJ0t*oeIFK`lS$T{;ABumX!uP$Q$iFGf ziA3ZcltC`nCdFzt5qR}s%$P38n8NVKX26Rl7A&_68n`(wKGNC{QnO^q2Ad*qm@|o3 za>Eo}6B=7xczyB=a&7npK;<1V*11D$$0RS?XSmI(+Mc&Ld~b~o=Ari#lVJu zeDC%EH!Uh~sP9As5>DV1e~=$}?ChV82HrvcI$c zAw_3{4oOn~h^6g>Kh+|dp_8UN#vs4Z_lxnp=A&;-lcE%V^ zoNwl5JGZsHHq+!!=#w}ce)eK9;P_&3&!kLt*Cfw!C%w|^Ie}Ud3i9jsp*RExIIQ2M z9h%Q0EFuE#bQ8Y&v67a+-Szu04eL`RC!xpu*nK&m>6D_Fra{4&@(Tk+EIq&Z^BC&% zcAyLpr_7ET?#gTKUVv0%&#k_wV%;vNt|M~rEg*mMxX81h4g2EE4fCBdTP;DR&Evpk z=kpuOb}jQi3A}+CW?wD^`Y&-?tp%GsLrwcf0{R-5bALu@Eg}MT6DB)KdjU?V5B|(w z$%;|Fb1_yLMR74mZl^ zcR5*ek74%%Z}Hna1k2^|p09u|E87u$r&SCGuG&kzNBqejfww`G4+E`;qvev}1ay$& zyZbGQ6G?nUgiZG*m>fzr95R-_A=OSSTW;Fktu`(@1=+YW;p?t=0#eh6)yS=#4MkaY z1wtd3OQG&<<1lvZ16KTL{p2`R(o-hUy#3{iwX3~$p1d#bymLuxv?CG4S_xsMM5x*9sw)Ltn|%qMaEJ?(#T z!@7K+an)+E+>E6Ajr~5N3A;ATfV7bx*wKjCiad(EBDh-aUk*o&5P?jtAzoSm=MRS9 z8(IWWdpw-?< zN(DQklTyizq17iTSr0iehWNd3y$c(AokrAOWLU?QKKfda4%mBFr2Bb_8b(D)so286 z8RYy%y%$sfuIezjBrrRDk3~h}x^Z%S_?cStw9aKhdE)TtfL_^&CJJ*pYdu+EA#r{c zquTry-k9WtDm$TzF4%LIzhqU~KLe{^Ob-p7`Fw8#Y>xR28ZIz$lxknINeGgo8U8^8%pkhaSdQLFK3Ms&?4x%x%RkX}`ugNMy zr61#r`9JrB8291NZvNz?P%Kuc8X9k5C*npj_=2AZ=-Sc6X7BkeTmb#{4J;t??R&XM zCVZji;B+eR+0r7%QSIe69u971xVLj{&$k#Ywwj!rPu7=*6yI5(C}-)%(if$AyzDyA zeuC`dwq-ccaFOsSS(`HudKPgX9)n^Je}f!hfF>loNF69pJ|=q(>bFd-5!$U-8FUH@5KV)vfS?d>$hOn@1V?k85ooa5gI<5kH$fkotg?n%LSuUHr9B za9G6L%|J{}4Ol0XaQrBivrt(vKz>UjebXXuIcHDpvD{Hd zz9g*ojBV_#RT#h*c_Y6`*yLNk@A%TfDUkR`J19VCIM`f?1zjAjS7#%z5>XS)KfkfF ziv}JW_b(n}lEPv>r5C{@GPra(w6Lx@#rE2dMo7qekw2 z6MDS<2#P%xK%sxd%EMt@s=d71UwBfEZ_2OZ9`{Mtv%JM!G&y8w8cdQf*;zOh}Y(Mpz_?BKQUZ$f|^j90{)fe@#Lo+Z@?L_e0!0cRgEBt|UQ z%@ZG(aR(hpfyJ(zWfMzq^N^A|p-Zzn7G-b4`WNrDNBTaE@zsN#GA~Te&YGR~A<%sq zxMOYHOPA>y`*~xkxw+9%Puf(?Ci9Bv_~4i`4I!*rS$NuqV%M_RsnB{#$J@w}wNDbeu9Grr8fNVoX@u)prD(Ji_ z%%j2=!O>GA2P-MU8Yyj`fz7eMb8(Ad6Rlm{9CkRy7UHEhf(Y1nFPZJ!?a)PeM8W6R zxe98!v?XbRki17PdAgGOFB`HYeVni;^Z;tjsKUI-V>ItkQ`WBA{O^Sd7(sM?u2)Q0 z-A8XV&EE-oqE@O^Id=I=VT5Gkb4}iQO>sH!YP!&o1A;gUC~&!%o52 zyJPh1el7gsi7Tj!iy}TX_{gtcc`(r;4vmx|QRm-9KQqKq8(e1%RSe)&D?&SloGquI zQ60f@gt@Nge(?un-q`RG&>`+tX z0#)b}&#hc>oEV*)L+-fAwOBsW(D1nt@l%gLULypDE~CvyKI zJ%?NjT#Aj?r!J4rn|Jg;iF+P?-rU^QFN*(Mbnkwv;pN3fwl=;7HdncIM>v$=BkCOw zP{ZgEF9J9Kb|#i8m}%nMvPE);%uFrsrOtwESd1#mE;{t75fRd`41MN=@6gY}uV07Y*8K_tKk9 znaX9jt0th+^bxOw1y3Jw2j5-C-am%Xdzf$SB*VGFC%L-p*FU{l;HtD}dHC88zSjmF zhdj6`oN}Z|CJ#a4n-_SAsW`J0?KGC!ik9_$glrW>#Bl&!zDhF~y2E;s51x^Ss) zHV~_O!izTv?JebRH7WWqNY}f>SrBF|o# z=B%$qHDtco#~uPYY$k*erw_x(z;Om3*lsG1h2QGtBP2eAGQT&FFt$tG@rw}O0>jgA z49l!#-tHm`QMWC9a&dC>3P2(78MH^Y{-qr)fN-mCy3O>=k|S>7<0!}{2iJUQU!;AK zT)G}~AvG{W3?OuewxQ5RD+0Hp6zW9#xo4nDE?`*C?1nqd~EGX zw_c@%%{XW#r)fG+c?oRYDI@1kH&n6azV-9j=X4LX9C&B9b-@QqrTS%>8H(UE;rkkL zplp(h;HiY1v%SU2{3kbjUcEB17R{_MuI{=Z953M|2Nwnt!s~hw!VJdM~|*6sbtwGNp$ucUiBXFOQYH1fqn0d6Y^XI!@MvIP>V2PAv*3Q8U6?_&0C^(xPgC>D^-%>BVQ=V z7xX!w`bW26K*7O{2wZ4RTe%D;2vC8XWJ-A7g8M>I9WxbJzbKY_TZe7~eyS**&PHOf6ZB2ia zZu$P_Tu(yb;juD${#;!T+A$)0ZCYC4LMip0HrVMUJ{~*bRSVlDD_(4CL0g)e$2TWR zS5qwmxGHYOl!Sv^2%pRb1C*ib=)!(8@{XH;!h@{ZPan4G9r4}0$7rR$-elhg+OJ?; z4X{aq=tY{u5XQ4)c{H7LVFal>Nhrv)N1cSgFcxZuO7+P^5gr8vaKYW_Qte` zky>R}=e?&gFrIX2S!iE4@!k_lOxaT3zfF6mmthmsiu7y!SNHFX{&GmJaCs4areIk@ zayf7-b%M)}Y6{bJZ!=s_2ebc;iYo~G-j{(qmoA4|@E5$u(-i2s;5(dl`YLAJawnRo zrmh}iIJqi@39_pfx||7ZoV$}zX)R(-*uF1B>CF;F^e+bBe?$oHw55?Y%w@f;raqV6 z(k)(Wq;pEYLbdTTBNZ-~$gQ~IND~*7*XNuvce2&IdF+^#9dcMY`cji3niL4}=ln`t z;rucDcCbmglbh&egl7L`Ht~f3Z(Z8cPVQpPg-xLJUY(6RI~PndJa^w3Emp8P0Ujfb zW}7Ez=t%Vl?60HXcFHl=P0c0KVboD+k_`1Y-JaHW`4EXdV0;J2i+Wj$$1IV5;(o^h z`b>m`>wk-$%2XSsKTlS`eoLFU9qt*^`>vsO*2E6;{-BQr2A>sRmoGvN#+_@M66h*) zQ2*utfyk}8>G+ki#R%vdr32cVI<6w)0>V;%M?!!su93SR-`iqdX2P@oE!Ql0gOgoK4z9Lkogm-+wd|tDgS*MBnq>LFouI_Q&yS?)<)R z3Y;Q`!{hc)I0h=KlJxnMrDrrx*5}afB~771W$WQ%O|p)kp(S@706*ff>*z=-{FHpp z;A>Hblv0MiaLB)D_WVlZ_80*%<$ag08Xmg*q33!ZE((|u6~3~@8q6=z@BqgHVQCM^ zA|HY6JV9!5rtLxg(O9=S`+=0MLd}esYJCUF67bL5Nyq2QC@12?lNQotMZA##o`5FQ0C%X;~~@Q9jevQ!j$w98`E!l`~TEHRneaVO|GW z54BIBxbZ9>uX;NJp}G{1U3;U2K(`<(O@rp(IpMu!^xYaGwyyx5-*kks+kD|;QQo5c z<0?FglI88Q>GU4itHONMgq!@9tqikISa$;Zn?X-cN@u_lNA5CST>L-KB;CPm`(yzD z0X0V(!}QBmz_Qit+J$mB?jf{&2FASh=xzli4872^RYdx=-y6<;p z(PudHgQ0xbusPu$d74ZWnjBs7qQXEjxEDyin*<0;TQEV@uKG+%1)MT*F8JZQ9>vTx zqm-S<6fZep>^Ohh$Hex>7q^M{xk9wS;PH{8kM))CGsx#`#r5pSzm;$h5YSA`@7=$d z4r6$*eBdt^?zZL~K>_FrBTT%>@=atgX3jQ(TY>sn^~749WMNYuE@GbUAXpOumCB zfd+6?k$}ws*!yD=n}CuDj{HNKIw9(#o%t3q;2?^jkGr2gPy(dXE#?#%@Fw-2Mx?-p zSbAm4cd~r(5U`wtT~o{lH#&ZH2p|DN@R$Prc31BpBwjCO2}JHGz{0}X6=Hfj-EE(5 zwF$6$5g0e%YX`yMr=kWHKHj@}&d}#dMLw(9dIzb`&%8_jbgnQo!c>$rtmDWXmCxAT zMt-OvQs_0HcD+~ocnYD(2zfGV4S&f57=e~rlwo64i|%`mb-eiUhqJe*YdJ3t2LzI` zLoU^SROX%1y9XANf7nK?!ppks(C}m78vl>0+KJ1~<_=Ztvf*9`Zc+pUqDulDS*2g0 zf9l~|j+O9r}mzG6&y6wbIEVYdZN~!h1nbkBukxci15&kPjKpr+GcBFl` zC8|mQyW6tYg|Aj5@iTEb!<@rPm?|I`ar(B)-k z6CBXXpGq8KIphT~9#Y>}O4NPBrruS&=D(Qa8MEJLvn$<~OdA2wAI3O?Iv8dE4u%cu z8`{y84kJeohEu52F-d;Pi`npz*z}?x_)s*X_s7oou=nYI%kG2qNFYI0gbuhkT09sL zk`ck(z+h_=5k51}Po##t-L%ol1SP-^G6kBF=kit;zyw)a^*2?#;@INI(9yBFU+x!;h%lGb4NODo%T zTYPtD))6M;fJHXzBuB<@Zfv%7kh|Y%Z(M$rqvk`GsYI(9JCgVFDZGyt@&ZzYL;M+q>8C$YJ!RG%NK2?dmKIX&OCE~h*?%VkVllyt)X;;7VMfQ_^Fw2LIm zYcT6K{=bq`|L{itFyqtp&o0}H76PMC!kTMxEV%#!Oz#a7F5U2UPTCzSzb9rNz(aqnY~r4EUU-y{lj3}T^~ zQ$!1Xt8pG%zsoHeE0u&phEJTq)W01ssOFfm7+Y_4PFI12?5GuxUCy`kRa6wSyX;7p zRv3EctV3G>()0VYhGq~+gXX7yaa!(wWu|6GC`xA`w4$*+>f!dqul4Qf_q+EUTMfDq zgVzKTzY*`c>dU&v_41grE=lzCi)u>oKZW4ORk+dXe#OlDlblO*%vS z7uH*K>u%?k;cGX=IhbCo-V8Beue*8#(vcd-WMp};gQTn#bA3(zWPVjCQPTD<^CJkL zOV|`9uVzW?)6yZF{5GjBPuWp;phUf7ln+D_=66YHA-rMLMUzb$fF%t>0Wt_K%~aQE ztA4qtB&X<)*kAZTruccwBF61JKSB1_UrfUiWNx|N>LvzIpSY8M^@AJu?g*LBjd+16>Hu1T9B11J zU`hDvG9AZNvR4B|kwfGk3WWsd9ql;sJQ`uU61H7Yua4t4U4F&ln)hn;_3#92c=Q!# zu-)Hcp0R-!H$yhfd9Uo7WEG71ruw8yVngmC#=DZo5Hj2;5fI=xF^7E`_s z@&%dY6v}0GAC_U<_&#t9`{qC+9~a2Rr1~fkft(Z!=SEI*Zw(?JOlRXG4CbW!jpfx) zWK{Fi<>s*L)st__0-@g%pxDP>Be=s*NL07lHbj*Scs{Jqnmj1 zF2u*HQ7)1ao@|0}^0oTDiZXPE;*}7X8p;- zjM$hZ7dkk9u{RXpzZs>^^%aDK&;n`M41xHMs^gvR$pCbgoCq%JHt~b!Zf@L_K@Rq~UmO~jcgUF$;V-Rk zvy1FI<`Rg<2%md2J-=EMac9=a(#|WLpin@(P~JiL@XdRd((feXSt!HK$&$PMYa-O& zzK3!L&)PfSu5(f2kuIL^Rq3vLiG>1nWyg_6R)kg+Q&Dz8hIC77lH7T6c37v05(7Rc z>CDFqxrG-<#de(#hn}~gJux?_9P)~UpCOP%O9#R+q*&evkKpITYF4e1?Cz@-uZMk0 z8Cwpn=wJDH#=M@|lLlwWVA?kKby(|QbQS`6AAa1}E*$Xq#Qe>%UU8ZAS~@ZYsI{d-;ISLT! zo?2DAnD|e>=dib{5#IU?ftzmUfxPILrek-Mi0DTY1fVg> z>hs1he#B?LTbDMT$}zZSIlV`Sj~9K%5n($o!vHw7P>z+wv;UH0wC4nZPwM?31`nVf zL2-{gE@A|+sS3{U>H>txE-k#JBzl0gUoQA`+VKbqok_cp>)l4MDG)~f{yjM7euu6?hB5#ueOtn2zr zol!$U=TCWO*)o3n)=6c#gg*>V+=H<8N-SCx`DquG@UasAEA}g|Q1Eb7 z!gs0;BH-}yE8CTON*iFo!t#1+Bxw};*35(=2(pH_q0k}U^p}l9$nv?`YD>wcoakB> z#n1En<_OZhcC*QG?IIwvcP+e5j_o7qepV)ndb!#KYYV3n&wB7Pj!t}zywQfGm0RVt zLWQ(ZP2=#N>%~2kA4<`*<;r*)&UzDlpZDY`9#%G|XjGe=)ENFe+*~~&sA6nCAb9TX zKFn*`SQPlt^ z$FMtz_2af~rH)rGo0ZsUBA#$SqFNo&9zTU@5x8WLkL3MozqB%CL;DF|Qvrg0y6-%^ ziMiVs&mt0!*hf?0DO*Yz7Xw}Gv575ZUwTNsLJ)|5S7Nh+i&VgQm)7WEOQGVOM?LnR zOIrf(z7D!x!^xv|}C}4hJpElR!BNe3) zonfO{P2Q?C(@%t-1&tZJ+?S2!>=?#OPTYZjzUdMQnmi~Z9Cuym`j>IhQXZw-CXe*D z)9J_6T%PvQ+2JrMYxngiKUxR1u-4!h^tir_6vpQ1(oGFJbmZuJvIr``8UjmZlkyYM zQ+NW^fNDJR3rPgFwU;3$D3>=_Paezi`q$NBvyU@L!31DyXrg*v<2He1&U*3X(2n5| zNe%87^ERa8VL^Ao61v+m!t-jB$oThop<%<|)1MzR@BET41k)6Q@BZes(r$T*8lOQs zM568guxhYtD^BjOpO9@HZP8CWO++1Iel8|#fGW9R>o4+b^Bo{{HX*8%(zO zp@7Q}pH3O50JOba^1FBM6z*eHN5Ro}j@;4xty>f~Z{J*jFtO#AEOj%v&wk|#YcZv? zTg%p|r!A+}GXnKnQvvrtx)2ao9X+l7VF-ZdV%plg_uguj{Dn}WcqXl53Q~dLM-rwJ z^iOWd$e--JLzTMp&)Z|GCBtiQ@e5>|2}Z*utb(VL0=KBj@90Qlo9_yui_w3?Q17q^ z+OJqqsbR!q`U)qW+!zLTIo(T0Aepvmep$@Rj-OhEFE2lvyrN&bt0upPFs?zQl|ESZ z?fhwA_Kc3Uj1w@)o%{WZX6S{hPYaXVt<{&k@?xXxsqZ>i71F(H{ObuLrXw`;NJiGv z;Z+N?R&plJtXH>sI*n|rmEi*%CHXObXS0zh&s6t%@RT)g({4nt@0tMnaCLL}FLaaO z=U*OqW%B2o8bw|qi9DfPavA@fOW)??x)DsWxOWi1_^Q;?OS82a6~v#)6LwCFKbrC> z;AYqv|FGagI^e)azF2CKqCl#Z9_(VzoXt?gzDFt?;z>B?c_hr%s3xk)Sl`s9Va~iC z&Zb8#Xm&8Svh=bwc`02&Z-YW(Cf5?!#C5NHF#bcWvA99SUx&RUsi5J+56 zYL;AcVf#7mX*Hq&2rZsz4{pKYbFSVoj`l@T+7$nQT5tM_oTtaZymx*o2E@<{P;yu+)vE+$INQ3Z{BaL& z0eu*tqFT^3u^T63X4m;8;QqqMbp+DCGqOul6@{P>q$$*y7($YtJscoVV$qMTUTJI&0wcU1sbIGg-krZi`TS{@ZiE3fL5T`$qdAPK)_=QA7f~%e;*C0sNaX`WEkv$CAaurzcWG|`K zDZb>nNwrNRbm0jA{Cv&6slRZ`Kl7^saxRc%Ahi2|BMhIsZ-WGe{yn{ADm8wnSh}eU zPZZ7kM_v8tu9}`vo_496J57CV1xkCeiTv}>3#ZKOp9q_|)sSqoOX8*c3*I(yN&V?X zMBAao;`(vvtyHBV%(v>h#p&k}28Y@u+J?4mm0=8`7w4-9Q83`{NYMkc3Dj6U?Alp$ zUK!!&-RGN=-!97WuWxY#ZVoLer>}HN#*WI7fYWo!h6XMSS_xeIlh<%|W)BZ-RQJI; z(Eb~->AJCQPWoAEDsFbGLC~NqnG9gZXD3<(Y0_8mSw_3Qw zLvh$A9!dvygU5_xAzd39C6{dH8Zw8AHTD|l>!J)Aj3f2cjfRU_i3#=o53uasMc1kk z5fSy?4UZs}CsiE%J+Nq~`|U>X@2AI^_PcT$pX_Kgu+pO?ocV$A79O7 zTW|9{f+foL<@U5uvq`-BzL*lB@yHrD__R&l_?(%5c)aq$%t<}hgpaST=%VSKD3vNz zA>kiKvb5X%AAiW-1Sv2?Oz?;PI{k&2JGeD56i#U6FKTS$v55F|@=dIg7Lk*zQk|YEjHHWbw5$L3b#gLZcPGZ*X=izD^-JFQG zy4MKEQ%(UHi&Hqs&cg|8;>LOI7(FuDzfk?d8t!8A1bo*=_C$JAHu42v2ctAs5Cx{c$_o8*ufZDHVJzSX z5^zeMWYv8Ekis2TRoDoVef<(I3N01a0Q+g7ZJ%_qxHq#vl` z{dCdtI>1%(EsBa0g}p3&oo~zdTFYJ^qWfWlJvQJE4fVMHYfYO5DM;9Dn(CZ3?FRr$ zKYHWO<;0we><;Ln!8W3bzk4X7KV$BMZJoIh&UD z9(`@310d0pD-jR+Nh&yf(Zk87dV`r709<6FdBN{>+RguC>8b;o{J!@#HabS9Fd7K~ zQ96c#AgF|NBPob<#|8pY5()y+rF6HXAdQ3q(%s#?{a!!c-{0HdeeXT@+ z2HhJ1-ARyGjkZ{Q?8(eZSU(b)hCkd{CLdHomw={XYuD;b!w3Bt$x(W#g{x9uN>1FF zbe2z*MIGCReEo+1-MOrCXYjnA=qt_$-_53&XS2R0gP7l6sACBPJ3GG(|B3H8Mj|bm zZq0X7UR}nAZpqb*1%3Hh_VW7Ef1~G&O15youvje;LeQWT>_DH^5Qyie-B@KTfzJRd z1bt<~cV4K*Zf-W~%j!v^-rS31oewNTf34wZ~W-)v~*k zr|N(YI=&C_ng3%vMn1Dm<`$>&u)Mz8)2ubH_kz4B%6!ZX)+q?AENScM>1l3g<~S;- zgy%mjX6Huks>1?-g;uS|OP4Yv(y_{e)a603+1QMCMN`4Tde^n!*V4=xhXC>PwI!IMA~+wx>mE~HHZv-jmi z8$NLbjh^T=JwT{b|M#FXV){ug1)PrN*Q`Lnh!3iy!$#(&?&$yG7QfVqz2$>~o)_}g zN6Z|tyfV^HA{&Vx@izhD!Ru>*#rvE{@#%2;mdY$asA0%g#6tQK@d6B107 zNbOg`znndK4?cuX&t5}#(|LQJ`$habYw!o1V6C_Zm43-O!jG zGXMQ1R(1c6RC}MhD}+`MWNB~@BqWbcZ41E{f+2K@-&_pE7NX(RX-xTdM3HFU^2wj} z%DR2~wa|S$f^e}i!3_EWkn-nydvQ~*pK-iQ#3{io;AmiOVWR(D%q=oG&nwqIQBZFd z)cN13tec5)p;KTbLm7mo^s@Cgw@aqIFGi339Wa!^z1($9lJq8MS$2LRYVc1UXZ(U| z++X3QaH-(!HCWS5z|4+KC8bQ^3rrYqa)ueuP}w@P14$2^D10vF7_$;?3>YBfG!R(T zH{@`m@r8I$NC*!SJ4^?H>Hva8DY0&@p@7HSPyqRCAj$1i6jV1+R z?%x~gt7?)wfF}e!Z5`TPcMbU8zu1(5G*Y~EnZ7@9E$qu`?dNV*xftMR=~g+~>S97( zTe-qW6L$gFw?KvO{5sdUldc0>UjAiwKY%k-nC&1v_YrbnX()>Q#MkQBz3V0PUlE^9 zvo&-5EZJ0RD!oVW^aOI33u-AJ%Y~(ksk(!Nlex_;sUqPoKi)?283iwkW@Uuo2p^~Q zEb2q~aB%=31qcy_+dpn3H_kU5!K%s;DsXz%vUGskuM10T`}3B713&2B%ZaSaCwP^v z3vaXZXwnD@!DB$MIx!2PNFC&)B%WrcA{+{&V_Pi}w7{}z-#nCR;f&U7PVTh9cxKk} zF+zGik3=Z+dxT3JrF#W6^1m;ulZc#sYbL)(J|P--ld>?voJ|P#l>}8uPUI}Y6#g7zOsx*?arf|u*mZp4U|chO`EIneI3fXrgg~1jj*`AK~X=i6Lkz!vzXwOci!6N1h5t8< z3=eUgbPh{6hDqt0zp2&e$m6YVhp zQ6IaH;?V{{!F{-(0YD+B?grrqqX%tX=U_8xYioJGe>a}gY!wILA~4E9AT6AK9R7cg zmUNdIbaML~gm^Cw8NY0NkH8QcfJXlQrQ>sk%Jcl|U*me?m{A}CSz|ZAkn8=yvGd0T zAoR_u=7a4AHxOJav6;{RAR^6LRxa=npH6WPzkVf0Tm^E7x*VdqKm|tbt@M7P4}(}SAp1ZCr9px~ zw;cFM_DC1lLCqACM*WY}094Y^zNX)Z-ZL<}L)?Rf1uJ&dVM!$Ur}PCgca z9|W{1^1B%D23`>hy%R;ke9-ql<81oKp{s0$Z~-w0T_|5B0Z5$We=FH`K^*yEA2v)x zdoRj4-by~U3Q7~y{Q&8-+D8o&HX|5AS2PyO@u&* zwCe$qP!2r;u-gs`ZXtvU1O}`u*r;|8hZ+$7BmU-m=O?`SrA%<(e_6(yOWh zUUlvjqC?3QbXHT z#ol2RC;kD1dG|R^t}PD762(Rd-ix@)_qI5>N%(Xj+#pXJpiyoWc0mCrdt`a9=+yd8 zTI`@7Ir|$1XqkY1o>RN(iqNLx7(^_eO!_uMs^c5D+K?0~Y^G0I4|$@8Po)>678)8# z@dodi86lMgFCzE_a$2-K*pBGGKwKg?LK#rzHl&>{_xc;jUm92h3*b=vcB*@cD9r_^ zalx9NPp@X-?cqDdnsddmSvS%JxX+G`|2#{_ZF`gnrA8kC8q7XAf`NsHM29}T+pOAX z!mnV84bY=7FuR{jn|;q~V7=0T;A-5*iMTC^r>ajfqN%&J=1W>Y!Fd|B27{3D z#Q7R~wA{#?gw9011E{h&etf(Amq`G-i~_Z3lO8lUwg@PB*yA!ld1#g{a!YB7@+PQZIE$&=Wag{Vp#j;X*$qVKLYUj z-?eA8JprUOx6GF^o6p###7GcBl8xQK{WFy9n)`%v6x z#rBVHv#zp0I#5&5bvm=*hao>sG5Y=%DM@>!3+_Mu^cjn&#JV^-mJMK(>hGxX0b+S| z1MXLMqO^a2A!xsiVw_zCu*Ls!_*ahOIw<>q?5O9qPLtXrB9e z%|7mDA+#pL-(I8fDMpol2wYSTMgrl`gxU0cbK@ihV?P|RL(qCA2%B8P< zo#l!c>rs*4;m<^Y4F9Xw{hsqES~G>+=uo8H(IqOb{W${yePK!df``=0Rv;dO*~he; z4D{J>Z15}`h6=bxs33(zc){)lXyPf*9ou*D!!&mn-S36Jg;)gMy#Wt=F@u5`>hiQQ z>ly#sG&|$1Fec*yQ6g@;Q)5TbcTBA?7u|BAw*Vg3UhtCJo^cPU=~Mhbhn+*Xru_$_ z(ON=vLr8T*j-m+l0%eldk}6je|HPXgk3ScMY^Ye|J$so|a0I|99DM2e8Z_Xn%M>j; zNLX?%th}YCiw++Mv}|QtQ0JMhIRrWPu95`8b}mW&-DNorg!5<4-xo*71pFoFhy`|PkkUX)meG>MU1VZf~mscZ@ z8vq=t=j-uscmJBtrIi^g*hiN|i8n3*7v;Y+)K&OR5w{K{x5QnJbNLQg&ZPot;CEzF zeF?*7Yg2E+;UZ!NoDP8W6=QHk`-)tL1$npeXp=jmmi5>Xb2jbBPB#{1gF&zl>7tGuL=pD!yZp1^W=~+Ng^1~22ZvX-u~F3U_#R6!=VZ?voa;N)LJIK zw>8jaXZ^U8c8iYWkFW_m;a6ukqeEG@aS0Rh2C!OVxQRrBQ<;}$@H(c;nP`+M`{`e&aGUiL&8hWi)#(GG>s zgPI3-2fphjVmG!dP%=kFm5fmPK=cO0vJ#1rofJG6n=n=E!E`7_(vmn%RX@d~7f(lv zeKG)J=<>o4GXrw}+ife3KBPS^fw=b6A6;t!t)~o)E?(U$}(rf$IFNfr=o33v6QDt-f z`^NdV>sQ+jW3yeyEA}RnbH(@pn|X;*xX6I_&xn-ah;}qzh_3)1eK^sALeijR<>3)^ zza#5Ktm$}QtANyR1F*?MbQECif&*Hru>L&t@-`8$c1XUtSpEgbI@3mes?r*-Io@~S zzT!h3w$h7WvM&+|LGhP;r#JiYmumOVO*s87K+4p}E?8Z2{d03}%Qup*|#1AGh=RS`7%={w0r>afSxV^vfq=;}a9K z*wZqpsg~Qd=^dbkurCy$ucG|n0d50$C}Y?Atz~n*SQ+tK0UeC=6;52K4ka)6SXlI; zi_tM3iPuG-UI^ALHA!A@-xtLpEIl>p_p4bh`n6ZOH+s#|#L^*T1NB@#@FML3tVVz} zxL16Y=-k4f+XcXd!psKY)B zn2G{naP|#i=^b4fv@iN!sQ++Sbj`cs2!HH#Y-4^{v~U>X++=Fk?6+u~cqwR9uHO7! zA3KjZtF!yP_S`yp-qiM(M}bcT^_w6 z;eONU?9A|Nyu|REa9n2MJwpcZ+d%yF9ru4I0Q_8%3`>R>|F}I5$XRt&Zt2@=rLZA0Z(F> zBtByfV^F+)x2F^B!|KmZO}8&SJx}v>O}rKTZw^r!Wi4lRCHwnfSZDk_BWn38?B1E( z4)q1F?+;!r6it*CGKx9sg`;Y$5HSf)!PSr-KwDg81{C=U4)uS8-rg;xLX?0AtS`oa z37(+`n+7EtU(tp%G5-9tgPmzOzRT}X;l9sZ<@5x7dGkdSLHX}+TJ%S1zbR+VXImh| z#CLc7=@U$T9@ZxYbY7LKI5IkwZSwal$mh63tU}-E<*xG*G0*#Ip4ihAGsUlXoxB%S zL$X9?yOV&rkG|q_*Vm~q{I;wi?#cU$Xce1oGxQblymOta(%> zLkss8xIz{ou9l7kr&3bBC^h;tKKTqV1(|@m(SP=kX4dOKCrRhY6b)Gal{Oj~{gqc4<@FRsg1R z?I1H9@7t$kGy7v5+j5hwC{*KdciQID`n|i$v!sycfv z>4E(d9>IW~Y{wQD9;Zw_Y~{4Py}D*;8A_FP_4#3mX2(kGrrGs>ExwE$)UKvLYdplFFAz;|}Y_+n%Sq(%_RxB%r6GggJ)_T=Qe+@aJbEM+T zDJFT*3AL9Ds9ex{$thRxN(%wze#HvFha~Y>xV+2b^~9W&X0~3n)sh;pWd@R z3~{?NsixSAP5<1UE{Bf7eLGpHm?rB!r3Joc+@j^1%BcIt77vNYKi``!I=)PutAB0r z`f%wdxDX_9ey%%9M!yZ9Q$QcQg9PRGl7;DTgk6a;X*<|@k+dax6t$Jul_+3Ln1L1h za6q>0!Oef@Jqkv*%IX<_8OI&f|M|EW)VL%RK*UF~MMV2n?MS=T z#v>;qSS0>Ii0y=J{P++KP^+MB28iVOHAlzLTBY4;VL~A)bN&qxl_}byJ0=;_CNn-yEhHpF=YPKIIcS__oacSK zPODpDaQBxyOnRO;1P#nsF=*h8R`RbC3;+~qV79i)To6CGPI__5PRjB!=H|vr(|yk* zPPaS&iEyb_fPLHDjf}30eKlB*ENFxyj7R+hbjNk=nU^1v?z@CHqVN84>i}p+w{?dx z{f>nCI4$z}J4BBF*v8F3LX^JP=<1M*P-G~32I|oJYWWw7)s9-rR@2e*NV8QDzA-7s z<{T6MRqc?|#;vTno9E`7$l>vEYZ`Ky7?qz9t9)8>wGg9Bb^jM*-9UAxT7%r5ZxxJO z&v-0EIquE&FaC%@)}_nym&aX)YImf))FW$3Sv~4+8;)9vFJ@~UQl>C{9yNA@ywp}~ z7IoVJUB8Jzsk7zwNQfrH39TuRsn;9abU2Od= zfi3v%ZBT?uB;+q4@JgAuX1J1=pIwJ#bz*T3Tjv9=jl+J*Wtm>-j&EaE6KgxR&K+t3 zmVL%%ri_lsyi#>qV~VH|Kad6LZ>Fj>j2qp0ZNYEb?;J?d;7W)e=150k3~w1$SO9pn zi%++dDP795R60xNP?5n?CG#=j-&DaxGEW?}Zy(tuy!`g!8&KIk_W)@1B`o>kC@|M#DyHD<73h+dx8dvpe$d?0&)&$FO zA2~lK%4{CMjIQ=4@3&&JsB;2gT8frzNFA2X8K2wY{#OTxzXN@{iW%81W0LganW^(M zbz8iFIX)127=Q&Bh<3A6HC_9t$)mhJ=S)M~2X}hs>oEn@)&>voUV`ZMMG`=h24TDC zKF(uJ_<GoqdzQU%?e^XQDOc%eXl(y-K&IgR**GAuF-y zE>Fo|ZsfsFT2rcF!dPeJNdakVoQ&ZuE;<0)%MynnA3WQi=WZy{E#soQRE^vGy5Sik zIM|Mwd^R47)rrT5IZQ%@XN^{gQI-$N$*&e$9%oALheXm83Jkp!EdxVK?>Ta<3z(JuG+P{+r90)`OKygz_2>2p4Q{Md>IBO-^r zam4`aA^BTdBUSrI{X%wEY$T1qm)z)Kx?NJ!6CSu6+6D-&Ysi!f2|}GTR=t;d;;W;KCG)W-|Q2uAJ;xG?|T%Wo?{nnlJBhL9_~T%!!>gQrD@+K zDW22TKdq-KnwUMM=#cvjV?J!l{3tt7GDw4oH3k3pf^&Kqp zPu5|6q91w-TiQj>?Jd^#(zb3Ijtj+g9+ZH@Xf^<8%VK!!DkAFJT+3tiG9_ptSEA;FgNHt>jcA zBPM<3)#SC&PmwlZ>T6u0U-i}cP)2#KSlYKT(F0Tx5BgX{ST*YbH+0_@xO3N^ zqLmoewi{dA7T%7-=%Wb#%j7ugn7s20^v%yhLBM*iu~hER)5EPW(bP}h0p;t8ClFYXz$?SbmX0((SF|CPn%YnVO0YyZo83a1i z|KTr#KG?b1R*iw%qO|WGtLJzAvXGFH&gp1=hDi=tn3z0Sb7@FDQXTma;BqcvT;LW) z3NpZVUl9Sa6 z5rN<28?LClQOCJ&Z{o)q9^Dh5A`FL;vw@=coEJ_QEB(m>-~X7f(qs+g%8AM#w6S*P z#*Z>&Wlrpy5B8;yD11A3;X*ZDh!h1KntYk+d#tOCzuj{kFz+Xm{C4pLHB6>GIQief zw{v;t);_U;y@|TKef`;d`OVGQ&F#_WY2b)a3zVqMfG43{dl2ly^2K>rY{Yw8 zFu-NwjzmWxhW&cvzdlXHH~zwjgkRBaWr!3dB`K^?_6R+ z&{_o>4}Z6n#u;N}C|W&CpS^w2_D_Bbq4SA^~#wlb5TBRZEWl- zDlA+xY%Oe@Dk>=v9n&6@P-Y;@)ufiC2mrb`lI>&9{W2iK9|EWdZ+Yk{`AO9Ev<{Bl zPu&~btQuKh?I28cQ#^^HIeW?pUq^}@MVfDz-(LvAshf{$DvhbB>rrW8i`T;k)u3Uz za2#=IBODGvLQHNni`-uOet@!$&FFmf=EM5i5-}1I5<#jH$2}23B(0-pQ)Bzg>t^=4 z-!^JWJ@0=P_yolx6j=kW>$MjsjW zT^+>@s4UCMrfk?yR=&{K1=Ie^z&PS9;-(B5D0@ON&%yBx(?b$H5MreNI!IwbmIq-O z|1PLqYeK8Po9DwoH`=tDkuW~%1N3*{j7I>u*ok%|1m+i^Fv z0tU85r%klG3+@w;$-pB=Q=$x>KzweztNVoV)`Ry(fcTQqhPq_{IFf+<^%PG zUSJM5fL~!o;#dA!7&<)TCnT0AlSrHY%8!ScOF24K-24^Thah=sYKp0woFD$i96h2= z15=DwwG7t?T;ybECJFej^c}&Dtu7&G=;e zP4~7_B^7AK#cA+n(}cd!9b!TVw!6opU8qxP$bf^L0IwA4s@zk}p4_&|fv=0;saHjf zxY~TmrlJc`?(KDz+;wric<=M!s9}Adod>58_10e<2V(tHKU(0T{vh-bx(7YOJ4&rc z#abPVE4i>J%S=eP$egKu7Z5lrWMx$ZZak{JZ%iNi4m}hXiMWBiU)Z3Pr=O2;5c7)p zUXp1W*roUM;?UlsKE@uLdDt_IN@^mizLD|Wdjo>h7)E|^99m2hTIM1c z*8EpPDE>z358DB=mB7A6N3;Z5j?RQuffta<_dqj{uWVkFB6id`coYypu|{-yhL)LN z=13S7C|QebEGDQACnMLzsJ~vtNAOvakh$593xYHrHV75{T?9${O8BR1vtJL|x>@Gg zL-rlTm^2rsPuSn^K@Yp@SDXcCc>4r~z23UW)mhHiH_X(~i{c0VCbk9M_28=<>E#gM zk|*Jex$%}*Sf1szS#awyZ1IzjZ0bw=3MxNq9Z@6+y=}TYUa7F49dHP*Jrk9OAn@Ka zes6?HEUqg)gd>PXz~v{Lah2*lYn6(mXmR4oZ9(brfZGGA>-OgmS>L^L*IBX-4!Tav zTu*y>Y7!iK4|9iu_e*!|^^+0xkhsSE$pfJ360N3M}=jpAUd;QRJf9Bz>|Lw<}E%v+HPoH z#H_JQ(&l-uQO5bu0@r&q_bQgsO9F4i(2$t;(y%E@#D#mr_w6~;{59%hECjz!_GW_Q z7sQ*Yps12Gd5sH41E-BJfPo##abbtRg-t!T$X&pZKWLXY;8y$c=IgU`3NsOQ5v#2~ zvIpco_5}^GFSuDdc8(i~$-1jM1ikWuyp$^x9tG>(WWHs!jjE*Zt#;71E;hi=`{}w^ zlSb&6ZvTs>PHX~#%$bo_svjp>^)1kv@VV`BuX3Dk`TVIh?U|Lxr0v_ml~$+4N8}yJ zGQMZ@;Jz;;f@euukm*^LGLh!q9W3JP}IZ?Q8*U>!}bGE_7Td!Bq7of`< zKm)A!m_tgELN@5lCM+;~jHVs_tg0trf&1cf9o-ipeuCJivlJ95DJg>&pARqZzWll- zBq_Aa9fkKreSM5}g7GG{fdE#R{4chcuU*l9zx=1L+)LQ$k&gikhO{~M&o3hJ}iK>xzZ^U zg72~21^pniax$Nt`|jy@u&Qf1ZjNoeU(A&UYBRT+`+F2Rd9|h^;=I(Ec-6+Z=g77B zp|^l@{{CM9+K3__j#UkAbZ=5`>a45;PP0ri4=ykWw%t#_-xp;w!6ytGn_JYf@{EKq zJHi9s9$pi7>druli>dT-v~A)(r^2&k#qf+Ayl7T0vvTDHN%OdTI%$n1GTJ+lhPZpt zEpfs3?kg@j$;<9xqz!ovO5j{9hn|bTu4LD6xZkz$6MMQ+rf%3J#F|hHR%zSCiD0TI zCvlp+60sS4hAb`wjc$P=LS-|>_@NX?@=>JI5#6F&h%q(a;?{wVRp_BZSd0Qc`1h== zx*KwteEr5{uv4_4p7T?K|8_0jVg;dBY_9N^(~;af=g?ds!ey_4$HzdGDkt~dY%z$q z)JjeZ|9O%;->{93fF1ZCwL;lj_yD~!7(kpYm+aVb$#uE-p5;mz!%SvSx1F`cQgfhN zs&{ubah0SgtO&Js7;K;YR(yvy2caVLh7~tAlO+dC12Lw<6S}mCy^+A(*xaMGL@Odq zS%}b&G;lyMaxBMh{i50NqM5I7uX1@7hSprUb8xBfn@++0J>d~%8n0tBJ~1}FeJi@+ zj0zwL4qYjkQJ3yn-Ah{|6A%VmNA8j(Q1!r0*d#C;hapBbkEkpSK2|kF2bRR#>>Ab)0vhhafa_hc z{XYdS!J?HAmk)1eTUM7F&%H}t;k?i3fe_A^T}G^~SXa>D7mG$#&7STsa6RNe-TSBw z|A9$QTc2hM_CHGp5R(VTb*;C9a)9EIfi^=c)&rxE@4hZ2HHf`JVG{LQo&9@vu!q#E zrsY5SBEJGg;U`3oA3m~@E*BW0q-2A)H^Fz_zZ#MIb4i&M z-VDyBehdx}DNg!0BaJGL6b*c=bCT07h>zvF9B*HkP+sKB@Vm%(?iT6;t^aylPtBVd z7S`d-USbP`{9Y{#I}ZQ=lC9b z)s>cAy3DU!NwN+;=D+Pz1~M=w+*K~CDvQ0+WL<_4AZffenoQCr`44D=t2oJo3dxl3 zzW*hM1zg5;nIzrv)2p`1eaRPTjLhZ$V}!vYOa6R0JHt-$e&R8zsL(F*fq7TYe{u$Us(U90bXVS9u#8pje^b4lqQ*Kt%Wv z^3Kxo5OyLE8(y!$j~}`GKFB`hMu-M%Zg__V z&qbL=&Q1dU`Mpqnpn6yYp&0$K=M(eCxd@2?Z!Po4R~}jlur^wr2f)3GC)DcuAlpE^ zcJ|+Y|AajcS;BqfdMw@|kQv(pkJ$h- z(>pAIofm3&XTD0&%+CS0x?hgXuHTBqlQX@&*}QQ~l$74%QBByG$80C@XfGz{nkOOLK-_yXYHQ=3e#^=dl@>1N&s(M17WcD{`V8%vdajhifNDTBk%Leke znn;;AcMhCD3gF7`C$}w8S10tc_!Y2Ni)&EF86_u(wHnR_8Ov(SlAGD+AzyS!AgZD8 z8llI)2ChDhbVgX@MKew~QUyplm7AaMvDmd@7q9z7Y=+E;hnJ7tedL%`ZY;#RVYaUx z5(lV$wszomI@}Lt0|~OGJdoBpS~Tj#vrNs28w8XUI2@>VY;8&j$!^sfL}nSPN8jdd zmO>&G4x@QM=6@2OUaS+)9I*H>EwqHOtlhdO1{c9?HIl>9SW3%D*hIUi{eAL{1|We1 z_4%Pm3BvNU-B2EA1S2jTK_;mFUFTETAI2QM^nwR1^(>!w9r7eJ7jDBnVz1jCu`wvZ zWf0tfU+^D&;SWZUMSNNq$xE5-mSf`&?X(r-;O>g7_0zk7Z-VGlYu!_IW6SBkY-dnr zsR_AHRnN>wxQa^(-50feeP~M;;`~T`UXRQ_s1Mp$p6YJkrbYbzR66X~{eelUeyj9p zKy|koM+r-6k@T;{&LO!wDhozu(O4J$)i=#yb+{vXk=8#$iYz(k9;tWb72;kQZd%MiOuQ{*ZV6R$=;~*0Y-P*7yks ziPzbN&yM?au0B3#rTrC6GmbcO-@yGI*pr1{e2MB0X5z{g^MiQ65+pJg43~5@`46_5V096Ce(SH$#g&S3yz?|kqzNV+61TIN=thB+|C@!`4tMg z@Q$$9TiZ;}l4}InDK4_=dA{lYGHvU}$J1Hme=<_$ZOred!|HdaO^| zditzxU~NJy=Q@M0cfQ2xRLG~1FZ5i>Wah1hIMlp;NCw{_3@a;oH+;sCP1us;qwo14 zTw8&|(SsE+|MYywGG<$IoUQ=eW*@f~G2^v?>l}w?&P@=#!tO@xP8HnrcUHhskp}kj z)kKeN(EM+Y1G0}Y2R}4^WIi-M%#KzUaNJlnlc9fe$gT`_Bl7C7=U<|M8?(PmQRk;X zF{j{JPo2DOum9@I+yGsmrptycP*81cZHs90AP&UW9`B)VWyA3ts5iD9-?(E)dj-kv zn>y`YJp^v07;2|A^^3dM`|fCuC~(M7Cp7W)O7r4?x!%!xpvZKGSn7zKp_^?+)QWVA z8V%YZ*KX6})vK}7=Hdr^tc43pLcE+qRdn#?;)+(Oe|s*keIS`Yl^Q0v&o*BP-`LrKKMyt(b5wC2=3^wJpL~M28)rbs&m8m~ zNq{?;kj=~c2=uJod;V_JUkQRrb^N`^KjrnTo=Z!4k*^PMKk`J`DO0?8{dRG#HYP4G z`9n&&&(nJgwz!--Vkd!nhm&POfDpsl4rt6(r$p;KDJse&r%HF zWkj|;!I@ho**y1T`0GjU_B5O+ovKD9u3W9?eGqFQJ<95O&$szP!t|H9-B5e@%B zoo(jm*LTFe2B4_JUm4Fv;atSU*^@xg1sOPP&gqUG9i5PJSy3ju!pNHI*^Cc4%k!}4 zS2v^yTYT03?o;9o22_C#-N6K?w$U_R1-rtPAaP5v^R@=FBR$BsSTWw$FV+gKioZ~w+ z_#6EU?Rxqk-9!%O~j0FH*HV*+ChoVkco3WD|Df=QM zGX0W^NrlgBugZN}r|R`@uE%Q|!Qpv)s3880$?T9H>|E(C8yy{Nj1OxbYH|#tBNP;v zBP#XLBrp$Y4pNur?0m7I*8YGAkMD0icTl@&J9I)t&eKImw37YE@FkZ0NKPo ztk0yyrC~+Ve91csOvQa$? z=Rdc9ysNU`x=B8^{?#PWlG&i<9b!m?*7$O|%oPf^JkP;7vK?$hVFSo$>3aiY7g*=5 zJ{=LP`($uN42OT9o#3s%c9?6LY&z^>v_8yMbUB#H=)FF&k$GTm^jIUSS~#LbAH`hwAJdj zsYzNi-Wwx}ssDE~kcv^W&)`wDbkzH%?0u!$Fv?&Z0JkGTK|?Ln&pv&PI$Q2fEJMGt zN_dkwBfr#*3pNk93r(H>>B`^qiT0Oq{^jk-etYiSPeGv(p{(a45VNSktB!~FOq|tS z2chVTA`o|;QxVQ@xi;3ssG-aLO?g56YOS>!!@6cGonj+Wo0?v(RK?FgTMC>l%)k^9 zS2^Qg7b>onB`X>Bv@+#`#Pye!mRH~29n*F4J;k`$z3HY4T!vs$FhnbK2VE~zyzo|N z7fw5Z+K6-bc~GAmRdo2-3znRw+a!4zY=VW1%7yBKO5v^V&H*Br?t%@+XhOo6z~G?l zTAeHf5^N~pe@uICCr#AdPLwG_E7$pJ9$Vb+f%wM913Z zAIbjGo^}~z`&srPO+q`eq>L#y$aT12V?FWx&m0<%Mc%fj_3h8&Z1o`GtPl;C)mgUl$dlFqGyF!W5i}%j@=JCQuc58fIx!@08`Vwlf*XC5)O2K;@qzSa8@|E&u9T$I;Uc%J*_1<@K7PATI1DkfOt`s#dYBL9gJ@nq?BPhr-;?$b zkb-1YQ~Z%OU2@@Bw78{X=sQvoC!*Ue z)bD+JktJQB2!3|g(JVhAj(S9$J^w*xbU$A^O`9B$kC zpm@%5QTfN#@^?HPa_teVu_-VG-1=8PcOmfl0RcFXph*jlI$v+_ji0aK_hK^*(n&jd zD)Q4N0^9-RmnLD~vXYFTpUy&4N82{^cF>+Ub@o0-aS{p4k7?~}7OOElq zH!(Jzy!qQY>2!NMIctz#zm`lVC24_<~2S_jaEN`TL>lOkiU^R-}Ov3=3GStdr? zbfH%g^)2>Q%|x`TRR3)N$L$vCW|_sP`OHXxCVak5XI7M}-Ng#>#(0E_CZw{nwurt`e+J3Xd*fqFNw zuE2pMsCQPd8p2*2D{0Y%6b)VT@0*$+!3%QHC-pR-oZNX9%4g)9aza6?Kc>FhIC5Iv zc}W$}RxB43JEPya>vQ3zlRF9_63}m?yMy)7Kg`LQUn%&Mv*P%}yOk^8Lt=?gIU$-A zyBDVpiT;Zy`)vbbMy<|wHqe~ZwLh#II-nh_I00U^ zv_F?0v449dk2sz^sJYjQ6&mm3mwu6UH46azeyc71a$n0O_0Z|)MXvUbKUFVk8M%a; zmkqkCEUqBwTSU^D>qFgT)a%Y`RmK?)+ccZ^7KZRB`P*i4`yc}MlAF!uAjb?|dMEmU zR%OY;uAv3WKprQf$wtBVf11S1_!USRYiIu-PhTC-)cd}Dw$a_KG@>A&bZ#^Tp-2fz zhXT?F5*r~csnRWof}|2sqohMYLOP^-)QIgp`1$_c^Y>so&vQTbbJulU`|G%?f@+F< zNFG4e)%Z4oZGT>-+lUG+o^}{c-Oz}D7BgNsRq+d_=3IE%jK>VZKSD%LokN899+A>R zqvSX2M}GfKm#tc+fl6$QmbDR98_sl|hQH1WW0dA;#669wcfE! z;n{5?YI|~(q2cKeuQvI3Xj5qLMWGh6Iq{w2g%0vOU03#vumG0p#c#ivnggvdnG8wR51%O@wZ9J4NWt~QBG67xg zf~`Jk97;l^6L?17`P76i&x9A3UFE8paB0R$9fA;z(O-`<7nI&dK(84Lk{L~4Qojp{ zMJ1MIPa17VJBcNIyq~xcIhcNNhkC*CT?`3=ZYd&s`|a*K7Vo66F@n+%zNlXudc{qy zsvkyQz+ws|%9(&;%MUSThrhQc>gA~yvj9;WIMtPX$Hi$sYt?+u zQ&Zr2BiZflhg?9CfOfk=RiPT8o(YbPZ8=x?{bF_NG5NlO53Emw1X2=Qz$MtjI7_QG z_p9Sx<&N&HJbCc56lTrd24=KR9JngQsK|k`xzu0Zg39%e?|vidQU1;+>~2G%?b9=8 znD2O>ch3(T(mFCKF-sucXrQ(dpQg!Nqpm|3~V^&Sl&7%akq^Ne?~J2jhc~CsD(rn+f9X+#%c%DTbN=gWZ34uP*UJbjR#d);4m@$0a?oDN4$KciyZ=yC zz1n)NZ)$3aGdsGv_5FUHMKLipNf5{?=BXGlk$>Y@hblt{4?Q&w+MpDBya?unJ&tM^ z>qkMWE9AJ95coAX$i_%?<8&T+&#jQ9MWutembp95-H@d;%=L}UUSfxx9m zi81%D1g3q_pZjB_=Bm~NQdD8atd)(+|J79%Yzl?V8Z?uA|pSzd0K9*_Iq; z%KJ4rTAD<7Z>Lt(%Btwo$I^y`04@^LQx7D zK@#sN$59uzD8L0=eIhYTwS#^tpPz6w@%tEDjW_MDCDBme)$SQs8yw|J?VsCRtStG$ zya8JElRfReg<(|cl!ZYgYf@^2BpupkVXCtcr#`fIjSkHyP)uT5An(CCXs9G{t(L=|83xa2L*~9kTzqi1|{I< zk9QzAe(U`{@4#b-l7KllMeaUkU;jK%V?}rPx-#MJP#z*LDOgsc0;_&9r;)E-cR<`|i92i199@XsHH~;SRsR&tD75K71j~fZDOdL)7 zgCMIQy}oe6>TgUU84V98!ixPk{#NMcqiM(?c~CBQ#+BM}2CG~|5RbRN6T^5=rI^on z+f=F|@limrC?FCXZu-C+SR~D(JD-rL-**)zcz#PWV;#-PB^R&G%!WD!;@pN9FS??+ zZoRK2nd%q5m$jHcsr{ORATTinYs9JKZcI@yjta*KXZ3dSU_I6n3#=yZ6~b);I(0c* zGQOd&J#F?Tm!~I%{=P?x$YyrGvk${ok^r@&I8d_qmyw7$+54NOY$FtO?TTPIN|!fn zP(%9axl+-IFDLGzEiB;d$RPih&Yt?FQcVNG3__B8zxA-`Cd~mz{P(gC97(l3EV|(t z9#wTNx##Gh&^$v#twMBidd)Y!T!hjpV&Bag)rJ0j&q)1sd?c1hf!rtm*#l`ALJ5(D zBCc&47EaiALejw-K}l_I$|JOv)uayyb+YAbMV@>&-dk+qWdHe>NDxrqlc#3alr8^L zuY7g$brjF!Tu;1^p7mYnU!eNVU^mJ!>U+VMsWRdnWTGisaGW9sw^M z)X4g_>-x=#kK`s6gjgH%M` zGz&%l)FnDz!Mr=tctFjCrQ1489*!8uzf0X!`}(a5>h5jzas0w-^1xD=jpVxz?X*Z}CZB9UWqx*r54`O&TG0U+R*NkM6k8a+>(LqJpH12f`F;xrFl?363BoyOMCfU^kMMcb_J&H}P1Mg{Fdq{#z!Gap==cq|c-8*A_5K@Zw z9Q!EAb)Mj}_>?f~$ySxtw*43(pvL6-uHkbp~AOpoD!SD9a>6wz3(Zc#@%c-NHc9INb* z&*6nDAH3;*(n9=cPzkSavC*BBb!fJCYe(0L;U3e4#R}Zy!zg|~8BcZbjiKpm>^hlXmV=RO9UML$kja)eufL6$7w1qb{T7Mgvke@5^%~Eo8!(4~tNGU-I!9?seJfQq)O+)=M16A!$t zC7qM^0Zq-4t}%TkA@Q^{IOju>#4&Re_ibZUI__n(!s;hc6-#a&=(ss?CM**>7jKVB$*@5m zt97qh)CMb)qrzYA^uE5M2;tE=0u2IY`N)tU!rpp4$G}>p=^Xq3CX!s z+9X@OeWG07XzM}xcFP_{r8Gcjre2-Bx-x)hiXocMA(CCwtOH!Tr- zs%{RvCNg9v*OPrWkSd)vZjru{jyN)ipqNK%DzJ_`hH>M(_#a~25)FWwKI}EYo_=0>dHXu2_OIEana*%9oNGJ+C%B-5=%Be znuf4>qrQqS-{m%34s&-EU+|ct$w5$0gw7p$@#d zYM(RU;KAQ*#d@J#`O|?TgM$m9;dgdNB@-$?mZg~J;gmU3;ZI54>OSK$u zYxnQJH%pko%~)nrm%@192K#j%&pkK@FHZ@>#W>bpPjThe8QB~OBOrGd%~qK>WJ=Os z{&-Gkl$TxKtU1G?favuXhnELlf*M}}Y~Oq;{c?R{=3{1aSlEw?YSOSGnuS`c6*7%L zPozr62!mzk?8MLO`6v(jK~i1R^r?X>aS_~=xLqZYxXw^(8zGV|)XN%SNLf&^ck?05 z_(69WBq7#DZk~L1h1QEp6GAHOaj^Om#R2im-_*Ke*PCO}ohZck!hoMs-%ZSwu8ylZ zcY_7?mFgcXad31nDsED{!wSs*YT8rst2mInU?bS zk5{~3=n@*UwMvdil8`NVQ87hT{#h%5<&oUaIV)@T5zJAjxCu<1IY3;I>hPy(SBi4n z;r@8l{_W}d;^VE^6$tLwX&)@QXYubCySO+W%Th-@FrA%>I!w4}_j}zSkHxmY|97LV zs;c$ZMk-LBr6mP6}5aS;7TX7J&wA#P!MXCoc)`vIv|VprvQ`Z$+QCjv9f?`M0y}CtIVbl~-YK&oBv(=IW`^CsK@uZix-%#G)I&MJ{e5^;OI2xI2YL(|CV$5ae2as?Y2*E-}9#}R0a ziS&v*{aQ=jaR-?aTC6G>+!L-aShjd(*M!^O*h4t6Q{nuFGUSmWT%4^^b}#m`!E)O~ z!?P-1>O$UGpMHeF-8u$WIQm}+4bvt5E-!b`zTTU? zL_u~|@}z#$>9X@#ohpo%<#M8^Jl|5#w7&q2LzkV^_0b+5x1qqVlxrUe4F#;TaRllM z7DWURhpqRLqZ)#GjYOmAa{rPok=0c;hpR&)$YeHRE7j3ei5Sl*W5q`aF+RkEL<7@6 z+PNZTc|b@w0}fA7a(oZB?Xf}3yzyn3JFtG+f|2Hwdd756ZJ+^}Eu$9-+?ox%$!MT!zwU1dBQ5t=l;qGnaM=iEp&UFtPSm!kKE|;)2%a4U!Iw12Kh_60#gdwW<|+g)aFV=UY*4dN_}9 zDS7cjBcF!PK{ksMa^{;oAh|hgI@arpNlPjB=Tf8BdhW%nkPBw2$zjQ%%`jkJ$a$5`!a^maA}s&)r;g>lz~ zi}m8F9{Yoc&GnwnTfqqo1b1U4Z{k^cn~AFug^yK^aplr`zopF!s5jy9+w7~+#yIXV zgC9@a`kl8g218ScML&SZT^?*1W2S@QGymYJv(I_i;9Y_#l%Crh(3FEU>{0OE5GxWd zN6x8t{1hEUm?roQxU3c;L8VyAnGCEhp>M7bZH&;+=}yKf^(9JPGoY#?(Wt_ zFax46C=Khr3?@;*a90k3wdW=^AwDT$`}NK&S?~+O!`Fp|38Qjr&I(Q2>-49#`+O}9 zdAsa|zrlDuKb5wnIx7n}{UsssgXzYu*d#$lu`cvZ*{6buz9(Y39OR!Aw5oE<17*4W zAzjd3LkRbUk0yJ|mbVKkAGk};+!w$|T&Lo5-o6JSaXE^pb;vNfU)sEOrzN}8qnT1c z#k4`E$!hJPXj1xXYTdIc>w{+=bUwF{^nok8e#ClAl))0QP zC}U?G%gp#Zgy-!(R~Ya)YaF!bvoHy^J`>Gn-j|Fveq$xBw19SBlJT+pwD{MfxFoTF}bDnGaKZNQt);NrQ`l zaD&eo0+ZpkR>pV(`HgLZRy{`wWJze7;*=J;Z*0qZpU9&Gh8G zH{+lG%H#$MXCP4U4YOPNoU%XU#;Tjql~Bbhw%Cs&3@705a6gFUS@Nd*p{8bfhr@G> zWcv4Nq7`q%_}0jiy@eIW)sxPe2Lhb4x35ZVu=DMC%nTZA)-vHRGuUKC0I?414AhT;00PQbHHBVwmTrZ2{#WJkyUL4cT1;vY9Q?G@R~3vwo;h3s z)cPx#K?1Ykqt`>C--}k6t;_dS=ADphzid}eYwoQydrv=Je6ALLIxGj~978_WYe<~j z7kr;R2x7{CzU9o4bbN0DRYQfgL>u!iIiM);8)Idv7Df8!WL>E@Y825+JM!z_m?%l4 zK{bh>-=fdyvAa)FA7&h@1{{K|1I7wYqhed(GV94s8G{5mt3n1~4(2(v5rJ!pht1XT z0x{Zjg3zK6Jwe@@{q}@QR&QG51=%%y*yK8(r^W?B}Tp zaxdL94=nbJ%iHz|zCFY6(sxvT_xnZ|a@6FK;N(G7D+4{-_^r`ND=xLNx3#m4XPDN3 z*{Z1$*{bO7Y{$U27Au9pr1lr19YdH#=7n2KU&1^e6f3jo&9aXw(juF6by21@FP1F` z-jR0BllH!20W+s1k5Md!db8Gd!}};4WTWDrYPLUqf!|COf7*Am+rv_*lk?C05ao6@ z2W&HTIenRNT*cj5xVkkZzz{mDKL5phA1Bk zxN<}xbx@1bbQw~e(Mr`F#=Nh+$v2rzW}RJ}#f1z@X$q14qwvwe_1dRUd#n?pH=%#} z?AGA7>KPq(_K>JZ%l)#}#RLuvFMw5zK8atE;W$*Sm#eWKjQa#KB~>oZwZI}lT-aCY zyXeo6HYA4e{<~((%?ydBPJP8v&Qm1&ByaqsNJ<{*8(rzouN{+Pn#c!3Lbky+B4slp z3s9yYPeR6JMxpAU^`R4G#_jO|I;`bBL6MZ!Gjk@`=!MSmMbbx9`nN}kaF?&9WiCac zme^*kMHA@?%$`RhQ`q$%YSa)C6!n;9Da`K6+kGu1fo}Ul3uD{n@}z5lvtHPRzf+}8 zvPT0U+IZD)q)z$1+u^ydT^{H@Nl;0t=kT_B+T;upgmf7kbP+0&Jed!l!szB>YPx562(q5s%Uyr_1Y^B#PPDuVP7q!jjV}?t{&>Ffx>vI~ zNHMZ_-Gi`XyU{+Ze9{Dv7?GwS6iJ-RP&=ypV*N2x%|uB01x$hD&qr@)_m1#e!IyAD zGL(B7E^h(}YD#a;w}oR?PwB-DKean`kryLONYR8mhV*HE*PpdWhd3e9g0kyoSGw#*xACp>tpw~1_ zhiB`=YD;G0mN-$H$tN4;__VNGT|$=FkY>fH560@j41$rc2wf@gER& z*}5YQG0#zWB_h5&szVQGWMact_l)W5?IeLhG) z5_x}Pi9^zGJln7)^-aAI}<8j?o@n$-QW&cZCTede&en5u#15c^&?E6 z>eb-XmguQ#t$FYjBYSsKeC# zgb1#kyDc`KS-?_Xzj0XyXNSu0;NZ=Gzs&^KPDqD*OLZUr8AeQX;)(IFW6UZ!V$2NSl{cs3vb&$neIM^U zS<}+OGAnxWxgMVi0{ zcRqLPeNs830RCz#F6H=R`HJ}^LpSxWrk{PB^08O#FY?aJNO-iZx0OVUYlj+eryF+H zNRS(&jaC9W9G>BoPROnE%NtwbX*(H;vUG|r&;)DFzSQS+?3o@r5DNq&O#G5RngD8< zNI@GeGNiQ2R3GkQhjY|_zx$fS89sv~32(8&*~9jDG{2kKG7h93BVB5??xd=6?;sN| zwati3=XY2s`_B?`C;{mTeOOKrOibaI^VkwMK|3u1LiW9`OSUtCEO5$B7R%y++iZ@hFvKM!2^S=D;o=A|TgZl!?fkSj+V!D=;<>1-5B;?*CM&fgh7UPskW&M|2 zp;SnICnO#2gt+^&`H7#v#Rron&pK?cInJuOxpBHL9X>cRGWx&w*!!hU7McARc=yg$ z3#Giait4`=1>9Y+*8~e)Ndb|?oG&)F{fc&pE6vGb10}%$Je{dVzZw~g<{XHxLRRc~ zy`|i+^sDumx2nQqX4+jlI!`V&W>aP^RSp*TZ{w4>$9D~tsZyGx8QP;AiL_=UA6%76 z#ZkOk=VIrCaJ^Le$%JUZT8Z*8j}O4-yUwc3K_ZDW7vx3FA6=!356hq$z{s^~^U$za zOjIPtP;=5bJ3D)Snt@{Z<_Y3d%m^%G2$h$N zr2g^_K+1mwTgkKNGq+M@KRW~SO^{SO4quXAF(}%6Zk5!$*LvWb`lhtNG9aMQq{9ET zFTU}(-!%RxQ{LspqrbJT&HpVNB_{C>RGiun&15VbGz~>Cgf!%a!JC%G(J|%BXy>UL zZpGHpaXctqlvC}>EHQI4#W;>}98+ew@3P|%cXVFSwmM&feY;=iLON=?hm+ zhYur;qv@I>yLm1zR0oF!wGNb1V@KCc)SjK?ZLj4zru5&$Mr#MT zwt=<*>6GYa&9wiWN-Hm}Y-tw69twNYtlX|%Mx zcX%f7L@asGAMz_`jf~LeR<|?G3y~+s)E{x>XW`4BB*-;+IXS0&k!>t}PqO)~_y9Z? zXNu>In>_HHMts&LR@As0Ju*5<6{K)EdI3Hx{E)j=D>s`6Y1;WhVb3=Ru5O2I*Qcmn zoS9I>#qOMyW6Jmm(Vg(hSwBP6f16AIwpun=>} zf?TBLyKRqmabU#~Z#jU+*5Ios3YEkiR@eoQrp^%+aTgVadGQq%I4DLJf1oRNH1Ui{ z-Desnicc5?0Tqka}(dD>@ z6M62&yXn93w87`6O|A^f3mBjOT}s&)wXxqWGgH3m_sr2aFwKl0gEf6yAo!m#NQ5x~ z41?5;VgmR7H!YS6X{q5(`qJ?iLU z1dwMNvpD~yt^{BE!K^1<;FQ|}F?}~hVr>yYR5iRkyyRxCCr(fFv6xGcC+#i?u;h_x?`b?}7-%XhC*G@%y6k@h2Pel4cv{ zR(HPRiK>}MqihJb&pd1`aShnz3wVQQHxk?PH=#H?{or4b6f~Bp!RKQPcMjV~&fjT4 znD>>nx;SMCfN4BO3Gk}6I~GntWOhe*;QVzc4Q|(r_9z=9)tsob1*N;kN}gD+uX_&u zi-J@ae-@(_8fFcCUGZFGUH@h3-kcx?`#u?%+@19bCvkxJ^0n*$cM{0QVO)(rSR3w- zt$Ep1iU&y#^Ots1aR0X-vzEGsE+kk1M4wK0~u?AHHNx*`$%|Nq|$BJ@-@k>AkYny(73 zL4gvBN}*>rKsjNdp4tHbo9L>K3hU*f*dso$Aq38<-Pq3nR9@-t7tr!I(&OpQB9mCSO5 zHjha)+X?8VI&m20ZHhkspEdh5sGw`Bo3kRT*QN?Nih~)TcbB6T& z>J`^!=O9f+=#9kVbE_5IU`YO_itYxm6mW?R7Y7SIk|3LkL`X1!Y}*qx?SIJz8(Z88 zAtJ)u48Kq(5s?aEHDRQf8%+;!8SMH3xx1pPYXzcez`-8mPSbIzug6BZ-OEc zu5Q-9AM(SRh!h3mn!vmlW$5ufEaedM1jz2dQ%M`WH8e;f#F{*D6AQ=1UrLi0o59jJ z?_H$F@<8M^1yLRv=k}WCGAIMXvT*9>8wbb7y*HBw;k6bdWP~xRXQ~@>wATUu1EFc& zQDp{V|CX7QTLk&~b^e>*w%Di_JeBzdkQl9g&;IWnl87aI?wfPcpqi9&cI#La(c*t& zPEI6wS!v(H9hdK8MonE(U*_)Dyz2ix;O*y7>(@AlP6Qy z=46^)iyM%_+X1N~THq2w#B9k7a8h@Uo1G?pttMq?|1aXny;Z4vghvpOAPQ6L?_rva z9#Vw9Z|Q0er0Y;|Zx2N+_oYUEXr|~!hEJTVYv`Mpum&Wag{7VR3oIoxSjgjVq9ApQ zcdLOY0so+UVj!u>idHY-#{5=?CQ+Oj&~Ub0Z#Uoi+V0}?&<-Tua#$W*(*5<{`-X+F zg1u%*FESQF3hr|g>8He`G~79!>fG;i-u-6qDS^%*af3)H@Pe@W^Yz&e+p~XHii1N6 zt^S1v?IFk{bsgZ^#ps!*@6r>JK`6QD6_oit9T~mlJ@^Lv^MONRyk*BbOu)qbYGYuj zhZzWBmW2=r?5|R$q&q{zZmS@Q1P>bfp8Dh*4B>KDQHZ142k)G`fqt8 zvUJKP?0zv{pKHpM1G4nV;}6YehiGEh#}dm*N_k*n^Q91_JBwLg--UbQFLPjUZh-Tb z4{xu1Whyrbg2r?McFs96_S^RHJw^pLWV2 zCgQA%!L`jKn!hk&beW5jmZf9d!^UT(fI=yG4iE6(`{;fm5lbM|8}RgkK~^24kH2qs zH&1If)LPJt;n~(VFyN~B&&J7;`#uIP;{~v)kzj=?LDB)Ah%L+t5VKW;h__Sv-8NSV z&@+*X*L>DIzw=B%S!lDbUV;4g2Pq#rVF(`SwVnf2=h+ql!CCN4$8I+j5Su5nch9QR z{_icsPsvc#Kn~R9JCCB8clHMAHklR$6Oj+RsJM*X!NG~;7AXh-xJ;KMB>KczAQqJI z(niqQ&=Kah$`rrW=zid}VgQhm@!#Wfmk%*;A^%7IK6o9k0#iFpF>1G!+2;rtA)0dk z|3i-nfl&a2F_Vpt%^Kmz8xr?|lJ6Ea#EZ^RNB4R=?ZN@PJ2!cn%|NSrP zk{IA5P%53C&mtnxT<AF^mSO|XF-T_%F3t-5VgQEF{W+@E3g}# z4!pM5T^s^-^*=pYxZ{QYzw_g>E(e6{ylewj!4jqz>^Z$8+RE@)yH%I$Qygbo6xSip zf3mmOAHyo12olUm?q$5`Mjd~6(e!`2nesCTxA+Hlygi)+&B@PiTXL_<+l@0i0JMBa zH^^2CZajPl+)tOgSyWbBMpchjn=%I34-##*4|Ivj9z;5FtMb>_AA% z2~|K&$hUl6n-|P%?CfWS_fr#}IXurH2BJ}XzoFS*MJjw}nSdaHfd72@Pa&Y|d}nP9 zq!*m_X@;Mg1>p&OnFxZWVTfDtWu>LtE>6$;=5LatZtwt9%0qswJfN%=LL`*&|MpQV zUmVJXjUz;un>VBJKQFjQGX_`gNdV=i%p;?}nbtQp6yt$UuncehkZc5jRw6td{~O0aPDnBdaN8*=(N<^6ep>FMc*1aBR2&})<-1SyDPa|3;SRSERV zf*?&444siZ47GThbs&SmJmBX2?|TKMf#qwY<(#{4TA&7qc%frWeSy#URPkwOYJ|gr zU@i_Zwi6i8!UULrW9UsAHBT<`{l7%0_$((h8(uoQMXUxy@a&Q)blCJEkfKK*IC2q3 zCs_@+8^ao;efMob9v~Ez9?>#U=*mF_L*F3Z6QU7XB6?TRBrh+{WwITV)W&?E>|(;_qOZq6M+lAC&wV)Ug<9=;{9&O!#Xq%XU^QU za|*&Bjaubv3%Z;OFHC_)Af<~zs%k2!=mq!^5jjy&o*LiKckB7U9 z1X>KS-b9E{B|wpgm$L~YlTg@Pn8DKc_5dk*IE)!Z3sBww!LQPxpNX3 z5q_fK5(8TNe0yiST<7{P2oSWvSM9#Mn0ns&87)Y*yPDaojYA*zzxH7618d&>pvFm` zXfy74saYeBu8*#Zu3JB^|Lv{5kCm0bz#4kWgoj=F?_$IBv8R6hC1&4#T{0ZJI#^r{ zz6>gUjFZs;lk$JGHSINNNn@kEqle$HV7Ai-4q7U!6( zyIQm$8`4N6FY&(vjxp=Sf47$;MO|E6EF5*dL!SijmoY5GA8Sz&W3uVcne!UsYT^!1 zwHAw=JF>vZ)hBI?0NZlQXB}^Y9jfm~jebXIPwKr>LFV}M7w8L$%@^+xa&DFpiR1(> zwftzP1<28DH&yFa_`NXAipH)rl=SD=#;crvYnup!HI@|t;0W?LlEtzXowdgokx$YE zvD|hyK6|n4J0hSM!3Zqjy!iLXs~5wo=bUx2t2_OybTj2_n@;zIs{97C1hMJyhrY99 z53-tvNPB#5m=J7UxRrhSTuP@hC*)|JP;+FzE_ms_!TMzBIKmEnaCnp&gbU*41*AI= zKo@o0I7$^1EJh?SI z`cojA*MIT$N$>Rixxi9mqVoN!qL$a>%zZ7{RB3JDROz5_DfzQ@Z+r>BIchz32e0$b znOvg~eGA>*NxFsV-Hglr)^`Pk*Z$6vsTsRQPWuWIik-M%^Ddlr;3)uVqbC)at%k&o zU*nuTTL!U4Vi#c3!44$nlQiD?yH9;TN4Yzg#Fj_<5krcr?b1R=^fC<@3M{Vw_Vk|e zvkA%VymRiY-!g4{X-z{u=LT+*4YZ8Q6IWM&xA(@ncwhTVWs`b%%((+uyJHK-8|_TR|Wi=sgl;<|FO-Zs8Xm4&|a1hz$HlddL|##%mw ztF+P#ed#WCeoSe*C8KR@woRJ`kca9K%+cm>w8~^=X1eEBSDF4)-2R^R7J>@XqWa1K zy{nlBL-75>rmD(4u6mWnvbD9Pce{aimB@hHYfJCassBvxIGHdr)prB~%loRTAsLvx z44y43pK&_ES~rZwVbBG!UEtnt=A?N0+k$K<#U50**%ex`oSMM)yyK25`JBMav(Cww z8xSQ0GPK%#lg`*!s^Q^j{To{+EO#cp1$oZ)FIS>M{`66vkbbS6`SIDpPEFq9Wq!_~ zcd?U1dC@c0F`nn4URCEOTdM zqL)83HNV4p6)X9w^jmwcw&*QzXFK8g64p-^FS$kEksNQ%Q0lbVieExyz}Yo2c2L-9 zPqz^eJbtO98)=x-1bWYq*1r#aZaRZy2Ix-Jgq;6U`4W}9(tDL0pC3egr}KTs^SnvH zB7Hs*HrVyg>zm)bHg2=i6kj>G!t#laA&@+e#Z2*>_9_v`lgAvcf}6UNByd?! z;5Y2ZHzA=gNsb{EP~2$Wz&(9mV~K>{Bm*s~Cv#9ieD$o-p_@n6W5RV0bB6rGN!#UH z9nPbiHL0Z{ptOfw$<|d18X6uN7c2M2x=U?RmWW!JwABw@-w7-whS%!{+MVTUgrkVx%mzw(FO1{PD=;5vE~A36^di8yTkZM<=IjA`In_?hQrU zZD;NAKzFmlKKWcaf4TGfbCBS4{o`QD3?wN>_y@_sF1KvFf>JWWDy4YOOZZGj1}7sh ze}7KLf$Z(_PLA&MMeZ{Js~d+E;va2939nvd%sRgRiJ?4)+-K&=8x(<(xv4%ch8I_o zpaR^jp(N_lMn9B(F~gw3n_pkUPS|4WvG~Jlo?tF7Ix3S^6M*va-8~BkH-!sdH_LLT z^TyjXi9V(-8o;LP7ODq<*0B2;J(v1J8^z!IK6o{mo*;FgyirN^F0h|nP;UvLvR;k$JmRA)<1TW7Nz)NGY~daG`>jBD45`qN4u09{$3ky;pZP&qb-1vu1S z5lgfN=qOW{y6s(?&E-jA7jz;9wKF8#Xtqc+N=YITn=nvItH0zSuSv;aOo}BLk+llc zk=0fC!x^#x8d)An()xwkx9G3wNPq?V?nE$3vDH(Zlvx^5D9kxDw+8jp!jSfV7o8d$ zi0o@BVxI7K8ZB!-eF$~>8&DF0$telbc0I~`Inym_R$I<_T5R#*X=NJvSWgq5@1kApS4o{>)-58CzTc-oOf-CD)qxeDm`5+9#+;`1W&rzDBbsE6~QI0YALEx2{ zjx;pxatDmvr^w(HVupZ2yB10+0&sm2>z66;`6g^U|MsyC+(Y~YVYwI z=3>`$vlymfVipB#h2jgXgyj8Ej6(gb9nm0557RimpDsW5#Ke`YRFjO|<4NMYCF$8f z>pRaIy^EtM8EZ-mzdYv85Xusj zpvbE%A^m2zCU9{Q%zo0&*uXEFbB7#7ta`lrTuT3=_B%e;{hB0NDEe9n;wyi!fnv+jAQhTD zr734>rs@}Ny=UWxM#-Fcam7E3HIk-(gzJ=nLd^-s@^bR+w|0**C;5-5+5cZlR~^uV z^S&2!r?ep5ogxen5F`Z@lR;-se#pmCKYNY5Vc5?%TI-Xeja=y^FcAb{kOvS-m+wv$c%1FF*Ew24Ke_qi?X| z3!Rf1Gc-;=3T1D@08AN%EQg}Zwfi*ZMy3S9*pypw`y`vXVaM;GBlkBg^QP5=4}Z2k zoh!XIHX38k>P)5H#jqg{Z)v0sYej>&iIjhJCbI_F)OMaYFKt5a_k6uq+komQ%qb=L zwUNJ0eW!{8E`_Ilq{u-vmsSQ0UZ8zVuJg6|I!^$9e}r3TsE*|C^^GhjgtADPVQFjY z^DA9u^GEv;3m0u0Nwne4E`-W~y0_}+;KvUz|8720>K=@}?MaX|h&U&bMOpDaqk?f4 zNTh8z%;Y?CIXv~Xxy3eJHbr4rSJOp#Jq34Gpcp9FflLh>I}1OSiOwyN_xrSc+bxzT zcU2LVTd>%Txu+H`6_DFKMPCmvlrHeY3xoaIUq-ZkC(E>(v)= z8qScg8~>_OO(6Zo`-R`7rVlmCd}FfXi;tDrfhF4*a-a^d#l#1bYUAefoeiJMvzO-> z^kzn!{H@w2KnLnQFzr#$!s}~RK#a8a0)MyQAwLV78&G(nzm2JeY(9T~!wi|DiJGl2 zXI2hAc|zX9rl>atGN*BPD6Usk5#tr?R?J~-!1G?lDk|{Dvx;Y|c5iSUqQ~E_ zODa*0&AC7W17DB=X0I{uk>Tv7pGCylEIYNm=+jyQWl&K2i_7{qCwRALWGckIlBtbG zjhGOoDhkInsHb1z8my*{}{D@Vq#(0{WavguVgNBSBvlS$)Ty=MyaHo$c zx3__cWX-lmzgD8Qt=Z(fg(5hY8iK&qQMtC_Yn)JFPZf4YOeywSXoHnH{T8$S9rD~- zjzzu}D8ZI75i?5?rJFSKCjtaxU}3!1%od&2Op{i;7NY8KDsiXPd%{hhCzA3E*c|jW z=k!Ush;{fp^42b|pUEx{3a}s15q;qCMO~8aN&+Cv(zf3#`)-pvA-Yt#DhuA|ZU1ls z23-2aDhLRxXpIq*phO{+Cu*sQvVrdD6U&O!SfU8FF$_IrAVw3DEi~_G1W9^%PI;u5 z@U;zfxYD%QX`m4`Hx6l&q4}~!U2~O+1tQ_AWmpcr|F>-Qlreey2n@v8;>&f1r-QT4!raxWEXl-lt42UEE}*Hq3$a6$rno zU;*!ROA1>q7LL*X1htLi-vn090fy|ZnCEbFdMj9$$mI|Lk0d3UaHuB<%!qnHEK&u1g5JW7kz0HyC6yz&y~tr1FFPp2Xn1 zy`*hL)9HE>d@yo9m)F82Y&yzSBt7_H-W$h366&@e?DkIh$HZb+K z+b3(CUf0zC7SC>_6H+Vx-YC+49U<=h+NQF&*CRgJ;yg z@WyBd3%1t%6ejo;YNl~7E+xkdS%Iy&DATTOey%pj6y$HLPqyqRz;IMAwPMy^$6K7$ zOI<)j8EGH;7uYLt7sHeLz~^|Yn^e-Rc#yY(&C~0z3T)aU#6lkh#})wgc4`>dv%FVV zgZoZ-%*2xjl};F>nw|bV+h$;HZM3qkmpnVw%2D$o3P~sNL!4UaHkH~||1hnU4`7f>>g2+Olce~3b$8##E+D zWA}$*l{#jKx3JIcizEL2 z85lZ?%zk~&R3DY^10^DM=TZ7?6T!)RqZsXFvo4N};&hHQ!ZS=&;VY@S$j!v)=vmxP zusj@l*Qf)&NxBb7j0adGXX{)<1A5MU6tOjG^8FY^L zv7?ytH!BOk9BIp%jwKoY{MFjf!JA(J7CRtGeZWDpZ3(x!)Pi zm9^1w|3xA$Wl7O!RP!!|9V4N8LmltGxGD+8503wVJVd|?3BO6U?O}jNd=%dX zU(_S(&SfFIS3iv!y{6#1PkFOnEZTq5PN(FOuameOG!&%CzFE@@6Ap|&*Cark14p>q z8rHq9-?OvJ-)ttn-7qdKpyD0*n0ViO-qDBN9WU6`3>f@GF;3e}Ot_C`cq2(WH9wT{ z)A&=`UTwJh}-+1J<%g@`y0z{1iEm@np6e|X7s{IAFZ>#DdS3G~P7GAOR;-<$cPQM!z!Qm8^>t?WXS|TvM+ZU6$(KIw z1fkyZSxUnQ{iSR5+r7QN1EdCL^!R49#f2Ao2W3sfdaTZ8nX3R{>|-XFZf;%z)c?H+ zmwI@8+Zhyt<03D5`1HPYn8^O?2w`9hM&jBGu~1z5k0m~ZwqN|$ur4>Y=|sS}NxP=E z@pBpMR~Y=%6SphbS|td!O0@FV1`SS#d8e&o07>=o)rO9|D&3xlaS)q*o};Ti`4@e% z2e%grx#_lf-3Q#GA5hVvn}1Ayvpnv(r}_>`=Vu@!yZJ`jwkp38c`qmpfd4M!h0pya z`Wxw-tR>w67Z%KNd-5k*>fY=Srq4jBaeN3c*e}`kr#bMg&T`bGyQzU@Nhut8H(?2A zyKoS~tvK1xUM2uLVTU=A0>a<}sHko_YDb;MkN0G=32S*z*0g@u-ZeMf*TUI7{PbH* zXSD)7ziY8e>5fiOb8KwI&z!dVKY`c?@4_*uTyE)#0_{eVp*&%1qKcVczkV5khX}i5 zxl!x*1*2ZQyy6r|>zmyj+|voDk?h(Ys6jZ$=(kPje64pXhogMeR#fx5uRr^@sO}?X zL8CM)Wofq%&-F05)F*u$Jkle@k!bP(n~3~pL8~dgSni#r$S&)88R~pq z#WeSKVv?&0dK;a9c(YJ<^BylFqJV=Y1W>bW0I! zcSOaJC`dmgYg%@b)|J;U^J|N;1vR&`ho*n-8BqPg(*Ig!S@g{nvXMG4&29p&Tn(IB zfCTL32>{0&EpKx!xmJb*pg^=Byy?01kT11UaJE{uxq>r|drt{u%egM;=CH+(<*Ddd zJ)E2owx$_8Y#xDd?ebUAN&T?;v@1yx#axVkjhc-yeY!KF=!CnP--`>hshr-h7?aBlA>)PKI!C2{KXV(TfNLR{NDI$L{ycV%Y2^_kZA(>8o1d?8?k!np&4Py%6`x;A zh(b0KmzsTR8WuZ=`v?|!KU2gsuFo^%KP9vCyY&n*`STu|AO>1vqghw|ZjIj-5ayIY zryyS8Tt|uZI)g(9M$c=O$xcB%aKaj<>%khsR0paa{@zJ7i}EgDgq^5Mkwvz4HKE?% z$f3-3%XLj(QafSUgqmG_&8X3Eza9T=`R1LV+nbq6oQGU*@qIu>o%TtZY$Hd24chxZ z+c`8c9m82TP%@(Fc13+X+_seqvGIM;;<2mlF9Sb+8pyxzIhb$h4Z^95%mz8eANt>% zruooodb&0ek25nRV;qe`u%V^1d)yB1@cnKX2ys))b@_~w}%O&7KK}B;rQV6 zi!~BGi``M!1>pV5jexjQZ}r_>J%RW_7mDisAC|AE5baqUS49)*DXgA4N6#!~V*`z3 ztH(YxA9EmW)*8U8cDQ$hJHUta)`R?QlfmFTKBt7*EH8q$=dMGJMt&C1JMU3B`�% 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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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] 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/186] =?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/186] =?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/186] =?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/186] =?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/186] =?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/186] =?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/186] 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/186] 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/186] 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/186] 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/186] 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/186] =?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/186] =?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/186] 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/186] 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/186] =?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/186] 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 3f39cf8c47b408ed7976d73ce9c318f59fb66528 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 1 Oct 2026 20:37:35 +0800 Subject: [PATCH 184/186] Update README.md --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ef72bdb8..2d92b558 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@

- 💘C/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 +English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

- - - - + + + +

@@ -33,11 +33,18 @@ +image + + > [!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) +

+ + + +

@@ -75,7 +82,7 @@ Run the commands below from the project root (the directory containing ```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 libfontconfig1-dev fonts-noto-cjk ``` ### 🔧 Configure and Build @@ -201,13 +208,17 @@ 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. SDL3 window, preferences, and gamepad events +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. @@ -321,6 +332,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 @@ -338,16 +367,36 @@ we need. ## Project Status ![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") +## Sponsors -## 🙏 Special Thanks +

+ + + + + +
+ SignPath + + Free code signing on Windows provided by + SignPath.io, certificate by + SignPath Foundation +
+
- - Contribution Leaderboard - +## 🙏 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 It is your support that makes desktop companionship more free and genuine.❤️‍🔥 + + + + + +[linux.do](https://linux.do/t/topic/2845597) ---
From 666999650f8afb405fa34dbf1ee0b98cd7145117 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 3 Oct 2026 23:02:45 +0800 Subject: [PATCH 185/186] ci: disable automatic builds and releases on tag pushes --- .github/workflows/ci.yml | 1 - .github/workflows/release.yml | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38b877e7..8e147b73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ name: BongoCat CI on: push: branches: ["**"] - tags: ["v*"] pull_request: workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e16f1c85..73b980c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,7 @@ name: Release BongoCat for Windows on: - push: - tags: - - "v*" + workflow_dispatch: permissions: contents: read From 7d77803be8eeea55e6e4a88c8a0a1ac498cc8b59 Mon Sep 17 00:00:00 2001 From: Yijie Xu <48432972+yeahjack@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:36:25 -0700 Subject: [PATCH 186/186] perf: skip hashes for ineligible configuration stores --- cmake/ConfigStoreTests.cmake | 56 + cmake/Tests.cmake | 1 + src/runtime/lifecycle/config_store.c | 4 +- tests/runtime/README.md | 152 ++ tests/runtime/bench_config_store.c | 157 ++ tests/runtime/config_store_measurements.csv | 1861 +++++++++++++++++++ tests/runtime/config_store_reference.c | 233 +++ tests/runtime/config_store_support.c | 136 ++ tests/runtime/config_store_test.h | 36 + tests/runtime/test_config_store.c | 232 +++ 10 files changed, 2866 insertions(+), 2 deletions(-) create mode 100644 cmake/ConfigStoreTests.cmake create mode 100644 tests/runtime/README.md create mode 100644 tests/runtime/bench_config_store.c create mode 100644 tests/runtime/config_store_measurements.csv create mode 100644 tests/runtime/config_store_reference.c create mode 100644 tests/runtime/config_store_support.c create mode 100644 tests/runtime/config_store_test.h create mode 100644 tests/runtime/test_config_store.c diff --git a/cmake/ConfigStoreTests.cmake b/cmake/ConfigStoreTests.cmake new file mode 100644 index 00000000..df95fcee --- /dev/null +++ b/cmake/ConfigStoreTests.cmake @@ -0,0 +1,56 @@ +# Compile the exact old and new store implementations in separate objects. +# Only save calls are intercepted; validation and JSON serialization are real. +add_library(bongo_cat_config_store_subject OBJECT + src/runtime/lifecycle/config_store.c) +add_library(bongo_cat_config_store_reference OBJECT + tests/runtime/config_store_reference.c) +foreach(target bongo_cat_config_store_subject bongo_cat_config_store_reference) + target_include_directories(${target} PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_link_libraries(${target} PRIVATE + bongo_cat_core SDL3::SDL3-static bongo_cat_warnings) + target_compile_definitions(${target} PRIVATE + bongo_cat_settings_save=config_test_settings_save + bongo_cat_session_save=config_test_session_save) +endforeach() +target_compile_definitions(bongo_cat_config_store_reference PRIVATE + bongo_cat_config_store_load=config_reference_load + bongo_cat_config_store_update=config_reference_update + bongo_cat_config_store_flush=config_reference_flush) + +function(bongo_cat_config_store_test_target target source) + add_executable(${target} ${ARGN} ${source} + tests/runtime/config_store_support.c + $ + $) + target_compile_definitions(${target} PRIVATE + $<$:_CRT_SECURE_NO_WARNINGS>) + target_include_directories(${target} PRIVATE tests/runtime tests/support + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_link_libraries(${target} PRIVATE + bongo_cat_core SDL3::SDL3-static bongo_cat_warnings) + if(UNIX AND NOT APPLE) + target_link_libraries(${target} PRIVATE m) + endif() +endfunction() + +bongo_cat_config_store_test_target(bongo_cat_config_store_tests + tests/runtime/test_config_store.c) +add_test(NAME config-store COMMAND bongo_cat_config_store_tests) +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/config-store-test") +set_tests_properties(config-store PROPERTIES + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/config-store-test") +bongo_cat_config_store_test_target(bongo_cat_config_store_bench + tests/runtime/bench_config_store.c EXCLUDE_FROM_ALL) + +if(MSVC) + set_property(SOURCE tests/runtime/config_store_reference.c + tests/runtime/config_store_support.c tests/runtime/test_config_store.c + tests/runtime/bench_config_store.c APPEND PROPERTY + COMPILE_OPTIONS "/experimental:c11atomics") +endif() + +# Match the native Release targets' IPO setting for both implementations. +bongo_cat_enable_release_ipo(bongo_cat_config_store_subject + bongo_cat_config_store_reference bongo_cat_config_store_tests + bongo_cat_config_store_bench) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index cbfe1b92..d7026a3d 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -98,4 +98,5 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_ui_tests PRIVATE m) target_link_libraries(bongo_cat_app_state_tests PRIVATE m) endif() + include(cmake/ConfigStoreTests.cmake) endif() diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index b9147661..de3fbd73 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -188,9 +188,8 @@ static bool save_session(BongoCatApp *app) { 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->secondary_pet && !app->settings_store_blocked) { + uint64_t settings = settings_hash(&app->settings); if (settings != app->settings_observed_hash) { app->settings_observed_hash = settings; app->settings_save_due_ns = now + SAVE_DELAY_NS; @@ -199,6 +198,7 @@ void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { app->settings_save_due_ns = now + SAVE_DELAY_NS; } if (!app->session_store_blocked) { + uint64_t session = session_hash(&app->session); if (session != app->session_observed_hash) { app->session_observed_hash = session; app->session_save_due_ns = now + SAVE_DELAY_NS; diff --git a/tests/runtime/README.md b/tests/runtime/README.md new file mode 100644 index 00000000..778acc82 --- /dev/null +++ b/tests/runtime/README.md @@ -0,0 +1,152 @@ +# Configuration-store eligibility regression and microbenchmark + +The production change moves each update-time hash into its existing eligibility +branch. No hash fields, debounce/retry deadlines, validation, serialization, +load behavior, or flush behavior change. In particular, flush still updates +both observed hashes even when the corresponding store cannot be saved. + +`config_store_reference.c` is a frozen copy of the production implementation at +`666999650f8afb405fa34dbf1ee0b98cd7145117`, with only its public symbols renamed +by the test target. Keep this reference independent from later optimizations. +The old and current implementations are compiled separately, using the same +compiler, Release optimization, and native IPO setting. + +## Run + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON +cmake --build build --parallel 2 +ctest --test-dir build --output-on-failure +cmake --build build --target bongo_cat_config_store_bench --parallel 2 +./build/bongo_cat_config_store_bench samples.csv > summary.csv +``` + +The benchmark is explicitly built, not part of the normal test run. No SDL +window or display is needed for either executable. Run from a disposable working +directory without `config-store-test-settings.json` or +`config-store-test-session.json`; the correctness test writes and removes these +files through the real atomic JSON serializers. CTest uses its own dedicated +working directory. + +## Correctness coverage + +The deterministic differential test compares settings/session bytes, observed +and saved hashes, deadlines, validity/blocking flags, and each ordered save +attempt's timestamp, outcome, and exact emitted JSON bytes. The clock is an +explicit argument; there are no sleeps or timing-tolerance assertions. + +- Default and capacity-filled configuration, all eight combinations of secondary + pet / settings blocked / session blocked +- First-store persistence, unchanged polling, changes, and reverts before save +- Exactly 300 ms debounce and 1 s retries, including one nanosecond before each +- Independent save failures, mutation by validation on failure, pending retries + across blocking, and restored eligibility +- Flush success/failure, its observed-hash behavior for ineligible stores, and + requeueing after failed flush +- Null app, smoke mode, either missing path, and unsigned clock wrap +- Two seeded 512-step mixed sequences, including capacity-overflow counts, + binding/session edits, eligibility transitions, and flush/failure combinations + +Save interception injects deterministic errors and records attempts. Successful +saves use the real core validation, JSON serializer, and file writer. System +language detection alone is stubbed to keep first-run state deterministic. + +## Measurement method and scope + +The benchmark measures steady-state `bongo_cat_config_store_update` wall elapsed +time (SDL monotonic timer) and process CPU time (`clock()` on POSIX, `GetProcessTimes` on Windows) separately. It starts with clean saved state and aborts on any unexpected save. Both +implementations receive the same fixture, eligibility flags, and clock value. +The saturated fixture contains 256 behavior bindings, 128 model labels, +128 removed models, 7 additional models, and 256 active behaviors. + +Each variant has untimed calibration/warmup, followed by 31 samples, alternating +baseline/optimized execution order. Each sample is a batch calibrated to at +least 30 ms; fast and slow variants use independent iteration counts so the +no-hash path remains measurable. Reported median and nearest-rank p95 describe +batch-average nanoseconds per call, not individual-call tail latency. An optional +second argument overrides the calibration target in milliseconds. Functions +remain reachable through function pointers and resulting state is consumed; +no synthetic replacement hash or volatile full-structure scan is timed. + +This does not measure input-to-render latency, FPS, disk-save latency, CPU usage +of a complete app, or Live2D rendering. The production change introduces no +allocation, cache, new state, or change to structure sizes. Do not convert these numbers into those +claims. Compiler optimization can already eliminate some unused baseline +hashing, so secondary-pet improvements must be demonstrated rather than assumed. + +## Linux cloud measurements, 2026-10-05 + +Environment: Intel Xeon Platinum 8573C, GCC 14.2.0, CMake 3.31.10, +Release `-O3 -DNDEBUG -Os` (last optimization flag wins), native IPO/LTO enabled. +Both versions use exactly the same flags and linked libraries. `taskset -c 0` +succeeded; this is CPU affinity on a shared cloud host, not dedicated hardware. + +Three fresh-process runs each used 31 alternating paired samples per scenario +and a 30 ms calibration target. The tables pool 93 batch averages per variant; +all 1,860 raw samples are in `config_store_measurements.csv`. They measure wall +elapsed time and Linux process CPU time separately. p95 is the nearest-rank +p95 of batch averages, never individual input, frame, or save latency. + +Only session-blocked savings are supported consistently by these measurements. +The compiler already moves baseline settings hashing behind its eligibility +checks. The primary and secondary controls have no established improvement; +several controls are slower in individual runs. Broad tails and round-to-round +variation prevent a precise small-regression or small-gain claim. The primary +default exploratory 10 ms run showed an 8.10% slowdown; the three longer repeats +were +2.64%, -0.67%, and +2.76% reductions, so that slowdown did not reproduce. + +In the saturated session-blocked case the pooled wall median falls from +616.932 to 390.059 microseconds per update (36.77%), with 33.57–38.81% reductions +across the three runs. When both stores are blocked, the saturated median falls +from 233.395 microseconds to 4.478 nanoseconds, leaving only guard/call overhead. +These are unusual blocked-store paths, not evidence of faster normal rendering. + +All entries below are nanoseconds per update, baseline → optimized. + +| Fixture / mode | Wall median | Wall p95 | Process CPU median | Process CPU p95 | +| --- | ---: | ---: | ---: | ---: | +| defaults / primary | 8913.912 → 8853.679 | 13300.299 → 13947.401 | 8914.551 → 8851.318 | 13297.363 → 13948.242 | +| defaults / secondary | 327.120 → 338.289 | 437.215 → 444.418 | 327.164 → 338.303 | 437.256 → 444.443 | +| defaults / settings-blocked | 329.431 → 336.846 | 447.670 → 441.014 | 329.330 → 336.884 | 447.601 → 441.048 | +| defaults / session-blocked | 8978.440 → 8552.805 | 12423.929 → 13086.853 | 8975.830 → 8553.467 | 12424.561 → 13087.646 | +| defaults / both-blocked | 344.644 → 4.460 | 555.538 → 7.278 | 344.666 → 4.460 | 555.565 → 7.279 | +| saturated / primary | 638310.438 → 635797.297 | 1071957.656 → 1069403.984 | 638359.375 → 635828.125 | 1072031.250 → 1075796.875 | +| saturated / secondary | 235052.844 → 239811.559 | 423165.395 → 377093.062 | 235078.125 → 239820.312 | 423187.500 → 377117.188 | +| saturated / settings-blocked | 238874.562 → 234269.000 | 280633.023 → 315140.438 | 238898.438 → 234273.438 | 280656.250 → 315078.125 | +| saturated / session-blocked | 616932.000 → 390059.133 | 1046382.672 → 783879.922 | 616968.750 → 390085.938 | 1046421.875 → 783914.062 | +| saturated / both-blocked | 233395.492 → 4.478 | 359344.312 → 5.676 | 233406.250 → 4.478 | 359390.625 → 5.656 | + +Per-run wall-median reduction percentages (negative means slower): + +| Fixture / mode | Run 1 | Run 2 | Run 3 | +| --- | ---: | ---: | ---: | +| defaults / primary | +2.64% | -0.67% | +2.76% | +| defaults / secondary | -1.79% | +2.55% | -8.93% | +| defaults / settings-blocked | -0.97% | -5.56% | -3.17% | +| defaults / session-blocked | +2.66% | +6.65% | +1.48% | +| defaults / both-blocked | +98.72% | +98.73% | +98.58% | +| saturated / primary | +2.58% | +2.72% | -8.35% | +| saturated / secondary | -2.25% | -1.58% | -8.94% | +| saturated / settings-blocked | +2.69% | +6.43% | -3.76% | +| saturated / session-blocked | +34.53% | +38.81% | +33.57% | +| saturated / both-blocked | +100.00% | +100.00% | +100.00% | + +Validation on the same cloud toolchain: + +- Full diagnostic-backend Release build with warnings as errors passed +- All six CTests passed; config-store exercised 1,425 differential steps, + 260 save attempts, and 3,196,535 exact JSON bytes with zero differences +- Line, localization, platform-runtime, and Cubism safety policies passed +- Focused AddressSanitizer/UndefinedBehaviorSanitizer run passed for the store, + frozen reference, test, and support code; linked core/SDL archives were not + instrumented. LeakSanitizer is unavailable under executor ptrace, so leak + detection was disabled explicitly +- Frozen source verified byte-for-byte against the baseline; production flush + verified byte-for-byte unchanged +- IPO disassembly confirmed an indirect update call on every timed iteration, + and baseline/current hash helpers share the same function addresses +- A source variant moving only the session hash produced identical optimized + update assembly to moving both declarations; the two-move version is retained + +Cubism SDK rendering and Windows/macOS execution were not tested. diff --git a/tests/runtime/bench_config_store.c b/tests/runtime/bench_config_store.c new file mode 100644 index 00000000..bdfdcae9 --- /dev/null +++ b/tests/runtime/bench_config_store.c @@ -0,0 +1,157 @@ +#include "config_store_test.h" + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +#define SAMPLE_COUNT 31 +static uint64_t target_ns = 30000000ull; + +int bongo_cat_test_failures; +static volatile uint64_t sink; +typedef void (*Update)(BongoCatApp *, uint64_t); + +typedef struct Measurement { + Update update; + BongoCatApp *app; + size_t iterations; + double wall[SAMPLE_COUNT], cpu[SAMPLE_COUNT]; +} Measurement; + +#ifdef _WIN32 +static double process_cpu_ns(void) { + FILETIME created, exited, kernel, user; + if (!GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) + abort(); + uint64_t ticks = ((uint64_t)kernel.dwHighDateTime << 32) | kernel.dwLowDateTime; + ticks += ((uint64_t)user.dwHighDateTime << 32) | user.dwLowDateTime; + return (double)ticks * 100.0; +} +#endif + +static uint64_t run(Measurement *measurement, size_t iterations, double *cpu) { +#ifdef _WIN32 + double cpu_start = process_cpu_ns(); +#else + clock_t cpu_start = clock(); +#endif + uint64_t start = SDL_GetTicksNS(); + for (size_t i = 0; i < iterations; ++i) + measurement->update(measurement->app, 0); + uint64_t elapsed = SDL_GetTicksNS() - start; +#ifdef _WIN32 + double cpu_end = process_cpu_ns(); + if (cpu) *cpu = cpu_end - cpu_start; +#else + clock_t cpu_end = clock(); + if (cpu_start == (clock_t)-1 || cpu_end == (clock_t)-1) abort(); + if (cpu) *cpu = (double)(cpu_end - cpu_start) * 1e9 / CLOCKS_PER_SEC; +#endif + sink ^= measurement->app->settings_observed_hash; + sink ^= measurement->app->session_observed_hash; + return elapsed; +} + +static void warmup(Measurement *measurement) { + size_t iterations = 1; + while (run(measurement, iterations, NULL) < target_ns && iterations < (1u << 24)) + iterations *= 2; + measurement->iterations = iterations; + (void)run(measurement, iterations, NULL); +} + +static int compare_double(const void *left, const void *right) { + double a = *(const double *)left, b = *(const double *)right; + return (a > b) - (a < b); +} + +static void sample(Measurement *measurement, size_t index, FILE *raw, + const char *fixture, const char *mode, const char *variant) { + double cpu_ns; + uint64_t elapsed = run(measurement, measurement->iterations, &cpu_ns); + double ns = (double)elapsed / (double)measurement->iterations; + cpu_ns /= (double)measurement->iterations; + measurement->wall[index] = ns; + measurement->cpu[index] = cpu_ns; + if (raw) fprintf(raw, "%s,%s,%s,%zu,%zu,%.6f,%.6f\n", fixture, mode, + variant, index, measurement->iterations, ns, cpu_ns); +} + +static void benchmark(bool saturated, unsigned mode, FILE *raw) { + const char *names[] = {"primary", "secondary", "settings-blocked", + "session-blocked", "both-blocked"}; + const char *fixture = saturated ? "saturated" : "defaults"; + BongoCatApp *app = config_test_app(saturated, true); + CHECK(app->settings.behavior_shortcut_count == + (saturated ? BONGO_CAT_BEHAVIOR_BINDING_CAP : 0)); + CHECK(app->settings.model_label_count == (saturated ? BONGO_CAT_MODEL_CAP : 0)); + CHECK(app->settings.removed_model_count == (saturated ? BONGO_CAT_MODEL_CAP : 0)); + CHECK(app->session.additional_model_count == + (saturated ? BONGO_CAT_ADDITIONAL_MODEL_CAP : 0)); + CHECK(app->session.active_behavior_count == + (saturated ? BONGO_CAT_BEHAVIOR_BINDING_CAP : 0)); + if (bongo_cat_test_failures) abort(); + app->secondary_pet = mode == 1; + app->settings_store_blocked = mode == 2 || mode == 4; + app->session_store_blocked = mode == 3 || mode == 4; + BongoCatApp *reference = malloc(sizeof(*reference)); + if (!reference) abort(); + memcpy(reference, app, sizeof(*reference)); + Measurement baseline = {.update = config_reference_update, .app = reference}; + Measurement current = {.update = bongo_cat_config_store_update, .app = app}; + /* Independent calibration keeps even the guard-only case measurable. */ + warmup(&baseline); + warmup(¤t); + for (size_t i = 0; i < SAMPLE_COUNT; ++i) { + if (i % 2) { + sample(¤t, i, raw, fixture, names[mode], "optimized"); + sample(&baseline, i, raw, fixture, names[mode], "baseline"); + } else { + sample(&baseline, i, raw, fixture, names[mode], "baseline"); + sample(¤t, i, raw, fixture, names[mode], "optimized"); + } + } + config_test_compare(app, reference); + qsort(baseline.wall, SAMPLE_COUNT, sizeof(double), compare_double); + qsort(current.wall, SAMPLE_COUNT, sizeof(double), compare_double); + qsort(baseline.cpu, SAMPLE_COUNT, sizeof(double), compare_double); + qsort(current.cpu, SAMPLE_COUNT, sizeof(double), compare_double); + /* Nearest-rank p95 for 31 independent batch averages is element 30. */ + printf("%s,%s,%zu,%zu,%.3f,%.3f,%.3f,%.3f,%.2f," + "%.3f,%.3f,%.3f,%.3f,%.2f\n", fixture, names[mode], + baseline.iterations, current.iterations, + baseline.wall[15], baseline.wall[29], current.wall[15], current.wall[29], + 100.0 * (baseline.wall[15] - current.wall[15]) / baseline.wall[15], + baseline.cpu[15], baseline.cpu[29], current.cpu[15], current.cpu[29], + 100.0 * (baseline.cpu[15] - current.cpu[15]) / baseline.cpu[15]); + free(reference); + free(app); +} + +int main(int argc, char **argv) { + if (argc > 2) { + char *end; + unsigned long milliseconds = strtoul(argv[2], &end, 10); + if (*end || milliseconds < 1 || milliseconds > 1000) return EXIT_FAILURE; + target_ns = milliseconds * 1000000ull; + } + FILE *raw = argc > 1 ? fopen(argv[1], "w") : NULL; + if (argc > 1 && !raw) return EXIT_FAILURE; + if (raw) fprintf(raw, "fixture,mode,variant,sample,iterations,wall_ns_per_call,cpu_ns_per_call\n"); + printf("fixture,mode,baseline_iterations,optimized_iterations," + "baseline_wall_median_ns,baseline_wall_p95_ns,optimized_wall_median_ns," + "optimized_wall_p95_ns,wall_reduction_pct," + "baseline_cpu_median_ns,baseline_cpu_p95_ns,optimized_cpu_median_ns," + "optimized_cpu_p95_ns,cpu_reduction_pct\n"); + /* Any unexpected save aborts: only steady-state update CPU cost is timed. */ + config_test_trace(NULL); + for (unsigned saturated = 0; saturated < 2; ++saturated) + for (unsigned mode = 0; mode < 5; ++mode) + benchmark(saturated != 0, mode, raw); + if (raw && fclose(raw)) return EXIT_FAILURE; + return bongo_cat_test_failures ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tests/runtime/config_store_measurements.csv b/tests/runtime/config_store_measurements.csv new file mode 100644 index 00000000..b954381e --- /dev/null +++ b/tests/runtime/config_store_measurements.csv @@ -0,0 +1,1861 @@ +run,fixture,mode,variant,sample,iterations,wall_ns_per_call,cpu_ns_per_call +1,defaults,primary,baseline,0,4096,8222.311523,8223.144531 +1,defaults,primary,optimized,0,4096,8786.779785,8787.353516 +1,defaults,primary,optimized,1,4096,7360.137695,7360.595703 +1,defaults,primary,baseline,1,4096,10506.770508,10507.568359 +1,defaults,primary,baseline,2,4096,8657.993164,8658.447266 +1,defaults,primary,optimized,2,4096,8525.030029,8525.390625 +1,defaults,primary,optimized,3,4096,9896.125977,9896.728516 +1,defaults,primary,baseline,3,4096,7074.898682,7075.195312 +1,defaults,primary,baseline,4,4096,7497.522461,7498.046875 +1,defaults,primary,optimized,4,4096,10111.489990,10112.304688 +1,defaults,primary,optimized,5,4096,8184.101807,8184.570312 +1,defaults,primary,baseline,5,4096,9008.933838,9009.521484 +1,defaults,primary,baseline,6,4096,11660.220947,11660.888672 +1,defaults,primary,optimized,6,4096,8612.078613,8612.792969 +1,defaults,primary,optimized,7,4096,8684.885986,8685.546875 +1,defaults,primary,baseline,7,4096,8623.550781,8624.023438 +1,defaults,primary,baseline,8,4096,7337.268799,7337.890625 +1,defaults,primary,optimized,8,4096,10373.830566,10375.000000 +1,defaults,primary,optimized,9,4096,13947.400879,13948.242188 +1,defaults,primary,baseline,9,4096,9331.728516,9332.519531 +1,defaults,primary,baseline,10,4096,8629.606934,8624.511719 +1,defaults,primary,optimized,10,4096,7704.146240,7704.833984 +1,defaults,primary,optimized,11,4096,10777.035645,10777.832031 +1,defaults,primary,baseline,11,4096,8131.179199,8131.835938 +1,defaults,primary,baseline,12,4096,11857.504883,11850.585938 +1,defaults,primary,optimized,12,4096,9776.427490,9776.855469 +1,defaults,primary,optimized,13,4096,7614.785400,7605.957031 +1,defaults,primary,baseline,13,4096,11057.444092,11058.105469 +1,defaults,primary,baseline,14,4096,7447.123779,7447.753906 +1,defaults,primary,optimized,14,4096,7197.587891,7197.998047 +1,defaults,primary,optimized,15,4096,11165.212158,11165.771484 +1,defaults,primary,baseline,15,4096,10319.743164,10320.312500 +1,defaults,primary,baseline,16,4096,8743.239990,8743.652344 +1,defaults,primary,optimized,16,4096,8968.805420,8969.482422 +1,defaults,primary,optimized,17,4096,8660.635986,8661.132812 +1,defaults,primary,baseline,17,4096,8775.606934,8776.123047 +1,defaults,primary,baseline,18,4096,8940.311035,8940.917969 +1,defaults,primary,optimized,18,4096,8701.577148,8702.148438 +1,defaults,primary,optimized,19,4096,11570.171631,11570.556641 +1,defaults,primary,baseline,19,4096,9387.646484,9388.183594 +1,defaults,primary,baseline,20,4096,9007.966064,9008.789062 +1,defaults,primary,optimized,20,4096,8703.986084,8704.589844 +1,defaults,primary,optimized,21,4096,7674.278320,7674.804688 +1,defaults,primary,baseline,21,4096,9758.248779,9758.789062 +1,defaults,primary,baseline,22,4096,9937.389648,9937.988281 +1,defaults,primary,optimized,22,4096,8234.819336,8231.445312 +1,defaults,primary,optimized,23,4096,14147.123779,14145.019531 +1,defaults,primary,baseline,23,4096,7945.538574,7946.289062 +1,defaults,primary,baseline,24,4096,9906.434082,9906.982422 +1,defaults,primary,optimized,24,4096,8434.413574,8435.058594 +1,defaults,primary,optimized,25,4096,8730.765869,8731.445312 +1,defaults,primary,baseline,25,4096,10762.696777,10753.906250 +1,defaults,primary,baseline,26,4096,8059.507324,8051.513672 +1,defaults,primary,optimized,26,4096,7871.520020,7872.070312 +1,defaults,primary,optimized,27,4096,9046.708496,9047.119141 +1,defaults,primary,baseline,27,4096,7204.875244,7205.078125 +1,defaults,primary,baseline,28,4096,9267.405518,9267.822266 +1,defaults,primary,optimized,28,4096,11588.087646,11588.623047 +1,defaults,primary,optimized,29,4096,8606.465088,8606.933594 +1,defaults,primary,baseline,29,4096,9533.862061,9534.423828 +1,defaults,primary,baseline,30,4096,8139.822021,8140.625000 +1,defaults,primary,optimized,30,4096,12272.657959,12273.193359 +1,defaults,secondary,baseline,0,131072,384.144753,384.162903 +1,defaults,secondary,optimized,0,131072,424.712189,424.736023 +1,defaults,secondary,optimized,1,131072,352.269714,352.294922 +1,defaults,secondary,baseline,1,131072,433.401474,433.288574 +1,defaults,secondary,baseline,2,131072,436.192268,436.210632 +1,defaults,secondary,optimized,2,131072,333.705429,333.724976 +1,defaults,secondary,optimized,3,131072,385.039978,384.826660 +1,defaults,secondary,baseline,3,131072,281.627800,281.555176 +1,defaults,secondary,baseline,4,131072,304.840546,304.862976 +1,defaults,secondary,optimized,4,131072,294.365463,294.380188 +1,defaults,secondary,optimized,5,131072,322.581566,322.593689 +1,defaults,secondary,baseline,5,131072,356.458405,356.475830 +1,defaults,secondary,baseline,6,131072,308.351807,308.364868 +1,defaults,secondary,optimized,6,131072,334.026711,334.045410 +1,defaults,secondary,optimized,7,131072,289.830208,289.848328 +1,defaults,secondary,baseline,7,131072,342.431335,342.445374 +1,defaults,secondary,baseline,8,131072,353.630203,353.645325 +1,defaults,secondary,optimized,8,131072,332.927124,332.939148 +1,defaults,secondary,optimized,9,131072,420.987671,421.012878 +1,defaults,secondary,baseline,9,131072,300.015747,300.041199 +1,defaults,secondary,baseline,10,131072,339.483948,339.508057 +1,defaults,secondary,optimized,10,131072,272.001358,272.018433 +1,defaults,secondary,optimized,11,131072,349.188873,349.205017 +1,defaults,secondary,baseline,11,131072,332.115517,332.130432 +1,defaults,secondary,baseline,12,131072,319.501953,319.519043 +1,defaults,secondary,optimized,12,131072,343.944717,343.955994 +1,defaults,secondary,optimized,13,131072,303.213882,303.138733 +1,defaults,secondary,baseline,13,131072,328.977173,328.994751 +1,defaults,secondary,baseline,14,131072,327.187805,327.209473 +1,defaults,secondary,optimized,14,131072,351.027534,351.051331 +1,defaults,secondary,optimized,15,131072,416.157913,415.725708 +1,defaults,secondary,baseline,15,131072,328.147766,328.170776 +1,defaults,secondary,baseline,16,131072,321.102867,321.136475 +1,defaults,secondary,optimized,16,131072,322.211800,322.235107 +1,defaults,secondary,optimized,17,131072,338.289017,338.302612 +1,defaults,secondary,baseline,17,131072,317.783249,317.802429 +1,defaults,secondary,baseline,18,131072,327.752907,327.621460 +1,defaults,secondary,optimized,18,131072,265.967262,265.983582 +1,defaults,secondary,optimized,19,131072,314.322182,314.346313 +1,defaults,secondary,baseline,19,131072,322.610298,322.624207 +1,defaults,secondary,baseline,20,131072,335.908730,335.922241 +1,defaults,secondary,optimized,20,131072,490.669090,490.684509 +1,defaults,secondary,optimized,21,131072,394.430283,394.439697 +1,defaults,secondary,baseline,21,131072,308.759895,308.776855 +1,defaults,secondary,baseline,22,131072,278.093712,278.114319 +1,defaults,secondary,optimized,22,131072,350.447617,350.471497 +1,defaults,secondary,optimized,23,131072,330.669228,330.688477 +1,defaults,secondary,baseline,23,131072,360.019463,360.031128 +1,defaults,secondary,baseline,24,131072,308.499092,308.113098 +1,defaults,secondary,optimized,24,131072,367.614662,367.538452 +1,defaults,secondary,optimized,25,131072,325.420914,325.431824 +1,defaults,secondary,baseline,25,131072,405.596054,405.616760 +1,defaults,secondary,baseline,26,131072,372.379669,372.138977 +1,defaults,secondary,optimized,26,131072,307.199120,307.220459 +1,defaults,secondary,optimized,27,131072,313.760300,313.774109 +1,defaults,secondary,baseline,27,131072,260.785492,260.803223 +1,defaults,secondary,baseline,28,131072,352.509117,352.523804 +1,defaults,secondary,optimized,28,131072,307.977272,307.991028 +1,defaults,secondary,optimized,29,131072,335.150841,335.174561 +1,defaults,secondary,baseline,29,131072,309.657349,309.669495 +1,defaults,secondary,baseline,30,131072,335.816956,335.998535 +1,defaults,secondary,optimized,30,131072,354.723503,354.736328 +1,defaults,settings-blocked,baseline,0,131072,312.892189,312.904358 +1,defaults,settings-blocked,optimized,0,131072,361.136108,361.152649 +1,defaults,settings-blocked,optimized,1,131072,303.825401,303.840637 +1,defaults,settings-blocked,baseline,1,131072,314.450127,314.468384 +1,defaults,settings-blocked,baseline,2,131072,432.219658,432.182312 +1,defaults,settings-blocked,optimized,2,131072,312.456589,312.477112 +1,defaults,settings-blocked,optimized,3,131072,390.965088,390.983582 +1,defaults,settings-blocked,baseline,3,131072,361.297752,360.977173 +1,defaults,settings-blocked,baseline,4,131072,325.380241,325.401306 +1,defaults,settings-blocked,optimized,4,131072,277.347519,277.359009 +1,defaults,settings-blocked,optimized,5,131072,347.578239,347.595215 +1,defaults,settings-blocked,baseline,5,131072,337.396255,337.417603 +1,defaults,settings-blocked,baseline,6,131072,362.011192,362.030029 +1,defaults,settings-blocked,optimized,6,131072,327.912720,327.934265 +1,defaults,settings-blocked,optimized,7,131072,274.900162,274.917603 +1,defaults,settings-blocked,baseline,7,131072,368.485535,368.499756 +1,defaults,settings-blocked,baseline,8,131072,323.258621,323.280334 +1,defaults,settings-blocked,optimized,8,131072,426.135391,426.155090 +1,defaults,settings-blocked,optimized,9,131072,462.029228,462.051392 +1,defaults,settings-blocked,baseline,9,131072,366.158531,366.180420 +1,defaults,settings-blocked,baseline,10,131072,279.698524,279.716492 +1,defaults,settings-blocked,optimized,10,131072,354.942101,354.965210 +1,defaults,settings-blocked,optimized,11,131072,330.664536,330.680847 +1,defaults,settings-blocked,baseline,11,131072,353.347466,353.370667 +1,defaults,settings-blocked,baseline,12,131072,327.491173,327.514648 +1,defaults,settings-blocked,optimized,12,131072,292.851463,292.877197 +1,defaults,settings-blocked,optimized,13,131072,345.559662,345.481873 +1,defaults,settings-blocked,baseline,13,131072,309.124100,309.143066 +1,defaults,settings-blocked,baseline,14,131072,366.482643,366.508484 +1,defaults,settings-blocked,optimized,14,131072,330.652855,330.459595 +1,defaults,settings-blocked,optimized,15,131072,309.766266,309.562683 +1,defaults,settings-blocked,baseline,15,131072,320.863579,320.549011 +1,defaults,settings-blocked,baseline,16,131072,271.434990,271.453857 +1,defaults,settings-blocked,optimized,16,131072,424.812523,424.835205 +1,defaults,settings-blocked,optimized,17,131072,345.674301,345.687866 +1,defaults,settings-blocked,baseline,17,131072,454.738556,454.765320 +1,defaults,settings-blocked,baseline,18,131072,298.560921,298.583984 +1,defaults,settings-blocked,optimized,18,131072,373.430435,373.451233 +1,defaults,settings-blocked,optimized,19,131072,333.706810,333.732605 +1,defaults,settings-blocked,baseline,19,131072,399.564835,399.581909 +1,defaults,settings-blocked,baseline,20,131072,385.251907,385.276794 +1,defaults,settings-blocked,optimized,20,131072,379.473915,379.486084 +1,defaults,settings-blocked,optimized,21,131072,304.502640,304.420471 +1,defaults,settings-blocked,baseline,21,131072,335.766075,335.784912 +1,defaults,settings-blocked,baseline,22,131072,316.690994,316.711426 +1,defaults,settings-blocked,optimized,22,131072,313.245308,313.270569 +1,defaults,settings-blocked,optimized,23,131072,377.343185,377.357483 +1,defaults,settings-blocked,baseline,23,131072,312.517685,312.538147 +1,defaults,settings-blocked,baseline,24,131072,342.903008,342.926025 +1,defaults,settings-blocked,optimized,24,131072,318.873215,318.794250 +1,defaults,settings-blocked,optimized,25,131072,381.171104,381.195068 +1,defaults,settings-blocked,baseline,25,131072,327.131081,326.515198 +1,defaults,settings-blocked,baseline,26,131072,297.944000,297.958374 +1,defaults,settings-blocked,optimized,26,131072,340.947853,340.660095 +1,defaults,settings-blocked,optimized,27,131072,267.658096,267.669678 +1,defaults,settings-blocked,baseline,27,131072,364.892509,364.913940 +1,defaults,settings-blocked,baseline,28,131072,296.006454,296.028137 +1,defaults,settings-blocked,optimized,28,131072,306.266945,306.282043 +1,defaults,settings-blocked,optimized,29,131072,330.060776,330.078125 +1,defaults,settings-blocked,baseline,29,131072,279.854843,279.869080 +1,defaults,settings-blocked,baseline,30,131072,349.800621,349.822998 +1,defaults,settings-blocked,optimized,30,131072,314.794128,314.811707 +1,defaults,session-blocked,baseline,0,4096,9046.454346,9046.875000 +1,defaults,session-blocked,optimized,0,4096,8005.923584,8006.347656 +1,defaults,session-blocked,optimized,1,4096,8552.805420,8553.466797 +1,defaults,session-blocked,baseline,1,4096,8958.987305,8959.472656 +1,defaults,session-blocked,baseline,2,4096,8540.262451,8540.771484 +1,defaults,session-blocked,optimized,2,4096,8242.239990,8242.675781 +1,defaults,session-blocked,optimized,3,4096,8126.500732,8126.953125 +1,defaults,session-blocked,baseline,3,4096,8153.713379,8151.855469 +1,defaults,session-blocked,baseline,4,4096,13922.362549,13916.992188 +1,defaults,session-blocked,optimized,4,4096,10875.462158,10444.580078 +1,defaults,session-blocked,optimized,5,4096,9912.397217,9912.841797 +1,defaults,session-blocked,baseline,5,4096,7813.587646,7811.035156 +1,defaults,session-blocked,baseline,6,4096,8047.918701,8048.828125 +1,defaults,session-blocked,optimized,6,4096,11793.888428,11794.677734 +1,defaults,session-blocked,optimized,7,4096,8517.572754,8518.066406 +1,defaults,session-blocked,baseline,7,4096,9337.932861,9338.378906 +1,defaults,session-blocked,baseline,8,4096,10130.979248,10129.882812 +1,defaults,session-blocked,optimized,8,4096,7486.143311,7486.572266 +1,defaults,session-blocked,optimized,9,4096,12606.298340,12606.689453 +1,defaults,session-blocked,baseline,9,4096,8724.204346,8724.609375 +1,defaults,session-blocked,baseline,10,4096,11563.993652,11564.941406 +1,defaults,session-blocked,optimized,10,4096,11588.725830,11589.355469 +1,defaults,session-blocked,optimized,11,4096,9046.012939,9046.630859 +1,defaults,session-blocked,baseline,11,4096,9140.067383,9140.625000 +1,defaults,session-blocked,baseline,12,4096,7990.935059,7991.455078 +1,defaults,session-blocked,optimized,12,4096,8485.454834,8486.083984 +1,defaults,session-blocked,optimized,13,4096,10034.517822,10035.156250 +1,defaults,session-blocked,baseline,13,4096,7931.176514,7931.640625 +1,defaults,session-blocked,baseline,14,4096,8661.670166,8662.109375 +1,defaults,session-blocked,optimized,14,4096,8058.806152,8059.326172 +1,defaults,session-blocked,optimized,15,4096,8410.381836,8410.888672 +1,defaults,session-blocked,baseline,15,4096,11245.109863,11245.605469 +1,defaults,session-blocked,baseline,16,4096,9138.318359,9138.671875 +1,defaults,session-blocked,optimized,16,4096,9627.238770,9624.755859 +1,defaults,session-blocked,optimized,17,4096,9228.614990,9208.007812 +1,defaults,session-blocked,baseline,17,4096,8130.857422,8131.347656 +1,defaults,session-blocked,baseline,18,4096,8223.166992,8221.191406 +1,defaults,session-blocked,optimized,18,4096,8485.365967,8485.839844 +1,defaults,session-blocked,optimized,19,4096,7762.571045,7755.859375 +1,defaults,session-blocked,baseline,19,4096,9185.991943,9186.523438 +1,defaults,session-blocked,baseline,20,4096,8887.359863,8887.695312 +1,defaults,session-blocked,optimized,20,4096,7596.506592,7596.923828 +1,defaults,session-blocked,optimized,21,4096,8434.396484,8435.058594 +1,defaults,session-blocked,baseline,21,4096,8485.071533,8485.595703 +1,defaults,session-blocked,baseline,22,4096,8539.096680,8539.794922 +1,defaults,session-blocked,optimized,22,4096,9006.223145,9006.835938 +1,defaults,session-blocked,optimized,23,4096,8326.343262,8326.660156 +1,defaults,session-blocked,baseline,23,4096,8898.189941,8898.681641 +1,defaults,session-blocked,baseline,24,4096,11806.856201,11807.617188 +1,defaults,session-blocked,optimized,24,4096,9140.240234,9141.113281 +1,defaults,session-blocked,optimized,25,4096,8818.375977,8819.091797 +1,defaults,session-blocked,baseline,25,4096,8786.414062,8786.865234 +1,defaults,session-blocked,baseline,26,4096,8334.279053,8334.716797 +1,defaults,session-blocked,optimized,26,4096,9570.038574,9570.556641 +1,defaults,session-blocked,optimized,27,4096,10785.355713,10785.888672 +1,defaults,session-blocked,baseline,27,4096,8297.992432,8298.583984 +1,defaults,session-blocked,baseline,28,4096,10280.002686,10280.517578 +1,defaults,session-blocked,optimized,28,4096,7827.979492,7828.369141 +1,defaults,session-blocked,optimized,29,4096,8500.498047,8501.220703 +1,defaults,session-blocked,baseline,29,4096,9196.619385,9197.021484 +1,defaults,session-blocked,baseline,30,4096,8783.089355,8783.691406 +1,defaults,session-blocked,optimized,30,4096,9577.124512,9574.951172 +1,defaults,both-blocked,baseline,0,131072,311.828056,311.851501 +1,defaults,both-blocked,optimized,0,8388608,4.514793,4.515052 +1,defaults,both-blocked,optimized,1,8388608,3.817753,3.818154 +1,defaults,both-blocked,baseline,1,131072,324.297424,324.310303 +1,defaults,both-blocked,baseline,2,131072,339.034698,339.050293 +1,defaults,both-blocked,optimized,2,8388608,4.164284,4.164577 +1,defaults,both-blocked,optimized,3,8388608,5.797150,5.797386 +1,defaults,both-blocked,baseline,3,131072,350.660988,350.677490 +1,defaults,both-blocked,baseline,4,131072,359.605042,359.619141 +1,defaults,both-blocked,optimized,4,8388608,3.987377,3.987670 +1,defaults,both-blocked,optimized,5,8388608,4.808514,4.808784 +1,defaults,both-blocked,baseline,5,131072,344.643639,344.665527 +1,defaults,both-blocked,baseline,6,131072,298.897919,298.912048 +1,defaults,both-blocked,optimized,6,8388608,4.586259,4.586577 +1,defaults,both-blocked,optimized,7,8388608,4.054572,4.054904 +1,defaults,both-blocked,baseline,7,131072,304.938858,304.954529 +1,defaults,both-blocked,baseline,8,131072,344.459473,344.482422 +1,defaults,both-blocked,optimized,8,8388608,4.120865,4.121184 +1,defaults,both-blocked,optimized,9,8388608,4.905639,4.905939 +1,defaults,both-blocked,baseline,9,131072,329.190369,329.063416 +1,defaults,both-blocked,baseline,10,131072,293.560059,293.579102 +1,defaults,both-blocked,optimized,10,8388608,5.088616,5.088806 +1,defaults,both-blocked,optimized,11,8388608,3.896016,3.896236 +1,defaults,both-blocked,baseline,11,131072,409.975746,407.310486 +1,defaults,both-blocked,baseline,12,131072,314.668449,314.682007 +1,defaults,both-blocked,optimized,12,8388608,4.318861,4.319072 +1,defaults,both-blocked,optimized,13,8388608,4.248666,4.248857 +1,defaults,both-blocked,baseline,13,131072,294.579727,294.593811 +1,defaults,both-blocked,baseline,14,131072,361.181969,361.206055 +1,defaults,both-blocked,optimized,14,8388608,4.581679,4.582047 +1,defaults,both-blocked,optimized,15,8388608,4.470097,4.470348 +1,defaults,both-blocked,baseline,15,131072,470.069923,470.085144 +1,defaults,both-blocked,baseline,16,131072,330.231483,330.253601 +1,defaults,both-blocked,optimized,16,8388608,4.488051,4.488468 +1,defaults,both-blocked,optimized,17,8388608,3.972056,3.972292 +1,defaults,both-blocked,baseline,17,131072,373.829468,373.847961 +1,defaults,both-blocked,baseline,18,131072,465.165237,465.194702 +1,defaults,both-blocked,optimized,18,8388608,6.271703,6.272197 +1,defaults,both-blocked,optimized,19,8388608,4.209533,4.209757 +1,defaults,both-blocked,baseline,19,131072,306.696793,306.716919 +1,defaults,both-blocked,baseline,20,131072,363.833633,363.853455 +1,defaults,both-blocked,optimized,20,8388608,3.967037,3.967404 +1,defaults,both-blocked,optimized,21,8388608,4.824796,4.821897 +1,defaults,both-blocked,baseline,21,131072,293.590515,293.525696 +1,defaults,both-blocked,baseline,22,131072,306.707726,306.724548 +1,defaults,both-blocked,optimized,22,8388608,4.362044,4.362345 +1,defaults,both-blocked,optimized,23,8388608,4.151212,4.151583 +1,defaults,both-blocked,baseline,23,131072,376.222221,376.113892 +1,defaults,both-blocked,baseline,24,131072,302.326279,302.345276 +1,defaults,both-blocked,optimized,24,8388608,4.330743,4.331112 +1,defaults,both-blocked,optimized,25,8388608,4.253214,4.253507 +1,defaults,both-blocked,baseline,25,131072,273.161942,273.178101 +1,defaults,both-blocked,baseline,26,131072,348.040413,348.060608 +1,defaults,both-blocked,optimized,26,8388608,4.636145,4.636645 +1,defaults,both-blocked,optimized,27,8388608,4.122161,4.122496 +1,defaults,both-blocked,baseline,27,131072,422.317284,422.332764 +1,defaults,both-blocked,baseline,28,131072,325.734924,325.759888 +1,defaults,both-blocked,optimized,28,8388608,4.631084,4.631400 +1,defaults,both-blocked,optimized,29,8388608,3.980176,3.980517 +1,defaults,both-blocked,baseline,29,131072,358.448273,358.474731 +1,defaults,both-blocked,baseline,30,131072,363.185692,363.067627 +1,defaults,both-blocked,optimized,30,8388608,7.278304,7.278681 +1,saturated,primary,baseline,0,64,687866.015625,687687.500000 +1,saturated,primary,optimized,0,64,618786.281250,618828.125000 +1,saturated,primary,optimized,1,64,586133.843750,586171.875000 +1,saturated,primary,baseline,1,64,673144.890625,672734.375000 +1,saturated,primary,baseline,2,64,574202.000000,573015.625000 +1,saturated,primary,optimized,2,64,596747.625000,596781.250000 +1,saturated,primary,optimized,3,64,613686.187500,613718.750000 +1,saturated,primary,baseline,3,64,553206.734375,553234.375000 +1,saturated,primary,baseline,4,64,621339.562500,621390.625000 +1,saturated,primary,optimized,4,64,658614.031250,658656.250000 +1,saturated,primary,optimized,5,64,632222.968750,632265.625000 +1,saturated,primary,baseline,5,64,834163.890625,834203.125000 +1,saturated,primary,baseline,6,64,627320.093750,627359.375000 +1,saturated,primary,optimized,6,64,664971.765625,665015.625000 +1,saturated,primary,optimized,7,64,557610.718750,557625.000000 +1,saturated,primary,baseline,7,64,675410.546875,675453.125000 +1,saturated,primary,baseline,8,64,695733.718750,695765.625000 +1,saturated,primary,optimized,8,64,562171.515625,562218.750000 +1,saturated,primary,optimized,9,64,685306.218750,685343.750000 +1,saturated,primary,baseline,9,64,551805.140625,551828.125000 +1,saturated,primary,baseline,10,64,574876.484375,574921.875000 +1,saturated,primary,optimized,10,64,654441.875000,654484.375000 +1,saturated,primary,optimized,11,64,588560.093750,588593.750000 +1,saturated,primary,baseline,11,64,704012.875000,703812.500000 +1,saturated,primary,baseline,12,64,587413.546875,587453.125000 +1,saturated,primary,optimized,12,64,586950.171875,586828.125000 +1,saturated,primary,optimized,13,64,608534.875000,608562.500000 +1,saturated,primary,baseline,13,64,592872.718750,592906.250000 +1,saturated,primary,baseline,14,64,686601.281250,686640.625000 +1,saturated,primary,optimized,14,64,602797.828125,602359.375000 +1,saturated,primary,optimized,15,64,591237.859375,591140.625000 +1,saturated,primary,baseline,15,64,711525.140625,711562.500000 +1,saturated,primary,baseline,16,64,546330.500000,546375.000000 +1,saturated,primary,optimized,16,64,619792.843750,619843.750000 +1,saturated,primary,optimized,17,64,661360.593750,661406.250000 +1,saturated,primary,baseline,17,64,639888.812500,639937.500000 +1,saturated,primary,baseline,18,64,807309.062500,807343.750000 +1,saturated,primary,optimized,18,64,624021.250000,624578.125000 +1,saturated,primary,optimized,19,64,621295.437500,621328.125000 +1,saturated,primary,baseline,19,64,554184.953125,554218.750000 +1,saturated,primary,baseline,20,64,684405.015625,684437.500000 +1,saturated,primary,optimized,20,64,663547.843750,663578.125000 +1,saturated,primary,optimized,21,64,624664.484375,624703.125000 +1,saturated,primary,baseline,21,64,816433.375000,816468.750000 +1,saturated,primary,baseline,22,64,528773.859375,528812.500000 +1,saturated,primary,optimized,22,64,793403.437500,793437.500000 +1,saturated,primary,optimized,23,64,592950.781250,592984.375000 +1,saturated,primary,baseline,23,64,692898.000000,692515.625000 +1,saturated,primary,baseline,24,64,620128.859375,620171.875000 +1,saturated,primary,optimized,24,64,530880.218750,530921.875000 +1,saturated,primary,optimized,25,64,681608.000000,681453.125000 +1,saturated,primary,baseline,25,64,579004.890625,579031.250000 +1,saturated,primary,baseline,26,64,663023.828125,663078.125000 +1,saturated,primary,optimized,26,64,650043.062500,650078.125000 +1,saturated,primary,optimized,27,64,607321.984375,606906.250000 +1,saturated,primary,baseline,27,64,636210.015625,636234.375000 +1,saturated,primary,baseline,28,64,549395.421875,549437.500000 +1,saturated,primary,optimized,28,64,569217.671875,569250.000000 +1,saturated,primary,optimized,29,64,635797.296875,635828.125000 +1,saturated,primary,baseline,29,64,612554.578125,612578.125000 +1,saturated,primary,baseline,30,64,1045039.421875,1045093.750000 +1,saturated,primary,optimized,30,64,664484.765625,664531.250000 +1,saturated,secondary,baseline,0,256,263257.152344,263222.656250 +1,saturated,secondary,optimized,0,256,197042.324219,197050.781250 +1,saturated,secondary,optimized,1,256,264370.851562,264335.937500 +1,saturated,secondary,baseline,1,256,248689.148438,248699.218750 +1,saturated,secondary,baseline,2,256,218205.542969,218214.843750 +1,saturated,secondary,optimized,2,256,243656.625000,243371.093750 +1,saturated,secondary,optimized,3,256,218792.660156,218804.687500 +1,saturated,secondary,baseline,3,256,239606.988281,239621.093750 +1,saturated,secondary,baseline,4,256,220409.386719,220417.968750 +1,saturated,secondary,optimized,4,256,303496.433594,303507.812500 +1,saturated,secondary,optimized,5,256,232710.691406,232722.656250 +1,saturated,secondary,baseline,5,256,234526.425781,234539.062500 +1,saturated,secondary,baseline,6,256,249213.039062,249226.562500 +1,saturated,secondary,optimized,6,256,272381.328125,272398.437500 +1,saturated,secondary,optimized,7,256,226337.734375,226351.562500 +1,saturated,secondary,baseline,7,256,246130.554688,246144.531250 +1,saturated,secondary,baseline,8,256,243349.429688,243253.906250 +1,saturated,secondary,optimized,8,256,217732.839844,217742.187500 +1,saturated,secondary,optimized,9,256,230345.058594,230359.375000 +1,saturated,secondary,baseline,9,256,219306.050781,219273.437500 +1,saturated,secondary,baseline,10,256,248529.949219,248542.968750 +1,saturated,secondary,optimized,10,256,226537.914062,226492.187500 +1,saturated,secondary,optimized,11,256,222764.285156,222777.343750 +1,saturated,secondary,baseline,11,256,238187.632812,238199.218750 +1,saturated,secondary,baseline,12,256,226348.171875,226359.375000 +1,saturated,secondary,optimized,12,256,298885.808594,298902.343750 +1,saturated,secondary,optimized,13,256,272084.328125,272093.750000 +1,saturated,secondary,baseline,13,256,217550.667969,217566.406250 +1,saturated,secondary,baseline,14,256,245294.574219,245308.593750 +1,saturated,secondary,optimized,14,256,235313.949219,235324.218750 +1,saturated,secondary,optimized,15,256,236600.863281,236613.281250 +1,saturated,secondary,baseline,15,256,242677.437500,242691.406250 +1,saturated,secondary,baseline,16,256,220133.875000,220148.437500 +1,saturated,secondary,optimized,16,256,265843.695312,265812.500000 +1,saturated,secondary,optimized,17,256,224552.339844,224574.218750 +1,saturated,secondary,baseline,17,256,218975.125000,218792.968750 +1,saturated,secondary,baseline,18,256,242048.894531,242062.500000 +1,saturated,secondary,optimized,18,256,239811.558594,239820.312500 +1,saturated,secondary,optimized,19,256,234997.804688,234937.500000 +1,saturated,secondary,baseline,19,256,215964.671875,215882.812500 +1,saturated,secondary,baseline,20,256,234988.054688,235003.906250 +1,saturated,secondary,optimized,20,256,266765.003906,266777.343750 +1,saturated,secondary,optimized,21,256,277410.679688,277425.781250 +1,saturated,secondary,baseline,21,256,220810.167969,220757.812500 +1,saturated,secondary,baseline,22,256,247864.847656,247886.718750 +1,saturated,secondary,optimized,22,256,232731.132812,232750.000000 +1,saturated,secondary,optimized,23,256,263633.121094,263652.343750 +1,saturated,secondary,baseline,23,256,232477.207031,232492.187500 +1,saturated,secondary,baseline,24,256,231029.972656,231046.875000 +1,saturated,secondary,optimized,24,256,262749.382812,262656.250000 +1,saturated,secondary,optimized,25,256,217689.519531,217703.125000 +1,saturated,secondary,baseline,25,256,226175.535156,226187.500000 +1,saturated,secondary,baseline,26,256,218825.441406,218792.968750 +1,saturated,secondary,optimized,26,256,298448.886719,298460.937500 +1,saturated,secondary,optimized,27,256,258643.988281,258906.250000 +1,saturated,secondary,baseline,27,256,242879.363281,242343.750000 +1,saturated,secondary,baseline,28,256,230219.187500,230230.468750 +1,saturated,secondary,optimized,28,256,273898.179688,273910.156250 +1,saturated,secondary,optimized,29,256,294365.132812,294359.375000 +1,saturated,secondary,baseline,29,256,221505.019531,221519.531250 +1,saturated,secondary,baseline,30,256,258143.171875,258156.250000 +1,saturated,secondary,optimized,30,256,225916.421875,225929.687500 +1,saturated,settings-blocked,baseline,0,256,240310.792969,240316.406250 +1,saturated,settings-blocked,optimized,0,128,216023.078125,216039.062500 +1,saturated,settings-blocked,optimized,1,128,226132.507812,226156.250000 +1,saturated,settings-blocked,baseline,1,256,233781.953125,233746.093750 +1,saturated,settings-blocked,baseline,2,256,222458.136719,222464.843750 +1,saturated,settings-blocked,optimized,2,128,228584.929688,228609.375000 +1,saturated,settings-blocked,optimized,3,128,217293.054688,217179.687500 +1,saturated,settings-blocked,baseline,3,256,229798.417969,229808.593750 +1,saturated,settings-blocked,baseline,4,256,219890.203125,219898.437500 +1,saturated,settings-blocked,optimized,4,128,234876.515625,234898.437500 +1,saturated,settings-blocked,optimized,5,128,341846.609375,341859.375000 +1,saturated,settings-blocked,baseline,5,256,248284.769531,248292.968750 +1,saturated,settings-blocked,baseline,6,256,224694.453125,224703.125000 +1,saturated,settings-blocked,optimized,6,128,239799.945312,239820.312500 +1,saturated,settings-blocked,optimized,7,128,248335.515625,248351.562500 +1,saturated,settings-blocked,baseline,7,256,225118.464844,225128.906250 +1,saturated,settings-blocked,baseline,8,256,249400.453125,249410.156250 +1,saturated,settings-blocked,optimized,8,128,202863.414062,202882.812500 +1,saturated,settings-blocked,optimized,9,128,237626.281250,237640.625000 +1,saturated,settings-blocked,baseline,9,256,231955.656250,231824.218750 +1,saturated,settings-blocked,baseline,10,256,246491.863281,246500.000000 +1,saturated,settings-blocked,optimized,10,128,231590.500000,231609.375000 +1,saturated,settings-blocked,optimized,11,128,209312.914062,209335.937500 +1,saturated,settings-blocked,baseline,11,256,241229.125000,241242.187500 +1,saturated,settings-blocked,baseline,12,256,240835.121094,240843.750000 +1,saturated,settings-blocked,optimized,12,128,248124.421875,248054.687500 +1,saturated,settings-blocked,optimized,13,128,227933.148438,227945.312500 +1,saturated,settings-blocked,baseline,13,256,255141.898438,255152.343750 +1,saturated,settings-blocked,baseline,14,256,281019.851562,280964.843750 +1,saturated,settings-blocked,optimized,14,128,258822.281250,258257.812500 +1,saturated,settings-blocked,optimized,15,128,317721.046875,317750.000000 +1,saturated,settings-blocked,baseline,15,256,299715.101562,299726.562500 +1,saturated,settings-blocked,baseline,16,256,264249.812500,264261.718750 +1,saturated,settings-blocked,optimized,16,128,221668.093750,221695.312500 +1,saturated,settings-blocked,optimized,17,128,218981.320312,219000.000000 +1,saturated,settings-blocked,baseline,17,256,257461.652344,257476.562500 +1,saturated,settings-blocked,baseline,18,256,249278.914062,249296.875000 +1,saturated,settings-blocked,optimized,18,128,242589.468750,242617.187500 +1,saturated,settings-blocked,optimized,19,128,230790.421875,230812.500000 +1,saturated,settings-blocked,baseline,19,256,260526.167969,260542.968750 +1,saturated,settings-blocked,baseline,20,256,241288.832031,241261.718750 +1,saturated,settings-blocked,optimized,20,128,240338.132812,240367.187500 +1,saturated,settings-blocked,optimized,21,128,227019.515625,227039.062500 +1,saturated,settings-blocked,baseline,21,256,219612.777344,219621.093750 +1,saturated,settings-blocked,baseline,22,256,226018.218750,226031.250000 +1,saturated,settings-blocked,optimized,22,128,246528.500000,246554.687500 +1,saturated,settings-blocked,optimized,23,128,237088.171875,237101.562500 +1,saturated,settings-blocked,baseline,23,256,221225.992188,221191.406250 +1,saturated,settings-blocked,baseline,24,256,243274.324219,243289.062500 +1,saturated,settings-blocked,optimized,24,128,187262.484375,187281.250000 +1,saturated,settings-blocked,optimized,25,128,245506.000000,245390.625000 +1,saturated,settings-blocked,baseline,25,256,245535.640625,245546.875000 +1,saturated,settings-blocked,baseline,26,256,274327.589844,274343.750000 +1,saturated,settings-blocked,optimized,26,128,278261.843750,278304.687500 +1,saturated,settings-blocked,optimized,27,128,234789.546875,234820.312500 +1,saturated,settings-blocked,baseline,27,256,230433.179688,230445.312500 +1,saturated,settings-blocked,baseline,28,256,244586.843750,244601.562500 +1,saturated,settings-blocked,optimized,28,128,230654.257812,230679.687500 +1,saturated,settings-blocked,optimized,29,128,212710.515625,212734.375000 +1,saturated,settings-blocked,baseline,29,256,257005.558594,257023.437500 +1,saturated,settings-blocked,baseline,30,256,218127.757812,218136.718750 +1,saturated,settings-blocked,optimized,30,128,273013.351562,273031.250000 +1,saturated,session-blocked,baseline,0,64,610808.078125,610859.375000 +1,saturated,session-blocked,optimized,0,128,370006.414062,369937.500000 +1,saturated,session-blocked,optimized,1,128,401193.164062,401218.750000 +1,saturated,session-blocked,baseline,1,64,539694.671875,539750.000000 +1,saturated,session-blocked,baseline,2,64,1103277.515625,1103390.625000 +1,saturated,session-blocked,optimized,2,128,609212.179688,609335.937500 +1,saturated,session-blocked,optimized,3,128,783879.921875,783914.062500 +1,saturated,session-blocked,baseline,3,64,732457.109375,732515.625000 +1,saturated,session-blocked,baseline,4,64,961680.687500,961718.750000 +1,saturated,session-blocked,optimized,4,128,402212.046875,402234.375000 +1,saturated,session-blocked,optimized,5,128,556395.164062,556421.875000 +1,saturated,session-blocked,baseline,5,64,580856.171875,580921.875000 +1,saturated,session-blocked,baseline,6,64,832103.796875,832171.875000 +1,saturated,session-blocked,optimized,6,128,418188.539062,417593.750000 +1,saturated,session-blocked,optimized,7,128,407141.859375,407171.875000 +1,saturated,session-blocked,baseline,7,64,568366.687500,568421.875000 +1,saturated,session-blocked,baseline,8,64,643690.765625,643734.375000 +1,saturated,session-blocked,optimized,8,128,420889.195312,420914.062500 +1,saturated,session-blocked,optimized,9,128,1652633.093750,1652562.500000 +1,saturated,session-blocked,baseline,9,64,657747.812500,658437.500000 +1,saturated,session-blocked,baseline,10,64,612765.203125,612828.125000 +1,saturated,session-blocked,optimized,10,128,331220.460938,331250.000000 +1,saturated,session-blocked,optimized,11,128,665659.867188,665695.312500 +1,saturated,session-blocked,baseline,11,64,691742.046875,691828.125000 +1,saturated,session-blocked,baseline,12,64,613036.343750,613093.750000 +1,saturated,session-blocked,optimized,12,128,988472.945312,988390.625000 +1,saturated,session-blocked,optimized,13,128,452757.093750,452789.062500 +1,saturated,session-blocked,baseline,13,64,648214.828125,648281.250000 +1,saturated,session-blocked,baseline,14,64,654715.765625,654359.375000 +1,saturated,session-blocked,optimized,14,128,394183.070312,394226.562500 +1,saturated,session-blocked,optimized,15,128,341546.429688,341578.125000 +1,saturated,session-blocked,baseline,15,64,739329.156250,739375.000000 +1,saturated,session-blocked,baseline,16,64,587280.328125,587328.125000 +1,saturated,session-blocked,optimized,16,128,447540.007812,447562.500000 +1,saturated,session-blocked,optimized,17,128,327607.625000,327632.812500 +1,saturated,session-blocked,baseline,17,64,582974.734375,583015.625000 +1,saturated,session-blocked,baseline,18,64,579598.218750,579640.625000 +1,saturated,session-blocked,optimized,18,128,467952.101562,467882.812500 +1,saturated,session-blocked,optimized,19,128,374142.054688,374164.062500 +1,saturated,session-blocked,baseline,19,64,597419.671875,597468.750000 +1,saturated,session-blocked,baseline,20,64,772078.625000,759671.875000 +1,saturated,session-blocked,optimized,20,128,369948.703125,369976.562500 +1,saturated,session-blocked,optimized,21,128,586318.390625,586343.750000 +1,saturated,session-blocked,baseline,21,64,566998.500000,567046.875000 +1,saturated,session-blocked,baseline,22,64,690118.703125,690156.250000 +1,saturated,session-blocked,optimized,22,128,332368.820312,332390.625000 +1,saturated,session-blocked,optimized,23,128,394100.742188,394132.812500 +1,saturated,session-blocked,baseline,23,64,1050482.859375,1050515.625000 +1,saturated,session-blocked,baseline,24,64,623332.656250,623375.000000 +1,saturated,session-blocked,optimized,24,128,494828.054688,494750.000000 +1,saturated,session-blocked,optimized,25,128,433928.523438,433953.125000 +1,saturated,session-blocked,baseline,25,64,1095372.109375,1095437.500000 +1,saturated,session-blocked,baseline,26,64,742134.187500,742187.500000 +1,saturated,session-blocked,optimized,26,128,456130.851562,456156.250000 +1,saturated,session-blocked,optimized,27,128,347099.835938,347117.187500 +1,saturated,session-blocked,baseline,27,64,580899.296875,580953.125000 +1,saturated,session-blocked,baseline,28,64,526635.828125,526687.500000 +1,saturated,session-blocked,optimized,28,128,453944.625000,453968.750000 +1,saturated,session-blocked,optimized,29,128,366675.523438,366601.562500 +1,saturated,session-blocked,baseline,29,64,611230.734375,611281.250000 +1,saturated,session-blocked,baseline,30,64,638742.515625,638500.000000 +1,saturated,session-blocked,optimized,30,128,351875.445312,347226.562500 +1,saturated,both-blocked,baseline,0,64,263049.984375,263093.750000 +1,saturated,both-blocked,optimized,0,8388608,4.790254,4.790425 +1,saturated,both-blocked,optimized,1,8388608,4.609693,4.609942 +1,saturated,both-blocked,baseline,1,64,223334.218750,223375.000000 +1,saturated,both-blocked,baseline,2,64,399486.781250,399546.875000 +1,saturated,both-blocked,optimized,2,8388608,4.332686,4.333019 +1,saturated,both-blocked,optimized,3,8388608,5.213019,5.213380 +1,saturated,both-blocked,baseline,3,64,241206.375000,241250.000000 +1,saturated,both-blocked,baseline,4,64,217142.453125,216578.125000 +1,saturated,both-blocked,optimized,4,8388608,4.900841,4.901052 +1,saturated,both-blocked,optimized,5,8388608,3.687842,3.688216 +1,saturated,both-blocked,baseline,5,64,222087.890625,222125.000000 +1,saturated,both-blocked,baseline,6,64,217118.718750,217156.250000 +1,saturated,both-blocked,optimized,6,8388608,5.262912,5.263209 +1,saturated,both-blocked,optimized,7,8388608,4.226389,4.226685 +1,saturated,both-blocked,baseline,7,64,218317.406250,218359.375000 +1,saturated,both-blocked,baseline,8,64,287392.031250,287421.875000 +1,saturated,both-blocked,optimized,8,8388608,4.060331,4.060507 +1,saturated,both-blocked,optimized,9,8388608,4.102291,4.102588 +1,saturated,both-blocked,baseline,9,64,229231.500000,229265.625000 +1,saturated,both-blocked,baseline,10,64,223822.937500,223859.375000 +1,saturated,both-blocked,optimized,10,8388608,3.909548,3.909826 +1,saturated,both-blocked,optimized,11,8388608,5.630861,5.631208 +1,saturated,both-blocked,baseline,11,64,219662.609375,219687.500000 +1,saturated,both-blocked,baseline,12,64,259581.765625,259640.625000 +1,saturated,both-blocked,optimized,12,8388608,4.079793,4.078627 +1,saturated,both-blocked,optimized,13,8388608,4.460674,4.461050 +1,saturated,both-blocked,baseline,13,64,226302.703125,226359.375000 +1,saturated,both-blocked,baseline,14,64,238484.359375,238531.250000 +1,saturated,both-blocked,optimized,14,8388608,4.232290,4.230142 +1,saturated,both-blocked,optimized,15,8388608,5.552624,5.552888 +1,saturated,both-blocked,baseline,15,64,529612.687500,529656.250000 +1,saturated,both-blocked,baseline,16,64,200665.750000,200703.125000 +1,saturated,both-blocked,optimized,16,8388608,4.179016,4.179239 +1,saturated,both-blocked,optimized,17,8388608,4.653861,4.654169 +1,saturated,both-blocked,baseline,17,64,211879.609375,211921.875000 +1,saturated,both-blocked,baseline,18,64,197682.109375,197703.125000 +1,saturated,both-blocked,optimized,18,8388608,4.179415,4.179716 +1,saturated,both-blocked,optimized,19,8388608,4.762186,4.762530 +1,saturated,both-blocked,baseline,19,64,209483.078125,209515.625000 +1,saturated,both-blocked,baseline,20,64,198875.859375,198906.250000 +1,saturated,both-blocked,optimized,20,8388608,5.263150,5.263448 +1,saturated,both-blocked,optimized,21,8388608,4.627860,4.628062 +1,saturated,both-blocked,baseline,21,64,194885.406250,194921.875000 +1,saturated,both-blocked,baseline,22,64,359344.312500,359390.625000 +1,saturated,both-blocked,optimized,22,8388608,5.454443,5.453348 +1,saturated,both-blocked,optimized,23,8388608,4.646258,4.646540 +1,saturated,both-blocked,baseline,23,64,212254.328125,212281.250000 +1,saturated,both-blocked,baseline,24,64,267231.015625,267265.625000 +1,saturated,both-blocked,optimized,24,8388608,5.465772,5.466104 +1,saturated,both-blocked,optimized,25,8388608,4.840357,4.840732 +1,saturated,both-blocked,baseline,25,64,216891.500000,216921.875000 +1,saturated,both-blocked,baseline,26,64,224928.421875,224968.750000 +1,saturated,both-blocked,optimized,26,8388608,4.701779,4.702091 +1,saturated,both-blocked,optimized,27,8388608,4.037845,4.038215 +1,saturated,both-blocked,baseline,27,64,229013.359375,229046.875000 +1,saturated,both-blocked,baseline,28,64,260456.406250,260515.625000 +1,saturated,both-blocked,optimized,28,8388608,4.150208,4.150510 +1,saturated,both-blocked,optimized,29,8388608,4.289627,4.289865 +1,saturated,both-blocked,baseline,29,64,352323.109375,352359.375000 +1,saturated,both-blocked,baseline,30,64,242988.203125,243031.250000 +1,saturated,both-blocked,optimized,30,8388608,4.037672,4.037857 +2,defaults,primary,baseline,0,4096,9346.461426,9347.167969 +2,defaults,primary,optimized,0,4096,7598.449707,7598.876953 +2,defaults,primary,optimized,1,4096,12402.269287,12403.076172 +2,defaults,primary,baseline,1,4096,8934.197510,8935.058594 +2,defaults,primary,baseline,2,4096,11333.595703,11334.716797 +2,defaults,primary,optimized,2,4096,8837.610107,8838.623047 +2,defaults,primary,optimized,3,4096,8163.233887,8164.062500 +2,defaults,primary,baseline,3,4096,8615.136719,8599.121094 +2,defaults,primary,baseline,4,4096,9458.276855,9458.984375 +2,defaults,primary,optimized,4,4096,8440.981201,8441.650391 +2,defaults,primary,optimized,5,4096,8874.886230,8875.488281 +2,defaults,primary,baseline,5,4096,10403.290039,10404.052734 +2,defaults,primary,baseline,6,4096,8569.636230,8570.556641 +2,defaults,primary,optimized,6,4096,9105.382568,9105.957031 +2,defaults,primary,optimized,7,4096,7445.600342,7446.044922 +2,defaults,primary,baseline,7,4096,7755.029053,7755.615234 +2,defaults,primary,baseline,8,4096,9321.353027,9321.777344 +2,defaults,primary,optimized,8,4096,7851.316650,7852.050781 +2,defaults,primary,optimized,9,4096,10990.903076,10991.455078 +2,defaults,primary,baseline,9,4096,8626.206543,8626.708984 +2,defaults,primary,baseline,10,4096,8042.749023,8043.457031 +2,defaults,primary,optimized,10,4096,8853.678955,8851.318359 +2,defaults,primary,optimized,11,4096,8409.641357,8410.400391 +2,defaults,primary,baseline,11,4096,7862.271729,7863.037109 +2,defaults,primary,baseline,12,4096,11356.318359,11352.783203 +2,defaults,primary,optimized,12,4096,11672.694580,11673.339844 +2,defaults,primary,optimized,13,4096,8703.988525,8704.833984 +2,defaults,primary,baseline,13,4096,8859.519043,8860.351562 +2,defaults,primary,baseline,14,4096,7237.946777,7238.769531 +2,defaults,primary,optimized,14,4096,8480.804199,8481.445312 +2,defaults,primary,optimized,15,4096,9583.683838,9584.472656 +2,defaults,primary,baseline,15,4096,7928.953125,7929.443359 +2,defaults,primary,baseline,16,4096,10765.336426,10765.869141 +2,defaults,primary,optimized,16,4096,7770.467773,7770.996094 +2,defaults,primary,optimized,17,4096,8306.614258,8307.128906 +2,defaults,primary,baseline,17,4096,9151.923340,9149.658203 +2,defaults,primary,baseline,18,4096,9068.524658,9069.091797 +2,defaults,primary,optimized,18,4096,8683.848633,8684.326172 +2,defaults,primary,optimized,19,4096,8896.198486,8896.728516 +2,defaults,primary,baseline,19,4096,10056.817627,10057.373047 +2,defaults,primary,baseline,20,4096,8913.912109,8914.550781 +2,defaults,primary,optimized,20,4096,8532.475342,8532.958984 +2,defaults,primary,optimized,21,4096,7495.258057,7495.849609 +2,defaults,primary,baseline,21,4096,8361.982178,8362.548828 +2,defaults,primary,baseline,22,4096,8486.059326,8486.816406 +2,defaults,primary,optimized,22,4096,8640.248535,8640.869141 +2,defaults,primary,optimized,23,4096,10511.275635,10512.207031 +2,defaults,primary,baseline,23,4096,7713.845459,7714.355469 +2,defaults,primary,baseline,24,4096,8375.154541,8375.732422 +2,defaults,primary,optimized,24,4096,9083.864258,9081.542969 +2,defaults,primary,optimized,25,4096,8290.084717,8290.771484 +2,defaults,primary,baseline,25,4096,13300.299316,13297.363281 +2,defaults,primary,baseline,26,4096,13467.717285,13461.425781 +2,defaults,primary,optimized,26,4096,10470.320068,10470.947266 +2,defaults,primary,optimized,27,4096,8582.525146,8583.251953 +2,defaults,primary,baseline,27,4096,8058.484131,8059.082031 +2,defaults,primary,baseline,28,4096,8497.259033,8497.802734 +2,defaults,primary,optimized,28,4096,9698.683105,9687.988281 +2,defaults,primary,optimized,29,4096,11724.945312,11725.341797 +2,defaults,primary,baseline,29,4096,8554.157715,8554.687500 +2,defaults,primary,baseline,30,4096,8496.010498,8496.582031 +2,defaults,primary,optimized,30,4096,7908.079590,7900.146484 +2,defaults,secondary,baseline,0,131072,297.377975,297.401428 +2,defaults,secondary,optimized,0,131072,356.270309,356.285095 +2,defaults,secondary,optimized,1,131072,383.108948,383.132935 +2,defaults,secondary,baseline,1,131072,304.519104,304.534912 +2,defaults,secondary,baseline,2,131072,345.478439,345.497131 +2,defaults,secondary,optimized,2,131072,324.290710,324.211121 +2,defaults,secondary,optimized,3,131072,301.689491,301.712036 +2,defaults,secondary,baseline,3,131072,450.239510,450.141907 +2,defaults,secondary,baseline,4,131072,403.497757,382.766724 +2,defaults,secondary,optimized,4,131072,351.790703,351.814270 +2,defaults,secondary,optimized,5,131072,277.661430,277.687073 +2,defaults,secondary,baseline,5,131072,322.094620,322.120667 +2,defaults,secondary,baseline,6,131072,354.062263,354.087830 +2,defaults,secondary,optimized,6,131072,375.797035,375.823975 +2,defaults,secondary,optimized,7,131072,306.436890,306.465149 +2,defaults,secondary,baseline,7,131072,316.080528,315.887451 +2,defaults,secondary,baseline,8,131072,345.816177,345.840454 +2,defaults,secondary,optimized,8,131072,318.658363,318.687439 +2,defaults,secondary,optimized,9,131072,316.237984,316.261292 +2,defaults,secondary,baseline,9,131072,400.385651,400.413513 +2,defaults,secondary,baseline,10,131072,322.831192,322.860718 +2,defaults,secondary,optimized,10,131072,302.186531,302.215576 +2,defaults,secondary,optimized,11,131072,272.099739,272.125244 +2,defaults,secondary,baseline,11,131072,306.822731,306.846619 +2,defaults,secondary,baseline,12,131072,334.704803,334.732056 +2,defaults,secondary,optimized,12,131072,389.373367,389.396667 +2,defaults,secondary,optimized,13,131072,294.404938,294.425964 +2,defaults,secondary,baseline,13,131072,304.239563,304.260254 +2,defaults,secondary,baseline,14,131072,312.683701,312.622070 +2,defaults,secondary,optimized,14,131072,306.015472,306.037903 +2,defaults,secondary,optimized,15,131072,472.695679,472.579956 +2,defaults,secondary,baseline,15,131072,355.560463,355.590820 +2,defaults,secondary,baseline,16,131072,367.342293,367.370605 +2,defaults,secondary,optimized,16,131072,298.043823,298.072815 +2,defaults,secondary,optimized,17,131072,326.009789,326.042175 +2,defaults,secondary,baseline,17,131072,307.765923,307.792664 +2,defaults,secondary,baseline,18,131072,378.609039,378.639221 +2,defaults,secondary,optimized,18,131072,304.621254,304.641724 +2,defaults,secondary,optimized,19,131072,374.033058,373.954773 +2,defaults,secondary,baseline,19,131072,381.432556,381.454468 +2,defaults,secondary,baseline,20,131072,307.111343,307.136536 +2,defaults,secondary,optimized,20,131072,347.598503,347.633362 +2,defaults,secondary,optimized,21,131072,377.518959,377.540588 +2,defaults,secondary,baseline,21,131072,366.030807,366.058350 +2,defaults,secondary,baseline,22,131072,292.730980,292.755127 +2,defaults,secondary,optimized,22,131072,351.389542,351.417542 +2,defaults,secondary,optimized,23,131072,330.627983,330.657959 +2,defaults,secondary,baseline,23,131072,466.585533,466.606140 +2,defaults,secondary,baseline,24,131072,303.906242,303.871155 +2,defaults,secondary,optimized,24,131072,308.616234,308.639526 +2,defaults,secondary,optimized,25,131072,313.973816,313.995361 +2,defaults,secondary,baseline,25,131072,304.150360,304.092407 +2,defaults,secondary,baseline,26,131072,437.215294,437.255859 +2,defaults,secondary,optimized,26,131072,385.432350,385.330200 +2,defaults,secondary,optimized,27,131072,343.885353,343.917847 +2,defaults,secondary,baseline,27,131072,291.450867,291.473389 +2,defaults,secondary,baseline,28,131072,365.944763,365.974426 +2,defaults,secondary,optimized,28,131072,326.163651,326.194763 +2,defaults,secondary,optimized,29,131072,433.152313,433.181763 +2,defaults,secondary,baseline,29,131072,317.946274,317.977905 +2,defaults,secondary,baseline,30,131072,354.493019,352.767944 +2,defaults,secondary,optimized,30,131072,374.119171,374.145508 +2,defaults,settings-blocked,baseline,0,131072,332.558105,332.580566 +2,defaults,settings-blocked,optimized,0,131072,411.479454,411.506653 +2,defaults,settings-blocked,optimized,1,131072,313.009064,313.034058 +2,defaults,settings-blocked,baseline,1,131072,314.090439,314.109802 +2,defaults,settings-blocked,baseline,2,131072,308.494461,308.517456 +2,defaults,settings-blocked,optimized,2,131072,296.509186,296.524048 +2,defaults,settings-blocked,optimized,3,131072,390.876755,390.808105 +2,defaults,settings-blocked,baseline,3,131072,447.670364,447.601318 +2,defaults,settings-blocked,baseline,4,131072,365.617180,365.646362 +2,defaults,settings-blocked,optimized,4,131072,307.636848,307.662964 +2,defaults,settings-blocked,optimized,5,131072,331.875732,331.901550 +2,defaults,settings-blocked,baseline,5,131072,299.895935,299.919128 +2,defaults,settings-blocked,baseline,6,131072,370.966492,371.002197 +2,defaults,settings-blocked,optimized,6,131072,336.811508,336.837769 +2,defaults,settings-blocked,optimized,7,131072,371.569672,371.513367 +2,defaults,settings-blocked,baseline,7,131072,493.138832,493.156433 +2,defaults,settings-blocked,baseline,8,131072,316.676224,316.696167 +2,defaults,settings-blocked,optimized,8,131072,446.139229,446.159363 +2,defaults,settings-blocked,optimized,9,131072,362.479362,362.503052 +2,defaults,settings-blocked,baseline,9,131072,323.079506,323.104858 +2,defaults,settings-blocked,baseline,10,131072,277.704681,277.725220 +2,defaults,settings-blocked,optimized,10,131072,326.514786,326.538086 +2,defaults,settings-blocked,optimized,11,131072,441.013878,441.047668 +2,defaults,settings-blocked,baseline,11,131072,432.239548,432.258606 +2,defaults,settings-blocked,baseline,12,131072,353.949783,353.981018 +2,defaults,settings-blocked,optimized,12,131072,350.267136,350.288391 +2,defaults,settings-blocked,optimized,13,131072,297.690147,297.714233 +2,defaults,settings-blocked,baseline,13,131072,385.534088,385.475159 +2,defaults,settings-blocked,baseline,14,131072,451.116257,452.331543 +2,defaults,settings-blocked,optimized,14,131072,378.652229,378.532410 +2,defaults,settings-blocked,optimized,15,131072,281.788101,281.806946 +2,defaults,settings-blocked,baseline,15,131072,331.823898,331.848145 +2,defaults,settings-blocked,baseline,16,131072,307.185493,307.205200 +2,defaults,settings-blocked,optimized,16,131072,323.475189,323.493958 +2,defaults,settings-blocked,optimized,17,131072,387.523918,387.542725 +2,defaults,settings-blocked,baseline,17,131072,317.711990,317.543030 +2,defaults,settings-blocked,baseline,18,131072,335.366951,335.388184 +2,defaults,settings-blocked,optimized,18,131072,321.831841,321.853638 +2,defaults,settings-blocked,optimized,19,131072,437.933548,437.957764 +2,defaults,settings-blocked,baseline,19,131072,373.672218,373.695374 +2,defaults,settings-blocked,baseline,20,131072,353.004318,353.027344 +2,defaults,settings-blocked,optimized,20,131072,295.815613,295.845032 +2,defaults,settings-blocked,optimized,21,131072,282.468018,282.501221 +2,defaults,settings-blocked,baseline,21,131072,318.937057,318.962097 +2,defaults,settings-blocked,baseline,22,131072,353.404114,353.431702 +2,defaults,settings-blocked,optimized,22,131072,389.032326,389.060974 +2,defaults,settings-blocked,optimized,23,131072,321.025711,321.060181 +2,defaults,settings-blocked,baseline,23,131072,308.905205,308.929443 +2,defaults,settings-blocked,baseline,24,131072,312.394455,312.423706 +2,defaults,settings-blocked,optimized,24,131072,298.312485,298.347473 +2,defaults,settings-blocked,optimized,25,131072,537.689995,537.635803 +2,defaults,settings-blocked,baseline,25,131072,341.014565,341.041565 +2,defaults,settings-blocked,baseline,26,131072,329.431198,329.048157 +2,defaults,settings-blocked,optimized,26,131072,335.969574,336.006165 +2,defaults,settings-blocked,optimized,27,131072,357.549561,357.582092 +2,defaults,settings-blocked,baseline,27,131072,315.190521,315.216064 +2,defaults,settings-blocked,baseline,28,131072,383.738258,383.766174 +2,defaults,settings-blocked,optimized,28,131072,400.940895,400.672913 +2,defaults,settings-blocked,optimized,29,131072,352.080147,352.111816 +2,defaults,settings-blocked,baseline,29,131072,331.446899,331.024170 +2,defaults,settings-blocked,baseline,30,131072,312.221008,312.255859 +2,defaults,settings-blocked,optimized,30,131072,382.819069,382.843018 +2,defaults,session-blocked,baseline,0,4096,10012.884277,10013.427734 +2,defaults,session-blocked,optimized,0,4096,8440.950439,8441.650391 +2,defaults,session-blocked,optimized,1,4096,8340.071289,8341.308594 +2,defaults,session-blocked,baseline,1,4096,10960.635986,10961.181641 +2,defaults,session-blocked,baseline,2,4096,7966.625244,7967.285156 +2,defaults,session-blocked,optimized,2,4096,8060.291992,8060.791016 +2,defaults,session-blocked,optimized,3,4096,14932.472656,14930.175781 +2,defaults,session-blocked,baseline,3,4096,9181.213379,9181.884766 +2,defaults,session-blocked,baseline,4,4096,10154.402100,10151.123047 +2,defaults,session-blocked,optimized,4,4096,7993.009033,7993.652344 +2,defaults,session-blocked,optimized,5,4096,8379.614014,8380.371094 +2,defaults,session-blocked,baseline,5,4096,8423.236816,8424.072266 +2,defaults,session-blocked,baseline,6,4096,8058.421875,8059.082031 +2,defaults,session-blocked,optimized,6,4096,9681.165039,9681.640625 +2,defaults,session-blocked,optimized,7,4096,8299.825928,8300.781250 +2,defaults,session-blocked,baseline,7,4096,8816.444092,8813.964844 +2,defaults,session-blocked,baseline,8,4096,13339.146729,13339.843750 +2,defaults,session-blocked,optimized,8,4096,8389.476318,8390.380859 +2,defaults,session-blocked,optimized,9,4096,13433.339844,13434.082031 +2,defaults,session-blocked,baseline,9,4096,10926.902588,10927.734375 +2,defaults,session-blocked,baseline,10,4096,10355.211914,10355.957031 +2,defaults,session-blocked,optimized,10,4096,8160.271729,8160.888672 +2,defaults,session-blocked,optimized,11,4096,7625.475342,7626.220703 +2,defaults,session-blocked,baseline,11,4096,8120.048584,8120.605469 +2,defaults,session-blocked,baseline,12,4096,9088.104736,9088.867188 +2,defaults,session-blocked,optimized,12,4096,9017.158447,9017.822266 +2,defaults,session-blocked,optimized,13,4096,11424.198975,11424.804688 +2,defaults,session-blocked,baseline,13,4096,8771.144531,8771.728516 +2,defaults,session-blocked,baseline,14,4096,8816.996582,8817.626953 +2,defaults,session-blocked,optimized,14,4096,8188.669678,8189.453125 +2,defaults,session-blocked,optimized,15,4096,7430.245850,7430.664062 +2,defaults,session-blocked,baseline,15,4096,7938.673584,7939.208984 +2,defaults,session-blocked,baseline,16,4096,14893.317627,14891.113281 +2,defaults,session-blocked,optimized,16,4096,8264.864746,8265.380859 +2,defaults,session-blocked,optimized,17,4096,9046.661621,9047.119141 +2,defaults,session-blocked,baseline,17,4096,8136.754639,8133.300781 +2,defaults,session-blocked,baseline,18,4096,12423.928955,12424.560547 +2,defaults,session-blocked,optimized,18,4096,8572.398682,8572.998047 +2,defaults,session-blocked,optimized,19,4096,7513.580078,7514.160156 +2,defaults,session-blocked,baseline,19,4096,10148.714355,10149.414062 +2,defaults,session-blocked,baseline,20,4096,9714.943604,9708.984375 +2,defaults,session-blocked,optimized,20,4096,9074.673584,9075.439453 +2,defaults,session-blocked,optimized,21,4096,14252.421631,14253.173828 +2,defaults,session-blocked,baseline,21,4096,8318.875732,8319.580078 +2,defaults,session-blocked,baseline,22,4096,9265.553223,9266.113281 +2,defaults,session-blocked,optimized,22,4096,9238.987061,9239.746094 +2,defaults,session-blocked,optimized,23,4096,7485.958252,7486.328125 +2,defaults,session-blocked,baseline,23,4096,9339.111328,9339.599609 +2,defaults,session-blocked,baseline,24,4096,8215.802002,8216.552734 +2,defaults,session-blocked,optimized,24,4096,7917.650635,7918.212891 +2,defaults,session-blocked,optimized,25,4096,8496.827637,8497.314453 +2,defaults,session-blocked,baseline,25,4096,8908.250000,8908.935547 +2,defaults,session-blocked,baseline,26,4096,12765.378174,12766.113281 +2,defaults,session-blocked,optimized,26,4096,11287.151855,11287.597656 +2,defaults,session-blocked,optimized,27,4096,8539.081299,8539.794922 +2,defaults,session-blocked,baseline,27,4096,8774.892090,8775.634766 +2,defaults,session-blocked,baseline,28,4096,7782.107178,7782.958984 +2,defaults,session-blocked,optimized,28,4096,7766.521240,7767.333984 +2,defaults,session-blocked,optimized,29,4096,14766.028320,14766.601562 +2,defaults,session-blocked,baseline,29,4096,8490.652832,8488.281250 +2,defaults,session-blocked,baseline,30,4096,8987.424316,8988.037109 +2,defaults,session-blocked,optimized,30,4096,7720.665771,7721.435547 +2,defaults,both-blocked,baseline,0,131072,332.205750,332.229614 +2,defaults,both-blocked,optimized,0,8388608,4.063138,4.063487 +2,defaults,both-blocked,optimized,1,8388608,5.318656,5.318880 +2,defaults,both-blocked,baseline,1,131072,339.921577,339.935303 +2,defaults,both-blocked,baseline,2,131072,342.170052,342.193604 +2,defaults,both-blocked,optimized,2,8388608,4.147918,4.148245 +2,defaults,both-blocked,optimized,3,8388608,4.061786,4.061937 +2,defaults,both-blocked,baseline,3,131072,338.934662,338.951111 +2,defaults,both-blocked,baseline,4,131072,345.474800,345.489502 +2,defaults,both-blocked,optimized,4,8388608,4.897311,4.897714 +2,defaults,both-blocked,optimized,5,8388608,4.260081,4.260421 +2,defaults,both-blocked,baseline,5,131072,305.749474,305.778503 +2,defaults,both-blocked,baseline,6,131072,375.671196,375.694275 +2,defaults,both-blocked,optimized,6,8388608,3.991230,3.991604 +2,defaults,both-blocked,optimized,7,8388608,6.853206,6.853700 +2,defaults,both-blocked,baseline,7,131072,350.062248,350.082397 +2,defaults,both-blocked,baseline,8,131072,347.713829,347.640991 +2,defaults,both-blocked,optimized,8,8388608,4.255289,4.255533 +2,defaults,both-blocked,optimized,9,8388608,4.424324,4.422426 +2,defaults,both-blocked,baseline,9,131072,311.635956,311.653137 +2,defaults,both-blocked,baseline,10,131072,359.064201,359.092712 +2,defaults,both-blocked,optimized,10,8388608,4.515108,4.501820 +2,defaults,both-blocked,optimized,11,8388608,4.235211,4.235506 +2,defaults,both-blocked,baseline,11,131072,329.411331,329.421997 +2,defaults,both-blocked,baseline,12,131072,400.225388,400.238037 +2,defaults,both-blocked,optimized,12,8388608,3.982407,3.982782 +2,defaults,both-blocked,optimized,13,8388608,4.983249,4.983544 +2,defaults,both-blocked,baseline,13,131072,320.408676,320.426941 +2,defaults,both-blocked,baseline,14,131072,341.374603,341.400146 +2,defaults,both-blocked,optimized,14,8388608,3.962121,3.962517 +2,defaults,both-blocked,optimized,15,8388608,4.331396,4.331708 +2,defaults,both-blocked,baseline,15,131072,374.156502,374.176025 +2,defaults,both-blocked,baseline,16,131072,328.676414,328.689575 +2,defaults,both-blocked,optimized,16,8388608,7.141003,7.141232 +2,defaults,both-blocked,optimized,17,8388608,4.370090,4.370451 +2,defaults,both-blocked,baseline,17,131072,358.716148,358.741760 +2,defaults,both-blocked,baseline,18,131072,311.394012,311.408997 +2,defaults,both-blocked,optimized,18,8388608,4.117052,4.143238 +2,defaults,both-blocked,optimized,19,8388608,7.597207,7.597566 +2,defaults,both-blocked,baseline,19,131072,310.285393,310.302734 +2,defaults,both-blocked,baseline,20,131072,347.307747,347.244263 +2,defaults,both-blocked,optimized,20,8388608,4.324081,4.324436 +2,defaults,both-blocked,optimized,21,8388608,4.576226,4.573464 +2,defaults,both-blocked,baseline,21,131072,297.908569,297.882080 +2,defaults,both-blocked,baseline,22,131072,398.075394,398.101807 +2,defaults,both-blocked,optimized,22,8388608,4.463387,4.462481 +2,defaults,both-blocked,optimized,23,8388608,3.986090,3.986359 +2,defaults,both-blocked,baseline,23,131072,349.504982,349.517822 +2,defaults,both-blocked,baseline,24,131072,321.225929,321.250916 +2,defaults,both-blocked,optimized,24,8388608,4.914954,4.915357 +2,defaults,both-blocked,optimized,25,8388608,5.155102,5.155325 +2,defaults,both-blocked,baseline,25,131072,289.983765,290.000916 +2,defaults,both-blocked,baseline,26,131072,328.250938,328.269958 +2,defaults,both-blocked,optimized,26,8388608,4.203888,4.204273 +2,defaults,both-blocked,optimized,27,8388608,4.373541,4.373908 +2,defaults,both-blocked,baseline,27,131072,370.085800,370.101929 +2,defaults,both-blocked,baseline,28,131072,361.344643,361.366272 +2,defaults,both-blocked,optimized,28,8388608,4.459513,4.459739 +2,defaults,both-blocked,optimized,29,8388608,4.235549,4.235864 +2,defaults,both-blocked,baseline,29,131072,350.307571,350.326538 +2,defaults,both-blocked,baseline,30,131072,312.881645,312.911987 +2,defaults,both-blocked,optimized,30,8388608,4.152928,4.153252 +2,saturated,primary,baseline,0,64,682812.765625,684812.500000 +2,saturated,primary,optimized,0,64,591785.937500,591218.750000 +2,saturated,primary,optimized,1,64,608363.765625,608406.250000 +2,saturated,primary,baseline,1,64,555939.343750,555984.375000 +2,saturated,primary,baseline,2,64,671480.328125,671515.625000 +2,saturated,primary,optimized,2,64,573974.843750,574031.250000 +2,saturated,primary,optimized,3,64,674142.531250,674171.875000 +2,saturated,primary,baseline,3,64,627694.718750,627734.375000 +2,saturated,primary,baseline,4,64,602957.062500,603000.000000 +2,saturated,primary,optimized,4,64,588097.921875,588140.625000 +2,saturated,primary,optimized,5,64,562491.656250,562531.250000 +2,saturated,primary,baseline,5,64,652895.515625,652921.875000 +2,saturated,primary,baseline,6,64,625042.046875,625078.125000 +2,saturated,primary,optimized,6,64,731765.578125,731812.500000 +2,saturated,primary,optimized,7,64,617211.968750,617250.000000 +2,saturated,primary,baseline,7,64,556005.609375,556062.500000 +2,saturated,primary,baseline,8,64,621204.531250,621265.625000 +2,saturated,primary,optimized,8,64,639734.875000,639781.250000 +2,saturated,primary,optimized,9,64,788505.890625,788562.500000 +2,saturated,primary,baseline,9,64,852896.687500,852937.500000 +2,saturated,primary,baseline,10,64,615763.312500,615796.875000 +2,saturated,primary,optimized,10,64,606041.562500,606093.750000 +2,saturated,primary,optimized,11,64,597413.078125,597234.375000 +2,saturated,primary,baseline,11,64,638310.437500,638359.375000 +2,saturated,primary,baseline,12,64,626518.359375,626296.875000 +2,saturated,primary,optimized,12,64,691963.906250,691859.375000 +2,saturated,primary,optimized,13,64,607622.984375,607656.250000 +2,saturated,primary,baseline,13,64,534579.593750,534609.375000 +2,saturated,primary,baseline,14,64,653179.953125,653218.750000 +2,saturated,primary,optimized,14,64,722275.140625,722312.500000 +2,saturated,primary,optimized,15,64,686411.546875,686468.750000 +2,saturated,primary,baseline,15,64,692849.078125,692890.625000 +2,saturated,primary,baseline,16,64,562860.187500,562906.250000 +2,saturated,primary,optimized,16,64,699401.734375,699437.500000 +2,saturated,primary,optimized,17,64,572975.062500,573015.625000 +2,saturated,primary,baseline,17,64,666396.359375,666437.500000 +2,saturated,primary,baseline,18,64,629924.093750,629953.125000 +2,saturated,primary,optimized,18,64,702797.296875,702843.750000 +2,saturated,primary,optimized,19,64,588304.609375,588359.375000 +2,saturated,primary,baseline,19,64,568455.906250,568500.000000 +2,saturated,primary,baseline,20,64,590960.890625,591000.000000 +2,saturated,primary,optimized,20,64,608051.671875,608125.000000 +2,saturated,primary,optimized,21,64,1058830.468750,1058890.625000 +2,saturated,primary,baseline,21,64,864852.562500,864906.250000 +2,saturated,primary,baseline,22,64,613989.484375,614046.875000 +2,saturated,primary,optimized,22,64,577815.843750,577875.000000 +2,saturated,primary,optimized,23,64,590363.218750,590234.375000 +2,saturated,primary,baseline,23,64,589359.515625,589406.250000 +2,saturated,primary,baseline,24,64,567590.359375,567640.625000 +2,saturated,primary,optimized,24,64,838368.125000,835593.750000 +2,saturated,primary,optimized,25,64,597095.218750,597125.000000 +2,saturated,primary,baseline,25,64,563808.203125,563875.000000 +2,saturated,primary,baseline,26,64,656441.359375,656484.375000 +2,saturated,primary,optimized,26,64,567855.640625,567890.625000 +2,saturated,primary,optimized,27,64,664288.546875,664312.500000 +2,saturated,primary,baseline,27,64,745573.890625,745609.375000 +2,saturated,primary,baseline,28,64,555795.453125,555890.625000 +2,saturated,primary,optimized,28,64,596300.718750,596343.750000 +2,saturated,primary,optimized,29,64,545650.812500,545687.500000 +2,saturated,primary,baseline,29,64,606242.406250,606296.875000 +2,saturated,primary,baseline,30,64,664215.000000,664265.625000 +2,saturated,primary,optimized,30,64,653997.093750,654031.250000 +2,saturated,secondary,baseline,0,128,247649.523438,247679.687500 +2,saturated,secondary,optimized,0,256,232110.312500,232125.000000 +2,saturated,secondary,optimized,1,256,238117.066406,238093.750000 +2,saturated,secondary,baseline,1,128,227428.640625,227445.312500 +2,saturated,secondary,baseline,2,128,299403.039062,299304.687500 +2,saturated,secondary,optimized,2,256,233453.371094,233187.500000 +2,saturated,secondary,optimized,3,256,234983.878906,235003.906250 +2,saturated,secondary,baseline,3,128,256477.484375,256507.812500 +2,saturated,secondary,baseline,4,128,230745.804688,230773.437500 +2,saturated,secondary,optimized,4,256,300334.632812,300355.468750 +2,saturated,secondary,optimized,5,256,224670.687500,224390.625000 +2,saturated,secondary,baseline,5,128,212288.773438,212320.312500 +2,saturated,secondary,baseline,6,128,212982.507812,213015.625000 +2,saturated,secondary,optimized,6,256,244533.085938,244550.781250 +2,saturated,secondary,optimized,7,256,263924.945312,263941.406250 +2,saturated,secondary,baseline,7,128,240698.289062,240718.750000 +2,saturated,secondary,baseline,8,128,232194.945312,232218.750000 +2,saturated,secondary,optimized,8,256,223208.187500,223222.656250 +2,saturated,secondary,optimized,9,256,208891.585938,208902.343750 +2,saturated,secondary,baseline,9,128,227927.687500,227953.125000 +2,saturated,secondary,baseline,10,128,448053.375000,447976.562500 +2,saturated,secondary,optimized,10,256,242656.738281,242671.875000 +2,saturated,secondary,optimized,11,256,237194.265625,237207.031250 +2,saturated,secondary,baseline,11,128,256281.492188,256226.562500 +2,saturated,secondary,baseline,12,128,234508.601562,234531.250000 +2,saturated,secondary,optimized,12,256,279194.761719,279128.906250 +2,saturated,secondary,optimized,13,256,240202.121094,240035.156250 +2,saturated,secondary,baseline,13,128,216321.468750,216343.750000 +2,saturated,secondary,baseline,14,128,249132.328125,249164.062500 +2,saturated,secondary,optimized,14,256,224545.519531,224562.500000 +2,saturated,secondary,optimized,15,256,277801.187500,277816.406250 +2,saturated,secondary,baseline,15,128,203879.468750,203898.437500 +2,saturated,secondary,baseline,16,128,230088.570312,230117.187500 +2,saturated,secondary,optimized,16,256,200416.828125,200425.781250 +2,saturated,secondary,optimized,17,256,232769.363281,232781.250000 +2,saturated,secondary,baseline,17,128,232570.992188,232601.562500 +2,saturated,secondary,baseline,18,128,246199.273438,246218.750000 +2,saturated,secondary,optimized,18,256,241460.312500,241472.656250 +2,saturated,secondary,optimized,19,256,236253.265625,236265.625000 +2,saturated,secondary,baseline,19,128,214241.679688,214273.437500 +2,saturated,secondary,baseline,20,128,203954.195312,203984.375000 +2,saturated,secondary,optimized,20,256,330047.167969,330062.500000 +2,saturated,secondary,optimized,21,256,224664.652344,224679.687500 +2,saturated,secondary,baseline,21,128,222031.820312,222054.687500 +2,saturated,secondary,baseline,22,128,235052.843750,235078.125000 +2,saturated,secondary,optimized,22,256,225356.175781,225371.093750 +2,saturated,secondary,optimized,23,256,225501.218750,225464.843750 +2,saturated,secondary,baseline,23,128,280585.453125,280531.250000 +2,saturated,secondary,baseline,24,128,257637.531250,257531.250000 +2,saturated,secondary,optimized,24,256,209129.875000,209136.718750 +2,saturated,secondary,optimized,25,256,233979.320312,233851.562500 +2,saturated,secondary,baseline,25,128,219602.351562,219625.000000 +2,saturated,secondary,baseline,26,128,282068.476562,282085.937500 +2,saturated,secondary,optimized,26,256,251633.179688,251644.531250 +2,saturated,secondary,optimized,27,256,239242.597656,239261.718750 +2,saturated,secondary,baseline,27,128,208300.679688,208320.312500 +2,saturated,secondary,baseline,28,128,274012.156250,274039.062500 +2,saturated,secondary,optimized,28,256,293636.675781,293652.343750 +2,saturated,secondary,optimized,29,256,336663.742188,336597.656250 +2,saturated,secondary,baseline,29,128,207849.250000,207875.000000 +2,saturated,secondary,baseline,30,128,238431.257812,238453.125000 +2,saturated,secondary,optimized,30,256,203616.660156,203628.906250 +2,saturated,settings-blocked,baseline,0,128,280633.023438,280656.250000 +2,saturated,settings-blocked,optimized,0,128,235137.179688,235070.312500 +2,saturated,settings-blocked,optimized,1,128,258189.210938,258148.437500 +2,saturated,settings-blocked,baseline,1,128,239271.640625,240812.500000 +2,saturated,settings-blocked,baseline,2,128,250440.226562,250320.312500 +2,saturated,settings-blocked,optimized,2,128,225724.421875,225742.187500 +2,saturated,settings-blocked,optimized,3,128,203455.929688,203476.562500 +2,saturated,settings-blocked,baseline,3,128,260849.195312,260867.187500 +2,saturated,settings-blocked,baseline,4,128,264614.062500,264640.625000 +2,saturated,settings-blocked,optimized,4,128,247817.500000,247843.750000 +2,saturated,settings-blocked,optimized,5,128,355842.867188,355859.375000 +2,saturated,settings-blocked,baseline,5,128,244876.070312,244898.437500 +2,saturated,settings-blocked,baseline,6,128,241600.414062,241632.812500 +2,saturated,settings-blocked,optimized,6,128,214495.382812,214515.625000 +2,saturated,settings-blocked,optimized,7,128,211098.484375,211117.187500 +2,saturated,settings-blocked,baseline,7,128,216259.078125,216273.437500 +2,saturated,settings-blocked,baseline,8,128,245685.835938,245703.125000 +2,saturated,settings-blocked,optimized,8,128,224650.476562,224671.875000 +2,saturated,settings-blocked,optimized,9,128,242777.296875,242789.062500 +2,saturated,settings-blocked,baseline,9,128,268008.898438,268031.250000 +2,saturated,settings-blocked,baseline,10,128,240032.296875,240054.687500 +2,saturated,settings-blocked,optimized,10,128,222250.609375,222273.437500 +2,saturated,settings-blocked,optimized,11,128,204267.601562,204281.250000 +2,saturated,settings-blocked,baseline,11,128,236617.000000,236640.625000 +2,saturated,settings-blocked,baseline,12,128,214932.867188,214953.125000 +2,saturated,settings-blocked,optimized,12,128,219164.218750,219187.500000 +2,saturated,settings-blocked,optimized,13,128,359489.757812,359507.812500 +2,saturated,settings-blocked,baseline,13,128,273794.992188,273812.500000 +2,saturated,settings-blocked,baseline,14,128,230583.703125,230601.562500 +2,saturated,settings-blocked,optimized,14,128,262565.195312,262593.750000 +2,saturated,settings-blocked,optimized,15,128,231600.914062,231617.187500 +2,saturated,settings-blocked,baseline,15,128,197423.414062,197437.500000 +2,saturated,settings-blocked,baseline,16,128,271808.296875,271820.312500 +2,saturated,settings-blocked,optimized,16,128,206828.429688,208593.750000 +2,saturated,settings-blocked,optimized,17,128,233952.039062,233875.000000 +2,saturated,settings-blocked,baseline,17,128,255228.585938,255164.062500 +2,saturated,settings-blocked,baseline,18,128,228937.148438,228953.125000 +2,saturated,settings-blocked,optimized,18,128,242342.726562,242226.562500 +2,saturated,settings-blocked,optimized,19,128,229128.476562,226281.250000 +2,saturated,settings-blocked,baseline,19,128,263999.078125,264015.625000 +2,saturated,settings-blocked,baseline,20,128,249405.937500,249429.687500 +2,saturated,settings-blocked,optimized,20,128,222489.679688,222507.812500 +2,saturated,settings-blocked,optimized,21,128,263667.757812,263687.500000 +2,saturated,settings-blocked,baseline,21,128,238874.562500,238898.437500 +2,saturated,settings-blocked,baseline,22,128,210385.054688,210398.437500 +2,saturated,settings-blocked,optimized,22,128,259128.546875,259093.750000 +2,saturated,settings-blocked,optimized,23,128,215703.695312,215726.562500 +2,saturated,settings-blocked,baseline,23,128,201208.757812,201226.562500 +2,saturated,settings-blocked,baseline,24,128,220231.804688,220250.000000 +2,saturated,settings-blocked,optimized,24,128,237868.781250,237890.625000 +2,saturated,settings-blocked,optimized,25,128,218796.390625,218812.500000 +2,saturated,settings-blocked,baseline,25,128,246401.257812,246421.875000 +2,saturated,settings-blocked,baseline,26,128,260568.328125,260585.937500 +2,saturated,settings-blocked,optimized,26,128,233685.789062,233710.937500 +2,saturated,settings-blocked,optimized,27,128,214499.187500,214515.625000 +2,saturated,settings-blocked,baseline,27,128,220204.640625,220226.562500 +2,saturated,settings-blocked,baseline,28,128,229462.414062,229484.375000 +2,saturated,settings-blocked,optimized,28,128,217914.312500,217929.687500 +2,saturated,settings-blocked,optimized,29,128,198100.562500,198117.187500 +2,saturated,settings-blocked,baseline,29,128,363781.578125,363796.875000 +2,saturated,settings-blocked,baseline,30,128,259508.812500,259523.437500 +2,saturated,settings-blocked,optimized,30,128,240845.015625,240867.187500 +2,saturated,session-blocked,baseline,0,64,583531.796875,581656.250000 +2,saturated,session-blocked,optimized,0,128,538064.125000,537671.875000 +2,saturated,session-blocked,optimized,1,128,430084.343750,430101.562500 +2,saturated,session-blocked,baseline,1,64,687280.640625,687328.125000 +2,saturated,session-blocked,baseline,2,64,629755.359375,629812.500000 +2,saturated,session-blocked,optimized,2,128,389585.039062,389601.562500 +2,saturated,session-blocked,optimized,3,128,345954.921875,345984.375000 +2,saturated,session-blocked,baseline,3,64,653924.796875,653984.375000 +2,saturated,session-blocked,baseline,4,64,711971.359375,712015.625000 +2,saturated,session-blocked,optimized,4,128,463373.523438,463398.437500 +2,saturated,session-blocked,optimized,5,128,363304.523438,363320.312500 +2,saturated,session-blocked,baseline,5,64,617241.140625,617281.250000 +2,saturated,session-blocked,baseline,6,64,555599.578125,555640.625000 +2,saturated,session-blocked,optimized,6,128,532693.867188,532718.750000 +2,saturated,session-blocked,optimized,7,128,390059.132812,390085.937500 +2,saturated,session-blocked,baseline,7,64,707480.265625,707546.875000 +2,saturated,session-blocked,baseline,8,64,600803.390625,600859.375000 +2,saturated,session-blocked,optimized,8,128,377187.750000,377210.937500 +2,saturated,session-blocked,optimized,9,128,370797.320312,370812.500000 +2,saturated,session-blocked,baseline,9,64,639785.093750,639125.000000 +2,saturated,session-blocked,baseline,10,64,613850.859375,613875.000000 +2,saturated,session-blocked,optimized,10,128,331612.617188,331632.812500 +2,saturated,session-blocked,optimized,11,128,412660.968750,412507.812500 +2,saturated,session-blocked,baseline,11,64,564553.734375,564593.750000 +2,saturated,session-blocked,baseline,12,64,674364.859375,674406.250000 +2,saturated,session-blocked,optimized,12,128,371377.546875,371398.437500 +2,saturated,session-blocked,optimized,13,128,362429.054688,362460.937500 +2,saturated,session-blocked,baseline,13,64,616932.000000,616968.750000 +2,saturated,session-blocked,baseline,14,64,585200.265625,585250.000000 +2,saturated,session-blocked,optimized,14,128,375596.828125,375562.500000 +2,saturated,session-blocked,optimized,15,128,388133.218750,388156.250000 +2,saturated,session-blocked,baseline,15,64,661583.390625,661625.000000 +2,saturated,session-blocked,baseline,16,64,626036.937500,626093.750000 +2,saturated,session-blocked,optimized,16,128,371510.125000,371546.875000 +2,saturated,session-blocked,optimized,17,128,343151.031250,343171.875000 +2,saturated,session-blocked,baseline,17,64,592806.906250,592859.375000 +2,saturated,session-blocked,baseline,18,64,1046382.671875,1046421.875000 +2,saturated,session-blocked,optimized,18,128,342839.148438,342859.375000 +2,saturated,session-blocked,optimized,19,128,385755.609375,385781.250000 +2,saturated,session-blocked,baseline,19,64,527560.453125,527609.375000 +2,saturated,session-blocked,baseline,20,64,605330.234375,605390.625000 +2,saturated,session-blocked,optimized,20,128,377696.382812,377195.312500 +2,saturated,session-blocked,optimized,21,128,407235.781250,407171.875000 +2,saturated,session-blocked,baseline,21,64,592540.062500,592578.125000 +2,saturated,session-blocked,baseline,22,64,578472.765625,578515.625000 +2,saturated,session-blocked,optimized,22,128,418341.093750,418234.375000 +2,saturated,session-blocked,optimized,23,128,340100.101562,340031.250000 +2,saturated,session-blocked,baseline,23,64,723554.640625,723593.750000 +2,saturated,session-blocked,baseline,24,64,541964.921875,542000.000000 +2,saturated,session-blocked,optimized,24,128,382432.507812,382453.125000 +2,saturated,session-blocked,optimized,25,128,367831.101562,367851.562500 +2,saturated,session-blocked,baseline,25,64,622523.281250,622578.125000 +2,saturated,session-blocked,baseline,26,64,576679.078125,576703.125000 +2,saturated,session-blocked,optimized,26,128,385625.250000,385648.437500 +2,saturated,session-blocked,optimized,27,128,409826.929688,409851.562500 +2,saturated,session-blocked,baseline,27,64,531272.531250,531296.875000 +2,saturated,session-blocked,baseline,28,64,636656.750000,636703.125000 +2,saturated,session-blocked,optimized,28,128,344588.992188,344601.562500 +2,saturated,session-blocked,optimized,29,128,536570.367188,536593.750000 +2,saturated,session-blocked,baseline,29,64,654855.265625,654906.250000 +2,saturated,session-blocked,baseline,30,64,669439.937500,669500.000000 +2,saturated,session-blocked,optimized,30,128,368618.710938,368648.437500 +2,saturated,both-blocked,baseline,0,128,227190.187500,227210.937500 +2,saturated,both-blocked,optimized,0,8388608,4.679950,4.678249 +2,saturated,both-blocked,optimized,1,8388608,4.088384,4.087448 +2,saturated,both-blocked,baseline,1,128,245243.968750,245273.437500 +2,saturated,both-blocked,baseline,2,128,262756.359375,262773.437500 +2,saturated,both-blocked,optimized,2,8388608,3.780739,3.780961 +2,saturated,both-blocked,optimized,3,8388608,5.708416,5.708694 +2,saturated,both-blocked,baseline,3,128,231050.039062,231070.312500 +2,saturated,both-blocked,baseline,4,128,230531.773438,230546.875000 +2,saturated,both-blocked,optimized,4,8388608,4.676435,4.676700 +2,saturated,both-blocked,optimized,5,8388608,4.152821,4.153013 +2,saturated,both-blocked,baseline,5,128,261349.429688,261375.000000 +2,saturated,both-blocked,baseline,6,128,259185.007812,259203.125000 +2,saturated,both-blocked,optimized,6,8388608,4.463236,4.463553 +2,saturated,both-blocked,optimized,7,8388608,3.922629,3.922939 +2,saturated,both-blocked,baseline,7,128,235966.921875,235984.375000 +2,saturated,both-blocked,baseline,8,128,219495.656250,219507.812500 +2,saturated,both-blocked,optimized,8,8388608,4.558389,4.557014 +2,saturated,both-blocked,optimized,9,8388608,7.038127,7.038474 +2,saturated,both-blocked,baseline,9,128,222463.445312,222476.562500 +2,saturated,both-blocked,baseline,10,128,225989.648438,226000.000000 +2,saturated,both-blocked,optimized,10,8388608,4.478131,4.478455 +2,saturated,both-blocked,optimized,11,8388608,3.977603,3.978014 +2,saturated,both-blocked,baseline,11,128,235908.914062,235937.500000 +2,saturated,both-blocked,baseline,12,128,243352.593750,243367.187500 +2,saturated,both-blocked,optimized,12,8388608,4.442598,4.441142 +2,saturated,both-blocked,optimized,13,8388608,5.049037,5.049348 +2,saturated,both-blocked,baseline,13,128,233395.492188,233406.250000 +2,saturated,both-blocked,baseline,14,128,223731.203125,223664.062500 +2,saturated,both-blocked,optimized,14,8388608,4.430625,4.430890 +2,saturated,both-blocked,optimized,15,8388608,4.507020,4.507303 +2,saturated,both-blocked,baseline,15,128,239051.031250,238921.875000 +2,saturated,both-blocked,baseline,16,128,239235.539062,239257.812500 +2,saturated,both-blocked,optimized,16,8388608,4.837577,4.837871 +2,saturated,both-blocked,optimized,17,8388608,4.034594,4.034877 +2,saturated,both-blocked,baseline,17,128,231809.703125,231835.937500 +2,saturated,both-blocked,baseline,18,128,205601.820312,205617.187500 +2,saturated,both-blocked,optimized,18,8388608,4.328363,4.328728 +2,saturated,both-blocked,optimized,19,8388608,4.938021,4.938364 +2,saturated,both-blocked,baseline,19,128,204162.101562,204179.687500 +2,saturated,both-blocked,baseline,20,128,241399.578125,241421.875000 +2,saturated,both-blocked,optimized,20,8388608,5.656048,5.623102 +2,saturated,both-blocked,optimized,21,8388608,4.153014,4.153252 +2,saturated,both-blocked,baseline,21,128,215142.156250,215164.062500 +2,saturated,both-blocked,baseline,22,128,233255.937500,233273.437500 +2,saturated,both-blocked,optimized,22,8388608,4.172184,4.172444 +2,saturated,both-blocked,optimized,23,8388608,4.425383,4.425645 +2,saturated,both-blocked,baseline,23,128,330377.750000,330398.437500 +2,saturated,both-blocked,baseline,24,128,403980.062500,404000.000000 +2,saturated,both-blocked,optimized,24,8388608,5.112806,5.113006 +2,saturated,both-blocked,optimized,25,8388608,4.018627,4.018903 +2,saturated,both-blocked,baseline,25,128,219768.781250,219796.875000 +2,saturated,both-blocked,baseline,26,128,305674.859375,305710.937500 +2,saturated,both-blocked,optimized,26,8388608,4.489541,4.488111 +2,saturated,both-blocked,optimized,27,8388608,4.706707,4.707098 +2,saturated,both-blocked,baseline,27,128,240395.828125,240421.875000 +2,saturated,both-blocked,baseline,28,128,230789.750000,230812.500000 +2,saturated,both-blocked,optimized,28,8388608,4.253971,4.252791 +2,saturated,both-blocked,optimized,29,8388608,5.464386,5.464792 +2,saturated,both-blocked,baseline,29,128,210799.765625,210820.312500 +2,saturated,both-blocked,baseline,30,128,247435.968750,247328.125000 +2,saturated,both-blocked,optimized,30,8388608,5.078074,5.078316 +3,defaults,primary,baseline,0,4096,9393.081787,9393.554688 +3,defaults,primary,optimized,0,4096,9060.893799,9061.767578 +3,defaults,primary,optimized,1,4096,8081.302002,8081.787109 +3,defaults,primary,baseline,1,4096,8138.830078,8139.404297 +3,defaults,primary,baseline,2,4096,8893.588623,8894.287109 +3,defaults,primary,optimized,2,4096,8794.845459,8795.166016 +3,defaults,primary,optimized,3,4096,11923.637451,11924.316406 +3,defaults,primary,baseline,3,4096,12483.032715,12483.642578 +3,defaults,primary,baseline,4,4096,9508.245850,9508.544922 +3,defaults,primary,optimized,4,4096,8906.852295,8907.226562 +3,defaults,primary,optimized,5,4096,13143.361816,13144.287109 +3,defaults,primary,baseline,5,4096,11337.584717,11317.382812 +3,defaults,primary,baseline,6,4096,8900.454102,8900.878906 +3,defaults,primary,optimized,6,4096,11748.047363,11748.779297 +3,defaults,primary,optimized,7,4096,8465.845215,8466.552734 +3,defaults,primary,baseline,7,4096,7821.119873,7821.777344 +3,defaults,primary,baseline,8,4096,10020.127930,10017.822266 +3,defaults,primary,optimized,8,4096,7991.951416,7992.675781 +3,defaults,primary,optimized,9,4096,8691.023926,8687.255859 +3,defaults,primary,baseline,9,4096,11722.899414,11690.429688 +3,defaults,primary,baseline,10,4096,8121.869629,8122.558594 +3,defaults,primary,optimized,10,4096,9084.317627,9084.960938 +3,defaults,primary,optimized,11,4096,8426.274170,8427.246094 +3,defaults,primary,baseline,11,4096,7928.168701,7928.955078 +3,defaults,primary,baseline,12,4096,9342.447021,9343.017578 +3,defaults,primary,optimized,12,4096,9395.882080,9396.484375 +3,defaults,primary,optimized,13,4096,9021.765625,9022.460938 +3,defaults,primary,baseline,13,4096,10685.378906,10686.035156 +3,defaults,primary,baseline,14,4096,7666.190674,7666.748047 +3,defaults,primary,optimized,14,4096,8788.867676,8789.550781 +3,defaults,primary,optimized,15,4096,9029.589111,9030.273438 +3,defaults,primary,baseline,15,4096,16172.445068,16173.583984 +3,defaults,primary,baseline,16,4096,13242.622559,13251.708984 +3,defaults,primary,optimized,16,4096,9885.666992,9886.474609 +3,defaults,primary,optimized,17,4096,9053.751709,9054.687500 +3,defaults,primary,baseline,17,4096,7744.055664,7744.873047 +3,defaults,primary,baseline,18,4096,8127.455566,8128.417969 +3,defaults,primary,optimized,18,4096,9522.741699,9523.437500 +3,defaults,primary,optimized,19,4096,14973.610596,14971.679688 +3,defaults,primary,baseline,19,4096,16878.831299,16880.126953 +3,defaults,primary,baseline,20,4096,22375.675537,22373.779297 +3,defaults,primary,optimized,20,4096,30352.162842,30349.365234 +3,defaults,primary,optimized,21,4096,10880.173096,10881.591797 +3,defaults,primary,baseline,21,4096,8791.628662,8792.724609 +3,defaults,primary,baseline,22,4096,9523.768799,9524.658203 +3,defaults,primary,optimized,22,4096,9528.243896,9529.052734 +3,defaults,primary,optimized,23,4096,10326.073730,10326.904297 +3,defaults,primary,baseline,23,4096,8722.620361,8723.144531 +3,defaults,primary,baseline,24,4096,8396.364746,8396.972656 +3,defaults,primary,optimized,24,4096,7952.982910,7953.613281 +3,defaults,primary,optimized,25,4096,10448.464355,10449.462891 +3,defaults,primary,baseline,25,4096,11021.961182,11022.949219 +3,defaults,primary,baseline,26,4096,12354.839844,12355.712891 +3,defaults,primary,optimized,26,4096,15375.689209,15376.708984 +3,defaults,primary,optimized,27,4096,9838.194336,9838.867188 +3,defaults,primary,baseline,27,4096,8833.114502,8833.984375 +3,defaults,primary,baseline,28,4096,10075.644043,10076.416016 +3,defaults,primary,optimized,28,4096,8612.206787,8590.332031 +3,defaults,primary,optimized,29,4096,10198.881348,10199.707031 +3,defaults,primary,baseline,29,4096,9124.320801,9125.000000 +3,defaults,primary,baseline,30,4096,8182.342529,8183.105469 +3,defaults,primary,optimized,30,4096,8545.783936,8546.386719 +3,defaults,secondary,baseline,0,65536,286.532440,286.560059 +3,defaults,secondary,optimized,0,131072,301.991791,302.009583 +3,defaults,secondary,optimized,1,131072,316.783897,316.802979 +3,defaults,secondary,baseline,1,65536,304.207428,304.229736 +3,defaults,secondary,baseline,2,65536,347.059708,347.091675 +3,defaults,secondary,optimized,2,131072,352.244858,352.165222 +3,defaults,secondary,optimized,3,131072,308.422081,308.441162 +3,defaults,secondary,baseline,3,65536,277.891220,277.923584 +3,defaults,secondary,baseline,4,65536,291.921936,291.961670 +3,defaults,secondary,optimized,4,131072,320.523247,320.541382 +3,defaults,secondary,optimized,5,131072,392.652519,392.662048 +3,defaults,secondary,baseline,5,65536,376.913940,376.953125 +3,defaults,secondary,baseline,6,65536,495.631348,495.697021 +3,defaults,secondary,optimized,6,131072,403.124275,403.144836 +3,defaults,secondary,optimized,7,131072,355.681496,355.705261 +3,defaults,secondary,baseline,7,65536,417.871689,417.922974 +3,defaults,secondary,baseline,8,65536,320.412354,320.465088 +3,defaults,secondary,optimized,8,131072,378.786598,378.822327 +3,defaults,secondary,optimized,9,131072,423.465698,423.416138 +3,defaults,secondary,baseline,9,65536,412.270309,412.322998 +3,defaults,secondary,baseline,10,65536,302.788284,302.825928 +3,defaults,secondary,optimized,10,131072,295.898659,295.913696 +3,defaults,secondary,optimized,11,131072,444.418175,444.442749 +3,defaults,secondary,baseline,11,65536,305.191345,305.236816 +3,defaults,secondary,baseline,12,65536,312.868332,312.728882 +3,defaults,secondary,optimized,12,131072,399.255447,399.269104 +3,defaults,secondary,optimized,13,131072,326.975601,326.873779 +3,defaults,secondary,baseline,13,65536,308.035538,308.074951 +3,defaults,secondary,baseline,14,65536,299.817856,299.850464 +3,defaults,secondary,optimized,14,131072,324.405029,324.424744 +3,defaults,secondary,optimized,15,131072,325.274094,325.294495 +3,defaults,secondary,baseline,15,65536,327.119553,327.163696 +3,defaults,secondary,baseline,16,65536,314.197266,314.224243 +3,defaults,secondary,optimized,16,131072,344.539490,344.558716 +3,defaults,secondary,optimized,17,131072,353.496017,353.515625 +3,defaults,secondary,baseline,17,65536,316.248062,316.284180 +3,defaults,secondary,baseline,18,65536,311.385620,311.416626 +3,defaults,secondary,optimized,18,131072,315.642967,315.658569 +3,defaults,secondary,optimized,19,131072,346.344688,346.366882 +3,defaults,secondary,baseline,19,65536,301.266098,301.300049 +3,defaults,secondary,baseline,20,65536,348.858826,348.876953 +3,defaults,secondary,optimized,20,131072,419.293999,419.311523 +3,defaults,secondary,optimized,21,131072,367.363892,367.301941 +3,defaults,secondary,baseline,21,65536,320.504028,320.541382 +3,defaults,secondary,baseline,22,65536,299.607666,299.636841 +3,defaults,secondary,optimized,22,131072,312.259483,312.278748 +3,defaults,secondary,optimized,23,131072,315.652626,315.666199 +3,defaults,secondary,baseline,23,65536,301.245789,301.086426 +3,defaults,secondary,baseline,24,65536,356.048019,356.063843 +3,defaults,secondary,optimized,24,131072,349.022606,349.044800 +3,defaults,secondary,optimized,25,131072,321.551666,321.571350 +3,defaults,secondary,baseline,25,65536,371.778885,371.810913 +3,defaults,secondary,baseline,26,65536,387.083237,387.115479 +3,defaults,secondary,optimized,26,131072,388.606956,388.618469 +3,defaults,secondary,optimized,27,131072,560.673424,560.279846 +3,defaults,secondary,baseline,27,65536,408.597717,408.660889 +3,defaults,secondary,baseline,28,65536,462.108841,461.120605 +3,defaults,secondary,optimized,28,131072,502.566536,502.601624 +3,defaults,secondary,optimized,29,131072,346.767448,346.801758 +3,defaults,secondary,baseline,29,65536,320.582611,320.632935 +3,defaults,secondary,baseline,30,65536,394.467514,394.500732 +3,defaults,secondary,optimized,30,131072,366.217323,366.249084 +3,defaults,settings-blocked,baseline,0,131072,409.431786,409.446716 +3,defaults,settings-blocked,optimized,0,65536,288.420624,288.467407 +3,defaults,settings-blocked,optimized,1,65536,330.235718,330.276489 +3,defaults,settings-blocked,baseline,1,131072,342.685349,342.704773 +3,defaults,settings-blocked,baseline,2,131072,326.751427,326.705933 +3,defaults,settings-blocked,optimized,2,65536,316.628601,316.650391 +3,defaults,settings-blocked,optimized,3,65536,348.922348,348.968506 +3,defaults,settings-blocked,baseline,3,131072,351.046455,351.058960 +3,defaults,settings-blocked,baseline,4,131072,286.720573,286.735535 +3,defaults,settings-blocked,optimized,4,65536,341.374329,341.400146 +3,defaults,settings-blocked,optimized,5,65536,420.405609,420.440674 +3,defaults,settings-blocked,baseline,5,131072,300.069801,300.086975 +3,defaults,settings-blocked,baseline,6,131072,368.281326,368.202209 +3,defaults,settings-blocked,optimized,6,65536,343.994705,344.024658 +3,defaults,settings-blocked,optimized,7,65536,338.411026,338.439941 +3,defaults,settings-blocked,baseline,7,131072,318.385712,318.260193 +3,defaults,settings-blocked,baseline,8,131072,323.487206,323.356628 +3,defaults,settings-blocked,optimized,8,65536,330.651978,330.688477 +3,defaults,settings-blocked,optimized,9,65536,328.747925,328.781128 +3,defaults,settings-blocked,baseline,9,131072,329.315071,329.330444 +3,defaults,settings-blocked,baseline,10,131072,371.649620,371.658325 +3,defaults,settings-blocked,optimized,10,65536,416.402374,416.442871 +3,defaults,settings-blocked,optimized,11,65536,373.329407,373.352051 +3,defaults,settings-blocked,baseline,11,131072,305.709122,305.725098 +3,defaults,settings-blocked,baseline,12,131072,309.308479,309.326172 +3,defaults,settings-blocked,optimized,12,65536,312.268219,312.301636 +3,defaults,settings-blocked,optimized,13,65536,311.348282,311.386108 +3,defaults,settings-blocked,baseline,13,131072,322.574364,322.586060 +3,defaults,settings-blocked,baseline,14,131072,410.920624,410.942078 +3,defaults,settings-blocked,optimized,14,65536,378.879410,378.936768 +3,defaults,settings-blocked,optimized,15,65536,355.953918,355.987549 +3,defaults,settings-blocked,baseline,15,131072,308.956459,308.975220 +3,defaults,settings-blocked,baseline,16,131072,302.463463,302.482605 +3,defaults,settings-blocked,optimized,16,65536,335.695602,335.723877 +3,defaults,settings-blocked,optimized,17,65536,518.916122,518.432617 +3,defaults,settings-blocked,baseline,17,131072,344.763039,344.779968 +3,defaults,settings-blocked,baseline,18,131072,403.187767,403.205872 +3,defaults,settings-blocked,optimized,18,65536,336.845734,336.883545 +3,defaults,settings-blocked,optimized,19,65536,282.475021,282.501221 +3,defaults,settings-blocked,baseline,19,131072,330.103104,330.116272 +3,defaults,settings-blocked,baseline,20,131072,359.706909,359.725952 +3,defaults,settings-blocked,optimized,20,65536,315.772202,315.811157 +3,defaults,settings-blocked,optimized,21,65536,344.458588,344.482422 +3,defaults,settings-blocked,baseline,21,131072,361.352684,361.282349 +3,defaults,settings-blocked,baseline,22,131072,305.259285,305.274963 +3,defaults,settings-blocked,optimized,22,65536,293.403580,293.258667 +3,defaults,settings-blocked,optimized,23,65536,361.412750,361.450195 +3,defaults,settings-blocked,baseline,23,131072,314.827950,314.849854 +3,defaults,settings-blocked,baseline,24,131072,310.084572,310.104370 +3,defaults,settings-blocked,optimized,24,65536,321.972229,322.021484 +3,defaults,settings-blocked,optimized,25,65536,349.784653,349.807739 +3,defaults,settings-blocked,baseline,25,131072,330.669350,330.680847 +3,defaults,settings-blocked,baseline,26,131072,328.003761,328.025818 +3,defaults,settings-blocked,optimized,26,65536,326.661621,326.705933 +3,defaults,settings-blocked,optimized,27,65536,274.823730,274.841309 +3,defaults,settings-blocked,baseline,27,131072,324.593475,323.753357 +3,defaults,settings-blocked,baseline,28,131072,311.028030,311.042786 +3,defaults,settings-blocked,optimized,28,65536,417.799423,417.846680 +3,defaults,settings-blocked,optimized,29,65536,391.556915,391.601562 +3,defaults,settings-blocked,baseline,29,131072,486.269417,486.289978 +3,defaults,settings-blocked,baseline,30,131072,347.497093,347.518921 +3,defaults,settings-blocked,optimized,30,65536,379.931793,379.974365 +3,defaults,session-blocked,baseline,0,4096,8789.056396,8789.794922 +3,defaults,session-blocked,optimized,0,4096,10693.329102,10694.091797 +3,defaults,session-blocked,optimized,1,4096,11759.482422,11760.009766 +3,defaults,session-blocked,baseline,1,4096,11494.830566,11492.431641 +3,defaults,session-blocked,baseline,2,4096,9843.899170,9844.726562 +3,defaults,session-blocked,optimized,2,4096,8927.459473,8928.222656 +3,defaults,session-blocked,optimized,3,4096,8919.720947,8916.259766 +3,defaults,session-blocked,baseline,3,4096,8285.251221,8285.888672 +3,defaults,session-blocked,baseline,4,4096,8092.365234,8093.017578 +3,defaults,session-blocked,optimized,4,4096,8996.416992,8996.826172 +3,defaults,session-blocked,optimized,5,4096,9849.746338,9850.097656 +3,defaults,session-blocked,baseline,5,4096,8864.390625,8864.990234 +3,defaults,session-blocked,baseline,6,4096,8610.644043,8611.328125 +3,defaults,session-blocked,optimized,6,4096,8742.333252,8743.164062 +3,defaults,session-blocked,optimized,7,4096,9348.905273,9349.609375 +3,defaults,session-blocked,baseline,7,4096,8439.072998,8439.453125 +3,defaults,session-blocked,baseline,8,4096,9943.225830,9943.603516 +3,defaults,session-blocked,optimized,8,4096,10579.591797,10580.566406 +3,defaults,session-blocked,optimized,9,4096,13086.853027,13087.646484 +3,defaults,session-blocked,baseline,9,4096,8724.331543,8725.097656 +3,defaults,session-blocked,baseline,10,4096,8549.501953,8550.048828 +3,defaults,session-blocked,optimized,10,4096,10302.450439,10297.119141 +3,defaults,session-blocked,optimized,11,4096,8554.869385,8555.419922 +3,defaults,session-blocked,baseline,11,4096,10210.931885,10211.425781 +3,defaults,session-blocked,baseline,12,4096,9041.923096,9042.236328 +3,defaults,session-blocked,optimized,12,4096,8098.256104,8098.632812 +3,defaults,session-blocked,optimized,13,4096,9317.621338,9318.359375 +3,defaults,session-blocked,baseline,13,4096,11797.765625,11798.339844 +3,defaults,session-blocked,baseline,14,4096,8811.115967,8811.767578 +3,defaults,session-blocked,optimized,14,4096,11586.028564,11586.914062 +3,defaults,session-blocked,optimized,15,4096,7978.053223,7975.585938 +3,defaults,session-blocked,baseline,15,4096,8866.717529,8867.431641 +3,defaults,session-blocked,baseline,16,4096,9100.675781,9097.900391 +3,defaults,session-blocked,optimized,16,4096,8358.308105,8358.886719 +3,defaults,session-blocked,optimized,17,4096,8375.479004,8375.976562 +3,defaults,session-blocked,baseline,17,4096,11172.204834,11172.851562 +3,defaults,session-blocked,baseline,18,4096,9125.060547,9125.488281 +3,defaults,session-blocked,optimized,18,4096,8203.053467,8203.613281 +3,defaults,session-blocked,optimized,19,4096,8263.292236,8263.916016 +3,defaults,session-blocked,baseline,19,4096,9157.492920,9158.203125 +3,defaults,session-blocked,baseline,20,4096,8721.658936,8722.167969 +3,defaults,session-blocked,optimized,20,4096,9374.933350,9375.488281 +3,defaults,session-blocked,optimized,21,4096,9825.169678,9825.927734 +3,defaults,session-blocked,baseline,21,4096,12085.113770,12086.181641 +3,defaults,session-blocked,baseline,22,4096,9910.870117,9911.621094 +3,defaults,session-blocked,optimized,22,4096,8377.224121,8378.173828 +3,defaults,session-blocked,optimized,23,4096,7974.136963,7974.853516 +3,defaults,session-blocked,baseline,23,4096,8519.848633,8520.751953 +3,defaults,session-blocked,baseline,24,4096,9055.440430,9031.250000 +3,defaults,session-blocked,optimized,24,4096,8955.048584,8956.054688 +3,defaults,session-blocked,optimized,25,4096,9546.557861,9547.363281 +3,defaults,session-blocked,baseline,25,4096,9480.648193,9481.201172 +3,defaults,session-blocked,baseline,26,4096,9061.842041,9062.744141 +3,defaults,session-blocked,optimized,26,4096,9216.763916,9217.285156 +3,defaults,session-blocked,optimized,27,4096,7938.448242,7939.208984 +3,defaults,session-blocked,baseline,27,4096,9112.508789,9113.037109 +3,defaults,session-blocked,baseline,28,4096,9684.508789,9685.058594 +3,defaults,session-blocked,optimized,28,4096,7895.420898,7893.066406 +3,defaults,session-blocked,optimized,29,4096,8246.550537,8247.314453 +3,defaults,session-blocked,baseline,29,4096,8978.439697,8975.830078 +3,defaults,session-blocked,baseline,30,4096,11817.660156,11813.964844 +3,defaults,session-blocked,optimized,30,4096,8388.344971,8389.160156 +3,defaults,both-blocked,baseline,0,131072,357.740189,357.749939 +3,defaults,both-blocked,optimized,0,8388608,5.639343,5.639791 +3,defaults,both-blocked,optimized,1,8388608,5.062868,5.063176 +3,defaults,both-blocked,baseline,1,131072,310.602676,310.623169 +3,defaults,both-blocked,baseline,2,131072,322.642677,322.662354 +3,defaults,both-blocked,optimized,2,8388608,4.359632,4.354119 +3,defaults,both-blocked,optimized,3,8388608,4.578242,4.578471 +3,defaults,both-blocked,baseline,3,131072,354.864342,354.881287 +3,defaults,both-blocked,baseline,4,131072,307.447868,307.472229 +3,defaults,both-blocked,optimized,4,8388608,3.863510,3.863692 +3,defaults,both-blocked,optimized,5,8388608,5.061843,5.062103 +3,defaults,both-blocked,baseline,5,131072,321.879875,321.899414 +3,defaults,both-blocked,baseline,6,131072,318.428085,318.443298 +3,defaults,both-blocked,optimized,6,8388608,4.259256,4.259586 +3,defaults,both-blocked,optimized,7,8388608,4.104545,4.104853 +3,defaults,both-blocked,baseline,7,131072,339.024513,338.951111 +3,defaults,both-blocked,baseline,8,131072,309.608963,309.631348 +3,defaults,both-blocked,optimized,8,8388608,4.195048,4.192352 +3,defaults,both-blocked,optimized,9,8388608,4.287015,4.281759 +3,defaults,both-blocked,baseline,9,131072,354.484520,354.499817 +3,defaults,both-blocked,baseline,10,131072,386.163872,386.177063 +3,defaults,both-blocked,optimized,10,8388608,4.118820,4.119158 +3,defaults,both-blocked,optimized,11,8388608,4.128593,4.128933 +3,defaults,both-blocked,baseline,11,131072,334.457581,334.480286 +3,defaults,both-blocked,baseline,12,131072,309.280640,309.303284 +3,defaults,both-blocked,optimized,12,8388608,4.757874,4.758239 +3,defaults,both-blocked,optimized,13,8388608,5.630093,5.630255 +3,defaults,both-blocked,baseline,13,131072,357.010948,357.025146 +3,defaults,both-blocked,baseline,14,131072,315.809113,315.826416 +3,defaults,both-blocked,optimized,14,8388608,4.045063,4.045367 +3,defaults,both-blocked,optimized,15,8388608,4.493143,4.484534 +3,defaults,both-blocked,baseline,15,131072,442.610458,442.848206 +3,defaults,both-blocked,baseline,16,131072,555.537590,555.564880 +3,defaults,both-blocked,optimized,16,8388608,12.359370,12.359858 +3,defaults,both-blocked,optimized,17,8388608,11.437165,11.437654 +3,defaults,both-blocked,baseline,17,131072,3293.736755,3290.039062 +3,defaults,both-blocked,baseline,18,131072,682.814857,682.861328 +3,defaults,both-blocked,optimized,18,8388608,6.105508,6.101727 +3,defaults,both-blocked,optimized,19,8388608,7.490123,7.489204 +3,defaults,both-blocked,baseline,19,131072,487.115303,487.144470 +3,defaults,both-blocked,baseline,20,131072,592.425827,592.453003 +3,defaults,both-blocked,optimized,20,8388608,4.001760,4.002213 +3,defaults,both-blocked,optimized,21,8388608,4.859554,4.860044 +3,defaults,both-blocked,baseline,21,131072,502.656853,502.693176 +3,defaults,both-blocked,baseline,22,131072,366.978455,367.004395 +3,defaults,both-blocked,optimized,22,8388608,5.667081,5.667686 +3,defaults,both-blocked,optimized,23,8388608,5.445811,5.446315 +3,defaults,both-blocked,baseline,23,131072,348.979729,349.006653 +3,defaults,both-blocked,baseline,24,131072,405.286705,405.319214 +3,defaults,both-blocked,optimized,24,8388608,4.464830,4.465222 +3,defaults,both-blocked,optimized,25,8388608,6.556132,6.554246 +3,defaults,both-blocked,baseline,25,131072,381.428215,381.454468 +3,defaults,both-blocked,baseline,26,131072,318.405106,318.435669 +3,defaults,both-blocked,optimized,26,8388608,7.023950,7.024407 +3,defaults,both-blocked,optimized,27,8388608,7.264067,7.264495 +3,defaults,both-blocked,baseline,27,131072,811.350334,811.477661 +3,defaults,both-blocked,baseline,28,131072,518.721313,519.958496 +3,defaults,both-blocked,optimized,28,8388608,5.080065,5.076766 +3,defaults,both-blocked,optimized,29,8388608,5.306023,5.306482 +3,defaults,both-blocked,baseline,29,131072,493.588264,493.621826 +3,defaults,both-blocked,baseline,30,131072,304.440880,304.473877 +3,defaults,both-blocked,optimized,30,8388608,5.310127,5.310655 +3,saturated,primary,baseline,0,64,605240.078125,605281.250000 +3,saturated,primary,optimized,0,64,894059.609375,837734.375000 +3,saturated,primary,optimized,1,64,778204.031250,777156.250000 +3,saturated,primary,baseline,1,64,789066.437500,789125.000000 +3,saturated,primary,baseline,2,64,567104.578125,567156.250000 +3,saturated,primary,optimized,2,64,574608.265625,574656.250000 +3,saturated,primary,optimized,3,64,1218639.750000,1218718.750000 +3,saturated,primary,baseline,3,64,1071957.656250,1072031.250000 +3,saturated,primary,baseline,4,64,700587.703125,700656.250000 +3,saturated,primary,optimized,4,64,866872.984375,865796.875000 +3,saturated,primary,optimized,5,64,971450.171875,971531.250000 +3,saturated,primary,baseline,5,64,1288679.515625,1288765.625000 +3,saturated,primary,baseline,6,64,920471.328125,920546.875000 +3,saturated,primary,optimized,6,64,800957.640625,801000.000000 +3,saturated,primary,optimized,7,64,666932.531250,666984.375000 +3,saturated,primary,baseline,7,64,661709.187500,661765.625000 +3,saturated,primary,baseline,8,64,632891.671875,632953.125000 +3,saturated,primary,optimized,8,64,568200.703125,568234.375000 +3,saturated,primary,optimized,9,64,532283.281250,532328.125000 +3,saturated,primary,baseline,9,64,720026.734375,720078.125000 +3,saturated,primary,baseline,10,64,555153.578125,555187.500000 +3,saturated,primary,optimized,10,64,708775.515625,708859.375000 +3,saturated,primary,optimized,11,64,768785.796875,768843.750000 +3,saturated,primary,baseline,11,64,597725.171875,597468.750000 +3,saturated,primary,baseline,12,64,622710.421875,622765.625000 +3,saturated,primary,optimized,12,64,541699.062500,541750.000000 +3,saturated,primary,optimized,13,64,624573.875000,624625.000000 +3,saturated,primary,baseline,13,64,808931.671875,808984.375000 +3,saturated,primary,baseline,14,64,762986.156250,763046.875000 +3,saturated,primary,optimized,14,64,648568.687500,648625.000000 +3,saturated,primary,optimized,15,64,535006.031250,535062.500000 +3,saturated,primary,baseline,15,64,962340.531250,962171.875000 +3,saturated,primary,baseline,16,64,632193.703125,632234.375000 +3,saturated,primary,optimized,16,64,2057728.609375,2060484.375000 +3,saturated,primary,optimized,17,64,6768913.718750,6768953.125000 +3,saturated,primary,baseline,17,64,960022.734375,959109.375000 +3,saturated,primary,baseline,18,64,553656.593750,553984.375000 +3,saturated,primary,optimized,18,64,826987.000000,827046.875000 +3,saturated,primary,optimized,19,64,705663.843750,705718.750000 +3,saturated,primary,baseline,19,64,1592241.015625,1589984.375000 +3,saturated,primary,baseline,20,64,676205.578125,676265.625000 +3,saturated,primary,optimized,20,64,703905.046875,703218.750000 +3,saturated,primary,optimized,21,64,526486.421875,526515.625000 +3,saturated,primary,baseline,21,64,767328.765625,767359.375000 +3,saturated,primary,baseline,22,64,677588.375000,677625.000000 +3,saturated,primary,optimized,22,64,863995.156250,864031.250000 +3,saturated,primary,optimized,23,64,1037496.000000,1037546.875000 +3,saturated,primary,baseline,23,64,617655.046875,617687.500000 +3,saturated,primary,baseline,24,64,574377.750000,574437.500000 +3,saturated,primary,optimized,24,64,730581.187500,730640.625000 +3,saturated,primary,optimized,25,64,798929.171875,799234.375000 +3,saturated,primary,baseline,25,64,770491.250000,770562.500000 +3,saturated,primary,baseline,26,64,709528.640625,709578.125000 +3,saturated,primary,optimized,26,64,697009.734375,698562.500000 +3,saturated,primary,optimized,27,64,1069403.984375,1075796.875000 +3,saturated,primary,baseline,27,64,770983.734375,758859.375000 +3,saturated,primary,baseline,28,64,3001111.437500,3001187.500000 +3,saturated,primary,optimized,28,64,824472.093750,824531.250000 +3,saturated,primary,optimized,29,64,584202.078125,584671.875000 +3,saturated,primary,baseline,29,64,516373.281250,516093.750000 +3,saturated,primary,baseline,30,64,1114081.812500,1114390.625000 +3,saturated,primary,optimized,30,64,1564729.578125,1564796.875000 +3,saturated,secondary,baseline,0,256,263470.648438,263484.375000 +3,saturated,secondary,optimized,0,128,276657.734375,276671.875000 +3,saturated,secondary,optimized,1,128,229409.117188,229429.687500 +3,saturated,secondary,baseline,1,256,299221.070312,298863.281250 +3,saturated,secondary,baseline,2,256,253031.566406,253046.875000 +3,saturated,secondary,optimized,2,128,563619.210938,563656.250000 +3,saturated,secondary,optimized,3,128,190581.265625,190601.562500 +3,saturated,secondary,baseline,3,256,359253.035156,359277.343750 +3,saturated,secondary,baseline,4,256,777589.816406,778128.906250 +3,saturated,secondary,optimized,4,128,233619.750000,233648.437500 +3,saturated,secondary,optimized,5,128,382820.085938,382804.687500 +3,saturated,secondary,baseline,5,256,209280.445312,209355.468750 +3,saturated,secondary,baseline,6,256,1025209.113281,1025230.468750 +3,saturated,secondary,optimized,6,128,327135.570312,327171.875000 +3,saturated,secondary,optimized,7,128,226419.101562,226445.312500 +3,saturated,secondary,baseline,7,256,240773.660156,240382.812500 +3,saturated,secondary,baseline,8,256,244987.125000,245000.000000 +3,saturated,secondary,optimized,8,128,219769.507812,219804.687500 +3,saturated,secondary,optimized,9,128,326012.101562,326046.875000 +3,saturated,secondary,baseline,9,256,305222.824219,305242.187500 +3,saturated,secondary,baseline,10,256,223319.738281,223289.062500 +3,saturated,secondary,optimized,10,128,326760.992188,326781.250000 +3,saturated,secondary,optimized,11,128,219126.710938,219148.437500 +3,saturated,secondary,baseline,11,256,264335.792969,264347.656250 +3,saturated,secondary,baseline,12,256,218838.660156,218855.468750 +3,saturated,secondary,optimized,12,128,226188.875000,226218.750000 +3,saturated,secondary,optimized,13,128,299188.679688,299218.750000 +3,saturated,secondary,baseline,13,256,423165.394531,423187.500000 +3,saturated,secondary,baseline,14,256,833226.820312,833304.687500 +3,saturated,secondary,optimized,14,128,304238.921875,304265.625000 +3,saturated,secondary,optimized,15,128,275661.914062,275679.687500 +3,saturated,secondary,baseline,15,256,206293.804688,206304.687500 +3,saturated,secondary,baseline,16,256,233444.511719,233382.812500 +3,saturated,secondary,optimized,16,128,321095.460938,321125.000000 +3,saturated,secondary,optimized,17,128,377093.062500,377117.187500 +3,saturated,secondary,baseline,17,256,288066.187500,288078.125000 +3,saturated,secondary,baseline,18,256,253546.367188,252667.968750 +3,saturated,secondary,optimized,18,128,321276.226562,321304.687500 +3,saturated,secondary,optimized,19,128,245836.570312,245859.375000 +3,saturated,secondary,baseline,19,256,219320.695312,219332.031250 +3,saturated,secondary,baseline,20,256,209152.546875,209164.062500 +3,saturated,secondary,optimized,20,128,211434.757812,211460.937500 +3,saturated,secondary,optimized,21,128,229493.507812,229515.625000 +3,saturated,secondary,baseline,21,256,369524.101562,369542.968750 +3,saturated,secondary,baseline,22,256,231097.332031,231109.375000 +3,saturated,secondary,optimized,22,128,262543.882812,262570.312500 +3,saturated,secondary,optimized,23,128,217703.968750,217718.750000 +3,saturated,secondary,baseline,23,256,189341.332031,189347.656250 +3,saturated,secondary,baseline,24,256,309783.992188,309750.000000 +3,saturated,secondary,optimized,24,128,261984.468750,262007.812500 +3,saturated,secondary,optimized,25,128,313272.804688,313296.875000 +3,saturated,secondary,baseline,25,256,201671.750000,201679.687500 +3,saturated,secondary,baseline,26,256,274100.636719,273785.156250 +3,saturated,secondary,optimized,26,128,294529.031250,294554.687500 +3,saturated,secondary,optimized,27,128,414380.750000,414406.250000 +3,saturated,secondary,baseline,27,256,277952.847656,277964.843750 +3,saturated,secondary,baseline,28,256,247281.695312,247222.656250 +3,saturated,secondary,optimized,28,128,416751.906250,416773.437500 +3,saturated,secondary,optimized,29,128,208418.328125,208445.312500 +3,saturated,secondary,baseline,29,256,227269.195312,227160.156250 +3,saturated,secondary,baseline,30,256,211629.687500,211640.625000 +3,saturated,secondary,optimized,30,128,220290.757812,220312.500000 +3,saturated,settings-blocked,baseline,0,128,235823.695312,235843.750000 +3,saturated,settings-blocked,optimized,0,256,282062.578125,282078.125000 +3,saturated,settings-blocked,optimized,1,256,252614.589844,252542.968750 +3,saturated,settings-blocked,baseline,1,128,193334.843750,193351.562500 +3,saturated,settings-blocked,baseline,2,128,213387.507812,213406.250000 +3,saturated,settings-blocked,optimized,2,256,228850.988281,228613.281250 +3,saturated,settings-blocked,optimized,3,256,315140.437500,315078.125000 +3,saturated,settings-blocked,baseline,3,128,261617.898438,261640.625000 +3,saturated,settings-blocked,baseline,4,128,230619.265625,230640.625000 +3,saturated,settings-blocked,optimized,4,256,234561.152344,234574.218750 +3,saturated,settings-blocked,optimized,5,256,234269.000000,234113.281250 +3,saturated,settings-blocked,baseline,5,128,270624.296875,270640.625000 +3,saturated,settings-blocked,baseline,6,128,196221.695312,196234.375000 +3,saturated,settings-blocked,optimized,6,256,230002.039062,230015.625000 +3,saturated,settings-blocked,optimized,7,256,197900.109375,197910.156250 +3,saturated,settings-blocked,baseline,7,128,233287.234375,233312.500000 +3,saturated,settings-blocked,baseline,8,128,240774.367188,240796.875000 +3,saturated,settings-blocked,optimized,8,256,263061.257812,263074.218750 +3,saturated,settings-blocked,optimized,9,256,234264.621094,234273.437500 +3,saturated,settings-blocked,baseline,9,128,222606.507812,222625.000000 +3,saturated,settings-blocked,baseline,10,128,231252.148438,231265.625000 +3,saturated,settings-blocked,optimized,10,256,191509.378906,191519.531250 +3,saturated,settings-blocked,optimized,11,256,282338.089844,282351.562500 +3,saturated,settings-blocked,baseline,11,128,224609.007812,224632.812500 +3,saturated,settings-blocked,baseline,12,128,279949.898438,279976.562500 +3,saturated,settings-blocked,optimized,12,256,251043.960938,251054.687500 +3,saturated,settings-blocked,optimized,13,256,223232.515625,223250.000000 +3,saturated,settings-blocked,baseline,13,128,211615.156250,211632.812500 +3,saturated,settings-blocked,baseline,14,128,278651.156250,278515.625000 +3,saturated,settings-blocked,optimized,14,256,306590.812500,306609.375000 +3,saturated,settings-blocked,optimized,15,256,249939.578125,249953.125000 +3,saturated,settings-blocked,baseline,15,128,199959.703125,199914.062500 +3,saturated,settings-blocked,baseline,16,128,197394.632812,197406.250000 +3,saturated,settings-blocked,optimized,16,256,263916.296875,263925.781250 +3,saturated,settings-blocked,optimized,17,256,216288.136719,216300.781250 +3,saturated,settings-blocked,baseline,17,128,235302.062500,235335.937500 +3,saturated,settings-blocked,baseline,18,128,195424.992188,195445.312500 +3,saturated,settings-blocked,optimized,18,256,224521.078125,224531.250000 +3,saturated,settings-blocked,optimized,19,256,255390.492188,255402.343750 +3,saturated,settings-blocked,baseline,19,128,229972.992188,229992.187500 +3,saturated,settings-blocked,baseline,20,128,220321.195312,220343.750000 +3,saturated,settings-blocked,optimized,20,256,250722.867188,250734.375000 +3,saturated,settings-blocked,optimized,21,256,209209.414062,209218.750000 +3,saturated,settings-blocked,baseline,21,128,190518.671875,190531.250000 +3,saturated,settings-blocked,baseline,22,128,281917.890625,281937.500000 +3,saturated,settings-blocked,optimized,22,256,247302.105469,247316.406250 +3,saturated,settings-blocked,optimized,23,256,273508.910156,273519.531250 +3,saturated,settings-blocked,baseline,23,128,195850.781250,195867.187500 +3,saturated,settings-blocked,baseline,24,128,223024.101562,223039.062500 +3,saturated,settings-blocked,optimized,24,256,238629.378906,238640.625000 +3,saturated,settings-blocked,optimized,25,256,274664.683594,274609.375000 +3,saturated,settings-blocked,baseline,25,128,227007.875000,227031.250000 +3,saturated,settings-blocked,baseline,26,128,266398.085938,266421.875000 +3,saturated,settings-blocked,optimized,26,256,218568.566406,218542.968750 +3,saturated,settings-blocked,optimized,27,256,236268.113281,236257.812500 +3,saturated,settings-blocked,baseline,27,128,252616.539062,252632.812500 +3,saturated,settings-blocked,baseline,28,128,198061.984375,198078.125000 +3,saturated,settings-blocked,optimized,28,256,224891.519531,224898.437500 +3,saturated,settings-blocked,optimized,29,256,200676.343750,200687.500000 +3,saturated,settings-blocked,baseline,29,128,231862.648438,231882.812500 +3,saturated,settings-blocked,baseline,30,128,231710.343750,231726.562500 +3,saturated,settings-blocked,optimized,30,256,272011.312500,271929.687500 +3,saturated,session-blocked,baseline,0,64,761223.421875,761265.625000 +3,saturated,session-blocked,optimized,0,128,333108.765625,333125.000000 +3,saturated,session-blocked,optimized,1,128,372782.414062,372812.500000 +3,saturated,session-blocked,baseline,1,64,595288.437500,595328.125000 +3,saturated,session-blocked,baseline,2,64,843880.640625,843343.750000 +3,saturated,session-blocked,optimized,2,128,386335.773438,386195.312500 +3,saturated,session-blocked,optimized,3,128,459879.906250,459898.437500 +3,saturated,session-blocked,baseline,3,64,536623.875000,536531.250000 +3,saturated,session-blocked,baseline,4,64,532296.140625,532328.125000 +3,saturated,session-blocked,optimized,4,128,457088.382812,457109.375000 +3,saturated,session-blocked,optimized,5,128,310524.617188,310546.875000 +3,saturated,session-blocked,baseline,5,64,611906.734375,611953.125000 +3,saturated,session-blocked,baseline,6,64,544216.390625,544250.000000 +3,saturated,session-blocked,optimized,6,128,367275.765625,367289.062500 +3,saturated,session-blocked,optimized,7,128,376565.562500,376585.937500 +3,saturated,session-blocked,baseline,7,64,735433.453125,735500.000000 +3,saturated,session-blocked,baseline,8,64,599619.062500,599671.875000 +3,saturated,session-blocked,optimized,8,128,342229.601562,342250.000000 +3,saturated,session-blocked,optimized,9,128,404121.984375,404140.625000 +3,saturated,session-blocked,baseline,9,64,498718.312500,498750.000000 +3,saturated,session-blocked,baseline,10,64,652366.421875,652421.875000 +3,saturated,session-blocked,optimized,10,128,423456.265625,423476.562500 +3,saturated,session-blocked,optimized,11,128,415696.664062,415726.562500 +3,saturated,session-blocked,baseline,11,64,588542.203125,588578.125000 +3,saturated,session-blocked,baseline,12,64,541793.421875,541843.750000 +3,saturated,session-blocked,optimized,12,128,381326.812500,381343.750000 +3,saturated,session-blocked,optimized,13,128,455767.976562,455796.875000 +3,saturated,session-blocked,baseline,13,64,712296.890625,711250.000000 +3,saturated,session-blocked,baseline,14,64,567834.218750,567890.625000 +3,saturated,session-blocked,optimized,14,128,431173.398438,431195.312500 +3,saturated,session-blocked,optimized,15,128,320480.414062,320414.062500 +3,saturated,session-blocked,baseline,15,64,702135.890625,702156.250000 +3,saturated,session-blocked,baseline,16,64,626951.781250,626984.375000 +3,saturated,session-blocked,optimized,16,128,343775.960938,343671.875000 +3,saturated,session-blocked,optimized,17,128,348166.929688,348187.500000 +3,saturated,session-blocked,baseline,17,64,516874.875000,516937.500000 +3,saturated,session-blocked,baseline,18,64,753214.437500,753281.250000 +3,saturated,session-blocked,optimized,18,128,426825.054688,426851.562500 +3,saturated,session-blocked,optimized,19,128,395443.914062,394921.875000 +3,saturated,session-blocked,baseline,19,64,567133.187500,567171.875000 +3,saturated,session-blocked,baseline,20,64,576156.828125,576203.125000 +3,saturated,session-blocked,optimized,20,128,372449.773438,372468.750000 +3,saturated,session-blocked,optimized,21,128,326062.484375,326078.125000 +3,saturated,session-blocked,baseline,21,64,836959.875000,837000.000000 +3,saturated,session-blocked,baseline,22,64,587391.156250,587437.500000 +3,saturated,session-blocked,optimized,22,128,460389.476562,460414.062500 +3,saturated,session-blocked,optimized,23,128,472240.859375,472265.625000 +3,saturated,session-blocked,baseline,23,64,590212.968750,590250.000000 +3,saturated,session-blocked,baseline,24,64,1130643.109375,1130687.500000 +3,saturated,session-blocked,optimized,24,128,886483.437500,886265.625000 +3,saturated,session-blocked,optimized,25,128,806909.085938,806937.500000 +3,saturated,session-blocked,baseline,25,64,522511.093750,522578.125000 +3,saturated,session-blocked,baseline,26,64,559642.062500,559687.500000 +3,saturated,session-blocked,optimized,26,128,408645.437500,408671.875000 +3,saturated,session-blocked,optimized,27,128,419331.671875,419359.375000 +3,saturated,session-blocked,baseline,27,64,742747.093750,742796.875000 +3,saturated,session-blocked,baseline,28,64,800520.437500,800593.750000 +3,saturated,session-blocked,optimized,28,128,375996.906250,376015.625000 +3,saturated,session-blocked,optimized,29,128,333864.960938,333890.625000 +3,saturated,session-blocked,baseline,29,64,669582.671875,669625.000000 +3,saturated,session-blocked,baseline,30,64,539048.421875,539093.750000 +3,saturated,session-blocked,optimized,30,128,477886.718750,477906.250000 +3,saturated,both-blocked,baseline,0,256,249993.007812,249929.687500 +3,saturated,both-blocked,optimized,0,8388608,4.885451,4.882932 +3,saturated,both-blocked,optimized,1,8388608,4.092284,4.092574 +3,saturated,both-blocked,baseline,1,256,267448.566406,267460.937500 +3,saturated,both-blocked,baseline,2,256,196203.496094,196214.843750 +3,saturated,both-blocked,optimized,2,8388608,4.752582,4.751086 +3,saturated,both-blocked,optimized,3,8388608,4.127844,4.128098 +3,saturated,both-blocked,baseline,3,256,244205.753906,244214.843750 +3,saturated,both-blocked,baseline,4,256,255979.277344,255988.281250 +3,saturated,both-blocked,optimized,4,8388608,4.522295,4.522681 +3,saturated,both-blocked,optimized,5,8388608,4.218712,4.218936 +3,saturated,both-blocked,baseline,5,256,209563.625000,209570.312500 +3,saturated,both-blocked,baseline,6,256,248262.816406,248269.531250 +3,saturated,both-blocked,optimized,6,8388608,5.675777,5.656362 +3,saturated,both-blocked,optimized,7,8388608,4.429706,4.430175 +3,saturated,both-blocked,baseline,7,256,237642.535156,237652.343750 +3,saturated,both-blocked,baseline,8,256,250439.773438,250453.125000 +3,saturated,both-blocked,optimized,8,8388608,4.570862,4.571199 +3,saturated,both-blocked,optimized,9,8388608,4.210919,4.211187 +3,saturated,both-blocked,baseline,9,256,274911.386719,274921.875000 +3,saturated,both-blocked,baseline,10,256,235177.175781,235191.406250 +3,saturated,both-blocked,optimized,10,8388608,4.721509,4.719019 +3,saturated,both-blocked,optimized,11,8388608,4.147002,4.146099 +3,saturated,both-blocked,baseline,11,256,280926.242188,280941.406250 +3,saturated,both-blocked,baseline,12,256,218959.355469,218972.656250 +3,saturated,both-blocked,optimized,12,8388608,4.348881,4.349232 +3,saturated,both-blocked,optimized,13,8388608,3.888498,3.888845 +3,saturated,both-blocked,baseline,13,256,220148.175781,220164.062500 +3,saturated,both-blocked,baseline,14,256,267312.992188,267328.125000 +3,saturated,both-blocked,optimized,14,8388608,4.272354,4.272819 +3,saturated,both-blocked,optimized,15,8388608,4.195991,4.196405 +3,saturated,both-blocked,baseline,15,256,200759.656250,200769.531250 +3,saturated,both-blocked,baseline,16,256,221464.621094,221476.562500 +3,saturated,both-blocked,optimized,16,8388608,4.544758,4.545093 +3,saturated,both-blocked,optimized,17,8388608,5.682233,5.682588 +3,saturated,both-blocked,baseline,17,256,217323.777344,217332.031250 +3,saturated,both-blocked,baseline,18,256,238685.785156,238695.312500 +3,saturated,both-blocked,optimized,18,8388608,3.993652,3.993988 +3,saturated,both-blocked,optimized,19,8388608,5.169066,5.169392 +3,saturated,both-blocked,baseline,19,256,269474.292969,269488.281250 +3,saturated,both-blocked,baseline,20,256,220271.917969,220285.156250 +3,saturated,both-blocked,optimized,20,8388608,4.845492,4.844666 +3,saturated,both-blocked,optimized,21,8388608,4.622312,4.609704 +3,saturated,both-blocked,baseline,21,256,221247.132812,221351.562500 +3,saturated,both-blocked,baseline,22,256,243168.847656,243183.593750 +3,saturated,both-blocked,optimized,22,8388608,4.056126,4.056334 +3,saturated,both-blocked,optimized,23,8388608,4.347240,4.347563 +3,saturated,both-blocked,baseline,23,256,473874.921875,473886.718750 +3,saturated,both-blocked,baseline,24,256,251322.082031,251335.937500 +3,saturated,both-blocked,optimized,24,8388608,9.357525,9.357929 +3,saturated,both-blocked,optimized,25,8388608,4.254995,4.255295 +3,saturated,both-blocked,baseline,25,256,209809.316406,209820.312500 +3,saturated,both-blocked,baseline,26,256,315812.828125,315824.218750 +3,saturated,both-blocked,optimized,26,8388608,3.778047,3.778338 +3,saturated,both-blocked,optimized,27,8388608,5.252404,5.252719 +3,saturated,both-blocked,baseline,27,256,201140.648438,201105.468750 +3,saturated,both-blocked,baseline,28,256,234963.847656,234976.562500 +3,saturated,both-blocked,optimized,28,8388608,5.389430,5.389810 +3,saturated,both-blocked,optimized,29,8388608,4.804401,4.804611 +3,saturated,both-blocked,baseline,29,256,219390.476562,219398.437500 +3,saturated,both-blocked,baseline,30,256,244684.199219,244460.937500 +3,saturated,both-blocked,optimized,30,8388608,3.729743,3.730059 diff --git a/tests/runtime/config_store_reference.c b/tests/runtime/config_store_reference.c new file mode 100644 index 00000000..fa5729e8 --- /dev/null +++ b/tests/runtime/config_store_reference.c @@ -0,0 +1,233 @@ +/* Frozen baseline from 666999650f8afb405fa34dbf1ee0b98cd7145117. + * Keep independent from the production eligibility optimization. */ +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include + +#define SAVE_DELAY_NS 300000000ull +#define RETRY_DELAY_NS 1000000000ull + +static uint64_t hash_bytes(uint64_t hash, const void *data, size_t size) { + const unsigned char *bytes = data; + for (size_t i = 0; i < size; ++i) { + hash ^= bytes[i]; + hash *= 1099511628211ull; + } + return hash; +} + +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(settings->model); + HASH_FIELD(settings->window); + 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, + 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, + 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; +} + +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(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->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) + 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) + 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; +} + +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); + 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); + 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 || + 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) { + 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) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + 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) { + 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) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + return false; + } + app->session_saved_hash = hash; + app->session_observed_hash = hash; + app->session_store_valid = true; + return true; +} + +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->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; + } 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->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; + 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; +} + +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->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); + 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/tests/runtime/config_store_support.c b/tests/runtime/config_store_support.c new file mode 100644 index 00000000..215cb0a0 --- /dev/null +++ b/tests/runtime/config_store_support.c @@ -0,0 +1,136 @@ +#include "config_store_test.h" + +#include +#include + +static ConfigTrace *active_trace; + +/* Locale selection is unrelated to update eligibility; keep loads stable. */ +bool bongo_cat_system_language(BongoCatLanguage *language) { + (void)language; + return false; +} + +void config_test_trace(ConfigTrace *trace) { + active_trace = trace; +} + +void config_test_clear(ConfigTrace *trace) { + for (size_t i = 0; i < trace->count; ++i) free(trace->saves[i].json); + trace->count = 0; +} + +static ConfigSave *attempt(bool settings) { + if (!active_trace || active_trace->count >= 2) abort(); + ConfigSave *save = &active_trace->saves[active_trace->count++]; + *save = (ConfigSave){.settings = settings, .time = active_trace->time}; + return save; +} + +static BongoCatResult finish(ConfigSave *save, const char *path, + BongoCatResult result) { + save->result = result; + if (result != BONGO_CAT_OK) return result; + FILE *file = fopen(path, "rb"); + if (!file || fseek(file, 0, SEEK_END)) abort(); + long length = ftell(file); + if (length < 0 || fseek(file, 0, SEEK_SET)) abort(); + save->length = (size_t)length; + save->json = malloc(save->length + 1); + if (!save->json || fread(save->json, 1, save->length, file) != + save->length || fclose(file)) abort(); + save->json[save->length] = '\0'; + if (remove(path)) abort(); + return result; +} + +BongoCatResult config_test_settings_save(const char *path, + const BongoCatSettings *settings, BongoCatError *error) { + ConfigSave *save = attempt(true); + if (active_trace->failures & 1) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "injected settings failure"); + return finish(save, path, BONGO_CAT_ERROR_IO); + } + return finish(save, path, bongo_cat_settings_save(path, settings, error)); +} + +BongoCatResult config_test_session_save(const char *path, + const BongoCatSessionState *session, BongoCatError *error) { + ConfigSave *save = attempt(false); + if (active_trace->failures & 2) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "injected session failure"); + return finish(save, path, BONGO_CAT_ERROR_IO); + } + return finish(save, path, bongo_cat_session_save(path, session, error)); +} + +static void fill_saturated(BongoCatApp *app) { + BongoCatSettings *settings = &app->settings; + BongoCatSessionState *session = &app->session; + settings->behavior_shortcut_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + session->active_behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + for (size_t i = 0; i < BONGO_CAT_BEHAVIOR_BINDING_CAP; ++i) { + BongoCatBehaviorShortcut *entry = &settings->behavior_shortcuts[i]; + snprintf(entry->id, sizeof(entry->id), "standard:expression:%zu", i); + snprintf(entry->label, sizeof(entry->label), "Expression %zu", i); + snprintf(entry->shortcut, sizeof(entry->shortcut), "Ctrl+Alt+%zu", i); + BongoCatActiveBehavior *active = &session->active_behaviors[i]; + snprintf(active->model_id, sizeof(active->model_id), "standard"); + snprintf(active->behavior_id, sizeof(active->behavior_id), "%s", entry->id); + } + settings->model_label_count = settings->removed_model_count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + snprintf(settings->model_labels[i].id, BONGO_CAT_ID_CAP, "model-%zu", i); + snprintf(settings->model_labels[i].label, BONGO_CAT_ID_CAP, "Model %zu", i); + snprintf(settings->removed_models[i].id, BONGO_CAT_ID_CAP, "removed-%zu", i); + } + session->additional_model_count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + for (size_t i = 0; i < BONGO_CAT_ADDITIONAL_MODEL_CAP; ++i) + snprintf(session->additional_model_ids[i], BONGO_CAT_ID_CAP, "pet-%zu", i); + bongo_cat_settings_validate(settings); + bongo_cat_session_validate(session); +} + +BongoCatApp *config_test_app(bool saturated, bool clean) { + BongoCatApp *app = calloc(1, sizeof(*app)); + if (!app) abort(); + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + if (saturated) fill_saturated(app); + snprintf(app->settings_path, sizeof(app->settings_path), + "config-store-test-settings.json"); + snprintf(app->session_path, sizeof(app->session_path), + "config-store-test-session.json"); + bongo_cat_config_store_load(app); + if (clean) { + app->settings_saved_hash = app->settings_observed_hash; + app->session_saved_hash = app->session_observed_hash; + app->settings_store_valid = app->session_store_valid = true; + } + return app; +} + +void config_test_compare(const BongoCatApp *left, const BongoCatApp *right) { +#define FIELD(name) CHECK(!memcmp(&left->name, &right->name, sizeof(left->name))) + FIELD(settings); FIELD(session); + FIELD(settings_observed_hash); FIELD(session_observed_hash); + FIELD(settings_saved_hash); FIELD(session_saved_hash); + FIELD(settings_save_due_ns); FIELD(session_save_due_ns); + FIELD(settings_store_valid); FIELD(session_store_valid); + FIELD(settings_store_blocked); FIELD(session_store_blocked); +#undef FIELD +} + +void config_test_compare_trace(const ConfigTrace *left, + const ConfigTrace *right) { + CHECK(left->count == right->count); + for (size_t i = 0; i < left->count && i < right->count; ++i) { + const ConfigSave *a = &left->saves[i], *b = &right->saves[i]; + CHECK(a->settings == b->settings); + CHECK(a->time == b->time); + CHECK(a->result == b->result); + CHECK(a->length == b->length); + if (a->length == b->length && a->length) + CHECK(!memcmp(a->json, b->json, a->length)); + } +} diff --git a/tests/runtime/config_store_test.h b/tests/runtime/config_store_test.h new file mode 100644 index 00000000..ed32ff96 --- /dev/null +++ b/tests/runtime/config_store_test.h @@ -0,0 +1,36 @@ +#ifndef BONGO_CAT_CONFIG_STORE_TEST_H +#define BONGO_CAT_CONFIG_STORE_TEST_H + +#include "runtime.h" +#include "test.h" + +typedef struct ConfigSave { + bool settings; + uint64_t time; + BongoCatResult result; + size_t length; + char *json; +} ConfigSave; + +typedef struct ConfigTrace { + ConfigSave saves[2]; + size_t count; + unsigned failures; + uint64_t time; +} ConfigTrace; + +void config_reference_load(BongoCatApp *app); +void config_reference_update(BongoCatApp *app, uint64_t now); +void config_reference_flush(BongoCatApp *app); +BongoCatApp *config_test_app(bool saturated, bool clean); +void config_test_trace(ConfigTrace *trace); +void config_test_clear(ConfigTrace *trace); +void config_test_compare(const BongoCatApp *left, const BongoCatApp *right); +void config_test_compare_trace(const ConfigTrace *left, + const ConfigTrace *right); +BongoCatResult config_test_settings_save(const char *path, + const BongoCatSettings *settings, BongoCatError *error); +BongoCatResult config_test_session_save(const char *path, + const BongoCatSessionState *session, BongoCatError *error); + +#endif diff --git a/tests/runtime/test_config_store.c b/tests/runtime/test_config_store.c new file mode 100644 index 00000000..9ec95ed0 --- /dev/null +++ b/tests/runtime/test_config_store.c @@ -0,0 +1,232 @@ +#include "config_store_test.h" + +#include +#include + +int bongo_cat_test_failures; +static size_t steps, attempts, json_bytes; + +typedef struct Pair { + BongoCatApp *current, *reference; + ConfigTrace actual, expected; +} Pair; + +#define SET(pair, field, value) do { \ + (pair)->current->field = (pair)->reference->field = (value); \ +} while (0) + +static Pair create_pair(bool saturated, bool clean) { + Pair pair = {0}; + pair.current = config_test_app(saturated, clean); + pair.reference = malloc(sizeof(*pair.reference)); + if (!pair.reference) abort(); + memcpy(pair.reference, pair.current, sizeof(*pair.reference)); + return pair; +} + +static size_t step(Pair *pair, uint64_t now, bool flush, unsigned failures) { + config_test_clear(&pair->actual); + config_test_clear(&pair->expected); + pair->actual.time = pair->expected.time = now; + pair->actual.failures = pair->expected.failures = failures; + config_test_trace(&pair->actual); + if (flush) bongo_cat_config_store_flush(pair->current); + else bongo_cat_config_store_update(pair->current, now); + config_test_trace(&pair->expected); + if (flush) config_reference_flush(pair->reference); + else config_reference_update(pair->reference, now); + config_test_compare(pair->current, pair->reference); + config_test_compare_trace(&pair->actual, &pair->expected); + ++steps; + attempts += pair->actual.count; + for (size_t i = 0; i < pair->actual.count; ++i) + json_bytes += pair->actual.saves[i].length; + return pair->actual.count; +} + +static void destroy_pair(Pair *pair) { + config_test_clear(&pair->actual); + config_test_clear(&pair->expected); + free(pair->current); + free(pair->reference); +} + +static void exercise(bool saturated, unsigned mask) { + Pair pair = create_pair(saturated, false); + SET(&pair, secondary_pet, (mask & 1) != 0); + SET(&pair, settings_store_blocked, (mask & 2) != 0); + SET(&pair, session_store_blocked, (mask & 4) != 0); + bool settings = !(mask & 3), session = !(mask & 4); + size_t eligible = (size_t)settings + (size_t)session; + /* A missing store writes once at exactly 300 ms, never one ns early. */ + CHECK(step(&pair, 0, false, 0) == 0); + CHECK(pair.current->settings_save_due_ns == (settings ? 300000000 : 0)); + CHECK(pair.current->session_save_due_ns == (session ? 300000000 : 0)); + CHECK(step(&pair, 299999999, false, 0) == 0); + CHECK(step(&pair, 300000000, false, 0) == eligible); + CHECK(step(&pair, 900000000, false, 0) == 0); + + /* Reverting to saved values still preserves the baseline's queued save. */ + bool mirror = pair.current->settings.model.mirror; + int x = pair.current->session.window.x; + SET(&pair, settings.model.mirror, !mirror); + SET(&pair, session.window.x, x + 1); + CHECK(step(&pair, 1000000000, false, 0) == 0); + CHECK(step(&pair, 1100000000, false, 0) == 0); + SET(&pair, settings.model.mirror, mirror); + SET(&pair, session.window.x, x); + CHECK(step(&pair, 1200000000, false, 0) == 0); + CHECK(step(&pair, 1499999999, false, 0) == 0); + CHECK(step(&pair, 1500000000, false, 0) == eligible); + + /* Failed saves retain their exact one-second retry, independently. */ + SET(&pair, settings.model.mirror, !mirror); + SET(&pair, session.window.x, x + 2); + CHECK(step(&pair, 2000000000, false, 0) == 0); + CHECK(step(&pair, 2300000000, false, 3) == eligible); + CHECK(pair.current->settings_save_due_ns == (settings ? 3300000000ull : 0)); + CHECK(pair.current->session_save_due_ns == (session ? 3300000000ull : 0)); + CHECK(step(&pair, 3299999999, false, 0) == 0); + CHECK(step(&pair, 3300000000, false, 0) == eligible); + + /* Validation during a failed save changes state; its new debounce wins. */ + SET(&pair, settings.model.max_fps, -7); + SET(&pair, session.window.width, 1); + CHECK(step(&pair, 4000000000, false, 0) == 0); + CHECK(step(&pair, 4300000000, false, 3) == eligible); + CHECK(step(&pair, 4300000001, false, 0) == 0); + CHECK(step(&pair, 4600000000, false, 0) == 0); + CHECK(step(&pair, 4600000001, false, 0) == eligible); + + /* Flush keeps observed hashes current even for ineligible stores. */ + SET(&pair, settings.model.mirror, mirror); + SET(&pair, session.window.x, x + 3); + CHECK(step(&pair, 5000000000, false, 0) == 0); + CHECK(step(&pair, 5100000000, true, 0) == eligible); + CHECK(!pair.current->settings_save_due_ns); + CHECK(!pair.current->session_save_due_ns); + uint64_t settings_observed = pair.current->settings_observed_hash; + uint64_t session_observed = pair.current->session_observed_hash; + CHECK(step(&pair, 5100000001, true, 0) == 0); + CHECK(pair.current->settings_observed_hash == settings_observed); + CHECK(pair.current->session_observed_hash == session_observed); + + /* Re-enable eligibility; any unsaved values retain the usual debounce. */ + SET(&pair, secondary_pet, false); + SET(&pair, settings_store_blocked, false); + SET(&pair, session_store_blocked, false); + CHECK(step(&pair, 6000000000, false, 0) == 0); + CHECK(step(&pair, 6299999999, false, 0) == 0); + CHECK(step(&pair, 6300000000, false, 0) == 2 - eligible); + destroy_pair(&pair); +} + +static void guards_and_flush_failure(void) { + Pair pair = create_pair(false, true); + SET(&pair, settings.model.mirror, !pair.current->settings.model.mirror); + SET(&pair, session.window.x, 17); + CHECK(step(&pair, 1, false, 0) == 0); + uint64_t due = pair.current->settings_save_due_ns; + SET(&pair, smoke, true); + CHECK(step(&pair, 9000000000, false, 0) == 0); + CHECK(step(&pair, 9000000000, true, 0) == 0); + CHECK(pair.current->settings_save_due_ns == due); + SET(&pair, smoke, false); + for (unsigned which = 0; which < 2; ++which) { + char *a = which ? pair.current->session_path : pair.current->settings_path; + char *b = which ? pair.reference->session_path : pair.reference->settings_path; + char first = a[0]; + a[0] = b[0] = '\0'; + CHECK(step(&pair, 9000000000, false, 0) == 0); + CHECK(step(&pair, 9000000000, true, 0) == 0); + CHECK(pair.current->settings_save_due_ns == due); + a[0] = b[0] = first; + } + CHECK(step(&pair, 9000000000, true, 3) == 2); + CHECK(!pair.current->settings_save_due_ns && !pair.current->session_save_due_ns); + CHECK(step(&pair, 9000000001, false, 0) == 0); + CHECK(step(&pair, 9300000000, false, 0) == 0); + CHECK(step(&pair, 9300000001, false, 1) == 2); + CHECK(pair.current->settings_save_due_ns == 10300000001ull); + CHECK(!pair.current->session_save_due_ns); + /* Blocking an already-queued retry preserves its deadline until unblocked. */ + SET(&pair, settings_store_blocked, true); + CHECK(step(&pair, 11000000000, false, 0) == 0); + CHECK(pair.current->settings_save_due_ns == 10300000001ull); + SET(&pair, settings_store_blocked, false); + CHECK(step(&pair, 11000000001, false, 0) == 1); + destroy_pair(&pair); +} + +static void mixed_sequence(bool saturated) { + Pair pair = create_pair(saturated, true); + uint32_t random = 0x4f07cafe; + uint64_t now = 0; + for (unsigned i = 0; i < 512; ++i) { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + switch (random % 12) { + case 0: + SET(&pair, settings.window.pass_through, + !pair.current->settings.window.pass_through); + break; + case 1: SET(&pair, session.window.y, (int)(random % 1000)); break; + case 2: SET(&pair, secondary_pet, (random & 64) != 0); break; + case 3: SET(&pair, settings_store_blocked, (random & 128) != 0); break; + case 4: SET(&pair, session_store_blocked, (random & 256) != 0); break; + case 5: + SET(&pair, settings.behavior_shortcut_count, + saturated ? BONGO_CAT_BEHAVIOR_BINDING_CAP + 1 : 0); + break; + case 6: + SET(&pair, session.active_behavior_count, + saturated ? BONGO_CAT_BEHAVIOR_BINDING_CAP + 1 : 0); + break; + case 7: + SET(&pair, settings.extensions_json[0], '{'); + SET(&pair, settings.extensions_json[1], '}'); + SET(&pair, settings.extensions_json[2], '\0'); + break; + case 8: + SET(&pair, session.window.opacity_percent, (float)(random % 150)); + break; + case 9: + if (saturated) { + SET(&pair, settings.behavior_shortcuts[255].label[0], 'A' + i % 26); + SET(&pair, session.active_behaviors[255].behavior_id[0], 'a' + i % 26); + } + break; + default: break; + } + now += (random % 3 == 0) ? 1000000000ull : 17000000ull; + (void)step(&pair, now, random % 11 == 0, (random >> 16) & 3); + } + SET(&pair, secondary_pet, false); + SET(&pair, settings_store_blocked, false); + SET(&pair, session_store_blocked, false); + (void)step(&pair, now + 1, true, 0); + /* Preserve unsigned clock-wrap behavior as well as ordinary deadlines. */ + SET(&pair, settings.model.mirror, !pair.current->settings.model.mirror); + SET(&pair, session.window.x, pair.current->session.window.x + 1); + CHECK(step(&pair, UINT64_MAX - 100, false, 0) == 2); + destroy_pair(&pair); +} + +int main(void) { + SDL_SetLogPriority(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_CRITICAL); + bongo_cat_config_store_update(NULL, 0); + config_reference_update(NULL, 0); + bongo_cat_config_store_flush(NULL); + config_reference_flush(NULL); + for (unsigned saturated = 0; saturated < 2; ++saturated) + for (unsigned mask = 0; mask < 8; ++mask) + exercise(saturated != 0, mask); + guards_and_flush_failure(); + mixed_sequence(false); + mixed_sequence(true); + printf("config-store: %zu differential steps, %zu save attempts, " + "%zu JSON bytes compared, %d failures\n", steps, attempts, + json_bytes, bongo_cat_test_failures); + return bongo_cat_test_failures ? EXIT_FAILURE : EXIT_SUCCESS; +}