Files
NX2navi/runtime/mouse_direct.c
chrg6 861357b2a4 Make the direct mouse camera work on game 1.1.1.
mouse_mode = direct is the default, but on 1.1.1 it did nothing: only the 1.4.3 patch table had the sites, so the mouse always went through the stick emulation, which gives no turn at slow mouse speeds and tops out at about 107 degrees a second. The 1.1.1 table now has the set mouse_direct (on by default): at main+0x37c45c, where the camera's input update holds the processed stick in s1 and s0, a host call performs the displaced store and adds the raw mouse counts to both, so the mouse meets no dead zone and no curve; at main+0x37c4f0 a second call clears the idle bit for an update the mouse moved in. The counts are divided by the frames the game steps per update and by the stick camera speed, so the same hand movement turns the same angle at every frame rate. The Controls tab gets the mouse mode and mouse sensitivity rows, and mouse_mode = stick or NX2NAVI_MOUSE_DIRECT_PATCH=0 gives the old behaviour.

Checked here: the generator lifts the 1.1.1 game with the two sites (it compares each site's original word), the lifted game compiles and links with the runtime, and tests/mouse_direct_check.py passes its 1.1.1 harness (register contract, displaced store and load, equal turn at every frame rate and stick speed, the reset pause, capture and mode). Not checked here: the game was not run, so aiming with the mouse was not tried, and the differential tests against Unicorn were not run because no Python on this PC has Unicorn. The contributor measured 0.0800 degrees per count on both axes in the game.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
(cherry picked from commit bb8fecfbe43517006eafd7be9c22e8dbe9601de7)
2026-11-11 02:36:54 -05:00

426 lines
21 KiB
C

// Direct mouse input for NX2navi 1.4.3. The camera controller's input update (main+0x874c1c) turns HID stick floats
// into a radial dead zone, clamps the radius to one and applies StickExponent. The mouse enters after all three,
// at main+0x874cb4, before the game's X/Y inversion and input-activity flags. It never becomes a HID stick value.
// Free orbit removes that sample again before the camera's additional vertical square/velocity filters and adds
// raw-count angular deltas at its spherical-angle output instead. Object manipulation has a separate post-curve hook.
// The ordinary stick's result stays, so a controller still uses the game's own response.
//
// This is deliberately not a render-camera override: the game remains responsible for aiming, camera collision,
// lock-on and scripted cameras. Which of its other modes consume this camera input needs in-game verification.
#include <math.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "cfg.h"
#include "host.h"
#include "input.h"
#include "input_wsys.h"
#include "platform/platform.h"
#if __has_include("patches.h")
#include "patches.h"
#endif
static int direct_mode(void) {
// cfg_value: unset is the settings table's default (direct), as the menu shows it
return !strcmp(cfg_value("NX2NAVI_MOUSE_MODE"), "direct") && rt_patch_enabled(rt_patch_set("mouse_direct"));
}
static uint64_t last_update;
static uint64_t hand_update;
static int accepts_mouse;
static int hand_active(uint64_t now) {
uint64_t last = __atomic_load_n(&hand_update, __ATOMIC_ACQUIRE);
return last && now - last < 150000000ull;
}
int input_direct_mouse_active(void) {
uint64_t last = __atomic_load_n(&last_update, __ATOMIC_ACQUIRE);
return direct_mode() && (__atomic_load_n(&accepts_mouse, __ATOMIC_RELAXED) || hand_active(plat_now_ns())) && last &&
plat_now_ns() - last < 150000000ull;
}
int input_direct_mouse_enabled(void) { return direct_mode(); }
int input_direct_mouse_available(void) { return rt_patch_enabled(rt_patch_set("mouse_direct")); }
void input_direct_mouse_radial(int32_t dx, int32_t dy, float sensitivity, float cursor[2], int32_t out[2]) {
if (!(sensitivity > 0)) { cursor[0] = cursor[1] = 0; out[0] = out[1] = 0; return; }
cursor[0] += dx * (double)sensitivity / 200;
cursor[1] -= dy * (double)sensitivity / 200;
double length = hypot(cursor[0], cursor[1]);
if (length > 1) { cursor[0] /= length; cursor[1] /= length; }
out[0] = (int32_t)lrint(cursor[0] * 32767);
out[1] = (int32_t)lrint(cursor[1] * 32767);
}
void input_direct_mouse_row(int32_t dx, float sensitivity, uint64_t now, InputMouseRow *row, int32_t out[2]) {
const float step_counts = 32;
out[0] = out[1] = 0;
if (!(sensitivity > 0)) { *row = (InputMouseRow){0}; return; }
if (dx) {
// Reversing direction cancels any remainder from the previous drag. Bound a fast flick to one pending step;
// a stopped mouse never starts another pulse, regardless of how often HID is polled.
if ((dx < 0 && row->distance > 0) || (dx > 0 && row->distance < 0)) row->distance = 0;
row->distance = fmaxf(-2 * step_counts, fminf(2 * step_counts, row->distance + dx * sensitivity));
}
if (row->direction) {
if (now < row->pulse_until && (!dx || (dx > 0 ? 1 : -1) == row->direction)) {
out[0] = row->direction * 32767;
return;
}
row->direction = 0;
row->neutral_until = now + 32000000ull;
return; // publish neutral even when a slow frame skipped the pulse's deadline
}
if (!dx || now < row->neutral_until || fabsf(row->distance) < step_counts) return;
row->direction = row->distance > 0 ? 1 : -1;
row->distance = fmodf(row->distance, step_counts);
row->pulse_until = now + 32000000ull;
out[0] = row->direction * 32767;
}
#ifdef RT_PATCH_PAGE
#define HOST_MOUSE_DIRECT (RT_PATCH_PAGE + 0xfd0)
#define HOST_MOUSE_ACTIVITY (RT_PATCH_PAGE + 0xfc0)
#define HOST_MOUSE_ORBIT_BEGIN (RT_PATCH_PAGE + 0xfb0)
#define HOST_MOUSE_ORBIT_ANGLES (RT_PATCH_PAGE + 0xfa0)
#define HOST_MOUSE_HAND (RT_PATCH_PAGE + 0xf90)
static _Thread_local uint64_t mouse_input_object;
typedef struct {
uint64_t object, generation, when;
float pad[2], motion[2], angle[2]; // post-inversion controller input, mouse input, mouse yaw/pitch in degrees
int camera_enabled;
} MouseSample;
static MouseSample sample;
static PlatMutex sample_lock = PLAT_MUTEX_INIT;
static _Thread_local struct { uint64_t object; MouseSample mouse; } orbit;
typedef struct {
uint64_t object, generation, input;
float pitch, original_target;
int hold_pitch, target_saved;
} OrbitTrack;
static OrbitTrack orbit_seen[16];
// sample_lock held. Camera objects can coexist while the game blends between their outputs.
static OrbitTrack *orbit_track(uint64_t object) {
int oldest = 0;
for (int i = 0; i < 16; i++) {
if (orbit_seen[i].object == object) return &orbit_seen[i];
if (orbit_seen[i].generation < orbit_seen[oldest].generation) oldest = i;
}
orbit_seen[oldest] = (OrbitTrack){.object = object};
return &orbit_seen[oldest];
}
static int orbit_sample_once(uint64_t object, uint64_t generation) {
plat_mutex_lock(&sample_lock);
OrbitTrack *track = orbit_track(object);
int fresh = track->generation != generation;
if (fresh) track->generation = generation;
plat_mutex_unlock(&sample_lock);
return fresh;
}
static float guest_float(uint64_t at) { float f; memcpy(&f, (void *)(uintptr_t)at, sizeof f); return f; }
static void guest_float_store(uint64_t at, float f) { memcpy((void *)(uintptr_t)at, &f, sizeof f); }
static uint64_t guest_pointer(uint64_t at) { uint64_t p; memcpy(&p, (void *)(uintptr_t)at, sizeof p); return p; }
static MouseSample sample_read(void) {
plat_mutex_lock(&sample_lock);
MouseSample m = sample;
plat_mutex_unlock(&sample_lock);
return m;
}
static float wrap_degrees(float a) { return remainderf(a, 360.0f); }
// main+0x191d78 builds the pitch target from +0x15c and +0x11c; the orbit update filters +0x108 toward it and adds
// (1 - +0x88) * +0x10c at output. Updating only +0x108 makes the camera spring back to the unchanged target.
static float orbit_pitch_offset(uint64_t object) {
return (1 - guest_float(object + 0x88)) * guest_float(object + 0x10c);
}
static void orbit_pitch_target(uint64_t object, float pitch) {
guest_float_store(object + 0x15c, pitch - orbit_pitch_offset(object) - guest_float(object + 0x11c));
}
static void orbit_pitch_release(OrbitTrack *track) {
if (track->target_saved) guest_float_store(track->object + 0x15c, track->original_target);
track->hold_pitch = track->target_saved = 0;
}
// A host call at an instruction site, not an AAPCS64 function replacement. Preserve every guest register except
// s9/s10, which carry the processed camera input, and perform the displaced store of s9 at [x20 + 0x420].
static void camera_input(Cpu *c) {
mouse_input_object = 0;
uint32_t flags = (uint32_t)c->x[21];
int accept = !(flags & 1) && !input_direct_mouse_selector_active();
__atomic_store_n(&accepts_mouse, accept, __ATOMIC_RELAXED);
__atomic_store_n(&last_update, plat_now_ns(), __ATOMIC_RELEASE);
MouseSample next = {.object = c->x[20], .when = plat_now_ns(), .camera_enabled = accept};
memcpy(&next.pad[0], &c->v[9], sizeof(float));
memcpy(&next.pad[1], &c->v[10], sizeof(float));
if (flags & 2) next.pad[0] = -next.pad[0];
if (flags & 4) next.pad[1] = -next.pad[1];
if (direct_mode()) {
int32_t dx, dy;
input_direct_mouse_sample(!input_direct_mouse_selector_active() && (accept || hand_active(plat_now_ns())), &dx, &dy);
float sensitivity = (float)cfg_double("NX2NAVI_MOUSE_SENS");
sensitivity = fminf(10, fmaxf(0, sensitivity));
// The game multiplies camera input by its 30 Hz frame ratio later. Divide by that exact ratio, rather
// than wall-clock time: an equal distance of mouse motion then gives equal input at every frame rate.
float ratio = 1;
if (frame_step_realtime()) memcpy(&ratio, rt_patch_page(), sizeof ratio);
else {
double hz = frame_step_game_hz(); // frame generation's frames are not the game's
if (hz > 0) ratio = (float)(30 / hz);
}
if (isfinite(ratio) && ratio > 0 && sensitivity > 0 && (dx || dy)) {
if (accept) mouse_input_object = c->x[20];
float x, y;
memcpy(&x, &c->v[9], sizeof x);
memcpy(&y, &c->v[10], sizeof y);
float scale = 0.01f * sensitivity / ratio;
next.motion[0] = dx * scale * (flags & 2 ? -1 : 1);
next.motion[1] = -dy * scale * (flags & 4 ? -1 : 1);
next.angle[0] = -dx * 0.08f * sensitivity * (flags & 2 ? -1 : 1);
next.angle[1] = dy * 0.08f * sensitivity * (flags & 4 ? -1 : 1);
if (accept) { x += dx * scale; y -= dy * scale; }
uint32_t xb, yb;
memcpy(&xb, &x, sizeof xb);
memcpy(&yb, &y, sizeof yb);
c->v[9] = xb;
c->v[10] = yb;
static int logged;
if (!logged) {
logged = 1;
rt_log("mouse: direct camera input after the stick dead zone, clamp and exponent");
}
}
}
plat_mutex_lock(&sample_lock);
next.generation = sample.generation + 1;
sample = next;
plat_mutex_unlock(&sample_lock);
uint32_t original = (uint32_t)c->v[9];
memcpy((void *)(uintptr_t)(c->x[20] + 0x420), &original, sizeof original);
}
// Free camera has another signed-square curve for its vertical axis, plus mode-dependent velocity filters.
// Remove only this mouse sample before that controller runs. At its final spherical-angle output, add degrees
// directly and move its persistent pitch/yaw by the same amounts so the next update starts at the new orientation.
// The controller still handles its gamepad input, following, collision and focus normally.
static void orbit_begin(Cpu *c) {
orbit.object = 0;
uint64_t object = c->x[0];
if (direct_mode()) {
MouseSample m = sample_read();
uint64_t input = guest_pointer(object + 0x20);
if (m.object == input && m.camera_enabled) {
// The hooks take a step when the mouse moved in it, or while the mouse holds the pitch. A step of the
// gamepad alone (no mouse motion, no hold: a player who never moved the mouse) keeps the game's input.
int moved = m.motion[0] != 0 || m.motion[1] != 0, take;
uint64_t held = guest_pointer(input + 0x148);
uint8_t override;
memcpy(&override, (void *)(uintptr_t)(input + 0x15c), 1);
plat_mutex_lock(&sample_lock);
OrbitTrack *track = orbit_track(object);
if (track->input != input) track->hold_pitch = track->target_saved = 0;
if (m.pad[1] != 0 || (held & 0x20) || (override & 0x40)) orbit_pitch_release(track);
take = moved || track->hold_pitch;
if (take) track->input = input;
if (track->hold_pitch) orbit_pitch_target(object, track->pitch);
plat_mutex_unlock(&sample_lock);
if (moved) { // the mouse's part of this step's stick goes to the final angles instead
guest_float_store(input + 0x420, m.pad[0]);
guest_float_store(input + 0x424, m.pad[1]);
}
if (take) orbit.object = object, orbit.mouse = m;
}
} else {
plat_mutex_lock(&sample_lock);
orbit_pitch_release(orbit_track(object));
plat_mutex_unlock(&sample_lock);
}
c->x[8] = guest_pointer(object + 8); // displaced ldr x8, [x0, #8]
}
static void orbit_angles(Cpu *c) {
uint64_t object = c->x[20];
if (orbit.object == object) {
float yaw_delta = orbit.mouse.angle[0], pitch_delta = orbit.mouse.angle[1];
int fresh = plat_now_ns() - orbit.mouse.when < 150000000ull &&
orbit_sample_once(object, orbit.mouse.generation);
if (!fresh) yaw_delta = pitch_delta = 0;
plat_mutex_lock(&sample_lock);
OrbitTrack *track = orbit_track(object);
if (track->hold_pitch || yaw_delta || pitch_delta) {
float pitch = guest_float(c->sp + 0x44), yaw = guest_float(c->sp + 0x48);
float low = guest_float(object + 0xf8), high = guest_float(object + 0xfc);
if (!isfinite(low) || !isfinite(high) || low >= high) low = -89.9f, high = 89.9f;
low = fmaxf(-89.9f, low); high = fminf(89.9f, high);
// The game can recalculate its pitch after orbit_begin (following, mode changes and another filter).
// Integrate from our last requested angle, then correct the final output even on stationary frames.
float base = track->hold_pitch ? track->pitch : pitch;
float new_pitch = fminf(high, fmaxf(low, base + pitch_delta));
guest_float_store(c->sp + 0x44, new_pitch);
guest_float_store(c->sp + 0x48, wrap_degrees(yaw + yaw_delta));
guest_float_store(object + 0x108, wrap_degrees(new_pitch - orbit_pitch_offset(object)));
guest_float_store(object + 0x120, wrap_degrees(guest_float(object + 0x120) + yaw_delta));
if (!track->target_saved) track->original_target = guest_float(object + 0x15c), track->target_saved = 1;
track->pitch = new_pitch; track->input = orbit.mouse.object; track->hold_pitch = 1;
orbit_pitch_target(object, new_pitch);
static int logged;
if (!logged) {
logged = 1;
rt_log("mouse: free-camera angles directly, 0.08 degrees per count at sensitivity 1, equal X/Y");
}
}
plat_mutex_unlock(&sample_lock);
orbit.object = 0;
}
uint32_t bits;
memcpy(&bits, (const void *)(uintptr_t)(c->sp + 0x44), sizeof bits);
c->v[12] = bits; // displaced ldr s12, [sp, #0x44]
}
// Object manipulation reads the player's manipulation input rather than the camera's processed stick. Insert the mouse
// after that controller's per-axis power curves (main+0x1d59460), preserving the keyboard/controller contribution.
static void hand_input(Cpu *c) {
__atomic_store_n(&hand_update, plat_now_ns(), __ATOMIC_RELEASE);
static _Thread_local uint64_t object, generation;
if (direct_mode()) {
MouseSample m = sample_read();
if (m.generation != generation || object != c->x[19]) {
float x = guest_float(c->sp + 0x60), y;
memcpy(&y, &c->v[15], sizeof y);
x += m.motion[0];
y += m.motion[1];
guest_float_store(c->sp + 0x60, x);
uint32_t bits;
memcpy(&bits, &y, sizeof bits);
c->v[15] = bits;
object = c->x[19]; generation = m.generation;
static int logged;
if (!logged && (m.motion[0] || m.motion[1])) {
logged = 1;
rt_log("mouse: held-object input after the manipulation controller's power curves");
}
}
}
uint32_t original = (uint32_t)c->x[8];
memcpy((void *)(uintptr_t)(c->sp + 0x48), &original, sizeof original);
}
// The game also uses a 10% activity threshold after processing the stick. A one-count mouse movement must count
// as active, even when its displacement is smaller than that threshold. The site is the displaced ldp d9, d8, [sp, #8]
// just before the update's last `str w8, [x20, #0x418]`: w8 holds the flags then (bit 0 raw input, bit 4 any input,
// bits 3 and 5 processed input below the threshold), so they are changed in w8; a write to +0x418 would be overwritten
// by that store. No flag is changed for controller-only input.
static void camera_activity(Cpu *c) {
if (mouse_input_object && mouse_input_object == c->x[20]) {
uint32_t flags = (uint32_t)c->x[8];
c->x[8] = (flags | 0x11u) & ~0x28u;
mouse_input_object = 0;
}
uint64_t d9, d8;
memcpy(&d9, (const void *)(uintptr_t)(c->sp + 8), sizeof d9);
memcpy(&d8, (const void *)(uintptr_t)(c->sp + 16), sizeof d8);
c->v[9] = d9;
c->v[8] = d8;
}
// ---- Ocarina 1.1.1 (recomp/patches-v111.txt, set mouse_direct)
// Its camera input update is main+0x37c300: the same steps as above with other registers and offsets. At the site s1
// and s0 hold the processed x and y (both zero while the camera-reset timer at +0x5e0 runs), x20 the input object and
// x21 the flags (bit 0 camera input off, bit 1 invert x, bit 2 invert y). The two stores, the inversion and the idle
// test that follow read s1 and s0, so the counts added here go through them as the stick's part does.
#define OOT_STICK 0x538 // the processed stick: x, and y at +0x53c
#define OOT_RESET_TIMER 0x5e0 // 60 Hz frames left of the pause after a camera reset (right stick click)
#define OOT_FLAG_IDLE 0x10u // flags (+0x530): the processed stick is under the game's 10% idle threshold
// NX2NAVI_MOUSE_TRACE=1 (test runs): one line per camera input update, with the stick the update made, the counts taken
// and what they added. vk_dlaa.c adds the camera's direction per frame, so a scripted run gives degrees per count.
static int mouse_trace(void) {
static int on = -1;
if (on < 0) {
const char *e = cfg_get("NX2NAVI_MOUSE_TRACE");
on = e && *e && strcmp(e, "0");
}
return on;
}
static void camera_input_oot(Cpu *c) {
mouse_input_object = 0;
uint64_t object = c->x[20], now = plat_now_ns();
uint32_t flags = (uint32_t)c->x[21];
float timer = guest_float(object + OOT_RESET_TIMER);
int accept = !(flags & 1) && !(timer > 0) && !input_direct_mouse_selector_active();
__atomic_store_n(&accepts_mouse, accept, __ATOMIC_RELAXED);
__atomic_store_n(&last_update, now, __ATOMIC_RELEASE);
float x, y, add[2] = {0, 0}, frames = frame_step_frames();
memcpy(&x, &c->v[1], sizeof x);
memcpy(&y, &c->v[0], sizeof y);
int32_t dx = 0, dy = 0;
if (direct_mode()) {
input_direct_mouse_sample(accept, &dx, &dy);
float sensitivity = fminf(10, fmaxf(0, (float)cfg_double("NX2NAVI_MOUSE_SENS")));
// The game multiplies the processed stick by the frames it steps and by its camera speeds, which
// NX2NAVI_STICK_SENS scales (camera_speed.c): divided by both, a count turns the camera by the same angle at
// every frame rate and stick speed. Nothing after the site clamps the sum, so a fast flick is not cut short.
float per = frames * cam_speed_applied();
if (accept && sensitivity > 0 && isfinite(per) && per > 0 && (dx || dy)) {
mouse_input_object = object;
add[0] = dx * (float)INPUT_OOT_COUNT_X * sensitivity / per;
add[1] = -dy * (float)INPUT_OOT_COUNT_Y * sensitivity / per;
float nx = x + add[0], ny = y + add[1];
uint32_t xb, yb;
memcpy(&xb, &nx, sizeof xb);
memcpy(&yb, &ny, sizeof yb);
c->v[1] = xb;
c->v[0] = yb;
static int logged;
if (!logged) {
logged = 1;
rt_log("mouse: direct camera input after the stick dead zone, clamp and exponent (Ocarina 1.1.1)");
}
}
}
if (mouse_trace())
rt_log("mousetrace: in t=%.3f frames=%.4f flags=%x accept=%d timer=%.2f stick=%.5f,%.5f counts=%d,%d add=%.5f,%.5f",
now / 1e6, frames, flags, accept, timer, x, y, dx, dy, add[0], add[1]);
uint32_t original = (uint32_t)c->v[1];
memcpy((void *)(uintptr_t)(object + OOT_STICK), &original, sizeof original); // displaced str s1, [x20, #0x538]
}
// The idle test follows the first site and already counts the mouse's part, but one slow count adds less than the
// game's 10% threshold: an update the mouse moved in is not idle. The site is the displaced ldp d9, d8, [sp, #0x10]
// before the update's last `str w8, [x20, #0x530]`, with the flags in w8.
static void camera_activity_oot(Cpu *c) {
if (mouse_input_object && mouse_input_object == c->x[20]) {
c->x[8] = (uint32_t)c->x[8] & ~OOT_FLAG_IDLE;
mouse_input_object = 0;
}
uint64_t d9, d8;
memcpy(&d9, (const void *)(uintptr_t)(c->sp + 0x10), sizeof d9);
memcpy(&d8, (const void *)(uintptr_t)(c->sp + 0x18), sizeof d8);
c->v[9] = d9;
c->v[8] = d8;
}
__attribute__((constructor)) static void mouse_direct_register(void) {
if (rt_game_version == 111) { // Ocarina: two sites of its own, no orbit or held-object site
rt_register_host(HOST_MOUSE_DIRECT, camera_input_oot);
rt_register_host(HOST_MOUSE_ACTIVITY, camera_activity_oot);
return;
}
rt_register_host(HOST_MOUSE_DIRECT, camera_input);
rt_register_host(HOST_MOUSE_ACTIVITY, camera_activity);
rt_register_host(HOST_MOUSE_ORBIT_BEGIN, orbit_begin);
rt_register_host(HOST_MOUSE_ORBIT_ANGLES, orbit_angles);
rt_register_host(HOST_MOUSE_HAND, hand_input);
}
#endif