feat(lcd): animate fortune results with shared GFX layers

This commit is contained in:
chenjintao
2026-10-10 11:36:54 +08:00
parent bf06618848
commit ee32b4d63f
26 changed files with 3063 additions and 11 deletions
+11 -1
View File
@@ -234,7 +234,7 @@ Use a clear conventional format:
和预览,不把 tq-cat 的实现或其他目录里的旧文件当成当前工程。
2. 优先查看 `lcd_direct.c`、领域表情目录、平台公共接口、构建列表和 Kconfig。
图层制作还需阅读 `split_idle_expression.py`、`compress_idle_expression.py`。
3. 当前是待机/listening GFX 与其他 PNG 混合后端;未接入 esp_emote_expression 状态机,
3. 当前是待机/listening/抽签 GFX 与其他 PNG 混合后端;未接入 esp_emote_expression 状态机,
也未整体迁移到 LVGL。不要凭依赖存在或请求优先级字段推断功能已经实现。
### 必须保持的正确性
@@ -314,6 +314,15 @@ Use a clear conventional format:
22. 实机验收包括待机/表情切换、跨分钟、充电、缓存启动、唤醒与网络并行、
取消和超时。区分源码检查、主机测试、编译、设备日志与用户观感证据。
没有 TE 同步,不能承诺完全无撕裂;用户满意也不等于已量化稳定帧率。
23. 抽签还需阅读 `docs/抽签分层动画开发说明.zh-CN.md`。共享六图层与五份签文,
一次只解压共享图层及所选签文;LZ4 与旧四字节 RLE 不是同一格式。
`prepare_fortune_expression.py --check` 验证生成一致性/往返,
`test_lcd_fortune_gfx.py` 验证实际 C 解码、几何、取消和失败释放。
不能把所有结果都常驻或缓存全屏中间帧;退出先解绑 GFX 再释放 PSRAM。
动画前准备失败才可 PNG 后备,取消/传输失败不得重播旧六帧。
保留原领域结果选择和六步匹配;不得在平台重新随机抽签或改签文语义。
保持 `(56,20,208,220)` 安全窗口,覆盖放大爪尖且避开时间/电池;
图层尺寸或几何变化须同步生成器、预览与测试,不只替换 PNG。
回归命令(仓库根目录):
@@ -322,6 +331,7 @@ python3 tools/test_lcd_status_hud.py
python3 tools/test_lcd_emote_transport.py
python3 tools/test_lcd_emote_timeline.py
python3 tools/test_lcd_listening_gfx.py
python3 tools/test_lcd_fortune_gfx.py
```
提交只包含任务相关文件,遵守主题分支与原有提交规则;不要提交临时补丁脚本、
@@ -659,6 +659,20 @@ static void handle_command_line(char *line)
handle_prepare_rotate(key_version);
} else if (strcmp(cmd, "lcd_test") == 0) {
handle_lcd_test();
} else if (strcmp(cmd, "lcd_fortune_test") == 0) {
/* 有界、仅显示测试;不调用云抽签、不写 NVS、不改变语义选择映射。 */
if (argc != 2 || argv[1][0] < '0' || argv[1][0] > '4' || argv[1][1] != '\0') {
cli_write_error("invalid_fortune_index");
return;
}
const domain_expression_request_t request = {
.id = DOMAIN_EXPRESSION_CHOUQIAN,
.trigger = DOMAIN_EXPRESSION_TRIGGER_FACTORY_LCD_TEST,
.chouqian_result_index_valid = true,
.chouqian_result_index = (size_t)(argv[1][0] - '0'),
};
if (domain_runtime_request_expression(&request) == ESP_OK) cli_write_ok();
else cli_write_error("lcd_fortune_test_failed");
} else {
cli_write_error("unknown_command");
}
+2
View File
@@ -31,6 +31,8 @@ if(CONFIG_TQ_LCD_EMOTE_GFX_TEST)
list(APPEND platform_srcs
"src/lcd_emote_assets.c"
"src/lcd_emote_listening.c"
"src/lcd_emote_fortune.c"
"src/generated/emote_gfx/fortune_assets.c"
"src/generated/emote_gfx/idle_base.c"
"src/generated/emote_gfx/idle_eyes_open.c"
"src/generated/emote_gfx/listening_base.c"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,114 @@
{
"canvas": [
320,
240
],
"window": [
56,
20,
208,
220
],
"layers": [
{
"name": "pile",
"width": 208,
"height": 76,
"x": 56,
"y": 164,
"raw_bytes": 47424,
"lz4_bytes": 9417
},
{
"name": "back",
"width": 63,
"height": 113,
"x": 0,
"y": 0,
"raw_bytes": 21357,
"lz4_bytes": 4179
},
{
"name": "edge",
"width": 41,
"height": 110,
"x": 0,
"y": 0,
"raw_bytes": 13530,
"lz4_bytes": 4389
},
{
"name": "front",
"width": 100,
"height": 190,
"x": 0,
"y": 0,
"raw_bytes": 57000,
"lz4_bytes": 10774
},
{
"name": "question",
"width": 27,
"height": 50,
"x": 0,
"y": 0,
"raw_bytes": 4050,
"lz4_bytes": 817
},
{
"name": "rod",
"width": 169,
"height": 56,
"x": 0,
"y": 40,
"raw_bytes": 28392,
"lz4_bytes": 2094
},
{
"name": "text_buyun",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2638
},
{
"name": "text_heping",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2851
},
{
"name": "text_shangshang",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2069
},
{
"name": "text_xiaoji",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2210
},
{
"name": "text_zhongshang",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2109
}
],
"note": "黑背景归零;共享爪子,签文字形来自原图;旧 PNG 保留"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,32 @@
#pragma once
#include "lcd_emote_assets.h"
/* 平台私有图层与几何;领域仍选择资产序列,不引入业务状态机。 */
enum { LCD_FORTUNE_PILE, LCD_FORTUNE_BACK, LCD_FORTUNE_EDGE,
LCD_FORTUNE_FRONT, LCD_FORTUNE_QUESTION, LCD_FORTUNE_ROD,
LCD_FORTUNE_SHARED_COUNT, LCD_FORTUNE_RESULT_COUNT = 5 };
#define LCD_FORTUNE_X 56
#define LCD_FORTUNE_Y 20
#define LCD_FORTUNE_WIDTH 208U
#define LCD_FORTUNE_HEIGHT 220U
#define LCD_FORTUNE_MOTION_MS 2500U
#define LCD_FORTUNE_TOTAL_MS 4000U
#define LCD_FORTUNE_HOLD_MS 1500U
#define LCD_FORTUNE_STEP_MS 34U
typedef struct {
uint16_t width, height, x, y;
size_t compressed_size;
const uint8_t *data;
} lcd_fortune_asset_t;
extern const lcd_fortune_asset_t lcd_fortune_shared[LCD_FORTUNE_SHARED_COUNT];
extern const lcd_fortune_asset_t lcd_fortune_text[LCD_FORTUNE_RESULT_COUNT];
/* 有界 LZ4 block 解码,无动态内存;拒绝截断、无效回指及长度不符。 */
bool lcd_emote_fortune_decode(const uint8_t *src, size_t size, uint8_t *dst, size_t capacity);
extern const char *const lcd_fortune_result_dirs[LCD_FORTUNE_RESULT_COUNT];
/* RGB565 高字节在前 + 独立 A8;所有像素由调用方拥有。 */
typedef struct { uint16_t width, height; const uint8_t *pixels; } lcd_fortune_texture_t;
/* 输出不透明 RGB565,完整重画固定局部窗口,确保旧轮廓恢复而不残影。 */
bool lcd_emote_fortune_build_frame(uint8_t *dst, size_t capacity,
const lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT],
const lcd_fortune_texture_t *text, uint32_t time_ms);
File diff suppressed because it is too large Load Diff
+207
View File
@@ -38,6 +38,7 @@
#include "gfx.h"
#include "lcd_emote_assets.h"
#include "lcd_emote_listening.h"
#include "lcd_emote_fortune.h"
#endif
#define LCD_DIRECT_SPI_HOST SPI2_HOST
@@ -970,6 +971,7 @@ static esp_err_t lcd_direct_listening_prepare(void)
static esp_err_t lcd_direct_emote_select_face(bool listening)
{
const lcd_emote_rle_asset_t *base = listening ? &listening_base_rle_asset : &idle_base_rle_asset;
ESP_RETURN_ON_ERROR(gfx_obj_set_visible(s_emote_background, true), TAG, "show face base failed");
ESP_RETURN_ON_ERROR(gfx_img_set_src(s_emote_background,
listening ? &s_listening_base_image : &s_emote_base_image), TAG, "switch face base failed");
ESP_RETURN_ON_ERROR(gfx_obj_set_pos(s_emote_background, base->x, base->y), TAG, "switch face position failed");
@@ -1166,6 +1168,203 @@ static esp_err_t lcd_direct_play_emote_face_until_pending(const lcd_direct_reque
}
return result != ESP_OK ? result : lock_err;
}
typedef struct {
lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT], text;
uint8_t *pixels[LCD_FORTUNE_SHARED_COUNT + 1], *frame;
gfx_image_dsc_t image, rod_image;
gfx_obj_t *object;
} lcd_direct_fortune_scene_t;
/* 极端锁/解绑故障保留已绑定资源到重启,不能释放给 GFX 的悬空指针。 */
static lcd_direct_fortune_scene_t *s_fortune_orphan;
static int lcd_direct_fortune_sequence_index(const lcd_direct_request_t *request)
{
if (request == NULL || request->step_count != 6) return -1;
for (size_t i = 0; i < 6; ++i) {
const platform_lcd_presentation_step_t *step = &request->steps[i];
if (step->asset_dir == NULL || step->frame_index != i % 3 ||
step->icon_id != PLATFORM_LCD_PRESENTATION_NO_ICON ||
step->delay_ms != (i == 5 ? 1500U : 500U)) return -1;
if (i < 3 && strcmp(step->asset_dir, "chouqian_qianzhi") != 0) return -1;
if (i >= 3 && strcmp(step->asset_dir, request->steps[3].asset_dir) != 0) return -1;
}
for (size_t i = 0; i < LCD_FORTUNE_RESULT_COUNT; ++i)
if (strcmp(request->steps[3].asset_dir, lcd_fortune_result_dirs[i]) == 0) return (int)i;
return -1; /* 不匹配的通用步骤仍使用原 PNG 播放,不偷换其他动作。 */
}
static void lcd_direct_fortune_free(lcd_direct_fortune_scene_t *scene)
{
if (scene == NULL) return;
for (size_t i = 0; i <= LCD_FORTUNE_SHARED_COUNT; ++i) free(scene->pixels[i]);
free(scene->frame);
free(scene);
}
static lcd_direct_fortune_scene_t *lcd_direct_fortune_prepare(size_t index)
{
if (index >= LCD_FORTUNE_RESULT_COUNT) return NULL;
lcd_direct_fortune_scene_t *scene = heap_caps_calloc(1, sizeof(*scene), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene == NULL) return NULL;
for (size_t i = 0; i <= LCD_FORTUNE_SHARED_COUNT; ++i) {
const lcd_fortune_asset_t *asset = i < LCD_FORTUNE_SHARED_COUNT ?
&lcd_fortune_shared[i] : &lcd_fortune_text[index];
size_t bytes = (size_t)asset->width * asset->height * 3;
scene->pixels[i] = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene->pixels[i] == NULL || !lcd_emote_fortune_decode(asset->data,
asset->compressed_size, scene->pixels[i], bytes)) {
lcd_direct_fortune_free(scene);
return NULL;
}
lcd_fortune_texture_t texture = {asset->width, asset->height, scene->pixels[i]};
if (i < LCD_FORTUNE_SHARED_COUNT) scene->shared[i] = texture;
else scene->text = texture;
}
const size_t bytes = LCD_FORTUNE_WIDTH * LCD_FORTUNE_HEIGHT * 2;
scene->frame = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene->frame == NULL) {lcd_direct_fortune_free(scene); return NULL;}
scene->image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565, .header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = LCD_FORTUNE_WIDTH, .header.h = LCD_FORTUNE_HEIGHT,
.data_size = bytes, .data = scene->frame,
};
const lcd_fortune_asset_t *rod = &lcd_fortune_shared[LCD_FORTUNE_ROD];
scene->rod_image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565A8, .header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = rod->width, .header.h = rod->height,
.data_size = (size_t)rod->width * rod->height * 3,
.data = scene->pixels[LCD_FORTUNE_ROD],
};
return scene;
}
/* 同一任务持锁退出并解绑对象,然后才能释放一次性 PSRAM 图层。 */
static esp_err_t lcd_direct_fortune_detach(lcd_direct_fortune_scene_t *scene)
{
esp_err_t err = gfx_emote_lock(s_emote_handle);
if (err != ESP_OK) return err;
portENTER_CRITICAL(&s_state_lock);
s_emote_active = false;
portEXIT_CRITICAL(&s_state_lock);
err = lcd_direct_emote_select_face(false);
if (scene->object != NULL) {
esp_err_t deleted = gfx_obj_delete(scene->object);
if (deleted == ESP_OK) scene->object = NULL;
if (err == ESP_OK) err = deleted;
}
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
return err != ESP_OK ? err : unlock_err;
}
static esp_err_t lcd_direct_play_fortune(size_t index)
{
if (s_fortune_orphan != NULL || s_emote_transfer_fault) return ESP_ERR_INVALID_STATE;
if (lcd_direct_should_stop_idle()) return ESP_ERR_INVALID_STATE;
const int64_t prepare_start_us = esp_timer_get_time();
lcd_direct_fortune_scene_t *scene = lcd_direct_fortune_prepare(index);
if (scene == NULL) return ESP_ERR_NOT_SUPPORTED;
esp_err_t result = gfx_emote_lock(s_emote_handle);
if (result != ESP_OK) {lcd_direct_fortune_free(scene); return result;}
bool started = false;
bool rod_hidden = false;
if (lcd_direct_should_stop_idle()) result = ESP_ERR_INVALID_STATE;
if (result == ESP_OK) {
scene->object = gfx_img_create(s_emote_disp);
result = scene->object != NULL ? ESP_OK : ESP_ERR_NO_MEM;
}
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_eyes_open, false);
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_closed_eyes, false);
if (result == ESP_OK && s_emote_cached_eyes != NULL) result = gfx_obj_set_visible(s_emote_cached_eyes, false);
if (result == ESP_OK && s_listening_eyes != NULL) result = gfx_obj_set_visible(s_listening_eyes, false);
if (result == ESP_OK) result = gfx_img_set_src(s_emote_background, &scene->rod_image);
if (result == ESP_OK) result = gfx_obj_set_pos(s_emote_background, 0, 40);
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_background, true);
if (result == ESP_OK) result = gfx_obj_set_pos(scene->object, LCD_FORTUNE_X, LCD_FORTUNE_Y);
if (result == ESP_OK) result = gfx_img_set_src(scene->object, &scene->image);
if (result == ESP_OK) result = lcd_direct_emote_update_hud(true, NULL);
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
if (result == ESP_OK) result = unlock_err;
size_t pixel_bytes = scene->image.data_size;
for (size_t i = 0; i < LCD_FORTUNE_SHARED_COUNT; ++i)
pixel_bytes += (size_t)scene->shared[i].width * scene->shared[i].height * 3;
pixel_bytes += (size_t)scene->text.width * scene->text.height * 3;
ESP_LOGI(TAG, "Fortune layers asset=%s pixels_psram=%u scene_bytes=%u prepare_us=%llu",
lcd_fortune_result_dirs[index], (unsigned)pixel_bytes, (unsigned)sizeof(*scene),
(unsigned long long)(esp_timer_get_time() - prepare_start_us));
const int64_t start_us = esp_timer_get_time();
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
uint32_t frames = 0, draw_attempts = 0, late = 0, max_draw_us = 0;
uint64_t total_draw_us = 0;
#endif
for (uint32_t ms = 0; result == ESP_OK;) {
if (lcd_direct_should_stop_idle()) {result = ESP_ERR_INVALID_STATE; break;}
const int64_t draw_start_us = esp_timer_get_time();
result = gfx_emote_lock(s_emote_handle);
if (result != ESP_OK) break;
if (!lcd_emote_fortune_build_frame(scene->frame, scene->image.data_size,
scene->shared, &scene->text, ms)) result = ESP_ERR_INVALID_SIZE;
if (result == ESP_OK) result = gfx_img_set_src(scene->object, &scene->image);
if (result == ESP_OK && ms >= 600 && !rod_hidden) {
result = gfx_obj_set_visible(s_emote_background, false);
rod_hidden = result == ESP_OK;
}
if (result == ESP_OK) result = lcd_direct_emote_update_hud(false, NULL);
if (result == ESP_OK) {
portENTER_CRITICAL(&s_state_lock);
s_emote_active = true;
portEXIT_CRITICAL(&s_state_lock);
if (!started) gfx_disp_refresh_all(s_emote_disp);
started = true;
result = lcd_direct_emote_refresh();
}
unlock_err = gfx_emote_unlock(s_emote_handle);
if (result == ESP_OK) result = unlock_err;
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
uint32_t draw_us = (uint32_t)(esp_timer_get_time() - draw_start_us);
total_draw_us += draw_us;
++draw_attempts;
if (draw_us > max_draw_us) max_draw_us = draw_us;
if (draw_us >= LCD_FORTUNE_STEP_MS * 1000) ++late;
if (result == ESP_OK) ++frames;
#else
(void)draw_start_us;
#endif
if (result != ESP_OK) break;
if (ms == LCD_FORTUNE_MOTION_MS) break;
uint32_t next_ms = ms + LCD_FORTUNE_STEP_MS;
if (next_ms > LCD_FORTUNE_MOTION_MS) next_ms = LCD_FORTUNE_MOTION_MS;
result = lcd_direct_emote_wait_until(start_us + (int64_t)next_ms * 1000);
ms = next_ms;
}
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
if (started && frames > 0) {
uint64_t motion_us = esp_timer_get_time() - start_us;
uint64_t fps100 = lcd_direct_emote_fps_x100(frames, motion_us);
ESP_LOGI(TAG, "fortune_fps result=%s frames=%u motion_us=%llu update_fps=%llu.%02llu "
"draw_avg_us=%llu draw_max_us=%u late_frames=%u status=%s",
lcd_fortune_result_dirs[index], (unsigned)frames, (unsigned long long)motion_us,
(unsigned long long)(fps100 / 100), (unsigned long long)(fps100 % 100),
(unsigned long long)(total_draw_us / draw_attempts), (unsigned)max_draw_us, (unsigned)late,
esp_err_to_name(result));
}
#endif
/* 运动仍锚定同一起点;迟到不能吞掉用户阅读签文所需的末帧停留。 */
if (result == ESP_OK) result = lcd_direct_emote_wait_until(
esp_timer_get_time() + (int64_t)LCD_FORTUNE_HOLD_MS * 1000);
esp_err_t detach_err = lcd_direct_fortune_detach(scene);
if (detach_err == ESP_OK) lcd_direct_fortune_free(scene);
else {
s_fortune_orphan = scene;
s_emote_transfer_fault = true;
ESP_LOGE(TAG, "Fortune detach failed; bound pixels retained, restart required");
}
if (result == ESP_OK) result = detach_err;
if (result == ESP_OK && !lcd_direct_should_stop_idle()) lcd_direct_signal_completion();
if (!started && detach_err == ESP_OK && result != ESP_ERR_INVALID_STATE)
return ESP_ERR_NOT_SUPPORTED;
return result;
}
#endif
static uint16_t lcd_direct_swap_color(uint16_t color)
@@ -2457,6 +2656,14 @@ static esp_err_t lcd_direct_play_step_sequence(const lcd_direct_request_t *reque
ESP_ERR_INVALID_ARG,
TAG,
"invalid step sequence");
#if CONFIG_TQ_LCD_EMOTE_GFX_TEST
int fortune = lcd_direct_fortune_sequence_index(request);
if (fortune >= 0 && s_emote_ready && LCD_DIRECT_WIDTH == 320 && LCD_DIRECT_HEIGHT == 240) {
esp_err_t err = lcd_direct_play_fortune((size_t)fortune);
if (err != ESP_ERR_NOT_SUPPORTED) return err;
ESP_LOGW(TAG, "Fortune layers unavailable; using PNG fallback");
}
#endif
for (size_t i = 0; i < request->step_count; ++i) {
(void)lcd_direct_service_preheat();
+200
View File
@@ -0,0 +1,200 @@
#include "lcd_emote_fortune.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
typedef struct { float x, y, u, v; } vertex_t;
static bool read_length(const uint8_t *src,size_t size,size_t *pos,size_t *length)
{
if (*length!=15) return true;
unsigned value;
do {
if (*pos>=size || *length>SIZE_MAX-255) return false;
value=src[(*pos)++]; *length+=value;
} while (value==255);
return true;
}
bool lcd_emote_fortune_decode(const uint8_t *src,size_t size,uint8_t *dst,size_t capacity)
{
if (!src || !size || !dst || !capacity) return false;
size_t pos=0,written=0;
while (pos<size) {
unsigned token=src[pos++]; size_t literals=token>>4;
if (!read_length(src,size,&pos,&literals) || literals>size-pos || literals>capacity-written) return false;
memcpy(dst+written,src+pos,literals);written+=literals;pos+=literals;
if (pos==size) return written==capacity && (token&15)==0;
if (size-pos<2) return false;
size_t offset=src[pos]|((size_t)src[pos+1]<<8);pos+=2;
size_t match=token&15;
if (!offset || offset>written || !read_length(src,size,&pos,&match) ||
match>SIZE_MAX-4 || match+4>capacity-written) return false;
match+=4;
for (size_t i=0;i<match;++i) {dst[written]=dst[written-offset];++written;}
}
return false;
}
static float smooth(float v) { v = fmaxf(0, fminf(1, v)); return v*v*(3-2*v); }
static uint16_t color_at(const lcd_fortune_texture_t *t, size_t i)
{ return ((uint16_t)t->pixels[i*2]<<8) | t->pixels[i*2+1]; }
static void blend(uint8_t *dst, int x, int y, uint16_t color, unsigned alpha)
{
x -= LCD_FORTUNE_X; y -= LCD_FORTUNE_Y;
if (x < 0 || y < 0 || x >= LCD_FORTUNE_WIDTH || y >= LCD_FORTUNE_HEIGHT || !alpha) return;
size_t i = ((size_t)y*LCD_FORTUNE_WIDTH+x)*2;
uint16_t old = ((uint16_t)dst[i]<<8) | dst[i+1];
unsigned r = (((color>>11)&31)*alpha + ((old>>11)&31)*(255-alpha)+127)/255;
unsigned g = (((color>>5)&63)*alpha + ((old>>5)&63)*(255-alpha)+127)/255;
unsigned b = ((color&31)*alpha + (old&31)*(255-alpha)+127)/255;
uint16_t out = (r<<11)|(g<<5)|b;
dst[i] = out>>8; dst[i+1] = out;
}
/* 双线性采样在预乘 Alpha 空间,避免黑色透明边缘造成轮廓暗边。 */
static void sample(uint8_t *dst, int x, int y, const lcd_fortune_texture_t *t,
float u, float v, unsigned opacity)
{
if (u < 0 || v < 0 || u > 1 || v > 1 || !opacity) return;
float sx = u*(t->width-1), sy = v*(t->height-1);
int x0 = (int)sx, y0 = (int)sy;
int x1 = x0+1 < t->width ? x0+1 : x0, y1 = y0+1 < t->height ? y0+1 : y0;
float fx = sx-x0, fy = sy-y0;
const size_t indices[4] = {(size_t)y0*t->width+x0, (size_t)y0*t->width+x1,
(size_t)y1*t->width+x0, (size_t)y1*t->width+x1};
const float weights[4] = {(1-fx)*(1-fy), fx*(1-fy), (1-fx)*fy, fx*fy};
float a=0, r=0, g=0, b=0;
size_t count = (size_t)t->width*t->height;
for (int i=0; i<4; ++i) {
float aw = weights[i]*t->pixels[count*2+indices[i]];
uint16_t c = color_at(t, indices[i]);
a += aw; r += ((c>>11)&31)*aw; g += ((c>>5)&63)*aw; b += (c&31)*aw;
}
if (a < 0.5f) return;
float inv_a = 1/a;
uint16_t c = ((unsigned)(r*inv_a+0.5f)<<11)|((unsigned)(g*inv_a+0.5f)<<5)|(unsigned)(b*inv_a+0.5f);
blend(dst,x,y,c,(unsigned)(a*opacity/255+0.5f));
}
static void blit(uint8_t *dst, const lcd_fortune_texture_t *t, int x, int y, unsigned opacity)
{
size_t count=(size_t)t->width*t->height;
for (int sy=0; sy<t->height; ++sy) for (int sx=0; sx<t->width; ++sx) {
size_t i=(size_t)sy*t->width+sx;
unsigned a=t->pixels[count*2+i]*opacity/255;
if (a) blend(dst,x+sx,y+sy,color_at(t,i),a);
}
}
static void triangle(uint8_t *dst, const lcd_fortune_texture_t *t,
vertex_t a, vertex_t b, vertex_t c, bool include_shared)
{
float det=(b.x-a.x)*(c.y-a.y)-(c.x-a.x)*(b.y-a.y);
if (fabsf(det)<0.01f) return;
int left=(int)floorf(fminf(a.x,fminf(b.x,c.x))), right=(int)ceilf(fmaxf(a.x,fmaxf(b.x,c.x)));
int top=(int)floorf(fminf(a.y,fminf(b.y,c.y))), bottom=(int)ceilf(fmaxf(a.y,fmaxf(b.y,c.y)));
if (left<LCD_FORTUNE_X) left=LCD_FORTUNE_X;
if (top<LCD_FORTUNE_Y) top=LCD_FORTUNE_Y;
if (right>LCD_FORTUNE_X+LCD_FORTUNE_WIDTH) right=LCD_FORTUNE_X+LCD_FORTUNE_WIDTH;
if (bottom>LCD_FORTUNE_Y+LCD_FORTUNE_HEIGHT) bottom=LCD_FORTUNE_Y+LCD_FORTUNE_HEIGHT;
float inv=1/det;
for (int y=top; y<bottom; ++y) for (int x=left; x<right; ++x) {
float px=x+0.5f-a.x, py=y+0.5f-a.y;
float wb=(px*(c.y-a.y)-py*(c.x-a.x))*inv;
float wc=((b.x-a.x)*py-(b.y-a.y)*px)*inv;
/* 两个三角形共边只属于一方,避免 Alpha 轮廓重复混合。 */
if (wb>=0 && wc>=0 && (include_shared ? wb+wc<=1 : wb+wc<1)) sample(dst,x,y,t,
a.u+wb*(b.u-a.u)+wc*(c.u-a.u), a.v+wb*(b.v-a.v)+wc*(c.v-a.v),255);
}
}
static vertex_t project(float u, float v, float width, float height, float theta,
float cx, float cy, bool side, bool front)
{
float c=cosf(theta), s=sinf(theta), roll=0.025f*s;
float x=side?0:(front?0.5f-u:u-0.5f)*width;
float z=side?(u-0.5f)*10:0;
float X=x*c-z*s, Z=x*s+z*c, k=320/(320+Z);
float xx=X*k, yy=(v-0.5f)*height*k;
return (vertex_t){cx+xx*cosf(roll)-yy*sinf(roll),cy+xx*sinf(roll)+yy*cosf(roll),u,v};
}
static void paw(uint8_t *dst, const lcd_fortune_texture_t *t, float width, float height,
float theta, float cx, float cy, bool side, bool front)
{
/* 16 条纵向纹理带近似透视;每带仅两个三角形,非三维模型。 */
for (int i=0; i<16; ++i) {
float u0=i/16.0f,u1=(i+1)/16.0f;
vertex_t a=project(u0,0,width,height,theta,cx,cy,side,front);
vertex_t b=project(u1,0,width,height,theta,cx,cy,side,front);
vertex_t c=project(u0,1,width,height,theta,cx,cy,side,front);
vertex_t d=project(u1,1,width,height,theta,cx,cy,side,front);
triangle(dst,t,a,b,c,true); triangle(dst,t,d,c,b,false);
}
}
static void flip_question(uint8_t *dst,const lcd_fortune_texture_t *t,float theta,float cx,float cy)
{
/* 小问号与签的正面共用透视平面;正面展开时不突然贴出平面图案。 */
vertex_t a=project(0.30f,0.43f,60,114,theta,cx,cy,false,true);
vertex_t b=project(0.70f,0.43f,60,114,theta,cx,cy,false,true);
vertex_t c=project(0.30f,0.816f,60,114,theta,cx,cy,false,true);
vertex_t d=project(0.70f,0.816f,60,114,theta,cx,cy,false,true);
/* 正面 local X 反向,因此按屏幕方向摆放 UV,签文不镜像。 */
a.u=c.u=0; b.u=d.u=1; a.v=b.v=0; c.v=d.v=1;
triangle(dst,t,a,b,c,true);triangle(dst,t,d,c,b,false);
}
static void overlay(uint8_t *dst,const lcd_fortune_texture_t *t,
float left,float top,float width,float height,float reveal)
{
for (int y=(int)floorf(top); y<(int)ceilf(top+height*reveal); ++y)
for (int x=(int)floorf(left); x<(int)ceilf(left+width); ++x)
sample(dst,x,y,t,(x+0.5f-left)/width,(y+0.5f-top)/height,255);
}
static void stars(uint8_t *dst,float progress)
{
static const uint8_t points[][3]={{89,62,4},{232,91,6},{75,132,5},{251,145,4},
{119,195,5},{229,211,6},{198,40,4},{79,191,4}};
unsigned alpha=(unsigned)(255*progress);
for (size_t i=0;i<sizeof(points)/sizeof(points[0]);++i) {
int radius=points[i][2];
for (int j=-radius;j<=radius;++j) {
unsigned a=alpha*(radius+1-abs(j))/(radius+1);
blend(dst,points[i][0]+j,points[i][1],0xffad,a);
if (j) blend(dst,points[i][0],points[i][1]+j,0xffad,a);
}
}
}
bool lcd_emote_fortune_build_frame(uint8_t *dst,size_t capacity,
const lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT],
const lcd_fortune_texture_t *text,uint32_t time_ms)
{
const size_t bytes=LCD_FORTUNE_WIDTH*LCD_FORTUNE_HEIGHT*2;
if (!dst || capacity<bytes || !shared || !text || !text->pixels ||
!text->width || !text->height || text->width>320 || text->height>240) return false;
for (int i=0;i<LCD_FORTUNE_SHARED_COUNT;++i)
if (!shared[i].pixels || !shared[i].width || !shared[i].height ||
shared[i].width>320 || shared[i].height>240) return false;
memset(dst,0,bytes);
if (time_ms>LCD_FORTUNE_MOTION_MS) time_ms=LCD_FORTUNE_MOTION_MS;
float zoom=smooth(((float)time_ms-1900)/600);
if (time_ms<600) blit(dst,&shared[LCD_FORTUNE_ROD],lcd_fortune_shared[LCD_FORTUNE_ROD].x,
lcd_fortune_shared[LCD_FORTUNE_ROD].y,255);
float width=60+40*zoom,height=114+76*zoom,cx=160,cy=94+25*zoom;
if (time_ms<600) {
cy=212-118*smooth(time_ms/600.0f);
paw(dst,&shared[LCD_FORTUNE_BACK],60,110,0,cx,cy,false,false);
} else if (time_ms<1200) {
float theta=3.14159265f*smooth((time_ms-600)/600.0f), c=cosf(theta), s=sinf(theta);
bool side=fabsf(c)<0.17f,front=c<0;
const lcd_fortune_texture_t *t=&shared[side?LCD_FORTUNE_EDGE:front?LCD_FORTUNE_FRONT:LCD_FORTUNE_BACK];
paw(dst,t,60,side?110:114,theta,cx+4*s,cy-3*s,side,front);
if (front && !side) flip_question(dst,&shared[LCD_FORTUNE_QUESTION],theta,cx+4*s,cy-3*s);
} else {
paw(dst,&shared[LCD_FORTUNE_FRONT],width,height,3.14159265f,cx,cy,false,true);
}
if (time_ms>=1200 && time_ms<1600)
overlay(dst,&shared[LCD_FORTUNE_QUESTION],cx-12,cy-8,24,44,1);
if (time_ms>=1600) {
float left=cx-width/2,top=cy-height/2;
overlay(dst,text,left+width*0.26f,top+height*70/190,width*0.48f,height*104/190,
smooth((time_ms-1600)/300.0f));
}
/* 下方爪子遮挡抽出时的签底;放大阶段淡出,最终背景纯黑。 */
blit(dst,&shared[LCD_FORTUNE_PILE],lcd_fortune_shared[LCD_FORTUNE_PILE].x,
lcd_fortune_shared[LCD_FORTUNE_PILE].y,(unsigned)(255*(1-zoom)));
if (zoom>0) stars(dst,zoom);
return true;
}
+32 -5
View File
@@ -1,6 +1,6 @@
# GFX 表情显示应用与开发指南
本文面向本项目的开发者,说明当前 `esp_emote_gfx` 的具体应用及后续开发方法。正文使用中文,代码、路径和日志字段保留原名以便检索。适用基线为 2026 年 10 月 9 日的工作区实现;资源和性能数字随代码变化而变化。
本文面向本项目的开发者,说明当前 `esp_emote_gfx` 的具体应用及后续开发方法。正文使用中文,代码、路径和日志字段保留原名以便检索。适用基线包含 2026 年 10 月 10 日的抽签分层动画;资源和性能数字随代码变化而变化。
## 一、当前方案与边界
@@ -8,9 +8,9 @@
- 底层为 ESP32-S3、ST7789P3、单线 SPI,当前像素时钟为 40 MHz,逻辑画布为 320×240。
- 当前使用 `esp_emote_gfx` 3.0.5,由 `platform` 私有依赖。没有接入 `esp_emote_expression` 状态管理组件,也没有把当前表情绘制建立在 LVGL 上。依赖目录中存在其他库不等于当前显示链路使用它们。
- 待机基础表情与 listening 采用 GFX;其他表情仍走原 PNG 或步骤序列,不是所有资产都已经迁移。listening 缓存失败时单独回退旧 PNG,不影响待机。
- 待机基础表情、listening 与五种抽签结果采用 GFX 图层;其他表情仍走原 PNG 或步骤序列,不是所有资产都已经迁移。listening 缓存失败时单独回退旧 PNG,不影响待机。抽签只在开始画面前准备失败时回退,播放途中取消/传输失败不从头重播 PNG。
- 屏幕目前未使用 TE 同步。软件预合成和连续传输能改善撕裂观感,但不能保证面板扫描与写入完全同步。
- 用户已确认待机缓存/固定时间轴以及新 listening 版本的观感满意。两者最终版本尚无新的配套量化日志,不能写成“稳定达到 30 FPS”或“全部实机验收完成”。
- 用户已确认待机缓存/固定时间轴以及新 listening 版本的观感满意。2026-10-10 启动样本中的待机眨眼约 410 ms、步骤约 29.26/s、实际眼型更新约 21.95/s;不是 listening/抽签帧率或全负载验证,不能写成“全部实机验收完成”。
已安装组件提供图片、网格图片、运动、动画、标签、二维码和按钮等接口。但本项目仅实际使用图片、网格、显示刷新及相关对象控制;通用中文文本、按钮、触摸和复杂布局不属于当前已实现能力。
@@ -63,6 +63,11 @@ esp_err_t err = domain_runtime_request_expression(&request);
| `components/platform/internal/lcd_emote_listening.h` | 平台私有插值接口,不向领域或控制层泄漏 GFX 类型 |
| `tools/prepare_listening_expression.py` | 拆分旧 listening、编码往返校验、生成四个 C 资产及同算法动画预览 |
| `tools/test_lcd_listening_gfx.py` | 实际资产、全闭合参数、缓存分配失败、GFX 对象失败、静态像素恢复与切换回归 |
| `components/platform/src/lcd_emote_fortune.c` | 抽签 LZ4 有界解码、轻透视纹理光栅、签文展开、放大与星光 |
| `components/platform/internal/lcd_emote_fortune.h` | 抽签私有图层/纹理/固定窗口契约 |
| `components/platform/src/generated/emote_gfx/fortune_assets.c` | 六个共享图层、五种签文 LZ4 压缩载荷 |
| `tools/prepare_fortune_expression.py` / `tools/preview_fortune_expression.py` | 拆分/压缩与实际 C 算法预览;均不进设备固件 |
| `tools/test_lcd_fortune_gfx.py` | 五签、损坏输入、几何边界、时间轴、取消和释放回归 |
`/tmp` 下修改过程中生成的补丁脚本不是工程运行依赖,不应提交或作为正式资源流程使用。
@@ -178,6 +183,7 @@ RLE(游程长度编码)把连续相同像素写成“数量+像素值”
| --- | --- | --- | --- |
| 普通 PNG 与 GFX 失败后备 | `lcd_source/*.png` | `lcd_optimized/*.png` 经素材包链接入应用 | 原 PNG 解码/缓存链路 |
| 新待机/listening GFX | 原 PNG 与拆分图层 PNG | 六个实际参与编译的 RLE C 资产 | 初始化解压,生成 PSRAM 眼型缓存,之后切换缓存 |
| 五种抽签 GFX | 抽签源 PNG 与 `lcd_layers/chouqian` 拆分层 | 十一图层的 LZ4 block C 数组 | 动作开始解压六个共享层及所选签文,实时局部纹理光栅,结束/取消解绑后释放 |
| 离线预览 | 图层与实际插值算法 | 不入固件 | 在电脑上检查造型,不能模拟 LCD 扫描 |
RLE 的编码往返对转换后的 RGB565+A8 数据无损,不等于原 PNG 的所有 RGB888
@@ -195,6 +201,7 @@ components/platform/assets/
lcd_layers/
jichubiaoqing/ 待机可编辑拆分图层、布局及预览
listening/ 聆听可编辑拆分图层、布局及预览
chouqian/ 抽签共享图层、五种原签文及布局
components/platform/src/generated/emote_gfx/
idle_base.c 实际编译的底图压缩数据
idle_eyes_open.c 实际编译的睁眼压缩数据
@@ -202,6 +209,7 @@ components/platform/src/generated/emote_gfx/
listening_eyes_open.c 实际编译的聆听睁眼关键图
listening_eyes_half.c 实际编译的聆听半闭关键图
listening_eyes_closed.c 实际编译的聆听闭眼关键图
fortune_assets.c 实际编译的抽签 LZ4;不是 RLE
```
半闭眼、闭眼源图及对应历史生成文件不代表当前参与编译;以构建列表为准,不把目录里的全部文件自动加入固件,也不擅自删除旧资源。
@@ -290,7 +298,7 @@ PSRAM 外部内存充足当成内部 DMA 安全。当前 listening 的已接受
## 八、性能日志如何读
通过 `menuconfig` 搜索 `TQ_LCD_EMOTE_GFX_TEST` 和 `TQ_LCD_EMOTE_FPS_LOG`。前者控制 GFX 待机及 listening 路径;后者控制性能统计,默认关闭,当前本地测量配置开启。关闭统计不应改变动画节奏。
通过 `menuconfig` 搜索 `TQ_LCD_EMOTE_GFX_TEST` 和 `TQ_LCD_EMOTE_FPS_LOG`。前者控制 GFX 待机、listening 及抽签路径;后者控制性能统计,默认关闭,当前本地测量配置开启。关闭统计不应改变动画节奏。抽签使用 `fortune_fps`,只计算运动段、不含最后停留,详见抽签开发说明。
每轮完整眨眼输出一次 `emote_fps` 和 `emote_cost`,不在逐帧传输中打印:
@@ -366,7 +374,25 @@ python3 tools/test_lcd_listening_gfx.py
固定窗口/锚点变化需同步脚本与测试。需要手工保留眉毛等素材编辑时,不要直接重新
拆分覆盖;当前生成脚本不是所有角色通用的热替换工具。
### 接入更多表情
### 已接入的五种抽签结果(2026-10-10)
统一抽出、轻透视翻面、问号、签文展开、放大/闪光与回待机。领域仍选择旧六步
序列和所选结果,平台只对完整匹配的资产序列优化;其他步骤序列不受影响。
纹理光栅由平台私有 C 完成,GFX 使用普通图片承接局部合成及 HUD,不是完整
三维引擎或 LVGL 动画。六图层共享,一次只载入一份签文,不建立五套缓存。
旧十八张 PNG 完整保留。详见 [抽签开发说明](抽签分层动画开发说明.zh-CN.md)。
新载荷 43,547 B,应用实际净增 51,856 B(本次构建 3,157,728 B);运动期
像素 PSRAM 278,249 B,加场景描述 148 B,正常结束/取消释放;临时图片对象
普通堆请求 96 B(不含分配器开销);静态可写 RAM 增量 4 B,无新 DMA/任务。
动画前分配失败后备 PNG;已开始后不因错误重播 PNG;解绑异常保留绑定资源
直到重启,不能假装完整释放。不是新格式一定最省 Flash,比较详见资产台账。
本次主机测试、ESP-IDF 5.5.4、日志/GFX 关闭编译及应用烧录校验通过。
启动日志确认待机/listening 缓存成功,短时显示日志无传输/锁/崩溃错误;
尚未捕获实机抽签播放日志,不宣称已测得抽签 FPS 或五签观感已验收。
### 接入更多表情(逐个评估)
先逐个迁移,保留未迁移 PNG 路径。每个表情准备静态层、动态图层、锚点、时间线、缓存预算及结束行为。固定短动作适合预生成缓存;大量不同动作或连续交互需要考虑按需生成、共享缓存和生命周期,不应给每个表情无上限常驻一份整屏缓存。
@@ -397,6 +423,7 @@ python3 tools/test_lcd_status_hud.py
python3 tools/test_lcd_emote_transport.py
python3 tools/test_lcd_emote_timeline.py
python3 tools/test_lcd_listening_gfx.py
python3 tools/test_lcd_fortune_gfx.py
```
这些脚本在电脑上从实际源码提取 C 函数,使用 Clang 与内存、未定义行为检查器。需要 Python 和 Clang;不是固件代码,不占设备 Flash 或 RAM。测试使用模拟接口,不能证明屏幕无撕裂、实时帧率或设备像素完全一致。
@@ -0,0 +1,151 @@
# 抽签分层动画开发说明
## 当前实现
2026-10-10 将五种结果接入同一动作:卜运签(保留原素材中的“补运签”字形)、
和平签、上上签、小吉签、中上签。领域结果目录及云端结果索引未改变,不在平台
重新随机,不改抽签请求、语音或联网流程。
领域继续下发原六步:前三步 `chouqian_qianzhi` 的 0/1/2,后三步所选结果
的 0/1/2,间隔 500/500/500/500/500/1500 ms。平台只有在完整匹配六步、
无附加图标、原间隔及 320×240、GFX 就绪时使用新动画。修改步骤/间隔会回到
普通 PNG,不静默覆盖通用步骤序列。
| 时间 | 动作 |
| --- | --- |
| 0~600 ms | 背面爪签从下方爪堆中抽出;爪堆遮挡签底,保留钓线/鱼 |
| 600~1200 ms | 轻透视翻面;背面缩窄,近 90° 用侧面厚度关键层,正面展开 |
| 1200~1600 ms | 正面问号保持;问号在翻面后半段已经随正面透视展开 |
| 1600~1900 ms | 所选原签文自上往下展开,不使用完整图片交叉淡化 |
| 1900~2500 ms | 爪签连续放大,爪堆淡出,程序金色星光出现 |
| 运动末帧后 1500 ms | 保持结果,变化的时间/电池可刷新,最后正常完成并守卫回待机 |
名义总长约 4 秒;若运动因负载迟到,仍保留完整 1500 ms 末帧阅读时间。
共同起点固定时间轴,目标运动步间隔 34 ms,75 张绘制(含起始与末端),不
画完再等 34 ms、不跳动作、不忙等。实际调度可迟到;目标不是实测 FPS。
取消以 20 ms 等待片轮询加当前绘制/传输时间为边界,不视为成功完成。
保留统一待机 guard,旧动画完成不能覆盖新 pending 请求。
## 图层和渲染
六个共享层:下方爪堆、背面、侧面、空白正面、问号、钓线/鱼;另有五个原签文。
保留源图,背景抖动杂点归零,清除背面裁剪内的钓线,将正面原签文区恢复签底,
保留肉垫与外轮廓;程序星光代替分散的原星光。属于小幅性能适配,并非原 PNG
逐像素不变。签文轮廓/颜色来自旧素材,不使用另加中文字体。
`lcd_emote_fortune.c` 在 PSRAM 的 `(56,20,208,220)` 局部不透明 RGB565 窗口
完成光栅:16 条纹理带近似透视、前后近远缩放、微小姿态变化;仅近侧面时使用
厚度层,避免半翻面时浮出一条不属于轮廓的金线。正面 UV 补偿反向,文字不镜像。
双线性采样在预乘 Alpha 空间进行,边缘不会把透明黑混出暗边。
每步完整恢复同一局部窗口并画新轮廓,防止旧轮廓残影。图片位置/尺寸固定,
无需以错误的“只更新新轮廓”留下旧像素。钓线在窗口外部分借用现有背景对象,
翻面开始只隐藏一次;不逐帧让不变的钓线失效。窗口顶部覆盖放大爪尖但避开
左上时间和右上电池;源图/几何变化需同步边界及预览。
GFX 负责普通图片图层、共享 HUD、区域合成及既有安全传输,不是使用 GFX
三维引擎,也没有 LVGL GUI。复用 153,600 B GFX 合成缓冲、10,240 B DMA
暂存区、ISR 完成等待和故障锁存;新增 91,520 B 是局部光栅缓冲,不是第二个
整屏换帧缓冲。屏幕无 TE,不能承诺完全无撕裂。
## 制作与测试
```text
components/platform/assets/lcd_source/chouqian_*.png 原十八张 PNG,保留
components/platform/assets/lcd_optimized/chouqian_*.png 旧 PNG 后备,仍入包
components/platform/assets/lcd_layers/chouqian/ 六共享图层、五签文、layout
components/platform/src/generated/emote_gfx/fortune_assets.c 实际编译压缩资产
components/platform/internal/lcd_emote_fortune.h 平台私有描述
components/platform/src/lcd_emote_fortune.c 解码与几何
components/platform/src/lcd_direct.c 匹配、准备、播放、解绑、测量
```
生成文件不要手改。以下需要 Pillow 和 Clang,生成会覆盖本次拆分图层及 C,
先保存手工美术修改;旧十八张源/优化 PNG 不改,不把拆分 PNG 重复入资源包。
```bash
python3 tools/prepare_fortune_expression.py
python3 tools/prepare_fortune_expression.py --check
python3 tools/test_lcd_fortune_gfx.py
python3 tools/preview_fortune_expression.py --output /tmp/lucky-fortune-preview --result 2
```
新压缩编码为 LZ4 block:字面量加 16-bit 回指,用有界、自包含解码器直接恢复
RGB565 高字节在前+独立 A8,不引入新解码任务/库,不在每帧解压。解码拒绝
越界回指、截断和输出长度错误。与旧四字节像素 RLE 不能互换。往返相对转换后
的 RGB565+A8 无损;转换/拆分并非 RGB888 原画无损。Alpha 全平面校验保留。
主机测试实际 C 解码和几何,五种结果共 1180 个采样时刻、空间不足/损坏输入、
九次分配失败、75 个取消点、4 秒时间轴、解绑/传输失败;日志开/关均覆盖。
预览也调用实际 C,另比较 C 解压与图层 PNG 转换后的像素及 Alpha。
仍需执行原四个显示回归和 IDF 5.5.4,并对 LCD 源文件验证日志/GFX 关闭条件编译。
替换资源不是只改 PNG:当前拆分坐标、尺寸、问号、原签文区域、锚点、放大
比例和窗口耦合。要调整生成器与几何,并用实际 C 预览检查翻面镜像、爪尖/签底
裁剪、字形缺笔、Alpha 暗边、HUD 遮挡及返回待机。
## 资源与生命周期
新载荷 43,547 B,十一张优化图层 PNG 37,046 B,原十八 PNG 372,012 B、
旧优化十八 PNG 136,226 B。不是相同图层数量的无损压缩比例对比;新方案
只保留少量关键素材而实时计算过渡。新 LZ4 比其十一 PNG 大 6,501 B,选择
它是为有界直接解压与减少 PNG 解码暂存,不是保证最小 Flash。
| 新增项 | 数值 |
| --- | ---: |
| 十一图层原始 RGB565+A8 | 246,633 B(含五签文,不全解压到运行内存) |
| 编译资产对象只读段(含描述/目录名) | 43,831 B;data/BSS 0 |
| 应用镜像 | 3,157,728 B,较上一提交 3,105,872 B 净增 51,856 B |
| 单 OTA 槽余量 | 1,560,864 B |
| 六共享图层解压像素 | 171,753 B |
| 本次所选一份签文 | 14,976 B |
| 局部光栅缓冲 | 91,520 B |
| 播放期像素 PSRAM 峰值 | 278,249 B |
| 场景状态/描述 | 148 B PSRAM;峰值明确请求合计 278,397 B |
| 临时 GFX 图片对象/状态/链节点 | 80+8+8=96 B 普通堆,不含管理开销 |
| 静态可写数据 | LCD 文件增加 4 B 指针 |
| 新内部/DMA 暂存区、任务 | 0 |
| 正常完成/取消后新增 PSRAM/对象常驻 | 0,解绑释放 |
数据来自目标编译器及本次构建,不包含分配器管理、局部栈和绘制临时开销。
旧 PNG 后备保留,包仍是 820,125 B、75 PNG/32 Opus,所以完整固件不会按
新旧素材差额减少。成本在本次提出的额外 Flash 80 KiB、像素 PSRAM 300 KiB
预算内;不要将此预算自动授权给后续更大缓存或新内部/DMA 缓冲。
播放准备失败释放部分成果;创建/绑定失败且安全解绑后才回退 PNG;开始绘制
后取消/失败不从头重播。每次动作只解压六共享层与一签文、申请一局部缓冲和
一个图片对象,正常退出持同任务 GFX 锁,背景重新绑定待机,再删除临时对象,
释放像素/场景。若锁/解绑异常,保留已绑定资源到重启,禁止再次创建该场景,
不能宣称此异常下已完成释放;既有强制删任务异常路径也不作为正常释放保证。
## 设备测试与日志
工厂 UART0 增加仅显示的测试命令,不调用云端,不写 NVS:
```text
lcd_fortune_test 0
lcd_fortune_test 1
lcd_fortune_test 2
lcd_fortune_test 3
lcd_fortune_test 4
```
每条结束后等至少 4 秒再发下一条;新命令会中断当前画面,不能当排队命令。
0~4 映射与上面顺序一致;仅接受单字符索引,缺失/越界/多参数拒绝。
`OK` 仅代表请求入队,完成/显示仍以日志及屏幕为准。当前 USB Serial/JTAG
是次级日志输出,CLI 读取 UART0,不能假设向 USB 日志口发送命令就会被执行。
正常抽签仍可通过既有 SW2/云端流程触发,不需要该测试命令。
开启 `CONFIG_TQ_LCD_EMOTE_FPS_LOG` 时每次运动段输出 `fortune_fps`:
`frames` 仅计成功绘制/传输;`motion_us` 为实际运动墙钟,排除最后保持;
`update_fps` 使用相同时间分母;`draw_avg_us/max_us` 含锁、光栅、GFX、DMA
复制及传输等待,平均分母是绘制尝试数(包括失败尝试);`late_frames` 是绘制
达到 34 ms 的次数,不是面板丢帧。
这些是任务墙钟,不能当纯 CPU,也不等于面板扫描率。开始日志另有解压/准备
时间、像素 PSRAM 及场景描述字节。
本次五签主机/条件编译/IDF 5.5.4 构建通过;当前设备 ota_0 有效,经读取 OTA
选择区确认后只用 app-flash 写 ota_0 应用,镜像校验成功,NVS/模型/选择区未改。
启动采样 25 秒:最终应用 SHA 前缀 06e65e4a0、待机/listening 缓存成功,采样内
无显示锁/传输/崩溃错误。未捕获抽签运动日志,未完成五签 LCD 观感及并行负载
验收,不写成实测稳定 30 FPS。启动期间的内存最低值不能代表抽签运行期最低值。
+51 -1
View File
@@ -419,7 +419,57 @@ PNG 包仍为 820,125 B、75 PNG/32 Opus;只读资产已包含于整个应用
内部/DMA 专用缓冲及静态可写数据均为 0;复用既有 HUD 和行缓冲。
局部栈、CPU 耗时和设备最低内存尚未新增实机测量,不应写成整体资源零成本。
## 维护与后续清理
## 新增抽签分层资产(2026-10-10)
以下新增项不合并进上面的历史目录汇总。旧抽签十八张源 PNG 共 372,012 B、
优化 PNG 共 136,226 B,全部保留且后备仍入包。PNG 包仍 820,125 B,不重复
加入本次拆分层。生成器使用优化 PNG 保存拆分层,但实际编译 LZ4,不是 RLE。
图层目录:`components/platform/assets/lcd_layers/chouqian/`。
| 图层 | 尺寸 | 优化 PNG B | 本机分配 B | RGB565+A8 B | LZ4 载荷 B |
| --- | --- | ---: | ---: | ---: | ---: |
| `pile.png` | 208×76 | 9,594 | 12,288 | 47,424 | 9,417 |
| `back.png` | 63×113 | 2,999 | 4,096 | 21,357 | 4,179 |
| `edge.png` | 41×110 | 2,957 | 4,096 | 13,530 | 4,389 |
| `front.png` | 100×190 | 7,579 | 8,192 | 57,000 | 10,774 |
| `question.png` | 27×50 | 730 | 4,096 | 4,050 | 817 |
| `rod.png` | 169×56 | 1,584 | 4,096 | 28,392 | 2,094 |
| `text_buyun.png` | 48×104 | 2,502 | 4,096 | 14,976 | 2,638 |
| `text_heping.png` | 48×104 | 2,784 | 4,096 | 14,976 | 2,851 |
| `text_shangshang.png` | 48×104 | 2,132 | 4,096 | 14,976 | 2,069 |
| `text_xiaoji.png` | 48×104 | 2,091 | 4,096 | 14,976 | 2,210 |
| `text_zhongshang.png` | 48×104 | 2,094 | 4,096 | 14,976 | 2,109 |
| 合计 | 十一图层 | 37,046 | 57,344 | 246,633 | 43,547 |
`layout.json` 1,929 B、分配 4,096 B,不入固件;目录共十二文件 38,975 B、
分配 61,440 B。预览工具默认输出 `/tmp/lucky-fortune-preview`,不是编译资产。
源十八 PNG 与本次拆分层不是相同数量的无损比较;背景/几何做了小幅性能适配。
LZ4 对转换后的 RGB565+A8 无损且完整保留 Alpha,但比拆分 PNG 多 6,501 B,
不能称为压得最小。生成器及实际 C 解码均做往返校验,原旧 PNG 不覆盖。
生成 C:`components/platform/src/generated/emote_gfx/fortune_assets.c`,文本
270,251 B、分配 270,336 B;目标对象只读段总计 43,831 B(载荷、176 B 资产
描述、20 B 目录指针、88 B 目录名),可写 data/BSS 为 0。生成 C 不手改。
本次完整应用 3,157,728 B,较 HUD 提交 3,105,872 B 增加 51,856 B,包含
资产、几何/解码、播放与 UART 显示测试命令的代码及对齐;单 OTA 槽余量
1,560,864 B。所有只读资源已经包含在应用里,不能再相加。
六共享层解压 171,753 B、一次仅选一份签文 14,976 B、208×220 RGB565
局部光栅缓冲 91,520 B,像素峰值合计 278,249 B PSRAM;目标编译器场景
描述 148 B 也在 PSRAM,明确申请峰值 278,397 B,不含堆管理。
正常结束/取消后释放,新增常驻像素为 0。临时 GFX 图片对象/状态/链节点
普通堆请求合计 96 B,不含分配器;LCD 静态可写数据增加 4 B 指针,
无新增内部/DMA 缓冲、合成整屏缓冲或任务;局部栈/CPU/实际最低余量另测。
解绑故障时保留绑定资源到重启,不冒充完整释放。
五签主机回归、IDF 5.5.4、日志关闭/GFX 关闭编译与应用烧录校验通过。
设备启动确认缓存成功、25 秒采样无显示锁/传输/崩溃错误;未捕获抽签运动
日志,FPS/五签观感/并行负载最低内存仍待实机验收。详见
[抽签开发说明](抽签分层动画开发说明.zh-CN.md)。
## 维护与后续清理(通用)
1. PNG 新增/替换按 [图片优化流程](lcd-image-asset-optimization.zh-CN.md) 生成并验证,
不能编辑优化产物代替修改源图。GFX 新增/替换使用对应压缩脚本和编码往返测试。
+7 -4
View File
@@ -639,7 +639,7 @@ menu "TQ Cat Configuration"
Stack size in bytes for the control-plane LCD render worker.
config TQ_LCD_EMOTE_GFX_TEST
bool "Use esp_emote_gfx for idle and listening expressions (experimental)"
bool "Use esp_emote_gfx for idle, listening and fortune animations (experimental)"
default n
depends on TQ_SCREEN_ENABLE
help
@@ -647,18 +647,21 @@ menu "TQ Cat Configuration"
RGB565A8 eye layer. Only the eye region is refreshed while
blinking. Listening uses three compressed key shapes to generate
a local eye-frame cache once. Both expressions share the display,
DMA staging and clock/battery overlay. Other presentations use
DMA staging and clock/battery overlay. Fortune sequences share
layered paw textures and five original result texts, decoded
only for one playback and freed afterward. Other presentations use
the existing PNG renderer; listening falls back to PNG if its
cache allocation fails.
config TQ_LCD_EMOTE_FPS_LOG
bool "Log measured GFX blink FPS"
bool "Log measured GFX animation FPS"
depends on TQ_LCD_EMOTE_GFX_TEST
default n
help
Log once per completed blink: measured step FPS, actual eye
update FPS, draw time and deadline overruns. Excludes the idle
open-eye pause. This does not measure panel scan rate.
open-eye pause. Fortune logs once per motion segment, excluding
the final hold. This does not measure panel scan rate.
config TQ_SCREEN_COLOR_ORDER_BGR
bool "Use BGR color order"
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""从保留的抽签源 PNG 拆分共享图层、五个签文,生成 LZ4;--check 不写文件。
会覆盖 lcd_layers/chouqian 和生成 C;先保存手工美术编辑。背景杂点归零、
清除背面残留钓线、抽取并共享正面;RGB565+A8 编码往返无损不等于拆分原画无损。
"""
import argparse
from collections import deque
import json
from pathlib import Path
from PIL import Image
from compress_idle_expression import pixels565
def compress(raw):
"""LZ4 block:字面量和 16-bit 回指;编码器自包含,不下载库。"""
out=bytearray();table={};anchor=pos=0
def length(value):
while value>=255: out.append(255);value-=255
out.append(value)
while pos+12<=len(raw):
key=raw[pos:pos+4];previous=table.get(key);table[key]=pos
if previous is None or pos-previous>65535:
pos+=1;continue
size=4
while pos+size<len(raw)-5 and raw[previous+size]==raw[pos+size]: size+=1
literals=pos-anchor;match=size-4
out.append((min(literals,15)<<4)|min(match,15))
if literals>=15: length(literals-15)
out.extend(raw[anchor:pos]);offset=pos-previous;out.extend((offset&255,offset>>8))
if match>=15: length(match-15)
end=pos+size
for p in range(pos+1,end):
if p+4<=len(raw):table[raw[p:p+4]]=p
pos=anchor=end
literals=len(raw)-anchor;out.append(min(literals,15)<<4)
if literals>=15:length(literals-15)
out.extend(raw[anchor:])
assert decompress(out)==raw
return out
def decompress(data):
out=bytearray();pos=0
def length(value):
nonlocal pos
if value==15:
while True:
v=data[pos];pos+=1;value+=v
if v!=255:break
return value
while pos<len(data):
token=data[pos];pos+=1;literals=length(token>>4)
out.extend(data[pos:pos+literals]);pos+=literals
if pos==len(data):break
offset=data[pos]|data[pos+1]<<8;pos+=2;match=length(token&15)+4
assert 0<offset<=len(out)
for _ in range(match):out.append(out[-offset])
return bytes(out)
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "components/platform/assets/lcd_source"
LAYERS = ROOT / "components/platform/assets/lcd_layers/chouqian"
GENERATED = ROOT / "components/platform/src/generated/emote_gfx/fortune_assets.c"
RESULTS = ("buyun", "heping", "shangshang", "xiaoji", "zhongshang")
def clean(name, box, largest=False):
image = Image.open(SOURCE / name).convert("RGBA").crop(box)
pixels = list(image.getdata())
candidates = {(i % image.width, i // image.width) for i, (r,g,b,a) in enumerate(pixels)
if max(r,g,b)>35 and a}
components=[]
while candidates:
seed=candidates.pop(); queue=deque([seed]); group={seed}
while queue:
x,y=queue.popleft()
for nx in range(max(0,x-1),min(image.width,x+2)):
for ny in range(max(0,y-1),min(image.height,y+2)):
if (nx,ny) in candidates:
candidates.remove((nx,ny));group.add((nx,ny));queue.append((nx,ny))
if len(group)>=8: components.append(group)
selected=max(components,key=len) if largest else set().union(*components)
image.putdata([(r,g,b,a) if (i%image.width,i//image.width) in selected else (0,0,0,0)
for i,(r,g,b,a) in enumerate(pixels)])
return image
def text_layer(name):
image=Image.open(SOURCE/f"chouqian_{name}2.png").convert("RGBA").crop((136,94,184,198))
# 只保留原签文橙红笔画及边缘,不把黄色签底一起叠加。
image.putdata([(r,g,b,a) if r>150 and g<175 and b<100 and r>g*1.35 else (0,0,0,0)
for r,g,b,a in image.getdata()])
assert image.getbbox(), name
return image
def layers():
pile=clean("chouqian_qianzhi1.png",(56,164,264,240))
back=clean("chouqian_qianzhi1.png",(130,37,193,150),True)
for y in range(18,32):
for x in range(8):
r,g,b,a=back.getpixel((x,y))
if r>g*1.3 and g>b*1.5: back.putpixel((x,y),(0,0,0,0))
edge=clean("chouqian_qianzhi2.png",(139,38,180,148),True)
front=clean("chouqian_shangshang2.png",(110,24,210,214),True)
# 原签文所在内部签底做逐行双色插值;边框、肉垫、外轮廓保持原图。
for y in range(70,174):
l=front.getpixel((26,y));r=front.getpixel((73,y))
assert l[3] and r[3], y
for x in range(26,74):
f=(x-26)/47
front.putpixel((x,y),tuple(round(l[c]*(1-f)+r[c]*f) for c in range(3))+(255,))
question=Image.open(SOURCE/"chouqian_shangshang0.png").convert("RGBA").crop((147,83,174,133))
question.putdata([(r,g,b,a) if r>150 and g<215 and b<170 and r>g*1.10 else (0,0,0,0)
for r,g,b,a in question.getdata()])
assert question.getbbox()
rod=clean("chouqian_qianzhi0.png",(0,40,169,96))
return [("pile",pile,56,164),("back",back,0,0),("edge",edge,0,0),
("front",front,0,0),("question",question,0,0),("rod",rod,0,40)] + [
("text_"+name,text_layer(name),0,0) for name in RESULTS]
def main():
args=argparse.ArgumentParser();args.add_argument("--check",action="store_true");args=args.parse_args()
items=layers(); out=['/* Generated by tools/prepare_fortune_expression.py; do not edit. */',
'#include "lcd_emote_fortune.h"','']
entries=[]; report=[]
if not args.check: LAYERS.mkdir(parents=True,exist_ok=True)
for name,image,x,y in items:
pixels=pixels565(image)
raw=bytes(v for hi,lo,a in pixels for v in (hi,lo))+bytes(a for hi,lo,a in pixels)
data=compress(raw) # 对 RGB565 高低字节与独立 A8 平面逐字节往返检查。
out += [f'static const uint8_t fortune_{name}[] = {{']
out += [' '+', '.join(f'0x{v:02x}' for v in data[i:i+24])+',' for i in range(0,len(data),24)]
out += ['};','']
entries.append(f' {{{image.width}, {image.height}, {x}, {y}, sizeof(fortune_{name}), fortune_{name}}},')
report.append(dict(name=name,width=image.width,height=image.height,x=x,y=y,
raw_bytes=image.width*image.height*3,lz4_bytes=len(data)))
if args.check:
with Image.open(LAYERS/f"{name}.png") as old:
assert old.convert("RGBA").tobytes()==image.tobytes(), name
else: image.save(LAYERS/f"{name}.png",optimize=True)
out += ['const lcd_fortune_asset_t lcd_fortune_shared[LCD_FORTUNE_SHARED_COUNT] = {',*entries[:6],'};',
'const lcd_fortune_asset_t lcd_fortune_text[LCD_FORTUNE_RESULT_COUNT] = {',*entries[6:],'};',
'const char *const lcd_fortune_result_dirs[LCD_FORTUNE_RESULT_COUNT] = {',
' '+', '.join('"chouqian_'+s+'"' for s in RESULTS)+',','};','']
source='\n'.join(out)
metadata=json.dumps(dict(canvas=[320,240],window=[56,20,208,220],layers=report,
note="黑背景归零;共享爪子,签文字形来自原图;旧 PNG 保留"),ensure_ascii=False,indent=2)+'\n'
if args.check:
assert GENERATED.read_text()==source
assert (LAYERS/"layout.json").read_text()==metadata
else:
GENERATED.write_text(source);(LAYERS/"layout.json").write_text(metadata)
total=sum(s["lz4_bytes"] for s in report)
peak=208*220*2+sum(s["raw_bytes"] for s in report[:6])+max(s["raw_bytes"] for s in report[6:])
assert total<70*1024, ("为代码/描述保留10 KiB预算",total)
assert peak<300*1024,peak
print(json.dumps(dict(layers=report,payload_bytes=total,pixel_psram_peak=peak),ensure_ascii=False,indent=2))
if __name__=="__main__": main()
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""调用实际固件 C 解码与光栅函数生成 GIF/五签预览;不模拟 SPI 或扫描撕裂。"""
import argparse
import ctypes as C
from pathlib import Path
import subprocess
import tempfile
from PIL import Image
from compress_idle_expression import pixels565
ROOT=Path(__file__).resolve().parents[1]
class Asset(C.Structure):
_fields_=[("width",C.c_uint16),("height",C.c_uint16),("x",C.c_uint16),("y",C.c_uint16),
("size",C.c_size_t),("data",C.POINTER(C.c_uint8))]
class Texture(C.Structure):
_fields_=[("width",C.c_uint16),("height",C.c_uint16),("pixels",C.POINTER(C.c_uint8))]
def image565(raw,width,height):
pixels=[]
for i in range(0,len(raw),2):
c=raw[i]<<8|raw[i+1];r=(c>>11)&31;g=(c>>5)&63;b=c&31
pixels.append(((r<<3)|(r>>2),(g<<2)|(g>>4),(b<<3)|(b>>2)))
image=Image.new("RGB",(width,height));image.putdata(pixels);return image
def main():
parser=argparse.ArgumentParser();parser.add_argument("--output",type=Path,default=Path("/tmp/lucky-fortune-preview"))
parser.add_argument("--result",type=int,choices=range(5),default=2);args=parser.parse_args()
args.output.mkdir(parents=True,exist_ok=True)
with tempfile.TemporaryDirectory(prefix="fortune-preview-") as tmp:
library=Path(tmp)/"fortune.dylib"
subprocess.run(["clang","-std=c11","-shared","-fPIC","-O2","-I",str(ROOT/"components/platform/internal"),
str(ROOT/"components/platform/src/lcd_emote_fortune.c"),
str(ROOT/"components/platform/src/generated/emote_gfx/fortune_assets.c"),"-o",str(library)],check=True)
lib=C.CDLL(str(library));shared=(Asset*6).in_dll(lib,"lcd_fortune_shared");texts=(Asset*5).in_dll(lib,"lcd_fortune_text")
decode=lib.lcd_emote_fortune_decode;decode.argtypes=[C.POINTER(C.c_uint8),C.c_size_t,C.POINTER(C.c_uint8),C.c_size_t];decode.restype=C.c_bool
build=lib.lcd_emote_fortune_build_frame;build.argtypes=[C.POINTER(C.c_uint8),C.c_size_t,C.POINTER(Texture),C.POINTER(Texture),C.c_uint32];build.restype=C.c_bool
buffers=[]
def texture(a,name):
count=a.width*a.height;buffer=(C.c_uint8*(count*3))();buffers.append(buffer)
assert decode(a.data,a.size,buffer,len(buffer))
pixels=pixels565(Image.open(ROOT/"components/platform/assets/lcd_layers/chouqian"/(name+".png")))
expected=bytes(v for hi,lo,alpha in pixels for v in (hi,lo))+bytes(alpha for hi,lo,alpha in pixels)
assert bytes(buffer)==expected,name # 验证 C 解压与图层完整 A8 一致。
return Texture(a.width,a.height,buffer)
names=("pile","back","edge","front","question","rod")
textures=(Texture*6)(*(texture(a,n) for a,n in zip(shared,names)))
results=("buyun","heping","shangshang","xiaoji","zhongshang")
text_textures=[texture(a,"text_"+n) for a,n in zip(texts,results)]
dst=(C.c_uint8*(208*220*2))()
rod=Image.open(ROOT/"components/platform/assets/lcd_layers/chouqian/rod.png").convert("RGBA")
def frame(ms,index):
assert build(dst,len(dst),textures,C.byref(text_textures[index]),ms)
image=Image.new("RGB",(320,240))
if ms<600:image.paste(rod,(0,40),rod)
image.paste(image565(bytes(dst),208,220),(56,20));return image
frames=[frame(t,args.result) for t in range(0,2500,40)]+[frame(2500,args.result)]
frames[0].save(args.output/"preview.gif",save_all=True,append_images=frames[1:],duration=[40]*(len(frames)-1)+[1500],loop=0)
sheet=Image.new("RGB",(320*5,240))
for i in range(5):
f=frame(2500,i);sheet.paste(f,(320*i,0));f.save(args.output/(results[i]+".png"))
sheet.save(args.output/"results.png")
phases=(0,600,750,900,1050,1200,1800,2200,2500)
stages=Image.new("RGB",(320*3,240*3))
for i,t in enumerate(phases):stages.paste(frame(t,args.result),(i%3*320,i//3*240))
stages.save(args.output/"stages.png")
print("PASS: actual C decode == RGB565+A8 inputs; actual C GIF, five results and motion stages:",args.output)
if __name__=="__main__":main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""抽签实际 C 解码/几何、五种资源、分配失败/取消/释放主机回归,非设备 FPS。"""
from pathlib import Path
import re
import subprocess
import tempfile
ROOT=Path(__file__).resolve().parents[1]
SRC=(ROOT/"components/platform/src/lcd_direct.c").read_text()
def extract(name):
match=re.search(r"static [^;{}]+\b"+name+r"\([^;{}]*\)\s*\{",SRC)
assert match,name
start=match.start();end=SRC.index("{",start)+1;depth=1
while depth:
if SRC[end]=="{":depth+=1
if SRC[end]=="}":depth-=1
end+=1
return SRC[start:end]
PREFIX=r'''
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lcd_emote_fortune.h"
#define ESP_OK 0
#define ESP_ERR_INVALID_STATE 1
#define ESP_ERR_NO_MEM 2
#define ESP_ERR_INVALID_SIZE 3
#define ESP_ERR_NOT_SUPPORTED 4
#define ESP_ERR_TIMEOUT 5
#define PLATFORM_LCD_PRESENTATION_NO_ICON 255
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
#define GFX_COLOR_FORMAT_RGB565 0
#define GFX_COLOR_FORMAT_RGB565A8 1
#define C_ARRAY_HEADER_MAGIC 0
#define ESP_LOGI(...) ((void)0)
#define ESP_LOGE(...) ((void)0)
#define portENTER_CRITICAL(x) ((void)0)
#define portEXIT_CRITICAL(x) ((void)0)
typedef int esp_err_t;
typedef struct {const char *asset_dir;unsigned frame_index,icon_id,delay_ms;} platform_lcd_presentation_step_t;
typedef struct {size_t step_count;platform_lcd_presentation_step_t steps[6];} lcd_direct_request_t;
typedef struct { struct {int cf,magic,w,h;} header;size_t data_size;const uint8_t *data;} gfx_image_dsc_t;
typedef struct {bool visible;int x,y;const gfx_image_dsc_t *src;} gfx_obj_t;
static void *s_emote_handle=(void*)1,*s_emote_disp=(void*)1;
static gfx_obj_t mesh,closed,cache,listening,bg,scene_obj;
static gfx_obj_t *s_emote_eyes_open=&mesh,*s_emote_closed_eyes=&closed,*s_emote_cached_eyes=&cache;
static gfx_obj_t *s_listening_eyes=&listening,*s_emote_background=&bg;
static bool s_emote_active,s_emote_transfer_fault,stop;
static int lock_depth,allocs,live,fail_alloc,fail_obj,fail_lock,fail_unlock,fail_delete;
static int completions,waits,cancel_wait,refreshes,fail_refresh;
static int64_t now,deadlines[100];
static int64_t esp_timer_get_time(void){return now;}
static bool lcd_direct_should_stop_idle(void){return stop;}
static void *heap_caps_malloc(size_t n,int cap){assert(cap==3);if(++allocs==fail_alloc)return NULL;void *p=malloc(n);if(p)++live;return p;}
static void *heap_caps_calloc(size_t n,size_t s,int cap){void *p=heap_caps_malloc(n*s,cap);if(p)memset(p,0,n*s);return p;}
static void test_free(void *p){if(p)--live;free(p);}
#define free test_free
static int gfx_emote_lock(void *h){if(fail_lock){--fail_lock;return 5;}assert(!lock_depth);++lock_depth;return 0;}
static int gfx_emote_unlock(void *h){assert(lock_depth==1);--lock_depth;if(fail_unlock){--fail_unlock;return 5;}return 0;}
static gfx_obj_t *gfx_img_create(void *d){assert(lock_depth==1);return fail_obj?NULL:&scene_obj;}
static int gfx_obj_delete(gfx_obj_t *o){assert(lock_depth==1);if(fail_delete)return 5;o->src=NULL;return 0;}
static int gfx_img_set_src(gfx_obj_t *o,void *src){assert(lock_depth==1);o->src=src;return 0;}
static int gfx_obj_set_pos(gfx_obj_t *o,int x,int y){assert(lock_depth==1);o->x=x;o->y=y;return 0;}
static int gfx_obj_set_visible(gfx_obj_t *o,bool v){assert(lock_depth==1);o->visible=v;return 0;}
static int lcd_direct_emote_update_hud(bool force,bool *change){assert(lock_depth==1);return 0;}
static int lcd_direct_emote_select_face(bool face){assert(lock_depth==1);bg.src=NULL;return 0;}
static void gfx_disp_refresh_all(void *d){assert(lock_depth==1);}
static int lcd_direct_emote_refresh(void){assert(lock_depth==1 && s_emote_active);++refreshes;now+=13000;return refreshes==fail_refresh?5:0;}
static int lcd_direct_emote_wait_until(int64_t deadline){assert(!lock_depth && waits<100);deadlines[waits++]=deadline;
if(waits==cancel_wait){stop=true;return 1;} if(now<deadline)now=deadline;return stop?1:0;}
static void lcd_direct_signal_completion(void){++completions;}
static uint64_t lcd_direct_emote_fps_x100(uint32_t n,uint64_t t){return t?n*100000000ULL/t:0;}
static const char *esp_err_to_name(int e){return "mock";}
'''
TEST=r'''
static void reset(void){assert(live==0 && !lock_depth && !s_fortune_orphan);
allocs=fail_alloc=fail_obj=fail_lock=fail_unlock=fail_delete=waits=refreshes=cancel_wait=fail_refresh=completions=0;
now=0;stop=s_emote_active=s_emote_transfer_fault=false;}
int main(void){
uint8_t out[64];
assert(!lcd_emote_fortune_decode(NULL,1,out,1));
const uint8_t invalid[][5]={{0x10},{0x00,0,0},{0x00,1,0},{0xf0,255,255},{0x11,5}};
for(size_t i=0;i<5;++i)assert(!lcd_emote_fortune_decode(invalid[i],sizeof(invalid[i]),out,sizeof(out)));
for(size_t r=0;r<5;++r){
reset();lcd_direct_fortune_scene_t *s=lcd_direct_fortune_prepare(r);assert(s && live==9);
const size_t bytes=LCD_FORTUNE_WIDTH*LCD_FORTUNE_HEIGHT*2;
uint8_t *guard=malloc(bytes+2);guard[0]=guard[bytes+1]=0xa5;
for(unsigned ms=0;ms<=4000;ms+=17){
assert(lcd_emote_fortune_build_frame(guard+1,bytes,s->shared,&s->text,ms));
assert(guard[0]==0xa5 && guard[bytes+1]==0xa5);
}
assert(!lcd_emote_fortune_build_frame(guard+1,bytes-1,s->shared,&s->text,0));
assert(!lcd_emote_fortune_build_frame(NULL,bytes,s->shared,&s->text,0));
assert(!lcd_emote_fortune_build_frame(guard+1,bytes,NULL,&s->text,0));
lcd_emote_fortune_build_frame(guard+1,bytes,s->shared,&s->text,4000);
lcd_emote_fortune_build_frame(s->frame,bytes,s->shared,&s->text,2500);
assert(!memcmp(guard+1,s->frame,bytes));
/* 最终签文输出供回归查看;五种结果不能全都变成同一张。 */
char path[100];snprintf(path,sizeof(path),"/tmp/lucky-fortune-%zu.rgb565",r);
FILE *f=fopen(path,"wb");assert(f);fwrite(s->frame,1,bytes,f);fclose(f);
/* 截断输入和不足的解压空间不允许写越界。 */
for(size_t i=0;i<6;++i){const lcd_fortune_asset_t *a=&lcd_fortune_shared[i];size_t n=(size_t)a->width*a->height*3;
uint8_t *bad=malloc(n+2);bad[0]=bad[n+1]=0xa5;
assert(!lcd_emote_fortune_decode(a->data,a->compressed_size-1,bad+1,n));
assert(!lcd_emote_fortune_decode(a->data,a->compressed_size,bad+1,n-1));
assert(bad[0]==0xa5 && bad[n+1]==0xa5);
/* 普通测试内存不计入设备分配追踪。 */
++live;free(bad);
}
++live;free(guard);lcd_direct_fortune_free(s);assert(live==0);
lcd_direct_request_t req={.step_count=6};
for(size_t i=0;i<6;++i)req.steps[i]=(platform_lcd_presentation_step_t){i<3?"chouqian_qianzhi":lcd_fortune_result_dirs[r],i%3,255,i==5?1500:500};
assert(lcd_direct_fortune_sequence_index(&req)==r);
req.steps[4].delay_ms=100;assert(lcd_direct_fortune_sequence_index(&req)==-1);
req.steps[4].delay_ms=500;req.steps[3].asset_dir=NULL;assert(lcd_direct_fortune_sequence_index(&req)==-1);
reset();assert(lcd_direct_play_fortune(r)==0 && live==0 && completions==1 && !s_emote_active);
assert(waits==75 && refreshes==75 && now==4013000 && deadlines[74]==4013000);
for(int i=0;i<73;++i)assert(deadlines[i]==(i+1)*34000);
assert(deadlines[73]==2500000);
}
for(int n=1;n<=9;++n){reset();fail_alloc=n;assert(lcd_direct_play_fortune(0)==4 && !live && !completions);}
reset();fail_obj=1;assert(lcd_direct_play_fortune(0)==4 && !live);
reset();fail_lock=1;assert(lcd_direct_play_fortune(0)==5 && !live);
for(int n=1;n<=75;++n){reset();cancel_wait=n;assert(lcd_direct_play_fortune(0)==1 && !live && !completions && !s_emote_active);}
reset();fail_refresh=3;assert(lcd_direct_play_fortune(0)==5 && !live && !completions);
reset();fail_delete=1;assert(lcd_direct_play_fortune(0)==5 && s_fortune_orphan && live==9 && !completions);
assert(lcd_direct_play_fortune(0)==1);fail_delete=0;
assert(!lcd_direct_fortune_detach(s_fortune_orphan));lcd_direct_fortune_free(s_fortune_orphan);s_fortune_orphan=NULL;
reset();s_emote_transfer_fault=true;assert(lcd_direct_play_fortune(0)==1 && !live);
puts("PASS: five assets/results, LZ4 corruption/bounds, 1180 geometry frames, nine allocation failures, 75 cancellation points, anchored 4s playback, cleanup/transfer fault");
}
'''
start=SRC.index("typedef struct {\n lcd_fortune_texture_t shared")
end=SRC.index("static int lcd_direct_fortune_sequence_index",start)
names=["lcd_direct_fortune_sequence_index","lcd_direct_fortune_free","lcd_direct_fortune_prepare",
"lcd_direct_fortune_detach","lcd_direct_play_fortune"]
with tempfile.TemporaryDirectory(prefix="fortune-test-") as tmp:
path=Path(tmp);c=path/"test.c";exe=path/"test"
c.write_text(PREFIX+SRC[start:end]+"\n"+"\n".join(extract(n) for n in names)+TEST)
for profile in (0,1):
subprocess.run(["clang","-std=c11","-O1","-g","-fsanitize=address,undefined",
f"-DCONFIG_TQ_LCD_EMOTE_FPS_LOG={profile}","-I",str(ROOT/"components/platform/internal"),
str(c),str(ROOT/"components/platform/src/lcd_emote_fortune.c"),
str(ROOT/"components/platform/src/generated/emote_gfx/fortune_assets.c"),"-o",str(exe)],check=True)
subprocess.run([str(exe)],check=True)
assert "DOMAIN_EXPRESSION_" not in (ROOT/"components/platform/src/lcd_emote_fortune.c").read_text()
assert "if (err != ESP_ERR_NOT_SUPPORTED) return err;" in SRC