在 Flipper Zero 上跑通拓麻歌子 P1 模拟器:从 ROM 部署到 4 位机模拟原理
2026/9/20 21:15:27 网站建设 项目流程

在 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.hTamaLIB 硬件抽象层(HAL)在 STM32WB 上的实现
tamalib/模拟内核:cpu.c(指令集)、hw.c(LCD/蜂鸣器时序)、tamalib.c(调度)
icons/ + compiled/8 个状态图标源文件及编译产物assets_icons.h

application.fam 里还有几个值得留意的身份参数:入口tama_p1_app、依赖guistorage两个系统服务、应用栈仅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.sconsCCFLAGS中追加-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
OKB 键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 的内部结构

存档把整台"机器"冻结成一个定长二进制块:

字段大小说明
Magic4 B固定"TLST",校验文件合法性
Version1 B当前为 2
PC / X / Y2+2+2 B程序计数器与各寄存器,高字节按位宽掩码(0x1F/0xF
A / B / NP / SP / Flags各 1 B累加器、B 寄存器、页选择、栈指针、标志位
tick / 定时器时间戳3×4 B + 3 B时钟周期计数、两个定时器及使能位
call_depth4 B调用栈深度
interrupts16×3 B6 个中断槽 × (factor/mask/triggered),按INT_SLOT_NUM展开
RAM640 B0x000~0x27F,每半字节存 1 字节
I/O128 B0xF00~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):

区域地址半字节数
RAM0x000~0x27F640
显示区 10xE00~0xE4F80
显示区 20xE80~0xECF80
I/O0xF00~0xF7F128

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 线程与一把递归锁

整体数据流是一条清晰的因果链:

  1. 独立线程(栈 1024 字节)持递归互斥锁g_state_mutex,循环调用tamalib_step()手动步进 CPU;
  2. 按键事件经FuriMessageQueue(深度 8)送入 GUI 主循环,分发到tamalib_set_button()
  3. 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询