From 7a54a56e10e0148e53ba50fce64e35a94da1e77b Mon Sep 17 00:00:00 2001 From: mthsk <111659316+mthsk@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:38:19 -0300 Subject: [PATCH 1/5] Quick Gadget Select --- .../1.0.0/manifest.toml | 27 + packaging/release/app.conf | 8 +- src/mods/ape_quick_gadget_select_plugin.c | 516 ++++++++++++++++++ tests/test_preloaded_mods.cpp | 43 +- 4 files changed, 577 insertions(+), 17 deletions(-) create mode 100644 mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml create mode 100644 src/mods/ape_quick_gadget_select_plugin.c diff --git a/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml b/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml new file mode 100644 index 0000000..2e90c1d --- /dev/null +++ b/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml @@ -0,0 +1,27 @@ +format_version = 5 +id = "ape.enhancement.quick-gadget-select" +version = "1.0.0" +name = "Ape Escape Quick Gadget Select" +author = "mthsk" +description = "Backport of the Quick Gadget Switch feature from Ape Escape 2 and 3" +resolver = "declarative" +save_compatibility = "shared" + +[[target]] +game_id = "SCUS-94423" +disc_sha256 = "1ae17e78ebb8c782c7c1785b0a0bd7b0ee28235b8a0c83c8df887129899a852a" + +[[feature]] +id = "quick-gadget-select" +name = "Quick Gadget Select (Experimental)" +description = "Press the equipped face button again to open the native-icon gadget row. Right-stick use or another face-button press commits." +group = "Gameplay" +default_enabled = false + +[[plugin]] +feature = "quick-gadget-select" +id = "ape.gadgets.quick-select" + +[[plugin]] +feature = "quick-gadget-select" +id = "ape.gadgets.quick-select-patches" diff --git a/packaging/release/app.conf b/packaging/release/app.conf index 45586f4..e26d10b 100644 --- a/packaging/release/app.conf +++ b/packaging/release/app.conf @@ -3,8 +3,8 @@ # and packaging/linux/AppRun are game-neutral and read their values from here. # # ENV_PREFIX names the per-game environment overrides the AppImage honours: -# _DATA_DIR relocate the writable data directory -# _SEED_ONLY seed and print the data dir, then exit (layout test) +# _DATA_DIR relocate the writable data directory +# _SEED_ONLY seed and print the data dir, then exit (layout test) APP_NAME="Ape Escape Recompiled" EXE_NAME="ApeEscapeRecomp" @@ -12,7 +12,9 @@ PAYLOAD_DIR="apeescaperecomp" DESKTOP_ID="io.github.mstan.ApeEscapeRecomp" ENV_PREFIX="APE_ESCAPE_RECOMP" ICON_SOURCE="recomp/launcher/boxart.tga" + # Number of preloaded mod packages this title ships; the packagers refuse to # build if the staged catalog does not match (an empty Mods page must not ship). -EXPECTED_MODS=3 +EXPECTED_MODS=4 + FRAMEWORK_DIR="psxrecomp-v4" diff --git a/src/mods/ape_quick_gadget_select_plugin.c b/src/mods/ape_quick_gadget_select_plugin.c new file mode 100644 index 0000000..c60b5e0 --- /dev/null +++ b/src/mods/ape_quick_gadget_select_plugin.c @@ -0,0 +1,516 @@ +#include "mod_plugins.h" + +#include + +static const uint32_t APE_TRANSITION_PHASE_ADDRESS = 0x800F447Cu; +static const uint32_t APE_PAD_LOW_INPUT_ADDRESS = 0x800B87A2u; +static const uint32_t APE_FACE_INPUT_ADDRESS = 0x800B87A3u; +static const uint32_t APE_RIGHT_STICK_Y_ADDRESS = 0x800B87A4u; +static const uint32_t APE_RIGHT_STICK_X_ADDRESS = 0x800B87A5u; +static const uint32_t APE_GADGET_ICON_TABLE_ADDRESS = 0x800B2254u; +static const uint32_t APE_PLAYER_IDLE_COUNTER_ADDRESS = 0x800EC328u; +static const uint32_t APE_SLINGSHOT_FACE_AMMO_BLOCK_ADDRESS = 0x80063B6Cu; +static const uint32_t APE_GADGET_SLOT_BASE = 0x800F51A8u; +static const uint32_t APE_HELD_GADGET_ADDRESS = 0x800EC2D2u; +static const uint32_t APE_UNLOCKED_GADGETS_ADDRESS = 0x800F51C4u; +static const uint32_t PSX_GPU_GP0_ADDRESS = 0x1F801810u; + +enum { + APE_TRANSITION_PLAYING = 0x03u, + APE_TRANSITION_NEARBY = 0x04u, + APE_TRANSITION_LOADED = 0x05u, + + APE_PAD_START = 0x08u, + + APE_FACE_TRIANGLE = 0x10u, + APE_FACE_CIRCLE = 0x20u, + APE_FACE_CROSS = 0x40u, + APE_FACE_SQUARE = 0x80u, + APE_FACE_MASK = 0xF0u, + + APE_FACE_SLOT_COUNT = 4u, + APE_GADGET_COUNT = 8u, + APE_INVALID_GADGET = 0xFFu, + + APE_RIGHT_STICK_DEADZONE = 28, + APE_IDLE_COUNTER_TRIGGER = 901u, + + APE_DISABLE_SLINGSHOT_FACE_AMMO_INSTRUCTION = 0x08018F7Cu, + + APE_ROW_RIGHT = 316, + APE_ROW_Y = 5, + APE_ICON_SIZE = 32, + APE_TILE_GAP = 2, + APE_TILE_STEP = APE_ICON_SIZE + APE_TILE_GAP +}; + +static uint8_t g_previous_face_buttons; +static uint8_t g_last_face_button; +static uint8_t g_quick_face_button; +static uint8_t g_quick_selected_gadget = APE_INVALID_GADGET; +static uint8_t g_quick_select_active; + +static uint8_t g_snapshot_slots[APE_FACE_SLOT_COUNT]; +static uint8_t g_snapshot_held_gadget = APE_INVALID_GADGET; +static uint8_t g_snapshot_valid; + +static uint16_t ape_read_half(uint32_t address) { + const uint16_t low = psx_mod_read_byte(address); + const uint16_t high = psx_mod_read_byte(address + 1u); + return (uint16_t)(low | (uint16_t)(high << 8u)); +} + + +static uint8_t ape_pressed_face_buttons(void) { + return (uint8_t)(~psx_mod_read_byte(APE_FACE_INPUT_ADDRESS) & APE_FACE_MASK); +} + +static int ape_start_is_pressed(void) { + return ((uint8_t)~psx_mod_read_byte(APE_PAD_LOW_INPUT_ADDRESS) & + APE_PAD_START) != 0u; +} + +static int ape_axis_is_active(uint32_t address, int deadzone) { + const int value = (int)psx_mod_read_byte(address) - 0x80; + return value < -deadzone || value > deadzone; +} + +static int ape_right_stick_is_active(void) { + return ape_axis_is_active(APE_RIGHT_STICK_X_ADDRESS, + APE_RIGHT_STICK_DEADZONE) || + ape_axis_is_active(APE_RIGHT_STICK_Y_ADDRESS, + APE_RIGHT_STICK_DEADZONE); +} + + +static int ape_is_entering_idle(void) { + return ape_read_half(APE_PLAYER_IDLE_COUNTER_ADDRESS) >= + APE_IDLE_COUNTER_TRIGGER; +} + +static int ape_is_playable_transition(uint8_t phase) { + return phase >= APE_TRANSITION_PLAYING && + phase <= APE_TRANSITION_LOADED; +} + +static int ape_is_single_bit(uint8_t value) { + return value != 0u && (value & (uint8_t)(value - 1u)) == 0u; +} + +static uint8_t ape_slot_from_face_button(uint8_t face_button) { + switch (face_button) { + case APE_FACE_TRIANGLE: + return 0u; + case APE_FACE_SQUARE: + return 1u; + case APE_FACE_CIRCLE: + return 2u; + case APE_FACE_CROSS: + return 3u; + default: + return APE_INVALID_GADGET; + } +} + +static uint8_t ape_face_button_from_slot(uint8_t slot) { + static const uint8_t faces[APE_FACE_SLOT_COUNT] = { + APE_FACE_TRIANGLE, APE_FACE_SQUARE, APE_FACE_CIRCLE, APE_FACE_CROSS + }; + return slot < APE_FACE_SLOT_COUNT ? faces[slot] : 0u; +} + +static uint8_t ape_read_slot(uint8_t slot) { + return psx_mod_read_byte(APE_GADGET_SLOT_BASE + (uint32_t)slot); +} + +static void ape_write_slot(uint8_t slot, uint8_t gadget) { + psx_mod_write_byte(APE_GADGET_SLOT_BASE + (uint32_t)slot, gadget); +} + +static int ape_slot_value_is_valid(uint8_t gadget) { + return gadget < APE_GADGET_COUNT || gadget == APE_INVALID_GADGET; +} + +static int ape_has_valid_gadget_context(void) { + const uint8_t unlocked = + psx_mod_read_byte(APE_UNLOCKED_GADGETS_ADDRESS); + const uint8_t held = psx_mod_read_byte(APE_HELD_GADGET_ADDRESS); + uint8_t slot; + uint8_t held_matches = 0u; + + if (unlocked == 0u || held >= APE_GADGET_COUNT) + return 0; + + if (g_quick_select_active && g_snapshot_valid) { + if (g_quick_selected_gadget >= APE_GADGET_COUNT || + (unlocked & (uint8_t)(1u << g_quick_selected_gadget)) == 0u) { + return 0; + } + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) { + if (!ape_slot_value_is_valid(g_snapshot_slots[slot])) + return 0; + } + return 1; + } + + if ((unlocked & (uint8_t)(1u << held)) == 0u) + return 0; + + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) { + const uint8_t gadget = ape_read_slot(slot); + if (!ape_slot_value_is_valid(gadget)) + return 0; + if (gadget == held) + ++held_matches; + } + return held_matches != 0u; +} + +static uint8_t ape_find_snapshot_slot_with_gadget(uint8_t gadget, + uint8_t excluded_slot) { + uint8_t slot; + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) { + if (slot != excluded_slot && g_snapshot_slots[slot] == gadget) + return slot; + } + return APE_INVALID_GADGET; +} + +static void ape_take_snapshot(void) { + uint8_t slot; + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) + g_snapshot_slots[slot] = ape_read_slot(slot); + g_snapshot_held_gadget = + psx_mod_read_byte(APE_HELD_GADGET_ADDRESS); + g_snapshot_valid = 1u; +} + +static void ape_restore_snapshot_slots(void) { + uint8_t slot; + if (!g_snapshot_valid) + return; + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) + ape_write_slot(slot, g_snapshot_slots[slot]); +} + +static void ape_discard_snapshot(void) { + g_snapshot_valid = 0u; + g_snapshot_held_gadget = APE_INVALID_GADGET; +} + +static void ape_close_quick_select_state(void) { + g_quick_select_active = 0u; + g_quick_face_button = 0u; + g_quick_selected_gadget = APE_INVALID_GADGET; +} + +static void ape_apply_snapshot_swap(uint8_t gadget); + +static void ape_commit_quick_select(uint8_t replacement_face_button) { + uint8_t replacement_slot; + + ape_apply_snapshot_swap(g_quick_selected_gadget); + + if (replacement_face_button != 0u) { + replacement_slot = ape_slot_from_face_button(replacement_face_button); + if (replacement_slot < APE_FACE_SLOT_COUNT) { + psx_mod_write_byte(APE_HELD_GADGET_ADDRESS, + ape_read_slot(replacement_slot)); + } + } + + ape_discard_snapshot(); + ape_close_quick_select_state(); +} + +static void ape_cancel_quick_select(uint8_t replacement_face_button) { + if (g_snapshot_valid) { + ape_restore_snapshot_slots(); + + if (replacement_face_button != 0u) { + const uint8_t replacement_slot = + ape_slot_from_face_button(replacement_face_button); + if (replacement_slot < APE_FACE_SLOT_COUNT) { + psx_mod_write_byte(APE_HELD_GADGET_ADDRESS, + g_snapshot_slots[replacement_slot]); + } else { + psx_mod_write_byte(APE_HELD_GADGET_ADDRESS, + g_snapshot_held_gadget); + } + } else { + psx_mod_write_byte(APE_HELD_GADGET_ADDRESS, + g_snapshot_held_gadget); + } + } + + ape_discard_snapshot(); + ape_close_quick_select_state(); +} + +static void ape_reset_quick_select_state(uint8_t pressed_face_buttons, + int restore_snapshot) { + if (restore_snapshot) + ape_cancel_quick_select(0u); + else { + ape_discard_snapshot(); + ape_close_quick_select_state(); + } + g_previous_face_buttons = pressed_face_buttons; + g_last_face_button = 0u; +} + +static uint8_t ape_find_next_unlocked_gadget(uint8_t current, + uint8_t unlocked) { + uint8_t step; + const uint8_t start = current < APE_GADGET_COUNT + ? current + : (uint8_t)(APE_GADGET_COUNT - 1u); + + for (step = 1u; step <= APE_GADGET_COUNT; ++step) { + const uint8_t candidate = + (uint8_t)((start + step) & (APE_GADGET_COUNT - 1u)); + if ((unlocked & (uint8_t)(1u << candidate)) != 0u) + return candidate; + } + return current; +} + +static void ape_seed_selected_face_from_held_gadget(void) { + const uint8_t held = psx_mod_read_byte(APE_HELD_GADGET_ADDRESS); + uint8_t matching_face = 0u; + uint8_t matches = 0u; + uint8_t slot; + + if (held >= APE_GADGET_COUNT) + return; + + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) { + if (ape_read_slot(slot) == held) { + matching_face = ape_face_button_from_slot(slot); + ++matches; + } + } + + if (matches == 1u) + g_last_face_button = matching_face; +} + +static uint32_t ape_gpu_color(uint8_t red, uint8_t green, uint8_t blue) { + return (uint32_t)red | ((uint32_t)green << 8u) | + ((uint32_t)blue << 16u); +} + +static void ape_gpu_rect(int x, int y, int width, int height, uint32_t color) { + if (width <= 0 || height <= 0) + return; + + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0x60000000u | color); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, + ((uint32_t)(uint16_t)y << 16u) | + (uint32_t)(uint16_t)x); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, + ((uint32_t)(uint16_t)height << 16u) | + (uint32_t)(uint16_t)width); +} + +static void ape_gpu_native_gadget_icon(uint8_t gadget, int x, int y) { + const uint32_t descriptor = + APE_GADGET_ICON_TABLE_ADDRESS + (uint32_t)gadget * 4u; + const uint8_t u = psx_mod_read_byte(descriptor); + const uint8_t v = psx_mod_read_byte(descriptor + 1u); + const uint16_t clut = ape_read_half(descriptor + 2u); + + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0xE100021Eu); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0x65000000u); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, + ((uint32_t)(uint16_t)y << 16u) | + (uint32_t)(uint16_t)x); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, + ((uint32_t)clut << 16u) | + ((uint32_t)v << 8u) | (uint32_t)u); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, + ((uint32_t)APE_ICON_SIZE << 16u) | + (uint32_t)APE_ICON_SIZE); +} + +static void ape_draw_quick_select_row(uint8_t unlocked, uint8_t selected) { + const uint32_t row_background = ape_gpu_color(18u, 20u, 27u); + const uint32_t normal_border = ape_gpu_color(78u, 84u, 96u); + const uint32_t selected_border = ape_gpu_color(255u, 226u, 90u); + const uint32_t icon_background = ape_gpu_color(24u, 27u, 35u); + uint8_t count = 0u; + uint8_t gadget; + int total_width; + int x; + + for (gadget = 0u; gadget < APE_GADGET_COUNT; ++gadget) { + if ((unlocked & (uint8_t)(1u << gadget)) != 0u) + ++count; + } + if (count == 0u) + return; + + total_width = (int)count * APE_ICON_SIZE + + ((int)count - 1) * APE_TILE_GAP; + x = APE_ROW_RIGHT - total_width; + if (x < 4) + x = 4; + + ape_gpu_rect(x - 3, APE_ROW_Y - 3, total_width + 6, + APE_ICON_SIZE + 6, row_background); + + for (gadget = 0u; gadget < APE_GADGET_COUNT; ++gadget) { + const int selected_gadget = gadget == selected; + if ((unlocked & (uint8_t)(1u << gadget)) == 0u) + continue; + + ape_gpu_rect(x - 1, APE_ROW_Y - 1, APE_ICON_SIZE + 2, + APE_ICON_SIZE + 2, + selected_gadget ? selected_border : normal_border); + ape_gpu_rect(x, APE_ROW_Y, APE_ICON_SIZE, APE_ICON_SIZE, + icon_background); + ape_gpu_native_gadget_icon(gadget, x, APE_ROW_Y); + + if (selected_gadget) { + ape_gpu_rect(x + 3, APE_ROW_Y - 3, APE_ICON_SIZE - 6, 2, + selected_border); + } + + x += APE_TILE_STEP; + } +} + +static void ape_apply_snapshot_swap(uint8_t gadget) { + const uint8_t selected_slot = + ape_slot_from_face_button(g_quick_face_button); + uint8_t other_slot; + + if (!g_snapshot_valid || selected_slot >= APE_FACE_SLOT_COUNT || + gadget >= APE_GADGET_COUNT) { + return; + } + + ape_restore_snapshot_slots(); + other_slot = + ape_find_snapshot_slot_with_gadget(gadget, selected_slot); + if (other_slot < APE_FACE_SLOT_COUNT) + ape_write_slot(other_slot, g_snapshot_slots[selected_slot]); + + ape_write_slot(selected_slot, gadget); + psx_mod_write_byte(APE_HELD_GADGET_ADDRESS, gadget); +} + +static void ape_preview_gadget(uint8_t gadget) { + ape_apply_snapshot_swap(gadget); + if (g_snapshot_valid && gadget < APE_GADGET_COUNT) + g_quick_selected_gadget = gadget; +} + +static void ape_advance_quick_selected_gadget(void) { + const uint8_t unlocked = + psx_mod_read_byte(APE_UNLOCKED_GADGETS_ADDRESS); + const uint8_t next = + ape_find_next_unlocked_gadget(g_quick_selected_gadget, unlocked); + ape_preview_gadget(next); +} + +static void ape_open_quick_select(uint8_t face_button, uint8_t gadget) { + ape_take_snapshot(); + g_quick_select_active = 1u; + g_quick_face_button = face_button; + g_quick_selected_gadget = gadget; +} + +static int ape_cancel_for_idle(void) { + if (!g_quick_select_active || !ape_is_entering_idle()) + return 0; + + ape_cancel_quick_select(0u); + g_last_face_button = 0u; + return 1; +} + +static void ape_quick_gadget_select_vblank(void) { + uint8_t pressed_face_buttons; + uint8_t just_pressed; + + if (!psx_mod_game_started()) { + ape_reset_quick_select_state(0u, 0); + return; + } + + pressed_face_buttons = ape_pressed_face_buttons(); + if (!ape_is_playable_transition( + psx_mod_read_byte(APE_TRANSITION_PHASE_ADDRESS)) || + !ape_has_valid_gadget_context() || ape_start_is_pressed()) { + ape_reset_quick_select_state(pressed_face_buttons, 1); + return; + } + + if (ape_cancel_for_idle()) { + g_previous_face_buttons = pressed_face_buttons; + return; + } + + just_pressed = + (uint8_t)(pressed_face_buttons & (uint8_t)~g_previous_face_buttons); + g_previous_face_buttons = pressed_face_buttons; + + if (g_quick_select_active && ape_right_stick_is_active()) { + g_last_face_button = g_quick_face_button; + ape_commit_quick_select(0u); + return; + } + + if (just_pressed != 0u) { + if (!ape_is_single_bit(just_pressed) || + pressed_face_buttons != just_pressed) { + ape_cancel_quick_select(0u); + g_last_face_button = 0u; + } else if (g_quick_select_active) { + if (just_pressed == g_quick_face_button) { + ape_advance_quick_selected_gadget(); + } else { + ape_commit_quick_select(just_pressed); + g_last_face_button = just_pressed; + } + } else { + const uint8_t slot = ape_slot_from_face_button(just_pressed); + const uint8_t slot_gadget = + slot < APE_FACE_SLOT_COUNT ? ape_read_slot(slot) + : APE_INVALID_GADGET; + const uint8_t held = + psx_mod_read_byte(APE_HELD_GADGET_ADDRESS); + + if (just_pressed == g_last_face_button && + slot_gadget < APE_GADGET_COUNT && held == slot_gadget) { + ape_open_quick_select(just_pressed, slot_gadget); + } else { + g_last_face_button = just_pressed; + } + } + } else if (!g_quick_select_active && pressed_face_buttons == 0u && + g_last_face_button == 0u) { + ape_seed_selected_face_from_held_gadget(); + } + + if (g_quick_select_active) { + const uint8_t unlocked = + psx_mod_read_byte(APE_UNLOCKED_GADGETS_ADDRESS); + ape_draw_quick_select_row(unlocked, g_quick_selected_gadget); + } +} + +static void ape_quick_gadget_select_activation(void) { + psx_mod_write_code_word( + APE_SLINGSHOT_FACE_AMMO_BLOCK_ADDRESS, + APE_DISABLE_SLINGSHOT_FACE_AMMO_INSTRUCTION); +} + +PSX_MOD_CONSTRUCTOR(ape_register_quick_gadget_select_plugin) { + (void)psx_mod_register_activation_plugin( + "ape.gadgets.quick-select-patches", + ape_quick_gadget_select_activation); + (void)psx_mod_register_vblank_plugin( + "ape.gadgets.quick-select", ape_quick_gadget_select_vblank); +} diff --git a/tests/test_preloaded_mods.cpp b/tests/test_preloaded_mods.cpp index 038714e..d161e0a 100644 --- a/tests/test_preloaded_mods.cpp +++ b/tests/test_preloaded_mods.cpp @@ -1,5 +1,4 @@ #include "mod_packages.h" - #include #include #include @@ -23,14 +22,12 @@ void no_op_plugin() {} int main(int argc, char** argv) { if (argc != 2) return fail("expected the preloaded mods root"); - const fs::path source(argv[1]); const fs::path root = fs::temp_directory_path() / "apeescape-preloaded-mods-test"; std::error_code ec; fs::remove_all(root, ec); fs::copy(source, root, fs::copy_options::recursive); - size_t manifest_count = 0; for (const fs::directory_entry& entry : fs::recursive_directory_iterator(root / "packages")) { @@ -46,8 +43,7 @@ int main(int argc, char** argv) { return fail("manifest parse failed: " + error); } } - if (manifest_count != 3) return fail("expected three package manifests"); - + if (manifest_count != 4) return fail("expected four package manifests"); PSXRecompV4::mod_clear_plugins_for_tests(); for (const char* id : { "ape.widescreen.16-9", @@ -58,18 +54,18 @@ int main(int argc, char** argv) { "ape.framerate.144", "ape.framerate.165", "ape.framerate.uncapped", - "ape.fmv.skip"}) { + "ape.fmv.skip", + "ape.gadgets.quick-select", + "ape.gadgets.quick-select-patches"}) { if (!PSXRecompV4::mod_register_activation_plugin(id, no_op_plugin)) return fail(std::string("could not register test plugin ") + id); } - PSXRecompV4::ModPackageManager manager(root); std::string error; if (!manager.scan(&error)) return fail("catalog scan failed: " + error); if (!manager.load_state(&error)) return fail("default state failed: " + error); - if (manager.packages().size() != 3) - return fail("expected three package families"); - + if (manager.packages().size() != 4) + return fail("expected four package families"); const auto default_plan = manager.resolve(kGameId, "", kDiscSha256); if (!default_plan.ok || !default_plan.writes.empty() || default_plan.plugins.size() != 1 || @@ -80,7 +76,6 @@ int main(int argc, char** argv) { "ape.enhancement.skip-fmvs", "skip-fmvs", false, &error)) { return fail(error); } - if (!manager.set_feature_enabled( "ape.enhancement.widescreen", "widescreen", true, &error)) { return fail(error); @@ -100,7 +95,6 @@ int main(int argc, char** argv) { return fail(std::string("wrong widescreen plugin for ") + choice); } } - if (!manager.set_feature_enabled( "ape.enhancement.widescreen", "widescreen", false, &error) || !manager.set_feature_enabled( @@ -126,10 +120,31 @@ int main(int argc, char** argv) { choice); } } + if (!manager.set_feature_enabled( + "ape.experimental.60fps", "native-60fps", false, &error) || + !manager.set_feature_enabled( + "ape.enhancement.quick-gadget-select", "quick-gadget-select", + true, &error)) { + return fail(error); + } + const auto quick_select_plan = manager.resolve(kGameId, "", kDiscSha256); + if (!quick_select_plan.ok || !quick_select_plan.writes.empty() || + quick_select_plan.plugins.size() != 2) { + return fail("wrong Quick Gadget Select plugin count"); + } + bool found_vblank = false; + bool found_patch = false; + for (const auto& plugin : quick_select_plan.plugins) { + found_vblank |= plugin.id == "ape.gadgets.quick-select"; + found_patch |= plugin.id == "ape.gadgets.quick-select-patches"; + } + if (!found_vblank || !found_patch) + return fail("wrong Quick Gadget Select plan"); fs::remove_all(root, ec); - std::cout << "Ape Escape preloaded mods: 3 packages, " + std::cout << "Ape Escape preloaded mods: 4 packages, " "3 widescreen choices, 5 interpolated frame-rate choices, " + "Quick Gadget Select default-off, " "motion-adaptive clarity blend, Skip FMVs migrated from Settings, " - "stock guest code untouched\n"; + "slingshot face-button ammo cycle disabled by opt-in activation patch\n"; return 0; } From 109e16610769c88ecb4a1a9aa844cc383b88cd63 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 22 Aug 2026 12:22:41 -0700 Subject: [PATCH 2/5] Quick Gadget Select: make the guest-code patch actually apply The slingshot-suppression half of the feature never ran. It was a psx_mod_write_code_word() in an activation callback, and activation callbacks fire at main.cpp:12196 -- before SDL_Init and before the run loop, so the game's own EXE load overwrites 0x80063B6C long afterwards. The address is inside the boot image (load_address 0x80010000, text_size 0xA5000), so the write was dead every launch and the stock ammo cycle kept claiming the same second button press the row opens on. Express it as a declarative [[patch]] instead. Those are applied at first dispatch of the entry PC, guarded by expected bytes, and reapplied after a savestate restore -- none of which the plugin path does. The second plugin id is gone; the feature is one vblank plugin plus one patch. Also in this change: - Survive savestate and rewind. Plugin statics are not in a save state, so a restore under an open row left the snapshot describing a timeline that no longer exists, and cancelling wrote those stale bytes into the restored save. Every write now records what it left in guest RAM and each frame re-checks it; a mismatch abandons the row without writing. - Place the row against the real right edge under widescreen via psx_mod_widescreen_x_margin() rather than a fixed 4:3 316. - Stop leaking the icon texture page into the game's next frame: save it from GPUSTAT bits 0..10 and put it back, and clear the texture window first so a leftover window cannot wrap the icon UVs. Document why the drawing offset is deliberately inherited rather than set. - Swap the right-stick X/Y address names, which were the wrong way round against the DualShock report order, and say in the feature description that the stick commit needs an analog pad. - Use psx_mod_read_half() instead of reimplementing it. - Record what the addresses are and what 0x08018F7C decodes to. The test now asserts the planned main_exe write -- target, address, expected and replacement bytes -- and that nothing is patched while the feature is off. A plugin-count assertion passed against the original manifest while the patch did nothing; this one fails against it. Verified: gcc -Wall -Wextra clean on the plugin; ape_preloaded_mods_test passes on this tree and fails with "wrong Quick Gadget Select plugin plan" when the manifest is reverted to the original. Expected bytes D2C24380 read from SCUS_944.23 at file offset 2048 + 0x53B6C. Not verified: no in-game run. The row's widescreen placement and the GPU state handling are reasoned from the GP0/GPUSTAT contract, not observed on screen. Co-Authored-By: Claude Opus 5 (1M context) --- .../1.0.0/manifest.toml | 31 +++- packaging/release/app.conf | 6 +- src/mods/ape_quick_gadget_select_plugin.c | 166 ++++++++++++++---- tests/test_preloaded_mods.cpp | 58 ++++-- 4 files changed, 206 insertions(+), 55 deletions(-) diff --git a/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml b/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml index 2e90c1d..3890db9 100644 --- a/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml +++ b/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml @@ -14,14 +14,37 @@ disc_sha256 = "1ae17e78ebb8c782c7c1785b0a0bd7b0ee28235b8a0c83c8df887129899a852a" [[feature]] id = "quick-gadget-select" name = "Quick Gadget Select (Experimental)" -description = "Press the equipped face button again to open the native-icon gadget row. Right-stick use or another face-button press commits." +description = "Press the equipped face button again to open the native-icon gadget row. Right-stick use or another face-button press commits. The right-stick commit needs an analog pad; on a D-Pad controller commit with another face button." group = "Gameplay" default_enabled = false -[[plugin]] +# Stock code cycles slingshot ammo when the slingshot's own face button is +# pressed again, which is precisely the second press this feature claims for +# opening the gadget row. Replace the head of that block with a jump to the +# block's own not-the-slingshot fall-through: +# +# 80063B68 lui v0, 0x800F +# 80063B6C lb v1, -0x3D2E(v0) ; v1 = held gadget (0x800EC2D2) <-- patched +# 80063B70 li v0, 4 ; (harmless in the new delay slot) +# 80063B74 bne v1, v0, +0x9E ; -> 0x80063DF0 when not the slingshot +# +# 0x08018F7C is `j 0x80063DF0`, i.e. unconditionally take the branch the +# comparison would have taken for every other gadget. +# +# This is a declarative patch rather than a psx_mod_write_code_word() call in an +# activation plugin on purpose: activation callbacks run before the guest boots +# (runtime/src/main.cpp, ahead of SDL_Init and the run loop), so the game's own +# EXE load would overwrite the write. Declarative main_exe patches are applied +# at first dispatch of the entry PC and reapplied after a savestate restore, +# both behind the expected-byte guard below. +[[patch]] feature = "quick-gadget-select" -id = "ape.gadgets.quick-select" +target = "main_exe" +address = 2147892076 +expected = "D2C24380" +replace = "7C8F0108" +order = 0 [[plugin]] feature = "quick-gadget-select" -id = "ape.gadgets.quick-select-patches" +id = "ape.gadgets.quick-select" diff --git a/packaging/release/app.conf b/packaging/release/app.conf index e26d10b..b0e57cb 100644 --- a/packaging/release/app.conf +++ b/packaging/release/app.conf @@ -3,8 +3,8 @@ # and packaging/linux/AppRun are game-neutral and read their values from here. # # ENV_PREFIX names the per-game environment overrides the AppImage honours: -# _DATA_DIR relocate the writable data directory -# _SEED_ONLY seed and print the data dir, then exit (layout test) +# _DATA_DIR relocate the writable data directory +# _SEED_ONLY seed and print the data dir, then exit (layout test) APP_NAME="Ape Escape Recompiled" EXE_NAME="ApeEscapeRecomp" @@ -12,9 +12,7 @@ PAYLOAD_DIR="apeescaperecomp" DESKTOP_ID="io.github.mstan.ApeEscapeRecomp" ENV_PREFIX="APE_ESCAPE_RECOMP" ICON_SOURCE="recomp/launcher/boxart.tga" - # Number of preloaded mod packages this title ships; the packagers refuse to # build if the staged catalog does not match (an empty Mods page must not ship). EXPECTED_MODS=4 - FRAMEWORK_DIR="psxrecomp-v4" diff --git a/src/mods/ape_quick_gadget_select_plugin.c b/src/mods/ape_quick_gadget_select_plugin.c index c60b5e0..3aef7d8 100644 --- a/src/mods/ape_quick_gadget_select_plugin.c +++ b/src/mods/ape_quick_gadget_select_plugin.c @@ -2,18 +2,42 @@ #include +/* + * Quick Gadget Select - backport of the Ape Escape 2/3 gadget row. + * + * A second press of the equipped face button opens a row of the unlocked + * gadgets drawn over the frame; further presses of the same button cycle the + * selection, and either right-stick use or a different face button commits. + * + * The guest-code half of this feature (suppressing the stock slingshot ammo + * cycle, which claims the same second press) is NOT here: it is a declarative + * [[patch]] in the package manifest. Activation callbacks run before the guest + * boots, so a psx_mod_write_code_word() from one would be overwritten by the + * game's own EXE load. + */ + +/* Guest addresses. Everything below 0x800B5000 is inside the boot EXE image + * (game.toml: load_address 0x80010000, text_size 0xA5000); the rest is BSS. + * APE_HELD_GADGET_ADDRESS is confirmed by the stock slingshot block that the + * manifest patch rewrites: `lui v0,0x800F` / `lb v1,-0x3D2E(v0)`. */ static const uint32_t APE_TRANSITION_PHASE_ADDRESS = 0x800F447Cu; +/* Game-side pad shadow. Byte order follows the DualShock report: the two + * digital button bytes, then right X, then right Y. */ static const uint32_t APE_PAD_LOW_INPUT_ADDRESS = 0x800B87A2u; static const uint32_t APE_FACE_INPUT_ADDRESS = 0x800B87A3u; -static const uint32_t APE_RIGHT_STICK_Y_ADDRESS = 0x800B87A4u; -static const uint32_t APE_RIGHT_STICK_X_ADDRESS = 0x800B87A5u; +static const uint32_t APE_RIGHT_STICK_X_ADDRESS = 0x800B87A4u; +static const uint32_t APE_RIGHT_STICK_Y_ADDRESS = 0x800B87A5u; +/* Four bytes per gadget: u, v, then the 16-bit CLUT id. */ static const uint32_t APE_GADGET_ICON_TABLE_ADDRESS = 0x800B2254u; static const uint32_t APE_PLAYER_IDLE_COUNTER_ADDRESS = 0x800EC328u; -static const uint32_t APE_SLINGSHOT_FACE_AMMO_BLOCK_ADDRESS = 0x80063B6Cu; +/* Four bytes, one gadget id per face-button slot. */ static const uint32_t APE_GADGET_SLOT_BASE = 0x800F51A8u; static const uint32_t APE_HELD_GADGET_ADDRESS = 0x800EC2D2u; +/* Bit per gadget. */ static const uint32_t APE_UNLOCKED_GADGETS_ADDRESS = 0x800F51C4u; + static const uint32_t PSX_GPU_GP0_ADDRESS = 0x1F801810u; +static const uint32_t PSX_GPU_GPUSTAT_ADDRESS = 0x1F801814u; enum { APE_TRANSITION_PLAYING = 0x03u, @@ -35,13 +59,17 @@ enum { APE_RIGHT_STICK_DEADZONE = 28, APE_IDLE_COUNTER_TRIGGER = 901u, - APE_DISABLE_SLINGSHOT_FACE_AMMO_INSTRUCTION = 0x08018F7Cu, - - APE_ROW_RIGHT = 316, + /* Right edge of the row in the game's own draw coordinates at 4:3; the + * widescreen reveal is added at draw time. */ + APE_ROW_RIGHT_4_3 = 316, + APE_ROW_MARGIN = 4, APE_ROW_Y = 5, APE_ICON_SIZE = 32, APE_TILE_GAP = 2, - APE_TILE_STEP = APE_ICON_SIZE + APE_TILE_GAP + APE_TILE_STEP = APE_ICON_SIZE + APE_TILE_GAP, + + /* Texture page holding the gadget icons: 4bpp at (896, 256), dithered. */ + APE_ICON_TEXPAGE = 0x021Eu }; static uint8_t g_previous_face_buttons; @@ -54,12 +82,18 @@ static uint8_t g_snapshot_slots[APE_FACE_SLOT_COUNT]; static uint8_t g_snapshot_held_gadget = APE_INVALID_GADGET; static uint8_t g_snapshot_valid; -static uint16_t ape_read_half(uint32_t address) { - const uint16_t low = psx_mod_read_byte(address); - const uint16_t high = psx_mod_read_byte(address + 1u); - return (uint16_t)(low | (uint16_t)(high << 8u)); -} - +/* + * Last guest state this plugin wrote. Plugin statics are not part of a save + * state, so a restore (or a rewind step, which does the same thing many times + * a second) can replace guest RAM underneath an open row and leave the + * snapshot describing an abandoned timeline. Writing that snapshot back would + * corrupt the restored save. Every write records what it left behind; each + * frame re-checks it, and any mismatch abandons the snapshot without writing. + * This also covers the game itself reassigning gadgets. + */ +static uint8_t g_expected_slots[APE_FACE_SLOT_COUNT]; +static uint8_t g_expected_held_gadget; +static uint8_t g_expected_valid; static uint8_t ape_pressed_face_buttons(void) { return (uint8_t)(~psx_mod_read_byte(APE_FACE_INPUT_ADDRESS) & APE_FACE_MASK); @@ -75,6 +109,8 @@ static int ape_axis_is_active(uint32_t address, int deadzone) { return value < -deadzone || value > deadzone; } +/* Needs an analog pad. On a D-Pad controller this stays false and the row is + * committed with another face button instead. */ static int ape_right_stick_is_active(void) { return ape_axis_is_active(APE_RIGHT_STICK_X_ADDRESS, APE_RIGHT_STICK_DEADZONE) || @@ -82,9 +118,8 @@ static int ape_right_stick_is_active(void) { APE_RIGHT_STICK_DEADZONE); } - static int ape_is_entering_idle(void) { - return ape_read_half(APE_PLAYER_IDLE_COUNTER_ADDRESS) >= + return psx_mod_read_half(APE_PLAYER_IDLE_COUNTER_ADDRESS) >= APE_IDLE_COUNTER_TRIGGER; } @@ -127,6 +162,31 @@ static void ape_write_slot(uint8_t slot, uint8_t gadget) { psx_mod_write_byte(APE_GADGET_SLOT_BASE + (uint32_t)slot, gadget); } +/* Record the guest state this plugin has just finished writing. */ +static void ape_record_expected_state(void) { + uint8_t slot; + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) + g_expected_slots[slot] = ape_read_slot(slot); + g_expected_held_gadget = psx_mod_read_byte(APE_HELD_GADGET_ADDRESS); + g_expected_valid = 1u; +} + +static void ape_forget_expected_state(void) { + g_expected_valid = 0u; +} + +/* False once guest RAM stops matching what this plugin left there. */ +static int ape_state_is_continuous(void) { + uint8_t slot; + if (!g_expected_valid) + return 1; + for (slot = 0u; slot < APE_FACE_SLOT_COUNT; ++slot) { + if (ape_read_slot(slot) != g_expected_slots[slot]) + return 0; + } + return psx_mod_read_byte(APE_HELD_GADGET_ADDRESS) == g_expected_held_gadget; +} + static int ape_slot_value_is_valid(uint8_t gadget) { return gadget < APE_GADGET_COUNT || gadget == APE_INVALID_GADGET; } @@ -204,6 +264,15 @@ static void ape_close_quick_select_state(void) { g_quick_selected_gadget = APE_INVALID_GADGET; } +/* Drop an open row without writing anything to the guest. Used when the world + * changed underneath the snapshot and restoring it would be corruption. */ +static void ape_abandon_quick_select(void) { + ape_discard_snapshot(); + ape_close_quick_select_state(); + ape_forget_expected_state(); + g_last_face_button = 0u; +} + static void ape_apply_snapshot_swap(uint8_t gadget); static void ape_commit_quick_select(uint8_t replacement_face_button) { @@ -221,6 +290,8 @@ static void ape_commit_quick_select(uint8_t replacement_face_button) { ape_discard_snapshot(); ape_close_quick_select_state(); + /* The committed layout is now the game's own; stop tracking it. */ + ape_forget_expected_state(); } static void ape_cancel_quick_select(uint8_t replacement_face_button) { @@ -245,16 +316,15 @@ static void ape_cancel_quick_select(uint8_t replacement_face_button) { ape_discard_snapshot(); ape_close_quick_select_state(); + ape_forget_expected_state(); } static void ape_reset_quick_select_state(uint8_t pressed_face_buttons, int restore_snapshot) { if (restore_snapshot) ape_cancel_quick_select(0u); - else { - ape_discard_snapshot(); - ape_close_quick_select_state(); - } + else + ape_abandon_quick_select(); g_previous_face_buttons = pressed_face_buttons; g_last_face_button = 0u; } @@ -318,9 +388,8 @@ static void ape_gpu_native_gadget_icon(uint8_t gadget, int x, int y) { APE_GADGET_ICON_TABLE_ADDRESS + (uint32_t)gadget * 4u; const uint8_t u = psx_mod_read_byte(descriptor); const uint8_t v = psx_mod_read_byte(descriptor + 1u); - const uint16_t clut = ape_read_half(descriptor + 2u); + const uint16_t clut = psx_mod_read_half(descriptor + 2u); - psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0xE100021Eu); psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0x65000000u); psx_mod_write_word(PSX_GPU_GP0_ADDRESS, ((uint32_t)(uint16_t)y << 16u) | @@ -338,6 +407,13 @@ static void ape_draw_quick_select_row(uint8_t unlocked, uint8_t selected) { const uint32_t normal_border = ape_gpu_color(78u, 84u, 96u); const uint32_t selected_border = ape_gpu_color(255u, 226u, 90u); const uint32_t icon_background = ape_gpu_color(24u, 27u, 35u); + /* Per-side widescreen reveal, zero at 4:3: keep the row against the real + * right edge of the visible frame rather than the 4:3 one. */ + const int32_t reveal = psx_mod_widescreen_x_margin(); + const int row_right = APE_ROW_RIGHT_4_3 + (int)reveal; + const int row_left_limit = APE_ROW_MARGIN - (int)reveal; + uint32_t saved_gpustat; + uint32_t saved_texpage; uint8_t count = 0u; uint8_t gadget; int total_width; @@ -352,9 +428,28 @@ static void ape_draw_quick_select_row(uint8_t unlocked, uint8_t selected) { total_width = (int)count * APE_ICON_SIZE + ((int)count - 1) * APE_TILE_GAP; - x = APE_ROW_RIGHT - total_width; - if (x < 4) - x = 4; + x = row_right - total_width; + if (x < row_left_limit) + x = row_left_limit; + + /* + * GPU state. The drawing offset, drawing area and mask settings are + * deliberately left as the game set them: this runs on the guest VBlank + * tick, before the game's own VBlank handler flips, so inheriting the + * offset is what places the row in the buffer that is about to become + * visible. Setting our own would put it in the wrong half of VRAM. + * + * The texture page and texture window are different - they are ours to + * set, because a leftover texture window would wrap the icon UVs. GPUSTAT + * bits 0..10 mirror the texture page, so it can be put back; the texture + * window has no readback and is left cleared. That is safe here because + * the game re-establishes both from its own ordering table at the top of + * every frame, and nothing else draws between this row and that. + */ + saved_gpustat = psx_mod_read_word(PSX_GPU_GPUSTAT_ADDRESS); + saved_texpage = saved_gpustat & 0x7FFu; + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0xE2000000u); + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0xE1000000u | APE_ICON_TEXPAGE); ape_gpu_rect(x - 3, APE_ROW_Y - 3, total_width + 6, APE_ICON_SIZE + 6, row_background); @@ -378,6 +473,8 @@ static void ape_draw_quick_select_row(uint8_t unlocked, uint8_t selected) { x += APE_TILE_STEP; } + + psx_mod_write_word(PSX_GPU_GP0_ADDRESS, 0xE1000000u | saved_texpage); } static void ape_apply_snapshot_swap(uint8_t gadget) { @@ -398,6 +495,7 @@ static void ape_apply_snapshot_swap(uint8_t gadget) { ape_write_slot(selected_slot, gadget); psx_mod_write_byte(APE_HELD_GADGET_ADDRESS, gadget); + ape_record_expected_state(); } static void ape_preview_gadget(uint8_t gadget) { @@ -419,6 +517,7 @@ static void ape_open_quick_select(uint8_t face_button, uint8_t gadget) { g_quick_select_active = 1u; g_quick_face_button = face_button; g_quick_selected_gadget = gadget; + ape_record_expected_state(); } static int ape_cancel_for_idle(void) { @@ -439,6 +538,16 @@ static void ape_quick_gadget_select_vblank(void) { return; } + /* Before anything reads or writes gadget state: if guest RAM no longer + * matches what this plugin last wrote, a save state, a rewind or the game + * itself replaced it. The snapshot describes a timeline that no longer + * exists, so drop it silently instead of writing it back. */ + if (g_quick_select_active && !ape_state_is_continuous()) { + ape_abandon_quick_select(); + g_previous_face_buttons = ape_pressed_face_buttons(); + return; + } + pressed_face_buttons = ape_pressed_face_buttons(); if (!ape_is_playable_transition( psx_mod_read_byte(APE_TRANSITION_PHASE_ADDRESS)) || @@ -501,16 +610,7 @@ static void ape_quick_gadget_select_vblank(void) { } } -static void ape_quick_gadget_select_activation(void) { - psx_mod_write_code_word( - APE_SLINGSHOT_FACE_AMMO_BLOCK_ADDRESS, - APE_DISABLE_SLINGSHOT_FACE_AMMO_INSTRUCTION); -} - PSX_MOD_CONSTRUCTOR(ape_register_quick_gadget_select_plugin) { - (void)psx_mod_register_activation_plugin( - "ape.gadgets.quick-select-patches", - ape_quick_gadget_select_activation); (void)psx_mod_register_vblank_plugin( "ape.gadgets.quick-select", ape_quick_gadget_select_vblank); } diff --git a/tests/test_preloaded_mods.cpp b/tests/test_preloaded_mods.cpp index d161e0a..6041f8f 100644 --- a/tests/test_preloaded_mods.cpp +++ b/tests/test_preloaded_mods.cpp @@ -1,4 +1,5 @@ #include "mod_packages.h" + #include #include #include @@ -22,12 +23,14 @@ void no_op_plugin() {} int main(int argc, char** argv) { if (argc != 2) return fail("expected the preloaded mods root"); + const fs::path source(argv[1]); const fs::path root = fs::temp_directory_path() / "apeescape-preloaded-mods-test"; std::error_code ec; fs::remove_all(root, ec); fs::copy(source, root, fs::copy_options::recursive); + size_t manifest_count = 0; for (const fs::directory_entry& entry : fs::recursive_directory_iterator(root / "packages")) { @@ -44,6 +47,7 @@ int main(int argc, char** argv) { } } if (manifest_count != 4) return fail("expected four package manifests"); + PSXRecompV4::mod_clear_plugins_for_tests(); for (const char* id : { "ape.widescreen.16-9", @@ -55,17 +59,18 @@ int main(int argc, char** argv) { "ape.framerate.165", "ape.framerate.uncapped", "ape.fmv.skip", - "ape.gadgets.quick-select", - "ape.gadgets.quick-select-patches"}) { + "ape.gadgets.quick-select"}) { if (!PSXRecompV4::mod_register_activation_plugin(id, no_op_plugin)) return fail(std::string("could not register test plugin ") + id); } + PSXRecompV4::ModPackageManager manager(root); std::string error; if (!manager.scan(&error)) return fail("catalog scan failed: " + error); if (!manager.load_state(&error)) return fail("default state failed: " + error); if (manager.packages().size() != 4) return fail("expected four package families"); + const auto default_plan = manager.resolve(kGameId, "", kDiscSha256); if (!default_plan.ok || !default_plan.writes.empty() || default_plan.plugins.size() != 1 || @@ -76,6 +81,7 @@ int main(int argc, char** argv) { "ape.enhancement.skip-fmvs", "skip-fmvs", false, &error)) { return fail(error); } + if (!manager.set_feature_enabled( "ape.enhancement.widescreen", "widescreen", true, &error)) { return fail(error); @@ -95,6 +101,7 @@ int main(int argc, char** argv) { return fail(std::string("wrong widescreen plugin for ") + choice); } } + if (!manager.set_feature_enabled( "ape.enhancement.widescreen", "widescreen", false, &error) || !manager.set_feature_enabled( @@ -127,24 +134,47 @@ int main(int argc, char** argv) { true, &error)) { return fail(error); } + + /* + * Quick Gadget Select is one vblank plugin plus one main_exe instruction + * patch. That patch has to be a declarative write rather than a + * psx_mod_write_code_word() from an activation callback: activation runs + * before the guest boots, so the game's own EXE load would overwrite it. + * Asserting the write is planned is what tells the two apart -- a + * plugin-count assertion passes either way while the patch does nothing. + */ const auto quick_select_plan = manager.resolve(kGameId, "", kDiscSha256); - if (!quick_select_plan.ok || !quick_select_plan.writes.empty() || - quick_select_plan.plugins.size() != 2) { - return fail("wrong Quick Gadget Select plugin count"); + if (!quick_select_plan.ok || quick_select_plan.plugins.size() != 1 || + quick_select_plan.plugins.front().id != "ape.gadgets.quick-select") { + return fail("wrong Quick Gadget Select plugin plan"); + } + if (quick_select_plan.writes.size() != 1) + return fail("Quick Gadget Select did not plan its guest-code patch"); + { + const auto& write = quick_select_plan.writes.front(); + const std::vector expected{0xD2, 0xC2, 0x43, 0x80}; + const std::vector replacement{0x7C, 0x8F, 0x01, 0x08}; + if (write.target != PSXRecompV4::ModPatchTarget::MainExe || + write.location != 0x80063B6Cull || write.expected != expected || + write.replacement != replacement) { + return fail("wrong Quick Gadget Select guest-code patch"); + } } - bool found_vblank = false; - bool found_patch = false; - for (const auto& plugin : quick_select_plan.plugins) { - found_vblank |= plugin.id == "ape.gadgets.quick-select"; - found_patch |= plugin.id == "ape.gadgets.quick-select-patches"; + + if (!manager.set_feature_enabled( + "ape.enhancement.quick-gadget-select", "quick-gadget-select", + false, &error)) { + return fail(error); } - if (!found_vblank || !found_patch) - return fail("wrong Quick Gadget Select plan"); + const auto disabled_plan = manager.resolve(kGameId, "", kDiscSha256); + if (!disabled_plan.ok || !disabled_plan.writes.empty()) + return fail("Quick Gadget Select patched guest code while disabled"); + fs::remove_all(root, ec); std::cout << "Ape Escape preloaded mods: 4 packages, " "3 widescreen choices, 5 interpolated frame-rate choices, " - "Quick Gadget Select default-off, " "motion-adaptive clarity blend, Skip FMVs migrated from Settings, " - "slingshot face-button ammo cycle disabled by opt-in activation patch\n"; + "Quick Gadget Select default-off with a declarative " + "slingshot-block patch, stock guest code untouched by default\n"; return 0; } From 89236976f40eb6a4db437f3a7ab63650caf4b4f3 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 22 Aug 2026 15:27:02 -0700 Subject: [PATCH 3/5] Quick Gadget Select: never read stick axes off a digital pad Measured on a running guest, at the title screen and inside the attract demo's playable phase: the pad shadow reads 41 5A FF FF 00 00. The 0x41 is the controller reply id for a DIGITAL pad, which carries one halfword of buttons and no axes at all -- so the two bytes this code was reading as right X / right Y are outside the reply and read 0x00. 0x00 is not neutral. ape_axis_is_active() measures against the 0x80 centre, so 0x00 is a full deflection and the right stick reported as permanently active. Any player whose pad enumerates as digital would have had the row commit on the very frame it opened, making the feature unusable rather than merely losing the stick shortcut. The previous commit's comment claimed the opposite -- that the check "stays false" on a D-Pad controller -- which was backwards. Check the reply id first and fail closed: no DualShock, no axis read. Verified: 41 5A FF FF 00 00 read live over the debug server at 0x800B87A0 on this machine with no gamepad attached, sampled at the title screen and while transition phase was 0x03. The 0x80-centred analog path is NOT verified -- it needs a real DualShock, which this machine does not have. Co-Authored-By: Claude Opus 5 (1M context) --- .../1.0.0/manifest.toml | 2 +- src/mods/ape_quick_gadget_select_plugin.c | 28 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml b/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml index 3890db9..16c6e12 100644 --- a/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml +++ b/mods/preloaded/packages/ape.enhancement.quick-gadget-select/1.0.0/manifest.toml @@ -14,7 +14,7 @@ disc_sha256 = "1ae17e78ebb8c782c7c1785b0a0bd7b0ee28235b8a0c83c8df887129899a852a" [[feature]] id = "quick-gadget-select" name = "Quick Gadget Select (Experimental)" -description = "Press the equipped face button again to open the native-icon gadget row. Right-stick use or another face-button press commits. The right-stick commit needs an analog pad; on a D-Pad controller commit with another face button." +description = "Press the equipped face button again to open the native-icon gadget row. Right-stick use or another face-button press commits. The right-stick commit needs a DualShock; on a digital pad commit with another face button." group = "Gameplay" default_enabled = false diff --git a/src/mods/ape_quick_gadget_select_plugin.c b/src/mods/ape_quick_gadget_select_plugin.c index 3aef7d8..0df1b0f 100644 --- a/src/mods/ape_quick_gadget_select_plugin.c +++ b/src/mods/ape_quick_gadget_select_plugin.c @@ -21,8 +21,13 @@ * APE_HELD_GADGET_ADDRESS is confirmed by the stock slingshot block that the * manifest patch rewrites: `lui v0,0x800F` / `lb v1,-0x3D2E(v0)`. */ static const uint32_t APE_TRANSITION_PHASE_ADDRESS = 0x800F447Cu; -/* Game-side pad shadow. Byte order follows the DualShock report: the two - * digital button bytes, then right X, then right Y. */ +/* Game-side pad shadow. It starts at the controller's reply header, so the + * layout is: id, 0x5A, the two digital button bytes, then -- and only when the + * id says DualShock -- right X, right Y, left X, left Y. Measured live at the + * title screen and in the attract demo with no pad attached: 41 5A FF FF 00 00, + * i.e. a DIGITAL pad, where the axis bytes are not part of the report at all. + * That is why the axes must never be read without checking the id first. */ +static const uint32_t APE_PAD_ID_ADDRESS = 0x800B87A0u; static const uint32_t APE_PAD_LOW_INPUT_ADDRESS = 0x800B87A2u; static const uint32_t APE_FACE_INPUT_ADDRESS = 0x800B87A3u; static const uint32_t APE_RIGHT_STICK_X_ADDRESS = 0x800B87A4u; @@ -46,6 +51,9 @@ enum { APE_PAD_START = 0x08u, + /* Controller reply id: 0x41 digital, 0x73 DualShock. */ + APE_PAD_ID_ANALOG = 0x73u, + APE_FACE_TRIANGLE = 0x10u, APE_FACE_CIRCLE = 0x20u, APE_FACE_CROSS = 0x40u, @@ -109,9 +117,21 @@ static int ape_axis_is_active(uint32_t address, int deadzone) { return value < -deadzone || value > deadzone; } -/* Needs an analog pad. On a D-Pad controller this stays false and the row is - * committed with another face button instead. */ +/* + * Only a DualShock report carries stick axes. On a digital pad the axis bytes + * are outside the reply and read 0x00, which is a full deflection away from the + * 0x80 centre -- so reading them unguarded reports the stick as permanently + * active, and the row would commit on the very frame it opened. Check the id + * first and fail closed: without an analog pad the row is committed with + * another face button instead. + */ +static int ape_pad_is_analog(void) { + return psx_mod_read_byte(APE_PAD_ID_ADDRESS) == APE_PAD_ID_ANALOG; +} + static int ape_right_stick_is_active(void) { + if (!ape_pad_is_analog()) + return 0; return ape_axis_is_active(APE_RIGHT_STICK_X_ADDRESS, APE_RIGHT_STICK_DEADZONE) || ape_axis_is_active(APE_RIGHT_STICK_Y_ADDRESS, From d2d5f0c6b3101a0ae8951606f390288fe54db739 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 22 Aug 2026 17:04:20 -0700 Subject: [PATCH 4/5] Quick Gadget Select: right-align the row to the real picture width The row was pinned to x=316, i.e. 320 minus a 4px margin. Ape Escape does not run at 320. Measured over the debug server on a live guest, it moves through three display modes -- 256x240 early in boot, 640x480, then 384x240 for gameplay -- so in game the row stopped 68 pixels short of the right edge, with visible dead space beside it. A hardcoded 384 would be no better: it would be wrong in the other two modes. Take the width from psx_mod_display_width() at draw time instead, and skip the row entirely while the geometry reports zero rather than drawing it somewhere guessed. In the gameplay mode the right edge is now 380 rather than 316. Requires the psx_mod_display_width() addition to psxrecomp; the width the presenter uses depends on the GP1(06h) horizontal range, which is write-only and unreachable from a plugin, so this cannot be derived locally. Co-Authored-By: Claude Opus 5 (1M context) --- src/mods/ape_quick_gadget_select_plugin.c | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/mods/ape_quick_gadget_select_plugin.c b/src/mods/ape_quick_gadget_select_plugin.c index 0df1b0f..f1efb8f 100644 --- a/src/mods/ape_quick_gadget_select_plugin.c +++ b/src/mods/ape_quick_gadget_select_plugin.c @@ -67,9 +67,9 @@ enum { APE_RIGHT_STICK_DEADZONE = 28, APE_IDLE_COUNTER_TRIGGER = 901u, - /* Right edge of the row in the game's own draw coordinates at 4:3; the - * widescreen reveal is added at draw time. */ - APE_ROW_RIGHT_4_3 = 316, + /* Gap kept between the row and the edges of the picture. The right edge + * itself comes from psx_mod_display_width() at draw time: Ape Escape + * scans out 384, not the 320 a fixed constant would assume. */ APE_ROW_MARGIN = 4, APE_ROW_Y = 5, APE_ICON_SIZE = 32, @@ -427,11 +427,17 @@ static void ape_draw_quick_select_row(uint8_t unlocked, uint8_t selected) { const uint32_t normal_border = ape_gpu_color(78u, 84u, 96u); const uint32_t selected_border = ape_gpu_color(255u, 226u, 90u); const uint32_t icon_background = ape_gpu_color(24u, 27u, 35u); - /* Per-side widescreen reveal, zero at 4:3: keep the row against the real - * right edge of the visible frame rather than the 4:3 one. */ + /* + * Where the picture actually ends. The width is whatever the guest is + * scanning out right now -- Ape Escape uses 368-mode and scans out 384, + * so anything derived from an assumed 320 puts the row well short of the + * edge. The widescreen reveal, zero at 4:3, extends the picture by that + * much on each side on top of it. + */ + const uint32_t display_width = psx_mod_display_width(); const int32_t reveal = psx_mod_widescreen_x_margin(); - const int row_right = APE_ROW_RIGHT_4_3 + (int)reveal; - const int row_left_limit = APE_ROW_MARGIN - (int)reveal; + int row_right; + int row_left_limit; uint32_t saved_gpustat; uint32_t saved_texpage; uint8_t count = 0u; @@ -445,6 +451,12 @@ static void ape_draw_quick_select_row(uint8_t unlocked, uint8_t selected) { } if (count == 0u) return; + /* No geometry yet: wait rather than draw the row in a guessed place. */ + if (display_width == 0u) + return; + + row_right = (int)display_width + (int)reveal - APE_ROW_MARGIN; + row_left_limit = APE_ROW_MARGIN - (int)reveal; total_width = (int)count * APE_ICON_SIZE + ((int)count - 1) * APE_TILE_GAP; From 7e559c4f48cd3b13c7bbff6c96f29d01570bbaa3 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 22 Aug 2026 17:58:21 -0700 Subject: [PATCH 5/5] submodules: pin psxrecomp to feat/mod-display-width Temporary pin onto the branch, not master. Quick Gadget Select needs psx_mod_display_width() to right-align its row to the picture the guest is actually scanning out, and that API does not exist in any released framework commit yet. Ape Escape moves through 256x240, 640x480 and 384x240, so no constant is correct in every mode; the width has to be asked for at draw time. The visible width depends on the GP1(06h) horizontal range, which is write-only, so a plugin cannot derive it and the framework has to hand it over. Repoint this at master once mstan/psxrecomp feat/mod-display-width lands. Co-Authored-By: Claude Opus 5 (1M context) --- psxrecomp-v4 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psxrecomp-v4 b/psxrecomp-v4 index ea1e34d..d500554 160000 --- a/psxrecomp-v4 +++ b/psxrecomp-v4 @@ -1 +1 @@ -Subproject commit ea1e34dce5b5dd40f4b40e139d0ee27c4b898725 +Subproject commit d500554bd860d423f163fb85fc1a38821ecd0fee