From 215d7e2750e0cf50d4ae04c75b5d4b7d9c1f698f Mon Sep 17 00:00:00 2001 From: MichaelJSr Date: Sun, 12 Apr 2026 08:40:41 -0700 Subject: [PATCH 1/3] fix: disable pickup celebration animation via correct branch patch Patch the conditional JNZ at VA 0x413A2 in the pickup handler (file 0x313A2) to an unconditional JMP, forcing all pickups to take the "gem path" that skips the stop-pose-delay celebration animation. Found via runtime debugging (LLDB + xemu GDB server): the pickup virtual function at VA 0x41390 checks bit 0x10000000 in [this+0x168] to decide whether to celebrate. Gems have this bit set (no animation); obsidians and other collectibles have it clear (animation plays). --- .gitignore | 1 + tools/randomizer/azurik_mod.py | 47 +++++++++++++--------------------- 2 files changed, 19 insertions(+), 29 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/tools/randomizer/azurik_mod.py b/tools/randomizer/azurik_mod.py index 5d0d158..46ceda6 100644 --- a/tools/randomizer/azurik_mod.py +++ b/tools/randomizer/azurik_mod.py @@ -912,17 +912,16 @@ def cmd_randomize_gems(args): # XBE QoL patch offsets # Gem popup string file offsets (null first byte to disable) GEM_POPUP_OFFSETS = [0x197858, 0x19783C, 0x197820, 0x197800, 0x1977D8] -# Obsidian fist pump animation: patch 6 bytes at file offset 0x0489C3 -OBSIDIAN_ANIM_OFFSET = 0x0489C3 -OBSIDIAN_ANIM_ORIGINAL = bytes([0x8B, 0x86, 0xD8, 0x01, 0x00, 0x00]) -OBSIDIAN_ANIM_PATCH = bytes([0xEB, 0x1C, 0x90, 0x90, 0x90, 0x90]) - -# Per-pickup fist pump animation: player state machine at 0x2B0F2 -# State 0x1E checks absorbed entity type (+0x148); if non-zero, plays animation 0x52 -# Patch the conditional JE to unconditional JMP to always skip the animation -FIST_PUMP_OFFSET = 0x02B0F2 -FIST_PUMP_ORIGINAL = bytes([0x74, 0x0C]) # JE +12 (skip if zero) -FIST_PUMP_PATCH = bytes([0xEB, 0x0C]) # JMP +12 (always skip) +# Pickup celebration animation patch (confirmed via runtime debugging). +# +# The pickup handler at VA 0x41390 (resolved from vtable[0xB8]) checks +# bit 0x10000000 in [this+0x168] to decide whether to play the celebration. +# Gems have this bit SET (no animation); obsidians have it CLEAR (animation). +# Replace the conditional JNZ with an unconditional JMP so all pickups +# take the "gem path" -- skip animation, go straight to despawn/cooldown. +PICKUP_ANIM_OFFSET = 0x0313A2 +PICKUP_ANIM_ORIGINAL = bytes([0x0F, 0x85, 0xCB, 0x00, 0x00, 0x00]) # JNZ +0xCB +PICKUP_ANIM_PATCH = bytes([0xE9, 0xCC, 0x00, 0x00, 0x00, 0x90]) # JMP +0xCC; NOP # Player character swap: replace "garret4" with another character model # At file offset 0x1976C8, "garret4\0d:\" = 12 bytes, can fit any name up to 11 chars @@ -1958,25 +1957,15 @@ def cmd_randomize_full(args): xbe_data[off] = 0x00 print(f" Disabled 5 gem first-pickup popups") - # Disable obsidian fist pump animation - if OBSIDIAN_ANIM_OFFSET + 6 <= len(xbe_data): - current = bytes(xbe_data[OBSIDIAN_ANIM_OFFSET:OBSIDIAN_ANIM_OFFSET + 6]) - if current == OBSIDIAN_ANIM_ORIGINAL: - xbe_data[OBSIDIAN_ANIM_OFFSET:OBSIDIAN_ANIM_OFFSET + 6] = OBSIDIAN_ANIM_PATCH - print(f" Disabled obsidian first-pickup notification") + # Disable pickup celebration animation (JNZ -> JMP in pickup handler) + if PICKUP_ANIM_OFFSET + 6 <= len(xbe_data): + current = bytes(xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + 6]) + if current == PICKUP_ANIM_ORIGINAL: + xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + 6] = PICKUP_ANIM_PATCH + print(f" Disabled pickup celebration animation") else: - print(f" WARNING: XBE bytes at 0x{OBSIDIAN_ANIM_OFFSET:X} don't match expected " - f"(got {current.hex()}, expected {OBSIDIAN_ANIM_ORIGINAL.hex()})") - - # Disable per-pickup fist pump animation - if FIST_PUMP_OFFSET + 2 <= len(xbe_data): - current = bytes(xbe_data[FIST_PUMP_OFFSET:FIST_PUMP_OFFSET + 2]) - if current == FIST_PUMP_ORIGINAL: - xbe_data[FIST_PUMP_OFFSET:FIST_PUMP_OFFSET + 2] = FIST_PUMP_PATCH - print(f" Disabled per-pickup fist pump animation") - else: - print(f" WARNING: XBE bytes at 0x{FIST_PUMP_OFFSET:X} don't match expected " - f"(got {current.hex()}, expected {FIST_PUMP_ORIGINAL.hex()})") + print(f" WARNING: XBE bytes at 0x{PICKUP_ANIM_OFFSET:X} don't match expected " + f"(got {current.hex()}, expected {PICKUP_ANIM_ORIGINAL.hex()})") # Player character swap (experimental) player_char = getattr(args, 'player_character', None) From dc9781873a751fd6523e4029b6ff380ed4b3b5c9 Mon Sep 17 00:00:00 2001 From: MichaelJSr Date: Sun, 12 Apr 2026 08:46:40 -0700 Subject: [PATCH 2/3] updated comments and descriptions Indicated that the fist pump animation qol now disables the animation for all pickups (not just obsidians) --- tools/randomizer/CHANGELOG.md | 6 +++--- tools/randomizer/README.md | 2 +- tools/randomizer/azurik_gui/tab_qol.py | 6 +++--- tools/randomizer/azurik_gui/tab_randomizer.py | 2 +- tools/randomizer/azurik_mod.py | 16 ++++++---------- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/tools/randomizer/CHANGELOG.md b/tools/randomizer/CHANGELOG.md index eef523f..d7d2ae6 100644 --- a/tools/randomizer/CHANGELOG.md +++ b/tools/randomizer/CHANGELOG.md @@ -32,8 +32,8 @@ - **Auto-integration** — Entity editor edits automatically included when building randomized ISO #### QoL Patches -- **Fist pump animation disabled** — Found and patched the real animation trigger in the player state machine (0x2B0F2), not just the one-time notification -- **Three QoL patches now**: gem first-pickup popups (5), obsidian first-pickup notification, per-pickup fist pump animation +- **All pickup celebration animations disabled** — Patched the pickup handler branch at VA 0x413A2 (JNZ->JMP) to always skip the stop-pose-delay animation, matching the gem code path +- **Two QoL patches now**: gem first-pickup popups (5), pickup celebration animations #### GUI Improvements - Warning labels on Keys, Barriers, and Connections checkboxes ("may cause unsolvable seeds") @@ -64,5 +64,5 @@ - Major items, keys, gems, barriers randomization - Seed-based reproducibility - GUI with category checkboxes -- QoL patches: gem popups, obsidian animation +- QoL patches: gem popups, pickup animations - CLI and GUI interfaces diff --git a/tools/randomizer/README.md b/tools/randomizer/README.md index ba08ee9..de4efc7 100644 --- a/tools/randomizer/README.md +++ b/tools/randomizer/README.md @@ -9,7 +9,7 @@ A full-game randomizer for **Azurik: Rise of Perathia** (Xbox, 2001). Shuffles c - **Gems**: Diamond, emerald, sapphire, ruby distribution randomized per-level (custom weights supported) - **Barriers**: Element vulnerability randomized - **Custom Item Pool**: Choose exactly how many of each item type to include -- **QoL Patches**: Disables gem first-pickup popups and obsidian fist-pump animation +- **QoL Patches**: Disables gem first-pickup popups and all pickup celebration animations - **Seed-based**: Reproducible results — share a seed to share a run - **GUI and CLI**: Graphical interface or command-line for advanced users diff --git a/tools/randomizer/azurik_gui/tab_qol.py b/tools/randomizer/azurik_gui/tab_qol.py index 836ffe8..48c4567 100644 --- a/tools/randomizer/azurik_gui/tab_qol.py +++ b/tools/randomizer/azurik_gui/tab_qol.py @@ -15,9 +15,9 @@ "included_in_randomizer": True, }, { - "key": "disable_obsidian_anim", - "label": "Disable obsidian pickup animation", - "description": "Skips the fist pump animation and voice line when collecting obsidian gems.", + "key": "disable_pickup_anims", + "label": "Disable pickup celebration animations", + "description": "Skips the stop-pose-delay celebration animation for all collectible pickups.", "default": True, "included_in_randomizer": True, }, diff --git a/tools/randomizer/azurik_gui/tab_randomizer.py b/tools/randomizer/azurik_gui/tab_randomizer.py index 7e379d9..c811947 100644 --- a/tools/randomizer/azurik_gui/tab_randomizer.py +++ b/tools/randomizer/azurik_gui/tab_randomizer.py @@ -132,7 +132,7 @@ def _build(self): ("do_gems", "Gems (diamond/emerald/sapphire/ruby per-level)", True), ("do_barriers", "Barriers (element vulnerability)", True), ("do_connections", "Level Connections (randomize exits between levels)", False), - ("do_qol", "QoL Patches (disable popups, obsidian animation)", True), + ("do_qol", "QoL Patches (disable popups, pickup animations)", True), ] for key, label, default in categories: var = tk.BooleanVar(value=default) diff --git a/tools/randomizer/azurik_mod.py b/tools/randomizer/azurik_mod.py index 46ceda6..7fc3053 100644 --- a/tools/randomizer/azurik_mod.py +++ b/tools/randomizer/azurik_mod.py @@ -912,13 +912,9 @@ def cmd_randomize_gems(args): # XBE QoL patch offsets # Gem popup string file offsets (null first byte to disable) GEM_POPUP_OFFSETS = [0x197858, 0x19783C, 0x197820, 0x197800, 0x1977D8] -# Pickup celebration animation patch (confirmed via runtime debugging). -# -# The pickup handler at VA 0x41390 (resolved from vtable[0xB8]) checks -# bit 0x10000000 in [this+0x168] to decide whether to play the celebration. -# Gems have this bit SET (no animation); obsidians have it CLEAR (animation). -# Replace the conditional JNZ with an unconditional JMP so all pickups -# take the "gem path" -- skip animation, go straight to despawn/cooldown. +# Disable all pickup celebration animations (file 0x0313A2, VA 0x0413A2). +# Forces the pickup handler to always skip the stop-pose-delay animation +# by replacing a conditional JNZ with an unconditional JMP. PICKUP_ANIM_OFFSET = 0x0313A2 PICKUP_ANIM_ORIGINAL = bytes([0x0F, 0x85, 0xCB, 0x00, 0x00, 0x00]) # JNZ +0xCB PICKUP_ANIM_PATCH = bytes([0xE9, 0xCC, 0x00, 0x00, 0x00, 0x90]) # JMP +0xCC; NOP @@ -1957,7 +1953,7 @@ def cmd_randomize_full(args): xbe_data[off] = 0x00 print(f" Disabled 5 gem first-pickup popups") - # Disable pickup celebration animation (JNZ -> JMP in pickup handler) + # Disable all pickup celebration animations if PICKUP_ANIM_OFFSET + 6 <= len(xbe_data): current = bytes(xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + 6]) if current == PICKUP_ANIM_ORIGINAL: @@ -2183,7 +2179,7 @@ def main(): " 3. Gems: diamond/emerald/sapphire/ruby shuffled per-level\n" " 4. Barriers: element vulnerability randomized per-level\n" "\n" - "Also applies QoL patches: disable gem popups + obsidian animation.\n" + "Also applies QoL patches: disable gem popups + pickup animations.\n" "Use --no-major, --no-keys, --no-gems, --no-barriers, --no-qol to skip." )) p_full.add_argument("--iso", required=True, help="Original game .iso") @@ -2203,7 +2199,7 @@ def main(): p_full.add_argument("--no-connections", action="store_true", help="Skip level connection randomization") p_full.add_argument("--no-qol", action="store_true", - help="Skip QoL patches (gem popups, obsidian animation)") + help="Skip QoL patches (gem popups, pickup animations)") p_full.add_argument("--obsidian-cost", type=int, metavar="N", help="Obsidian cost per temple lock (default: 10 = locks at 10,20,...100)") p_full.add_argument("--item-pool", From a71e3e55ef68d2f3c739ecf1744f4de3d74c9328 Mon Sep 17 00:00:00 2001 From: MichaelJSr Date: Mon, 13 Apr 2026 01:58:38 -0700 Subject: [PATCH 3/3] fix: preserve save persistence when disabling pickup celebration animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous patch (JNZ→JMP at VA 0x413A2) skipped the entire pickup handler block, which disabled animations but also skipped FUN_00061360 (collected flag) and FUN_0006FC90 (pickup counter decrement), breaking save persistence — collected pickups would respawn on level reload. Root cause found via LLDB + xemu GDB server: the celebration animation (anim 0x52) is driven by the pickup entity's own per-frame update (FUN_00037950 → FUN_00037AB0 → FUN_00042910), reading animation data from the entity's linked-list and counter fields at [this+0x1EC] and [[[this+0x154]+0xD8]+4]. New patch at file 0x0313EE (VA 0x0413EE) replaces the first instruction of the linked-list cleanup with JMP 0x4146F, skipping steps C (cleanup) and D (counter update) that keep the animation data live, while preserving steps A (FUN_00061360, collected flag) and B (FUN_0006FC90, pickup counter) needed for save persistence. Both null-check JZ branches (0x413CA, 0x413D1) already target 0x413EE, so they naturally hit the JMP with no additional code changes. --- tools/randomizer/CHANGELOG.md | 2 +- tools/randomizer/azurik_gui/tab_qol.py | 2 +- tools/randomizer/azurik_mod.py | 30 +++++++++++++++++--------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tools/randomizer/CHANGELOG.md b/tools/randomizer/CHANGELOG.md index d7d2ae6..5c09b0f 100644 --- a/tools/randomizer/CHANGELOG.md +++ b/tools/randomizer/CHANGELOG.md @@ -32,7 +32,7 @@ - **Auto-integration** — Entity editor edits automatically included when building randomized ISO #### QoL Patches -- **All pickup celebration animations disabled** — Patched the pickup handler branch at VA 0x413A2 (JNZ->JMP) to always skip the stop-pose-delay animation, matching the gem code path +- **All pickup celebration animations disabled** — JMP at VA 0x413EE skips the linked-list cleanup and counter update that keep the celebration animation data live, while FUN_00061360 (collected flag) and FUN_0006FC90 (pickup counter) still run for save persistence - **Two QoL patches now**: gem first-pickup popups (5), pickup celebration animations #### GUI Improvements diff --git a/tools/randomizer/azurik_gui/tab_qol.py b/tools/randomizer/azurik_gui/tab_qol.py index 48c4567..081c429 100644 --- a/tools/randomizer/azurik_gui/tab_qol.py +++ b/tools/randomizer/azurik_gui/tab_qol.py @@ -17,7 +17,7 @@ { "key": "disable_pickup_anims", "label": "Disable pickup celebration animations", - "description": "Skips the stop-pose-delay celebration animation for all collectible pickups.", + "description": "Skips the celebration animation for all collectible pickups (obsidians, keys, powers, disc fragments) without affecting save persistence.", "default": True, "included_in_randomizer": True, }, diff --git a/tools/randomizer/azurik_mod.py b/tools/randomizer/azurik_mod.py index 7fc3053..a058b4d 100644 --- a/tools/randomizer/azurik_mod.py +++ b/tools/randomizer/azurik_mod.py @@ -912,12 +912,21 @@ def cmd_randomize_gems(args): # XBE QoL patch offsets # Gem popup string file offsets (null first byte to disable) GEM_POPUP_OFFSETS = [0x197858, 0x19783C, 0x197820, 0x197800, 0x1977D8] -# Disable all pickup celebration animations (file 0x0313A2, VA 0x0413A2). -# Forces the pickup handler to always skip the stop-pose-delay animation -# by replacing a conditional JNZ with an unconditional JMP. -PICKUP_ANIM_OFFSET = 0x0313A2 -PICKUP_ANIM_ORIGINAL = bytes([0x0F, 0x85, 0xCB, 0x00, 0x00, 0x00]) # JNZ +0xCB -PICKUP_ANIM_PATCH = bytes([0xE9, 0xCC, 0x00, 0x00, 0x00, 0x90]) # JMP +0xCC; NOP +# Disable all pickup celebration animations (file 0x0313EE, VA 0x0413EE). +# The pickup handler FUN_00041390 enters a block for non-gem pickups that: +# (A) FUN_00061360 — sets "collected" flag, adds to save list +# (B) virtual call — FUN_0006FC90, decrements pickup counter (persistence) +# (C) linked-list cleanup — zeroes [this+0x1EC/0x1F0] +# (D) counter update — writes [[[this+0x154]+0xD8]+4] +# Steps C and D keep the pickup entity's animation data live, which its +# per-frame update (FUN_00037950 → FUN_00037AB0) reads to play anim 0x52 +# (the celebration). We replace the first instruction of step C with a JMP +# to the epilog (0x4146F), skipping C+D. Steps A+B still run for persistence. +# Both null-check JZ branches (0x413CA, 0x413D1) already target 0x413EE, +# so they naturally hit our JMP — no other code changes needed. +PICKUP_ANIM_OFFSET = 0x0313EE +PICKUP_ANIM_ORIGINAL = bytes([0x8B, 0x8A, 0xEC, 0x01, 0x00]) # MOV ECX,[EDX+0x1EC] (5 of 6 bytes) +PICKUP_ANIM_PATCH = bytes([0xE9, 0x7C, 0x00, 0x00, 0x00]) # JMP 0x4146F (epilog) # Player character swap: replace "garret4" with another character model # At file offset 0x1976C8, "garret4\0d:\" = 12 bytes, can fit any name up to 11 chars @@ -1953,11 +1962,12 @@ def cmd_randomize_full(args): xbe_data[off] = 0x00 print(f" Disabled 5 gem first-pickup popups") - # Disable all pickup celebration animations - if PICKUP_ANIM_OFFSET + 6 <= len(xbe_data): - current = bytes(xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + 6]) + # Disable all pickup celebration animations (skip linked-list cleanup + counter update) + patch_len = len(PICKUP_ANIM_ORIGINAL) + if PICKUP_ANIM_OFFSET + patch_len <= len(xbe_data): + current = bytes(xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + patch_len]) if current == PICKUP_ANIM_ORIGINAL: - xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + 6] = PICKUP_ANIM_PATCH + xbe_data[PICKUP_ANIM_OFFSET:PICKUP_ANIM_OFFSET + patch_len] = PICKUP_ANIM_PATCH print(f" Disabled pickup celebration animation") else: print(f" WARNING: XBE bytes at 0x{PICKUP_ANIM_OFFSET:X} don't match expected "