在 Flipper Zero 上跑通拓麻歌子 P1 模拟器:从 ROM 部署到 4 位机模拟原理
【免费下载链接】FlipperPlayground (and dump) of stuff I make or modify for the Flipper Zero项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper
想在 Flipper Zero 的 128×64 屏幕上养一只初代拓麻歌子?这个仓库里的 Tama P1 模拟器插件可以直接满足你:它模拟 P1 初代的 4 位 CPU 与蜂鸣器,三键即可游玩,支持把宠物状态写进save.bin存档,下次开机接着养。跟着本文走一遍,你不仅能在自己的设备(或 SD 卡)上把它跑起来,还会清楚它如何用一块硬件定时器驱动 64 kHz 的模拟时钟、又如何把 4096 个半字节的虚拟内存压进不到 500 字节的真实 RAM。
它是什么:一个"外壳 + 模拟内核"的插件应用
Tama P1 是一个EXTERNAL类型的外部应用(编译产物是.fap,从 SD 卡加载),源码位于 Applications/Official/source-OLDER/xMasterX/tama_p1/。它的架构可以一句话概括:平台无关的模拟内核 TamaLIB 负责跑 CPU,Flipper 侧的外壳代码只干三件事——接按键、喂时钟、画屏幕。
| 文件 | 职责 |
|---|---|
| tama_p1.c | 应用入口tama_p1_app:ROM 加载、事件循环、渲染回调、存档读写 |
| tama.h | 路径、缩放因子、状态结构等常量定义 |
| hal.c / hal_types.h | TamaLIB 硬件抽象层(HAL)在 STM32WB 上的实现 |
| tamalib/ | 模拟内核:cpu.c(指令集)、hw.c(LCD/蜂鸣器时序)、tamalib.c(调度) |
| icons/ + compiled/ | 8 个状态图标源文件及编译产物assets_icons.h |
application.fam 里还有几个值得留意的身份参数:入口tama_p1_app、依赖gui与storage两个系统服务、应用栈仅1 * 1024字节、发布到Games_Extra分类。
从零跑起来:放 ROM、编图标、一条命令上设备
前置条件:一张 microSD 卡、一份新版 Flipper 固件源码树(用于构建)。仓库若需获取:
git clone https://gitcode.com/GitHub_Trending/fl/Flipper放置 ROM
在 SD 卡根目录建tama_p1文件夹,把 P1 的 ROM 改名为rom.bin放进去。这个路径在 tama.h 中写死:
#define TAMA_ROM_PATH EXT_PATH("tama_p1/rom.bin")启动时先storage_common_stat检查文件是否存在,缺 ROM 时屏幕只打印 "No ROM"。
编译图标头文件
应用引用的是compiled/assets_icons.h里的I_icon_0~I_icon_7,需要先跑一次资源脚本,把icons/下的 PNG 转成 C 头文件(输出到compiled/目录):
scripts/assets.py icons applications/tama_p1/icons applications/tama_p1/compiled编译时若 TamaLIB 的cpu.c/hw.c报未使用参数告警(严格告警配置下会被当错误),在site_scons/cc.scons的CCFLAGS中追加-Wno-unused-parameter即可。
编译并推送到设备
在固件源码树根目录执行fbt,它会完成编译、安装并立即在设备上启动应用(Linux 下注意用正斜杠):
./fbt launch_app APPSRC=applications/plugins/tama_p1前提是把整个tama_p1目录放到固件树的applications/plugins/tama_p1位置——README 明确要求这一步。
怎么玩:按键映射、两个隐藏功能与存档版本差异
基础操作只有三键,源码 tama_p1.c 的输入分发里还藏着两个 README 没细说的功能:
| 按键 | 行为 | 说明 |
|---|---|---|
| 左键 | 拓麻歌子 A 键 | BTN_LEFT |
| OK | B 键 | BTN_MIDDLE |
| 右键 | C 键 | BTN_RIGHT |
| 上键 | 静音 | 同时向 TamaLIB 上报 A+C 按下/释放 |
| 下键短按 | 立即存档 | 调用tama_p1_save_state() |
| 返回短按 | 立即存档 | 同上 |
| 返回长按 | 存档并退出 | 停掉 30 fps 定时器,走退出流程 |
README 与源码打架时,以源码为准
README.md 的 "How to play" 一节约 14 行,写着 "There is currently no saving";但同一 README 的 "Implemented" 一节列着 "Saving/Loading emulator state (stored in/ext/tama_p1/save.bin)"。两处矛盾,而源码给出的答案是:存档早已实现——退出时(返回长按)写入TAMA_SAVE_PATH,启动时tama_p1_worker里第一步就是tama_p1_load_state()。前文那句"无存档"属于过时描述。
save.bin 的内部结构
存档把整台"机器"冻结成一个定长二进制块:
| 字段 | 大小 | 说明 |
|---|---|---|
| Magic | 4 B | 固定"TLST",校验文件合法性 |
| Version | 1 B | 当前为 2 |
| PC / X / Y | 2+2+2 B | 程序计数器与各寄存器,高字节按位宽掩码(0x1F/0xF) |
| A / B / NP / SP / Flags | 各 1 B | 累加器、B 寄存器、页选择、栈指针、标志位 |
| tick / 定时器时间戳 | 3×4 B + 3 B | 时钟周期计数、两个定时器及使能位 |
| call_depth | 4 B | 调用栈深度 |
| interrupts | 16×3 B | 6 个中断槽 × (factor/mask/triggered),按INT_SLOT_NUM展开 |
| RAM | 640 B | 0x000~0x27F,每半字节存 1 字节 |
| I/O | 128 B | 0xF00~0xF7F的 128 个半字节 |
加载时先校验 magic 与版本号,不匹配则打印FATAL日志并放弃恢复;恢复完 RAM 与 I/O 后调用tamalib_refresh_hw()让内核重新同步显示和蜂鸣器状态。
串口日志辅助定位
应用内用FURI_LOG_D/FURI_LOG_E记录了 ROM 加载、存档读写(TagTamaP1)和 TamaLIB 层日志(TagTamaLIB)。配合 FlipperScripts 里的串口日志脚本即可边跑边看:
python .\serial_logger.py每次按键都会以sequence / key / type三要素打印,验证按键映射和状态机行为时最有用。
为什么能跑起来:硬件时钟、半字节内存与 30 fps 渲染
用一块硬件定时器当"模拟时间源"
P1 初代基于 Epson E0C6S46 4 位 MCU,晶振 32,768 Hz,LCD 为 32×16 黑白像素加 8 个状态图标;网上流传的 ROM 据 TamaLIB 的说明是从芯片裸片照片光学识读出来的。要保真模拟,模拟时钟的粒度必须够细。该应用的做法是把 STM32WB 的 TIM2 定时器当时间源:
LL_TIM_InitTypeDef tim_init = { .Prescaler = 999, .CounterMode = LL_TIM_COUNTERMODE_UP, .Autoreload = 0xFFFFFFFF, }; LL_TIM_Init(TIM2, &tim_init);预分频 999 让计数器以 64 kHz 走数,get_timestamp()直接返回LL_TIM_GetCounter(TIM2)。TamaLIB 初始化时传入的64000就是这个频率——它决定了时间戳的最小粒度。sleep_until()是节奏控制的关键:模拟时间落后于目标时释放互斥锁、furi_delay_tick(1)让出 CPU,再拿锁继续步进,否则模拟线程会越跑越欠账。
4096 个半字节如何塞进小内存
被模拟 CPU 的地址空间共MEMORY_SIZE 4096(4096×4 bit):
| 区域 | 地址 | 半字节数 |
|---|---|---|
| RAM | 0x000~0x27F | 640 |
| 显示区 1 | 0xE00~0xE4F | 80 |
| 显示区 2 | 0xE80~0xECF | 80 |
| I/O | 0xF00~0xF7F | 128 |
cpu.h 默认开启LOW_FOOTPRINT宏:无效地址不占用缓冲,四个有效区各两半字节打包进一个u8_t,总缓冲只有 464 字节,读写都靠SET_RAM_MEMORY/GET_RAM_MEMORY等位操作宏完成。STM32WB 的 RAM 紧张,这一步压缩是它能在设备端常驻的前提。
ROM 读进来后还要做一次半字节重排,原因在于 P1 是 4 位机、程序按半字节组织,而 SD 卡读到的是字节流:
// Reorder endianess of ROM for(size_t i = 0; i < fi.size; i += 2) { uint8_t b = ctx->rom[i]; ctx->rom[i] = ctx->rom[i + 1]; ctx->rom[i + 1] = b & 0xF; }重排完成后调用tamalib_init((u12_t*)ctx->rom, NULL, 64000)把 ROM 交给内核。
HAL:约 146 行代码接通屏幕与蜂鸣器
TamaLIB 通过hal_t结构体(hal.h)声明了全部平台依赖:内存、halt、日志、时钟、屏幕、声音。hal.c 逐一对应实现,其中三处值得细看:
static void tama_p1_hal_set_lcd_matrix(u8_t x, u8_t y, bool_t val) { if(val) g_ctx->framebuffer[y] |= 1 << x; else g_ctx->framebuffer[y] &= ~(1 << x); }模拟器对每个像素的写入只是改帧缓冲里的一位——每行 32 个像素正好装进一个uint32_t,共 16 行;真正的绘制由 GUI 线程完成,所以update_screen()是空实现。声音侧,set_frequency把上报频率除以 10 换算成实际值,play_frequency通过furi_hal_speaker_acquire/start/stop驱动内置蜂鸣器,固定 0.5 音量(因此"音量调节"至今是 To-do)。
线程模型:模拟线程、GUI 线程与一把递归锁
整体数据流是一条清晰的因果链:
- 独立线程(栈 1024 字节)持递归互斥锁
g_state_mutex,循环调用tamalib_step()手动步进 CPU; - 按键事件经
FuriMessageQueue(深度 8)送入 GUI 主循环,分发到tamalib_set_button(); - 30 fps 的周期定时器(
furi_kernel_get_tick_frequency() / 30)触发重绘,回调里先以 25 tick 超时抢锁,再把帧缓冲按TAMA_SCREEN_SCALE_FACTOR = 2放大成 64×32 的像素块画在屏幕正中,上下各 4 个 14×14 的状态图标按g_ctx->icons的 8 个位逐一决定显隐。
两个线程读写同一份帧缓冲,全靠这把锁串行化;模拟线程退出前 GUI 线程会furi_thread_flags_set+furi_thread_join干净收尾。
TIM2、蜂鸣器、显示屏都是 STM32WB 的片上外设——上图可以帮你定位这颗主控,理解"模拟时间源"实际落在哪块硬件上。
边界与坑:还没做到的事和容易踩的地方
- 多存档槽(Slots):目前只有一个
save.bin,想多养几只宠物只能手动备份文件; - 游戏内复位:无法不退出应用就软复位宠物;
- 测试模式、音量调节:都在 README 的 To-do 清单里,蜂鸣器音量固定 0.5;
- 快进:源码中
fast_forward_done已被置为true,上方留着// TODO: implement fast forwarding注释——加速功能尚未实现; - 时间戳回绕:hal_types.h 特意把
timestamp_t定为无符号uint32_t,并注释了原因:以微秒计时时 32 位无符号数约 1 小时 11 分自然回绕,换成有符号类型会让时间比较出错; - 文档可信度:遇到 README 与源码不一致(如前文的"无存档"描述),以源码为准。
想继续深入,tamalib/cpu.c 是指令集实现的最好入口,hw.c里则是 LCD 与蜂鸣器时序的还原;也可以翻 Applications/ 下其他插件,看看同一套"外壳 + 内核"模式还有哪些玩法。
【免费下载链接】FlipperPlayground (and dump) of stuff I make or modify for the Flipper Zero项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考