From f35413077cbec789b64c26bc7d5c358e4e1778f9 Mon Sep 17 00:00:00 2001 From: Mateusz Kaflowski Date: Sat, 26 Sep 2026 15:50:55 +0200 Subject: [PATCH 1/4] Add experimental BOTW 1.9 FPS and graphics profiles --- README.md | 2 +- docs/BOTW-1.9.md | 216 ++++++++++++++++++ src/PatchInfo/Breath Of The Wild/FPS-1.9.json | 36 +++ .../Breath Of The Wild/NativeFPS-1.9.json | 25 ++ .../Breath Of The Wild/NativeFPS-1.9.s | 83 +++++++ .../Breath Of The Wild/Options-1.9.json | 14 ++ .../Options-Native-1.9.json | 67 ++++++ .../Breath Of The Wild/PatchInfo.json | 39 +++- .../Breath Of The Wild/Presets-1.9.json | 5 + .../Presets-Native-1.9.json | 6 + src/modules/GameManager/BotwNativePatch.py | 107 +++++++++ src/modules/GameManager/ExefsPatch.py | 164 +++++++++++++ src/modules/GameManager/FileManager.py | 49 +++- src/modules/GameManager/GameManager.py | 29 ++- src/modules/GameManager/PatchInfo.py | 10 +- src/modules/config.py | 12 +- src/modules/qt_config.py | 2 +- tests/test_botw_native_patch.py | 90 ++++++++ tests/test_exefs_patch.py | 124 ++++++++++ tests/test_patch_profiles.py | 89 ++++++++ tools/build_botw_native_payload.py | 38 +++ tools/export_botw_fps.py | 42 ++++ tools/verify_botw_fps.py | 135 +++++++++++ tools/verify_botw_native.py | 212 +++++++++++++++++ 24 files changed, 1567 insertions(+), 29 deletions(-) create mode 100644 docs/BOTW-1.9.md create mode 100644 src/PatchInfo/Breath Of The Wild/FPS-1.9.json create mode 100644 src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.json create mode 100644 src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.s create mode 100644 src/PatchInfo/Breath Of The Wild/Options-1.9.json create mode 100644 src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json create mode 100644 src/PatchInfo/Breath Of The Wild/Presets-1.9.json create mode 100644 src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json create mode 100644 src/modules/GameManager/BotwNativePatch.py create mode 100644 src/modules/GameManager/ExefsPatch.py create mode 100644 tests/test_botw_native_patch.py create mode 100644 tests/test_exefs_patch.py create mode 100644 tests/test_patch_profiles.py create mode 100644 tools/build_botw_native_payload.py create mode 100644 tools/export_botw_fps.py create mode 100644 tools/verify_botw_fps.py create mode 100644 tools/verify_botw_native.py diff --git a/README.md b/README.md index a12e4f3..95c1a75 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ ### **📃Supported Games - Full Game Information 📃** - [Tears Of The Kingdom](https://www.nxoptimizer.com/games/tears-of-the-kingdom) Versions - 1.0.0 (PARTIAL), 1.1.0, 1.1.1, 1.1.2, 1.2.0, 1.2.1, 1.4.0 (PARTIAL), 1.4.1 (PARTIAL), 1.4.2 - [Echoes Of Wisdom](https://www.nxoptimizer.com/games/echoes-of-wisdom) Versions - 1.0.1, 1.0.2 -- [Breath Of The Wild](https://www.nxoptimizer.com/games/breath-of-the-wild) Versions - 1.6 +- [Breath Of The Wild](https://www.nxoptimizer.com/games/breath-of-the-wild) Versions - 1.6 (UltraCam), [1.9.0 experimental configurable FPS and graphics](docs/BOTW-1.9.md) - [Pokemon Scarlet/Violet](https://www.nxoptimizer.com/games/pokemon-scarlet-violet) Versions - Latest - [Xenoblade Chronicles X](https://www.nxoptimizer.com/games/xenoblade-chronicles-x) Versions - 1.0.1, 1.0.2 diff --git a/docs/BOTW-1.9.md b/docs/BOTW-1.9.md new file mode 100644 index 0000000..cddb421 --- /dev/null +++ b/docs/BOTW-1.9.md @@ -0,0 +1,216 @@ +# BOTW 1.9.0: configurable FPS and graphics + +The experimental BOTW 1.9.0 profiles provide a **20-120 FPS limiter with measured frame +time**, world-camera **FOV and far clip**, and emulator graphics controls for +the Switch version of BOTW 1.9.0. The user confirmed **45 FPS with normal game +speed and a visibly wider FOV** in Eden nightly `5f142c7926`. + +The earlier minimal fixed-60-FPS profile is also available and was confirmed +at 60 FPS. Both are separate from UltraCam 1.6.0. Broader physics, menu, cutscene +and other framerate testing remains pending; these profiles are experimental. + +## Using NX Optimizer + +Run from source (`python run.py` in `src`, with the dependencies from +`src/dependencies.py` installed), or use a build that includes the BOTW 1.9.0 +profiles listed below. + +In **Select Game**, choose: + +- **Breath of The Wild**: existing UltraCam for **1.6.0**. +- **BOTW 1.9.0 - 60 FPS (Experimental)**: minimal fixed 60 FPS. +- **BOTW 1.9.0 - FPS + Graphics (Experimental)**: configurable FPS and graphics. + +Choose **Extract** to export a standalone mod to `Extracted Files`, or **Apply** +to install it to the selected emulator. The 1.9 profile exports **IPSwitch +(`.pchtxt`) for Eden/Legacy**, or IPS32 (`.ips`) for Ryujinx, together with +instructions. There is no UltraCam executable or INI. Its selection/settings +are saved separately from the 1.6 profile. + +Keep emulator speed at **100%**. Enable only one FPS mod. When using 1.9.0, +disable the old `!!!BOTW Optimizer` / UltraCam mod (Apply requests this through +the emulator's existing mod configuration). Check this manually when installing +an exported folder or if your emulator uses a different mod manager. + +### Configurable profile + +| Setting | Range / behavior | +| --- | --- | +| FPS limit | Integer 20-120, including 45; clock-based limiter with measured simulation time | +| World camera FOV | 20-120; 50 preserves original behavior; other values scale the freshly calculated world-camera angle relative to 50 | +| Far clip distance | 1000-25000; 25000 preserves original behavior; lower values override the world-camera far plane | +| Resolution scale (emulator) | Keep current, 1x, 2x, 3x, 4x | +| Anti-aliasing (emulator) | Keep current, Off, FXAA, SMAA | +| Anisotropic filtering (emulator) | Keep current, Automatic, 2x, 4x, 8x, 16x | + +The configurable mod is named `!!!BOTW 1.9.0 Optimizer` and needs **both its +main and SDK patch files**. Disable the minimal `!!!BOTW 1.9.0 60 FPS` mod and +UltraCam when using it. Apply manages these known conflicts; manual installation +requires selecting the enabled mod in the emulator. + +FOV scaling preserves relative aiming/cutscene zoom, with resulting angles +clamped to 5-120 degrees. Far clip is a camera clipping plane, not object +streaming or LOD distance. The native framebuffer resolution, native FXAA/DR +disabling, shadows, free camera and other UltraCam features are not ported. + +The three settings marked **emulator** affect its per-game configuration through +**Apply**. **Extract** writes instructions containing the requested settings but +does not change emulator configuration. Keep current is the default. Resolution +scale multiplies the game's output; AA Off disables only the emulator's added +AA, not the game's own FXAA. Current Eden's resolution indices are mapped +explicitly, including its 1/4x and 1.25x entries. + +### Minimal fixed-60 profile + +This older profile has no injected limiter or graphics options. It targets +sustained 60 FPS and may slow the game down below the target. Use the configurable +profile for other FPS targets or measured frame-time correction. + +## Export without GUI dependencies + +Python 3.10+ and extracted ExeFS files are sufficient; no game keys are needed +by this exporter. For the minimal fixed-60 profile: + +```powershell +python tools/export_botw_fps.py --main "C:\path\to\exefs\main" --output "C:\path\to\mods" +``` + +For the configurable profile, including the settings used in the user's test: + +```powershell +python tools/export_botw_fps.py --main "C:\path\to\exefs\main" --output "C:\path\to\mods" --fps 45 --fov 65 --far-clip 5000 +``` + +Configurable mode also verifies the sibling **`sdk`** executable. If it is in +another folder, supply `--sdk "C:\path\to\sdk"`. Both files must match the known +Build IDs and hashes before the exporter writes anything. Without graphics +arguments, the configurable defaults are original FOV/far clip (50/25000). + +The default format is `pchtxt`; use `--format ips` for Ryujinx. The exporter +verifies the Build ID and SHA-256 before exporting. The graphical profile +uses a known Build ID in the IPSwitch header / IPS filename; the emulator chooses whether it matches +the loaded executable. The GUI does not infer the installed version from an NSP. + +Supported executable: + +- Title ID: `01007EF00011E000` +- Version: `1.9.0` +- Build ID: `CD57B23FA4BBAD65803D9788C01821EE00000000000000000000000000000000` +- ExeFS main SHA-256: `f6f0b20bb1f2b67d164c24871cd473147eef1d285f12e7bd492e3126c6022e27` +- SDK Build ID: `574284B40C06CF1821A44857939083BEE5716820000000000000000000000000` +- SDK SHA-256: `1d187f29784cd0af46e9e123fc2a62c77b3b07c5a39c8633d90d6c80de64e4ad` + +Other builds, including Switch 2 executables, are not covered by this patch. +Disable/remove the exported mod to restore original behavior. + +## Fix for the initial export staying at 30 FPS + +The initial IPS32-only export did not take effect in the tested Eden nightly, +despite its log saying `Applying IPS patch`. A read-only inspection of the +running game's memory confirmed that all four target instructions were still +original. Switching the same four writes to **IPSwitch** produced 60 FPS in the +user's subsequent test. This was a format/loading issue, not a change in patch +addresses or an additional frame limiter. + +Version `0.1.1-experimental` therefore defaults to IPSwitch for Eden. Re-exporting +to the same folder removes the previous `.ips` only if its contents exactly +match this generator's patch; unrelated or user-edited patches are preserved. +Restart emulation after updating the files. + +## Minimal fixed-60 implementation + +The patch uses the **native frame timer and presentation interval**, rather than +porting the closed-source UltraCam binary. Both `setInterval` entry points are +patched so that interval **1** is passed to both the timer and presentation code. + +| NSO memory offset | Original bytes | New bytes | +| --- | --- | --- | +| `0x011269CC` | `F303012A` (`mov w19, w1`) | `21008052` (`mov w1, #1`) | +| `0x011269D8` | `E103132A` (`mov w1, w19`) | `21008052` | +| `0x011269FC` | `F303012A` | `21008052` | +| `0x01126A08` | `E103132A` | `21008052` | + +The manifest is `src/PatchInfo/Breath Of The Wild/FPS-1.9.json`. +IPSwitch uses memory offsets with `@flag offset_shift 0x100`; IPS32 file offsets +include the same NSO header (`memory offset + 0x100`). No original +game executables or keys are included in the repository or generated mod. + +Relevant 1.9 code locations: + +- Framework/interface entry points: `0x011269B8`, `0x011269E8`. +- Native timer setter: `0x01126C70` (accepts interval 1, unlike the old 1.6 clamp). +- Presentation wrapper: `0x00E9AF20`. +- VFR update: `0x0151D3D0`, corresponding to 1.6's `0x01C01180`. +- VFR initialization: caller at `0x0127E740` passes baseline interval **2**. +- VFR update computes **interval / 2**: 1 -> 0.5, 2 -> 1.0, 3 -> 1.5. + +## Configurable implementation + +`src/modules/GameManager/BotwNativePatch.py` builds the configurable patch from +the minimal manifest and our own ARM64 payload. Runtime generation needs only +the standard Python library; the game files are never bundled. + +- The framework's final tick call at `0x0112699C` runs a clock-based limiter + once per frame. It sleeps using the native tick/time APIs and returns a tick + matching the original call's ABI. +- The native VFR update at `0x0151D484` receives measured elapsed time scaled + to the game's 30 FPS simulation baseline. Category/time-scale processing is + retained. Long stalls are capped at 100 ms to avoid a large simulation jump. +- Presentation is requested with interval zero so fractional-refresh targets + such as 45 FPS are controlled by the software limiter. +- The **SDK patch at `0x0019CC64`** makes its native-window setter honor that + request instead of clamping it to its minimum. The first configurable trial + was still displaying 30 FPS without this companion patch, even while memory + inspection showed the limiter and VFR correctly computing the 45 FPS step. + After adding it, the user confirmed 45 FPS and normal game speed. +- Camera stores at `0x00C2DBBC` (FOV) and `0x00C2DBD0` (far clip) affect the + active world camera. Defaults omit these hooks; shared constants used by other + game logic are not changed. +- Code uses verified zero-filled padding in the last text page, starting at + `0x015A1C80`. Timing state occupies padding at `0x01DB21E0`, before the game's + BSS start at `0x01DB2200`. Every patch is scoped to its exact module Build ID. + +Assembly source: `src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.s`. +Rebuild its checked-in JSON payload after changing the assembly: + +```powershell +python -m pip install keystone-engine +python tools/build_botw_native_payload.py +python tools/build_botw_native_payload.py --check +``` + +## Verification + +Run the generator tests: + +```powershell +python -m unittest discover -s tests -v +``` + +To repeat the executable/instruction checks and execute the original/patched +ARM64 routines using your own local dump: + +```powershell +python -m pip install lz4 unicorn +python tools/verify_botw_fps.py --main "C:\path\to\exefs\main" +python tools/verify_botw_native.py --main "C:\path\to\exefs\main" +``` + +This executes 24 timer/presentation cases (both entry points, requested intervals +1/2/3, timer clamp enabled/disabled, original/patched) and three native VFR cases. +The tests check preserved stack and callee-saved registers, verify equivalence +of the generated IPSwitch and IPS32 writes, and apply the records to the emulated +memory. They do not simulate the whole Switch/GPU or verify an emulator's loader. + +The configurable verifier additionally checks both module hashes, original +instructions, code/state padding, the SDK minimum-interval regression, timing +at 20/30/40/45/60/90/120 FPS, a relocated image, slow frames, long stalls, +native category multipliers, and camera FOV/zoom/clamps and far-plane writes. +UI smoke testing also covered three-profile switching, persisted settings, +two-module export, no emulator changes on Extract, and per-game graphics/conflict +handling on Apply in an isolated configuration. + +For broader gameplay testing, check the FPS counter together with actual game +speed, running/climbing, combat/physics, menus, map and a cutscene. Report the +emulator/version, FPS and any speed/physics differences; these tests are needed +before treating this profile as stable. diff --git a/src/PatchInfo/Breath Of The Wild/FPS-1.9.json b/src/PatchInfo/Breath Of The Wild/FPS-1.9.json new file mode 100644 index 0000000..801f5be --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/FPS-1.9.json @@ -0,0 +1,36 @@ +{ + "game": "The Legend of Zelda: Breath of the Wild", + "version": "1.9.0", + "title_id": "01007EF00011E000", + "build_id": "CD57B23FA4BBAD65803D9788C01821EE00000000000000000000000000000000", + "main_sha256": "f6f0b20bb1f2b67d164c24871cd473147eef1d285f12e7bd492e3126c6022e27", + "mod_name": "!!!BOTW 1.9.0 60 FPS", + "status": "experimental - IPSwitch export confirmed at 60 FPS by user in Eden nightly 5f142c7926; broader gameplay testing pending", + "fps": 60, + "patches": [ + { + "offset": "0x011269CC", + "expected": "F303012A", + "replacement": "21008052", + "description": "Framework setInterval: pass interval 1 to native frame timer (mov w1, #1)." + }, + { + "offset": "0x011269D8", + "expected": "E103132A", + "replacement": "21008052", + "description": "Framework setInterval: pass interval 1 to presentation wrapper (mov w1, #1)." + }, + { + "offset": "0x011269FC", + "expected": "F303012A", + "replacement": "21008052", + "description": "Secondary interface: pass interval 1 to native frame timer (mov w1, #1)." + }, + { + "offset": "0x01126A08", + "expected": "E103132A", + "replacement": "21008052", + "description": "Secondary interface: pass interval 1 to presentation wrapper (mov w1, #1)." + } + ] +} diff --git a/src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.json b/src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.json new file mode 100644 index 0000000..5aa32f2 --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.json @@ -0,0 +1,25 @@ +{ + "source_sha256": "cd5dda16ed2ee1970e6bb95f1e6b73d4d1bd1db923180a53152de068f2bc6bf7", + "blocks": [ + { + "name": "limiter", + "offset": "0x015A1C80", + "replacement": "FD7BBDA9FD030091F35301A9F55B02A9934000B073820791E2FBFF97F40300AA08190018950AC89A96F8FF97760240F9360100B4090016CB3F0115EBE2000054A00209CB53FBFF974EFBFF978DF8FF97F9FFFF17E90315AA600200F92001239E8102239E0018211E01D0271E0008211E0110211E0058211E600A00BDF55B42A9F35341A9FD7BC3A8C0035FD6" + }, + { + "name": "fov", + "offset": "0x015A1E80", + "replacement": "300A001C0008301E500A001C0058301EF009001C0048301E007500BDC0035FD6" + }, + { + "name": "far_clip", + "offset": "0x015A1EC0", + "replacement": "4008001C007100BDC0035FD6" + }, + { + "name": "delta", + "offset": "0x015A1E00", + "replacement": "904000B001EA41BD2820201EAC000054900D00180102231E10D0271E011A211E613200BD2020201EC0035FD6" + } + ] +} diff --git a/src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.s b/src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.s new file mode 100644 index 0000000..b74ed78 --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.s @@ -0,0 +1,83 @@ +// BOTW 1.9.0 / CD57B23FA4BBAD65803D9788C01821EE +// Assembled at the addresses documented in tools/build_botw_native_payload.py. +// The text cave is unused page padding after .text (0x015A1C64). +// State at 0x01DB21E0 occupies alignment padding before .bss (0x01DB2200). +// No original game code is included in this payload. + +// @block limiter 0x015A1C80 +// Replaces the last GetSystemTick call in the framework's frame loop. +// Returns the current tick in x0, preserving the original call's ABI. +stp x29, x30, [sp, #-0x30]! +mov x29, sp +stp x19, x20, [sp, #0x10] +stp x21, x22, [sp, #0x20] +adrp x19, 0x01DB2000 +add x19, x19, #0x1E0 +bl 0x015A0C20 +mov x20, x0 +ldr w8, 0x015A1FC0 +udiv x21, x20, x8 +bl 0x0159FF00 +ldr x22, [x19] +cbz x22, first_frame +wait_frame: +sub x9, x0, x22 +cmp x9, x21 +b.hs frame_ready +sub x0, x21, x9 +bl 0x015A0A10 +bl 0x015A0A00 +bl 0x0159FF00 +b wait_frame +first_frame: +mov x9, x21 +frame_ready: +str x0, [x19] +ucvtf s0, x9 +ucvtf s1, x20 +fdiv s0, s0, s1 +fmov s1, #30.0 +fmul s0, s0, s1 +// Avoid a huge simulation jump after a loading stall or suspension. +fmov s1, #3.0 +fmin s0, s0, s1 +str s0, [x19, #8] +ldp x21, x22, [sp, #0x20] +ldp x19, x20, [sp, #0x10] +ldp x29, x30, [sp], #0x30 +ret + +// @block fov 0x015A1E80 +// Replaces the CameraMgr store to the active world camera's FOV at +0x74. +// Scale the freshly calculated angle, preserving relative aim/cutscene zoom. +// s16 is dead at this callsite; NZCV and x8 are preserved. +ldr s16, 0x015A1FC4 +fmul s0, s0, s16 +ldr s16, 0x015A1FD0 +fmin s0, s0, s16 +ldr s16, 0x015A1FCC +fmax s0, s0, s16 +str s0, [x8, #0x74] +ret + +// @block far_clip 0x015A1EC0 +// Replaces only the active world camera's far plane store. +ldr s0, 0x015A1FC8 +str s0, [x8, #0x70] +ret + +// @block delta 0x015A1E00 +// Inline call replacing FCMP s1,s0 in VFRMgr::update. Native code still +// handles pause/category multipliers and publishes the resulting time values. +adrp x16, 0x01DB2000 +ldr s1, [x16, #0x1E8] +fcmp s1, #0.0 +b.gt have_delta +ldr w16, 0x015A1FC0 +ucvtf s1, w16 +fmov s16, #30.0 +fdiv s1, s16, s1 +have_delta: +str s1, [x19, #0x30] +fcmp s1, s0 +ret diff --git a/src/PatchInfo/Breath Of The Wild/Options-1.9.json b/src/PatchInfo/Breath Of The Wild/Options-1.9.json new file mode 100644 index 0000000..8bf1610 --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/Options-1.9.json @@ -0,0 +1,14 @@ +{ + "NativeFPS": { + "fps": { + "Name": "FPS (Experimental)", + "Class": "dropdown", + "Section": "main", + "Auto": false, + "Name_Values": ["60 FPS"], + "Values": [60], + "Default": 0, + "Description": "Experimental fixed 60 FPS patch for BOTW 1.9.0, Build ID CD57B23FA4BBAD65803D9788C01821EE. Uses native game timing. IPSwitch export confirmed at 60 FPS in Eden; broader gameplay testing pending. This is not UltraCam or dynamic FPS. Keep emulator speed at 100%. Disable other FPS mods and the 1.6 UltraCam mod. Graphics settings are controlled by your emulator." + } + } +} diff --git a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json new file mode 100644 index 0000000..99a70c6 --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json @@ -0,0 +1,67 @@ +{ + "NativeFPS": { + "fps": { + "Name": "FPS limit", + "Class": "scale", + "Section": "main", + "Auto": false, + "Values": [20, 120], + "Type": "s32", + "Default": 60, + "Increments": 1, + "Description": "Experimental clock-based FPS limit. Supports values such as 45 FPS. Native simulation time uses measured frame duration, including slower frames. Keep emulator speed at 100%." + }, + "fov": { + "Name": "World camera FOV", + "Class": "scale", + "Section": "main", + "Auto": false, + "Values": [20, 120], + "Type": "s32", + "Default": 50, + "Increments": 1, + "Description": "50 leaves the original camera unchanged. Other values scale the world camera's freshly calculated FOV relative to 50, keeping relative aiming/cutscene zoom. Resulting angles are clamped to 5-120 degrees." + }, + "far clip": { + "Name": "Far clip distance", + "Class": "scale", + "Section": "main", + "Auto": false, + "Values": [1000, 25000], + "Type": "s32", + "Default": 25000, + "Increments": 1000, + "Description": "Far clipping plane of the world camera. 25000 preserves the original game's handling. Lower values hide distant geometry; this does not change object streaming or LOD distances." + }, + "emulator scale": { + "Name": "Emulator scale", + "Class": "dropdown", + "Section": "main", + "Auto": false, + "Name_Values": ["Keep current", "1x", "2x", "3x", "4x"], + "Values": [0, 1, 2, 3, 4], + "Default": 0, + "Description": "Emulator resolution scaling, applied by Apply. Keep current preserves your setting. Extract does not change emulator configuration. This does not change the game's native framebuffer resolution or its dynamic resolution logic." + }, + "emulator aa": { + "Name": "Emulator AA", + "Class": "dropdown", + "Section": "main", + "Auto": false, + "Name_Values": ["Keep current", "Off", "FXAA", "SMAA"], + "Values": [-1, 0, 1, 2], + "Default": 0, + "Description": "Post-processing anti-aliasing provided by the emulator. Applied by Apply only. Off disables the emulator's added AA, not BOTW's own anti-aliasing." + }, + "anisotropy": { + "Name": "Emulator AF", + "Class": "dropdown", + "Section": "main", + "Auto": false, + "Name_Values": ["Keep current", "Automatic", "2x", "4x", "8x", "16x"], + "Values": [-1, 0, 2, 4, 8, 16], + "Default": 0, + "Description": "Emulator texture filtering. Applied by Apply only; Keep current preserves the existing setting." + } + } +} diff --git a/src/PatchInfo/Breath Of The Wild/PatchInfo.json b/src/PatchInfo/Breath Of The Wild/PatchInfo.json index 9a937ae..0831d39 100644 --- a/src/PatchInfo/Breath Of The Wild/PatchInfo.json +++ b/src/PatchInfo/Breath Of The Wild/PatchInfo.json @@ -12,5 +12,40 @@ "benchmarks_file" : "BOTWBenchmark.txt", "EmulationScale" : true, "Cheats" : true, - "SD" : false -} \ No newline at end of file + "SD" : false, + "Variants": [ + { + "Name": "BOTW 1.9.0 - 60 FPS (Experimental)", + "SelectionID": "01007EF00011E000@1.9.0-fps", + "Versions": ["1.9.0"], + "Mod_Version": "0.1.1-experimental", + "ModName": "!!!BOTW 1.9.0 60 FPS", + "ModFolder": "NativeFPS", + "ModConfig": "", + "Config": "Options-1.9.json", + "Presets": "Presets-1.9.json", + "ExefsPatch": "FPS-1.9.json", + "ConflictingMods": ["!!!BOTW Optimizer", "!!!BOTW 1.9.0 Optimizer"], + "benchmarks": false, + "EmulationScale": false, + "Cheats": false + }, + { + "Name": "BOTW 1.9.0 - FPS + Graphics (Experimental)", + "SelectionID": "01007EF00011E000@1.9.0-native", + "Versions": ["1.9.0"], + "Mod_Version": "0.2.0-experimental", + "ModName": "!!!BOTW 1.9.0 Optimizer", + "ModFolder": "NativeFPS", + "ModConfig": "", + "Config": "Options-Native-1.9.json", + "Presets": "Presets-Native-1.9.json", + "ExefsPatch": "FPS-1.9.json", + "NativePayload": "NativeFPS-1.9.json", + "ConflictingMods": ["!!!BOTW Optimizer", "!!!BOTW 1.9.0 60 FPS"], + "benchmarks": false, + "EmulationScale": false, + "Cheats": false + } + ] +} diff --git a/src/PatchInfo/Breath Of The Wild/Presets-1.9.json b/src/PatchInfo/Breath Of The Wild/Presets-1.9.json new file mode 100644 index 0000000..2d1f207 --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/Presets-1.9.json @@ -0,0 +1,5 @@ +{ + "60 FPS (Experimental)": { + "fps": 0 + } +} diff --git a/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json new file mode 100644 index 0000000..b6d29d6 --- /dev/null +++ b/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json @@ -0,0 +1,6 @@ +{ + "45 FPS": {"fps": 45, "far clip": 25000}, + "60 FPS": {"fps": 60, "far clip": 25000}, + "90 FPS": {"fps": 90, "far clip": 25000}, + "120 FPS": {"fps": 120, "far clip": 25000} +} diff --git a/src/modules/GameManager/BotwNativePatch.py b/src/modules/GameManager/BotwNativePatch.py new file mode 100644 index 0000000..b232577 --- /dev/null +++ b/src/modules/GameManager/BotwNativePatch.py @@ -0,0 +1,107 @@ +"""Configurable BOTW 1.9 patches. Runtime generation uses only the standard library.""" +import copy +import json +import math +from pathlib import Path +import struct + +from modules.GameManager.ExefsPatch import encode_ips32 + +MOD_NAME = "!!!BOTW 1.9.0 Optimizer" +FPS_CONFIG = 0x015A1FC0 +STATE = 0x01DB21E0 + + +def emulator_graphics(emulator, scale="Keep current", aa="Keep current", anisotropy="Keep current"): + """Explicit enum mapping; no changes for settings left at Keep current.""" + if scale not in ("Keep current", "1x", "2x", "3x", "4x"): + raise ValueError("Unknown emulator resolution scale") + if aa not in ("Keep current", "Off", "FXAA", "SMAA"): + raise ValueError("Unknown emulator anti-aliasing mode") + if anisotropy not in ("Keep current", "Automatic", "2x", "4x", "8x", "16x"): + raise ValueError("Unknown anisotropic filtering mode") + settings = {} + if emulator.lower() == "ryujinx": + if scale != "Keep current": + settings["res_scale"] = int(scale[:-1]) + if aa != "Keep current": + settings["anti_aliasing"] = {"Off": "None", "FXAA": "Fxaa", "SMAA": "SmaaHigh"}[aa] + if anisotropy != "Keep current": + settings["max_anisotropy"] = -1 if anisotropy == "Automatic" else int(anisotropy[:-1]) + else: + # Current Eden includes both 1/4x and 1.25x entries. Original Yuzu's + # integer scale indices are different; scale - 1 is not an enum index. + scales = {"1x": 3, "2x": 6, "3x": 7, "4x": 8} if "eden" in emulator.lower() else { + "1x": 2, "2x": 4, "3x": 5, "4x": 6, + } + if scale != "Keep current": + settings["resolution_setup"] = str(scales[scale]) + if aa != "Keep current": + settings["anti_aliasing"] = str({"Off": 0, "FXAA": 1, "SMAA": 2}[aa]) + if anisotropy != "Keep current": + settings["max_anisotropy"] = str({"Automatic": 0, "2x": 2, "4x": 3, "8x": 4, "16x": 5}[anisotropy]) + return settings + + +def branch(source, destination, link=True): + distance = destination - source + if distance % 4 or not -(1 << 27) <= distance < (1 << 27): + raise ValueError("ARM64 branch is unaligned or out of range") + return struct.pack(" 0x100000000: + raise ValueError("Overlapping or out-of-range patch offset") + if len(replacement) > 0xffff or offset == int.from_bytes(b"EEOF", "big"): + raise ValueError("Record cannot be represented in IPS32") + result.extend(struct.pack(">IH", offset, len(replacement))) + result.extend(replacement) + previous_end = offset + len(replacement) + result.extend(b"EEOF") + return bytes(result) + + +def encode_ipswitch(patch): + """Emit IPSwitch with memory-relative addresses and an explicit NSO shift. + + Eden nightly 5f142c7926 reported applying the IPS32 export but left the + instructions unchanged in guest memory. This text format was confirmed + working in the same game/build at 60 FPS. + """ + encode_ips32(patch) # Share range, size and overlap validation. + build_id = patch["build_id"].upper() + if not re.fullmatch(r"[0-9A-F]{64}", build_id): + raise ValueError("A full NSO Build ID is required") + lines = [f"@nsobid-{build_id}", "", "@flag offset_shift 0x100", "", "@enabled"] + for item in sorted(patch["patches"], key=lambda item: int(item["offset"], 0)): + replacement = bytes.fromhex(item["replacement"]).hex().upper() + lines.append(f"{int(item['offset'], 0):08X} {replacement}") + lines.extend(["@stop", ""]) + return "\n".join(lines).encode("ascii") + + +def module_patches(patch): + """Yield independently Build-ID-scoped patches for main and companion NSOs.""" + result = [patch] + [patch | module for module in patch.get("modules", [])] + identifiers = set() + for module in result: + identifier = module["build_id"].upper() + if not re.fullmatch(r"[0-9A-F]{64}", identifier) or identifier in identifiers: + raise ValueError("Invalid or duplicate module Build ID") + identifiers.add(identifier) + return result + + +def verify_main(patch, main_path): + """Check the exact executable used for the port, without needing game keys.""" + with open(main_path, "rb") as file: + header = file.read(NSO_HEADER_SIZE) + if len(header) != NSO_HEADER_SIZE or header[:4] != b"NSO0": + raise ValueError("Expected an extracted ExeFS main (NSO0), not an NSP or RomFS") + build_id = header[0x40:0x60].hex().upper() + if build_id != patch["build_id"].upper(): + raise ValueError(f"Unsupported Build ID {build_id}; this patch requires BOTW {patch['version']}") + file.seek(0) + hasher = hashlib.sha256() + for chunk in iter(lambda: file.read(1024 * 1024), b""): + hasher.update(chunk) + digest = hasher.hexdigest() + if digest != patch["main_sha256"].lower(): + raise ValueError("Build ID matches, but main differs from the executable used for this port") + return build_id + + +def export_patch(patch, destination, patch_format="pchtxt"): + """Export one format and migrate only an exact copy of our previous format.""" + encoders = {"pchtxt": encode_ipswitch, "ips": encode_ips32} + if patch_format not in encoders: + raise ValueError(f"Unknown patch format: {patch_format}") + destination = Path(destination) + exefs = destination / "exefs" + other_format = "ips" if patch_format == "pchtxt" else "pchtxt" + outputs = [] + # Validate every module and migration before changing any files. + for module in module_patches(patch): + build_id = module["build_id"].upper() + encoded = encoders[patch_format](module) + previous = exefs / f"{build_id}.{other_format}" + if previous.exists() and previous.read_bytes() != encoders[other_format](module): + raise ValueError(f"{previous} differs from the generated patch; use a separate output folder") + outputs.append((exefs / f"{build_id}.{patch_format}", encoded, previous)) + exefs.mkdir(parents=True, exist_ok=True) + for output, encoded, previous in outputs: + output.write_bytes(encoded) + if previous.exists(): + previous.unlink() + if patch.get("timing") == "dynamic": + timing_notes = ( + f"Clock-based limit: {patch['fps']} FPS. Native simulation time uses\n" + "measured frame duration, with long loading stalls clamped to 100 ms.\n" + f"Graphics settings: {json.dumps(patch['settings'], sort_keys=True)}\n" + "Both the main and SDK patch files must be enabled.\n" + f"Emulator settings: {json.dumps(patch.get('emulator_settings', {}), sort_keys=True)}\n" + "Emulator settings are written by Apply, not by an extracted mod.\n" + ) + validation_notes = ( + "Validated with executable hashes and ARM64 tests of the limiter,\n" + "native timing and register preservation. The user confirmed 45 FPS\n" + "with normal game speed and a wider FOV in Eden after the SDK fix.\n" + "Physics/cutscenes, far-distance effects and other FPS targets still\n" + "require broader gameplay testing.\n" + ) + else: + timing_notes = ( + "This is a fixed 60 FPS patch using the game's native timing. It does\n" + "not include UltraCam, graphics changes or arbitrary/dynamic FPS.\n" + "Sustained 60 FPS is required for the intended game speed.\n" + ) + validation_notes = ( + "Validated: executable hash, original instructions, both patched ARM64\n" + "setInterval entry points, native timing ratio at 60 FPS. The IPSwitch\n" + "export was confirmed at 60 FPS by the user in Eden nightly 5f142c7926.\n" + "Broader physics, menu and cutscene testing is still needed.\n" + ) + (destination / "README.md").write_text( + f"# BOTW {patch['version']} - {patch['fps']} FPS\n\n" + f"Status: {patch['status']}.\n\n" + f"Title ID: `{patch['title_id']}`\n\n" + f"Build ID: `{patch['build_id']}`\n\n" + f"Format: {'IPSwitch (.pchtxt) for Eden' if patch_format == 'pchtxt' else 'IPS32 (.ips), for Ryujinx; do not use this format with the tested Eden nightly'}.\n\n" + "Place this mod folder in the emulator's mod directory for BOTW.\n" + "Keep emulator speed at 100% and enable only one FPS mod. Disable the\n" + "1.6 UltraCam / !!!BOTW Optimizer mod when playing 1.9.0.\n\n" + + timing_notes + "Disable this mod to restore the original behavior.\n\n" + + validation_notes, + encoding="utf-8", + ) + return outputs[0][0] diff --git a/src/modules/GameManager/FileManager.py b/src/modules/GameManager/FileManager.py index e6719cd..7841c75 100644 --- a/src/modules/GameManager/FileManager.py +++ b/src/modules/GameManager/FileManager.py @@ -5,6 +5,9 @@ from modules.GameManager.LaunchManager import LaunchManager from modules.GameManager.ModCreator import ModCreator from modules.GameManager.DragFile import DragFile +from modules.GameManager.ExefsPatch import load_patch, export_patch +from modules.GameManager.BotwNativePatch import build_patch as build_botw_native_patch +from modules.GameManager.BotwNativePatch import emulator_graphics from modules.TOTK_Optimizer_Modules import * from configuration.settings import * from tkinter import messagebox @@ -473,6 +476,39 @@ def __CreateModPatch(filemgr, mode: str | None = None): return elif mode == None: + if patchInfo.ExefsPatch: + patch = load_patch(os.path.join(patchInfo.Folder, patchInfo.ExefsPatch)) + settings = {} + if patchInfo.NativePayload: + choices = filemgr._manager.UserChoices + patch = build_botw_native_patch( + patch, os.path.join(patchInfo.Folder, patchInfo.NativePayload), + fps=int(choices["fps"].get()), far_clip=int(choices["far clip"].get()), + fov=int(choices["fov"].get()), + ) + graphics = {key: choices[key].get() for key in ( + "emulator scale", "emulator aa", "anisotropy" + )} + emulator = filemgr.LegacyEmuName() if NxMode.isLegacy() else "ryujinx" + settings = emulator_graphics(emulator, scale=graphics["emulator scale"], + aa=graphics["emulator aa"], anisotropy=graphics["anisotropy"]) + patch["emulator_settings"] = graphics + root = os.path.join(os.getcwd(), "Extracted Files") if filemgr.is_extracting else modDir + destination = os.path.join(root, modName) + patch_format = "pchtxt" if NxMode.isLegacy() else "ips" + output = export_patch(patch, destination, patch_format) + if not filemgr.is_extracting and settings: + if NxMode.isLegacy(): + write_Legacy_configs(filemgr._manager, filemgr._gameconfig, patchInfo.ID, + {("Renderer", key): value for key, value in settings.items()}) + else: + for key, value in settings.items(): + write_ryujinx_config(filemgr, filemgr._emuconfig, key, value) + filemgr.mod_whitelist.append(modName) + filemgr.mod_blacklist.extend(patchInfo.ConflictingMods) + log.info(f"Created experimental Build-ID-scoped patch: {output}") + return + log.info(f"Generating mod at {modDir}") os.makedirs(modDir, exist_ok=True) @@ -651,7 +687,18 @@ def stop_extracting(): filemgr.Copyright, ] - if get_setting("auto-backup") in ["On"]: + if filemgr._manager._patchInfo.ExefsPatch: + # ExeFS profiles do not install UltraCam or write its INI. Export + # must also work without changing a running emulator's setup. + tasklist = [filemgr.__CreateModPatch] + if not filemgr.is_extracting: + tasklist.insert(0, filemgr.__CheckExeRunning) + tasklist.append(filemgr.__DisableMods) + tasklist.append(stop_extracting) + + if get_setting("auto-backup") in ["On"] and not ( + filemgr._manager._patchInfo.ExefsPatch and filemgr.is_extracting + ): tasklist.append(filemgr.backup) run_tasklists(tasklist) diff --git a/src/modules/GameManager/GameManager.py b/src/modules/GameManager/GameManager.py index 4456363..b22544f 100644 --- a/src/modules/GameManager/GameManager.py +++ b/src/modules/GameManager/GameManager.py @@ -39,20 +39,17 @@ def CreatePatchInfo(cls, patchfolder) -> PatchInfo: with open(filepath, "r", encoding="utf-8") as file: jsonfile = json.load(file) - # check if a game already exists inside of our loop - for item in cls.GamePatches: - if item.Name == jsonfile['Name']: - return item - - log.info( - f"{jsonfile['Name']} [{jsonfile['ID']}] : {jsonfile['Versions']}" - ) - - _PatchInfo: PatchInfo = PatchInfo(patchfolder, jsonfile) - - cls.GamePatches.append( - _PatchInfo - ) + # Variants share a title ID and artwork, but have distinct options + # and saved selections (e.g. UltraCam 1.6 vs native FPS 1.9). + definitions = [jsonfile] + [jsonfile | variant for variant in jsonfile.get("Variants", [])] + for definition in definitions: + item = next((p for p in cls.GamePatches if p.Name == definition['Name']), None) + if item is None: + log.info(f"{definition['Name']} [{definition['ID']}] : {definition['Versions']}") + item = PatchInfo(patchfolder, definition) + cls.GamePatches.append(item) + if _PatchInfo is None: + _PatchInfo = item return _PatchInfo @classmethod @@ -65,10 +62,10 @@ def CreatePatches(cls, patch_directory) -> None: @classmethod def GetJsonByID(cls, ID: str) -> PatchInfo: - """Finds the current json file for a TITLEID.""" + """Find a saved selection, accepting legacy title IDs for base profiles.""" for item in cls.GamePatches: - if ID.lower() == item.ID.lower(): + if ID.lower() == item.SelectionID.lower(): return item # if we don't find anything return TOTK patch. diff --git a/src/modules/GameManager/PatchInfo.py b/src/modules/GameManager/PatchInfo.py index 020c92f..b027d70 100644 --- a/src/modules/GameManager/PatchInfo.py +++ b/src/modules/GameManager/PatchInfo.py @@ -22,6 +22,7 @@ class PatchInfo: def __init__(self, folder: str, JsonFile: json): # fmt: skip self.Folder = folder self.ID = self.Json(JsonFile, "ID") # mandatory + self.SelectionID = JsonFile.get("SelectionID", self.ID) self.Name = self.Json(JsonFile, "Name") # mandatory self.Versions = self.Json(JsonFile, "Versions", []) self.ModName = self.Json(JsonFile, "ModName", "!!!NX-Optimizer") @@ -31,6 +32,11 @@ def __init__(self, folder: str, JsonFile: json): # fmt: skip self.isSDconfig = self.Json(JsonFile, "SD", False) self.ResolutionScale = self.Json(JsonFile, "EmulationScale", True) self.ModVersion = self.Json(JsonFile, "Mod_Version", "1.0") + self.OptionsFile = JsonFile.get("Config", "Options.json") + self.PresetsFile = JsonFile.get("Presets", "Presets.json") + self.ExefsPatch = JsonFile.get("ExefsPatch") + self.NativePayload = JsonFile.get("NativePayload") + self.ConflictingMods = JsonFile.get("ConflictingMods", []) self.Support_Benchmark = self.Json(JsonFile, "benchmarks", False) self.Benchmark_Version = self.Json(JsonFile, "benchmarks_version", 0) @@ -52,12 +58,12 @@ def IDtoNum(self): return int(self.ID, 16) def LoadJson(self): - Location = os.path.join(self.Folder, "Options.json") + Location = os.path.join(self.Folder, self.OptionsFile) with open(Location, "r", encoding="utf-8") as file: return json.load(file) def LoadPresetsJson(self): - Location = os.path.join(self.Folder, "Presets.json") + Location = os.path.join(self.Folder, self.PresetsFile) if not os.path.exists(Location): return {"Saved": {}} diff --git a/src/modules/config.py b/src/modules/config.py index ce43547..dc94ca3 100644 --- a/src/modules/config.py +++ b/src/modules/config.py @@ -73,7 +73,7 @@ def setGameConfig(Manager, config): # UltraCam Beyond new patches. for i, (key, value) in enumerate(Manager.UltracamPatchJson.items()): patch_info = value - sectionName = Manager._patchInfo.ID + sectionName = Manager._patchInfo.SelectionID if not config.has_section(sectionName): config.add_section(sectionName) @@ -104,7 +104,7 @@ def loadGameConfig(Manager, config): # Load UltraCam Beyond new patches. for i, (key, value) in enumerate(Manager.UltracamPatchJson.items()): patch_info = value - GameID = Manager._patchInfo.ID + GameID = Manager._patchInfo.SelectionID for itemName in value: Manager.UserConfigs[itemName] = key @@ -127,7 +127,7 @@ def loadGameConfig(Manager, config): except (configparser.NoOptionError, KeyError): pass except ValueError: - if config[Manager._patchInfo.ID][patch] == "auto": + if config[GameID][patch] == "auto": Manager.UserChoices[patch].set(dropdown_name(patch_dict, patch_default)) continue continue @@ -186,7 +186,7 @@ def save_user_choices(Manager, config_file, Legacy_path=None): "Attempting to write into file.") if (Manager._patchInfo.ResolutionScale): - config.set(Manager._patchInfo.ID, "emuscale", Manager._EmulatorScale.get()) + config.set(Manager._patchInfo.SelectionID, "emuscale", Manager._EmulatorScale.get()) # Write the updated configuration back to the file with open(config_file, 'w', encoding="utf-8") as file: @@ -328,7 +328,7 @@ def load_config_game(Manager, config_file): config["Options"] = {} return "0" - return config.get("Options", "Game", fallback=Manager._patchInfo.ID) + return config.get("Options", "Game", fallback=Manager._patchInfo.SelectionID) def save_config_game(Manager, config_file): ''' Saves the current selected Configuration game. ''' @@ -336,7 +336,7 @@ def save_config_game(Manager, config_file): config.read(config_file, encoding="utf-8") if not config.has_section("Options"): config["Options"] = {} - config.set("Options", "Game", Manager._patchInfo.ID) + config.set("Options", "Game", Manager._patchInfo.SelectionID) with open(config_file, 'w', encoding="utf-8") as file: config.write(file) diff --git a/src/modules/qt_config.py b/src/modules/qt_config.py index db43c8f..906010a 100644 --- a/src/modules/qt_config.py +++ b/src/modules/qt_config.py @@ -51,7 +51,7 @@ def find_title_id_index(config, config_title_id): return None else: for key, value in config.items(section): - if value == config_title_id: + if value == str(config_title_id): TitleIndexnum = key.split("\\")[0] return TitleIndexnum return None diff --git a/tests/test_botw_native_patch.py b/tests/test_botw_native_patch.py new file mode 100644 index 0000000..390b8c7 --- /dev/null +++ b/tests/test_botw_native_patch.py @@ -0,0 +1,90 @@ +import copy +import json +from pathlib import Path +import struct +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +from modules.GameManager.BotwNativePatch import build_patch, emulator_graphics, FPS_CONFIG +from modules.GameManager.ExefsPatch import load_patch, export_patch, module_patches + +FOLDER = ROOT / "src/PatchInfo/Breath Of The Wild" + + +class NativePatchTests(unittest.TestCase): + def setUp(self): + self.base = load_patch(FOLDER / "FPS-1.9.json") + + def build(self, **settings): + return build_patch(self.base, FOLDER / "NativeFPS-1.9.json", **settings) + + def test_45fps_exports_both_required_modules(self): + original = copy.deepcopy(self.base) + patch = self.build(fps=45) + self.assertEqual(self.base, original) + self.assertEqual(patch["fps"], 45) + self.assertEqual(len(module_patches(patch)), 2) + records = {int(p["offset"], 0): bytes.fromhex(p["replacement"]) for p in patch["patches"]} + self.assertEqual(struct.unpack_from("IH", encoded, cursor) + cursor += 6 + records.append((offset, encoded[cursor:cursor+size])) + cursor += size + self.assertEqual(cursor + 4, len(encoded)) + self.assertEqual(records, [ + (0x1126acc, bytes.fromhex("21008052")), + (0x1126ad8, bytes.fromhex("21008052")), + (0x1126afc, bytes.fromhex("21008052")), + (0x1126b08, bytes.fromhex("21008052")), + ]) + + def test_export_is_build_id_scoped_and_keeps_other_files(self): + with tempfile.TemporaryDirectory() as temp: + destination = Path(temp) / self.patch["mod_name"] + destination.mkdir() + user_file = destination / "user-note.md" + user_file.write_text("keep", encoding="utf-8") + output = export_patch(self.patch, destination) + self.assertEqual(output.name, self.patch["build_id"] + ".pchtxt") + self.assertEqual(output.read_bytes(), encode_ipswitch(self.patch)) + self.assertEqual(user_file.read_text(), "keep") + self.assertFalse((destination / "romfs").exists()) + self.assertEqual(list((destination / "exefs").iterdir()), [output]) + export_patch(self.patch, destination) + self.assertEqual(output.read_bytes(), encode_ipswitch(self.patch)) + + def test_ipswitch_has_build_id_and_exact_instructions_without_double_shift(self): + text = encode_ipswitch(self.patch).decode("ascii") + self.assertEqual(text.splitlines(), [ + "@nsobid-" + self.patch["build_id"], "", "@flag offset_shift 0x100", "", + "@enabled", "011269CC 21008052", "011269D8 21008052", + "011269FC 21008052", "01126A08 21008052", "@stop", + ]) + + def test_format_migration_removes_only_our_previous_generated_patch(self): + with tempfile.TemporaryDirectory() as temp: + destination = Path(temp) + old = export_patch(self.patch, destination, "ips") + self.assertEqual(old.read_bytes(), encode_ips32(self.patch)) + unrelated = old.parent / "other.ips" + unrelated.write_bytes(b"user patch") + new = export_patch(self.patch, destination) + self.assertFalse(old.exists()) + self.assertTrue(new.exists()) + self.assertEqual(unrelated.read_bytes(), b"user patch") + restored = export_patch(self.patch, destination, "ips") + self.assertFalse(new.exists()) + self.assertEqual(restored.read_bytes(), encode_ips32(self.patch)) + + def test_modified_previous_format_is_not_overwritten_or_deleted(self): + with tempfile.TemporaryDirectory() as temp: + old = export_patch(self.patch, temp, "ips") + old.write_bytes(b"user-edited") + with self.assertRaisesRegex(ValueError, "differs"): + export_patch(self.patch, temp) + self.assertEqual(old.read_bytes(), b"user-edited") + self.assertFalse(old.with_suffix(".pchtxt").exists()) + + def test_invalid_records_are_rejected_before_creating_output(self): + invalid = [] + for field, value in (("offset", "-0x4"), ("offset", "0x100000000"), + ("expected", "00"), ("replacement", "")): + patch = copy.deepcopy(self.patch) + patch["patches"][0][field] = value + invalid.append(patch) + patch = copy.deepcopy(self.patch) + patch["patches"].append(patch["patches"][0]) + invalid.append(patch) + with tempfile.TemporaryDirectory() as temp: + for index, patch in enumerate(invalid): + output = Path(temp) / str(index) + with self.subTest(index=index), self.assertRaises(ValueError): + export_patch(patch, output) + self.assertFalse(output.exists()) + + def test_verify_main_rejects_wrong_version_and_modified_executable(self): + header = bytearray(0x100) + header[:4] = b"NSO0" + header[0x40:0x60] = bytes.fromhex(self.patch["build_id"]) + patch = copy.deepcopy(self.patch) + patch["main_sha256"] = hashlib.sha256(header).hexdigest() + with tempfile.TemporaryDirectory() as temp: + main = Path(temp) / "main" + main.write_bytes(header) + self.assertEqual(verify_main(patch, main), patch["build_id"]) + main.write_bytes(header + b"modified") + with self.assertRaisesRegex(ValueError, "differs"): + verify_main(patch, main) + header[0x40] ^= 1 + main.write_bytes(header) + with self.assertRaisesRegex(ValueError, "Unsupported Build ID"): + verify_main(patch, main) + main.write_bytes(b"PFS0" + bytes(0xfc)) + with self.assertRaisesRegex(ValueError, "NSO0"): + verify_main(patch, main) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_patch_profiles.py b/tests/test_patch_profiles.py new file mode 100644 index 0000000..3501af6 --- /dev/null +++ b/tests/test_patch_profiles.py @@ -0,0 +1,89 @@ +import configparser +import importlib +import logging +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class PatchProfileTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Loading patch metadata must not require a monitor/GPU in these tests. + logger = types.ModuleType("modules.logger") + logger.log = logger.superlog = logging.getLogger("profile-tests") + with patch.dict(sys.modules, {"modules.logger": logger}): + cls.games = importlib.import_module("modules.GameManager.GameManager").Game_Manager + cls.qt = importlib.import_module("modules.qt_config") + + def setUp(self): + self.saved = self.games.GamePatches + self.games.GamePatches = [] + + def tearDown(self): + self.games.GamePatches = self.saved + + def test_versions_have_separate_selection_and_options_but_same_title(self): + self.games.CreatePatchInfo(ROOT / "src/PatchInfo/Breath Of The Wild") + base = self.games.GetJsonByID("01007ef00011e000") + newer = self.games.GetJsonByID("01007ef00011e000@1.9.0-fps") + self.assertIsNot(base, newer) + self.assertEqual(base.ID, newer.ID) + self.assertEqual(base.Versions, ["1.6.0"]) + self.assertEqual(newer.Versions, ["1.9.0"]) + self.assertIsNone(base.ExefsPatch) + self.assertEqual(newer.ExefsPatch, "FPS-1.9.json") + self.assertIn("freecam", base.LoadJson()["Keys"]) + self.assertEqual(newer.LoadJson()["NativeFPS"]["fps"]["Values"], [60]) + self.assertFalse(newer.Cheats) + self.assertFalse(newer.ResolutionScale) + self.assertFalse(newer.Support_Benchmark) + self.assertEqual(newer.LoadPresetsJson()["60 FPS (Experimental)"]["fps"], 0) + + def test_repeated_discovery_does_not_duplicate_variants(self): + folder = ROOT / "src/PatchInfo/Breath Of The Wild" + first = self.games.CreatePatchInfo(folder) + self.assertIs(self.games.CreatePatchInfo(folder), first) + self.assertEqual(len(self.games.GamePatches), 3) + + def test_configurable_profile_exposes_real_native_and_emulator_settings(self): + self.games.CreatePatchInfo(ROOT / "src/PatchInfo/Breath Of The Wild") + configurable = self.games.GetJsonByID("01007EF00011E000@1.9.0-native") + fixed = self.games.GetJsonByID("01007EF00011E000@1.9.0-fps") + options = configurable.LoadJson()["NativeFPS"] + self.assertEqual(set(options), {"fps", "fov", "far clip", "emulator scale", "emulator aa", "anisotropy"}) + self.assertEqual(options["fps"]["Values"], [20, 120]) + self.assertEqual(configurable.NativePayload, "NativeFPS-1.9.json") + self.assertIn(fixed.ModName, configurable.ConflictingMods) + self.assertIn(configurable.ModName, fixed.ConflictingMods) + + def test_install_can_disable_old_ultracam_and_enable_new_patch(self): + title = int("01007EF00011E000", 16) + old, new = "!!!BOTW Optimizer", "!!!BOTW 1.9.0 60 FPS" + config = configparser.ConfigParser() + config["DisabledAddOns"] = { + "1\\title_id": str(title), + "1\\disabled\\1\\d": new, + "1\\disabled\\size": "1", + } + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + (directory / old).mkdir() + (directory / new).mkdir() + config_file = directory / "qt-config.ini" + self.qt.add_entry(config_file, directory, config, title, old) + self.qt.find_and_remove_entry(config_file, directory, config, title, new) + loaded = configparser.ConfigParser() + loaded.read(config_file) + self.assertEqual(self.qt.get_d_values(loaded, "1"), [old]) + self.assertEqual(self.qt.find_title_id_index(loaded, str(title)), "1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_botw_native_payload.py b/tools/build_botw_native_payload.py new file mode 100644 index 0000000..afde8f2 --- /dev/null +++ b/tools/build_botw_native_payload.py @@ -0,0 +1,38 @@ +"""Rebuild our small ARM64 payload. Development dependency: keystone-engine.""" +import argparse +import hashlib +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.s" +OUTPUT = SOURCE.with_suffix(".json") + + +def build(): + from keystone import Ks, KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN + source = SOURCE.read_text(encoding="utf-8") + blocks = [] + for name, address, body in re.findall( + r"// @block (\w+) (0x[0-9A-Fa-f]+)\n(.*?)(?=// @block|\Z)", source, re.S + ): + assembly = "\n".join(line.split("//")[0] for line in body.splitlines()) + code, _ = Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN).asm(assembly, addr=int(address, 0)) + data = bytes(code) + assert 0x15a1c80 <= int(address, 0) < int(address, 0) + len(data) <= 0x15a1fc0 + blocks.append({"name": name, "offset": address, "replacement": data.hex().upper()}) + return {"source_sha256": hashlib.sha256(source.encode()).hexdigest(), "blocks": blocks} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + payload = build() + if args.check: + assert payload == json.loads(OUTPUT.read_text()), "Rebuild the payload after editing assembly" + else: + OUTPUT.write_text(json.dumps(payload, indent=4) + "\n", encoding="utf-8") + for block in payload["blocks"]: + print(block["name"], block["offset"], len(bytes.fromhex(block["replacement"])), "bytes") diff --git a/tools/export_botw_fps.py b/tools/export_botw_fps.py new file mode 100644 index 0000000..5343709 --- /dev/null +++ b/tools/export_botw_fps.py @@ -0,0 +1,42 @@ +"""Export the experimental BOTW 1.9.0 patch without GUI dependencies.""" +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +from modules.GameManager.ExefsPatch import load_patch, verify_main, export_patch, module_patches +from modules.GameManager.BotwNativePatch import build_patch + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--main", type=Path, required=True, help="Extracted BOTW 1.9.0 ExeFS main to verify") + parser.add_argument("--output", type=Path, required=True, help="Parent folder in which to create the mod") + parser.add_argument("--format", choices=("pchtxt", "ips"), default="pchtxt", + help="pchtxt for Eden/Legacy (default); ips for Ryujinx") + parser.add_argument("--fps", type=int, help="Use the configurable frame limiter (20-120 FPS)") + parser.add_argument("--far-clip", type=int, help="Default camera far clipping distance (1000-25000)") + parser.add_argument("--fov", type=int, help="World camera FOV, relative to the original 50 degrees (20-120)") + parser.add_argument("--sdk", type=Path, help="ExeFS sdk for configurable mode; defaults to main's sibling sdk") + args = parser.parse_args() + try: + patch = load_patch(ROOT / "src/PatchInfo/Breath Of The Wild/FPS-1.9.json") + verify_main(patch, args.main) + if args.fps is not None or args.far_clip is not None or args.fov is not None: + patch = build_patch( + patch, ROOT / "src/PatchInfo/Breath Of The Wild/NativeFPS-1.9.json", + fps=60 if args.fps is None else args.fps, + far_clip=25000 if args.far_clip is None else args.far_clip, + fov=50 if args.fov is None else args.fov, + ) + verify_main(module_patches(patch)[1], args.sdk or args.main.with_name("sdk")) + output = export_patch(patch, args.output / patch["mod_name"], args.format) + except (ValueError, OSError) as error: + parser.exit(1, f"Error: {error}\n") + print(f"Exported: {output}") + print(f"Status: {patch['status']}") + + +if __name__ == "__main__": + main() diff --git a/tools/verify_botw_fps.py b/tools/verify_botw_fps.py new file mode 100644 index 0000000..9efc410 --- /dev/null +++ b/tools/verify_botw_fps.py @@ -0,0 +1,135 @@ +"""Execute original/patched BOTW 1.9 ARM64 routines with Unicorn (local ExeFS required).""" +import argparse +import hashlib +from pathlib import Path +import struct +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +from modules.GameManager.ExefsPatch import load_patch, verify_main, encode_ips32, encode_ipswitch + + +def verify(main_path): + import lz4.block + from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM + from unicorn.arm64_const import (UC_ARM64_REG_PC, UC_ARM64_REG_SP, UC_ARM64_REG_LR, + UC_ARM64_REG_X0, UC_ARM64_REG_X1, + UC_ARM64_REG_X19, UC_ARM64_REG_X20) + + patch = load_patch(ROOT / "src/PatchInfo/Breath Of The Wild/FPS-1.9.json") + verify_main(patch, main_path) + raw = main_path.read_bytes() + flags = struct.unpack_from("IH", ips, cursor) + cursor += 6 + offset -= 0x100 + patched_memory[offset:offset+size] = ips[cursor:cursor+size] + cursor += size + + # Independently apply the exported IPSwitch text to an NSO-header-prefixed + # image, as Eden does. Both formats must produce identical guest code. + text_image = bytearray(0x100) + memory + enabled = False + shift = 0 + records = 0 + for line in encode_ipswitch(patch).decode("ascii").splitlines(): + if line.startswith("@nsobid-"): + assert line[8:] == patch["build_id"] + elif line.startswith("@flag offset_shift "): + shift = int(line.split()[-1], 0) + elif line == "@enabled": + enabled = True + elif line == "@stop": + break + elif line and enabled: + offset, value = line.split() + address = int(offset, 16) + shift + data = bytes.fromhex(value) + text_image[address:address+len(data)] = data + records += 1 + assert records == len(patch["patches"]) + assert text_image[0x100:] == patched_memory + print("PASS: IPSwitch and IPS32 exports produce identical guest instructions") + + stack, obj, graphics, stop = 0x500f000, 0x4000000, 0x4002000, 0x6000000 + + def machine(patched): + uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM) + uc.mem_map(0, len(memory)) + uc.mem_write(0, bytes(patched_memory if patched else memory)) + uc.mem_map(obj, 0x10000) + uc.mem_map(0x5000000, 0x100000) + uc.mem_map(stop, 0x1000) + uc.reg_write(UC_ARM64_REG_SP, stack) + uc.reg_write(UC_ARM64_REG_LR, stop) + return uc + + checks = 0 + for patched in (False, True): + for wrapper, this in ((0x11269b8, obj), (0x11269e8, obj+0x218)): + for requested in (1, 2, 3): + for clamp in (0, 1): + uc = machine(patched) + timer = obj + 0x228 + uc.mem_write(obj+0x130, struct.pack(" delta ratio {dt}") + print("These isolated CPU tests do not replace gameplay testing.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--main", type=Path, required=True) + args = parser.parse_args() + if not __debug__: + parser.error("Run without -O: verification uses assertions") + verify(args.main) diff --git a/tools/verify_botw_native.py b/tools/verify_botw_native.py new file mode 100644 index 0000000..64684f6 --- /dev/null +++ b/tools/verify_botw_native.py @@ -0,0 +1,212 @@ +"""Verify configurable BOTW patches against local ExeFS and execute ARM64 regression cases.""" +import argparse +import hashlib +import math +from pathlib import Path +import struct +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +from modules.GameManager.ExefsPatch import load_patch, verify_main, module_patches, encode_ipswitch +from modules.GameManager.BotwNativePatch import build_patch, STATE + + +def image(path): + import lz4.block + raw = path.read_bytes() + flags = struct.unpack_from(" Date: Sat, 26 Sep 2026 17:59:00 +0200 Subject: [PATCH 2/4] Consolidate BOTW 1.9 profile and restore distance presets --- docs/BOTW-1.9.md | 55 +++++++++++-------- .../Breath Of The Wild/Options-1.9.json | 14 ----- .../Options-Native-1.9.json | 16 +++--- .../Breath Of The Wild/PatchInfo.json | 19 +------ .../Breath Of The Wild/Presets-1.9.json | 5 -- .../Presets-Native-1.9.json | 8 +-- src/modules/GameManager/BotwNativePatch.py | 4 +- src/modules/GameManager/FileManager.py | 4 +- src/modules/GameManager/GameManager.py | 6 ++ src/modules/GameManager/PatchInfo.py | 24 +++++++- src/modules/config.py | 1 + tests/test_botw_native_patch.py | 10 ++++ tests/test_patch_profiles.py | 44 ++++++++++++--- tools/export_botw_fps.py | 21 +++---- tools/verify_botw_native.py | 9 +++ 15 files changed, 144 insertions(+), 96 deletions(-) delete mode 100644 src/PatchInfo/Breath Of The Wild/Options-1.9.json delete mode 100644 src/PatchInfo/Breath Of The Wild/Presets-1.9.json diff --git a/docs/BOTW-1.9.md b/docs/BOTW-1.9.md index cddb421..30ba4cc 100644 --- a/docs/BOTW-1.9.md +++ b/docs/BOTW-1.9.md @@ -1,13 +1,13 @@ # BOTW 1.9.0: configurable FPS and graphics -The experimental BOTW 1.9.0 profiles provide a **20-120 FPS limiter with measured frame +The experimental BOTW 1.9.0 profile provides a **20-120 FPS limiter with measured frame time**, world-camera **FOV and far clip**, and emulator graphics controls for the Switch version of BOTW 1.9.0. The user confirmed **45 FPS with normal game speed and a visibly wider FOV** in Eden nightly `5f142c7926`. -The earlier minimal fixed-60-FPS profile is also available and was confirmed -at 60 FPS. Both are separate from UltraCam 1.6.0. Broader physics, menu, cutscene -and other framerate testing remains pending; these profiles are experimental. +There is one profile for 1.9.0, including 60 FPS as a selectable limit. It is +separate from UltraCam 1.6.0. Broader physics, menu, cutscene and other framerate +testing remains pending; the profile is experimental. ## Using NX Optimizer @@ -18,7 +18,6 @@ profiles listed below. In **Select Game**, choose: - **Breath of The Wild**: existing UltraCam for **1.6.0**. -- **BOTW 1.9.0 - 60 FPS (Experimental)**: minimal fixed 60 FPS. - **BOTW 1.9.0 - FPS + Graphics (Experimental)**: configurable FPS and graphics. Choose **Extract** to export a standalone mod to `Extracted Files`, or **Apply** @@ -38,19 +37,21 @@ an exported folder or if your emulator uses a different mod manager. | --- | --- | | FPS limit | Integer 20-120, including 45; clock-based limiter with measured simulation time | | World camera FOV | 20-120; 50 preserves original behavior; other values scale the freshly calculated world-camera angle relative to 50 | -| Far clip distance | 1000-25000; 25000 preserves original behavior; lower values override the world-camera far plane | +| Render Distance | Original presets: VERY LOW (1000), LOW (5000), MEDIUM (12500), DEFAULT (25000), experimental (35000) | | Resolution scale (emulator) | Keep current, 1x, 2x, 3x, 4x | | Anti-aliasing (emulator) | Keep current, Off, FXAA, SMAA | | Anisotropic filtering (emulator) | Keep current, Automatic, 2x, 4x, 8x, 16x | The configurable mod is named `!!!BOTW 1.9.0 Optimizer` and needs **both its -main and SDK patch files**. Disable the minimal `!!!BOTW 1.9.0 60 FPS` mod and +main and SDK patch files**. Disable any previously exported `!!!BOTW 1.9.0 60 FPS` mod and UltraCam when using it. Apply manages these known conflicts; manual installation requires selecting the enabled mod in the emulator. FOV scaling preserves relative aiming/cutscene zoom, with resulting angles -clamped to 5-120 degrees. Far clip is a camera clipping plane, not object -streaming or LOD distance. The native framebuffer resolution, native FXAA/DR +clamped to 5-120 degrees. Render Distance uses the same preset labels and numeric +values as the original BOTW profile. It adjusts the world camera's far clipping +plane (previously labelled Far clip distance), not object streaming or LOD. +**DEFAULT is 25000** and preserves the game's own far-plane handling. The native framebuffer resolution, native FXAA/DR disabling, shadows, free camera and other UltraCam features are not ported. The three settings marked **emulator** affect its per-game configuration through @@ -60,31 +61,37 @@ scale multiplies the game's output; AA Off disables only the emulator's added AA, not the game's own FXAA. Current Eden's resolution indices are mapped explicitly, including its 1/4x and 1.25x entries. -### Minimal fixed-60 profile +### Upgrading an earlier build -This older profile has no injected limiter or graphics options. It targets -sustained 60 FPS and may slow the game down below the target. Use the configurable -profile for other FPS targets or measured frame-time correction. +The redundant fixed-60 profile has been removed. Its saved selection ID resolves +to the configurable profile. Existing configurable-profile settings are retained; +otherwise the FPS default is 60. The old fixed-profile dropdown index is not +imported as an FPS value. + +Saved numeric `far clip` values are mapped to the nearest Render Distance preset +when no new preset has been saved yet. For example, 5000 becomes LOW and 25000 +becomes DEFAULT. Existing Render Distance choices take precedence. ## Export without GUI dependencies Python 3.10+ and extracted ExeFS files are sufficient; no game keys are needed -by this exporter. For the minimal fixed-60 profile: +by this exporter. Export the configurable profile with its default 60 FPS: ```powershell python tools/export_botw_fps.py --main "C:\path\to\exefs\main" --output "C:\path\to\mods" ``` -For the configurable profile, including the settings used in the user's test: +Or choose settings, including those used in the user's test: ```powershell python tools/export_botw_fps.py --main "C:\path\to\exefs\main" --output "C:\path\to\mods" --fps 45 --fov 65 --far-clip 5000 ``` -Configurable mode also verifies the sibling **`sdk`** executable. If it is in +The exporter also verifies the sibling **`sdk`** executable. If it is in another folder, supply `--sdk "C:\path\to\sdk"`. Both files must match the known Build IDs and hashes before the exporter writes anything. Without graphics -arguments, the configurable defaults are original FOV/far clip (50/25000). +arguments, the defaults are original FOV/Render Distance (50/25000). +`--far-clip` remains available as a numeric CLI option (1000-35000). The default format is `pchtxt`; use `--format ips` for Ryujinx. The exporter verifies the Build ID and SHA-256 before exporting. The graphical profile @@ -117,11 +124,12 @@ to the same folder removes the previous `.ips` only if its contents exactly match this generator's patch; unrelated or user-edited patches are preserved. Restart emulation after updating the files. -## Minimal fixed-60 implementation +## Internal interval patch seed -The patch uses the **native frame timer and presentation interval**, rather than -porting the closed-source UltraCam binary. Both `setInterval` entry points are -patched so that interval **1** is passed to both the timer and presentation code. +The generator retains the initial four-instruction interval patch as its internal +seed and a regression reference, not as a separate user-facing profile. The seed +passes interval **1** to the timer and presentation code; the current generator +then modifies presentation and adds the limiter/SDK/camera hooks described below. | NSO memory offset | Original bytes | New bytes | | --- | --- | --- | @@ -147,7 +155,7 @@ Relevant 1.9 code locations: ## Configurable implementation `src/modules/GameManager/BotwNativePatch.py` builds the configurable patch from -the minimal manifest and our own ARM64 payload. Runtime generation needs only +the interval seed manifest and our own ARM64 payload. Runtime generation needs only the standard Python library; the game files are never bundled. - The framework's final tick call at `0x0112699C` runs a clock-based limiter @@ -206,7 +214,8 @@ The configurable verifier additionally checks both module hashes, original instructions, code/state padding, the SDK minimum-interval regression, timing at 20/30/40/45/60/90/120 FPS, a relocated image, slow frames, long stalls, native category multipliers, and camera FOV/zoom/clamps and far-plane writes. -UI smoke testing also covered three-profile switching, persisted settings, +UI smoke testing also covered the 1.6/1.9 profiles, retired-selection handling, +Render Distance migration, persisted settings, two-module export, no emulator changes on Extract, and per-game graphics/conflict handling on Apply in an isolated configuration. diff --git a/src/PatchInfo/Breath Of The Wild/Options-1.9.json b/src/PatchInfo/Breath Of The Wild/Options-1.9.json deleted file mode 100644 index 8bf1610..0000000 --- a/src/PatchInfo/Breath Of The Wild/Options-1.9.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "NativeFPS": { - "fps": { - "Name": "FPS (Experimental)", - "Class": "dropdown", - "Section": "main", - "Auto": false, - "Name_Values": ["60 FPS"], - "Values": [60], - "Default": 0, - "Description": "Experimental fixed 60 FPS patch for BOTW 1.9.0, Build ID CD57B23FA4BBAD65803D9788C01821EE. Uses native game timing. IPSwitch export confirmed at 60 FPS in Eden; broader gameplay testing pending. This is not UltraCam or dynamic FPS. Keep emulator speed at 100%. Disable other FPS mods and the 1.6 UltraCam mod. Graphics settings are controlled by your emulator." - } - } -} diff --git a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json index 99a70c6..702831f 100644 --- a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json +++ b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json @@ -22,16 +22,16 @@ "Increments": 1, "Description": "50 leaves the original camera unchanged. Other values scale the world camera's freshly calculated FOV relative to 50, keeping relative aiming/cutscene zoom. Resulting angles are clamped to 5-120 degrees." }, - "far clip": { - "Name": "Far clip distance", - "Class": "scale", + "render distance": { + "Name": "Render Distance", + "Class": "dropdown", "Section": "main", "Auto": false, - "Values": [1000, 25000], - "Type": "s32", - "Default": 25000, - "Increments": 1000, - "Description": "Far clipping plane of the world camera. 25000 preserves the original game's handling. Lower values hide distant geometry; this does not change object streaming or LOD distances." + "Name_Values": ["VERY LOW", "LOW", "MEDIUM", "DEFAULT", "(EXPERIMENTAL) HAS ISSUES"], + "Values": [1000, 5000, 12500, 25000, 35000], + "Default": 3, + "LegacyNumericOption": "far clip", + "Description": "Original BOTW Render Distance presets: VERY LOW 1000, LOW 5000, MEDIUM 12500, DEFAULT 25000, experimental 35000. Controls the world camera's far clipping plane, not object streaming or LOD. DEFAULT preserves the original game's handling." }, "emulator scale": { "Name": "Emulator scale", diff --git a/src/PatchInfo/Breath Of The Wild/PatchInfo.json b/src/PatchInfo/Breath Of The Wild/PatchInfo.json index 0831d39..b5266b8 100644 --- a/src/PatchInfo/Breath Of The Wild/PatchInfo.json +++ b/src/PatchInfo/Breath Of The Wild/PatchInfo.json @@ -14,27 +14,12 @@ "Cheats" : true, "SD" : false, "Variants": [ - { - "Name": "BOTW 1.9.0 - 60 FPS (Experimental)", - "SelectionID": "01007EF00011E000@1.9.0-fps", - "Versions": ["1.9.0"], - "Mod_Version": "0.1.1-experimental", - "ModName": "!!!BOTW 1.9.0 60 FPS", - "ModFolder": "NativeFPS", - "ModConfig": "", - "Config": "Options-1.9.json", - "Presets": "Presets-1.9.json", - "ExefsPatch": "FPS-1.9.json", - "ConflictingMods": ["!!!BOTW Optimizer", "!!!BOTW 1.9.0 Optimizer"], - "benchmarks": false, - "EmulationScale": false, - "Cheats": false - }, { "Name": "BOTW 1.9.0 - FPS + Graphics (Experimental)", "SelectionID": "01007EF00011E000@1.9.0-native", + "SelectionAliases": ["01007EF00011E000@1.9.0-fps"], "Versions": ["1.9.0"], - "Mod_Version": "0.2.0-experimental", + "Mod_Version": "0.2.1-experimental", "ModName": "!!!BOTW 1.9.0 Optimizer", "ModFolder": "NativeFPS", "ModConfig": "", diff --git a/src/PatchInfo/Breath Of The Wild/Presets-1.9.json b/src/PatchInfo/Breath Of The Wild/Presets-1.9.json deleted file mode 100644 index 2d1f207..0000000 --- a/src/PatchInfo/Breath Of The Wild/Presets-1.9.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "60 FPS (Experimental)": { - "fps": 0 - } -} diff --git a/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json index b6d29d6..75469b4 100644 --- a/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json +++ b/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json @@ -1,6 +1,6 @@ { - "45 FPS": {"fps": 45, "far clip": 25000}, - "60 FPS": {"fps": 60, "far clip": 25000}, - "90 FPS": {"fps": 90, "far clip": 25000}, - "120 FPS": {"fps": 120, "far clip": 25000} + "45 FPS": {"fps": 45, "render distance": 3}, + "60 FPS": {"fps": 60, "render distance": 3}, + "90 FPS": {"fps": 90, "render distance": 3}, + "120 FPS": {"fps": 120, "render distance": 3} } diff --git a/src/modules/GameManager/BotwNativePatch.py b/src/modules/GameManager/BotwNativePatch.py index b232577..de9fdab 100644 --- a/src/modules/GameManager/BotwNativePatch.py +++ b/src/modules/GameManager/BotwNativePatch.py @@ -58,8 +58,8 @@ def record(offset, expected, replacement, description): def build_patch(base_patch, payload_path, fps=60, far_clip=25000, fov=50): if isinstance(fps, bool) or not isinstance(fps, int) or not 20 <= fps <= 120: raise ValueError("FPS must be an integer between 20 and 120") - if isinstance(far_clip, bool) or not isinstance(far_clip, int) or not 1000 <= far_clip <= 25000: - raise ValueError("Far clip must be an integer between 1000 and 25000") + if isinstance(far_clip, bool) or not isinstance(far_clip, int) or not 1000 <= far_clip <= 35000: + raise ValueError("Far clip must be an integer between 1000 and 35000") if isinstance(fov, bool) or not isinstance(fov, int) or not 20 <= fov <= 120: raise ValueError("FOV must be an integer between 20 and 120") if base_patch["build_id"] != "CD57B23FA4BBAD65803D9788C01821EE00000000000000000000000000000000": diff --git a/src/modules/GameManager/FileManager.py b/src/modules/GameManager/FileManager.py index 7841c75..fb78744 100644 --- a/src/modules/GameManager/FileManager.py +++ b/src/modules/GameManager/FileManager.py @@ -481,9 +481,11 @@ def __CreateModPatch(filemgr, mode: str | None = None): settings = {} if patchInfo.NativePayload: choices = filemgr._manager.UserChoices + distance_option = filemgr._manager.UltracamPatchJson["NativeFPS"]["render distance"] + distance_index = distance_option["Name_Values"].index(choices["render distance"].get()) patch = build_botw_native_patch( patch, os.path.join(patchInfo.Folder, patchInfo.NativePayload), - fps=int(choices["fps"].get()), far_clip=int(choices["far clip"].get()), + fps=int(choices["fps"].get()), far_clip=distance_option["Values"][distance_index], fov=int(choices["fov"].get()), ) graphics = {key: choices[key].get() for key in ( diff --git a/src/modules/GameManager/GameManager.py b/src/modules/GameManager/GameManager.py index b22544f..b2afa58 100644 --- a/src/modules/GameManager/GameManager.py +++ b/src/modules/GameManager/GameManager.py @@ -68,6 +68,12 @@ def GetJsonByID(cls, ID: str) -> PatchInfo: if ID.lower() == item.SelectionID.lower(): return item + # Retired selections resolve to the current profile, without importing + # their old option encoding (e.g. FPS dropdown index 0 is not 0 FPS). + for item in cls.GamePatches: + if any(ID.lower() == alias.lower() for alias in item.SelectionAliases): + return item + # if we don't find anything return TOTK patch. for item in cls.GamePatches: if item.ID.lower() == cls._DefaultID.lower(): diff --git a/src/modules/GameManager/PatchInfo.py b/src/modules/GameManager/PatchInfo.py index b027d70..3919134 100644 --- a/src/modules/GameManager/PatchInfo.py +++ b/src/modules/GameManager/PatchInfo.py @@ -1,5 +1,5 @@ from modules.logger import log, superlog -import json, os +import json, math, os class PatchInfo: @@ -23,6 +23,7 @@ def __init__(self, folder: str, JsonFile: json): # fmt: skip self.Folder = folder self.ID = self.Json(JsonFile, "ID") # mandatory self.SelectionID = JsonFile.get("SelectionID", self.ID) + self.SelectionAliases = JsonFile.get("SelectionAliases", []) self.Name = self.Json(JsonFile, "Name") # mandatory self.Versions = self.Json(JsonFile, "Versions", []) self.ModName = self.Json(JsonFile, "ModName", "!!!NX-Optimizer") @@ -71,6 +72,27 @@ def LoadPresetsJson(self): with open(Location, "r", encoding="utf-8") as file: return {"Saved": {}} | json.load(file) + def MigrateUserConfig(self, config, options): + """Map retired numeric settings to the closest preset, in memory only.""" + section = self.SelectionID + if not config.has_section(section): + return + for group in options.values(): + for name, option in group.items(): + legacy = option.get("LegacyNumericOption") + if not legacy or config.has_option(section, name) or not config.has_option(section, legacy): + continue + try: + value = config.getfloat(section, legacy) + if not math.isfinite(value): + raise ValueError("Non-finite legacy setting") + values = option["Values"] + index = min(range(len(values)), key=lambda i: abs(values[i] - value)) + except ValueError: + index = option["Default"] + config.set(section, name, str(index)) + log.info(f"Migrated {legacy} to {name}: {option['Name_Values'][index]}") + def LoadCheatsJson(self): if self.Cheats is False: log.warning("No Cheats detected") diff --git a/src/modules/config.py b/src/modules/config.py index dc94ca3..34dd365 100644 --- a/src/modules/config.py +++ b/src/modules/config.py @@ -197,6 +197,7 @@ def load_user_choices(Manager, config_file, mode=None): config = configparser.ConfigParser() config.read(config_file, encoding="utf-8") + Manager._patchInfo.MigrateUserConfig(config, Manager.UltracamPatchJson) loadGameConfig(Manager, config) # Load the enable/disable choices diff --git a/tests/test_botw_native_patch.py b/tests/test_botw_native_patch.py index 390b8c7..c6b272c 100644 --- a/tests/test_botw_native_patch.py +++ b/tests/test_botw_native_patch.py @@ -59,6 +59,16 @@ def test_invalid_settings_do_not_silently_truncate_or_clamp(self): with self.subTest(values=values), self.assertRaises(ValueError): self.build(**values) + def test_all_render_distance_presets_reach_the_native_parameter(self): + options = json.loads((FOLDER / "Options-Native-1.9.json").read_text())["NativeFPS"]["render distance"] + for distance in options["Values"]: + with self.subTest(distance=distance): + patch = self.build(far_clip=distance) + records = {int(p["offset"], 0): p for p in patch["patches"]} + values = struct.unpack(" Date: Sat, 26 Sep 2026 18:14:22 +0200 Subject: [PATCH 3/4] Remove non-original BOTW emulator graphics options --- docs/BOTW-1.9.md | 19 ++++++++-------- .../Options-Native-1.9.json | 22 +------------------ .../Breath Of The Wild/PatchInfo.json | 2 +- src/modules/GameManager/BotwNativePatch.py | 14 +----------- src/modules/GameManager/FileManager.py | 7 ++---- tests/test_botw_native_patch.py | 8 ++----- tests/test_patch_profiles.py | 3 ++- 7 files changed, 18 insertions(+), 57 deletions(-) diff --git a/docs/BOTW-1.9.md b/docs/BOTW-1.9.md index 30ba4cc..e8cf12e 100644 --- a/docs/BOTW-1.9.md +++ b/docs/BOTW-1.9.md @@ -1,7 +1,7 @@ # BOTW 1.9.0: configurable FPS and graphics The experimental BOTW 1.9.0 profile provides a **20-120 FPS limiter with measured frame -time**, world-camera **FOV and far clip**, and emulator graphics controls for +time**, world-camera **FOV and Render Distance**, and **Resolution Scale** for the Switch version of BOTW 1.9.0. The user confirmed **45 FPS with normal game speed and a visibly wider FOV** in Eden nightly `5f142c7926`. @@ -38,9 +38,7 @@ an exported folder or if your emulator uses a different mod manager. | FPS limit | Integer 20-120, including 45; clock-based limiter with measured simulation time | | World camera FOV | 20-120; 50 preserves original behavior; other values scale the freshly calculated world-camera angle relative to 50 | | Render Distance | Original presets: VERY LOW (1000), LOW (5000), MEDIUM (12500), DEFAULT (25000), experimental (35000) | -| Resolution scale (emulator) | Keep current, 1x, 2x, 3x, 4x | -| Anti-aliasing (emulator) | Keep current, Off, FXAA, SMAA | -| Anisotropic filtering (emulator) | Keep current, Automatic, 2x, 4x, 8x, 16x | +| Resolution Scale | Emulator scaling: Keep current, 1x, 2x, 3x, 4x | The configurable mod is named `!!!BOTW 1.9.0 Optimizer` and needs **both its main and SDK patch files**. Disable any previously exported `!!!BOTW 1.9.0 60 FPS` mod and @@ -54,12 +52,13 @@ plane (previously labelled Far clip distance), not object streaming or LOD. **DEFAULT is 25000** and preserves the game's own far-plane handling. The native framebuffer resolution, native FXAA/DR disabling, shadows, free camera and other UltraCam features are not ported. -The three settings marked **emulator** affect its per-game configuration through -**Apply**. **Extract** writes instructions containing the requested settings but -does not change emulator configuration. Keep current is the default. Resolution -scale multiplies the game's output; AA Off disables only the emulator's added -AA, not the game's own FXAA. Current Eden's resolution indices are mapped -explicitly, including its 1/4x and 1.25x entries. +**Resolution Scale**, which was also available in the original application, +affects the emulator's per-game configuration through **Apply**. **Extract** +writes instructions with the requested scale but does not change emulator +configuration. Keep current is the default. Scaling multiplies the game's output. +Current Eden's indices are mapped explicitly, including its 1/4x and 1.25x entries. +The extra emulator AA/AF controls from the initial build have been removed; +their saved values are ignored and existing emulator AA/AF settings are preserved. ### Upgrading an earlier build diff --git a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json index 702831f..bf7b511 100644 --- a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json +++ b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json @@ -34,7 +34,7 @@ "Description": "Original BOTW Render Distance presets: VERY LOW 1000, LOW 5000, MEDIUM 12500, DEFAULT 25000, experimental 35000. Controls the world camera's far clipping plane, not object streaming or LOD. DEFAULT preserves the original game's handling." }, "emulator scale": { - "Name": "Emulator scale", + "Name": "Resolution Scale", "Class": "dropdown", "Section": "main", "Auto": false, @@ -42,26 +42,6 @@ "Values": [0, 1, 2, 3, 4], "Default": 0, "Description": "Emulator resolution scaling, applied by Apply. Keep current preserves your setting. Extract does not change emulator configuration. This does not change the game's native framebuffer resolution or its dynamic resolution logic." - }, - "emulator aa": { - "Name": "Emulator AA", - "Class": "dropdown", - "Section": "main", - "Auto": false, - "Name_Values": ["Keep current", "Off", "FXAA", "SMAA"], - "Values": [-1, 0, 1, 2], - "Default": 0, - "Description": "Post-processing anti-aliasing provided by the emulator. Applied by Apply only. Off disables the emulator's added AA, not BOTW's own anti-aliasing." - }, - "anisotropy": { - "Name": "Emulator AF", - "Class": "dropdown", - "Section": "main", - "Auto": false, - "Name_Values": ["Keep current", "Automatic", "2x", "4x", "8x", "16x"], - "Values": [-1, 0, 2, 4, 8, 16], - "Default": 0, - "Description": "Emulator texture filtering. Applied by Apply only; Keep current preserves the existing setting." } } } diff --git a/src/PatchInfo/Breath Of The Wild/PatchInfo.json b/src/PatchInfo/Breath Of The Wild/PatchInfo.json index b5266b8..01ff34e 100644 --- a/src/PatchInfo/Breath Of The Wild/PatchInfo.json +++ b/src/PatchInfo/Breath Of The Wild/PatchInfo.json @@ -19,7 +19,7 @@ "SelectionID": "01007EF00011E000@1.9.0-native", "SelectionAliases": ["01007EF00011E000@1.9.0-fps"], "Versions": ["1.9.0"], - "Mod_Version": "0.2.1-experimental", + "Mod_Version": "0.2.2-experimental", "ModName": "!!!BOTW 1.9.0 Optimizer", "ModFolder": "NativeFPS", "ModConfig": "", diff --git a/src/modules/GameManager/BotwNativePatch.py b/src/modules/GameManager/BotwNativePatch.py index de9fdab..6f9ebde 100644 --- a/src/modules/GameManager/BotwNativePatch.py +++ b/src/modules/GameManager/BotwNativePatch.py @@ -12,22 +12,14 @@ STATE = 0x01DB21E0 -def emulator_graphics(emulator, scale="Keep current", aa="Keep current", anisotropy="Keep current"): +def emulator_graphics(emulator, scale="Keep current"): """Explicit enum mapping; no changes for settings left at Keep current.""" if scale not in ("Keep current", "1x", "2x", "3x", "4x"): raise ValueError("Unknown emulator resolution scale") - if aa not in ("Keep current", "Off", "FXAA", "SMAA"): - raise ValueError("Unknown emulator anti-aliasing mode") - if anisotropy not in ("Keep current", "Automatic", "2x", "4x", "8x", "16x"): - raise ValueError("Unknown anisotropic filtering mode") settings = {} if emulator.lower() == "ryujinx": if scale != "Keep current": settings["res_scale"] = int(scale[:-1]) - if aa != "Keep current": - settings["anti_aliasing"] = {"Off": "None", "FXAA": "Fxaa", "SMAA": "SmaaHigh"}[aa] - if anisotropy != "Keep current": - settings["max_anisotropy"] = -1 if anisotropy == "Automatic" else int(anisotropy[:-1]) else: # Current Eden includes both 1/4x and 1.25x entries. Original Yuzu's # integer scale indices are different; scale - 1 is not an enum index. @@ -36,10 +28,6 @@ def emulator_graphics(emulator, scale="Keep current", aa="Keep current", anisotr } if scale != "Keep current": settings["resolution_setup"] = str(scales[scale]) - if aa != "Keep current": - settings["anti_aliasing"] = str({"Off": 0, "FXAA": 1, "SMAA": 2}[aa]) - if anisotropy != "Keep current": - settings["max_anisotropy"] = str({"Automatic": 0, "2x": 2, "4x": 3, "8x": 4, "16x": 5}[anisotropy]) return settings diff --git a/src/modules/GameManager/FileManager.py b/src/modules/GameManager/FileManager.py index fb78744..5339ed3 100644 --- a/src/modules/GameManager/FileManager.py +++ b/src/modules/GameManager/FileManager.py @@ -488,12 +488,9 @@ def __CreateModPatch(filemgr, mode: str | None = None): fps=int(choices["fps"].get()), far_clip=distance_option["Values"][distance_index], fov=int(choices["fov"].get()), ) - graphics = {key: choices[key].get() for key in ( - "emulator scale", "emulator aa", "anisotropy" - )} + graphics = {"emulator scale": choices["emulator scale"].get()} emulator = filemgr.LegacyEmuName() if NxMode.isLegacy() else "ryujinx" - settings = emulator_graphics(emulator, scale=graphics["emulator scale"], - aa=graphics["emulator aa"], anisotropy=graphics["anisotropy"]) + settings = emulator_graphics(emulator, scale=graphics["emulator scale"]) patch["emulator_settings"] = graphics root = os.path.join(os.getcwd(), "Extracted Files") if filemgr.is_extracting else modDir destination = os.path.join(root, modName) diff --git a/tests/test_botw_native_patch.py b/tests/test_botw_native_patch.py index c6b272c..f052e83 100644 --- a/tests/test_botw_native_patch.py +++ b/tests/test_botw_native_patch.py @@ -85,13 +85,9 @@ def test_duplicate_module_ids_are_rejected_before_writing(self): def test_emulator_graphics_enum_mapping(self): self.assertEqual(emulator_graphics("eden"), {}) - self.assertEqual(emulator_graphics("eden", "2x", "SMAA", "16x"), { - "resolution_setup": "6", "anti_aliasing": "2", "max_anisotropy": "5", - }) + self.assertEqual(emulator_graphics("eden", "2x"), {"resolution_setup": "6"}) self.assertEqual(emulator_graphics("yuzu", "2x"), {"resolution_setup": "4"}) - self.assertEqual(emulator_graphics("ryujinx", "2x", "SMAA", "16x"), { - "res_scale": 2, "anti_aliasing": "SmaaHigh", "max_anisotropy": 16, - }) + self.assertEqual(emulator_graphics("ryujinx", "2x"), {"res_scale": 2}) with self.assertRaises(ValueError): emulator_graphics("eden", "1.25x") diff --git a/tests/test_patch_profiles.py b/tests/test_patch_profiles.py index ac948ed..56983ea 100644 --- a/tests/test_patch_profiles.py +++ b/tests/test_patch_profiles.py @@ -62,7 +62,8 @@ def test_configurable_profile_exposes_real_native_and_emulator_settings(self): self.games.CreatePatchInfo(ROOT / "src/PatchInfo/Breath Of The Wild") configurable = self.games.GetJsonByID("01007EF00011E000@1.9.0-native") options = configurable.LoadJson()["NativeFPS"] - self.assertEqual(set(options), {"fps", "fov", "render distance", "emulator scale", "emulator aa", "anisotropy"}) + self.assertEqual(set(options), {"fps", "fov", "render distance", "emulator scale"}) + self.assertEqual(options["emulator scale"]["Name"], "Resolution Scale") self.assertEqual(options["fps"]["Values"], [20, 120]) self.assertEqual(configurable.NativePayload, "NativeFPS-1.9.json") self.assertIn("!!!BOTW 1.9.0 60 FPS", configurable.ConflictingMods) From e74292271ca97da61617d1f49d7ded44b3d46ca0 Mon Sep 17 00:00:00 2001 From: Mateusz Kaflowski Date: Sat, 26 Sep 2026 20:48:25 +0200 Subject: [PATCH 4/4] Match BOTW 1.9 defaults to the original 1.6 profile --- docs/BOTW-1.9.md | 8 +++++++- src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json | 4 ++-- src/PatchInfo/Breath Of The Wild/PatchInfo.json | 2 +- src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/BOTW-1.9.md b/docs/BOTW-1.9.md index e8cf12e..45828e6 100644 --- a/docs/BOTW-1.9.md +++ b/docs/BOTW-1.9.md @@ -40,6 +40,11 @@ an exported folder or if your emulator uses a different mod manager. | Render Distance | Original presets: VERY LOW (1000), LOW (5000), MEDIUM (12500), DEFAULT (25000), experimental (35000) | | Resolution Scale | Emulator scaling: Keep current, 1x, 2x, 3x, 4x | +Shared defaults match the original BOTW 1.6 UI: **60 FPS, FOV 50, Render Distance +DEFAULT (25000), Resolution Scale 1x**. Select **Default** under Optimizer Presets +to reset all four settings. Previously saved custom choices still take precedence +on startup until you explicitly reset them. + The configurable mod is named `!!!BOTW 1.9.0 Optimizer` and needs **both its main and SDK patch files**. Disable any previously exported `!!!BOTW 1.9.0 60 FPS` mod and UltraCam when using it. Apply manages these known conflicts; manual installation @@ -55,7 +60,8 @@ disabling, shadows, free camera and other UltraCam features are not ported. **Resolution Scale**, which was also available in the original application, affects the emulator's per-game configuration through **Apply**. **Extract** writes instructions with the requested scale but does not change emulator -configuration. Keep current is the default. Scaling multiplies the game's output. +configuration. The default is **1x**, matching the original 1.6 profile; +Keep current is available as an explicit choice. Scaling multiplies the game's output. Current Eden's indices are mapped explicitly, including its 1/4x and 1.25x entries. The extra emulator AA/AF controls from the initial build have been removed; their saved values are ignored and existing emulator AA/AF settings are preserved. diff --git a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json index bf7b511..6345c1e 100644 --- a/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json +++ b/src/PatchInfo/Breath Of The Wild/Options-Native-1.9.json @@ -40,8 +40,8 @@ "Auto": false, "Name_Values": ["Keep current", "1x", "2x", "3x", "4x"], "Values": [0, 1, 2, 3, 4], - "Default": 0, - "Description": "Emulator resolution scaling, applied by Apply. Keep current preserves your setting. Extract does not change emulator configuration. This does not change the game's native framebuffer resolution or its dynamic resolution logic." + "Default": 1, + "Description": "Emulator resolution scaling, applied by Apply. Default is 1x, as in the original BOTW 1.6 profile. Keep current preserves your setting. Extract does not change emulator configuration. This does not change the game's native framebuffer resolution or its dynamic resolution logic." } } } diff --git a/src/PatchInfo/Breath Of The Wild/PatchInfo.json b/src/PatchInfo/Breath Of The Wild/PatchInfo.json index 01ff34e..d5de3d3 100644 --- a/src/PatchInfo/Breath Of The Wild/PatchInfo.json +++ b/src/PatchInfo/Breath Of The Wild/PatchInfo.json @@ -19,7 +19,7 @@ "SelectionID": "01007EF00011E000@1.9.0-native", "SelectionAliases": ["01007EF00011E000@1.9.0-fps"], "Versions": ["1.9.0"], - "Mod_Version": "0.2.2-experimental", + "Mod_Version": "0.2.3-experimental", "ModName": "!!!BOTW 1.9.0 Optimizer", "ModFolder": "NativeFPS", "ModConfig": "", diff --git a/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json b/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json index 75469b4..9b7b092 100644 --- a/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json +++ b/src/PatchInfo/Breath Of The Wild/Presets-Native-1.9.json @@ -1,4 +1,5 @@ { + "Default": {}, "45 FPS": {"fps": 45, "render distance": 3}, "60 FPS": {"fps": 60, "render distance": 3}, "90 FPS": {"fps": 90, "render distance": 3},