Skip to content

Fix "Disable obsidian fist pump animation" qol patch#1

Open
MichaelJSr wants to merge 3 commits into
JTCPP:mainfrom
MichaelJSr:fix/pickup_animation_qol
Open

Fix "Disable obsidian fist pump animation" qol patch#1
MichaelJSr wants to merge 3 commits into
JTCPP:mainfrom
MichaelJSr:fix/pickup_animation_qol

Conversation

@MichaelJSr
Copy link
Copy Markdown

@MichaelJSr MichaelJSr commented Apr 12, 2026

First off, thanks so much for creating this awesome project! This is honestly such cool and exciting work, and I see a ton of potential for this project. Modding Azurik will now be way more manageable now.

I have been testing the waters and trying to wrap my head around these tools and this code, and thus for my first contribution I aimed to do something fairly simple. I noticed that the qol patch for disabling the pickup animation for picking up obsidians was not working properly, so I decided to fix it using Ghidra and Xemu debugging.

The previous patch targeted the wrong code. Through runtime debugging (LLDB + xemu GDB server), I traced the full pickup collection flow:

  1. The pickup handler at VA 0x41390 checks bit 0x10000000 in [this+0x168]. Gems have this bit set (no animation); obsidians, keys, powers, and disc fragments have it clear.
  2. For non-gem pickups, a block executes that: (A) calls FUN_00061360 to set the "collected" flag and register in the save list, (B) calls FUN_0006FC90 via vtable to decrement the pickup counter, (C) cleans up a linked list at [this+0x1EC], and (D) updates a collection counter.
  3. Steps C and D keep the pickup entity's animation data live. The entity's per-frame update (FUN_00037950 -> FUN_00037AB0) reads this data to play anim 0x52 (the celebration) every frame until the entity is despawned.
    An earlier attempt (JNZ->JMP at 0x413A2) skipped the entire block, which disabled animations but also skipped steps A+B, breaking save persistence -- collected pickups would respawn on level/save reload.

The fix replaces the first instruction of step C (file 0x0313EE, VA 0x0413EE) with JMP 0x4146F, skipping C+D while preserving A+B. Both null-check JZ branches already target 0x413EE, so they naturally hit the JMP with no additional changes needed. This disables the celebration animation for all non-gem pickups while keeping save persistence intact.

Testing done:

Ran xemu with the "-s" flag to enable the gdb server, then opened a gdb (lldb on Mac) session in order to set a breakpoint for the specific pickup function for obsidians to analyze exactly what was happening.

Proof that picking up obsidians will now not play an animation: https://github.com/user-attachments/assets/a7175e90-9864-4a5a-9b50-9d43b70b8549

Picking up other things like powers and disk fragments will also not play animations: https://github.com/user-attachments/assets/b237678c-2661-4a17-9e26-916a05540698

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).
Indicated that the fist pump animation qol now disables the animation for all pickups (not just obsidians)
@MichaelJSr
Copy link
Copy Markdown
Author

If you accept this pull request, I think pull requests should be squashed into main, so not to bloat the commit log. I will try to keep contributions self contained with a feature per branch so that squashed PR's create 1 nice commit with that feature self contained.

@AzurikPerathia
Copy link
Copy Markdown
Collaborator

As for me, it seems correct! I’m waiting for confirmation from @JTCPP ;)

@AzurikPerathia AzurikPerathia added enhancement New feature or request Verification wanted Need the verification labels Apr 12, 2026
…ations

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.
MichaelJSr added a commit to MichaelJSr/Elemental_Games_Modding that referenced this pull request Apr 18, 2026
The AdreniumLogo call at 0x05F6E5 lives inside FUN_0005f620, a boot-
time state machine.  Case 1 reads play_movie_fn's AL return value to
decide whether to enter the movie-polling state (AL != 0) or skip
straight to the next movie (AL == 0):

  0x05f6df: PUSH EBP            ; EBP = 0 (scratch zero); arg #2
  0x05f6e0: PUSH 0x0019e150     ; &"AdreniumLogo.bik"; arg JTCPP#1
  0x05f6e5: CALL play_movie_fn  ; __stdcall — callee pops 8 B
  0x05f6ea: NEG AL
            SBB EAX, EAX
            ADD EAX, 3          ; state = 2 (poll) or 3 (skip)
            MOV [state], EAX

The previous version of this patch NOP'd the whole 10-byte PUSH+CALL
pair.  That leaked 4 bytes of stack (PUSH EBP's value was never
popped) and left AL holding garbage from a prior function — so the
state machine drifted into case 2 and polled a movie that never
started, hanging the boot on a black screen.

Fix: narrow the trampoline site to the 5-byte CALL at 0x05F6E5.  The
two caller PUSHes now run as normal, and the shim:

    __attribute__((naked))
    void c_skip_logo(void) {
        __asm__("xorb %al, %al\n\tret $8");  // 30 C0 C2 08 00
    }

is a precise __stdcall replacement — returns AL=0 (state = 3, skip to
prophecy) and pops both args via `ret 8`.

The legacy AZURIK_SKIP_LOGO_LEGACY=1 path was broken in the same way
and is fixed simultaneously: SKIP_LOGO_SPEC's 10-byte patch now writes
`ADD ESP, 4; XOR AL, AL; NOP x5` instead of ten NOPs, popping the 4-byte
leftover from PUSH EBP and clearing AL.

Tests updated:
  - tests/test_qol_skip_logo.py pins VA=0x05F6E5, 5-byte replaced_bytes,
    corrected legacy patch bytes, and asserts PUSH 0x19E150 stays live
    after apply on the real XBE.
  - tests/test_trampoline_patch.py pins the new 5-byte shim
    (30 C0 C2 08 00) and the untouched NEG AL at 0x05F6EA.

verify-patches --strict: clean (10 bytes differ, all declared).
Full regression: 94 passed.

Docs: PATCHES.md updated with the full state-machine analysis and
before/after disassembly; CHANGELOG documents the fix.

Made-with: Cursor
MichaelJSr added a commit to MichaelJSr/Elemental_Games_Modding that referenced this pull request Apr 19, 2026
Builds the MCP test infrastructure we were missing, then uses it
to ship tool #4 from the roadmap.  The harness is the key
unblocker — with it in place, any future Ghidra-aware tool
(shim scaffolder, etc.) can be unit-tested against a synthetic
Ghidra instead of requiring a live one.

## Why we need a harness at all

The upstream MCP bridge (``Scripts/bridge_mcp_hydra.py``) wraps
an HTTP REST API that Ghidra's GhydraMCP plugin serves on ports
8192-8201.  The MCP layer is great for interactive agent work
but unsuitable for CI:

  - Pulls in ``mcp``, ``fastmcp``, stdio transports, JSON-RPC
    framing — ~30 transitive deps
  - Every call requires a live Ghidra instance (we can't spawn
    one in a hermetic test)
  - No way to fake failure modes (error codes, malformed
    responses) from tests

This commit adds two pieces that together solve the problem:

1. **`GhidraClient`** (new): zero-dep HTTP client using stdlib
   ``urllib``.  Same endpoint contract as the MCP bridge, but
   no MCP dependency.  Handles auth-origin headers + parses the
   structured error envelope Ghidra returns.  Typed responses
   (``GhidraFunction``, ``GhidraLabel``, ``GhidraProgramInfo``),
   typed errors (``GhidraClientError`` with ``status_code`` +
   ``code`` + ``message``).

2. **`MockGhidraServer`** (new): threaded in-process HTTP server
   that re-implements just enough of the plugin's contract to
   exercise our client.  Bound to an ephemeral port so multiple
   tests can run in parallel.  Records every request in
   ``request_log`` so tests can assert on API call sequences.
   Zero external deps — tests run on CI without Ghidra or Java
   installed.

## Tool #4 — `azurik-mod ghidra-sync`

Takes every named VA we track in Python (azurik.h anchors +
vanilla_symbols.py + patch-site registry) and writes them back
into the open Ghidra project via ``PATCH /functions/{addr}``
(rename / set signature) + ``POST /memory/{addr}/comments/plate``.

Planning pass (``plan_sync``) classifies each VA as:
  - ``rename``  — function still has a ``FUN_*`` name
  - ``keep``    — function is already named correctly
  - ``comment`` — VA isn't a function (data / BSS) but we have
                  a useful plate comment to leave

Apply pass (``apply_sync``) is opt-in.  Refuses to overwrite
user-meaningful names (anything not matching ``FUN_*`` / ``LAB_*``
/ ``DAT_*``) unless ``--force`` is passed.

Live dry-run against Ghidra's default.xbe instance produces:
  - 9 function renames (play_movie_fn, gravity_integrate_raw,
    entity_lookup, config_name_lookup, dev_menu_flag_check, ...)
  - 33 plate comments (patch-site annotations + data anchors)
  - 0 keeps (fresh project with no prior sync)

## Bonus — `ghidra-coverage --live`

The same HTTP client plugs into ``build_coverage_report``.
``--live --port 8193`` pulls a fresh function list out of
Ghidra in ~3 s, removing the need to maintain a snapshot JSON
for the common "I'm working right now with Ghidra open"
workflow.

Label iteration is OFF by default in live mode (45k symbols in
the Azurik project take ~30 s to paginate and add no coverage
info the function list doesn't already give).  Opt in via the
``include_labels=True`` Python API.

## CLI

Three new subcommands / flags:

```bash
azurik-mod ghidra-coverage --live --port 8193
azurik-mod ghidra-sync                # dry-run plan
azurik-mod ghidra-sync --apply        # actually modify
azurik-mod ghidra-sync --apply --force --port 8193
```

## Tests

tests/test_ghidra_harness.py (new, 20 cases):

  MockServerClientRoundTrip (10):
    ping / program_info / get_function / missing-function 404 /
    rename / set_signature / set_comment / bad-kind rejection /
    iter_labels / iter_functions pagination + request-log check

  MockServerErrorPaths (1):
    unknown-endpoint envelope surfaces as typed exception

  GhidraSyncPlanning (4):
    plan classifies rename/keep correctly, apply mutates the
    mock, apply skips protected names without --force, --force
    overwrites them

  GhidraCoverageLiveClient (2):
    unlabeled-known detection works against a live client, a
    meaningful name is NOT flagged

  LiveGhidraReadOnly (3):
    skips when nothing answers on :8193; otherwise pings +
    fetches program_info + (if default.xbe) fetches a known
    function

Full suite: 528 passed, 1 skipped in 96 s (was 508 passed).

## Docs

docs/TOOLING_ROADMAP.md — tool #4 moves from planned → shipped
with the full design rationale + CLI examples.

azurik_mod/xbe_tools/__init__.py now re-exports the client +
mock types so consumers can ``from azurik_mod.xbe_tools import
GhidraClient``.

## Remaining roadmap items

Tool #6 (shim scaffolder with ABI picker) is still planned.
With the harness now in place, it's straightforward — fetch the
hook function's signature via ``GhidraClient.get_function(va)``,
translate to a C prototype, drop into a template.  Left as
future work; no blocker.

All other Tier-2 items shipped:

  JTCPP#1 xbe swiss-army     ✅
  #2 ghidra-coverage    ✅ (now with --live)
  #3 shim-inspect       ✅
  #4 ghidra-sync        ✅ (this commit)
  #5 plan-trampoline    ✅
  #6 shim scaffolder    planned
  #7 xbr inspect        ✅
  #8 entity diff        ✅
  #9 test-for-va        ✅
  #10 pin_va_*          ✅

Made-with: Cursor
MichaelJSr added a commit to MichaelJSr/Elemental_Games_Modding that referenced this pull request Apr 19, 2026
…k NOP + dev-menu v4 trampoline + unclamped slider entry

Four user-reported issues fixed in one pass.

## JTCPP#1. GUI text-box unclamped past slider bounds

ParametricSlider previously clamped typed values to
[slider_min, slider_max] on commit.  Power users who wanted
walk_speed_scale=25 couldn't — the value got silently snapped to
10.  The text-box is now unclamped: any finite float commits
verbatim, the slider thumb rests at whichever bound is closer,
and a [!] badge in the header indicates the exact value is outside
the slider's visual range.  get_value() returns the exact typed
value.  Slider drags still operate inside the declared range.

## #2. New jump_speed_scale slider

Reverse-engineered the main jump initialiser FUN_00089060 (plays
fx/sound/player/jump).  The jump velocity scalar lives directly
in the player entity at +0x140, written by five
`MOV [reg+0x140], 0x41100000` instructions at VAs 0x84ECD /
0x856CE / 0x890E4 / 0x89120 / 0x8D31C (main ground jump + 4
alternate airborne-state entry paths).  The new slider rewrites
the 4-byte IEEE-754 imm32 at each site with 9.0 * jump_scale.
No shared constants touched; no shim needed.

- JUMP_SPEED_SCALE ParametricPatch added to PLAYER_PHYSICS_SITES.
- CLI: --player-jump-scale (full build), --jump-speed (physics-
  only) accept any float.
- GUI Patches page renders a new "Player jump height" slider
  (range 0.1-5.0, default 1.0, text-box unclamped).
- Dynamic whitelist auto-whitelists the 5 imm32 sites.

## #3. Roll slider now fires on sustained WHITE-button hold

Confirmed the 3x boost at VA 0x849E4 IS player-specific but its
activation flag is gated by either RIGHT_THUMB (click) or WHITE
(one-frame tap, then edge-locked).  Result: roll_scale was
effectively invisible during sustained play because the engine
set the flag for only a single frame per WHITE tap.  Fix:
additionally NOP the 2-byte JNZ +8 at VA 0x00085200 whenever
roll_scale != 1.0, removing the WHITE edge-lock so holding
WHITE gives sustained 3x magnitude boost every frame.  The
byte patch was always correct — this change makes it observable
in gameplay.

## #4. enable_dev_menu rewritten (v4) to trampoline FUN_00053750

v1-v3 post-mortem: all patched upstream branches inside
dev_menu_flag_check, but the main New-Game → cutscene → first-
level flow goes through FUN_00055AB0 which calls FUN_00053750
DIRECTLY with a hardcoded level name (e.g. "levels/water/w1"
after prophecy), bypassing dev_menu_flag_check entirely.  That's
why v1-v3 looked like "nothing happens" even with bytes verified.

v4 hooks the universal entry.  At VA 0x00053750, install a 7-byte
trampoline (5-byte JMP rel32 + 2 NOPs) that jumps to a 27-byte
shim placed via _carve_shim_landing.  The shim:

1. Guards: CMP [ESP+0x10], 0 + JNZ — if param_4 != 0 (bink movie
   path), skip the override so cutscenes still play.
2. Overwrites param_2 (level-name pointer at [ESP+8]) with the VA
   of "levels/selector" at 0x001A1E3C.
3. Replays the clobbered MOV EAX, [ESP+4] + MOV ECX, [EAX+0x40]
   so EAX/ECX match vanilla at the return point.
4. JMPs back to SUB ESP, 0x824 at VA 0x00053757.

Every level transition — New Game, Load Save, cutscene-end,
developer-console loadlevel — now routes to levels/selector
regardless of the upstream caller.  Movies (bink:) keep playing.

## Docs

- docs/LEARNINGS.md: three new sections.
  - "WHITE-button sustained roll": documents the edge-lock
    mechanism at VA 0x00085200 and the NOP fix.
  - "Jump velocity is an imm32 at 5 call sites": documents the
    5-site imm32 scan approach to finding physics parameters.
  - "v4: trampoline FUN_00053750 directly": post-mortem of why
    v1-v3 didn't work, + the trampoline-based stack-arg
    rewriting pattern.
- CHANGELOG.md: full entry for all four issues.
- azurik_mod/patches/enable_dev_menu/__init__.py + README.md:
  fully rewritten for the v4 trampoline design.
- azurik_mod/patches/player_physics/__init__.py: module
  docstring extended for jump + roll edge-lock NOP behaviour.

## Tests (744 passed / 1 skipped, +7 new)

- ApplyJumpSpeedBehaviour (6 tests): the 5 imm32 sites, scale
  multipliers, site-isolation guards, apply_player_physics
  routing.
- test_roll_scale_nops_white_edge_lock: pins the edge-lock NOP
  (roll=1.0 leaves JNZ intact; roll=2.0 NOPs it).
- DynamicWhitelistFromXbe updated: vanilla yields 9-11 ranges
  (3 instr + 1 edge-lock + 5 jump + 0-2 shared-const abs32
  follows); patched yields 17 ranges.
- EnableDevMenuFeature rewritten (8 tests): hook VA, vanilla
  prologue drift guard, selector-string VA drift guard, JMP
  installation, shim layout byte-by-byte decode, section-
  landing, idempotency, dynamic whitelist shape.

Made-with: Cursor
MichaelJSr added a commit to MichaelJSr/Elemental_Games_Modding that referenced this pull request Apr 19, 2026
…ysics diagnostic

Three user-reported "still doesn't work" issues traced to root
cause.  In every case, v1/v2 landed bytes correctly on disk but
targeted the wrong runtime semantics.

## JTCPP#1. Jump: v1 patched the WRONG physics field

v1 rewrote 5 ``MOV [reg+0x140], 0x41100000`` imm32 sites, but
entity+0x140 is the HORIZONTAL AIR-CONTROL speed, NOT the jump
height.  Used by FUN_00089480 (airborne physics) as a per-frame
mid-air steering multiplier.  Patching it scales air-control,
not jump height — hence users saw no vertical effect.

The ACTUAL jump formula is at VA 0x89160 inside FUN_00089060:

    FLD  [0x001980A8]        ; g = 9.8 (gravity)
    FMUL [ESI + 0x144]        ; × h (jump height)
    FADD ST0, ST0             ; × 2
    FSQRT                     ; v0 = sqrt(2gh)

v2 rewrites the FLD at VA 0x89160 to load from an injected
9.8*jump_scale^2 constant instead of the shared gravity global.
The SQRT then produces jump_scale * vanilla_v0 — linear scaling
on initial velocity, quadratic on peak height.

Single 6-byte FLD rewrite + 4-byte injected float.  Shared
gravity constant untouched (gravity slider owns it).

## #2. Roll: force-always-on so xemu users without WHITE/R3 see effect

Byte patches were always applying correctly, but bit 0x40 of the
input-state flags only gets set on WHITE tap or RIGHT_THUMB
(R3) click — and many xemu input configs don't route those
buttons.  Users never saw roll_scale take effect.

Added 2 × 2-byte force-always-on patches (VA 0x85214: AND AL,
0x40 -> MOV AL, 0x40; VA 0x8521C: XOR DL, AL -> OR DL, AL)
that make bit 0x40 unconditionally set every frame, so the roll
FMUL fires on every movement regardless of controller input.

Simplified inject_roll_mult from ``3 * roll_scale / walk_scale``
to just ``roll_scale`` (the 3× factor was vanilla's WHITE boost
baseline — with force-always-on that meaning no longer applies).
Semantics are now cleanly compounding:

    velocity = 7 * walk_scale * roll_scale * raw_stick * direction

Both sliders at 1.0 short-circuit to identity.  roll=1 no
longer installs any roll-related bytes (was wastefully writing
no-op FMUL × 1.0 before).

## #3. Dev menu: trampoline bytes verified correct

Re-inspected the v4 trampoline against the built XBE.  Hook at
VA 0x53750 installs JMP to SHIMS section at VA 0x39F000;
SHIMS has flags=0x06 (EXECUTABLE | PRELOADED) so the Xbox
loader maps it executable.  Bytes land correctly.

If users still see no effect in-game, they're almost certainly
not running the patched ISO.  Added a diagnostic CLI so they
can verify.

## New CLI: azurik-mod inspect-physics

Read-only diagnostic that reports — per slider — whether the
bytes are VANILLA / PATCHED (with injected float values) or
DRIFTED.  Also dumps the roll edge-lock + force-on state and
the enable_dev_menu trampoline.  Run this FIRST when a patch
seems inert:

    $ azurik-mod inspect-physics --iso Azurik_patched.iso
    Player physics sliders:
      gravity        [VANILLA]  value = 9.8000 m/s^2
      walk           [PATCHED]  inject VA 0x1001D0 = 14.0000
      roll (FMUL)    [PATCHED]  inject VA 0x1001D4 = 3.0000
      swim           [PATCHED]  inject VA 0x1001D8 = 15.0000
      jump (FLD)     [PATCHED]  inject VA 0x1001DC = 39.2000
    Roll auxiliary patches:
      edge-lock     [NOPED]   bytes = 9090 (VA 0x85200)
      force-on JTCPP#1   [PATCHED] bytes = b040 (VA 0x85214)
      force-on #2   [PATCHED] bytes = 0ad0 (VA 0x8521C)
    enable_dev_menu trampoline:
      [INSTALLED] hook bytes = e9abb834009090 -> JMP to VA
                  0x39F000 (section 'SHIMS')

## Files

- azurik_mod/patches/player_physics/__init__.py: rewrite
  apply_jump_speed to target VA 0x89160 FLD; add force-always-
  on patches; simplify inject_roll_mult; make walk/roll site
  patching independently opt-in when their slider != 1.0;
  update module docstring with the corrected jump physics.
- azurik_mod/randomizer/commands.py: new cmd_inspect_physics.
- azurik_mod/cli.py: register inspect-physics subcommand +
  update --jump-speed help text.
- tests/test_player_speed.py: rewrite ApplyJumpSpeedBehaviour
  for the new FLD-site target; replace IndependenceSemantics
  with SliderSemantics reflecting the compounding semantics;
  add RollForceAlwaysOn pinning the 2-byte force-on patches;
  update DynamicWhitelistFromXbe for new site counts.
- tests/test_player_physics.py: expose JUMP_SPEED_SCALE (no
  logic change).
- docs/LEARNINGS.md: "Jump velocity: the v2 correction" section
  explaining the +0x140 vs +0x144 distinction + the FLD-retarget
  approach; keep the v1 section as historical record.
- CHANGELOG.md: full entry.

748 tests passed (+4), 0 linter errors.

Made-with: Cursor
MichaelJSr added a commit to MichaelJSr/Elemental_Games_Modding that referenced this pull request Apr 19, 2026
Two new patches, bringing the pack to 7 sliders total: gravity +
walk + roll + swim + jump + air-control + wing-flap.

## JTCPP#1. Horizontal air-control speed slider

entity+0x140 stores a per-frame mid-air horizontal steering
scalar that FUN_00089480 consumes every airborne frame as
`local_16c = entity[+0x140] * magnitude`.  Vanilla 9.0.  Written
by 5 `MOV DWORD [reg+0x140], 0x41100000` imm32 instructions at
VAs 0x84ED3, 0x856D4, 0x890EA, 0x89126, 0x8D322.

apply_air_control_speed rewrites each imm32 to 9.0 *
air_control_scale.  These are the 5 sites the pre-v2 jump patch
mistakenly targeted — they DO meaningfully affect movement, but
HORIZONTALLY not vertically.  Jump HEIGHT remains owned by
apply_jump_speed (which targets the FLD [0x001980A8] in the
sqrt(2gh) formula at VA 0x89160).

## #2. Wing-flap (Air-power double-jump) height slider

FUN_00089480's airborne physics adds 8.0 to velocity.z when
BOTH the flap button (input flag 0x04) AND the roll flag
(0x40, which apply_player_speed's force-always-on enables) are
set.  The FADD at VA 0x000896EA reads from the shared 0x001A25C0
(which has 4 non-player readers).

apply_flap_height rewrites the FADD to FADD [inject_va] where
inject_va holds 8.0 * flap_scale, via the shim-landing
infrastructure.  Shared 0x001A25C0 left untouched.

## User-facing changes

- GUI Patches page: two new sliders (range 0.1-10.0, default
  1.0, text-box unclamped like the others).
- CLI: --player-air-control-scale and --player-flap-scale on
  randomize-full; --air-control-speed and --flap-height on
  apply-physics.
- apply_player_physics gains air_control_scale and flap_scale
  kwargs.
- azurik-mod inspect-physics reports both new patches.

## Files

- azurik_mod/patches/player_physics/__init__.py: new constants
  (_AIR_CONTROL_SITE_VAS, _VANILLA_AIR_CONTROL,
  _AIR_CONTROL_IMM32_VANILLA, _FLAP_SITE_VA,
  _FLAP_SITE_VANILLA, _VANILLA_FLAP_IMPULSE); new
  ParametricPatches (AIR_CONTROL_SCALE, FLAP_HEIGHT_SCALE);
  new apply functions apply_air_control_speed and
  apply_flap_height; dispatcher + registry + dynamic whitelist
  + docstring updates.
- azurik_mod/patches/__init__.py: re-export new symbols.
- azurik_mod/randomizer/commands.py: cmd_randomize_full +
  cmd_apply_physics + cmd_inspect_physics gain the new
  kwargs and reporting.
- azurik_mod/cli.py: --player-air-control-scale,
  --player-flap-scale, --air-control-speed, --flap-height
  flags with full help text.
- gui/backend.py: forward air_control_scale + flap_height_scale
  from pack_params to the CLI args.
- tests/test_player_speed.py: new ApplyAirControlBehaviour (5
  tests) + ApplyFlapHeightBehaviour (6 tests); extended
  DynamicWhitelistFromXbe for new site counts.
- tests/test_player_physics.py: expose AIR_CONTROL_SCALE and
  FLAP_HEIGHT_SCALE.
- tests/test_categories.py: updated slider keys + count.
- docs/LEARNINGS.md: "Airborne horizontal-control speed" and
  "Wing-flap (double-jump) vertical impulse" sections with
  the FUN_00089060/FUN_00089480 decode.
- CHANGELOG.md: full entry.

759 passed (+11), 0 linter errors.

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Verification wanted Need the verification

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants