Compare commits

...
Author SHA1 Message Date
d4rks1d33 bc59edff17 FlipperGB is more fluid and stable, tutorial ask on discord
Build Dev Firmware / build (push) Has been cancelled
2026-07-15 00:56:04 -03:00
d4rks1d33 63d951dc38 Update ProtoPirate
Build Dev Firmware / build (push) Has been cancelled
2026-07-13 22:15:44 -03:00
d4rks1d33 f1422cb701 Full dpad emulation on receiver scene not need to save the signal to use all the buttons 2026-07-13 22:14:08 -03:00
45 changed files with 1279 additions and 802 deletions
@@ -15,6 +15,7 @@ typedef enum {
SubGhzCustomEventSceneReceiverInfoTxStart,
SubGhzCustomEventSceneReceiverInfoTxStop,
SubGhzCustomEventSceneReceiverInfoSave,
SubGhzCustomEventSceneReceiverInfoTxFullDpad,
SubGhzCustomEventSceneSaveName,
SubGhzCustomEventSceneSignalSettings,
SubGhzCustomEventSceneSaveSuccess,
@@ -1,6 +1,7 @@
#include "../subghz_i.h"
#include <lib/subghz/blocks/custom_btn.h>
#include <flipper_format/flipper_format_i.h>
#include "applications/main/subghz/helpers/subghz_txrx_i.h"
#include <lib/subghz/blocks/generic.h>
@@ -20,6 +21,9 @@ void subghz_scene_receiver_info_callback(GuiButtonType result, InputType type, v
} else if((result == GuiButtonTypeRight) && (type == InputTypeShort)) {
view_dispatcher_send_custom_event(
subghz->view_dispatcher, SubGhzCustomEventSceneReceiverInfoSave);
} else if((result == GuiButtonTypeLeft) && (type == InputTypeShort)) {
view_dispatcher_send_custom_event(
subghz->view_dispatcher, SubGhzCustomEventSceneReceiverInfoTxFullDpad);
}
}
@@ -29,7 +33,6 @@ static bool subghz_scene_receiver_info_update_parser(void* context) {
if(subghz_txrx_load_decoder_by_name_protocol(
subghz->txrx,
subghz_history_get_protocol_name(subghz->history, subghz->idx_menu_chosen))) {
// we are trying to deserialize without checking for errors, since it is assumed that we just received this chignal
subghz_protocol_decoder_base_deserialize(
subghz_txrx_get_decoder(subghz->txrx),
subghz_history_get_raw_data(subghz->history, subghz->idx_menu_chosen));
@@ -37,7 +40,6 @@ static bool subghz_scene_receiver_info_update_parser(void* context) {
SubGhzRadioPreset* preset =
subghz_history_get_radio_preset(subghz->history, subghz->idx_menu_chosen);
//Edit TX power, if necessary.
subghz_txrx_set_tx_power(preset->data, preset->data_size, subghz->tx_power);
subghz_txrx_set_preset(
@@ -93,7 +95,7 @@ void subghz_scene_receiver_info_draw_widget(SubGhz* subghz) {
subghz_scene_receiver_info_callback,
subghz);
}
// Removed static check
if(subghz_txrx_protocol_is_transmittable(subghz->txrx, false)) {
widget_add_button_element(
subghz->widget,
@@ -101,9 +103,14 @@ void subghz_scene_receiver_info_draw_widget(SubGhz* subghz) {
"Send",
subghz_scene_receiver_info_callback,
subghz);
widget_add_button_element(
subghz->widget,
GuiButtonTypeLeft,
"Full",
subghz_scene_receiver_info_callback,
subghz);
}
} else {
// [NO_DOLPHIN] widget_add_icon_element(subghz->widget, 83, 22, &I_WarningDolphinFlip_45x42);
widget_add_string_element(
subghz->widget, 13, 8, AlignLeft, AlignBottom, FontSecondary, "Error history parse.");
}
@@ -131,12 +138,7 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
if(!subghz_scene_receiver_info_update_parser(subghz)) {
return false;
}
//CC1101 Stop RX -> Start TX
subghz_txrx_hopper_pause(subghz->txrx);
// key concept: we start endless TX until user release OK button, and after this we send last
// protocols repeats - this guarantee that one press OK will
// be guarantee send the required minimum protocol data packets
// for all of this we use subghz_block_generic_global.endless_tx in protocols _yield function.
subghz->state_notifications = SubGhzNotificationStateTx;
subghz_block_generic_global.endless_tx = true;
if(!subghz_tx_start(
@@ -146,37 +148,51 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
subghz_txrx_hopper_unpause(subghz->txrx);
subghz->state_notifications = SubGhzNotificationStateRx;
subghz_block_generic_global.endless_tx = false;
return true;
}
return true;
} else if(event.event == SubGhzCustomEventSceneReceiverInfoTxStop) {
//CC1101 Stop Tx -> next tick event Start RX
// user release OK
// we switch off endless_tx - that mean protocols yield finish endless transmission,
// send upload "repeat=xx" times, and after will be stoped by the tick event down in this code
subghz->state_notifications = SubGhzNotificationStateTxWait;
subghz_block_generic_global.endless_tx = false;
return true;
} else if(event.event == SubGhzCustomEventSceneReceiverInfoSave) {
//CC1101 Stop RX -> Save
subghz->state_notifications = SubGhzNotificationStateIDLE;
subghz_txrx_hopper_set_state(subghz->txrx, SubGhzHopperStateOFF);
subghz_txrx_stop(subghz->txrx);
if(!subghz_scene_receiver_info_update_parser(subghz)) {
return false;
}
if(subghz_txrx_protocol_is_serializable(subghz->txrx)) {
subghz_file_name_clear(subghz);
subghz->save_datetime =
subghz_history_get_datetime(subghz->history, subghz->idx_menu_chosen);
subghz->save_datetime_set = true;
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneSaveName);
}
return true;
} else if(event.event == SubGhzCustomEventSceneReceiverInfoTxFullDpad) {
if(!subghz_scene_receiver_info_update_parser(subghz)) {
return false;
}
FlipperFormat* fff_history =
subghz_history_get_raw_data(subghz->history, subghz->idx_menu_chosen);
FlipperFormat* fff_data = subghz_txrx_get_fff_data(subghz->txrx);
Stream* src = flipper_format_get_raw_stream(fff_history);
Stream* dst = flipper_format_get_raw_stream(fff_data);
stream_seek(src, 0, StreamOffsetFromStart);
stream_clean(dst);
stream_copy_full(src, dst);
stream_seek(dst, 0, StreamOffsetFromStart);
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneTransmitter);
return true;
}
} else if(event.type == SceneManagerEventTypeTick) {
if(subghz_txrx_hopper_get_state(subghz->txrx) != SubGhzHopperStateOFF) {
subghz_txrx_hopper_update(subghz->txrx, subghz->last_settings->hopping_threshold);
@@ -193,18 +209,15 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
subghz->state_notifications = SubGhzNotificationStateRx;
break;
case SubGhzNotificationStateTxWait:
// we wait until hardware TX finished and after stop TX and start RX, else just blink led
if(!subghz_devices_is_async_complete_tx(subghz->txrx->radio_device)) {
notification_message(subghz->notifications, &sequence_blink_magenta_10);
} else {
subghz_txrx_stop(subghz->txrx);
// update screen
widget_reset(subghz->widget);
subghz_scene_receiver_info_draw_widget(subghz);
subghz->state_notifications = SubGhzNotificationStateIDLE;
if(!scene_manager_has_previous_scene(subghz->scene_manager, SubGhzSceneDecodeRAW)) {
if(!scene_manager_has_previous_scene(
subghz->scene_manager, SubGhzSceneDecodeRAW)) {
subghz_txrx_rx_start(subghz->txrx);
subghz_txrx_hopper_unpause(subghz->txrx);
if(!subghz_history_get_text_space_left(subghz->history, NULL)) {
@@ -222,7 +235,6 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
void subghz_scene_receiver_info_on_exit(void* context) {
SubGhz* subghz = context;
widget_reset(subghz->widget);
subghz_txrx_reset_dynamic_and_custom_btns(subghz->txrx);
}
+57 -15
View File
@@ -50,7 +50,7 @@ Navigate with **Up/Down**, activate with **OK**, close with **Back**.
| **Press START** | Sends a Start press to the game (pause menus, "PRESS START" screens) and resumes |
| **Press SELECT** | Sends a Select press to the game and resumes |
| **Frameskip** | Change with **Left/Right**: `auto` (recommended), or fixed `04`. `auto` shows the skip level currently in use |
| **Sound** | Toggle piezo sound on/off (`n/a` if the speaker is in use by another app) |
| **Sound** | Volume: cycle off/25/50/75/100% with **Left/Right**; **OK** toggles mute/full (`n/a` if the speaker is in use by another app) |
| **Save SRAM** | Writes the cartridge battery save (`.sav`) to the SD card immediately |
| **Exit** | Saves SRAM (if the cartridge has a battery) and quits the app |
@@ -95,8 +95,9 @@ piezo:
3. otherwise the noise channel, mapped to a short low buzz (percussion).
The result is a monophonic ringtone-style rendition of the game's music
and sound effects (sweeps like Mario's jump work). It can be toggled in
the emulator menu. Since no waveforms are synthesized, the CPU cost is
and sound effects (sweeps like Mario's jump work). Volume is adjustable in
the emulator menu (off/25/50/75/100%, perceptually spaced for the piezo's
nonlinear loudness). Since no waveforms are synthesized, the CPU cost is
negligible (one counter per emulated instruction) and RAM cost is ~120
bytes.
@@ -108,8 +109,31 @@ bytes.
(70224 M-cycles instead of 17556). Unnoticeable on a desktop, a slideshow
on a 64 MHz Cortex-M4. Fixed — this alone made everything ~4.5x faster.
- While halted (games spend most of each frame in HALT waiting for vblank),
the CPU steps 4 M-cycles at a time instead of 1, making the idle part of
the CPU steps 8 M-cycles at a time instead of 1, making the idle part of
the frame cheap.
- PPU/timer/APU use catch-up batching: instead of stepping their state
machines after every CPU instruction, cycles accumulate and are flushed
every 32 M-cycles - or immediately before any IO register access, so
LY/STAT/DIV/TIMA/IF always read exact. This removes the single biggest
per-instruction cost of the interpreter loop (~2.8x faster emulation).
- Undefined opcodes cost 1 cycle instead of 0: with 0-cycle entries,
executing garbage could advance the PC without advancing the PPU clock
and spin run_to_vblank() forever, freezing the whole device.
- EI enables interrupts only after the following instruction (hardware
behaviour). Without the delay, the classic `EI / HALT` pause idiom
consumed its wake interrupt before HALT and games froze waiting for a
button press that had already been eaten (input dead, music playing).
- STOP freezes the CPU until joypad activity, independent of IE/IF/IME
(hardware behaviour), and skips its padding byte.
- ROM streaming: bank switches to the already-mapped bank skip the cache;
the upper 8 KB half of a bank streams in lazily on first read; the LRU
never evicts the most-recently-used page and protects the single hottest
page (the music-driver bank) from map-streaming evictions; the Gameboy
core block is pre-allocated during ROM load so the page cache can size
itself against real free heap (Pokemon: ~2x more cache slots).
- The auto-frameskip EMA uses signed arithmetic (an unsigned underflow
could pin it at maximum), and the menu shows the measured cost of one
emulated frame in ms next to the free heap (16.7ms = full speed).
- **Frameskip `auto`** (default) measures the real cost of each emulated frame
and skips *rendering* (never emulation) to keep the game running at correct
speed. Games stay full-speed logically; visible FPS drops instead.
@@ -117,8 +141,26 @@ bytes.
- The PPU only renders the 64 scanlines (out of 144) that survive the
downscale to the Flipper LCD — ~55% of the per-frame rendering work is
skipped with zero visual difference.
- Bank-switch heavy games may micro-stutter when a 16 KB bank has to be
streamed from the SD card (only happens when the ROM doesn't fit in RAM).
- ROM streaming works on 8 KB pages (half a MBC bank): twice the cache
slots per KB of heap and half the SD stall per miss compared to whole-bank
caching. Bank-switch heavy games may still micro-stutter on a cache miss
(only happens when the ROM doesn't fit in RAM).
- The emulator core is compiled `-O2` (the rest of the app stays `-Os`) and
the per-instruction hot path (opcode fetch, PPU/timer/APU ticks) is
manually inlined across the core, cutting per-instruction call overhead.
- The CPU interpreter is a unity build: the 500 opcode bodies compile in
the same translation unit as the dispatch switch, so the compiler inlines
them directly into the jump table (two cross-TU calls per instruction
eliminated) - and the deduplication actually made the binary smaller.
- The PPU renders tiles byte-wise, not pixel-wise: tile bitplanes are
decoded 8 pixels at once through a 256-entry spread LUT and written as
whole palette-mapped bytes (4 px per store) - ~7x fewer operations per
scanline. Sprites skip fully-transparent rows and use the same LUTs.
- The 160x144 -> 128x64 downscale/dither also works one packed byte
(4 pixels) per lookup instead of per-pixel shade extraction.
- In `auto` mode the frameskip may go up to 8 (fixed settings stay 0-4):
for heavy streamed games, correct game speed at a lower visible fps beats
slow motion.
- The emulator menu shows the free heap (`NNk free`) so you can see the
memory headroom of the current game at a glance.
@@ -149,8 +191,8 @@ ufbt launch # builds, installs and runs on a connected Flipper
The exact core that ships in the FAP can be compiled and tested on a desktop:
```sh
g++ -std=c++17 -O2 -fno-exceptions -fno-rtti -I gb \
-o hosttest/hosttest hosttest/main.cpp gb/*.cc
g++ -std=c++17 -O2 -fno-exceptions -fno-rtti -I lib/gbcore \
-o hosttest/hosttest hosttest/main.cpp lib/gbcore/*.cc
# Blargg CPU tests (print Passed/Failed via the serial port):
./hosttest/hosttest path/to/01-special.gb 4000
@@ -170,8 +212,8 @@ Current status: **Blargg `cpu_instrs` 11/11 PASS**.
| Upstream (PC) | This port (Flipper) |
|---|---|
| Whole ROM in RAM (with several transient copies) | 16 KB bank streaming from SD with an adaptive LRU cache; bank 0 resident; when every bank fits, the whole ROM is preloaded into individual 16 KB slots (O(1) switching, SD file closed) |
| — | All ROM-dependent allocations are 16 KB or smaller and are checked against the largest free heap block first: heap fragmentation can never crash the firmware, the app degrades to streaming or shows "Not enough RAM" instead |
| Whole ROM in RAM (with several transient copies) | 8 KB page streaming from SD with an adaptive LRU cache; bank 0 resident; when every page fits, the whole ROM is preloaded into individual 8 KB slots (O(1) switching, SD file closed) |
| — | All ROM-dependent allocations are 8 KB or smaller and are checked against the largest free heap block first: heap fragmentation can never crash the firmware, the app degrades to streaming or shows "Not enough RAM" instead |
| Renders all 144 scanlines | Renders only the 64 scanlines that are actually displayed after the 144→64 downscale (row mask, ~2x faster rendering) |
| PPU counts M-cycles against T-cycle constants (4x too much CPU emulation per frame) | Hardware-correct M-cycle constants (114 per scanline): ~4.5x faster overall |
| DIV register incremented every M-cycle (64x too fast) | Correct 16384 Hz rate (games use DIV for delays and randomness) |
@@ -187,14 +229,14 @@ Current status: **Blargg `cpu_instrs` 11/11 PASS**.
| No APU at all | Register-level APU (sweep/envelope/length/NR52, proper read-back masks) driving the piezo with the dominant voice |
RAM budget on device (256 KB total, ~140 KB heap; the app binary itself
loads into ~32 KB of that heap): ~19.5 KB emulation state, 16 KB bank 0,
adaptive bank cache (10 KB heap kept in reserve for the system), 032 KB
cartridge RAM per game, 4 KB stack. The bank cache is allocated greedily in
independent 16 KB blocks until the reserve would be touched, so any `.gb`
loads into ~37 KB of that heap): ~19.5 KB emulation state, 16 KB bank 0,
adaptive page cache (10 KB heap kept in reserve for the system), 032 KB
cartridge RAM per game, 4 KB stack. The page cache is allocated greedily in
independent 8 KB blocks until the reserve would be touched, so any `.gb`
ROM size works: small ROMs end up fully resident, large ones stream through
however many slots fit. Worst case (1 MB ROM + 32 KB battery RAM, e.g.
Pokémon Red/Blue) needs ~78 KB before the first cache slot, which fits the
post-launch heap with room for 12 streaming slots.
post-launch heap with room for a few streaming pages.
## License
+12 -1
View File
@@ -3,10 +3,21 @@ App(
name="FlipGB",
apptype=FlipperAppType.EXTERNAL,
entry_point="flipgb_app",
sources=["*.c*", "!hosttest"],
sources=["*.c*", "!hosttest", "!lib"],
requires=["gui", "dialogs", "storage"],
stack_size=8 * 1024,
cdefines=[("GB_FB_ROWS", "64")],
fap_private_libs=[
Lib(
# The emulator core is built as a private lib so it can use -O2:
# ufbt builds FAP sources with -Os, which costs ~30% emulation
# speed in the per-instruction hot path. Code size difference
# is ~6 KB - well worth it.
name="gbcore",
cflags=["-O2"],
cdefines=[("GB_FB_ROWS", "64")],
),
],
fap_category="Games",
fap_icon="flipgb_icon.png",
fap_author="user",
Binary file not shown.
Binary file not shown.
@@ -1,73 +0,0 @@
#pragma once
#include "input.h"
#include "cpu.h"
#include "video.h"
#include "timer.h"
#include "mmu.h"
#include "cartridge.h"
#include "apu.h"
class Gameboy {
public:
/* bank0: pointer to the first 16 KB of ROM (stays resident).
* provider: returns pointers to 16 KB switchable banks. */
Gameboy(
const u8* bank0,
uint rom_bank_count,
MBCType mbc,
u8* cart_ram,
u32 cart_ram_size,
RomBankProvider provider,
void* provider_ctx);
/* Runs the emulator until the next vblank (one full frame). */
void run_to_vblank();
/* Called on every vblank BEFORE the framebuffer is cleared for the next
* frame: this is where the frontend must convert/copy the image. */
void set_frame_callback(void (*cb)(void*), void* ctx) {
user_frame_cb = cb;
user_frame_ctx = ctx;
}
void button_pressed(GbButton button);
void button_released(GbButton button);
void set_skip_render(bool skip) { video.skip_render = skip; }
/* Optional display-line mask (see Video::row_mask). The array must stay
* valid for the lifetime of the emulator. */
void set_row_mask(const u8* mask) { video.set_row_mask(mask); }
auto get_framebuffer() const -> const FrameBuffer& { return video.get_framebuffer(); }
auto get_cartridge_ram() -> u8* { return cartridge.get_ram(); }
auto get_cartridge_ram_size() const -> u32 { return cartridge.get_ram_size(); }
Cartridge cartridge;
CPU cpu;
friend class CPU;
Video video;
friend class Video;
MMU mmu;
friend class MMU;
Timer timer;
friend class Timer;
Apu apu;
Input input;
private:
void tick();
static void vblank_trampoline(void* ctx);
volatile bool frame_done = false;
void (*user_frame_cb)(void*) = nullptr;
void* user_frame_ctx = nullptr;
};
-77
View File
@@ -1,77 +0,0 @@
#include "timer.h"
#include "definitions.h"
#include "gameboy.h"
#include "cpu.h"
#include "bitwise.h"
const uint CLOCKS_PER_CYCLE = 4;
Timer::Timer(Gameboy& _gb) : gb(_gb) {}
void Timer::tick(uint cycles) {
/* DIV increments at 16384 Hz = every 64 M-cycles (upstream incremented
* it once per M-cycle: 64x too fast, breaking games that use DIV for
* delays or randomness) */
div_clocks += cycles;
if(div_clocks >= 64) {
divider.set(static_cast<u8>(divider.value() + (div_clocks >> 6)));
div_clocks &= 63;
}
clocks += cycles * CLOCKS_PER_CYCLE;
auto timer_is_on = timer_control.check_bit(2);
if (timer_is_on == 0) { return; }
auto clock_limit = clocks_needed_to_increment();
if (clocks >= clock_limit) {
clocks = clocks % clock_limit;
u8 old_timer_counter = timer_counter.value();
timer_counter.increment();
if (timer_counter.value() < old_timer_counter) {
gb.cpu.interrupt_flag.set_bit_to(2, true);
timer_counter.set(timer_modulo.value());
}
}
}
auto Timer::get_divider() const -> u8 { return divider.value(); }
auto Timer::get_timer() const -> u8 { return timer_counter.value(); }
auto Timer::get_timer_modulo() const -> u8 { return timer_modulo.value(); }
// Only the bottom three bits of this register are usable
auto Timer::get_timer_control() const -> u8 { return timer_control.value() & 0x3; }
void Timer::reset_divider() {
divider.set(0x0);
}
void Timer::set_timer(u8 value) {
timer_counter.set(value);
}
void Timer::set_timer_modulo(u8 value) {
timer_modulo.set(value);
}
void Timer::set_timer_control(u8 value) {
timer_control.set(value);
}
uint Timer::clocks_needed_to_increment() {
using bitwise::check_bit;
switch (get_timer_control()) {
case 0: return CLOCK_RATE / 4096;
case 1: return CLOCK_RATE / 262144;
case 2: return CLOCK_RATE / 65536;
case 3: return CLOCK_RATE / 16384;
default: return CLOCK_RATE / 4096; /* unreachable */
}
}
-37
View File
@@ -1,37 +0,0 @@
#pragma once
#include "definitions.h"
#include "register.h"
class Gameboy;
class Timer {
public:
Timer(Gameboy& inGb);
void tick(uint cycles);
auto get_divider() const -> u8;
auto get_timer() const -> u8;
auto get_timer_modulo() const -> u8;
auto get_timer_control() const -> u8;
void reset_divider();
void set_timer(u8 value);
void set_timer_modulo(u8 value);
void set_timer_control(u8 value);
private:
uint clocks_needed_to_increment();
uint clocks = 0;
uint div_clocks = 0;
Gameboy& gb;
ByteRegister divider;
ByteRegister timer_counter;
ByteRegister timer_modulo;
ByteRegister timer_control;
};
-353
View File
@@ -1,353 +0,0 @@
#include "video.h"
#include "gameboy.h"
#include "cpu.h"
#include "bitwise.h"
using bitwise::check_bit;
Video::Video(Gameboy& inGb)
: gb(inGb) {
}
void Video::tick(Cycles cycles) {
cycle_counter += cycles.cycles;
switch(current_mode) {
case VideoMode::ACCESS_OAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_OAM) {
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, true);
current_mode = VideoMode::ACCESS_VRAM;
}
break;
case VideoMode::ACCESS_VRAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_VRAM) {
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE_VRAM;
current_mode = VideoMode::HBLANK;
bool hblank_interrupt = check_bit(lcd_status.value(), 3);
if(hblank_interrupt) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
bool ly_coincidence_interrupt = check_bit(lcd_status.value(), 6);
bool ly_coincidence = ly_compare.value() == line.value();
if(ly_coincidence_interrupt && ly_coincidence) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, ly_coincidence);
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, false);
}
break;
case VideoMode::HBLANK:
if(cycle_counter >= CLOCKS_PER_HBLANK) {
if(!skip_render) write_scanline(line.value());
line.increment();
cycle_counter = cycle_counter % CLOCKS_PER_HBLANK;
/* Line 145 (index 144) is the first line of VBLANK */
if(line == 144) {
current_mode = VideoMode::VBLANK;
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, true);
gb.cpu.interrupt_flag.set_bit_to(0, true);
} else {
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
current_mode = VideoMode::ACCESS_OAM;
}
}
break;
case VideoMode::VBLANK:
if(cycle_counter >= CLOCKS_PER_SCANLINE) {
line.increment();
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE;
/* Line 155 (index 154) is the last line */
if(line == 154) {
if(!skip_render) {
write_sprites();
draw();
buffer.reset();
} else {
draw(); /* still notify the frontend for pacing */
}
line.reset();
current_mode = VideoMode::ACCESS_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
};
}
break;
}
}
auto Video::display_enabled() const -> bool {
return check_bit(control_byte, 7);
}
auto Video::window_tile_map() const -> bool {
return check_bit(control_byte, 6);
}
auto Video::window_enabled() const -> bool {
return check_bit(control_byte, 5);
}
auto Video::bg_window_tile_data() const -> bool {
return check_bit(control_byte, 4);
}
auto Video::bg_tile_map_display() const -> bool {
return check_bit(control_byte, 3);
}
auto Video::sprite_size() const -> bool {
return check_bit(control_byte, 2);
}
auto Video::sprites_enabled() const -> bool {
return check_bit(control_byte, 1);
}
auto Video::bg_enabled() const -> bool {
return check_bit(control_byte, 0);
}
void Video::write_scanline(u8 current_line) {
if(!display_enabled()) {
return;
}
/* Lines the frontend never displays are not worth rendering */
if(row_mask && current_line < GAMEBOY_HEIGHT && !row_mask[current_line]) {
return;
}
if(bg_enabled()) {
draw_bg_line(current_line);
}
if(window_enabled()) {
draw_window_line(current_line);
}
}
void Video::write_sprites() {
if(!sprites_enabled()) {
return;
}
for(uint sprite_n = 0; sprite_n < 40; sprite_n++) {
draw_sprite(sprite_n);
}
}
void Video::draw_bg_line(uint current_line) {
/* Note: tileset two uses signed numbering to share half the tiles with
* tileset one */
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !bg_tile_map_display();
Palette palette = load_palette(bg_palette);
u16 tile_set_address = use_tile_set_zero ? TILE_SET_ZERO_ADDRESS : TILE_SET_ONE_ADDRESS;
u16 tile_map_address = use_tile_map_zero ? TILE_MAP_ZERO_ADDRESS : TILE_MAP_ONE_ADDRESS;
uint screen_y = current_line;
uint scrolled_y = (screen_y + scroll_y.value()) % BG_MAP_SIZE;
uint tile_y = scrolled_y / TILE_HEIGHT_PX;
uint tile_pixel_y = scrolled_y % TILE_HEIGHT_PX;
uint tile_data_line_offset = tile_pixel_y * 2;
/* Render tile-by-tile instead of refetching the tile data for every
* pixel like upstream did */
uint scroll_x_val = scroll_x.value();
uint screen_x = 0;
while(screen_x < GAMEBOY_WIDTH) {
uint scrolled_x = (screen_x + scroll_x_val) % BG_MAP_SIZE;
uint tile_x = scrolled_x / TILE_WIDTH_PX;
uint tile_pixel_x = scrolled_x % TILE_WIDTH_PX;
uint tile_index = tile_y * TILES_PER_LINE + tile_x;
u8 tile_id = video_ram[tile_map_address - 0x8000 + tile_index];
uint tile_data_mem_offset = use_tile_set_zero ?
tile_id * TILE_BYTES :
static_cast<uint>(
(static_cast<s8>(tile_id) + 128)) *
TILE_BYTES;
uint line_addr = (tile_set_address - 0x8000) + tile_data_mem_offset +
tile_data_line_offset;
u8 pixels_1 = video_ram[line_addr];
u8 pixels_2 = video_ram[line_addr + 1];
/* Draw the remainder of this tile's row */
for(uint px = tile_pixel_x; px < TILE_WIDTH_PX && screen_x < GAMEBOY_WIDTH;
px++, screen_x++) {
u8 pixel_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(px));
buffer.set_pixel(screen_x, screen_y, get_shade_from_palette(pixel_color, palette));
}
}
}
void Video::draw_window_line(uint current_line) {
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !window_tile_map();
Palette palette = load_palette(bg_palette);
u16 tile_set_address = use_tile_set_zero ? TILE_SET_ZERO_ADDRESS : TILE_SET_ONE_ADDRESS;
u16 tile_map_address = use_tile_map_zero ? TILE_MAP_ZERO_ADDRESS : TILE_MAP_ONE_ADDRESS;
uint screen_y = current_line;
uint scrolled_y = screen_y - window_y.value();
if(scrolled_y >= GAMEBOY_HEIGHT) {
return;
}
uint tile_y = scrolled_y / TILE_HEIGHT_PX;
uint tile_pixel_y = scrolled_y % TILE_HEIGHT_PX;
uint tile_data_line_offset = tile_pixel_y * 2;
for(uint screen_x = 0; screen_x < GAMEBOY_WIDTH; screen_x++) {
uint scrolled_x = screen_x + window_x.value() - 7;
uint tile_x = scrolled_x / TILE_WIDTH_PX;
uint tile_pixel_x = scrolled_x % TILE_WIDTH_PX;
uint tile_index = tile_y * TILES_PER_LINE + tile_x;
if(tile_index >= 32 * 32) continue;
u8 tile_id = video_ram[tile_map_address - 0x8000 + tile_index];
uint tile_data_mem_offset = use_tile_set_zero ?
tile_id * TILE_BYTES :
static_cast<uint>(
(static_cast<s8>(tile_id) + 128)) *
TILE_BYTES;
uint line_addr = (tile_set_address - 0x8000) + tile_data_mem_offset +
tile_data_line_offset;
u8 pixels_1 = video_ram[line_addr];
u8 pixels_2 = video_ram[line_addr + 1];
u8 pixel_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(tile_pixel_x));
buffer.set_pixel(screen_x, screen_y, get_shade_from_palette(pixel_color, palette));
}
}
void Video::draw_sprite(const uint sprite_n) {
/* Each sprite is represented by 4 bytes */
u16 oam_start = static_cast<u16>(sprite_n * SPRITE_BYTES);
u8 sprite_y = gb.mmu.oam_ram[oam_start];
u8 sprite_x = gb.mmu.oam_ram[oam_start + 1];
/* Offscreen sprites are not drawn */
if(sprite_y == 0 || sprite_y >= 160) {
return;
}
if(sprite_x == 0 || sprite_x >= 168) {
return;
}
uint sprite_height = sprite_size() ? 16 : 8;
u8 pattern_n = gb.mmu.oam_ram[oam_start + 2];
u8 sprite_attrs = gb.mmu.oam_ram[oam_start + 3];
/* Bits 0-3 are used only for CGB */
bool use_palette_1 = check_bit(sprite_attrs, 4);
bool flip_x = check_bit(sprite_attrs, 5);
bool flip_y = check_bit(sprite_attrs, 6);
bool obj_behind_bg = check_bit(sprite_attrs, 7);
Palette palette = use_palette_1 ? load_palette(sprite_palette_1) :
load_palette(sprite_palette_0);
uint tile_offset = pattern_n * TILE_BYTES;
int start_y = sprite_y - 16;
int start_x = sprite_x - 8;
for(uint y = 0; y < sprite_height; y++) {
int screen_y = start_y + static_cast<int>(y);
if(screen_y < 0 || screen_y >= static_cast<int>(GAMEBOY_HEIGHT)) continue;
if(row_mask && !row_mask[screen_y]) continue;
uint src_y = !flip_y ? y : sprite_height - y - 1;
uint line_addr = tile_offset + src_y * 2; /* relative to tile set zero */
u8 pixels_1 = video_ram[line_addr];
u8 pixels_2 = video_ram[line_addr + 1];
for(uint x = 0; x < TILE_WIDTH_PX; x++) {
int screen_x = start_x + static_cast<int>(x);
if(screen_x < 0 || screen_x >= static_cast<int>(GAMEBOY_WIDTH)) continue;
uint src_x = !flip_x ? x : TILE_WIDTH_PX - x - 1;
u8 gb_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(src_x));
/* Color 0 is transparent */
if(gb_color == 0) {
continue;
}
Shade existing_pixel = buffer.get_pixel(
static_cast<uint>(screen_x), static_cast<uint>(screen_y));
/* Note: same behaviour as upstream - compares the final shade
* rather than the logical color 0 */
if(obj_behind_bg && existing_pixel != SHADE_WHITE) {
continue;
}
buffer.set_pixel(
static_cast<uint>(screen_x),
static_cast<uint>(screen_y),
get_shade_from_palette(gb_color, palette));
}
}
}
auto Video::get_pixel_from_line(u8 byte1, u8 byte2, u8 pixel_index) -> u8 {
using bitwise::bit_value;
return static_cast<u8>(
(bit_value(byte2, 7 - pixel_index) << 1) | bit_value(byte1, 7 - pixel_index));
}
auto Video::load_palette(const ByteRegister& palette_register) -> Palette {
u8 v = palette_register.value();
Palette palette;
palette.color0 = static_cast<Shade>(v & 0x3);
palette.color1 = static_cast<Shade>((v >> 2) & 0x3);
palette.color2 = static_cast<Shade>((v >> 4) & 0x3);
palette.color3 = static_cast<Shade>((v >> 6) & 0x3);
return palette;
}
auto Video::get_shade_from_palette(u8 color, const Palette& palette) -> Shade {
switch(color) {
case 0:
return palette.color0;
case 1:
return palette.color1;
case 2:
return palette.color2;
default:
return palette.color3;
}
}
void Video::draw() {
if(vblank_callback) vblank_callback(vblank_ctx);
}
Binary file not shown.
@@ -3,7 +3,7 @@
* Usage: hosttest <rom.gb> [max_frames] [--dump-frame N]
*/
#include "../gb/gameboy.h"
#include "../lib/gbcore/gameboy.h"
#include <cstdio>
#include <cstdlib>
@@ -29,9 +29,9 @@ static void serial_hook(u8 byte) {
}
}
static const u8* bank_provider(void* /*ctx*/, uint bank) {
long offset = static_cast<long>(bank) * 0x4000;
if(offset + 0x4000 > g_rom_size) offset = 0;
static const u8* bank_provider(void* /*ctx*/, uint page) {
long offset = static_cast<long>(page) * 0x2000; /* 8 KB ROM pages */
if(offset + 0x2000 > g_rom_size) offset = 0;
return g_rom + offset;
}
@@ -98,10 +98,12 @@ int main(int argc, char** argv) {
bool dump = false;
bool rowmask = false;
bool dump_audio = false;
bool skiprender = false;
for(int i = 1; i < argc; i++) {
if(!strcmp(argv[i], "--dump-frame")) dump = true;
if(!strcmp(argv[i], "--rowmask")) rowmask = true;
if(!strcmp(argv[i], "--dump-audio")) dump_audio = true;
if(!strcmp(argv[i], "--skiprender")) skiprender = true;
}
/* Same 144 -> 64 line subsampling the Flipper frontend uses */
@@ -146,6 +148,7 @@ int main(int argc, char** argv) {
auto* gb = new Gameboy(g_rom, banks, mbc, cart_ram, ram_size, bank_provider, nullptr);
gb->set_frame_callback(frame_hook, gb);
if(rowmask) gb->set_row_mask(mask);
if(skiprender) gb->set_skip_render(true);
u32 last_freq = 0;
int last_ch = -1;
@@ -1,15 +1,9 @@
#include "apu.h"
/* One frame-sequencer step every 2048 M-cycles (8192 T-cycles = 512 Hz),
* in the same M-cycle domain the CPU/PPU/timer now share. */
static const uint FRAME_SEQ_PERIOD = 2048;
void Apu::tick(uint cycles) {
if(!power) return;
seq_counter += cycles;
while(seq_counter >= FRAME_SEQ_PERIOD) {
seq_counter -= FRAME_SEQ_PERIOD;
/* Slow path of Apu::tick (see apu.h): runs the due frame-sequencer steps */
void Apu::seq_run() {
while(seq_counter >= 2048) {
seq_counter -= 2048;
seq_step = (u8)((seq_step + 1) & 7);
if((seq_step & 1) == 0) clock_lengths(); /* 256 Hz */
@@ -28,7 +28,14 @@ struct ApuVoice {
class Apu {
public:
void tick(uint cycles);
/* Called once per emulated instruction; the fast path (powered off, or
* no sequencer step due) must stay inline and branch-cheap. One
* frame-sequencer step every 2048 M-cycles = 8192 T-cycles = 512 Hz. */
void tick(uint cycles) {
if(!power) return;
seq_counter += cycles;
if(seq_counter >= 2048) seq_run();
}
/* 0xFF10 - 0xFF3F (sound registers + wave RAM) */
auto read(u16 addr) const -> u8;
@@ -81,6 +88,7 @@ private:
}
void trigger(uint n);
void seq_run();
void clock_lengths();
void clock_envelopes();
void clock_sweep();
@@ -21,6 +21,7 @@ void Cartridge::init(
bank_low = 1;
bank_high = 0;
ram_bank = 0;
cur_bank = 0xFFFFFFFFu;
update_rom_bank();
}
@@ -51,7 +52,24 @@ void Cartridge::update_rom_bank() {
if(bank_count) bank %= bank_count;
bankN = provider(provider_ctx, bank);
/* Games (Pokemon's Bankswitch routine included) frequently rewrite the
* bank register with the bank that is already mapped: skip the cache
* lookups entirely in that case. */
if(bank == cur_bank) return;
cur_bank = bank;
/* map the lo 8 KB page now; the hi page streams in lazily on first
* read from 0x6000-0x7FFF (Cartridge::read) */
bankN_lo = provider(provider_ctx, bank * 2);
bankN_hi = nullptr;
}
void Cartridge::fetch_hi_page() const {
/* Refresh the lo page's LRU stamp first so the hi fetch can never
* evict it (the provider guarantees the two most recently returned
* pages are safe when the cache has >= 2 slots). */
bankN_lo = provider(provider_ctx, cur_bank * 2);
bankN_hi = provider(provider_ctx, cur_bank * 2 + 1);
}
void Cartridge::write(u16 addr, u8 value) {
@@ -2,19 +2,23 @@
#include "definitions.h"
/* Cartridge with pluggable ROM bank provider.
/* Cartridge with pluggable ROM page provider.
*
* Instead of holding the whole ROM in RAM (impossible on Flipper Zero for
* anything above 32 KB), the cartridge asks the platform for a pointer to a
* 16 KB bank whenever the game switches banks. On the desktop test build the
* provider just returns `rom + bank * 0x4000`; on the Flipper it is backed
* by an LRU cache streaming from the SD card.
* anything above 32 KB), the cartridge asks the platform for pointers to
* 8 KB ROM pages (page n = ROM offset n * 0x2000) whenever the game
* switches banks. 8 KB granularity -- half a MBC bank -- doubles how many
* cache slots fit in the same RAM and halves the SD stall of a cache miss,
* which matters a lot for bank-switch heavy games (Pokemon switches banks
* for music/code/data every frame). On the desktop test build the provider
* just returns `rom + page * 0x2000`; on the Flipper it is backed by an
* LRU cache streaming from the SD card.
*
* Supported mappers: ROM only, MBC1 (incl. upper bits / mode select),
* MBC2 (built-in 512x4 RAM), MBC3 (no RTC), MBC5.
*/
using RomBankProvider = const u8* (*)(void* ctx, uint bank);
using RomBankProvider = const u8* (*)(void* ctx, uint page);
enum class MBCType : u8 {
None,
@@ -39,7 +43,15 @@ public:
auto read(u16 addr) const -> u8 {
if(addr < 0x4000) return bank0[addr];
if(addr < 0x8000) return bankN[addr - 0x4000];
if(addr < 0x6000) return bankN_lo[addr - 0x4000];
if(addr < 0x8000) {
/* lazy: the hi half of a bank is only streamed in when the
* game actually reads 0x6000-0x7FFF from it. Many switches
* exist just to read a table at 0x4xxx; fetching both 8 KB
* pages eagerly doubled the SD misses of streamed games. */
if(!bankN_hi) fetch_hi_page();
return bankN_hi[addr - 0x6000];
}
/* 0xA000 - 0xBFFF: cartridge RAM */
return read_ram(addr);
}
@@ -59,9 +71,12 @@ private:
auto read_ram(u16 addr) const -> u8;
void write_ram(u16 addr, u8 value);
void update_rom_bank();
void fetch_hi_page() const;
const u8* bank0 = nullptr;
const u8* bankN = nullptr;
mutable const u8* bankN_lo = nullptr; /* 0x4000 - 0x5FFF */
mutable const u8* bankN_hi = nullptr; /* 0x6000 - 0x7FFF, lazy (see read) */
uint cur_bank = 0xFFFFFFFFu; /* currently mapped bank (memo) */
u8* ram = nullptr;
u32 ram_size = 0;
@@ -29,18 +29,42 @@ void CPU::init_post_boot() {
}
auto CPU::tick() -> Cycles {
handle_interrupts();
/* STOP: the CPU core is frozen until joypad activity (notify_joypad).
* Interrupt flags raised meanwhile stay pending. */
if (stopped) { return 8; }
/* Halted: batch 4 M-cycles per iteration. Games spend most of every
/* Interrupt fast path: nothing pending for the enabled sources (the
* overwhelmingly common case) costs two loads and a branch. Bits 5-7
* of IF/IE are unwired on hardware and must be masked: without the
* mask, IF's always-set upper bits (post-boot 0xE1) against a game
* writing IE=0xFF would push PC without dispatching any vector. */
u8 fired = (u8)(interrupt_flag.value() & interrupt_enabled.value() & 0x1F);
if(fired) handle_interrupts(fired);
/* Halted: batch 8 M-cycles per iteration. Games spend most of every
* frame in HALT waiting for vblank; stepping 1 cycle at a time made
* the idle part of the frame as expensive to emulate as the busy part.
* Interrupt recognition is delayed by at most 3 M-cycles (12 T-cycles),
* Interrupt recognition is delayed by at most 7 M-cycles (28 T-cycles),
* well within what real hardware tolerates. */
if (halted) { return 4; }
if (halted) { return 8; }
/* EI enables IME only AFTER the instruction that follows it. Without
* this delay, the classic pause idiom `EI / HALT` with an interrupt
* already pending dispatched BETWEEN the two: the ISR consumed the
* wake event, RETI returned onto the HALT, and the game slept with
* its wake condition already spent -- input appeared permanently dead
* while the vblank ISR (music) kept running. */
bool ei_was_pending = ime_pending;
u16 opcode_pc = pc.value();
auto opcode = get_byte_from_pc();
auto cycles = execute_opcode(opcode, opcode_pc);
if(ei_was_pending && ime_pending) {
/* not cancelled by a DI in the delay slot */
interrupts_enabled = true;
ime_pending = false;
}
return cycles;
}
@@ -55,11 +79,8 @@ auto CPU::execute_opcode(const u8 opcode, u16 opcode_pc) -> Cycles {
return execute_normal_opcode(opcode, opcode_pc);
}
void CPU::handle_interrupts() {
u8 fired_interrupts = interrupt_flag.value() & interrupt_enabled.value();
if (!fired_interrupts) { return; }
if (halted && fired_interrupts != 0x0) {
void CPU::handle_interrupts(u8 fired_interrupts) {
if (halted) {
// TODO: Handle halt bug
halted = false;
}
@@ -99,24 +120,9 @@ auto CPU::handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_inte
return true;
}
auto CPU::get_byte_from_pc() -> u8 {
u8 byte = gb.mmu.read(Address(pc));
pc.increment();
return byte;
}
auto CPU::get_signed_byte_from_pc() -> s8 {
u8 byte = get_byte_from_pc();
return static_cast<s8>(byte);
}
auto CPU::get_word_from_pc() -> u16 {
u8 low_byte = get_byte_from_pc();
u8 high_byte = get_byte_from_pc();
return compose_bytes(high_byte, low_byte);
}
/* get_byte_from_pc / get_signed_byte_from_pc / get_word_from_pc are the
* hottest functions in the emulator (every instruction fetches through
* them): they are defined inline at the bottom of gameboy.h. */
void CPU::set_flag_zero(bool set) { f.set_flag_zero(set); }
void CPU::set_flag_subtract(bool set) { f.set_flag_subtract(set); }
@@ -223,3 +229,11 @@ auto CPU::execute_cb_opcode(const u8 opcode, u16 opcode_pc) -> Cycles {
return opcode_cycles_cb[opcode];
}
/* Unity build: the opcode implementations are compiled inside this same
* translation unit so the dispatch switches above can call (and inline)
* them directly. As separate TUs, every emulated instruction paid two
* cross-TU calls (dispatch -> opcode_XX -> opcode helper), which the
* compiler could not eliminate without LTO. */
#include "opcode_mapping.inc"
#include "opcodes.inc"
@@ -52,13 +52,27 @@ public:
ByteRegister interrupt_enabled;
private:
void handle_interrupts();
void handle_interrupts(u8 fired_interrupts);
auto handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_interrupts) -> bool;
Gameboy& gb;
bool interrupts_enabled = false;
bool ime_pending = false; /* EI takes effect AFTER the next instruction */
bool halted = false;
bool stopped = false; /* STOP: woken only by joypad activity */
public:
/* Joypad line activity: wakes STOP unconditionally (real DMG behaviour:
* STOP exits on a joypad line change regardless of IE/IF/IME). */
void notify_joypad() {
if(stopped) {
stopped = false;
halted = false;
}
}
private:
bool branch_taken = false;
@@ -61,6 +61,15 @@ public:
* storage slot s starts at bit offset s * GAMEBOY_WIDTH * 2. */
auto raw() const -> const u8* { return buf; }
/* Direct pointer to the packed bytes of screen row y (40 bytes,
* 4 pixels each, LSB-first) or nullptr if the row has no storage.
* Used by the byte-oriented renderer in video.cc. */
auto row_ptr(uint y) -> u8* {
uint slot = row_slot[y];
if(slot == NO_ROW) return nullptr;
return buf + slot * (GAMEBOY_WIDTH / 4);
}
private:
static const u8 NO_ROW = 0xFF;
@@ -44,8 +44,10 @@ void Gameboy::vblank_trampoline(void* ctx) {
void Gameboy::button_pressed(GbButton button) {
input.button_pressed(button);
/* Request the joypad interrupt (missing upstream); mainly wakes
* games waiting in HALT/STOP for input. */
* games waiting in HALT for input. */
cpu.interrupt_flag.set_bit_to(4, true);
/* STOP wakes on joypad activity regardless of IE/IF/IME */
cpu.notify_joypad();
}
void Gameboy::button_released(GbButton button) {
@@ -53,15 +55,11 @@ void Gameboy::button_released(GbButton button) {
}
void Gameboy::run_to_vblank() {
/* Gameboy::tick and the peripheral ticks it calls are defined inline
* in gameboy.h, so this loop compiles into one flattened body with a
* single out-of-line call per instruction (cpu.tick). */
frame_done = false;
while(!frame_done) {
tick();
}
}
void Gameboy::tick() {
auto cycles = cpu.tick();
video.tick(cycles);
timer.tick(cycles.cycles);
apu.tick(cycles.cycles);
}
@@ -0,0 +1,206 @@
#pragma once
#include "input.h"
#include "cpu.h"
#include "video.h"
#include "timer.h"
#include "mmu.h"
#include "cartridge.h"
#include "apu.h"
class Gameboy {
public:
/* bank0: pointer to the first 16 KB of ROM (stays resident).
* provider: returns pointers to 16 KB switchable banks. */
Gameboy(
const u8* bank0,
uint rom_bank_count,
MBCType mbc,
u8* cart_ram,
u32 cart_ram_size,
RomBankProvider provider,
void* provider_ctx);
/* Runs the emulator until the next vblank (one full frame). */
void run_to_vblank();
/* Called on every vblank BEFORE the framebuffer is cleared for the next
* frame: this is where the frontend must convert/copy the image. */
void set_frame_callback(void (*cb)(void*), void* ctx) {
user_frame_cb = cb;
user_frame_ctx = ctx;
}
void button_pressed(GbButton button);
void button_released(GbButton button);
void set_skip_render(bool skip) { video.skip_render = skip; }
/* Flush pending peripheral cycles. Called automatically every
* PERIPH_BATCH M-cycles and before any IO register read/write so
* LY/STAT/DIV/TIMA/IF are always fresh where the game can see them. */
void sync_peripherals() {
uint n = pending_cycles;
if(n) {
pending_cycles = 0;
video.tick(Cycles(n));
timer.tick(n);
apu.tick(n);
}
}
/* Optional display-line mask (see Video::row_mask). The array must stay
* valid for the lifetime of the emulator. */
void set_row_mask(const u8* mask) { video.set_row_mask(mask); }
auto get_framebuffer() const -> const FrameBuffer& { return video.get_framebuffer(); }
auto get_cartridge_ram() -> u8* { return cartridge.get_ram(); }
auto get_cartridge_ram_size() const -> u32 { return cartridge.get_ram_size(); }
Cartridge cartridge;
CPU cpu;
friend class CPU;
Video video;
friend class Video;
MMU mmu;
friend class MMU;
Timer timer;
friend class Timer;
Apu apu;
Input input;
private:
void tick();
/* Peripheral catch-up batching (the single biggest hot-loop win on the
* Cortex-M4): instead of stepping the PPU/timer/APU state machines
* after every CPU instruction, cycles accumulate and are flushed every
* PERIPH_BATCH M-cycles -- or immediately whenever the CPU touches an
* IO register. Interrupt delivery is delayed by at most PERIPH_BATCH
* M-cycles (128 T-cycles), far below what games can observe, while IO
* reads always see exact values thanks to the sync points. */
static const uint PERIPH_BATCH = 32;
uint pending_cycles = 0;
static void vblank_trampoline(void* ctx);
volatile bool frame_done = false;
void (*user_frame_cb)(void*) = nullptr;
void* user_frame_ctx = nullptr;
};
/* ==================== hot-path inline implementations ====================
*
* Everything below runs once (or more) per emulated instruction. These
* bodies need the complete Gameboy definition, and they are defined here
* -- instead of in their .cc files -- so the core can inline them without
* LTO. Before this, each emulated instruction paid 6-8 cross-TU function
* calls (opcode fetch through the MMU, PPU/timer/APU ticks), which
* dominated the frame time on the Cortex-M4.
*
* Deliberately NOT inlined: the full MMU read/write dispatch. Inlining it
* into the ~300 opcode call sites costs ~15 KB of code (= RAM on the
* Flipper, where the binary loads into the same heap as the ROM cache).
* Only the instruction-fetch fast path (PC in ROM, true for virtually
* every instruction) is inlined below. */
inline void Video::tick(Cycles cycles) {
cycle_counter += cycles.cycles;
/* Fast path: no mode boundary crossed. Boundary work (interrupts,
* scanline rendering) is out-of-line in video.cc. The counter wrap
* uses subtraction instead of the old modulo: per-instruction cycle
* increments (<= 6) can never overshoot a whole extra period. */
switch(current_mode) {
case VideoMode::ACCESS_OAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_OAM) {
cycle_counter -= CLOCKS_PER_SCANLINE_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, true);
current_mode = VideoMode::ACCESS_VRAM;
}
break;
case VideoMode::ACCESS_VRAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_VRAM) {
cycle_counter -= CLOCKS_PER_SCANLINE_VRAM;
mode_transition_vram_end();
}
break;
case VideoMode::HBLANK:
if(cycle_counter >= CLOCKS_PER_HBLANK) {
cycle_counter -= CLOCKS_PER_HBLANK;
mode_transition_hblank_end();
}
break;
case VideoMode::VBLANK:
if(cycle_counter >= CLOCKS_PER_SCANLINE) {
cycle_counter -= CLOCKS_PER_SCANLINE;
mode_transition_vblank_line();
}
break;
}
}
inline void Timer::tick(uint cycles) {
/* DIV increments at 16384 Hz = every 64 M-cycles */
div_clocks += cycles;
if(div_clocks >= 64) {
divider.set(static_cast<u8>(divider.value() + (div_clocks >> 6)));
div_clocks &= 63;
}
/* Accumulate T-cycles only while the timer is enabled: accumulating
* with TAC off built up an unbounded backlog that, when a game later
* enabled the timer, drained as a burst of TIMA overflows + spurious
* timer interrupts (plus a long stall in the loop below). */
if(!timer_control.check_bit(2)) {
clocks = 0;
return;
}
clocks += cycles * 4; /* M-cycles -> T-cycles */
uint clock_limit = clocks_needed_to_increment();
/* a long instruction can cross more than one period of the fastest
* (16 T-cycle) timer rate; the old modulo silently dropped those */
while(clocks >= clock_limit) {
clocks -= clock_limit;
u8 old_timer_counter = timer_counter.value();
timer_counter.increment();
if(timer_counter.value() < old_timer_counter) {
gb.cpu.interrupt_flag.set_bit_to(2, true);
timer_counter.set(timer_modulo.value());
}
}
}
inline auto CPU::get_byte_from_pc() -> u8 {
u16 a = pc.value();
pc.increment();
/* PC sits in cartridge ROM for virtually every instruction: read the
* mapped page directly and skip the full MMU dispatch */
if(a < 0x8000) return gb.cartridge.read(a);
return gb.mmu.read(Address(a));
}
inline auto CPU::get_signed_byte_from_pc() -> s8 {
return static_cast<s8>(get_byte_from_pc());
}
inline auto CPU::get_word_from_pc() -> u16 {
u8 low_byte = get_byte_from_pc();
u8 high_byte = get_byte_from_pc();
return static_cast<u16>((high_byte << 8) | low_byte);
}
inline void Gameboy::tick() {
pending_cycles += cpu.tick().cycles;
if(pending_cycles >= PERIPH_BATCH) sync_peripherals();
}
@@ -50,5 +50,6 @@ auto Input::get_input() const -> u8 {
buttons = set_bit_to(buttons, 4, !direction_switch);
buttons = set_bit_to(buttons, 5, !button_switch);
return buttons;
/* bits 6-7 are unwired and read as 1 on hardware */
return static_cast<u8>(buttons | 0xC0);
}
@@ -94,6 +94,9 @@ void MMU::write(const Address& address, u8 byte) {
}
auto MMU::read_io(const Address& address) const -> u8 {
/* catch-up: make LY/STAT/DIV/TIMA/IF/NRxx exact before the game reads */
gb.sync_peripherals();
u16 a = address.value();
/* Sound registers + wave RAM */
@@ -167,6 +170,9 @@ auto MMU::read_io(const Address& address) const -> u8 {
}
void MMU::write_io(const Address& address, u8 byte) {
/* catch-up: apply pending time before the write changes IO state */
gb.sync_peripherals();
u16 a = address.value();
/* Sound registers + wave RAM */
@@ -3,6 +3,13 @@
#include <array>
/* NOTE: undefined opcodes (0xD3, 0xDB, 0xDD, 0xE3, 0xE4, 0xEB, 0xEC, 0xED,
* 0xF4, 0xFC, 0xFD) and the 0xCB prefix placeholder cost 1 M-cycle here,
* NOT 0. With 0-cycle entries, executing garbage (bad jump, corrupted
* read) advanced the PC without advancing the PPU/timer clocks, so
* run_to_vblank() could spin without ever finishing a frame: on the
* Flipper that froze the whole device until a hard reboot. */
const std::array<u8, 256> opcode_cycles = {
1, 3, 2, 2, 1, 1, 2, 1, 5, 2, 2, 2, 1, 1, 2, 1,
1, 3, 2, 2, 1, 1, 2, 1, 3, 2, 2, 2, 1, 1, 2, 1,
@@ -16,10 +23,10 @@ const std::array<u8, 256> opcode_cycles = {
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
2, 3, 3, 4, 3, 4, 2, 4, 2, 4, 3, 0, 3, 6, 2, 4,
2, 3, 3, 0, 3, 4, 2, 4, 2, 4, 3, 0, 3, 0, 2, 4,
3, 3, 2, 0, 0, 4, 2, 4, 4, 1, 4, 0, 0, 0, 2, 4,
3, 3, 2, 1, 0, 4, 2, 4, 3, 2, 4, 1, 0, 0, 2, 4
2, 3, 3, 4, 3, 4, 2, 4, 2, 4, 3, 1, 3, 6, 2, 4,
2, 3, 3, 1, 3, 4, 2, 4, 2, 4, 3, 1, 3, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 4, 1, 4, 1, 1, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 3, 2, 4, 1, 1, 1, 2, 4
};
const std::array<u8, 256> opcode_cycles_branched = {
@@ -35,10 +42,10 @@ const std::array<u8, 256> opcode_cycles_branched = {
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
5, 3, 4, 4, 6, 4, 2, 4, 5, 4, 4, 0, 6, 6, 2, 4,
5, 3, 4, 0, 6, 4, 2, 4, 5, 4, 4, 0, 6, 0, 2, 4,
3, 3, 2, 0, 0, 4, 2, 4, 4, 1, 4, 0, 0, 0, 2, 4,
3, 3, 2, 1, 0, 4, 2, 4, 3, 2, 4, 1, 0, 0, 2, 4
5, 3, 4, 4, 6, 4, 2, 4, 5, 4, 4, 1, 6, 6, 2, 4,
5, 3, 4, 1, 6, 4, 2, 4, 5, 4, 4, 1, 6, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 4, 1, 4, 1, 1, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 3, 2, 4, 1, 1, 1, 2, 4
};
const std::array<u8, 256> opcode_cycles_cb = {
@@ -1,4 +1,5 @@
#include "cpu.h"
#include "gameboy.h" /* inline hot-path definitions (operand fetch) */
/* clang-format off */
/**
@@ -262,12 +262,13 @@ void CPU::opcode_dec(Address&& addr) {
/* DI */
void CPU::opcode_di() {
interrupts_enabled = false;
ime_pending = false; /* DI in the EI delay slot cancels the enable */
}
/* EI */
/* EI: IME is enabled after the NEXT instruction (see CPU::tick) */
void CPU::opcode_ei() {
interrupts_enabled = true;
ime_pending = true;
}
@@ -543,10 +544,10 @@ void CPU::opcode_ret(Condition condition) {
}
/* RETI */
/* RETI: unlike EI, re-enables IME immediately (no delay slot) */
void CPU::opcode_reti() {
opcode_ret();
opcode_ei();
interrupts_enabled = true;
}
@@ -810,9 +811,17 @@ void CPU::opcode_srl(Address&& addr) {
}
/* STOP */
/* STOP: on real DMG the CPU freezes until a joypad LINE CHANGE -- this is
* independent of IE/IF/IME (it does not go through interrupt dispatch).
* Modelling it as plain HALT was wrong twice over: with IE bit4 clear a
* press never satisfied IF&IE so the game froze forever, and with IE
* bit0 set our free-running PPU vblank woke it instantly. The `stopped`
* flag is cleared only by CPU::notify_joypad() (wired to button presses).
* STOP is also a 2-byte instruction: consume the padding byte. */
void CPU::opcode_stop() {
/* halted = true; */
get_byte_from_pc(); /* skip the 0x00 pad */
halted = true;
stopped = true;
}
@@ -0,0 +1,48 @@
#pragma once
#include "definitions.h"
#include "register.h"
class Gameboy;
class Timer {
public:
Timer(Gameboy& inGb)
: gb(inGb) {}
/* Called once per emulated instruction: defined inline at the bottom
* of gameboy.h (needs the complete Gameboy type). */
void tick(uint cycles);
auto get_divider() const -> u8 { return divider.value(); }
auto get_timer() const -> u8 { return timer_counter.value(); }
auto get_timer_modulo() const -> u8 { return timer_modulo.value(); }
/* Only the bottom three bits of this register are usable */
auto get_timer_control() const -> u8 { return timer_control.value() & 0x7; }
void reset_divider() { divider.set(0x0); }
void set_timer(u8 value) { timer_counter.set(value); }
void set_timer_modulo(u8 value) { timer_modulo.set(value); }
void set_timer_control(u8 value) { timer_control.set(value); }
private:
auto clocks_needed_to_increment() const -> uint {
switch(get_timer_control() & 0x3) {
case 0: return CLOCK_RATE / 4096;
case 1: return CLOCK_RATE / 262144;
case 2: return CLOCK_RATE / 65536;
default: return CLOCK_RATE / 16384;
}
}
uint clocks = 0;
uint div_clocks = 0;
Gameboy& gb;
ByteRegister divider;
ByteRegister timer_counter;
ByteRegister timer_modulo;
ByteRegister timer_control;
};
@@ -0,0 +1,398 @@
#include "video.h"
#include "gameboy.h"
#include "cpu.h"
#include "bitwise.h"
using bitwise::check_bit;
/* ---------------------------------------------------------- tile decode */
/* s_tile_lut[b] spreads the 8 bits of a tile-data byte into 8 2-bit pixel
* fields, leftmost pixel (bit 7) in the LOWEST field -- the same LSB-first
* packing the framebuffer uses, so whole bytes can be emitted directly.
* s_tile_lut_rev is the X-flipped variant for sprites. 1 KB total. */
static u16 s_tile_lut[256];
static u16 s_tile_lut_rev[256];
static bool s_luts_ready = false;
Video::Video(Gameboy& inGb)
: gb(inGb) {
if(!s_luts_ready) {
for(uint b = 0; b < 256; b++) {
u16 fwd = 0, rev = 0;
for(uint k = 0; k < 8; k++) {
u16 bit = (u16)((b >> (7 - k)) & 1);
fwd |= (u16)(bit << (2 * k));
rev |= (u16)(bit << (2 * (7 - k)));
}
s_tile_lut[b] = fwd;
s_tile_lut_rev[b] = rev;
}
s_luts_ready = true;
}
}
/* Palette LUT: maps a packed 2bpp byte (4 pixels) through BGP in one
* lookup. Rebuilt only when the game writes a new BGP value. */
auto Video::bg_pal_lut() -> const u8* {
u8 bgp = bg_palette.value();
if(!bgp_lut_valid || bgp != bgp_lut_cached_for) {
u8 shade[4] = {
(u8)(bgp & 0x3),
(u8)((bgp >> 2) & 0x3),
(u8)((bgp >> 4) & 0x3),
(u8)((bgp >> 6) & 0x3),
};
for(uint v = 0; v < 256; v++) {
bgp_lut[v] = (u8)(
shade[v & 3] | (shade[(v >> 2) & 3] << 2) | (shade[(v >> 4) & 3] << 4) |
(shade[(v >> 6) & 3] << 6));
}
bgp_lut_cached_for = bgp;
bgp_lut_valid = true;
}
return bgp_lut;
}
/* Video::tick is called once per emulated instruction; it lives as an
* inline definition at the bottom of gameboy.h. Mode-transition work
* (rendering, interrupts) stays out-of-line in this file. */
void Video::mode_transition_vram_end() {
current_mode = VideoMode::HBLANK;
bool hblank_interrupt = check_bit(lcd_status.value(), 3);
if(hblank_interrupt) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
bool ly_coincidence_interrupt = check_bit(lcd_status.value(), 6);
bool ly_coincidence = ly_compare.value() == line.value();
if(ly_coincidence_interrupt && ly_coincidence) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, ly_coincidence);
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, false);
}
void Video::mode_transition_hblank_end() {
if(!skip_render) write_scanline(line.value());
line.increment();
/* Line 145 (index 144) is the first line of VBLANK */
if(line == 144) {
current_mode = VideoMode::VBLANK;
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, true);
gb.cpu.interrupt_flag.set_bit_to(0, true);
} else {
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
current_mode = VideoMode::ACCESS_OAM;
}
}
void Video::mode_transition_vblank_line() {
line.increment();
/* LY=LYC STAT interrupt must also fire for lines 144-153: games that
* wait for a coincidence inside vblank hung without this (it was only
* evaluated at the mode3->hblank transition of visible lines) */
if(check_bit(lcd_status.value(), 6) && ly_compare.value() == line.value()) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, ly_compare.value() == line.value());
/* Line 155 (index 154) is the last line */
if(line == 154) {
if(!skip_render) {
write_sprites();
draw();
buffer.reset();
} else {
draw(); /* still notify the frontend for pacing */
}
line.reset();
current_mode = VideoMode::ACCESS_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
}
}
auto Video::display_enabled() const -> bool {
return check_bit(control_byte, 7);
}
auto Video::window_tile_map() const -> bool {
return check_bit(control_byte, 6);
}
auto Video::window_enabled() const -> bool {
return check_bit(control_byte, 5);
}
auto Video::bg_window_tile_data() const -> bool {
return check_bit(control_byte, 4);
}
auto Video::bg_tile_map_display() const -> bool {
return check_bit(control_byte, 3);
}
auto Video::sprite_size() const -> bool {
return check_bit(control_byte, 2);
}
auto Video::sprites_enabled() const -> bool {
return check_bit(control_byte, 1);
}
auto Video::bg_enabled() const -> bool {
return check_bit(control_byte, 0);
}
void Video::write_scanline(u8 current_line) {
if(!display_enabled()) {
return;
}
/* Lines the frontend never displays are not worth rendering */
if(row_mask && current_line < GAMEBOY_HEIGHT && !row_mask[current_line]) {
return;
}
if(bg_enabled()) {
draw_bg_line(current_line);
}
if(window_enabled()) {
draw_window_line(current_line);
}
}
void Video::write_sprites() {
if(!sprites_enabled()) {
return;
}
for(uint sprite_n = 0; sprite_n < 40; sprite_n++) {
draw_sprite(sprite_n);
}
}
/* Byte-oriented tile-strip renderer: emits `count` pixels of one tile-map
* row into the packed framebuffer row `dst_row`, starting at screen pixel
* `dst_x`, sourcing map pixels from `src_px` (wraps at 256). Pixels flow
* through a small bit-queue so the bulk of the line is written as whole
* palette-mapped bytes (4 px per store) instead of per-pixel RMW packing:
* ~7x fewer operations per line than the old per-pixel path. */
void Video::render_strip(
u8* dst_row,
uint dst_x,
uint count,
uint map_row_base, /* VRAM offset of the tile map row (32 entries) */
uint src_px,
uint tile_line_off, /* 2 * (row within the tile) */
bool use_tile_set_zero,
const u8* pal_lut) {
uint tile_x = (src_px >> 3) & 31;
uint fine = src_px & 7;
uint tset_base = use_tile_set_zero ? 0x0000u : 0x0800u;
/* bit-queue of pending 2bpp pixels, LSB = next pixel to emit */
u32 acc = 0;
uint nbits = 0;
#define FETCH_TILE_ROW() \
do { \
u8 tid = video_ram[map_row_base + tile_x]; \
tile_x = (tile_x + 1) & 31; \
uint toff = use_tile_set_zero ? \
(uint)tid * TILE_BYTES : \
(uint)((s8)tid + 128) * TILE_BYTES; \
uint la = tset_base + toff + tile_line_off; \
u16 v = (u16)(s_tile_lut[video_ram[la]] | (s_tile_lut[video_ram[la + 1]] << 1)); \
acc |= (u32)v << nbits; \
nbits += 16; \
} while(0)
/* prime the queue, discarding the fine-scroll pixels */
FETCH_TILE_ROW();
acc >>= fine * 2;
nbits -= fine * 2;
/* head: single pixels until the destination is byte-aligned */
while(count && (dst_x & 3)) {
if(nbits < 2) FETCH_TILE_ROW();
uint sh = (dst_x & 3) * 2;
u8* p = dst_row + (dst_x >> 2);
*p = (u8)((*p & ~(3u << sh)) | ((pal_lut[acc & 3] & 3u) << sh));
acc >>= 2;
nbits -= 2;
dst_x++;
count--;
}
/* body: whole bytes, palette applied 4 pixels at a time */
u8* out = dst_row + (dst_x >> 2);
while(count >= 4) {
if(nbits < 8) FETCH_TILE_ROW();
*out++ = pal_lut[acc & 0xFF];
acc >>= 8;
nbits -= 8;
count -= 4;
dst_x += 4;
}
/* tail */
while(count) {
if(nbits < 2) FETCH_TILE_ROW();
uint sh = (dst_x & 3) * 2;
u8* p = dst_row + (dst_x >> 2);
*p = (u8)((*p & ~(3u << sh)) | ((pal_lut[acc & 3] & 3u) << sh));
acc >>= 2;
nbits -= 2;
dst_x++;
count--;
}
#undef FETCH_TILE_ROW
}
void Video::draw_bg_line(uint current_line) {
/* Note: tileset two uses signed numbering to share half the tiles with
* tileset one */
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !bg_tile_map_display();
u8* dst = buffer.row_ptr(current_line);
if(!dst) return;
uint scrolled_y = (current_line + scroll_y.value()) % BG_MAP_SIZE;
uint map_row_base = (use_tile_map_zero ? (TILE_MAP_ZERO_ADDRESS - 0x8000) :
(TILE_MAP_ONE_ADDRESS - 0x8000)) +
(scrolled_y / TILE_HEIGHT_PX) * TILES_PER_LINE;
render_strip(
dst,
0,
GAMEBOY_WIDTH,
map_row_base,
scroll_x.value(),
(scrolled_y % TILE_HEIGHT_PX) * 2,
use_tile_set_zero,
bg_pal_lut());
}
void Video::draw_window_line(uint current_line) {
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !window_tile_map();
uint scrolled_y = current_line - window_y.value();
if(scrolled_y >= GAMEBOY_HEIGHT) {
return;
}
/* the window covers screen pixels from WX-7 onward, sourcing the
* window map from column 0 */
uint wx = window_x.value();
uint start_x = wx >= 7 ? wx - 7 : 0;
if(start_x >= GAMEBOY_WIDTH) return;
u8* dst = buffer.row_ptr(current_line);
if(!dst) return;
uint map_row_base = (use_tile_map_zero ? (TILE_MAP_ZERO_ADDRESS - 0x8000) :
(TILE_MAP_ONE_ADDRESS - 0x8000)) +
(scrolled_y / TILE_HEIGHT_PX) * TILES_PER_LINE;
render_strip(
dst,
start_x,
GAMEBOY_WIDTH - start_x,
map_row_base,
wx >= 7 ? 0 : 7 - wx,
(scrolled_y % TILE_HEIGHT_PX) * 2,
use_tile_set_zero,
bg_pal_lut());
}
void Video::draw_sprite(const uint sprite_n) {
/* Each sprite is represented by 4 bytes */
u16 oam_start = static_cast<u16>(sprite_n * SPRITE_BYTES);
u8 sprite_y = gb.mmu.oam_ram[oam_start];
u8 sprite_x = gb.mmu.oam_ram[oam_start + 1];
/* Offscreen sprites are not drawn */
if(sprite_y == 0 || sprite_y >= 160) {
return;
}
if(sprite_x == 0 || sprite_x >= 168) {
return;
}
uint sprite_height = sprite_size() ? 16 : 8;
u8 pattern_n = gb.mmu.oam_ram[oam_start + 2];
u8 sprite_attrs = gb.mmu.oam_ram[oam_start + 3];
/* Bits 0-3 are used only for CGB */
bool use_palette_1 = check_bit(sprite_attrs, 4);
bool flip_x = check_bit(sprite_attrs, 5);
bool flip_y = check_bit(sprite_attrs, 6);
bool obj_behind_bg = check_bit(sprite_attrs, 7);
/* 4-entry shade table instead of a per-pixel palette switch */
u8 obp = use_palette_1 ? sprite_palette_1.value() : sprite_palette_0.value();
u8 shade[4] = {
(u8)(obp & 0x3),
(u8)((obp >> 2) & 0x3),
(u8)((obp >> 4) & 0x3),
(u8)((obp >> 6) & 0x3),
};
uint tile_offset = pattern_n * TILE_BYTES;
int start_y = sprite_y - 16;
int start_x = sprite_x - 8;
const u16* lut = flip_x ? s_tile_lut_rev : s_tile_lut;
for(uint y = 0; y < sprite_height; y++) {
int screen_y = start_y + static_cast<int>(y);
if(screen_y < 0 || screen_y >= static_cast<int>(GAMEBOY_HEIGHT)) continue;
if(row_mask && !row_mask[screen_y]) continue;
u8* row = buffer.row_ptr(static_cast<uint>(screen_y));
if(!row) continue;
uint src_y = !flip_y ? y : sprite_height - y - 1;
uint line_addr = tile_offset + src_y * 2; /* relative to tile set zero */
/* all 8 pixel colors of this sprite row, LSB-first left-to-right
* (flip handled by the reversed LUT) */
u16 v = (u16)(lut[video_ram[line_addr]] | (lut[video_ram[line_addr + 1]] << 1));
if(v == 0) continue; /* fully transparent row: common, skip */
for(uint x = 0; x < TILE_WIDTH_PX; x++, v >>= 2) {
uint color = v & 3;
if(color == 0) continue; /* color 0 is transparent */
int screen_x = start_x + static_cast<int>(x);
if(screen_x < 0 || screen_x >= static_cast<int>(GAMEBOY_WIDTH)) continue;
uint sh = ((uint)screen_x & 3) * 2;
u8* p = row + ((uint)screen_x >> 2);
/* Note: same behaviour as upstream - the priority bit compares
* the final shade rather than the logical color 0 */
if(obj_behind_bg && ((*p >> sh) & 3) != SHADE_WHITE) {
continue;
}
*p = (u8)((*p & ~(3u << sh)) | (shade[color] << sh));
}
}
}
void Video::draw() {
if(vblank_callback) vblank_callback(vblank_ctx);
}
@@ -72,10 +72,22 @@ private:
void write_scanline(u8 current_line);
void write_sprites();
void draw();
void mode_transition_vram_end();
void mode_transition_hblank_end();
void mode_transition_vblank_line();
void draw_bg_line(uint current_line);
void draw_window_line(uint current_line);
void draw_sprite(uint sprite_n);
static auto get_pixel_from_line(u8 byte1, u8 byte2, u8 pixel_index) -> u8;
void render_strip(
u8* dst_row,
uint dst_x,
uint count,
uint map_row_base,
uint src_px,
uint tile_line_off,
bool use_tile_set_zero,
const u8* pal_lut);
auto bg_pal_lut() -> const u8*;
static auto is_on_screen(int x, int y) -> bool {
return x >= 0 && y >= 0 && x < static_cast<int>(GAMEBOY_WIDTH) &&
@@ -91,13 +103,15 @@ private:
auto sprites_enabled() const -> bool;
auto bg_enabled() const -> bool;
static auto load_palette(const ByteRegister& palette_register) -> Palette;
static auto get_shade_from_palette(u8 color, const Palette& palette) -> Shade;
Gameboy& gb;
FrameBuffer buffer;
/* packed-byte -> palette-mapped-byte LUT for the current BGP value */
u8 bgp_lut[256];
u8 bgp_lut_cached_for = 0;
bool bgp_lut_valid = false;
u8 video_ram[0x2000] = {}; /* DMG: 8 KB (was 16 KB upstream) */
VideoMode current_mode = VideoMode::ACCESS_OAM;
+207 -105
View File
@@ -21,14 +21,15 @@
#include <string.h>
#include <new>
#include "gb/gameboy.h"
#include "lib/gbcore/gameboy.h"
#define SCREEN_W 128
#define SCREEN_H 64
#define BUFFER_SIZE (SCREEN_W * SCREEN_H / 8)
#define GB_FRAME_US 16742 /* 59.73 Hz */
#define BANK_SIZE 0x4000u
#define BANK_SIZE 0x4000u /* MBC bank: 16 KB (bank 0 stays resident whole) */
#define PAGE_SIZE 0x2000u /* cache granularity: 8 KB = half a bank */
/* Heap kept free for the GUI/system while playing. 10 KB is enough for the
* direct-draw takeover + input subscription + background services; the old
* 24 KB reserve was more than one whole ROM bank of wasted headroom and
@@ -58,28 +59,32 @@ enum {
KBIT_B = 1 << 5,
};
/* ROM bank cache.
/* ROM page cache.
*
* Every switchable bank lives in its own 16 KB heap block: there is no
* "whole ROM in one contiguous malloc" fast path any more, because a large
* contiguous allocation is exactly what fails (and used to crash the
* firmware) on a fragmented heap. Instead:
* Every 8 KB ROM page lives in its own heap block: there is no "whole ROM
* in one contiguous malloc" fast path, because a large contiguous
* allocation is exactly what fails (and used to crash the firmware) on a
* fragmented heap. 8 KB granularity (half a MBC bank) doubles the number
* of cache slots per KB of heap and halves the SD stall of a miss, which
* is what makes bank-switch heavy games (Pokemon) playable while
* streaming.
*
* - as many 16 KB slots as the heap safely affords are allocated up front;
* - if every switchable bank got a slot, the ROM is fully resident: bank
* - as many 8 KB slots as the heap safely affords are allocated up front;
* - if every switchable page got a slot, the ROM is fully resident: page
* lookup is a direct O(1) index and the SD file is closed;
* - otherwise the slots form an LRU cache streaming banks from SD.
* - otherwise the slots form an LRU cache streaming pages from SD.
*/
typedef struct {
File* file; /* NULL once the ROM is fully resident */
u8* bank0;
u8** slots; /* num_slots pointers to 16 KB blocks */
u16* slot_bank; /* which bank each slot holds (0 = empty) */
u8** slots; /* num_slots pointers to 8 KB blocks */
u16* slot_page; /* which ROM page each slot holds (0 = empty) */
u32* slot_use; /* LRU stamps (streaming mode only) */
u16* slot_hits; /* hit counters: the hottest slot is eviction-protected */
u32 use_counter;
u16 num_slots;
u16 banks;
bool fully_loaded; /* slots[i] permanently holds bank i+1 */
bool fully_loaded; /* slots[i] permanently holds page i+2 */
} RomCache;
typedef struct {
@@ -90,21 +95,24 @@ typedef struct {
FuriMutex* fb_mutex;
volatile uint8_t keys; /* KBIT_* currently pressed */
uint8_t keys_blocked; /* held keys suppressed until physical release */
volatile bool menu_requested;
volatile bool menu_active;
volatile bool exit_requested;
Gameboy* gb;
void* gb_mem; /* raw block for the Gameboy, reserved during rom_load */
RomCache rom;
u8* cart_ram;
u32 cart_ram_size;
bool has_battery;
/* sound (single-tone piezo fed with the dominant APU voice) */
bool sound_enabled;
uint8_t volume_setting; /* 0 = off, 1..4 = 25/50/75/100% */
bool speaker_acquired;
uint32_t tone_freq; /* currently playing tone, 0 = silent */
uint8_t tone_vol; /* 0..15, scaled by master volume */
uint8_t tone_setting; /* volume_setting the current tone was started with */
/* menu */
int menu_cursor;
@@ -138,39 +146,66 @@ extern "C" [[noreturn]] void gb_fatal(const char* msg) {
/* ------------------------------------------------------- rom bank provider */
static const u8* rom_bank_provider(void* ctx, uint bank) {
static const u8* rom_bank_provider(void* ctx, uint page) {
RomCache* rc = (RomCache*)ctx;
if(bank == 0) return rc->bank0;
/* pages 0 and 1 are the two halves of the resident bank 0 */
if(page < 2) return rc->bank0 + page * PAGE_SIZE;
if(rc->fully_loaded) {
return rc->slots[bank - 1]; /* O(1), the whole ROM is resident */
return rc->slots[page - 2]; /* O(1), the whole ROM is resident */
}
/* cache lookup */
u16 lru = 0;
/* cache lookup. Eviction is LRU with two protections:
* - never evict the MRU slot: the cartridge's lo page is always the
* MRU when its (lazy) hi page faults in, so the lo pointer can
* never be left dangling;
* - keep the single hottest slot (most hits, e.g. the page holding
* the music driver that is switched in every frame): pure LRU on
* Pokemon's cyclic per-frame bank pattern degenerated to a ~0%
* hit rate. Hit counters decay periodically so the protection
* adapts when the hot page changes. */
u16 hottest = 0;
u16 mru = 0;
for(u16 i = 1; i < rc->num_slots; i++) {
if(rc->slot_hits[i] > rc->slot_hits[hottest]) hottest = i;
if(rc->slot_use[i] > rc->slot_use[mru]) mru = i;
}
u16 lru = 0xFFFF;
u32 lru_use = 0xFFFFFFFFu;
for(u16 i = 0; i < rc->num_slots; i++) {
if(rc->slot_bank[i] == bank) {
if(rc->slot_page[i] == page) {
rc->slot_use[i] = ++rc->use_counter;
if(rc->slot_hits[i] < 0xFFFF) rc->slot_hits[i]++;
if((rc->use_counter & 63) == 0) {
for(u16 j = 0; j < rc->num_slots; j++)
rc->slot_hits[j] >>= 1;
}
return rc->slots[i];
}
if(rc->slot_use[i] < lru_use) {
if(i != hottest && i != mru && rc->slot_use[i] < lru_use) {
lru_use = rc->slot_use[i];
lru = i;
}
}
/* miss: stream the bank from SD into the LRU slot */
storage_file_seek(rc->file, (u32)bank * BANK_SIZE, true);
size_t got = storage_file_read(rc->file, rc->slots[lru], BANK_SIZE);
if(got < BANK_SIZE) {
/* short read (SD hiccup / truncated ROM): open-bus instead of
* stale data from whatever bank lived here before */
memset(rc->slots[lru] + got, 0xFF, BANK_SIZE - got);
if(lru == 0xFFFF) {
/* every other slot excluded (2-slot cache): sacrifice the hottest;
* it is never the MRU in this branch, so the lo page stays safe */
lru = (hottest != mru) ? hottest : (u16)(mru == 0 ? 1 : 0);
}
rc->slot_bank[lru] = (u16)bank;
/* miss: stream the page from SD into the LRU slot */
storage_file_seek(rc->file, (u32)page * PAGE_SIZE, true);
size_t got = storage_file_read(rc->file, rc->slots[lru], PAGE_SIZE);
if(got < PAGE_SIZE) {
/* short read (SD hiccup / truncated ROM): open-bus instead of
* stale data from whatever page lived here before */
memset(rc->slots[lru] + got, 0xFF, PAGE_SIZE - got);
}
rc->slot_page[lru] = (u16)page;
rc->slot_use[lru] = ++rc->use_counter;
rc->slot_hits[lru] = 1;
return rc->slots[lru];
}
@@ -185,9 +220,11 @@ static uint8_t s_ymap[SCREEN_H];
* rendering work per frame). */
static u8 s_rowmask[GAMEBOY_HEIGHT];
/* light/dark decision per (y-parity, x-parity, shade): 2x2 ordered dither.
* white -> lit, light gray -> 3/4 lit, dark gray -> 1/4 lit, black -> off */
static uint8_t s_dither[2][2][4];
/* Dither LUT: maps one packed 2bpp framebuffer byte (4 consecutive GB
* pixels) to 4 light/dark bits (bit j = pixel j), per destination row
* parity. 2x2 ordered dither: white -> lit, light gray -> 3/4 lit,
* dark gray -> 1/4 lit, black -> off. */
static uint8_t s_dither4[2][256];
static void init_scale_maps(void) {
for(int x = 0; x < SCREEN_W; x++)
@@ -200,17 +237,32 @@ static void init_scale_maps(void) {
s_rowmask[s_ymap[y]] = 1;
for(int py = 0; py < 2; py++) {
for(int px = 0; px < 2; px++) {
s_dither[py][px][0] = 1;
s_dither[py][px][1] = (px == 1 && py == 1) ? 0 : 1;
s_dither[py][px][2] = (px == 0 && py == 0) ? 1 : 0;
s_dither[py][px][3] = 0;
for(int v = 0; v < 256; v++) {
uint8_t bits = 0;
for(int j = 0; j < 4; j++) {
int s = (v >> (j * 2)) & 3;
int px = j & 1;
bool light;
switch(s) {
case 0: light = true; break;
case 1: light = !(px == 1 && py == 1); break;
case 2: light = (px == 0 && py == 0); break;
default: light = false; break;
}
if(light) bits |= (uint8_t)(1 << j);
}
s_dither4[py][v] = bits;
}
}
}
/* Called by the core on every vblank, before the framebuffer is cleared.
* Converts 4 shades -> 1 bit with a 2x2 ordered dither. */
* Converts 4 shades -> 1 bit with a 2x2 ordered dither.
*
* Byte-oriented: destination pixels 4g..4g+3 map to source pixels
* 5g..5g+3 (the 160->128 downscale drops every 5th column), so each
* destination nibble comes from 8 consecutive source bits, converted with
* a single LUT lookup instead of per-pixel shade extraction. */
static void frame_callback(void* ctx) {
AppState* app = (AppState*)ctx;
const u8* raw = app->gb->get_framebuffer().raw(); /* packed 2bpp */
@@ -221,19 +273,24 @@ static void frame_callback(void* ctx) {
for(int y = 0; y < SCREEN_H; y++) {
/* storage is compacted to the 64 displayed rows in ascending order
* (GB_FB_ROWS=64 + row mask), so displayed row y == storage slot y */
uint base = (uint)y * GAMEBOY_WIDTH;
const u8* src = raw + (uint)y * (GAMEBOY_WIDTH / 4);
uint8_t bit = (uint8_t)(1u << (y & 7));
uint8_t nbit = (uint8_t)~bit;
uint8_t* row = dst + (y >> 3) * SCREEN_W;
const uint8_t(*dither)[4] = s_dither[y & 1];
const uint8_t* dlut = s_dither4[y & 1];
for(int x = 0; x < SCREEN_W; x++) {
uint i = base + s_xmap[x];
uint s = (raw[i >> 2] >> ((i & 3) << 1)) & 0x3;
if(dither[x & 1][s])
row[x] |= bit;
else
row[x] &= nbit;
for(int g = 0; g < SCREEN_W / 4; g++) {
uint spx = (uint)g * 5; /* first source pixel of this group */
uint so = spx >> 2;
uint sh = (spx & 3) * 2;
uint packed = ((uint)src[so] | ((uint)src[so + 1] << 8)) >> sh;
uint8_t four = dlut[packed & 0xFF];
uint8_t* p = row + g * 4;
p[0] = (four & 1) ? (uint8_t)(p[0] | bit) : (uint8_t)(p[0] & nbit);
p[1] = (four & 2) ? (uint8_t)(p[1] | bit) : (uint8_t)(p[1] & nbit);
p[2] = (four & 4) ? (uint8_t)(p[2] | bit) : (uint8_t)(p[2] & nbit);
p[3] = (four & 8) ? (uint8_t)(p[3] | bit) : (uint8_t)(p[3] & nbit);
}
}
@@ -311,8 +368,11 @@ static void input_events_callback(const void* value, void* ctx) {
uint8_t keys =
(uint8_t)__atomic_or_fetch((uint8_t*)&app->keys, bit, __ATOMIC_RELAXED);
/* Up+Down together: physically impossible on a real GB d-pad,
* so it is our reserved menu gesture */
if((keys & (KBIT_UP | KBIT_DOWN)) == (KBIT_UP | KBIT_DOWN)) {
* so it is our reserved menu gesture. Ignored while the menu
* is already open: fast Up->Down rollover while navigating
* used to re-latch the request and instantly reopen the menu
* after closing it (merging consecutive Start injections). */
if(!app->menu_active && (keys & (KBIT_UP | KBIT_DOWN)) == (KBIT_UP | KBIT_DOWN)) {
app->menu_requested = true;
}
} else if(event->type == InputTypeRelease) {
@@ -325,7 +385,11 @@ static void input_events_callback(const void* value, void* ctx) {
/* Apply the current key snapshot to the emulated joypad (edge based) */
static void apply_input(AppState* app, uint8_t* last_applied) {
uint8_t now = app->menu_active ? 0 : app->keys;
/* keys_blocked: the A/B press that operated the menu must not leak
* into the game as a fresh press when the menu closes; blocked bits
* clear automatically on physical release */
app->keys_blocked &= app->keys;
uint8_t now = app->menu_active ? 0 : (uint8_t)(app->keys & ~app->keys_blocked);
uint8_t changed = (uint8_t)(now ^ *last_applied);
if(!changed && !app->inject_start_frames && !app->inject_select_frames) return;
@@ -379,7 +443,7 @@ static void sound_update(AppState* app, bool force_silent) {
ApuVoice best = {false, 0, 0, 0};
bool have = false;
if(!force_silent && app->sound_enabled && !app->menu_active) {
if(!force_silent && app->volume_setting && !app->menu_active) {
ApuVoice v;
/* pulse 1 / pulse 2 */
for(uint n = 0; n < 2; n++) {
@@ -422,10 +486,17 @@ static void sound_update(AppState* app, bool force_silent) {
uint8_t vol = (uint8_t)((best.volume * (master + 1)) >> 3); /* 0..15 */
if(vol == 0) {
have = false;
} else if(f != app->tone_freq || vol != app->tone_vol) {
furi_hal_speaker_start((float)f, (float)vol / 15.0f);
} else if(
f != app->tone_freq || vol != app->tone_vol ||
app->volume_setting != app->tone_setting) {
/* piezo loudness vs PWM value is very nonlinear: spread
* the 4 user levels perceptually, not linearly */
static const float level[5] = {0.0f, 0.10f, 0.25f, 0.50f, 1.0f};
furi_hal_speaker_start(
(float)f, ((float)vol / 15.0f) * level[app->volume_setting]);
app->tone_freq = f;
app->tone_vol = vol;
app->tone_setting = app->volume_setting;
}
}
}
@@ -511,18 +582,25 @@ static void menu_draw(AppState* app) {
canvas_draw_str(c, 72, y, buf);
}
if(i == 4) {
static const char* vols[5] = {"off", "25%", "50%", "75%", "100%"};
canvas_draw_str(
c, 72, y, !app->speaker_acquired ? "n/a" : (app->sound_enabled ? "on" : "off"));
c, 72, y, !app->speaker_acquired ? "n/a" : vols[app->volume_setting]);
}
}
if(app->status_msg[0]) {
canvas_draw_str(c, 70, 10, app->status_msg);
} else {
/* free heap indicator: helps spotting memory pressure on device */
char rambuf[16];
snprintf(rambuf, sizeof(rambuf), "%uk free", (unsigned)(memmgr_get_free_heap() / 1024u));
canvas_draw_str_aligned(c, 126, 10, AlignRight, AlignBottom, rambuf);
/* diagnostics: free heap + real cost of one emulated frame
* (16.7ms = full speed; above that the game runs slow) */
char diagbuf[28];
snprintf(
diagbuf,
sizeof(diagbuf),
"%uk %lums",
(unsigned)(memmgr_get_free_heap() / 1024u),
(unsigned long)(app->emu_ms_ema >> 4));
canvas_draw_str_aligned(c, 126, 10, AlignRight, AlignBottom, diagbuf);
}
canvas_commit(c);
@@ -554,7 +632,11 @@ static bool menu_tick(AppState* app, Storage* storage, FuriString* sav_path) {
app->frameskip_setting = v;
}
if(app->menu_cursor == 4 && app->speaker_acquired) {
app->sound_enabled = !app->sound_enabled;
/* volume: off, 25, 50, 75, 100% */
if(pressed & KBIT_RIGHT)
app->volume_setting = (uint8_t)((app->volume_setting + 1) % 5);
else
app->volume_setting = (uint8_t)((app->volume_setting + 4) % 5);
}
}
@@ -565,17 +647,22 @@ static bool menu_tick(AppState* app, Storage* storage, FuriString* sav_path) {
case 0:
return false;
case 1:
app->gb->button_pressed(GbButton::Start);
/* extend rather than re-press if an injection is still live:
* two overlapping presses would merge into one edge for the
* game (Tetris pause state would get out of sync) */
if(app->inject_start_frames == 0) app->gb->button_pressed(GbButton::Start);
app->inject_start_frames = 8;
return false;
case 2:
app->gb->button_pressed(GbButton::Select);
if(app->inject_select_frames == 0) app->gb->button_pressed(GbButton::Select);
app->inject_select_frames = 8;
return false;
case 3:
break;
case 4:
if(app->speaker_acquired) app->sound_enabled = !app->sound_enabled;
/* OK toggles mute <-> full volume */
if(app->speaker_acquired)
app->volume_setting = app->volume_setting ? 0 : 4;
break;
case 5:
if(app->has_battery) {
@@ -648,52 +735,59 @@ static RomLoadResult
memset(app->cart_ram, 0, app->cart_ram_size);
}
/* Allocate as many 16 KB bank slots as the heap safely affords.
*
* IMPORTANT: the emulator core (Gameboy: 8K VRAM + 8K WRAM + packed
* framebuffer + CPU state, ~22 KB) plus the GUI takeover (mutex, canvas,
* input subscription) are allocated AFTER the ROM cache, so room for
* them is reserved up front. Each slot is its own allocation: no huge
* contiguous block is ever requested, which makes the loader immune to
* heap fragmentation (the old single-malloc full-ROM path could crash
* the firmware even when the total free heap looked sufficient). */
u32 switchable = (u32)(rc->banks - 1);
const size_t reserve = HEAP_RESERVE + sizeof(Gameboy) + 1024 /* alloc slack */;
/* Reserve the emulator core RIGHT NOW as a real allocation instead of
* a pessimistic estimate: every byte of over-reservation here used to
* cost cache slots (Pokemon got 2-3 slots for a 1 MB ROM). The block
* is placement-constructed later. */
app->gb_mem = safe_malloc(sizeof(Gameboy));
if(!app->gb_mem) return RomLoadNoMem;
/* Allocate as many 8 KB page slots as the heap safely affords. Each
* slot is its own allocation: no huge contiguous block is ever
* requested, which makes the loader immune to heap fragmentation.
* HEAP_RESERVE stays free for the GUI takeover + system services. */
u32 switchable = (u32)(rc->banks - 1) * 2; /* 8 KB pages past bank 0 */
const size_t reserve = HEAP_RESERVE;
/* slot bookkeeping arrays (a few bytes per slot) */
size_t free_heap = memmgr_get_free_heap();
u32 max_slots = (u32)(free_heap / BANK_SIZE) + 1;
u32 max_slots = (u32)(free_heap / PAGE_SIZE) + 1;
if(max_slots > switchable) max_slots = switchable;
if(max_slots < 1) max_slots = 1;
if(max_slots < 2) max_slots = 2;
rc->slots = (u8**)safe_malloc(max_slots * sizeof(u8*));
rc->slot_bank = (u16*)safe_malloc(max_slots * sizeof(u16));
rc->slot_page = (u16*)safe_malloc(max_slots * sizeof(u16));
rc->slot_use = (u32*)safe_malloc(max_slots * sizeof(u32));
if(!rc->slots || !rc->slot_bank || !rc->slot_use) return RomLoadNoMem;
rc->slot_hits = (u16*)safe_malloc(max_slots * sizeof(u16));
if(!rc->slots || !rc->slot_page || !rc->slot_use || !rc->slot_hits) return RomLoadNoMem;
rc->num_slots = 0;
while((u32)rc->num_slots < max_slots) {
if(memmgr_get_free_heap() < reserve + BANK_SIZE + ALLOC_MARGIN) break;
u8* slot = (u8*)safe_malloc(BANK_SIZE);
if(memmgr_get_free_heap() < reserve + PAGE_SIZE + ALLOC_MARGIN) break;
u8* slot = (u8*)safe_malloc(PAGE_SIZE);
if(!slot) break;
rc->slots[rc->num_slots] = slot;
rc->slot_bank[rc->num_slots] = 0; /* bank 0 never lives in a slot */
rc->slot_page[rc->num_slots] = 0; /* page 0 never lives in a slot */
rc->slot_use[rc->num_slots] = 0;
rc->slot_hits[rc->num_slots] = 0;
rc->num_slots++;
}
/* not even one 16 KB slot fits: fail gracefully with the
* "Not enough RAM" dialog instead of crashing inside malloc() */
if(rc->num_slots == 0) return RomLoadNoMem;
/* fewer than two 8 KB slots: fail gracefully with the "Not enough
* RAM" dialog instead of crashing inside malloc(). Two is the hard
* minimum: the cartridge maps a lo and a (lazy) hi page and the
* provider guarantees the two most recently returned pages are never
* evicted -- with a single slot the lo pointer would dangle. */
if(rc->num_slots < 2) return RomLoadNoMem;
if((u32)rc->num_slots >= switchable) {
/* every switchable bank fits: preload the whole ROM and close the
/* every switchable page fits: preload the whole ROM and close the
* SD file (O(1) bank switching, zero stutter, frees the handle) */
storage_file_seek(rc->file, BANK_SIZE, true);
for(u32 i = 0; i < switchable; i++) {
size_t got = storage_file_read(rc->file, rc->slots[i], BANK_SIZE);
if(got < BANK_SIZE) memset(rc->slots[i] + got, 0xFF, BANK_SIZE - got);
rc->slot_bank[i] = (u16)(i + 1);
size_t got = storage_file_read(rc->file, rc->slots[i], PAGE_SIZE);
if(got < PAGE_SIZE) memset(rc->slots[i] + got, 0xFF, PAGE_SIZE - got);
rc->slot_page[i] = (u16)(i + 2);
}
rc->fully_loaded = true;
storage_file_close(rc->file);
@@ -709,8 +803,9 @@ static void rom_free(AppState* app) {
for(u16 i = 0; i < rc->num_slots; i++)
if(rc->slots[i]) free(rc->slots[i]);
if(rc->slots) free(rc->slots);
if(rc->slot_bank) free(rc->slot_bank);
if(rc->slot_page) free(rc->slot_page);
if(rc->slot_use) free(rc->slot_use);
if(rc->slot_hits) free(rc->slot_hits);
if(rc->bank0) free(rc->bank0);
if(rc->file) {
storage_file_close(rc->file);
@@ -778,18 +873,10 @@ extern "C" int32_t flipgb_app(void* p) {
app->fb_mutex = furi_mutex_alloc(FuriMutexTypeNormal);
if(!app->fb_mutex) break;
/* rom_load reserved room for this, but double-check anyway: on the
* Flipper an unchecked new/malloc crashes the firmware on OOM */
void* gb_mem = safe_malloc(sizeof(Gameboy));
if(!gb_mem) {
DialogMessage* msg = dialog_message_alloc();
dialog_message_set_text(msg, "Not enough RAM", 64, 30, AlignCenter, AlignCenter);
dialog_message_set_buttons(msg, NULL, "OK", NULL);
dialog_message_show(dialogs, msg);
dialog_message_free(msg);
break;
}
app->gb = new(gb_mem) Gameboy(
/* rom_load pre-allocated this block (so the ROM cache could size
* itself against real free heap, not an estimate) */
furi_check(app->gb_mem != NULL);
app->gb = new(app->gb_mem) Gameboy(
app->rom.bank0,
app->rom.banks,
mbc,
@@ -802,7 +889,7 @@ extern "C" int32_t flipgb_app(void* p) {
/* --- sound: piezo plays the dominant APU voice --- */
app->speaker_acquired = furi_hal_speaker_acquire(50);
app->sound_enabled = app->speaker_acquired;
app->volume_setting = app->speaker_acquired ? 4 : 0;
/* --- take over the display --- */
gui = (Gui*)furi_record_open(RECORD_GUI);
@@ -846,6 +933,11 @@ extern "C" int32_t flipgb_app(void* p) {
}
furi_delay_ms(33);
}
/* the A/B press that operated the menu must not reach the
* game; the request latch is cleared so held Up+Down (or
* menu-navigation rollover) can't instantly reopen it */
app->keys_blocked = (uint8_t)(app->keys & (KBIT_A | KBIT_B));
app->menu_requested = false;
frame_deadline_us = 0;
last_now_ms = furi_get_tick();
continue;
@@ -867,11 +959,18 @@ extern "C" int32_t flipgb_app(void* p) {
/* refresh the piezo with this frame's dominant APU voice */
sound_update(app, false);
/* EMA of the cost of one emulated frame (x16 fixed point) */
app->emu_ms_ema += ((emu_ms << 4) - app->emu_ms_ema) / 8;
/* EMA of the cost of one emulated frame (x16 fixed point).
* MUST be signed: with unsigned arithmetic, any frame faster
* than the average underflowed the difference to ~4 billion,
* blowing the EMA up and pinning auto-frameskip at maximum. */
int32_t ema_diff = (int32_t)(emu_ms << 4) - (int32_t)app->emu_ms_ema;
app->emu_ms_ema = (uint32_t)((int32_t)app->emu_ms_ema + ema_diff / 8);
uint32_t ema_ms = app->emu_ms_ema >> 4;
app->auto_skip = ema_ms <= 17 ? 0 : (int)((ema_ms - 1) / 17);
if(app->auto_skip > 4) app->auto_skip = 4;
/* auto mode may skip harder than the fixed 0-4 settings: for
* heavy streamed games, correct game speed at a lower visible
* fps beats slow motion */
if(app->auto_skip > 8) app->auto_skip = 8;
if(render_this && !app->menu_active) {
canvas_commit(canvas);
@@ -925,6 +1024,9 @@ extern "C" int32_t flipgb_app(void* p) {
if(app->gb) {
app->gb->~Gameboy(); /* placement-new counterpart */
free(app->gb);
app->gb_mem = NULL;
} else if(app->gb_mem) {
free(app->gb_mem); /* rom_load reserved it but construction never ran */
}
rom_free(app);
if(app->cart_ram) free(app->cart_ram);
+25 -12
View File
@@ -13,12 +13,15 @@ def ProtoPirateDefineEnabled(name, app_manifest_path=app_manifest_path):
_ENABLE_TIMING_TUNER = ProtoPirateDefineEnabled("ENABLE_TIMING_TUNER_SCENE")
_ENABLE_EMULATE = ProtoPirateDefineEnabled("ENABLE_EMULATE_FEATURE")
_MAIN_APP_SOURCES = [
"*.c*",
"!protocols/plugins",
"!scenes/plugins",
"!protocols",
"!protopirate_emulate_plugin.c",
"!protopirate_psa_bf_plugin.c",
"protocols/protocol_items.c",
"protocols/protocols_common.c",
"!raw_file_reader.c",
@@ -30,6 +33,11 @@ if not _ENABLE_TIMING_TUNER:
"!protocol_timings.c",
]
if not _ENABLE_EMULATE:
_MAIN_APP_SOURCES += [
"!protopirate_scene_emulate.c",
]
App(
appid="proto_pirate",
name="ProtoPirate",
@@ -39,7 +47,7 @@ App(
requires=["gui"],
stack_size=8 * 1024,
fap_description="Decode car key fob signals from Sub-GHz",
fap_version="3.0",
fap_version="3.2",
fap_icon="images/protopirate_10px.png",
fap_category="Sub-GHz",
fap_icon_assets="images",
@@ -165,7 +173,11 @@ def ProtoPirateTxProtocolPlugin(
App=App,
FlipperAppType=FlipperAppType,
tx_plugin_sources=_TX_PLUGIN_SOURCES,
enable_emulate=_ENABLE_EMULATE,
):
if not enable_emulate:
return
App(
appid=f"protopirate_tx_{key}_plugin",
apptype=FlipperAppType.PLUGIN,
@@ -370,17 +382,18 @@ if _ENABLE_TIMING_TUNER:
fal_embedded=True,
)
App(
appid="protopirate_emulate_plugin",
apptype=FlipperAppType.PLUGIN,
entry_point="protopirate_emulate_plugin_ep",
requires=["proto_pirate"],
sources=[
"scenes/plugins/protopirate_emulate_plugin.c",
"protocols/protocol_items.c",
],
fal_embedded=True,
)
if _ENABLE_EMULATE:
App(
appid="protopirate_emulate_plugin",
apptype=FlipperAppType.PLUGIN,
entry_point="protopirate_emulate_plugin_ep",
requires=["proto_pirate"],
sources=[
"scenes/plugins/protopirate_emulate_plugin.c",
"protocols/protocol_items.c",
],
fal_embedded=True,
)
App(
appid="protopirate_psa_bf_plugin",
@@ -7,7 +7,9 @@
#include <string.h>
#define TAG "ProtoPirateProtocolPlugin"
#ifdef ENABLE_EMULATE_FEATURE
#define PROTOPIRATE_TX_PLUGIN_PATH_MAX 160U
#endif
static const char* protopirate_get_registry_plugin_path(ProtoPirateProtocolRegistryRoute route) {
switch(route) {
@@ -25,6 +27,7 @@ static const char* protopirate_get_registry_plugin_path(ProtoPirateProtocolRegis
}
}
#ifdef ENABLE_EMULATE_FEATURE
static bool protopirate_build_tx_protocol_plugin_path(
const char* tx_key,
char* plugin_path,
@@ -40,6 +43,7 @@ static bool protopirate_build_tx_protocol_plugin_path(
tx_key);
return (written > 0) && ((size_t)written < plugin_path_size);
}
#endif
static const SubGhzProtocolRegistry protopirate_empty_protocol_registry = {
.items = NULL,
@@ -165,6 +169,7 @@ static bool protopirate_ensure_protocol_registry_plugin(
return true;
}
#ifdef ENABLE_EMULATE_FEATURE
static bool protopirate_ensure_tx_protocol_plugin(
ProtoPirateApp* app,
const char* protocol_name,
@@ -264,6 +269,7 @@ static bool protopirate_ensure_tx_protocol_plugin(
*registry = plugin->registry;
return true;
}
#endif
bool protopirate_refresh_protocol_registry(ProtoPirateApp* app, bool ensure_receiver_ready) {
furi_check(app);
@@ -378,6 +384,7 @@ bool protopirate_apply_protocol_registry_for_context(
}
if(protocol_name && protocol_name[0] != '\0') {
#ifdef ENABLE_EMULATE_FEATURE
const char* registry_name = protopirate_protocol_catalog_canonical_name(protocol_name);
if(!registry_name || !protopirate_protocol_catalog_can_tx(protocol_name)) {
FURI_LOG_E(TAG, "No TX protocol plugin for %s", protocol_name);
@@ -410,6 +417,9 @@ bool protopirate_apply_protocol_registry_for_context(
subghz_environment_set_protocol_registry(app->txrx->environment, tx_registry);
app->txrx->protocol_registry = tx_registry;
return true;
#else
return false;
#endif
}
ProtoPirateProtocolRegistryRoute route = protopirate_get_protocol_registry_route(
@@ -103,12 +103,14 @@ void protopirate_settings_load(ProtoPirateSettings* settings) {
}
settings->hopping_enabled = (hopping_temp == 1);
#ifdef ENABLE_EMULATE_FEATURE
uint32_t emulate_temp = 0;
if(!flipper_format_read_uint32(ff, "EmulateFeature", &emulate_temp, 1)) {
FURI_LOG_I(TAG, "EmulateFeature key missing, defaulting to disabled");
emulate_temp = 0;
}
settings->emulate_feature_enabled = (emulate_temp == 1);
#endif
uint32_t check_saved_temp = 0;
if(!flipper_format_read_uint32(ff, "CheckSaved", &check_saved_temp, 1)) {
@@ -185,11 +187,13 @@ void protopirate_settings_save(ProtoPirateSettings* settings) {
break;
}
#ifdef ENABLE_EMULATE_FEATURE
uint32_t emulate_temp = settings->emulate_feature_enabled ? 1 : 0;
if(!flipper_format_write_uint32(ff, "EmulateFeature", &emulate_temp, 1)) {
FURI_LOG_E(TAG, "Failed to write emulate feature flag");
break;
}
#endif
uint32_t check_saved_temp = settings->check_saved ? 1 : 0;
if(!flipper_format_write_uint32(ff, "CheckSaved", &check_saved_temp, 1)) {
@@ -513,6 +513,20 @@ static bool protopirate_storage_copy_key_2(
protopirate_storage_copy_u32_optional(save_file, flipper_format, "Key_2");
}
static bool protopirate_storage_copy_key2(
FlipperFormat* save_file,
FlipperFormat* flipper_format) {
bool copied = false;
if(!protopirate_storage_copy_hex_fixed(save_file, flipper_format, "Key2", 8, &copied)) {
return false;
}
if(copied) {
return true;
}
return protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "Key2", 4);
}
static bool protopirate_storage_write_capture_data(
FlipperFormat* save_file,
FlipperFormat* flipper_format) {
@@ -548,7 +562,7 @@ static bool protopirate_storage_write_capture_data(
protopirate_storage_base_u32_fields,
COUNT_OF(protopirate_storage_base_u32_fields)))
break;
if(!protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "Key2", 4)) break;
if(!protopirate_storage_copy_key2(save_file, flipper_format)) break;
if(!protopirate_storage_copy_u32_optional(save_file, flipper_format, "KeyIdx")) break;
if(!protopirate_storage_copy_u32_optional(save_file, flipper_format, "Seed")) break;
if(!protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "ValidationField", 2))
@@ -3,6 +3,8 @@
#include <furi.h>
#include <string.h>
#include "../defines.h"
#define TAG "ProtoPirateCatalog"
#define PROTOPIRATE_CC1101_REG_MDMCFG2 0x12U
@@ -17,6 +19,12 @@
#define PROTOPIRATE_COUNT_OF(array) (sizeof(array) / sizeof((array)[0]))
#ifdef ENABLE_EMULATE_FEATURE
#define PROTOPIRATE_TX_KEY(key) key
#else
#define PROTOPIRATE_TX_KEY(key) NULL
#endif
typedef enum {
ProtoPirateProtocolCatalogModulationAM = 0,
ProtoPirateProtocolCatalogModulationFM,
@@ -28,33 +36,33 @@ typedef struct {
} ProtoPirateProtocolCatalogAlias;
static const ProtoPirateProtocolCatalogEntry protopirate_protocol_catalog[] = {
{"Chrysler V0", ProtoPirateProtocolCatalogRouteAMDefault, "chrysler_v0"},
{"Fiat V0", ProtoPirateProtocolCatalogRouteAMDefault, "fiat_v0"},
{"Fiat V1", ProtoPirateProtocolCatalogRouteAMDefault, "fiat_v1"},
{"Chrysler V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("chrysler_v0")},
{"Fiat V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("fiat_v0")},
{"Fiat V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("fiat_v1")},
{"Fiat V2", ProtoPirateProtocolCatalogRouteAMDefault, NULL},
{"Ford V0", ProtoPirateProtocolCatalogRouteAMDefault, "ford_v0"},
{"Ford V1", ProtoPirateProtocolCatalogRouteFMF4, "ford_v1"},
{"Ford V2", ProtoPirateProtocolCatalogRouteFMF4, "ford_v2"},
{"Ford V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("ford_v0")},
{"Ford V1", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("ford_v1")},
{"Ford V2", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("ford_v2")},
{"Ford V3", ProtoPirateProtocolCatalogRouteFMF4, NULL},
{"Honda Static", ProtoPirateProtocolCatalogRouteFMHonda1, "honda_static"},
{"Honda V1", ProtoPirateProtocolCatalogRouteAMDefault, "honda_v1"},
{"Kia V0", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v0"},
{"Kia V1", ProtoPirateProtocolCatalogRouteAMDefault, "kia_v1"},
{"Kia V2", ProtoPirateProtocolCatalogRouteAMDefault, "kia_v2"},
{"Kia V3/V4", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v3_v4"},
{"Kia V5", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v5"},
{"Kia V6", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v6"},
{"Kia V7", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v7"},
{"Honda V2", ProtoPirateProtocolCatalogRouteFMF4, "honda_v2"},
{"Mazda V0", ProtoPirateProtocolCatalogRouteByModulation, "mazda_v0"},
{"Honda Static", ProtoPirateProtocolCatalogRouteFMHonda1, PROTOPIRATE_TX_KEY("honda_static")},
{"Honda V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("honda_v1")},
{"Kia V0", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v0")},
{"Kia V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("kia_v1")},
{"Kia V2", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("kia_v2")},
{"Kia V3/V4", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v3_v4")},
{"Kia V5", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v5")},
{"Kia V6", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v6")},
{"Kia V7", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v7")},
{"Honda V2", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("honda_v2")},
{"Mazda V0", ProtoPirateProtocolCatalogRouteByModulation, PROTOPIRATE_TX_KEY("mazda_v0")},
{"Mitsubishi V0", ProtoPirateProtocolCatalogRouteFMDefault, NULL},
{"Porsche Touareg", ProtoPirateProtocolCatalogRouteAMDefault, NULL},
{"PSA", ProtoPirateProtocolCatalogRouteByModulation, "psa"},
{"Renault V0", ProtoPirateProtocolCatalogRouteAMDefault, "renault_v0"},
{"PSA", ProtoPirateProtocolCatalogRouteByModulation, PROTOPIRATE_TX_KEY("psa")},
{"Renault V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("renault_v0")},
{"Scher-Khan", ProtoPirateProtocolCatalogRouteFMDefault, NULL},
{"Star Line", ProtoPirateProtocolCatalogRouteAMDefault, "star_line"},
{"Subaru", ProtoPirateProtocolCatalogRouteAMDefault, "subaru"},
{"VAG", ProtoPirateProtocolCatalogRouteAMVag, "vag"},
{"Star Line", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("star_line")},
{"Subaru", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("subaru")},
{"VAG", ProtoPirateProtocolCatalogRouteAMVag, PROTOPIRATE_TX_KEY("vag")},
};
static const ProtoPirateProtocolCatalogAlias protopirate_protocol_catalog_aliases[] = {
@@ -90,7 +90,11 @@ ProtoPirateApp* protopirate_app_alloc() {
app->auto_save = settings.auto_save;
app->check_saved = settings.check_saved;
app->tx_power = settings.tx_power;
#ifdef ENABLE_EMULATE_FEATURE
app->emulate_feature_enabled = settings.emulate_feature_enabled;
#else
app->emulate_feature_enabled = false;
#endif
// Init setting - KEEP THIS, it's small
app->setting = subghz_setting_alloc();
@@ -175,7 +179,11 @@ void protopirate_app_free(ProtoPirateApp* app) {
settings.check_saved = app->check_saved;
settings.tx_power = app->tx_power;
settings.hopping_enabled = (app->txrx->hopper_state != ProtoPirateHopperStateOFF);
#ifdef ENABLE_EMULATE_FEATURE
settings.emulate_feature_enabled = app->emulate_feature_enabled;
#else
settings.emulate_feature_enabled = false;
#endif
// Find current preset index
settings.preset_index = 0;
@@ -273,14 +281,18 @@ int32_t protopirate_app(char* p) {
//We now jump straight to emulate scene from Browser. If the user wanted the key to look at, just click back.
if(load_saved) {
#ifdef ENABLE_EMULATE_FEATURE
if(protopirate_app->emulate_feature_enabled) {
view_dispatcher_send_custom_event(
protopirate_app->view_dispatcher, ProtoPirateCustomEventSavedInfoEmulate);
notification_message(protopirate_app->notifications, &sequence_success);
} else {
#endif
view_dispatcher_send_custom_event(
protopirate_app->view_dispatcher, ProtoPirateCustomEventReceiverInfoSave);
#ifdef ENABLE_EMULATE_FEATURE
}
#endif
}
view_dispatcher_run(protopirate_app->view_dispatcher);
@@ -12,6 +12,7 @@
#define CREDIT_LINE_HEIGHT 10
#define SCROLL_SPEED 1
#ifdef ENABLE_EMULATE_FEATURE
static const InputKey EMULATE_TOGGLE_COMBO[] = {
InputKeyUp,
InputKeyUp,
@@ -23,6 +24,7 @@ static const InputKey EMULATE_TOGGLE_COMBO[] = {
InputKeyRight,
};
#define EMULATE_TOGGLE_COMBO_LEN (sizeof(EMULATE_TOGGLE_COMBO) / sizeof(EMULATE_TOGGLE_COMBO[0]))
#endif
static const char* credits[] = {
"",
@@ -62,7 +64,9 @@ typedef struct {
uint8_t frame;
uint8_t seed;
int16_t scroll_offset;
#ifdef ENABLE_EMULATE_FEATURE
uint8_t combo_progress;
#endif
} GlitchState;
static GlitchState g_state = {0};
@@ -159,6 +163,7 @@ static void about_draw_callback(Canvas* canvas, void* context) {
static bool about_input_callback(InputEvent* event, void* context) {
furi_check(context);
#ifdef ENABLE_EMULATE_FEATURE
ProtoPirateApp* app = context;
if(event->type != InputTypePress) {
@@ -184,8 +189,13 @@ static bool about_input_callback(InputEvent* event, void* context) {
}
return true;
#else
UNUSED(event);
return false;
#endif
}
#ifdef ENABLE_EMULATE_FEATURE
static void about_show_emulate_toggle_popup(ProtoPirateApp* app) {
const bool now_enabled = app->emulate_feature_enabled;
@@ -204,6 +214,7 @@ static void about_show_emulate_toggle_popup(ProtoPirateApp* app) {
dialog_message_show(app->dialogs, message);
dialog_message_free(message);
}
#endif
void protopirate_scene_about_on_enter(void* context) {
furi_check(context);
@@ -218,7 +229,9 @@ void protopirate_scene_about_on_enter(void* context) {
g_state.frame = 0;
g_state.seed = furi_get_tick() & 0xFF;
g_state.scroll_offset = 0;
#ifdef ENABLE_EMULATE_FEATURE
g_state.combo_progress = 0;
#endif
view_set_draw_callback(app->view_about, about_draw_callback);
view_set_input_callback(app->view_about, about_input_callback);
@@ -245,7 +258,9 @@ bool protopirate_scene_about_on_event(void* context, SceneManagerEvent event) {
view_commit_model(app->view_about, true);
consumed = true;
} else if(event.type == SceneManagerEventTypeCustom) {
}
#ifdef ENABLE_EMULATE_FEATURE
else if(event.type == SceneManagerEventTypeCustom) {
if(event.event == ProtoPirateCustomEventAboutToggleEmulate) {
app->emulate_feature_enabled = !app->emulate_feature_enabled;
@@ -262,6 +277,7 @@ bool protopirate_scene_about_on_event(void* context, SceneManagerEvent event) {
consumed = true;
}
}
#endif
return consumed;
}
@@ -1205,7 +1205,9 @@ bool protopirate_scene_sub_decode_on_event(void* context, SceneManagerEvent even
app);
ctx->signal_info_left_is_emulate = false;
#ifdef ENABLE_EMULATE_FEATURE
bool left_button_used = false;
#endif
app->emulate_disabled_for_loaded = true;
// Store reference to history item's flipper format for saving
@@ -1239,7 +1241,9 @@ bool protopirate_scene_sub_decode_on_event(void* context, SceneManagerEvent even
"Brute force",
protopirate_scene_sub_decode_widget_callback,
app);
#ifdef ENABLE_EMULATE_FEATURE
left_button_used = true;
#endif
}
}
}
+6 -1
View File
@@ -3,6 +3,7 @@ import hashlib
import os
import struct
import subprocess
import shutil
import tempfile
import time
from collections import defaultdict
@@ -26,6 +27,10 @@ def replace_file_with_retry(source: str, target: str, attempts: int = 50) -> Non
if attempt == attempts - 1:
raise
time.sleep(0.1)
except OSError:
shutil.copy2(source, target)
os.remove(source)
return
@dataclass
@@ -147,7 +152,7 @@ class Main(App):
RelSection(section_name, section.name, unique_relocations)
)
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(dir=os.path.dirname(os.path.abspath(fap_path))) as temp_dir:
current_fap_path = fap_path
for section_index, section in enumerate(sections):