在 Flipper Zero 上复刻拓麻歌子:Tama P1 模拟器从 ROM 到源码的完整指南
【免费下载链接】FlipperPlayground (and dump) of stuff I make or modify for the Flipper Zero项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper
在一台口袋设备里复刻一台 32,768 Hz 的 4 位掌机,这就是 Tama P1 模拟器干的事:它把拓麻歌子初代的 ROM 从 SD 卡读进来,模拟出那块 32×16 的液晶屏和蜂鸣器,用 Flipper Zero 的三枚侧键完成喂食、清洁与游戏,还自带存档。本文先拆原理,再走部署,最后落到排查手段。
它是什么、由哪些件组成
一句话定位:这是一个编译成.fap的外部插件,内核则是一个与平台无关的 TamaLIB 模拟库。整个目录 Applications/Official/source-OLDER/xMasterX/tama_p1 按"谁负责什么"分得很清楚:
| 文件 / 目录 | 职责 |
|---|---|
| tama_p1.c | 应用主入口:ROM 读取、事件循环、画面绘制、存档读写 |
| tama.h | 常量与结构体:ROM/存档路径、缩放因子、TamaApp状态结构 |
| hal.c + hal_types.h | TamaLIB 的硬件抽象层在 STM32WB 上的落地实现 |
| tamalib/ | 模拟内核:cpu.c、hw.c、tamalib.c及头文件 |
| icons/ + compiled/ | 8 个 LCD 状态图标源图与编译产物assets_icons.h |
| application.fam | 构建元数据 |
对照 application.fam 能确认几个影响部署的参数:appid为TAMA_P1,类型是EXTERNAL(独立编成插件、从 SD 卡加载),入口函数tama_p1_app,依赖gui和storage两个系统服务,应用主栈只有 1 KB(CPU 步进跑在独立线程里),发布到Games_Extra分类,图标用tamaIcon.png。
先把原理讲透:模拟内核如何运转
4 位 CPU 与内存模型:把半字节内存打包进字节缓冲
TamaLIB 是作者从零写成的 Tama P1 模拟库(见 tamalib/README.md):真机基于 Epson E0C6S46 4 位 MCU,主频 32,768 Hz,屏是 32×16 黑白点阵加 8 枚状态图标;网上流传的 ROM 据称来自芯片裸片照片的光学识读。
被模拟的 CPU 地址空间只有 4096 个 4 位单元(cpu.h 里的MEMORY_SIZE 4096),划分如下:
| 区域 | 地址 | 规模 |
|---|---|---|
| RAM | 0x000~0x27F | 640 个 4 位单元 |
| 显示缓冲 1 | 0xE00~0xE4F | 80 个 |
| 显示缓冲 2 | 0xE80~0xECF | 80 个 |
| I/O 寄存器 | 0xF00~0xF7F | 128 个 |
注意中间有大片地址根本不存在。为了不在 STM32WB 上白占 4 KB,代码默认打开LOW_FOOTPRINT宏:只把实际存在的 928 个半字节挤进一个 464 字节的缓冲区,两个半字节塞进同一个u8_t,读写全靠SET_RAM_MEMORY/GET_RAM_MEMORY这类宏做位运算。打个比方:与其租一整层写字楼,不如把四个工位拼成一张桌子。
HAL 层如何接住宿主机:TIM2 当模拟时钟源
TamaLIB 本身不认识任何硬件,它通过 hal.h 里的hal_t结构体把时钟、屏幕、声音、日志、halt 回调全部外包给宿主。本项目在 hal.c 里逐一填坑:
- 时钟:直接用 STM32 硬件定时器 TIM2。初始化时
Prescaler = 999,计数频率约为 64 kHz;get_timestamp()就是读一下LL_TIM_GetCounter(TIM2)。sleep_until()是节奏关键——发现模拟时间落后目标时间戳时,先放掉互斥锁、furi_delay_tick(1)让出 CPU,再抢回锁继续跑,否则模拟会越追越慢。 - 屏幕:
set_lcd_matrix(x, y, val)把单个像素写进framebuffer[16](一行 32 像素恰好装进一个uint32_t),set_lcd_icon(icon, val)写icons位图;update_screen()留空,因为真正的渲染交给 GUI 主循环。 - 声音:
set_frequency把内核上报的频率除以 10.0 换算成实际值,play_frequency走furi_hal_speaker_acquire/start/stop驱动内置蜂鸣器。 - halt:模拟 CPU 停机时置位
halted标志,界面随即打出 "Halted"。
还有一个容易被忽略的细节:hal_types.h 里timestamp_t被注释为必须是无符号类型——按微秒计时时,32 位无符号数大约 1 小时 11 分就自然回绕一次,用有符号类型会让时间比较逻辑出错。
32×16 画面怎么铺满屏幕,两个线程怎么不打架
主循环用 30 FPS 的周期定时器(furi_kernel_get_tick_frequency() / 30)不断投递EventTypeTick,触发view_port_update()。绘制回调做的事很直接:先抢互斥锁(超时 25 tick,抢不到就放弃这一帧),然后把 32×16 像素矩阵按TAMA_SCREEN_SCALE_FACTOR = 2放大、在画布中居中,用canvas_draw_box逐点画出一个 64×32 的像素块;再在矩阵上下两侧各排 4 枚 14×14 的状态图标,按icons位图逐位决定显隐,横向间距按矩阵宽度自动均分(tama_p1.c 绘制分支)。
线程模型是:一个名为 "TamaLIB" 的工作线程循环调用tamalib_step()推进 CPU,GUI 线程负责输入与刷新;两者共用一把递归互斥锁g_state_mutex保护帧缓冲,事件则经过一个深度 8 的FuriMessageQueue传递EventTypeInput/EventTypeTick两类消息。之所以不用现成的tamalib_mainloop()而改用手动步进,正是为了让 Flipper 的 GUI 事件循环掌握节奏。
save.bin 存档结构
存档落在/ext/tama_p1/save.bin(tama.h 的TAMA_SAVE_PATH),由 tama_p1_save_state 写、tama_p1_load_state读,二进制布局是顺序拼接的定长字段:
| 字段 | 字节数 | 说明 |
|---|---|---|
| Magic | 4 | 固定"TLST",校验文件合法性 |
| Version | 1 | 当前为 2,不匹配直接放弃恢复 |
| PC | 2 | 程序计数器,高字节只取低 5 位(掩码0x1F) |
| X / Y | 2+2 | 通用寄存器,高字节掩码0xF |
| A / B / NP | 1+1+1 | 累加器、B 寄存器(各 4 位)、页选择(5 位) |
| SP / Flags | 1+1 | 栈指针、标志寄存器 |
| tick_counter | 4 | 时钟周期计数 |
| clk_timer_timestamp | 4 | 时钟定时器时间戳 |
| prog_timer_timestamp | 4 | 可编程定时器时间戳 |
| prog_timer_enabled / data / rld | 1+1+1 | 定时器使能、数据、重载值 |
| call_depth | 4 | 调用栈深度 |
| interrupts | 3×16 | 16 个中断槽各存 factor/mask/triggered |
| RAM | 640 | 每个半字节占 1 字节 |
| I/O | 128 | 0xF00~0xF7F的全部 I/O 半字节 |
加载时若 Magic 或版本不对,只打FATAL错误日志、跳过恢复;恢复完 RAM 与 I/O 后调tamalib_refresh_hw()让显示和蜂鸣器状态重新同步——所以关掉再开,宠物接着上次活。
从零到能玩:部署全流程
第一步:备料(ROM 准备与字节序重排)
📌 在 microSD 卡根目录建tama_p1文件夹,放入名为rom.bin的 ROM(README 提到网传资源里有个叫a的文件,改名即可)。路径常量TAMA_ROM_PATH即/ext/tama_p1/rom.bin。
ROM 读入内存后并不是直接可用,tama_p1_init 里紧接着做了一次字节序重排:
// 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; }原因是 P1 的程序存储按半字节组织:每对相邻字节,把后一个挪到前面、前一个只留低 4 位,才还原出内核期望的顺序。完成后以tamalib_init((u12_t*)ctx->rom, NULL, 64000)初始化,参数 64000 决定了时间戳的刻度。文件不存在时,屏幕会显示 "No ROM";运行中 CPU 停机则显示 "Halted"。
第二步:编译(fbt 一键编译部署 + 图标资源)
把整个tama_p1文件夹放到固件源码树的applications/plugins/tama_p1位置,然后在仓库里执行:
./fbt launch_app APPSRC=applications/plugins/tama_p1launch_app目标会一次完成编译、串口部署并立即运行。原文档里APPSRC用的是 Windows 反斜杠写法,在 Linux/macOS 的 Shell 中要换成斜杠(如上面所示)。
应用还引用了 8 枚状态图标与启动图标,需要先跑资源脚本把 PNG 编成 C 头文件:
scripts/assets.py icons applications/tama_p1/icons applications/tama_p1/compiled产物是compiled/assets_icons.h,声明了I_icon_0~I_icon_7。
💡 若编译时因未使用参数告警被当成错误,按 README 的提示,在site_cons/cc.scons的CCFLAGS里追加-Wno-unused-parameter即可压掉 TamaLIB 带来的该类告警。
第三步:上机
运行后观察屏幕:出现放大的宠物画面即成功;出现 "No ROM" 检查 SD 卡路径与文件名;出现 "Halted" 说明 ROM 内容或字节序有问题。
怎么操作、怎么存档
基础三键对应关系(对照 tama_p1.c 输入处理):
| Flipper 按键 | 实际动作 |
|---|---|
| 左键 | 拓麻歌子 A 键 |
| OK | B 键 |
| 右键 | C 键 |
| 长按返回 | 保存并退出 |
| 上键(短按) | 静音——同时按下 A 与 C |
| 下键(短按)/ 返回(短按) | 立即写一次存档 |
最后两行是文档没细说、但代码里确实存在的行为:InputKeyUp会同时向内核上报BTN_LEFT与BTN_RIGHT的按下/释放;InputKeyDown短按与返回短按都会触发tama_p1_save_state()。
至于"能不能存档",README 里自相矛盾:正文写着"目前不支持保存,退出即重置",下方 Implemented 清单却又列着"保存/加载模拟器状态(/ext/tama_p1/save.bin)"。对照源码可以下结论——前一句是过时信息:保存/加载函数完整实现且已接入启动与退出路径,存档功能实际可用。
卡住了怎么查
串口日志是最快的定位手段。参考 README 的调试一节,用 FlipperScripts 里的serial_logger.py监听:
python serial_logger.py也可以编译部署和挂日志一起执行(两条命令用分号串起来):
./fbt launch_app APPSRC=applications/plugins/tama_p1; python serial_logger.py日志分两个 Tag:TamaP1覆盖 ROM 加载、存档读写与输入事件,TamaLIB覆盖内核层错误与状态信息。每条输入事件会打印sequence/key/type三要素,排查按键映射或状态机行为时直接看这三个数即可。
当前边界与可挖的方向
README 的 To-do 清单标出了现有版本的边界,对照源码大致是这些:
- 多存档槽(Slots):现在只有一个
save.bin;想扩展的话,给TAMA_SAVE_PATH加参数化文件名即可; - 游戏内重置:目前只能退出重进来;内核侧已有
cpu_reset()接口(cpu.h),缺的是一个入口键; - 测试模式:拓麻歌子固件自带的自检入口尚未接通;
- 音量调节:蜂鸣器响度写死在
furi_hal_speaker_start的 0.5 参数里; - 加速(Fast-forward):
fast_forward_done在初始化时就被置为true,旁边挂着TODO: implement fast forwarding,属于占位未完成。
结语
这套"外部插件外壳 + 跨平台模拟内核"的分工值得借鉴:TamaLIB 管 4 位机指令与硬件状态机,hal.c用不到两百行代码接上定时器、蜂鸣器与屏幕,换个平台只需重写 HAL。想继续看同类玩法,可以逛 Applications/Official/source-OLDER/xMasterX 下的其他游戏插件,或深入 tamalib/ 的核心源码。
【免费下载链接】FlipperPlayground (and dump) of stuff I make or modify for the Flipper Zero项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考