From 5159b55f77136487f06a11383025b46295264965 Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 08:00:05 -0500 Subject: [PATCH 01/10] Program cache: bounds-check records, one instance per cache file A corrupt record in hookshot_programs.bin crashed the Presentation Thread in program_cache_load: Reader::list read a garbage byte count for st.c1 and copied past the record's buffer into an unmapped page (hookshot_vk.dll+0x190fe1, exception 0xc0000005). - Reader now carries an end pointer; reads past it set `bad`, counts are capped by the bytes left, and a bad record is logged, skipped and translated again instead of crashing. - disk_init takes a per-path named mutex. A second Hookshot started while the first is writing used to see the record being written as cut short and truncate the file under the running instance; it now runs without the cache instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- gpu/vk/vk_shader.cpp | 67 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/gpu/vk/vk_shader.cpp b/gpu/vk/vk_shader.cpp index e3471b4a..0dcb0c43 100644 --- a/gpu/vk/vk_shader.cpp +++ b/gpu/vk/vk_shader.cpp @@ -11,6 +11,7 @@ // vk_pipeline.cpp lays out from the stages' Info the same way. #include #include +#include #include #include #include @@ -766,6 +767,20 @@ void disk_init() { const DWORD n = GetModuleFileNameA(nullptr, exe, MAX_PATH); std::string path(exe, n); path = path.substr(0, path.find_last_of("\\/") + 1) + "hookshot_programs.bin"; + // (one Hookshot at a time on a cache: a second one, started while the first wrote, took the record being written for one + // cut short and cut the file there, under the first, whose list of records then named bytes written over since. Held + // until the process ends) + { + uint64_t h = 0xCBF29CE484222325ull; + for (const char ch : path) h = (h ^ (uint8_t)tolower((unsigned char)ch)) * 1099511628211ull; + char name[64]; + snprintf(name, sizeof name, "Local\\HookshotProgramCache_%016llx", (unsigned long long)h); + const HANDLE held = CreateMutexA(nullptr, FALSE, name); + if (!held || GetLastError() == ERROR_ALREADY_EXISTS) { + logf("vk: program cache %s: another Hookshot is using it: this one runs without it", path.c_str()); + return; + } + } const uint64_t stamp = disk_stamp(); int64_t good = 0, whole = 0; if (FILE* f = fopen(path.c_str(), "rb")) { @@ -816,18 +831,35 @@ template void put_list(std::vector& o, const V& v) { put(o, (uint64_t)v.size()); for (const auto& e : v) put(o, e); } +// (a record is read within its own bytes: one made wrong, a count that ran past them, read on into the heap until it met an +// unmapped page, a crash in program_cache_load. Past the end, bad is set, the gets give zeros, and the record is left out) struct Reader { const uint8_t* p; + const uint8_t* end; + bool bad = false; + bool has(uint64_t n) { + if (!bad && n > (uint64_t)(end - p)) bad = true; + return !bad; + } template T get() { - alignas(T) uint8_t b[sizeof(T)]; - std::memcpy(b, p, sizeof(T)); - p += sizeof(T); + alignas(T) uint8_t b[sizeof(T)]{}; + if (has(sizeof(T))) { + std::memcpy(b, p, sizeof(T)); + p += sizeof(T); + } return *(T*)b; } - template void list(V& v) { + /// a count of elements of size elem, no more than the bytes left could hold (0 when bad) + uint64_t count(size_t elem) { const uint64_t n = get(); + if (!bad && n > (uint64_t)(end - p) / (elem ? elem : 1)) bad = true; + return bad ? 0 : n; + } + template void list(V& v) { + const uint64_t n = count(sizeof(typename V::value_type)); v.clear(); - for (uint64_t i = 0; i < n; i++) v.push_back(get()); + v.reserve((size_t)n); + for (uint64_t i = 0; i < n && !bad; i++) v.push_back(get()); } }; @@ -847,6 +879,7 @@ void put_info(std::vector& o, const Shader::Info& i) { for (const auto& h : i.handle_programs) put_list(o, h); } void get_info(Reader& r, Shader::Info& i) { + if (!r.has(sizeof i)) return; // (left as constructed: the record is bad, and left out) alignas(Shader::Info) static thread_local uint8_t fresh[sizeof(Shader::Info)]; std::memcpy(fresh, (const void*)&i, sizeof i); // (those members as constructed, here) std::memcpy((void*)&i, r.p, sizeof i); @@ -867,11 +900,11 @@ void get_info(Reader& r, Shader::Info& i) { r.list(i.image_buffer_descriptors); r.list(i.texture_descriptors); r.list(i.image_descriptors); - for (uint64_t n = r.get(); n; n--) { + for (uint64_t n = r.count(2 * sizeof(Shader::IR::Attribute)); n && !r.bad; n--) { const auto k = r.get(); i.legacy_stores_mapping[k] = r.get(); } - i.handle_programs.resize((size_t)r.get()); + i.handle_programs.resize((size_t)r.count(sizeof(uint64_t))); // (each at least its own count) for (auto& h : i.handle_programs) r.list(h); } @@ -923,7 +956,7 @@ void program_cache_load(uint64_t key, std::vector>& var _fseeki64(disk_in, d.at, SEEK_SET); if (fread(data.data(), 1, d.size, disk_in) != d.size) continue; } - Reader r{data.data()}; + Reader r{data.data(), data.data() + data.size()}; auto p = std::make_unique(); p->key = key; const uint32_t kind = r.get(); @@ -932,9 +965,9 @@ void program_cache_load(uint64_t key, std::vector>& var p->variant = answers_hash(*p); p->workgroup = r.get>(); p->shared_memory = r.get(); - const uint64_t n = r.get(); - bool ok = true; - for (uint64_t k = 0; k < n; k++) { + const uint64_t n = r.count(1); + bool ok = !r.bad && n <= 8; // (a program has at most a few stages) + for (uint64_t k = 0; ok && k < n; k++) { p->stages.emplace_back(); Stage& st = p->stages.back(); st.nvn_stage = r.get(); @@ -943,10 +976,20 @@ void program_cache_load(uint64_t key, std::vector>& var r.list(spirv); r.list(st.c1); get_info(r, st.info); + if (r.bad || spirv.empty()) { + ok = false; + break; + } st.module = make_module(spirv); ok = ok && st.module; } - if (!ok) continue; + if (!ok) { + static int logged; + if (r.bad && logged++ < 20) + logf("vk: program cache: the record of program %016llx at %lld (%zu bytes) is not whole: left out, translated again", + (unsigned long long)key, (long long)d.at, d.size); + continue; + } p->ready.store(true, std::memory_order_release); variants.push_back(std::move(p)); st_cache_hits++; From 1f346f1e683693cb08a9196af7ea43845dda6d0d Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 14:06:50 -0500 Subject: [PATCH 02/10] Hookshot Launcher: update the build tools from GitHub and rebuild in one button launcher/launcher.py runs on the builder package's own Python (tools\python) with the standard library only, and shows its page in the browser on 127.0.0.1. It checks the newest commit of the chosen source (the fork, or NotRexed's repository) against the one tools\ holds, downloads that commit as a zip, writes only the files whose bytes changed into gpu, host, nx2, scripts, third_party and launcher (keeping what they replace in launcher_backups, the last three updates), then runs nx2\make_game.py with the settings in launcher.json as HookshotBuilder.exe does. Files the update never wrote are never removed. It updates itself along with the rest. POSTs need the page's token and a 127.0.0.1 Host. Start it with "Hookshot Launcher.bat" beside HookshotBuilder.exe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- launcher/Hookshot Launcher.bat | 10 + launcher/launcher.py | 669 +++++++++++++++++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 launcher/Hookshot Launcher.bat create mode 100644 launcher/launcher.py diff --git a/launcher/Hookshot Launcher.bat b/launcher/Hookshot Launcher.bat new file mode 100644 index 00000000..f4b4dc53 --- /dev/null +++ b/launcher/Hookshot Launcher.bat @@ -0,0 +1,10 @@ +@echo off +rem Hookshot Launcher: updates the build tools from GitHub and rebuilds the game. Keep this file beside HookshotBuilder.exe. +cd /d "%~dp0" +if not exist "tools\python\python.exe" ( + echo The tools folder with its Python was not found beside this file. + pause + exit /b 1 +) +"tools\python\python.exe" -I "tools\launcher\launcher.py" +if errorlevel 1 pause diff --git a/launcher/launcher.py b/launcher/launcher.py new file mode 100644 index 00000000..52177791 --- /dev/null +++ b/launcher/launcher.py @@ -0,0 +1,669 @@ +"""Hookshot Launcher: the builder's source kept up to date from GitHub, and the game rebuilt with one button. + +It runs on the Python the HookshotBuilder package ships (tools\\python\\python.exe), with nothing to install, and shows its window in the +web browser (127.0.0.1 only). Start it with "Hookshot Launcher.bat" beside HookshotBuilder.exe. + + Check the newest commit of the chosen source (your fork, or NotRexed's official repository) against the one tools\\ was + last updated to. + Update that commit's source as a zip from GitHub; the folders the build reads (gpu, host, nx2, scripts, third_party, and + launcher, this file's own) written into tools\\. Only files whose bytes changed are written, so unchanged files keep + their dates; every file changed or removed is kept first in launcher_backups\\ (the last three updates). + Rebuild nx2\\make_game.py with the settings in launcher.json, as HookshotBuilder.exe runs it. + Play hookshot.exe in the game folder. + +Files beside HookshotBuilder.exe: launcher.json (settings), launcher_backups\\, launcher_download\\. In tools\\: .hookshot_source.json +(the commit tools\\ holds, and the files that update wrote, so a file a later commit removes is removed too; files it never wrote are +never touched). +""" +import collections +import datetime +import json +import os +import secrets +import shutil +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +import webbrowser +import zipfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +LAUNCHER_VERSION = 1 +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.environ.get("HOOKSHOT_TOOLS") or os.path.dirname(HERE) +HOME = os.environ.get("HOOKSHOT_HOME") or os.path.dirname(TOOLS) +CONFIG = os.path.join(HOME, "launcher.json") +SOURCE_STATE = os.path.join(TOOLS, ".hookshot_source.json") +BACKUPS = os.path.join(HOME, "launcher_backups") +DOWNLOADS = os.path.join(HOME, "launcher_download") +SYNCED = ("gpu", "host", "nx2", "scripts", "third_party", "launcher") +PORT = int(os.environ.get("HOOKSHOT_LAUNCHER_PORT", "47390")) +WINDOWS = os.name == "nt" +API = os.environ.get("HOOKSHOT_GITHUB_API", "https://api.github.com") +CODELOAD = os.environ.get("HOOKSHOT_CODELOAD", "https://codeload.github.com") + +DEFAULTS = { + "source": "fork", + "fork_repo": "xellow78-lgtm/Hookshot", + "fork_branch": "main", + "official_repo": "NotRexed/hookshot", + "official_branch": "main", + "nup": "", + "romfs": "", + "exefs": "", + "out": "", + "language": "en-US", + "check_minutes": 15, +} +LANGUAGES = ["en-US", "en-GB", "fr", "fr-CA", "de", "it", "es", "es-419", "pt-BR", "nl", "pl", "ru", "ja", "ko", "zh-Hans", "zh-Hant"] +STAGES = [("download", "Downloading the new source"), ("sync", "Updating the build tools"), ("read", "Reading your game files"), + ("lift", "Translating the game"), ("compile", "Compiling"), ("finish", "Finishing")] + + +# ---- settings and state ---- + +def read_json(path, default): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return default + + +def write_json(path, value): + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".new" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(value, f, indent=2) + os.replace(tmp, path) + + +def load_config(): + cfg = dict(DEFAULTS) + cfg.update(read_json(CONFIG, {})) + return cfg + + +def source_of(cfg, which=None): + which = which or cfg["source"] + return (cfg["fork_repo"], cfg["fork_branch"]) if which == "fork" else (cfg["official_repo"], cfg["official_branch"]) + + +def installed(): + s = read_json(SOURCE_STATE, {}) + return {k: s.get(k) for k in ("repo", "branch", "sha", "date", "message", "updated")} if s.get("sha") else None + + +# ---- GitHub ---- + +class GitHubError(Exception): + pass + + +def github(path): + req = urllib.request.Request(API + path, headers={"User-Agent": "HookshotLauncher", "Accept": "application/vnd.github+json"}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.load(r) + except urllib.error.HTTPError as e: + if e.code in (403, 429) and e.headers.get("X-RateLimit-Remaining") == "0": + reset = int(e.headers.get("X-RateLimit-Reset", "0") or 0) + at = datetime.datetime.fromtimestamp(reset).strftime("%H:%M") if reset else "later" + raise GitHubError("GitHub allows 60 checks an hour without signing in, and they are used up. Try again after %s." % at) + if e.code == 404: + raise GitHubError("GitHub has no %s (a wrong repository or branch name in the settings?)" % path.split("/repos/")[-1]) + raise GitHubError("GitHub answered %d for %s" % (e.code, path)) + except (urllib.error.URLError, OSError) as e: + raise GitHubError("GitHub could not be reached: %s" % getattr(e, "reason", e)) + + +def latest_commit(repo, branch): + c = github("/repos/%s/commits/%s" % (repo, urllib.request.quote(branch, safe=""))) + return {"repo": repo, "branch": branch, "sha": c["sha"], "date": c["commit"]["committer"]["date"], + "message": c["commit"]["message"].splitlines()[0][:200]} + + +def official_ahead(cfg): + """How many commits NotRexed's branch has that the fork's branch does not (None when unknown).""" + owner = cfg["official_repo"].split("/")[0] + try: + c = github("/repos/%s/compare/%s...%s:%s" % (cfg["fork_repo"], cfg["fork_branch"], owner, cfg["official_branch"])) + return int(c.get("ahead_by", 0)) + except (GitHubError, KeyError, ValueError): + return None + + +def download(repo, sha, dest, progress): + url = "%s/%s/zip/%s" % (CODELOAD, repo, sha) + req = urllib.request.Request(url, headers={"User-Agent": "HookshotLauncher"}) + os.makedirs(os.path.dirname(dest), exist_ok=True) + got = 0 + with urllib.request.urlopen(req, timeout=60) as r, open(dest + ".part", "wb") as f: + total = int(r.headers.get("Content-Length") or 0) + while True: + chunk = r.read(1 << 16) + if not chunk: + break + f.write(chunk) + got += len(chunk) + progress(got / total if total else None, got) + os.replace(dest + ".part", dest) + return got + + +# ---- the update of tools\ ---- + +def sync(zip_path, commit, log): + """The synced folders of the commit written into tools\\: changed files only, what they replace kept in a backup. Returns counts.""" + with zipfile.ZipFile(zip_path) as z: + entries = [i for i in z.infolist() if not i.is_dir()] + if not entries: + raise RuntimeError("The download was empty.") + prefix = entries[0].filename.split("/")[0] + "/" + new = {} + for i in entries: + if not i.filename.startswith(prefix): + continue + rel = i.filename[len(prefix):] + if "/" in rel and rel.split("/")[0] in SYNCED and ".." not in rel.split("/"): + new[rel] = i + if "nx2/make_game.py" not in new: + raise RuntimeError("The download has no nx2/make_game.py: it is not a Hookshot source.") + old = read_json(SOURCE_STATE, {}) + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "_" + (old.get("sha") or "unknown")[:7] + backup = os.path.join(BACKUPS, stamp) + counts = {"changed": 0, "added": 0, "removed": 0, "same": 0} + launcher_changed = False + + def keep(rel, dst, move): + b = os.path.join(backup, *rel.split("/")) + os.makedirs(os.path.dirname(b), exist_ok=True) + (shutil.move if move else shutil.copy2)(dst, b) + + for rel, info in sorted(new.items()): + dst = os.path.join(TOOLS, *rel.split("/")) + data = z.read(info) + if os.path.isfile(dst): + with open(dst, "rb") as f: + if f.read() == data: + counts["same"] += 1 + continue + keep(rel, dst, move=False) + counts["changed"] += 1 + log(" changed " + rel) + else: + counts["added"] += 1 + log(" added " + rel) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst + ".new", "wb") as f: + f.write(data) + os.replace(dst + ".new", dst) + launcher_changed |= rel.startswith("launcher/") + for rel in sorted(set(old.get("files", [])) - set(new)): + dst = os.path.join(TOOLS, *rel.split("/")) + if os.path.isfile(dst): + keep(rel, dst, move=True) + counts["removed"] += 1 + log(" removed " + rel) + write_json(SOURCE_STATE, dict(commit, updated=datetime.datetime.now().isoformat(timespec="seconds"), files=sorted(new))) + if os.path.isdir(BACKUPS): + for d in sorted(os.listdir(BACKUPS))[:-3]: + shutil.rmtree(os.path.join(BACKUPS, d), ignore_errors=True) + counts["launcher"] = launcher_changed + return counts + + +# ---- the build ---- + +def game_running(): + if not WINDOWS: + return False + try: + out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hookshot.exe", "/NH"], capture_output=True, text=True, + creationflags=0x08000000).stdout + return "hookshot.exe" in out.lower() + except OSError: + return False + + +def build_command(cfg): + py = os.path.join(TOOLS, "python", "python.exe") + if not os.path.isfile(py): + py = sys.executable + cmd = [py, "-I", os.path.join(TOOLS, "nx2", "make_game.py")] + if cfg["nup"]: + cmd += ["--nup", cfg["nup"]] + else: + cmd += ["--romfs", cfg["romfs"], "--exefs", cfg["exefs"]] + return cmd + ["--out", cfg["out"], "--language", cfg["language"]] + + +def settings_problem(cfg): + if cfg["nup"]: + if not os.path.isfile(cfg["nup"]): + return "The game file (.nup) in the settings was not found: " + cfg["nup"] + elif not (os.path.isdir(cfg["romfs"]) and os.path.isfile(os.path.join(cfg["exefs"], "main"))): + return "Set your game file (.nup), or the romfs and exefs folders, in the settings." + if len(cfg["out"]) < 3: + return "Set the folder the game is built into in the settings." + if cfg["language"] not in LANGUAGES: + return "The language in the settings is not one the game has." + if not os.path.isfile(os.path.join(TOOLS, "nx2", "make_game.py")): + return "The build tools were not found in " + TOOLS + return None + + +class Job: + """One update and/or build at a time, in a thread; its state read by the page.""" + + def __init__(self): + self.lock = threading.Lock() + self.reset("idle") + + def reset(self, state): + self.state = state # idle, running, done, failed, cancelled + self.kind = "" + self.stages = {k: "waiting" for k, _ in STAGES} + self.current = None + self.progress = None + self.log = collections.deque(maxlen=4000) + self.message = "" + self.started = self.ended = None + self.process = None + self.cancelled = False + self.relaunch = False + + def view(self): + with self.lock: + return {"state": self.state, "kind": self.kind, "stages": [{"id": k, "text": t, "state": self.stages[k]} for k, t in STAGES], + "current": self.current, "progress": self.progress, "message": self.message, "started": self.started, + "ended": self.ended, "log": list(self.log)[-400:], "relaunch": self.relaunch} + + def say(self, line): + with self.lock: + self.log.append(line) + + def stage(self, sid): + with self.lock: + if self.current and self.stages.get(self.current) == "running": + self.stages[self.current] = "done" + self.current = sid + self.progress = None + if sid in self.stages: + self.stages[sid] = "running" + + def start(self, update, cfg, commit=None): + with self.lock: + if self.state == "running": + return "Something is already running." + problem = settings_problem(cfg) + if problem: + return problem + if game_running(): + return "The game is running: close it first (the build replaces hookshot.exe)." + self.reset("running") + self.kind = "update" if update else "rebuild" + self.started = time.time() + if not update: + for k in ("download", "sync"): + self.stages[k] = "skipped" + threading.Thread(target=self.run, args=(update, cfg, commit), daemon=True).start() + return None + + def finish(self, state, message): + with self.lock: + if self.current and self.stages.get(self.current) == "running": + self.stages[self.current] = "done" if state == "done" else ("cancelled" if state == "cancelled" else "failed") + self.state = state + self.message = message + self.ended = time.time() + self.process = None + + def run(self, update, cfg, commit): + try: + if update: + self.stage("download") + repo, branch = source_of(cfg) + commit = commit or latest_commit(repo, branch) + self.say("Downloading %s %s (%s)" % (repo, commit["sha"][:7], commit["message"])) + zpath = os.path.join(DOWNLOADS, "source.zip") + + def got(f, n): + with self.lock: + self.progress = f + self.message = "%.1f MB" % (n / 1048576) + download(repo, commit["sha"], zpath, got) + if self.cancelled: + return self.finish("cancelled", "Cancelled before the build tools were changed.") + self.stage("sync") + self.say("Writing the new source into " + TOOLS) + c = sync(zpath, commit, self.say) + self.say("%d files changed, %d added, %d removed, %d the same" % (c["changed"], c["added"], c["removed"], c["same"])) + with self.lock: + self.relaunch = c["launcher"] + self.message = "" + if self.cancelled: + return self.finish("cancelled", "Cancelled.") + self.build(cfg) + except GitHubError as e: + self.finish("failed", str(e)) + except Exception as e: # (said on the page rather than lost in the console) + self.say("error: %r" % e) + self.finish("failed", "Stopped: %s" % e) + + def build(self, cfg): + cmd = build_command(cfg) + env = dict(os.environ, PYTHONIOENCODING="utf-8") + for name, folder in (("EMSDK", "emsdk"), ("RX_CPM", "cpm"), ("WGPU_NATIVE", "wgpu-native")): + if os.path.isdir(os.path.join(TOOLS, folder)): + env[name] = os.path.join(TOOLS, folder) + self.say("> " + subprocess.list2cmdline(cmd)) + flags = 0x00004000 | 0x08000000 if WINDOWS else 0 # (below normal priority, no console window: as HookshotBuilder.exe) + p = subprocess.Popen(cmd, cwd=TOOLS, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", + errors="replace", bufsize=1, creationflags=flags) + with self.lock: + self.process = p + done, error = None, None + for line in p.stdout: + line = line.rstrip("\r\n") + if line.startswith("@@stage "): + self.stage(line.split(" ")[1]) + elif line.startswith("@@progress "): + try: + with self.lock: + self.progress = float(line[11:]) + except ValueError: + pass + continue + elif line.startswith("@@done "): + done = line[7:] + elif line.startswith("@@error "): + error = line[8:] + self.say(line) + p.wait() + if self.cancelled: + self.finish("cancelled", "Cancelled.") + elif p.returncode == 0 and done: + self.finish("done", "The game is ready in " + done) + else: + self.finish("failed", error or "The build stopped (exit code %s). See the details." % p.returncode) + + def cancel(self): + with self.lock: + if self.state != "running": + return + self.cancelled = True + p = self.process + if p: + if WINDOWS: + subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], capture_output=True, creationflags=0x08000000) + else: + p.kill() + + +# ---- checks ---- + +class Checker: + def __init__(self): + self.lock = threading.Lock() + self.latest = None + self.ahead = None + self.error = "" + self.checked = None + self.busy = False + + def check(self): + with self.lock: + if self.busy: + return + self.busy = True + try: + cfg = load_config() + latest = latest_commit(*source_of(cfg)) + ahead = official_ahead(cfg) if cfg["source"] == "fork" else None + with self.lock: + self.latest, self.ahead, self.error = latest, ahead, "" + except GitHubError as e: + with self.lock: + self.error = str(e) + finally: + with self.lock: + self.busy = False + self.checked = time.time() + + def view(self): + with self.lock: + return {"latest": self.latest, "ahead": self.ahead, "error": self.error, "checked": self.checked, "busy": self.busy} + + def loop(self): + while True: + self.check() + time.sleep(max(5, int(load_config().get("check_minutes") or 15)) * 60) + + +# ---- the page ---- + +TOKEN = secrets.token_hex(16) +job = Job() +checker = Checker() + + +def state(): + cfg = load_config() + inst = installed() + chk = checker.view() + latest = chk["latest"] + repo, branch = source_of(cfg) + update = bool(latest and (not inst or inst["sha"] != latest["sha"])) + out = cfg["out"] + return {"version": LAUNCHER_VERSION, "settings": cfg, "languages": LANGUAGES, "installed": inst, "check": chk, "update": update, + "source": {"repo": repo, "branch": branch, "url": "https://github.com/%s/tree/%s" % (repo, branch)}, + "fork_url": "https://github.com/" + cfg["fork_repo"], "tools": TOOLS, "problem": settings_problem(cfg), + "playable": bool(out) and os.path.isfile(os.path.join(out, "hookshot.exe")), "job": job.view()} + + +def apply_settings(body): + cfg = load_config() + for k in ("nup", "romfs", "exefs", "out"): + if isinstance(body.get(k), str): + cfg[k] = body[k].strip().strip('"') + if body.get("source") in ("fork", "official"): + cfg["source"] = body["source"] + if body.get("language") in LANGUAGES: + cfg["language"] = body["language"] + write_json(CONFIG, cfg) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def send(self, code, body, kind="application/json"): + data = body if isinstance(body, bytes) else json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", kind) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def local(self): # (only this PC's own page: another site's page in the browser cannot set the header, nor name this host) + return self.headers.get("Host", "") in ("127.0.0.1:%d" % PORT, "localhost:%d" % PORT) + + def do_GET(self): + if not self.local(): + return self.send(403, {"error": "not here"}) + if self.path == "/": + return self.send(200, PAGE.replace("__TOKEN__", TOKEN).encode(), "text/html; charset=utf-8") + if self.path == "/api/state": + return self.send(200, state()) + self.send(404, {"error": "no such page"}) + + def do_POST(self): + if not self.local() or self.headers.get("X-Token") != TOKEN: + return self.send(403, {"error": "not allowed"}) + try: + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}") + except ValueError: + body = {} + action = self.path.rsplit("/", 1)[-1] + error = None + if action == "check": + threading.Thread(target=checker.check, daemon=True).start() + elif action == "update": + chk = checker.view() + cfg = load_config() + latest = chk["latest"] if chk["latest"] and (chk["latest"]["repo"], chk["latest"]["branch"]) == source_of(cfg) else None + error = job.start(True, cfg, latest) + elif action == "rebuild": + error = job.start(False, load_config()) + elif action == "cancel": + job.cancel() + elif action == "play": + out = load_config()["out"] + exe = os.path.join(out, "hookshot.exe") + if job.view()["state"] == "running": + error = "Wait for the build to finish." + elif not os.path.isfile(exe): + error = "hookshot.exe is not in " + out + else: + subprocess.Popen([exe], cwd=out, creationflags=0x00000008 if WINDOWS else 0) + elif action == "settings": + apply_settings(body) + threading.Thread(target=checker.check, daemon=True).start() + elif action == "quit": + self.send(200, {"ok": True}) + threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start() + return + else: + return self.send(404, {"error": "no such action"}) + self.send(200, {"ok": error is None, "error": error}) + + +PAGE = r""" + +Hookshot Launcher +
+

Hookshot Launcher

Update the build tools from GitHub and rebuild the game in one go.

+ +
+
Installed
…
+
Newest
…
+
Source
…
+
+
+ + + + +
+
+ + + +
Settings +
+
+ + + +
+

+
+""" + + +def main(): + try: + server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) + except OSError: # (one is running already: its page is opened) + webbrowser.open("http://127.0.0.1:%d/" % PORT) + print("The launcher is already running: its page was opened in your browser.") + return + if not os.path.exists(CONFIG): + write_json(CONFIG, load_config()) + threading.Thread(target=checker.loop, daemon=True).start() + url = "http://127.0.0.1:%d/" % PORT + print("Hookshot Launcher is running at %s\nIts page is open in your browser. Close this window to stop the launcher." % url, flush=True) + if not os.environ.get("HOOKSHOT_NO_BROWSER"): + webbrowser.open(url) + try: + server.serve_forever() + except KeyboardInterrupt: + job.cancel() + + +if __name__ == "__main__": + main() From 0935acb60853adaa778896111b1d57c84739e43a Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 15:00:25 -0500 Subject: [PATCH 03/10] Mouse: log each step of the mouse path One player's mouse did nothing (keys worked). The log said nothing about the mouse, so where it stops is unknown. Now logged: - whether raw mouse input registered (it was unchecked), with the error - each capture and release, and why (click, Esc, focus lost, cancel mode), and a failed ClipCursor - every 5 s while the mouse did anything: clicks, raw reports (all, while captured, absolute ones, which are not used), the motion taken, the mouse stick and the right stick the game was given Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- host/display.c | 48 ++++++++++++++++++++++++++++++---------- host/nn_hid.c | 33 +++++++++++++++++++++++++++ host/posix/display_sdl.c | 6 +++++ 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/host/display.c b/host/display.c index d419e532..4a729446 100644 --- a/host/display.c +++ b/host/display.c @@ -33,6 +33,17 @@ int hos_mouse_take(int32_t *dx, int32_t *dy) { return mouse_captured; } +/* (diagnostics, the mouse that did nothing for one player: nn_hid.c logs these every 5 s while the mouse is in use) the raw mouse + reports since the last call, all of them and those taken while captured, and the clicks the window had */ +static volatile LONG raw_reports, raw_taken, raw_absolute, clicks; +int hos_mouse_stats(long *reports, long *taken, long *absolute, long *click_count) { + *reports = InterlockedExchange(&raw_reports, 0); + *taken = InterlockedExchange(&raw_taken, 0); + *absolute = InterlockedExchange(&raw_absolute, 0); + *click_count = InterlockedExchange(&clicks, 0); + return mouse_captured; +} + static HANDLE renderer_up; static int fullscreen; static WINDOWPLACEMENT windowed_place; @@ -75,16 +86,17 @@ static UINT window_dpi(HWND w) { return dpi ? dpi : 96; } -static void mouse_capture(HWND w, int on) { +static void mouse_capture(HWND w, int on, const char *why) { if (on == (int)mouse_captured) return; mouse_captured = on; + if (why) hos_log("mouse: %s (%s)", on ? "captured: its motion turns the camera" : "released", why); InterlockedExchange(&mouse_dx, 0); InterlockedExchange(&mouse_dy, 0); if (on) { RECT rc; GetClientRect(w, &rc); MapWindowPoints(w, NULL, (POINT *)&rc, 2); - ClipCursor(&rc); + if (!ClipCursor(&rc) && why) hos_log("mouse: the cursor could not be kept in the window (error %lu)", GetLastError()); if (!cursor_hidden) { ShowCursor(FALSE); cursor_hidden = 1; } } else { ClipCursor(NULL); @@ -99,18 +111,27 @@ static LRESULT CALLBACK wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { case WM_INPUT: { RAWINPUT ri; UINT size = sizeof ri; - if (mouse_captured && GetRawInputData((HRAWINPUT)lp, RID_INPUT, &ri, &size, sizeof(RAWINPUTHEADER)) != (UINT)-1 && - ri.header.dwType == RIM_TYPEMOUSE && !(ri.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE)) { - InterlockedExchangeAdd(&mouse_dx, ri.data.mouse.lLastX); - InterlockedExchangeAdd(&mouse_dy, ri.data.mouse.lLastY); + if (GetRawInputData((HRAWINPUT)lp, RID_INPUT, &ri, &size, sizeof(RAWINPUTHEADER)) != (UINT)-1 && + ri.header.dwType == RIM_TYPEMOUSE) { + InterlockedIncrement(&raw_reports); + if (ri.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) { + /* (a pen tablet, remote desktop, a VM or some mouse software: positions, not motion, and not used) */ + InterlockedIncrement(&raw_absolute); + } else if (mouse_captured) { + InterlockedIncrement(&raw_taken); + InterlockedExchangeAdd(&mouse_dx, ri.data.mouse.lLastX); + InterlockedExchangeAdd(&mouse_dy, ri.data.mouse.lLastY); + } } return DefWindowProcA(w, msg, wp, lp); } case WM_LBUTTONDOWN: - if (renderer && !mouse_captured) mouse_capture(w, 1); + InterlockedIncrement(&clicks); + if (!renderer) hos_log("mouse: a click before the renderer was up: not captured"); + if (renderer && !mouse_captured) mouse_capture(w, 1, "a click in the window"); return 0; case WM_KEYDOWN: - if (wp == VK_ESCAPE) mouse_capture(w, 0); + if (wp == VK_ESCAPE) mouse_capture(w, 0, "Esc"); if (wp == VK_F11 && !(lp & (1 << 30))) set_fullscreen(w, !fullscreen); return 0; case WM_APP + 7: /* (the Insert menu's row) */ @@ -125,7 +146,7 @@ static LRESULT CALLBACK wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { return 0; case WM_KILLFOCUS: case WM_CANCELMODE: - mouse_capture(w, 0); + mouse_capture(w, 0, msg == WM_KILLFOCUS ? "the window lost the focus" : "cancel mode"); break; case WM_SETCURSOR: if (mouse_captured && LOWORD(lp) == HTCLIENT) { @@ -134,10 +155,10 @@ static LRESULT CALLBACK wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { } break; case WM_MOVE: - if (mouse_captured) { mouse_capture(w, 0); mouse_capture(w, 1); } /* (the clip follows the window) */ + if (mouse_captured) { mouse_capture(w, 0, NULL); mouse_capture(w, 1, NULL); } /* (the clip follows the window) */ break; case WM_SIZE: - if (mouse_captured) { mouse_capture(w, 0); mouse_capture(w, 1); } + if (mouse_captured) { mouse_capture(w, 0, NULL); mouse_capture(w, 1, NULL); } if (renderer) rx_resize(LOWORD(lp), HIWORD(lp)); return 0; case WM_PAINT: @@ -199,7 +220,10 @@ static DWORD WINAPI display_main(void *p) { ShowWindow(hwnd, SW_SHOW); if (getenv("HOS_FULLSCREEN") && atoi(getenv("HOS_FULLSCREEN"))) set_fullscreen(hwnd, 1); RAWINPUTDEVICE rid = {1, 2, 0, hwnd}; /* generic desktop mouse: WM_INPUT while the window is in front */ - RegisterRawInputDevices(&rid, 1, sizeof rid); + if (!RegisterRawInputDevices(&rid, 1, sizeof rid)) + hos_log("mouse: raw mouse input could not be registered (error %lu): the mouse cannot turn the camera", GetLastError()); + else + hos_log("mouse: raw mouse input registered: click in the window to use the mouse, Esc releases it"); renderer = rx_start(hwnd); if (!renderer) hos_log("display: no WebGPU device: the window stays black"); SetEvent(renderer_up); diff --git a/host/nn_hid.c b/host/nn_hid.c index ac96c9c5..ad4c8960 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -40,6 +40,28 @@ int rx_option(int id, int otherwise); static int key(int vk) { return !rx_option(1, 0) && (GetAsyncKeyState(vk) & 0x8000) != 0; } int hos_mouse_take(int32_t *dx, int32_t *dy); /* display.c */ +int hos_mouse_stats(long *reports, long *taken, long *absolute, long *click_count); /* display.c */ + +/* (diagnostics, the mouse that did nothing for one player) every 5 s while the mouse did anything: what the window had (reports, + clicks), what reached the stick, and the right stick the game was given */ +extern double hos_mouse_sens; +static long long mouse_moved_x, mouse_moved_y; +static int32_t mouse_shown_peak, stick_given_peak; +static void mouse_report(void) { + static int64_t next; + const int64_t now = hos_now_ns(); + if (now < next) return; + next = now + 5000000000LL; + long reports, taken, absolute, click_count; + const int captured = hos_mouse_stats(&reports, &taken, &absolute, &click_count); + if (reports || click_count || captured || mouse_moved_x || mouse_moved_y) + hos_log("mouse: in 5 s: %ld clicks, %ld raw reports (%ld while captured, %ld absolute), moved %lld,%lld; mouse stick up to %d, " + "right stick given to the game up to %d; %s, sensitivity %.2f", + click_count, reports, taken, absolute, mouse_moved_x, mouse_moved_y, (int)mouse_shown_peak, (int)stick_given_peak, + captured ? "captured" : "not captured", hos_mouse_sens); + mouse_moved_x = mouse_moved_y = 0; + mouse_shown_peak = stick_given_peak = 0; +} /* The captured mouse's sensitivity (mouse_stick below): - and = change it, kept in /mouse_sens.txt */ double hos_mouse_sens = 0; /* (display.c shows it in the title) */ @@ -134,6 +156,8 @@ static int mouse_stick(int32_t *rx, int32_t *ry, int pointing) { if (now >= next_tick) { int32_t dx, dy; captured = hos_mouse_take(&dx, &dy); + mouse_moved_x += dx; + mouse_moved_y += dy; if (pointing != was_pointing) look_x = look_y = 0, was_pointing = pointing; if (!captured) look_x = look_y = 0; else if (pointing) { @@ -158,6 +182,10 @@ static int mouse_stick(int32_t *rx, int32_t *ry, int pointing) { if (!captured) return 0; *rx = shown[0]; *ry = shown[1]; + { + const int32_t m = abs(shown[0]) > abs(shown[1]) ? abs(shown[0]) : abs(shown[1]); + if (m > mouse_shown_peak) mouse_shown_peak = m; + } return 1; } @@ -425,6 +453,11 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { cy = (key(VK_UP) || key('I')) - (key(VK_DOWN) || key('K')); const int32_t keys[4] = {(key('D') - key('A')) * 32767, (key('W') - key('S')) * 32767, cx * 32767, cy * 32767}; stick_max(stick, keys); + { + const int32_t m = abs(stick[2]) > abs(stick[3]) ? abs(stick[2]) : abs(stick[3]); + if (m > stick_given_peak) stick_given_peak = m; + } + mouse_report(); } static void vibration_send(uint64_t handle, uint64_t value); diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index 99022c55..13f5e1c2 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -31,6 +31,12 @@ int hos_mouse_take(int32_t *dx, int32_t *dy) { return mouse_captured; } +/* (diagnostics, display.c's: none counted here) */ +int hos_mouse_stats(long *reports, long *taken, long *absolute, long *click_count) { + *reports = *taken = *absolute = *click_count = 0; + return mouse_captured; +} + /* ---- the SDL2 entry points used ---- */ typedef struct { uint32_t format; int w, h, refresh_rate; void *driverdata; } SdlDisplayMode; typedef union { uint32_t type; uint8_t raw[56]; } SdlEvent; From 23dcca3ce8e49a7ac84bdf969f37819519ae9338 Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 15:50:17 -0500 Subject: [PATCH 04/10] Launcher v2: Update asks GitHub for the newest commit when it starts v1 used the last check's answer, which could be minutes old: a commit pushed after that check was missed (the mouse diagnostics build installed the commit before them). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- launcher/launcher.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/launcher/launcher.py b/launcher/launcher.py index 52177791..bf4c1c9c 100644 --- a/launcher/launcher.py +++ b/launcher/launcher.py @@ -31,7 +31,7 @@ import zipfile from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -LAUNCHER_VERSION = 1 +LAUNCHER_VERSION = 2 HERE = os.path.dirname(os.path.abspath(__file__)) TOOLS = os.environ.get("HOOKSHOT_TOOLS") or os.path.dirname(HERE) HOME = os.environ.get("HOOKSHOT_HOME") or os.path.dirname(TOOLS) @@ -342,6 +342,7 @@ def got(f, n): self.say("Writing the new source into " + TOOLS) c = sync(zpath, commit, self.say) self.say("%d files changed, %d added, %d removed, %d the same" % (c["changed"], c["added"], c["removed"], c["same"])) + threading.Thread(target=checker.check, daemon=True).start() # (the page shows the new commit as installed) with self.lock: self.relaunch = c["launcher"] self.message = "" @@ -514,10 +515,9 @@ def do_POST(self): if action == "check": threading.Thread(target=checker.check, daemon=True).start() elif action == "update": - chk = checker.view() - cfg = load_config() - latest = chk["latest"] if chk["latest"] and (chk["latest"]["repo"], chk["latest"]["branch"]) == source_of(cfg) else None - error = job.start(True, cfg, latest) + # (the newest commit is asked for again when the update starts: the last check's answer could be minutes old, and a + # commit pushed since was missed. v1 used it, and built the commit before the one that was wanted) + error = job.start(True, load_config()) elif action == "rebuild": error = job.start(False, load_config()) elif action == "cancel": From 3df95e25e5fa5539d3d5a312583303dfe588735f Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 16:36:30 -0500 Subject: [PATCH 05/10] Camera finder (HOS_CAMFIND=1, F6): find the camera's yaw and pitch in memory Native mouse look needs the camera's own angles, and nothing names where they are. F6 starts a ~25 s run that has the controls: for yaw and then pitch it snapshots the heap's used pages, leaves out what changes with nothing pressed, turns the camera one way, back, and the first way again, and keeps the floats that moved with each turn by a similar amount and stayed still between them (an angle that wraps counts the short way). The candidates are logged with the floats around them. Tested on a simulated heap: the yaw (wrapping past pi) and pitch were found, the animation noise and a value that only follows one direction were left out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- host/camfind.c | 271 +++++++++++++++++++++++++++++++++++++++++++++++++ host/nn_hid.c | 4 + 2 files changed, 275 insertions(+) create mode 100644 host/camfind.c diff --git a/host/camfind.c b/host/camfind.c new file mode 100644 index 00000000..f9157f3b --- /dev/null +++ b/host/camfind.c @@ -0,0 +1,271 @@ +/* (hookshot) The camera finder: HOS_CAMFIND=1, then F6 in the game. Native mouse look needs the camera's own angles (its + yaw and pitch, "lng" and "lat" in the engine's camera parameters: PlayerCameraBasicParam's StickSensitivityLat2LngRatio), + and nothing names where they are. This finds them by watching the game turn its camera. + + F6 (the game's window in front) starts a run of about 25 s; the player's controls are off until it ends. For each axis, + yaw (the right stick's x) and then pitch (its y): + 1. idle 1.5 s, then a snapshot of the heap's used pages (resident in the process and not all zero) + 2. idle 0.4 s more: the words that changed with nothing pressed (animation, particles, timers) are noise + 3. the stick one way for a moment; the words that changed, noise left out, are the candidates + 4. idle 2 s, then 0.5 s: candidates that still moved are dropped (the camera is at rest) + 5. the stick the other way as long: kept if they moved back, by a similar amount + 6. idle again: dropped if still moving + 7. the stick the first way again: kept if they moved that way, by a similar amount + The candidates left are logged ("camfind:" lines in hookshot_log.txt), best match first, each with its values at every + step and the floats around it (the object it is in). An angle that wraps (radians past pi, degrees past 180) counts + as moving the short way round. */ +#include "hos.h" +#include +#include +#include +#include +#define WIN32_LEAN_AND_MEAN +#include + +extern uint64_t hos_heap_size; +enum { PAGE = 4096, WORDS = PAGE / 4, NV = 9, MAX_CANDS = 4 << 20, REPORT = 48 }; + +typedef struct { uint64_t addr; float v[NV]; } Cand; + +typedef enum { A_NONE, A_SNAP, A_NOISE, A_DIFF, A_REC, A_STABLE, A_OPP, A_SAME, A_REPORT, A_NEXT } Action; +typedef struct { int dir; double seconds; Action then; int slot; const char *what; } Step; + +/* one axis: the stick direction (+1/-1 times the axis), how long, and what is done when the step ends */ +static const Step steps[] = { + {0, 1.5, A_SNAP, 0, "snapshot"}, + {0, 0.4, A_NOISE, 0, "noise"}, + {+1, 0.0, A_DIFF, 1, "first turn"}, /* (the turn's length: per axis, below) */ + {0, 2.0, A_REC, 2, "settle"}, + {0, 0.5, A_STABLE, 3, "at rest"}, + {-1, 0.0, A_OPP, 4, "turn back"}, + {0, 2.0, A_REC, 5, "settle"}, + {0, 0.5, A_STABLE, 6, "at rest"}, + {+1, 0.0, A_SAME, 7, "turn again"}, + {0, 1.5, A_REC, 8, "settle"}, + {0, 0.0, A_REPORT, 0, "report"}, + {0, 0.0, A_NEXT, 0, "next axis"}, +}; +static const char *axis_name[2] = {"yaw (right stick x)", "pitch (right stick y)"}; +static const double turn_seconds[2] = {0.6, 0.35}; + +static int enabled = -1, running, axis, step, key_was; +static int64_t step_start; +static uint8_t *snap; /* the snapshot's pages, packed */ +static uint64_t *snap_addr; /* each page's guest address */ +static uint8_t *noise; /* a bit a word: changed while idle */ +static size_t snap_pages; +static Cand *cands; +static size_t ncands; + +static double secs(int64_t ns) { return ns / 1e9; } + +static void drop_snapshot(void) { + free(snap), free(snap_addr), free(noise); + snap = NULL, snap_addr = NULL, noise = NULL, snap_pages = 0; +} + +static int page_zero(const uint8_t *p) { + const uint64_t *q = (const uint64_t *)p; + for (int i = 0; i < PAGE / 8; i++) + if (q[i]) return 0; + return 1; +} + +/* Which heap pages the process has touched: Windows says which are in its working set; elsewhere every page is read + (an untouched page of an anonymous mapping reads as the shared zero page there, and zero pages are left out) */ +#ifdef _WIN32 +typedef struct { void *va; ULONG_PTR attr; } WsInfo; +typedef BOOL(WINAPI *QwsFn)(HANDLE, void *, DWORD); +#endif + +static void take_snapshot(void) { + const int64_t t0 = hos_now_ns(); + drop_snapshot(); + const uint64_t pages = hos_heap_size / PAGE; + uint8_t *use = (uint8_t *)calloc((size_t)pages, 1); + if (!use) { hos_log("camfind: no memory for the page map"); return; } +#ifdef _WIN32 + QwsFn qws = (QwsFn)GetProcAddress(GetModuleHandleA("kernel32.dll"), "K32QueryWorkingSetEx"); + if (qws) { + enum { BATCH = 65536 }; + WsInfo *info = (WsInfo *)malloc(sizeof(WsInfo) * BATCH); + for (uint64_t i = 0; info && i < pages; i += BATCH) { + const uint64_t n = pages - i < BATCH ? pages - i : BATCH; + for (uint64_t k = 0; k < n; k++) info[k].va = GP(HOS_HEAP_BASE + (i + k) * PAGE), info[k].attr = 0; + if (qws(GetCurrentProcess(), info, (DWORD)(n * sizeof(WsInfo)))) + for (uint64_t k = 0; k < n; k++) use[i + k] = (uint8_t)(info[k].attr & 1); + } + free(info); + } else +#endif + memset(use, 1, (size_t)pages); + size_t n = 0; + for (uint64_t i = 0; i < pages; i++) + if (use[i] && !page_zero((const uint8_t *)GP(HOS_HEAP_BASE + i * PAGE))) use[i] = 2, n++; + snap = (uint8_t *)malloc(n * PAGE); + snap_addr = (uint64_t *)malloc(n * sizeof(uint64_t)); + noise = (uint8_t *)calloc(n * WORDS / 8 + 1, 1); + if (!snap || !snap_addr || !noise) { + hos_log("camfind: no memory for a %zu MB snapshot", (n * PAGE) >> 20); + drop_snapshot(); + free(use); + return; + } + size_t j = 0; + for (uint64_t i = 0; i < pages; i++) + if (use[i] == 2) { + snap_addr[j] = HOS_HEAP_BASE + i * PAGE; + memcpy(snap + j * PAGE, GP(snap_addr[j]), PAGE); + j++; + } + snap_pages = n; + free(use); + hos_log("camfind: snapshot of %zu MB (%zu pages of the %llu MB heap) in %.0f ms", (n * PAGE) >> 20, n, + (unsigned long long)(hos_heap_size >> 20), (hos_now_ns() - t0) / 1e6); +} + +static void mark_noise(void) { + size_t marked = 0; + for (size_t p = 0; p < snap_pages; p++) { + const uint32_t *old = (const uint32_t *)(snap + p * PAGE), *now = (const uint32_t *)GP(snap_addr[p]); + for (int w = 0; w < WORDS; w++) + if (old[w] != now[w]) { + const size_t bit = p * WORDS + w; + noise[bit >> 3] |= (uint8_t)(1 << (bit & 7)); + marked++; + } + } + hos_log("camfind: %zu words changed with nothing pressed (left out)", marked); +} + +static int usable(float f) { return isfinite(f) && fabsf(f) < 1e6f; } + +static void diff(void) { + free(cands); + cands = (Cand *)malloc(sizeof(Cand) * MAX_CANDS); + ncands = 0; + if (!cands || !snap) { hos_log("camfind: no snapshot to compare with"); return; } + size_t over = 0; + for (size_t p = 0; p < snap_pages; p++) { + const uint32_t *old = (const uint32_t *)(snap + p * PAGE), *now = (const uint32_t *)GP(snap_addr[p]); + for (int w = 0; w < WORDS; w++) { + if (old[w] == now[w]) continue; + const size_t bit = p * WORDS + w; + if (noise[bit >> 3] & (1 << (bit & 7))) continue; + float a, b; + memcpy(&a, &old[w], 4), memcpy(&b, &now[w], 4); + if (!usable(a) || !usable(b) || fabsf(b - a) < 1e-5f) continue; + if (ncands == MAX_CANDS) { over++; continue; } + Cand *c = &cands[ncands++]; + memset(c->v, 0, sizeof c->v); + c->addr = snap_addr[p] + (uint64_t)w * 4; + c->v[0] = a, c->v[1] = b; + } + } + drop_snapshot(); + hos_log("camfind: %zu candidates moved with the turn%s", ncands, over ? " (more than kept: the rest dropped)" : ""); +} + +static float now_of(const Cand *c) { float f; memcpy(&f, GP(c->addr), 4); return f; } + +/* d, or d the short way round for an angle that wrapped (radians or degrees) */ +static int matches(float d, float d0, int opposite) { + const float want = opposite ? -d0 : d0; + const float tries[3] = {d, d - copysignf(6.2831853f, d), d - copysignf(360.0f, d)}; + for (int i = 0; i < 3; i++) { + const float r = tries[i] / want; + if (r >= 0.3f && r <= 3.3f) return 1; + } + return 0; +} + +static void keep(Action a, int slot) { + size_t kept = 0; + for (size_t i = 0; i < ncands; i++) { + Cand *c = &cands[i]; + const float f = now_of(c); + int ok = usable(f); + if (ok && a == A_STABLE) ok = fabsf(f - c->v[slot - 1]) <= 1e-6f * fmaxf(1.0f, fabsf(f)); + if (ok && a == A_OPP) ok = matches(f - c->v[slot - 1], c->v[1] - c->v[0], 1); + if (ok && a == A_SAME) ok = matches(f - c->v[slot - 1], c->v[1] - c->v[0], 0); + if (!ok) continue; + c->v[slot] = f; + cands[kept++] = *c; + } + ncands = kept; +} + +static float score(const Cand *c) { /* (how alike the three turns were: 0 = the same) */ + const float d0 = c->v[1] - c->v[0], d1 = -(c->v[4] - c->v[3]), d2 = c->v[7] - c->v[6]; + return fabsf(logf(fabsf(d1 / d0))) + fabsf(logf(fabsf(d2 / d0))); +} + +static int by_score(const void *a, const void *b) { + const float x = score((const Cand *)a), y = score((const Cand *)b); + return x < y ? -1 : x > y; +} + +static void report(void) { + qsort(cands, ncands, sizeof(Cand), by_score); + hos_log("camfind: %s: %zu candidates", axis_name[axis], ncands); + for (size_t i = 0; i < ncands && i < REPORT; i++) { + const Cand *c = &cands[i]; + char around[400]; + int k = 0; + for (int o = -8; o <= 8; o++) { + float f; + memcpy(&f, GP(c->addr + o * 4), 4); + k += snprintf(around + k, sizeof around - k, o ? " %.4g" : " [%.4g]", f); + } + hos_log("camfind: %#llx: %.5f -> %.5f, rest %.5f, back %.5f, rest %.5f, again %.5f, now %.5f (score %.2f); around:%s", + (unsigned long long)c->addr, c->v[0], c->v[1], c->v[3], c->v[4], c->v[6], c->v[7], c->v[8], score(c), around); + } +} + +/* Called with each read of the controller (the game's thread): 1 while a run has the controls */ +int hos_camfind(uint64_t *buttons, int32_t stick[4]) { + if (enabled < 0) { + enabled = getenv("HOS_CAMFIND") && atoi(getenv("HOS_CAMFIND")) != 0; + if (enabled) hos_log("camfind: on: stand still somewhere open, then press F6 (about 25 s, hands off the controls)"); + } + if (!enabled) return 0; + const int key = (GetAsyncKeyState(VK_F6) & 0x8000) != 0; + if (key && !key_was && !running) { + running = 1, axis = 0, step = 0, step_start = hos_now_ns(); + hos_log("camfind: started"); + } + key_was = key; + if (!running) return 0; + *buttons = 0; + memset(stick, 0, 4 * sizeof *stick); + const Step *s = &steps[step]; + const double length = s->dir ? turn_seconds[axis] : s->seconds; + if (s->dir) stick[2 + axis] = s->dir * 30000; + if (secs(hos_now_ns() - step_start) < length) return 1; + switch (s->then) { + case A_SNAP: take_snapshot(); break; + case A_NOISE: mark_noise(); break; + case A_DIFF: diff(); break; + case A_REC: + for (size_t i = 0; i < ncands; i++) cands[i].v[s->slot] = now_of(&cands[i]); + break; + case A_STABLE: case A_OPP: case A_SAME: + keep(s->then, s->slot); + hos_log("camfind: %s: %zu left", s->what, ncands); + break; + case A_REPORT: report(); break; + case A_NEXT: + if (++axis == 2) { + running = 0; + free(cands), cands = NULL, ncands = 0; + hos_log("camfind: done: the controls are back"); + return 0; + } + step = -1; + break; + default: break; + } + step++; + step_start = hos_now_ns(); /* (after the work: a snapshot takes the game's thread a moment) */ + return 1; +} diff --git a/host/nn_hid.c b/host/nn_hid.c index ad4c8960..3d4456e7 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -416,6 +416,10 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { *buttons = 0; memset(stick, 0, 4 * sizeof *stick); scripted_input(buttons, stick); + { /* (hookshot) HOS_CAMFIND=1: F6 runs the camera finder (camfind.c), which has the controls while it runs */ + int hos_camfind(uint64_t *buttons, int32_t stick[4]); + if (hos_camfind(buttons, stick)) return; + } /* A scripted run (a test) takes no input from the player's devices until 5 s after its last press (a variable left set in a shell no longer leaves the game without controls), and otherwise the pads count only while the game's window is in front, like the keyboard: XInput is global, so a test run in the background From 8250c6ff984db5414c42b53d570fb294a17d4e46 Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 17:11:56 -0500 Subject: [PATCH 06/10] Camera finder v2: turn on, then back; the axes check each other; every candidate to a file v1 turned one way, back and the first way again, which a flag that says which way the camera last turned passes as well as an angle: most of its best were such flags. Now the second turn goes on the same way (an angle moves on, a flag does not), then back. Floats that were 0 before or after the first turn (fresh objects) are left out. The yaw candidates are watched while the pitch is found and must hold still, and a last yaw turn must leave the pitch candidates alone. Every candidate left is written to /camfind.txt with the 512 bytes before it as 64-bit words (the object and its vtable). Several threads read the controller: one does a step's work at a time. Tested on a simulated heap with such a flag, a value that moves with both axes, and fresh values: only the yaw and the pitch were left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- host/camfind.c | 200 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 149 insertions(+), 51 deletions(-) diff --git a/host/camfind.c b/host/camfind.c index f9157f3b..bc86725a 100644 --- a/host/camfind.c +++ b/host/camfind.c @@ -2,18 +2,24 @@ yaw and pitch, "lng" and "lat" in the engine's camera parameters: PlayerCameraBasicParam's StickSensitivityLat2LngRatio), and nothing names where they are. This finds them by watching the game turn its camera. - F6 (the game's window in front) starts a run of about 25 s; the player's controls are off until it ends. For each axis, + F6 (the game's window in front) starts a run of about 35 s; the player's controls are off until it ends. For each axis, yaw (the right stick's x) and then pitch (its y): 1. idle 1.5 s, then a snapshot of the heap's used pages (resident in the process and not all zero) 2. idle 0.4 s more: the words that changed with nothing pressed (animation, particles, timers) are noise - 3. the stick one way for a moment; the words that changed, noise left out, are the candidates + 3. the stick one way for a moment: the floats that changed, noise and fresh ones (0 before or after) left out, + are the candidates 4. idle 2 s, then 0.5 s: candidates that still moved are dropped (the camera is at rest) - 5. the stick the other way as long: kept if they moved back, by a similar amount + 5. the stick the same way again: kept if they moved on that way by a similar amount (a flag that only says + which way the camera last turned does not, an angle does) 6. idle again: dropped if still moving - 7. the stick the first way again: kept if they moved that way, by a similar amount - The candidates left are logged ("camfind:" lines in hookshot_log.txt), best match first, each with its values at every - step and the floats around it (the object it is in). An angle that wraps (radians past pi, degrees past 180) counts - as moving the short way round. */ + 7. the stick the other way: kept if they moved back by a similar amount + Then the axes check each other: the yaw candidates are watched while the pitch is found and must not move, and a + last turn of the yaw must not move the pitch candidates. An angle that wraps (radians past pi, degrees past 180) + counts as moving the short way round. + The best are logged ("camfind:" lines in hookshot_log.txt); every one left is written to /camfind.txt, with + its values, the floats around it and the 512 bytes before it as 64-bit words (the object it is in, and its vtable: + what a later build looks for to find the camera in any run). (v1 turned one way, back and the first way again, + which a flag passes as well as an angle, and logged 48.) */ #include "hos.h" #include #include @@ -23,39 +29,47 @@ #include extern uint64_t hos_heap_size; -enum { PAGE = 4096, WORDS = PAGE / 4, NV = 9, MAX_CANDS = 4 << 20, REPORT = 48 }; +extern const char *hos_user_dir; +enum { PAGE = 4096, WORDS = PAGE / 4, NV = 9, MAX_CANDS = 4 << 20, LOGGED = 24 }; -typedef struct { uint64_t addr; float v[NV]; } Cand; +typedef struct { uint64_t addr; float v[NV]; float cross; } Cand; /* cross: the most it moved while the other axis turned */ -typedef enum { A_NONE, A_SNAP, A_NOISE, A_DIFF, A_REC, A_STABLE, A_OPP, A_SAME, A_REPORT, A_NEXT } Action; +typedef enum { A_SNAP, A_NOISE, A_DIFF, A_REC, A_STABLE, A_SAME, A_OPP, A_REPORT, A_NEXT, A_CROSS } Action; typedef struct { int dir; double seconds; Action then; int slot; const char *what; } Step; -/* one axis: the stick direction (+1/-1 times the axis), how long, and what is done when the step ends */ +/* one axis: the stick direction (+1/-1 along the axis), how long (0: the axis' turn), what is done when the step ends */ static const Step steps[] = { {0, 1.5, A_SNAP, 0, "snapshot"}, {0, 0.4, A_NOISE, 0, "noise"}, - {+1, 0.0, A_DIFF, 1, "first turn"}, /* (the turn's length: per axis, below) */ + {+1, 0, A_DIFF, 1, "first turn"}, {0, 2.0, A_REC, 2, "settle"}, {0, 0.5, A_STABLE, 3, "at rest"}, - {-1, 0.0, A_OPP, 4, "turn back"}, + {+1, 0, A_SAME, 4, "turn on"}, {0, 2.0, A_REC, 5, "settle"}, {0, 0.5, A_STABLE, 6, "at rest"}, - {+1, 0.0, A_SAME, 7, "turn again"}, + {-1, 0, A_OPP, 7, "turn back"}, {0, 1.5, A_REC, 8, "settle"}, - {0, 0.0, A_REPORT, 0, "report"}, - {0, 0.0, A_NEXT, 0, "next axis"}, + {0, 0, A_REPORT, 0, "report"}, + {0, 0, A_NEXT, 0, "next axis"}, +}; +/* after the pitch: the yaw turned once more, the pitch candidates watched */ +static const Step cross_steps[] = { + {+1, 0.4, A_CROSS, 0, "yaw turn (the pitch must not move)"}, + {0, 1.5, A_CROSS, 0, "settle"}, + {0, 0, A_NEXT, 0, "done"}, }; static const char *axis_name[2] = {"yaw (right stick x)", "pitch (right stick y)"}; -static const double turn_seconds[2] = {0.6, 0.35}; +static const double turn_seconds[2] = {0.5, 0.2}; -static int enabled = -1, running, axis, step, key_was; +static int enabled = -1, running, axis, step, key_was, crossing; static int64_t step_start; static uint8_t *snap; /* the snapshot's pages, packed */ static uint64_t *snap_addr; /* each page's guest address */ static uint8_t *noise; /* a bit a word: changed while idle */ static size_t snap_pages; -static Cand *cands; -static size_t ncands; +static Cand *cands, *yaw; /* this axis' candidates; the yaw's, kept while the pitch is found */ +static size_t ncands, nyaw; +static float *pitch_start; /* the pitch candidates' values before the cross turn */ static double secs(int64_t ns) { return ns / 1e9; } @@ -71,6 +85,8 @@ static int page_zero(const uint8_t *p) { return 1; } +static float at(uint64_t a) { float f; memcpy(&f, GP(a), 4); return f; } + /* Which heap pages the process has touched: Windows says which are in its working set; elsewhere every page is read (an untouched page of an anonymous mapping reads as the shared zero page there, and zero pages are left out) */ #ifdef _WIN32 @@ -154,10 +170,10 @@ static void diff(void) { if (noise[bit >> 3] & (1 << (bit & 7))) continue; float a, b; memcpy(&a, &old[w], 4), memcpy(&b, &now[w], 4); - if (!usable(a) || !usable(b) || fabsf(b - a) < 1e-5f) continue; + if (!usable(a) || !usable(b) || a == 0.0f || b == 0.0f || fabsf(b - a) < 1e-5f) continue; if (ncands == MAX_CANDS) { over++; continue; } Cand *c = &cands[ncands++]; - memset(c->v, 0, sizeof c->v); + memset(c, 0, sizeof *c); c->addr = snap_addr[p] + (uint64_t)w * 4; c->v[0] = a, c->v[1] = b; } @@ -166,9 +182,7 @@ static void diff(void) { hos_log("camfind: %zu candidates moved with the turn%s", ncands, over ? " (more than kept: the rest dropped)" : ""); } -static float now_of(const Cand *c) { float f; memcpy(&f, GP(c->addr), 4); return f; } - -/* d, or d the short way round for an angle that wrapped (radians or degrees) */ +/* d along d0 (or against it), by 0.3 to 3.3 times as much; d the short way round for an angle that wrapped */ static int matches(float d, float d0, int opposite) { const float want = opposite ? -d0 : d0; const float tries[3] = {d, d - copysignf(6.2831853f, d), d - copysignf(360.0f, d)}; @@ -179,15 +193,17 @@ static int matches(float d, float d0, int opposite) { return 0; } +static int still(float now, float was) { return fabsf(now - was) <= 1e-6f * fmaxf(1.0f, fabsf(now)); } + static void keep(Action a, int slot) { size_t kept = 0; for (size_t i = 0; i < ncands; i++) { Cand *c = &cands[i]; - const float f = now_of(c); + const float f = at(c->addr); int ok = usable(f); - if (ok && a == A_STABLE) ok = fabsf(f - c->v[slot - 1]) <= 1e-6f * fmaxf(1.0f, fabsf(f)); - if (ok && a == A_OPP) ok = matches(f - c->v[slot - 1], c->v[1] - c->v[0], 1); + if (ok && a == A_STABLE) ok = still(f, c->v[slot - 1]); if (ok && a == A_SAME) ok = matches(f - c->v[slot - 1], c->v[1] - c->v[0], 0); + if (ok && a == A_OPP) ok = matches(f - c->v[slot - 1], c->v[1] - c->v[0], 1); if (!ok) continue; c->v[slot] = f; cands[kept++] = *c; @@ -195,9 +211,15 @@ static void keep(Action a, int slot) { ncands = kept; } -static float score(const Cand *c) { /* (how alike the three turns were: 0 = the same) */ - const float d0 = c->v[1] - c->v[0], d1 = -(c->v[4] - c->v[3]), d2 = c->v[7] - c->v[6]; - return fabsf(logf(fabsf(d1 / d0))) + fabsf(logf(fabsf(d2 / d0))); +static float wrapped(float d) { /* (the short way round, for the score) */ + if (fabsf(d) > 300.0f) d -= copysignf(360.0f, d); + else if (fabsf(d) > 3.2f && fabsf(d) < 7.0f) d -= copysignf(6.2831853f, d); + return d; +} + +static float score(const Cand *c) { /* how alike the three turns were (0: the same), plus what the other axis moved */ + const float d0 = wrapped(c->v[1] - c->v[0]), d1 = wrapped(c->v[4] - c->v[3]), d2 = -wrapped(c->v[7] - c->v[6]); + return fabsf(logf(fabsf(d1 / d0))) + fabsf(logf(fabsf(d2 / d0))) + (c->cross > 1e-4f ? 10 + c->cross : 0); } static int by_score(const void *a, const void *b) { @@ -205,67 +227,143 @@ static int by_score(const void *a, const void *b) { return x < y ? -1 : x > y; } +static void describe(FILE *f, const Cand *c, int with_object) { + char around[640]; + int k = 0; + for (int o = -8; o <= 8; o++) k += snprintf(around + k, sizeof around - k, o ? " %.5g" : " [%.5g]", at(c->addr + o * 4)); + const char *fmt = "%#llx: %.5f -> %.5f, rest %.5f, on %.5f, rest %.5f, back %.5f, now %.5f; other axis moved it %.5f " + "(score %.2f); around:%s"; + if (f) { + fprintf(f, fmt, (unsigned long long)c->addr, c->v[0], c->v[1], c->v[3], c->v[4], c->v[6], c->v[7], c->v[8], c->cross, + score(c), around); + fputc('\n', f); + if (with_object) { + const uint64_t from = (c->addr & ~7ULL) - 512; + for (int r = 0; r < 64; r += 4) + fprintf(f, " %#llx: %016llx %016llx %016llx %016llx\n", (unsigned long long)(from + r * 8), + (unsigned long long)G64(from + r * 8), (unsigned long long)G64(from + r * 8 + 8), + (unsigned long long)G64(from + r * 8 + 16), (unsigned long long)G64(from + r * 8 + 24)); + } + } else + hos_log("camfind: %#llx: %.5f -> %.5f, rest %.5f, on %.5f, rest %.5f, back %.5f, now %.5f; other axis %.5f (score %.2f); " + "around:%s", (unsigned long long)c->addr, c->v[0], c->v[1], c->v[3], c->v[4], c->v[6], c->v[7], c->v[8], + c->cross, score(c), around); +} + +static void write_results(void) { + char path[600]; + snprintf(path, sizeof path, "%s/camfind.txt", hos_user_dir ? hos_user_dir : "user"); + FILE *f = fopen(path, "w"); + if (!f) { hos_log("camfind: %s could not be written", path); return; } + const Cand *lists[2] = {yaw, cands}; + const size_t counts[2] = {nyaw, ncands}; + for (int a = 0; a < 2; a++) { + fprintf(f, "== %s: %zu candidates, best first\n", axis_name[a], counts[a]); + for (size_t i = 0; i < counts[a]; i++) describe(f, &lists[a][i], i < 200); + } + fclose(f); + hos_log("camfind: every candidate written to %s", path); +} + static void report(void) { qsort(cands, ncands, sizeof(Cand), by_score); hos_log("camfind: %s: %zu candidates", axis_name[axis], ncands); - for (size_t i = 0; i < ncands && i < REPORT; i++) { - const Cand *c = &cands[i]; - char around[400]; - int k = 0; - for (int o = -8; o <= 8; o++) { - float f; - memcpy(&f, GP(c->addr + o * 4), 4); - k += snprintf(around + k, sizeof around - k, o ? " %.4g" : " [%.4g]", f); - } - hos_log("camfind: %#llx: %.5f -> %.5f, rest %.5f, back %.5f, rest %.5f, again %.5f, now %.5f (score %.2f); around:%s", - (unsigned long long)c->addr, c->v[0], c->v[1], c->v[3], c->v[4], c->v[6], c->v[7], c->v[8], score(c), around); + for (size_t i = 0; i < ncands && i < LOGGED; i++) describe(NULL, &cands[i], 0); +} + +static void watch_yaw(void) { /* (during the pitch's steps: the most each yaw candidate moved from where it was) */ + for (size_t i = 0; i < nyaw; i++) { + const float d = fabsf(at(yaw[i].addr) - yaw[i].v[8]); + if (d > yaw[i].cross) yaw[i].cross = d; + } +} + +static void finish(void) { + /* the yaw: those that held still while the pitch turned first; the pitch: those the last yaw turn left alone */ + qsort(yaw, nyaw, sizeof(Cand), by_score); + qsort(cands, ncands, sizeof(Cand), by_score); + size_t yaw_still = 0, pitch_still = 0; + for (size_t i = 0; i < nyaw; i++) yaw_still += yaw[i].cross <= 1e-4f; + for (size_t i = 0; i < ncands; i++) pitch_still += cands[i].cross <= 1e-4f; + hos_log("camfind: yaw candidates the pitch left alone: %zu of %zu; pitch candidates the yaw left alone: %zu of %zu", yaw_still, + nyaw, pitch_still, ncands); + for (int a = 0; a < 2; a++) { + hos_log("camfind: best %s:", axis_name[a]); + const Cand *l = a ? cands : yaw; + const size_t n = a ? ncands : nyaw; + for (size_t i = 0; i < n && i < LOGGED; i++) describe(NULL, &l[i], 0); } + write_results(); } -/* Called with each read of the controller (the game's thread): 1 while a run has the controls */ +/* Called with each read of the controller (the game's threads): 1 while a run has the controls */ int hos_camfind(uint64_t *buttons, int32_t stick[4]) { if (enabled < 0) { enabled = getenv("HOS_CAMFIND") && atoi(getenv("HOS_CAMFIND")) != 0; - if (enabled) hos_log("camfind: on: stand still somewhere open, then press F6 (about 25 s, hands off the controls)"); + if (enabled) hos_log("camfind: on (v2): stand still somewhere open, then press F6 (about 35 s, hands off the controls)"); } if (!enabled) return 0; + static volatile LONG busy; /* (several threads read the controller: one does a step's work, the others wait it out) */ const int key = (GetAsyncKeyState(VK_F6) & 0x8000) != 0; if (key && !key_was && !running) { - running = 1, axis = 0, step = 0, step_start = hos_now_ns(); + running = 1, axis = 0, step = 0, crossing = 0, step_start = hos_now_ns(); + free(yaw), yaw = NULL, nyaw = 0; hos_log("camfind: started"); } key_was = key; if (!running) return 0; *buttons = 0; memset(stick, 0, 4 * sizeof *stick); - const Step *s = &steps[step]; - const double length = s->dir ? turn_seconds[axis] : s->seconds; - if (s->dir) stick[2 + axis] = s->dir * 30000; + const Step *s = crossing ? &cross_steps[step] : &steps[step]; + const int stick_axis = crossing ? 0 : axis; + const double length = s->seconds > 0 ? s->seconds : (s->dir ? turn_seconds[axis] : 0); + if (s->dir) stick[2 + stick_axis] = s->dir * 30000; if (secs(hos_now_ns() - step_start) < length) return 1; + if (InterlockedExchange(&busy, 1)) return 1; + if (axis == 1 && !crossing) watch_yaw(); switch (s->then) { case A_SNAP: take_snapshot(); break; case A_NOISE: mark_noise(); break; case A_DIFF: diff(); break; case A_REC: - for (size_t i = 0; i < ncands; i++) cands[i].v[s->slot] = now_of(&cands[i]); + for (size_t i = 0; i < ncands; i++) cands[i].v[s->slot] = at(cands[i].addr); break; - case A_STABLE: case A_OPP: case A_SAME: + case A_STABLE: case A_SAME: case A_OPP: keep(s->then, s->slot); hos_log("camfind: %s: %zu left", s->what, ncands); break; case A_REPORT: report(); break; + case A_CROSS: + for (size_t i = 0; i < ncands; i++) { + const float d = fabsf(at(cands[i].addr) - pitch_start[i]); + if (d > cands[i].cross) cands[i].cross = d; + } + break; case A_NEXT: - if (++axis == 2) { + if (crossing) { + finish(); running = 0; free(cands), cands = NULL, ncands = 0; + free(yaw), yaw = NULL, nyaw = 0; + free(pitch_start), pitch_start = NULL; hos_log("camfind: done: the controls are back"); + busy = 0; return 0; } + if (axis == 0) { /* the yaw's candidates kept, watched from now on */ + yaw = cands, nyaw = ncands, cands = NULL, ncands = 0; + axis = 1; + } else { /* the pitch's: watched through a last yaw turn */ + pitch_start = (float *)malloc(sizeof(float) * (ncands + 1)); + for (size_t i = 0; i < ncands; i++) pitch_start[i] = at(cands[i].addr); + crossing = 1; + } step = -1; break; default: break; } step++; step_start = hos_now_ns(); /* (after the work: a snapshot takes the game's thread a moment) */ + busy = 0; return 1; } From c7bad5681388ac8a44418917c6a9f2af9a980a2a Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 17:36:44 -0500 Subject: [PATCH 07/10] Native mouse look (HOS_MOUSE_NATIVE=1): the mouse turns the camera's own angles The camera finder found the player camera in Kokiri Forest: its controller holds the pitch limits {-85, 89, -85, 89} (degrees), its pitch at +0x14 from them and its yaw at +0x2c, which the stick turns about 90 degrees a second (yaw falling to the right, pitch falling as the stick goes up); its output (pitch and yaw side by side) follows. With HOS_MOUSE_NATIVE=1 the captured mouse adds to those (0.1 degree a count at sensitivity 1; - and = as before) instead of standing in for the right stick: no decay, no response curve, no top speed. The limits are looked for in the heap when the mouse first moves and again when they move (a new scene). Which copy the game reads is not known yet: F7 cycles the target (the controller, the output copies, both). camfind.c: the used-page walk shared (hos_heap_find). nn_hid.c: the mouse's setup out of mouse_stick, for both paths. Launcher v3: "Game built from" (the commit of the last finished build), a warning when it is older than the build tools, no "Up to date" while a build runs, and an update that changes only the launcher skips the rebuild. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- host/camfind.c | 36 +++++++++++-- host/mouse_native.c | 118 +++++++++++++++++++++++++++++++++++++++++++ host/nn_hid.c | 29 ++++++++--- launcher/launcher.py | 30 +++++++++-- 4 files changed, 198 insertions(+), 15 deletions(-) create mode 100644 host/mouse_native.c diff --git a/host/camfind.c b/host/camfind.c index bc86725a..09b84c4e 100644 --- a/host/camfind.c +++ b/host/camfind.c @@ -94,12 +94,10 @@ typedef struct { void *va; ULONG_PTR attr; } WsInfo; typedef BOOL(WINAPI *QwsFn)(HANDLE, void *, DWORD); #endif -static void take_snapshot(void) { - const int64_t t0 = hos_now_ns(); - drop_snapshot(); - const uint64_t pages = hos_heap_size / PAGE; +/* a byte a heap page: 1 if the process has it (the caller frees it) */ +static uint8_t *pages_in_use(uint64_t pages) { uint8_t *use = (uint8_t *)calloc((size_t)pages, 1); - if (!use) { hos_log("camfind: no memory for the page map"); return; } + if (!use) return NULL; #ifdef _WIN32 QwsFn qws = (QwsFn)GetProcAddress(GetModuleHandleA("kernel32.dll"), "K32QueryWorkingSetEx"); if (qws) { @@ -115,6 +113,34 @@ static void take_snapshot(void) { } else #endif memset(use, 1, (size_t)pages); + return use; +} + +/* (mouse_native.c) the 4-byte-aligned places in the heap's used pages that hold len bytes of pat (len <= a page), at + most max of them; the number found */ +size_t hos_heap_find(const void *pat, size_t len, uint64_t *out, size_t max) { + const uint64_t pages = hos_heap_size / PAGE; + uint8_t *use = pages_in_use(pages); + if (!use || len < 4 || len > PAGE) { free(use); return 0; } + uint32_t first; + memcpy(&first, pat, 4); + size_t n = 0; + for (uint64_t i = 0; i < pages && n < max; i++) { + if (!use[i]) continue; + const uint8_t *p = (const uint8_t *)GP(HOS_HEAP_BASE + i * PAGE); + for (size_t o = 0; o + len <= PAGE && n < max; o += 4) + if (*(const uint32_t *)(p + o) == first && !memcmp(p + o, pat, len)) out[n++] = HOS_HEAP_BASE + i * PAGE + o; + } + free(use); + return n; +} + +static void take_snapshot(void) { + const int64_t t0 = hos_now_ns(); + drop_snapshot(); + const uint64_t pages = hos_heap_size / PAGE; + uint8_t *use = pages_in_use(pages); + if (!use) { hos_log("camfind: no memory for the page map"); return; } size_t n = 0; for (uint64_t i = 0; i < pages; i++) if (use[i] && !page_zero((const uint8_t *)GP(HOS_HEAP_BASE + i * PAGE))) use[i] = 2, n++; diff --git a/host/mouse_native.c b/host/mouse_native.c new file mode 100644 index 00000000..61512e05 --- /dev/null +++ b/host/mouse_native.c @@ -0,0 +1,118 @@ +/* (hookshot) Native mouse look (HOS_MOUSE_NATIVE=1): the captured mouse turns the camera itself, its yaw and pitch in + degrees, instead of standing in for the right stick (no decay, no response curve, no top speed: nn_hid.c's + mouse_stick). + + Where the angles are (the camera finder, camfind.c, in Kokiri Forest, 2026-11-07): the player camera's controller + holds its pitch limits as {-85, 89, -85, 89} (four floats, degrees), its distance after them, its pitch at +0x14 + from them and its yaw at +0x2c; the stick turns those, about 90 degrees a second, yaw falling as the stick goes + right and pitch falling as it goes up. Its output (pitch and yaw side by side, after a ring of recent camera + positions) follows. The limits are looked for in the heap when the mouse first moves and whenever they are no + longer where they were (a new scene), at most every 3 s. + + Which of those the game reads is not known yet: F7 cycles what the mouse writes, logged as "mouse native: target": + 1 the controller, 2 the output copies, 3 both. The sensitivity is the mouse's (- and =): 0.1 degrees a count at 1. + HOS_MOUSE_INVERT_Y=1 flips up and down. */ +#include "hos.h" +#include +#include +#include +#include +#define WIN32_LEAN_AND_MEAN +#include + +int hos_mouse_take(int32_t *dx, int32_t *dy); /* display.c */ +size_t hos_heap_find(const void *pat, size_t len, uint64_t *out, size_t max); /* camfind.c */ +extern double hos_mouse_sens; + +enum { MAX_T = 16, PITCH_AT = 0x14, YAW_AT = 0x2c }; +static const float limits_sig[4] = {-85.0f, 89.0f, -85.0f, 89.0f}; +static uint64_t ctl[MAX_T], outs[MAX_T]; /* the controllers (where their limits are); the output copies (their pitch) */ +static size_t nctl, nout; +static int target; /* 0 the controller, 1 the output copies, 2 both */ +static int64_t next_scan; +static volatile LONG busy, pend_x, pend_y; /* (motion taken by a thread while another applied: applied next) */ +static long long applied, moves; + +static float getf(uint64_t a) { float f; memcpy(&f, GP(a), 4); return f; } +static void putf(uint64_t a, float f) { memcpy(GP(a), &f, 4); } + +static int still_there(void) { return nctl && !memcmp(GP(ctl[0]), limits_sig, sizeof limits_sig); } + +static void find(void) { + const int64_t t0 = hos_now_ns(); + uint64_t found[MAX_T]; + size_t n = hos_heap_find(limits_sig, sizeof limits_sig, found, MAX_T); + nctl = 0; + for (size_t i = 0; i < n; i++) { + const float p = getf(found[i] + PITCH_AT), y = getf(found[i] + YAW_AT); + if (isfinite(p) && isfinite(y) && fabsf(p) <= 90.0f && fabsf(y) <= 360.0f) ctl[nctl++] = found[i]; + hos_log("mouse native: limits at %#llx: pitch %.3f, yaw %.3f%s", (unsigned long long)found[i], p, y, + nctl && ctl[nctl - 1] == found[i] ? "" : " (not angles: left out)"); + } + nout = 0; + if (nctl) { /* the output copies: the controller's pitch and yaw side by side */ + const float pair[2] = {getf(ctl[0] + PITCH_AT), getf(ctl[0] + YAW_AT)}; + const size_t n2 = hos_heap_find(pair, sizeof pair, outs, MAX_T); + for (size_t i = 0; i < n2; i++) { /* (not inside a controller: its yaw after a 0 reads as the pair at pitch 0) */ + int inside = 0; + for (size_t c = 0; c < nctl; c++) inside |= outs[i] >= ctl[c] && outs[i] < ctl[c] + 0x40; + if (!inside) outs[nout++] = outs[i]; + } + } + hos_log("mouse native: %zu controller(s), %zu output copies, found in %.0f ms; target %d (%s)", nctl, nout, + (hos_now_ns() - t0) / 1e6, target + 1, target == 0 ? "the controller" : target == 1 ? "the output copies" : "both"); +} + +static void turn(uint64_t pitch_at, uint64_t yaw_at, float dpitch, float dyaw, float lo, float hi) { + float y = getf(yaw_at) + dyaw, p = getf(pitch_at) + dpitch; + if (y > 180.0f) y -= 360.0f; + if (y < -180.0f) y += 360.0f; + if (p < lo) p = lo; + if (p > hi) p = hi; + putf(yaw_at, y), putf(pitch_at, p); +} + +/* With each read of the controller (nn_hid.c, the game's threads): the mouse's motion since the last into the camera. + 1 while the mouse is captured (its buttons count then) */ +int hos_mouse_native(void) { + static int invert = -1, f7_was; + if (invert < 0) { + invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; + hos_log("mouse native: on: the mouse turns the camera itself (F7: what it writes)"); + } + const int f7 = (GetAsyncKeyState(VK_F7) & 0x8000) != 0; + if (f7 && !f7_was) { + target = (target + 1) % 3; + hos_log("mouse native: target %d (%s)", target + 1, + target == 0 ? "the controller" : target == 1 ? "the output copies" : "both"); + } + f7_was = f7; + int32_t mx, my; + const int captured = hos_mouse_take(&mx, &my); + if (!captured) return 0; + if (mx || my) InterlockedExchangeAdd(&pend_x, mx), InterlockedExchangeAdd(&pend_y, my); + if (InterlockedExchange(&busy, 1)) return captured; /* (another thread is at it: it takes this motion too) */ + const LONG dx = InterlockedExchange(&pend_x, 0), dy = InterlockedExchange(&pend_y, 0); + if (!dx && !dy) { busy = 0; return captured; } + const int64_t now = hos_now_ns(); + if (!still_there() && now >= next_scan) { + next_scan = now + 3000000000LL; + find(); + } + if (nctl) { + const double k = 0.1 * hos_mouse_sens; + const float dyaw = (float)(-dx * k), dpitch = (float)((invert ? dy : -dy) * k); + const float lo = getf(ctl[0]), hi = getf(ctl[0] + 4); + if (target != 1) + for (size_t i = 0; i < nctl; i++) turn(ctl[i] + PITCH_AT, ctl[i] + YAW_AT, dpitch, dyaw, lo, hi); + if (target != 0) + for (size_t i = 0; i < nout; i++) turn(outs[i], outs[i] + 4, dpitch, dyaw, lo, hi); + applied++; + } + moves++; + if ((moves & 1023) == 1) + hos_log("mouse native: %lld moves, %lld applied; controller pitch %.2f yaw %.2f", moves, applied, + nctl ? getf(ctl[0] + PITCH_AT) : 0.0f, nctl ? getf(ctl[0] + YAW_AT) : 0.0f); + busy = 0; + return captured; +} diff --git a/host/nn_hid.c b/host/nn_hid.c index 3d4456e7..459b164a 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -132,11 +132,8 @@ static void mouse_look_tick(void) { the centre as soon as the mouse stopped, taking the pick with it. The stick starts at the centre, stays where the mouse puts it (cut to length 1) and is centred again when L is let go, so the camera does not turn by itself after the wheel closes. */ -static int mouse_stick(int32_t *rx, int32_t *ry, int pointing) { - static int64_t next_tick; - static int32_t shown[2]; - static int captured, was_pointing; - static int invert = -1; +static int invert = -1; +static void mouse_setup(void) { /* (once: the sensitivity kept in /mouse_sens.txt, HOS_MOUSE_SENS, the inversion) */ if (invert < 0) { invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; hos_mouse_sens = 1.0; @@ -151,6 +148,13 @@ static int mouse_stick(int32_t *rx, int32_t *ry, int pointing) { const char *e = getenv("HOS_MOUSE_SENS"); if (e && atof(e) > 0) hos_mouse_sens = atof(e); } +} + +static int mouse_stick(int32_t *rx, int32_t *ry, int pointing) { + static int64_t next_tick; + static int32_t shown[2]; + static int captured, was_pointing; + mouse_setup(); mouse_sens_keys(); const int64_t now = hos_now_ns(); if (now >= next_tick) { @@ -443,8 +447,19 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { /* left button Y (attack), right button ZL (shield, lock on), while the mouse is captured; a button held when the capture began counts only after it was let go (the click that captures never reaches the game) */ static int armed[2]; - if (mouse_stick(&mouse[2], &mouse[3], (int)(*buttons >> B_L & 1))) { - stick_max(stick, mouse); + /* (hookshot) HOS_MOUSE_NATIVE=1: the mouse turns the camera itself (mouse_native.c), not the right stick */ + static int native = -1; + if (native < 0) native = getenv("HOS_MOUSE_NATIVE") && atoi(getenv("HOS_MOUSE_NATIVE")) != 0; + int hos_mouse_native(void); + int mouse_on; + if (native) { + mouse_setup(); + mouse_sens_keys(); + mouse_on = hos_mouse_native(); + } else + mouse_on = mouse_stick(&mouse[2], &mouse[3], (int)(*buttons >> B_L & 1)); + if (mouse_on) { + if (!native) stick_max(stick, mouse); const int vk[2] = {VK_LBUTTON, VK_RBUTTON}, b[2] = {B_Y, B_ZL}; for (int i = 0; i < 2; i++) { if (!key(vk[i])) armed[i] = 1; diff --git a/launcher/launcher.py b/launcher/launcher.py index bf4c1c9c..c57a5a48 100644 --- a/launcher/launcher.py +++ b/launcher/launcher.py @@ -31,12 +31,13 @@ import zipfile from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -LAUNCHER_VERSION = 2 +LAUNCHER_VERSION = 3 HERE = os.path.dirname(os.path.abspath(__file__)) TOOLS = os.environ.get("HOOKSHOT_TOOLS") or os.path.dirname(HERE) HOME = os.environ.get("HOOKSHOT_HOME") or os.path.dirname(TOOLS) CONFIG = os.path.join(HOME, "launcher.json") SOURCE_STATE = os.path.join(TOOLS, ".hookshot_source.json") +BUILT_STATE = os.path.join(HOME, "launcher_built.json") # (the commit the game was last built from) BACKUPS = os.path.join(HOME, "launcher_backups") DOWNLOADS = os.path.join(HOME, "launcher_download") SYNCED = ("gpu", "host", "nx2", "scripts", "third_party", "launcher") @@ -97,6 +98,11 @@ def installed(): return {k: s.get(k) for k in ("repo", "branch", "sha", "date", "message", "updated")} if s.get("sha") else None +def built(): + b = read_json(BUILT_STATE, {}) + return b if b.get("sha") else None + + # ---- GitHub ---- class GitHubError(Exception): @@ -176,6 +182,7 @@ def sync(zip_path, commit, log): stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "_" + (old.get("sha") or "unknown")[:7] backup = os.path.join(BACKUPS, stamp) counts = {"changed": 0, "added": 0, "removed": 0, "same": 0} + touched = [] launcher_changed = False def keep(rel, dst, move): @@ -202,10 +209,12 @@ def keep(rel, dst, move): f.write(data) os.replace(dst + ".new", dst) launcher_changed |= rel.startswith("launcher/") + touched.append(rel) for rel in sorted(set(old.get("files", [])) - set(new)): dst = os.path.join(TOOLS, *rel.split("/")) if os.path.isfile(dst): keep(rel, dst, move=True) + touched.append(rel) counts["removed"] += 1 log(" removed " + rel) write_json(SOURCE_STATE, dict(commit, updated=datetime.datetime.now().isoformat(timespec="seconds"), files=sorted(new))) @@ -213,6 +222,8 @@ def keep(rel, dst, move): for d in sorted(os.listdir(BACKUPS))[:-3]: shutil.rmtree(os.path.join(BACKUPS, d), ignore_errors=True) counts["launcher"] = launcher_changed + counts["only_launcher"] = bool(touched) and all(r.startswith("launcher/") for r in touched) + counts["nothing"] = not touched return counts @@ -324,6 +335,7 @@ def finish(self, state, message): def run(self, update, cfg, commit): try: + self.previous_sha = (installed() or {}).get("sha") if update: self.stage("download") repo, branch = source_of(cfg) @@ -346,6 +358,12 @@ def got(f, n): with self.lock: self.relaunch = c["launcher"] self.message = "" + b = built() + if c["only_launcher"] and b and b.get("sha") == self.previous_sha: + write_json(BUILT_STATE, dict(b, sha=commit["sha"], date=commit["date"], message=commit["message"])) + for k in ("read", "lift", "compile", "finish"): + self.stages[k] = "skipped" + return self.finish("done", "Only the launcher changed: no rebuild needed. Restart the launcher to use it.") if self.cancelled: return self.finish("cancelled", "Cancelled.") self.build(cfg) @@ -388,6 +406,9 @@ def build(self, cfg): if self.cancelled: self.finish("cancelled", "Cancelled.") elif p.returncode == 0 and done: + inst = installed() + if inst: + write_json(BUILT_STATE, dict(inst, built=datetime.datetime.now().isoformat(timespec="seconds"))) self.finish("done", "The game is ready in " + done) else: self.finish("failed", error or "The build stopped (exit code %s). See the details." % p.returncode) @@ -460,7 +481,7 @@ def state(): repo, branch = source_of(cfg) update = bool(latest and (not inst or inst["sha"] != latest["sha"])) out = cfg["out"] - return {"version": LAUNCHER_VERSION, "settings": cfg, "languages": LANGUAGES, "installed": inst, "check": chk, "update": update, + return {"version": LAUNCHER_VERSION, "settings": cfg, "languages": LANGUAGES, "installed": inst, "built": built(), "check": chk, "update": update, "source": {"repo": repo, "branch": branch, "url": "https://github.com/%s/tree/%s" % (repo, branch)}, "fork_url": "https://github.com/" + cfg["fork_repo"], "tools": TOOLS, "problem": settings_problem(cfg), "playable": bool(out) and os.path.isfile(os.path.join(out, "hookshot.exe")), "job": job.view()} @@ -579,6 +600,7 @@ def do_POST(self):
Installed
…
+
Game built from
…
Newest
…
Source
…
@@ -620,11 +642,13 @@ def do_POST(self): const val=id=>document.getElementById(id).value; function render(){const s=S,j=s.job,c=s.check,run=j.state==="running"; document.getElementById("inst").innerHTML=s.installed?commit(s.installed)+(s.installed.repo!==s.source.repo?`
from ${esc(s.installed.repo)}
`:""):'Not recorded yet: the source came with the builder package.'; - document.getElementById("latest").innerHTML=c.latest?commit(c.latest)+(s.update?'Update available':'Up to date'):(c.busy?"Checking…":'Not checked yet'); + document.getElementById("built").innerHTML=s.built?commit(s.built):'Not recorded yet (the first build with this launcher records it)'; + document.getElementById("latest").innerHTML=c.latest?commit(c.latest)+(run?'Installing…':s.update?'Update available':'Up to date'):(c.busy?"Checking…":'Not checked yet'); document.getElementById("src").innerHTML=`${esc(s.source.repo)} · ${esc(s.source.branch)}`+(c.checked?` checked ${esc(when(c.checked*1000))}`:""); let n="";if(c.error)n+=`

${esc(c.error)}

`; if(s.problem)n+=`

${esc(s.problem)}

`; if(s.settings.source==="fork"&&c.ahead>0)n+=`

NotRexed has ${c.ahead} commit${c.ahead>1?"s":""} your fork doesn't have yet. To bring them in, open your fork and press Sync fork, or switch Settings to Official only.

`; + if(!run&&s.built&&s.installed&&s.built.sha!==s.installed.sha)n+=`

The game was built from ${esc(s.built.sha.slice(0,7))}, older than the build tools (${esc(s.installed.sha.slice(0,7))}): press Rebuild only to bring it up to date.

`; if(j.relaunch)n+='

The launcher itself was updated: close it and start it again to use the new version.

'; document.getElementById("notes").innerHTML=n; const go=document.getElementById("go");go.disabled=run||!!s.problem;go.textContent="Update & rebuild"; From e46f18ae056a35fab87ebdecb673f57dfa69b67e Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 17:52:03 -0500 Subject: [PATCH 08/10] Native mouse look on by default; up and down the right way; no hitch The test build's answers: the controller's angles are what the game reads (target 1 turned the camera like a mouse; the output copies, target 2, did nothing: the game recomputes them), and the pitch was the wrong way round (it rises as the view tilts down). Now: - the mouse turns the controller only (no F7, no output copies), pitch the right way; HOS_MOUSE_INVERT_Y=1 flips it - on by default; HOS_MOUSE_NATIVE=0 for the mouse as the right stick - the heap is searched for the camera on a thread of its own (it held the game's thread ~950 ms on the first motion), at most every 3 s, then less often while none is found (up to 30 s) - until a camera is found the motion is given back and the mouse turns the camera as the stick does (display.c: hos_mouse_give_back) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- host/display.c | 6 ++ host/mouse_native.c | 153 ++++++++++++++++++++------------------- host/nn_hid.c | 21 ++++-- host/posix/display_sdl.c | 6 ++ 4 files changed, 102 insertions(+), 84 deletions(-) diff --git a/host/display.c b/host/display.c index 4a729446..9ceaf9c6 100644 --- a/host/display.c +++ b/host/display.c @@ -33,6 +33,12 @@ int hos_mouse_take(int32_t *dx, int32_t *dy) { return mouse_captured; } +/* (mouse_native.c) motion taken and not used, put back for the next taker (the stick's, while no camera is found) */ +void hos_mouse_give_back(int32_t dx, int32_t dy) { + InterlockedExchangeAdd(&mouse_dx, dx); + InterlockedExchangeAdd(&mouse_dy, dy); +} + /* (diagnostics, the mouse that did nothing for one player: nn_hid.c logs these every 5 s while the mouse is in use) the raw mouse reports since the last call, all of them and those taken while captured, and the clicks the window had */ static volatile LONG raw_reports, raw_taken, raw_absolute, clicks; diff --git a/host/mouse_native.c b/host/mouse_native.c index 61512e05..1cd5e974 100644 --- a/host/mouse_native.c +++ b/host/mouse_native.c @@ -1,17 +1,19 @@ -/* (hookshot) Native mouse look (HOS_MOUSE_NATIVE=1): the captured mouse turns the camera itself, its yaw and pitch in - degrees, instead of standing in for the right stick (no decay, no response curve, no top speed: nn_hid.c's - mouse_stick). +/* (hookshot) Native mouse look (on by default; HOS_MOUSE_NATIVE=0: the mouse as the right stick again, nn_hid.c's + mouse_stick): the captured mouse turns the camera itself, its yaw and pitch in degrees, with no decay, no response + curve and no top speed. Where the angles are (the camera finder, camfind.c, in Kokiri Forest, 2026-11-07): the player camera's controller holds its pitch limits as {-85, 89, -85, 89} (four floats, degrees), its distance after them, its pitch at +0x14 - from them and its yaw at +0x2c; the stick turns those, about 90 degrees a second, yaw falling as the stick goes - right and pitch falling as it goes up. Its output (pitch and yaw side by side, after a ring of recent camera - positions) follows. The limits are looked for in the heap when the mouse first moves and whenever they are no - longer where they were (a new scene), at most every 3 s. + from them and its yaw at +0x2c. Yaw falls as the view turns right and pitch rises as it tilts down (the first test + build had the pitch the other way: up and down were swapped). The output beside it (pitch and yaw side by side) is + recomputed by the game every frame: writing it did nothing (the second target of that build). - Which of those the game reads is not known yet: F7 cycles what the mouse writes, logged as "mouse native: target": - 1 the controller, 2 the output copies, 3 both. The sensitivity is the mouse's (- and =): 0.1 degrees a count at 1. - HOS_MOUSE_INVERT_Y=1 flips up and down. */ + The limits are looked for in the heap on a thread of their own (about a second over the heap's 2 GB in use, which + took the game's thread that long in the test build), when the mouse first moves and whenever they are no longer + where they were (a new scene); at most every 3 s, then less often while none is found (up to 30 s). Until one is + found the motion is given back and the mouse turns the camera as the stick does. + + The sensitivity is the mouse's (- and =): 0.1 degrees a count at 1. HOS_MOUSE_INVERT_Y=1 flips up and down. */ #include "hos.h" #include #include @@ -21,98 +23,97 @@ #include int hos_mouse_take(int32_t *dx, int32_t *dy); /* display.c */ +void hos_mouse_give_back(int32_t dx, int32_t dy); /* display.c */ size_t hos_heap_find(const void *pat, size_t len, uint64_t *out, size_t max); /* camfind.c */ extern double hos_mouse_sens; -enum { MAX_T = 16, PITCH_AT = 0x14, YAW_AT = 0x2c }; +enum { MAX_T = 8, PITCH_AT = 0x14, YAW_AT = 0x2c }; static const float limits_sig[4] = {-85.0f, 89.0f, -85.0f, 89.0f}; -static uint64_t ctl[MAX_T], outs[MAX_T]; /* the controllers (where their limits are); the output copies (their pitch) */ -static size_t nctl, nout; -static int target; /* 0 the controller, 1 the output copies, 2 both */ -static int64_t next_scan; -static volatile LONG busy, pend_x, pend_y; /* (motion taken by a thread while another applied: applied next) */ -static long long applied, moves; +static uint64_t ctl[MAX_T]; /* the controllers in use: where their limits are */ +static size_t nctl; +static uint64_t found[MAX_T]; /* the search's answer, taken up by the next motion */ +static size_t nfound; +static volatile LONG searching, answered, busy; +static int64_t next_search, wait_ns = 3000000000LL; +static long long applied, given_back; static float getf(uint64_t a) { float f; memcpy(&f, GP(a), 4); return f; } static void putf(uint64_t a, float f) { memcpy(GP(a), &f, 4); } -static int still_there(void) { return nctl && !memcmp(GP(ctl[0]), limits_sig, sizeof limits_sig); } +static int angles(uint64_t at) { + const float p = getf(at + PITCH_AT), y = getf(at + YAW_AT); + return isfinite(p) && isfinite(y) && fabsf(p) <= 90.0f && fabsf(y) <= 360.0f; +} + +static int still_there(void) { + return nctl && !memcmp(GP(ctl[0]), limits_sig, sizeof limits_sig) && angles(ctl[0]); +} -static void find(void) { +static DWORD WINAPI search(void *arg) { + (void)arg; const int64_t t0 = hos_now_ns(); - uint64_t found[MAX_T]; - size_t n = hos_heap_find(limits_sig, sizeof limits_sig, found, MAX_T); - nctl = 0; - for (size_t i = 0; i < n; i++) { - const float p = getf(found[i] + PITCH_AT), y = getf(found[i] + YAW_AT); - if (isfinite(p) && isfinite(y) && fabsf(p) <= 90.0f && fabsf(y) <= 360.0f) ctl[nctl++] = found[i]; - hos_log("mouse native: limits at %#llx: pitch %.3f, yaw %.3f%s", (unsigned long long)found[i], p, y, - nctl && ctl[nctl - 1] == found[i] ? "" : " (not angles: left out)"); - } - nout = 0; - if (nctl) { /* the output copies: the controller's pitch and yaw side by side */ - const float pair[2] = {getf(ctl[0] + PITCH_AT), getf(ctl[0] + YAW_AT)}; - const size_t n2 = hos_heap_find(pair, sizeof pair, outs, MAX_T); - for (size_t i = 0; i < n2; i++) { /* (not inside a controller: its yaw after a 0 reads as the pair at pitch 0) */ - int inside = 0; - for (size_t c = 0; c < nctl; c++) inside |= outs[i] >= ctl[c] && outs[i] < ctl[c] + 0x40; - if (!inside) outs[nout++] = outs[i]; - } - } - hos_log("mouse native: %zu controller(s), %zu output copies, found in %.0f ms; target %d (%s)", nctl, nout, - (hos_now_ns() - t0) / 1e6, target + 1, target == 0 ? "the controller" : target == 1 ? "the output copies" : "both"); + uint64_t at[MAX_T]; + const size_t n = hos_heap_find(limits_sig, sizeof limits_sig, at, MAX_T); + nfound = 0; + for (size_t i = 0; i < n; i++) + if (angles(at[i])) found[nfound++] = at[i]; + hos_log("mouse native: %zu camera controller(s) found (%zu places with the limits) in %.0f ms%s", nfound, n, + (hos_now_ns() - t0) / 1e6, nfound ? "" : ": the mouse turns the camera as the stick does until one is"); + answered = 1; + searching = 0; + return 0; } -static void turn(uint64_t pitch_at, uint64_t yaw_at, float dpitch, float dyaw, float lo, float hi) { - float y = getf(yaw_at) + dyaw, p = getf(pitch_at) + dpitch; +static void turn(uint64_t at, float dpitch, float dyaw) { + float y = getf(at + YAW_AT) + dyaw, p = getf(at + PITCH_AT) + dpitch; + const float lo = getf(at), hi = getf(at + 4); if (y > 180.0f) y -= 360.0f; if (y < -180.0f) y += 360.0f; if (p < lo) p = lo; if (p > hi) p = hi; - putf(yaw_at, y), putf(pitch_at, p); + putf(at + YAW_AT, y), putf(at + PITCH_AT, p); } -/* With each read of the controller (nn_hid.c, the game's threads): the mouse's motion since the last into the camera. - 1 while the mouse is captured (its buttons count then) */ +/* With each read of the controller (nn_hid.c, the game's threads): 0 if the mouse is not captured, 1 if its motion + turned the camera, -1 if no camera was found and the motion was given back (the stick's way takes it) */ int hos_mouse_native(void) { - static int invert = -1, f7_was; + static int invert = -1; if (invert < 0) { invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; - hos_log("mouse native: on: the mouse turns the camera itself (F7: what it writes)"); + hos_log("mouse native: on (HOS_MOUSE_NATIVE=0: the mouse as the right stick)"); } - const int f7 = (GetAsyncKeyState(VK_F7) & 0x8000) != 0; - if (f7 && !f7_was) { - target = (target + 1) % 3; - hos_log("mouse native: target %d (%s)", target + 1, - target == 0 ? "the controller" : target == 1 ? "the output copies" : "both"); + int32_t dx, dy; + if (!hos_mouse_take(&dx, &dy)) return 0; + if (InterlockedExchange(&busy, 1)) { /* (another thread is at it: the motion waits for the next read) */ + hos_mouse_give_back(dx, dy); + return 1; + } + if (answered) { + answered = 0; + memcpy(ctl, found, sizeof ctl); + nctl = nfound; + wait_ns = nctl ? 3000000000LL : (wait_ns * 2 < 30000000000LL ? wait_ns * 2 : 30000000000LL); } - f7_was = f7; - int32_t mx, my; - const int captured = hos_mouse_take(&mx, &my); - if (!captured) return 0; - if (mx || my) InterlockedExchangeAdd(&pend_x, mx), InterlockedExchangeAdd(&pend_y, my); - if (InterlockedExchange(&busy, 1)) return captured; /* (another thread is at it: it takes this motion too) */ - const LONG dx = InterlockedExchange(&pend_x, 0), dy = InterlockedExchange(&pend_y, 0); - if (!dx && !dy) { busy = 0; return captured; } const int64_t now = hos_now_ns(); - if (!still_there() && now >= next_scan) { - next_scan = now + 3000000000LL; - find(); + int ok = still_there(); + if (!ok && (dx || dy) && now >= next_search && !InterlockedExchange(&searching, 1)) { + next_search = now + wait_ns; + if (!CreateThread(NULL, 0, search, NULL, 0, NULL)) searching = 0; + } + if (!ok) { + busy = 0; + if (dx || dy) hos_mouse_give_back(dx, dy), given_back++; + return -1; } - if (nctl) { + if (dx || dy) { const double k = 0.1 * hos_mouse_sens; - const float dyaw = (float)(-dx * k), dpitch = (float)((invert ? dy : -dy) * k); - const float lo = getf(ctl[0]), hi = getf(ctl[0] + 4); - if (target != 1) - for (size_t i = 0; i < nctl; i++) turn(ctl[i] + PITCH_AT, ctl[i] + YAW_AT, dpitch, dyaw, lo, hi); - if (target != 0) - for (size_t i = 0; i < nout; i++) turn(outs[i], outs[i] + 4, dpitch, dyaw, lo, hi); - applied++; + const float dyaw = (float)(-dx * k), dpitch = (float)((invert ? -dy : dy) * k); + for (size_t i = 0; i < nctl; i++) + if (!memcmp(GP(ctl[i]), limits_sig, sizeof limits_sig) && angles(ctl[i])) turn(ctl[i], dpitch, dyaw); + if ((++applied & 2047) == 1) + hos_log("mouse native: %lld moves applied (%lld given to the stick while no camera was found); pitch %.2f yaw %.2f", + applied, given_back, getf(ctl[0] + PITCH_AT), getf(ctl[0] + YAW_AT)); } - moves++; - if ((moves & 1023) == 1) - hos_log("mouse native: %lld moves, %lld applied; controller pitch %.2f yaw %.2f", moves, applied, - nctl ? getf(ctl[0] + PITCH_AT) : 0.0f, nctl ? getf(ctl[0] + YAW_AT) : 0.0f); busy = 0; - return captured; + return 1; } diff --git a/host/nn_hid.c b/host/nn_hid.c index 459b164a..9b43908a 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -5,8 +5,8 @@ (action), Shift B (run/cancel), Q L (abilities), R R, Z ZL, X or F ZR (bow), Ctrl left-stick click (crouch), C right-stick click (first person), Tab or Enter Plus, M Minus (map), 1 2 3 4 d-pad left, up, right, down. Only while the game's window is in front (the key state is global). - Mouse (after a click in the window, display.c; Esc releases): its motion is the right stick (mouse panning, - mouse_stick), left button Y (attack), right button ZL (shield, lock on). - and = change its sensitivity (x0.8 / + Mouse (after a click in the window, display.c; Esc releases): its motion turns the camera itself (mouse_native.c; + as the right stick, mouse panning, mouse_stick, while no camera is found or with HOS_MOUSE_NATIVE=0), left button Y (attack), right button ZL (shield, lock on). - and = change its sensitivity (x0.8 / x1.25, shown in the title bar, kept in /mouse_sens.txt); HOS_MOUSE_SENS= sets it, HOS_MOUSE_INVERT_Y=1 flips up/down. @@ -447,19 +447,24 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { /* left button Y (attack), right button ZL (shield, lock on), while the mouse is captured; a button held when the capture began counts only after it was let go (the click that captures never reaches the game) */ static int armed[2]; - /* (hookshot) HOS_MOUSE_NATIVE=1: the mouse turns the camera itself (mouse_native.c), not the right stick */ + /* (hookshot) the mouse turns the camera itself (mouse_native.c); as the right stick while no camera is found, or + always with HOS_MOUSE_NATIVE=0 */ static int native = -1; - if (native < 0) native = getenv("HOS_MOUSE_NATIVE") && atoi(getenv("HOS_MOUSE_NATIVE")) != 0; + if (native < 0) { + const char *e = getenv("HOS_MOUSE_NATIVE"); + native = !e || atoi(e) != 0; + } int hos_mouse_native(void); - int mouse_on; + int mouse_on, as_stick = !native; if (native) { mouse_setup(); mouse_sens_keys(); mouse_on = hos_mouse_native(); - } else - mouse_on = mouse_stick(&mouse[2], &mouse[3], (int)(*buttons >> B_L & 1)); + if (mouse_on < 0) as_stick = 1; + } + if (as_stick) mouse_on = mouse_stick(&mouse[2], &mouse[3], (int)(*buttons >> B_L & 1)); if (mouse_on) { - if (!native) stick_max(stick, mouse); + if (as_stick) stick_max(stick, mouse); const int vk[2] = {VK_LBUTTON, VK_RBUTTON}, b[2] = {B_Y, B_ZL}; for (int i = 0; i < 2; i++) { if (!key(vk[i])) armed[i] = 1; diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index 13f5e1c2..bf9430a2 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -31,6 +31,12 @@ int hos_mouse_take(int32_t *dx, int32_t *dy) { return mouse_captured; } +/* (mouse_native.c) motion taken and not used, put back for the next taker (the stick's, while no camera is found) */ +void hos_mouse_give_back(int32_t dx, int32_t dy) { + InterlockedExchangeAdd(&mouse_dx, dx); + InterlockedExchangeAdd(&mouse_dy, dy); +} + /* (diagnostics, display.c's: none counted here) */ int hos_mouse_stats(long *reports, long *taken, long *absolute, long *click_count) { *reports = *taken = *absolute = *click_count = 0; From 5f28a8194772696f79921377e8e01dde8d739aa6 Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 18:17:20 -0500 Subject: [PATCH 09/10] The menu remade: tabs, mouse and controller; Camera & Mouse settings The Insert/F1 menu was a list worked with the arrow keys alone (60% keyboards have none) and no controller. Now: - three tabs: Display, Performance, Camera & Mouse; a switch for on/off rows, a slider for the long ranges, arrows for choices; the help under them; the pause menu's colours (still only rectangles and text: the POSIX GDI stand-in has nothing else) - the mouse (Windows): hover selects, a click flips a switch, steps a choice (the left or right half) or sets a slider, which drags; tabs and a close box. The window lets the mouse go while the menu is open and does not take it with a click then (display.c) - the controllers: both sticks pressed in open and close it; the d-pad, A, B, L and R work it (vk_api.h: menu_pad). The game gets nothing from the pads while it is open (before: only the keyboard was kept from it) - the keyboard: as before, plus Tab (next tab) and Enter; held arrows repeat - Camera & Mouse: Mouse camera (Native / As the stick), Mouse sensitivity (one for every camera, 0.10 to 5.00) and Invert vertical, kept in hookshot_options.ini (rx_option 4, 5, 6). - and = change the sensitivity there (vk_api.h: option_set); mouse_sens.txt's value is moved in once; HOS_MOUSE_SENS, HOS_MOUSE_INVERT_Y and HOS_MOUSE_NATIVE still win Rendered on Linux through the GDI stand-in to check the layout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- gpu/vk/vk_api.h | 7 +- gpu/vk/vk_menu.cpp | 379 +++++++++++++++++++++++++++++++++---------- gpu/vk/vk_menu.h | 5 + gpu/vk/vk_replay.cpp | 5 +- host/display.c | 7 +- host/mouse_native.c | 10 +- host/nn_hid.c | 51 ++++-- host/rx_backend.c | 2 + 8 files changed, 360 insertions(+), 106 deletions(-) diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index 701bdec5..9adc0753 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -95,9 +95,14 @@ typedef struct MkwVkRenderer { void (*nvn_texture_created)(const nvn_tex *t); /* the CPU set an event's value: in order with the submits (the GPU's waits on it) */ void (*nvn_event_signal)(uint64_t gpu, uint32_t value); - int (*option)(int id); /* (hookshot, the Insert menu) 0: game time follows the clock, 1: the menu is open, 2: dynamic resolution, 3: its fixed scale in percent */ + int (*option)(int id); /* (hookshot, the Insert menu) 0: game time follows the clock, 1: the menu is open, 2: dynamic resolution, 3: its fixed scale in percent, + 4: mouse sensitivity in percent, 5: mouse vertical inverted, 6: native mouse look */ /* a submit with its queue */ void (*nvn_submit_q)(const uint64_t *handles, int n, uint64_t queue); + /* (hookshot, the menu) the controllers' buttons (host/nn_hid.c's B_* bits) with each read: they open, close and work it */ + void (*menu_pad)(uint64_t buttons); + /* (hookshot, the menu) a setting changed from the game's side (the - and = keys: the mouse's sensitivity), kept in the file */ + void (*option_set)(int id, int value); } MkwVkRenderer; /* the DLL's one export: null when Vulkan is not usable (the executable then falls back to WebGPU) */ diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 31f5acd6..dac5190b 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -27,6 +27,7 @@ struct Row { int lo, hi, step; const char* (*text)(int v); const char* help; // (shown under the list while the row is selected) + int tab; // (hookshot: the tab it is on, tab_names) }; const char* tx_framegen(int v) { static const char* t[] = {"Off", "", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; @@ -51,37 +52,56 @@ const char* tx_speed(int v) { return v ? "Real time" : "Per frame"; } const char* tx_handheld(int v) { return v ? "On (restart)" : "Off (restart)"; } const char* tx_ultrawide(int v) { return v ? "Monitor's shape (restart)" : "16:9 (restart)"; } +const char* tx_sens(int v) { + static char b[12]; + snprintf(b, sizeof b, "%.2f", v / 100.0); + return b; +} +const char* tx_mousecam(int v) { return v ? "Native" : "As the stick"; } + +const char* tab_names[] = {"Display", "Performance", "Camera & Mouse"}; +constexpr int kTabs = 3; + Row rows[] = { {"Fullscreen", &opts.fullscreen, 0, 1, 1, tx_onoff, - "Fills the whole screen with the game, without the window's border. The same as F11."}, - {"Frame generation", &opts.framegen, 0, 8, 1, tx_framegen, - "Makes extra frames between the game's own (2x up to 8x), for smoother motion. Adds a little input delay, and fast motion or camera cuts " - "can show artifacts."}, - {"Frame pacing", &opts.pace, 0, 1, 1, tx_onoff, - "Shows each frame at an even interval instead of as soon as it is ready. Less stutter, at up to one frame of delay."}, + "Fills the whole screen with the game, without the window's border. The same as F11.", 0}, {"Resolution", &opts.resolution, 0, 4, 1, tx_resolution, "How sharp the 3D is drawn. Auto lets the game lower it when the GPU is busy. A fixed percentage stays steady: lower is " - "faster."}, + "faster.", 0}, {"Upscaling", &opts.upscale, 0, 2, 1, tx_upscale, - "The filter that scales the picture to your window. Off is the plain copy. Catmull-Rom is sharper, Lanczos sharper still."}, + "The filter that scales the picture to your window. Off is the plain copy. Catmull-Rom is sharper, Lanczos sharper still.", 0}, {"Sharpness", &opts.sharpness, 0, 100, 10, tx_percent, - "Sharpens the picture as it goes to the window, with or without an upscaling filter. Too much shows halos around edges."}, + "Sharpens the picture as it goes to the window, with or without an upscaling filter. Too much shows halos around edges.", 0}, + {"Ultrawide", &opts.ultrawide, 0, 1, 1, tx_ultrawide, + "Monitor's shape widens the camera to fill a screen wider than 16:9. The HUD is still stretched. Applies after a restart.", 0}, + {"FPS counter", &opts.show_fps, 0, 1, 1, tx_onoff, + "Shows frames per second in the top right corner. With frame generation on, it also shows the game's own rate.", 0}, + {"Frame generation", &opts.framegen, 0, 8, 1, tx_framegen, + "Makes extra frames between the game's own (2x up to 8x), for smoother motion. Adds a little input delay, and fast motion or camera cuts " + "can show artifacts.", 1}, + {"Frame pacing", &opts.pace, 0, 1, 1, tx_onoff, + "Shows each frame at an even interval instead of as soon as it is ready. Less stutter, at up to one frame of delay.", 1}, {"Game speed", &opts.realtime, 0, 1, 1, tx_speed, "Per frame: the game slows down whenever it runs below 60 fps. Real time: it keeps normal speed down to 15 fps by taking " - "longer steps."}, + "longer steps.", 1}, {"Handheld profile", &opts.handheld, 0, 1, 1, tx_handheld, - "Runs the game as if the console were undocked: lower resolutions, lighter on the GPU. Applies after a restart."}, - {"Ultrawide", &opts.ultrawide, 0, 1, 1, tx_ultrawide, - "Monitor's shape widens the camera to fill a screen wider than 16:9. The HUD is still stretched. Applies after a restart."}, - {"FPS counter", &opts.show_fps, 0, 1, 1, tx_onoff, - "Shows frames per second in the top right corner. With frame generation on, it also shows the game's own rate."}, + "Runs the game as if the console were undocked: lower resolutions, lighter on the GPU. Applies after a restart.", 1}, + {"Mouse camera", &opts.mouse_native, 0, 1, 1, tx_mousecam, + "Native: the mouse turns the camera itself, one to one, like a PC game. As the stick: the mouse stands in for the right stick " + "(the game's own stick curve and top speed). Click in the game's window to use the mouse; Esc lets it go.", 2}, + {"Mouse sensitivity", &opts.mouse_sens, 10, 500, 5, tx_sens, + "How far the camera turns for a given mouse movement: at 1.00, a tenth of a degree a count. One setting for every camera. " + "The - and = keys change it in the game too.", 2}, + {"Invert vertical", &opts.mouse_invert, 0, 1, 1, tx_onoff, + "Swaps up and down for the mouse: moving it forward tilts the camera down.", 2}, }; constexpr int kRows = sizeof rows / sizeof rows[0]; -constexpr int kMenuWidth = 360; // (at a scale of 1: 720 lines) +constexpr int kMenuWidth = 480; // (at a scale of 1: 720 lines) std::atomic open_now{false}; -int selected = 0; -bool menu_refresh = false; +int cur_tab = 0, selected = 0; // (selected: the row's index in rows, on cur_tab) +std::atomic menu_refresh{false}; +std::atomic pad_now{0}; std::string ini_path() { char exe[MAX_PATH]; const DWORD n = GetModuleFileNameA(nullptr, exe, MAX_PATH); @@ -199,60 +219,141 @@ void fill(HDC dc, int x, int y, int w, int h, COLORREF c) { FillRect(dc, &r, b); DeleteObject(b); } +// (hookshot) The menu redrawn: tabs, a row a setting with a switch, a slider or arrows, the help under them; in the pause +// menu's colours. Only rectangles and text: the POSIX GDI stand-in (host/posix/gdi_posix.c) has nothing else. Where each +// part landed is kept for the mouse (hits), in the menu's own pixels. +enum Kind { K_CHOICE, K_TOGGLE, K_SLIDER }; +Kind kind_of(const Row& r) { + if (r.text == tx_onoff) return K_TOGGLE; + if ((r.hi - r.lo) / r.step >= 8 && r.text != tx_framegen) return K_SLIDER; + return K_CHOICE; +} +constexpr int kMaxShown = 8; // (rows a tab shows at most: the panel keeps that height) +struct Hits { + RECT tab[kTabs]{}, close{}; + RECT row[kMaxShown]{}, ctl[kMaxShown]{}; + int index[kMaxShown]{}, shown = 0; +} hits; +const COLORREF kBg = RGB(30, 24, 18), kHead = RGB(46, 36, 25), kTabOff = RGB(38, 30, 21), kSel = RGB(72, 56, 36), + kGold = RGB(214, 174, 92), kText = RGB(242, 232, 212), kDim = RGB(172, 156, 128), kTrack = RGB(64, 52, 38), + kLine = RGB(84, 68, 46); +int shown_rows(int tab, int* out) { + int n = 0; + for (int i = 0; i < kRows && n < kMaxShown; i++) + if (rows[i].tab == tab) out[n++] = i; + return n; +} +int most_rows() { + int most = 0, tmp[kMaxShown]; + for (int t = 0; t < kTabs; t++) most = std::max(most, shown_rows(t, tmp)); + return most; +} +int text_w(HDC dc, const char* t) { + SIZE sz{}; + GetTextExtentPoint32A(dc, t, (int)strlen(t), &sz); + return (int)sz.cx; +} +void text_at(HDC dc, int x, int y, const char* t, COLORREF c) { + SetTextColor(dc, c); + TextOutA(dc, x, y, t, (int)strlen(t)); +} +void frame(HDC dc, int x, int y, int w, int h, int t, COLORREF c) { + fill(dc, x, y, w, t, c), fill(dc, x, y + h - t, w, t, c), fill(dc, x, y, t, h, c), fill(dc, x + w - t, y, t, h, c); +} void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { - fill(dc, 0, 0, (int)w, (int)h, RGB(16, 20, 18)); - fill(dc, 0, 0, (int)w, 2 * s, RGB(96, 176, 112)); + const int W = (int)w; + fill(dc, 0, 0, W, (int)h, kBg); + frame(dc, 0, 0, W, (int)h, std::max(1, s), kLine); SetBkMode(dc, TRANSPARENT); - HFONT title = font(20 * s, FW_SEMIBOLD), body = font(16 * s, FW_NORMAL); + HFONT title = font(19 * s, FW_SEMIBOLD), body = font(15 * s, FW_NORMAL), bold = font(15 * s, FW_SEMIBOLD), + small = font(12 * s, FW_NORMAL), help = font(14 * s, FW_NORMAL); HGDIOBJ old = SelectObject(dc, title); - SetTextColor(dc, RGB(236, 240, 236)); - TextOutA(dc, 16 * s, 12 * s, "Hookshot", 8); - { // (which build, beside the title, so a report can say it without opening the log; the release alone when the whole - // line would run into the title) - SIZE tsz{}; - GetTextExtentPoint32A(dc, "Hookshot", 8, &tsz); - HFONT small = font(12 * s, FW_NORMAL); - SelectObject(dc, small); - SetTextColor(dc, RGB(140, 150, 144)); - std::string ver = HOOKSHOT_VERSION; - const int room = (int)w - 32 * s - tsz.cx - 12 * s; - SIZE sz{}; - GetTextExtentPoint32A(dc, ver.c_str(), (int)ver.size(), &sz); - if (sz.cx > room && ver.find(" (") != std::string::npos) { - ver.resize(ver.find(" (")); - GetTextExtentPoint32A(dc, ver.c_str(), (int)ver.size(), &sz); - } - if (sz.cx <= room) TextOutA(dc, (int)w - 16 * s - sz.cx, 18 * s, ver.c_str(), (int)ver.size()); - SelectObject(dc, title); - DeleteObject(small); - } + // the header: the name, and a close box + const int head = 44 * s; + fill(dc, 0, 0, W, head, kHead); + fill(dc, 0, head - 2 * s, W, 2 * s, kGold); + text_at(dc, 18 * s, 11 * s, "Hookshot", kText); + const int tw = text_w(dc, "Hookshot"); SelectObject(dc, body); - const int rh = 28 * s, top = 44 * s; - for (int i = 0; i < kRows; i++) { - const int y = top + i * rh; - if (i == selected) fill(dc, 8 * s, y, (int)w - 16 * s, rh, RGB(44, 72, 54)); - SetTextColor(dc, i == selected ? RGB(255, 255, 255) : RGB(196, 204, 198)); - TextOutA(dc, 16 * s, y + 4 * s, rows[i].name, (int)strlen(rows[i].name)); - char v[40]; - snprintf(v, sizeof v, i == selected ? "< %s >" : "%s", rows[i].text(*rows[i].value)); - SIZE sz{}; - GetTextExtentPoint32A(dc, v, (int)strlen(v), &sz); - TextOutA(dc, (int)w - 16 * s - sz.cx, y + 4 * s, v, (int)strlen(v)); + text_at(dc, 18 * s + tw + 10 * s, 14 * s, "Settings", kDim); + const int cb = 28 * s; + hits.close = {W - cb - 10 * s, 8 * s, W - 10 * s, 8 * s + cb}; + frame(dc, hits.close.left, hits.close.top, cb, cb, std::max(1, s), kLine); + SelectObject(dc, bold); + text_at(dc, hits.close.left + (cb - text_w(dc, "X")) / 2, hits.close.top + 5 * s, "X", kDim); + // the tabs + const int ty = head, th = 36 * s, tabw = W / kTabs; + for (int t = 0; t < kTabs; t++) { + const int x = t * tabw, wd = t == kTabs - 1 ? W - x : tabw; + hits.tab[t] = {x, ty, x + wd, ty + th}; + fill(dc, x, ty, wd, th, t == cur_tab ? kBg : kTabOff); + if (t == cur_tab) fill(dc, x + 12 * s, ty + th - 3 * s, wd - 24 * s, 3 * s, kGold); + SelectObject(dc, t == cur_tab ? bold : body); + text_at(dc, x + (wd - text_w(dc, tab_names[t])) / 2, ty + 9 * s, tab_names[t], t == cur_tab ? kText : kDim); } - const int help_top = top + kRows * rh + 8 * s; - fill(dc, 16 * s, help_top - 4 * s, (int)w - 32 * s, std::max(1, s), RGB(52, 64, 56)); - HFONT help = font(14 * s, FW_NORMAL); + // the rows + const int top = ty + th + 8 * s, rh = 40 * s, cw = 210 * s, cx = W - 18 * s - cw; + int list[kMaxShown]; + hits.shown = shown_rows(cur_tab, list); + for (int k = 0; k < hits.shown; k++) { + const Row& r = rows[list[k]]; + const int y = top + k * rh, sel = list[k] == selected; + hits.index[k] = list[k]; + hits.row[k] = {8 * s, y, W - 8 * s, y + rh}; + hits.ctl[k] = {cx, y, cx + cw, y + rh}; + if (sel) fill(dc, 8 * s, y + 2 * s, W - 16 * s, rh - 4 * s, kSel), fill(dc, 8 * s, y + 2 * s, 4 * s, rh - 4 * s, kGold); + SelectObject(dc, body); + text_at(dc, 22 * s, y + 10 * s, r.name, sel ? kText : RGB(222, 210, 188)); + const int v = *r.value, mid = y + rh / 2; + switch (kind_of(r)) { + case K_TOGGLE: { + const int sw = 48 * s, sh = 22 * s, sx = cx + cw - sw, sy = mid - sh / 2; + fill(dc, sx, sy, sw, sh, v ? kGold : kTrack); + fill(dc, v ? sx + sw - sh + 3 * s : sx + 3 * s, sy + 3 * s, sh - 6 * s, sh - 6 * s, v ? RGB(40, 30, 18) : kDim); + const char* t = r.text(v); + text_at(dc, sx - 12 * s - text_w(dc, t), y + 10 * s, t, sel ? kText : kDim); + break; + } + case K_SLIDER: { + const int vw = 52 * s, tx = cx, tw2 = cw - vw - 10 * s, ky = mid - 3 * s; + const int at = tx + (int)((long long)(v - r.lo) * tw2 / std::max(1, r.hi - r.lo)); + fill(dc, tx, ky, tw2, 6 * s, kTrack); + fill(dc, tx, ky, at - tx, 6 * s, kGold); + fill(dc, at - 5 * s, mid - 10 * s, 10 * s, 20 * s, sel ? kText : kDim); + const char* t = r.text(v); + SelectObject(dc, bold); + text_at(dc, cx + cw - text_w(dc, t), y + 10 * s, t, sel ? kText : kDim); + break; + } + default: { + const int bw = 26 * s, by = mid - 13 * s; + frame(dc, cx, by, bw, 26 * s, std::max(1, s), sel ? kGold : kLine); + frame(dc, cx + cw - bw, by, bw, 26 * s, std::max(1, s), sel ? kGold : kLine); + SelectObject(dc, bold); + text_at(dc, cx + (bw - text_w(dc, "<")) / 2, by + 3 * s, "<", sel ? kGold : kDim); + text_at(dc, cx + cw - bw + (bw - text_w(dc, ">")) / 2, by + 3 * s, ">", sel ? kGold : kDim); + SelectObject(dc, body); + const char* t = r.text(v); + text_at(dc, cx + (cw - text_w(dc, t)) / 2, y + 10 * s, t, sel ? kText : kDim); + } + } + } + // the help, then the hints + const int help_top = top + most_rows() * rh + 8 * s; + fill(dc, 16 * s, help_top, W - 32 * s, std::max(1, s), kLine); SelectObject(dc, help); - SetTextColor(dc, RGB(176, 186, 180)); - wrap_help(dc, rows[selected].help, (int)w - 32 * s, 16 * s, help_top + 4 * s, true); - SelectObject(dc, body); - SetTextColor(dc, RGB(140, 150, 144)); - const char* hint = "Arrows change Insert or F1 closes"; - TextOutA(dc, 16 * s, help_top + 4 * s + help_px + 10 * s, hint, (int)strlen(hint)); + SetTextColor(dc, RGB(200, 186, 160)); + wrap_help(dc, rows[selected].help, W - 32 * s, 16 * s, help_top + 10 * s, true); + const int foot = help_top + 10 * s + help_px + 12 * s; + fill(dc, 0, foot, W, (int)h - foot, kHead); + SelectObject(dc, small); + text_at(dc, 16 * s, foot + 8 * s, "Mouse: click, drag Keys: arrows, Tab, Enter Pad: D-pad, A, B, L / R", kDim); + text_at(dc, 16 * s, foot + 26 * s, "Close: Esc, Insert, F1, or both sticks pressed in", kDim); + std::string ver = HOOKSHOT_VERSION; + if (ver.find(" (") != std::string::npos) ver.resize(ver.find(" (")); + text_at(dc, W - 16 * s - text_w(dc, ver.c_str()), foot + 26 * s, ver.c_str(), RGB(128, 114, 92)); SelectObject(dc, old); - DeleteObject(title); - DeleteObject(body); - DeleteObject(help); + for (HFONT f : {title, body, bold, small, help}) DeleteObject(f); } char fps_text[48] = ""; char fps_sub[32] = ""; @@ -304,8 +405,9 @@ int measure_help(int s) { void redraw_menu(int s) { static int measured_at = 0; if (measured_at != s) help_px = measure_help(s), measured_at = s; - // (title and rows, the rule and the help text, the hint line) - gdi_draw(menu_bmp, (uint32_t)(kMenuWidth * s), (uint32_t)((44 + kRows * 28 + 12 + 10 + 30) * s + help_px), paint_menu, s); + // (header, tabs, the tallest tab's rows, the help, the hints) + gdi_draw(menu_bmp, (uint32_t)(kMenuWidth * s), (uint32_t)((44 + 36 + 8 + most_rows() * 40 + 8 + 10 + 12 + 46) * s + help_px), + paint_menu, s); } std::atomic game_frames{0}, shown_frames{0}; @@ -425,38 +527,141 @@ bool fps_take(int64_t now, int64_t shown, int64_t game, int64_t* next, int64_t* return have; } +namespace { +/// A row's value moved one step (dir -1 or +1): a switch flips, a slider stops at its ends, a choice goes round +void change(Row& r, int dir) { + int v = *r.value + dir * r.step; + if (r.value == &opts.framegen && v == 1) v = dir > 0 ? 2 : 0; // (off, 2x ... 8x) + if (kind_of(r) == K_SLIDER) v = std::min(std::max(v, r.lo), r.hi); + else v = v > r.hi ? r.lo : v < r.lo ? r.hi : v; + if (v != *r.value) *r.value = v, save(); +} +void set_from_x(Row& r, int x, const RECT& ctl, int s) { // (a slider: the value under the pointer) + const int tw = (ctl.right - ctl.left) - 52 * s - 10 * s; + const int v = r.lo + (int)((long long)(x - ctl.left) * (r.hi - r.lo) / std::max(1, tw)); + const int stepped = r.lo + (std::min(std::max(v, r.lo), r.hi) - r.lo + r.step / 2) / r.step * r.step; + if (stepped != *r.value) *r.value = std::min(stepped, r.hi), save(); +} +bool in(const RECT& r, int x, int y) { return x >= r.left && x < r.right && y >= r.top && y < r.bottom; } +int first_on(int tab) { + for (int i = 0; i < kRows; i++) + if (rows[i].tab == tab) return i; + return 0; +} +void move_sel(int dir) { // (up or down among the rows on cur_tab) + int list[kMaxShown]; + const int n = shown_rows(cur_tab, list); + int k = 0; + while (k < n && list[k] != selected) k++; + selected = list[((k + dir) % n + n) % n]; +} +void to_tab(int t) { + cur_tab = (t % kTabs + kTabs) % kTabs; + selected = first_on(cur_tab); +} +/// a press that repeats while held: the first at once, then after 350 ms every 60 ms +bool repeat(bool down, int64_t now, int64_t& since, int64_t& next) { + if (!down) { since = 0; return false; } + if (!since) { since = now, next = now + 350000000; return true; } + if (now >= next) { next = now + 60000000; return true; } + return false; +} +} // namespace + +void menu_pad(uint64_t buttons) { pad_now.store(buttons, std::memory_order_relaxed); } + +void menu_option_set(int id, int value) { + if (id == 4) opts.mouse_sens = std::min(std::max(value, 10), 500); + else return; + save(); + menu_refresh = true; +} + void menu_tick() { fullscreen_tick(); - static bool held[7]; - static const int keys[7] = {VK_INSERT, VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_ESCAPE, VK_F1}; // (F1: for keyboards without Insert) + // the keyboard (while the game's window is in front), the mouse's left button, and the controllers (host/nn_hid.c) + enum { K_INSERT, K_UP, K_DOWN, K_LEFT, K_RIGHT, K_ESC, K_F1, K_TAB, K_ENTER, K_MOUSE, NKEYS }; + static const int keys[NKEYS] = {VK_INSERT, VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_ESCAPE, VK_F1, VK_TAB, VK_RETURN, VK_LBUTTON}; + static bool held[NKEYS]; HWND fg = GetForegroundWindow(); DWORD pid = 0; if (fg) GetWindowThreadProcessId(fg, &pid); const bool mine = pid == GetCurrentProcessId(); - bool press[7] = {}; - for (int i = 0; i < 7; i++) { - const bool down = mine && (GetAsyncKeyState(keys[i]) & 0x8000) != 0; - press[i] = down && !held[i]; - held[i] = down; + bool press[NKEYS] = {}, down[NKEYS] = {}; + for (int i = 0; i < NKEYS; i++) { + down[i] = mine && (GetAsyncKeyState(keys[i]) & 0x8000) != 0; + press[i] = down[i] && !held[i]; + held[i] = down[i]; } + // (the Switch layout's bits, as host/nn_hid.c reads every pad: A 0, B 1, L 6, R 7, d-pad left 12, up 13, right 14, down 15) + const uint64_t pad = pad_now.load(std::memory_order_relaxed); + static uint64_t pad_was; + const uint64_t pad_press = pad & ~pad_was; + pad_was = pad; + auto bit = [](uint64_t b, int n) { return (b >> n & 1) != 0; }; + const bool sticks = bit(pad, 4) && bit(pad, 5) && (bit(pad_press, 4) || bit(pad_press, 5)); // (both sticks pressed in) const int scale = scale_of(dev.swap_extent.height); - bool changed = scale != cur_scale || menu_refresh; - menu_refresh = false; + bool changed = scale != cur_scale || menu_refresh.exchange(false); cur_scale = scale; - if (press[0] || press[6]) open_now = !open_now.load(), changed = true; + if (press[K_INSERT] || press[K_F1] || sticks) open_now = !open_now.load(), changed = true; if (open_now.load()) { - if (press[5]) open_now = false; - if (press[1]) selected = (selected + kRows - 1) % kRows, changed = true; - if (press[2]) selected = (selected + 1) % kRows, changed = true; - if (press[3] || press[4]) { + const int64_t now = prof_now(); + static int64_t since[2], next[2]; + const bool left = repeat(down[K_LEFT] || bit(pad, 12), now, since[0], next[0]); + const bool right = repeat(down[K_RIGHT] || bit(pad, 14), now, since[1], next[1]); + if (rows[selected].tab != cur_tab) selected = first_on(cur_tab); + if (press[K_ESC] || bit(pad_press, 1)) open_now = false, changed = true; + if (press[K_UP] || bit(pad_press, 13)) move_sel(-1), changed = true; + if (press[K_DOWN] || bit(pad_press, 15)) move_sel(+1), changed = true; + if (press[K_TAB] || bit(pad_press, 7)) to_tab(cur_tab + 1), changed = true; + if (bit(pad_press, 6)) to_tab(cur_tab - 1), changed = true; + if (left || right) change(rows[selected], right ? +1 : -1), changed = true; + if (press[K_ENTER] || bit(pad_press, 0)) { Row& r = rows[selected]; - int v = *r.value + (press[4] ? r.step : -r.step); - if (r.value == &opts.framegen && v == 1) v = press[4] ? 2 : 0; // (off, 2x ... 8x) - *r.value = v > r.hi ? r.lo : v < r.lo ? r.hi : v; - changed = true; - save(); + if (kind_of(r) != K_SLIDER) change(r, +1), changed = true; } - if (changed) redraw_menu(scale); +#ifdef _WIN32 + { // the mouse: where it points in the menu's own pixels (the menu sits in the middle of the window's image) + static POINT last{-1, -1}; + static int dragging = -1; + POINT p{}; + const HWND win = app_window(); + RECT cr{}; + std::unique_lock lk(bmp_mu); + const int mw = (int)menu_bmp.w, mh = (int)menu_bmp.h; + lk.unlock(); + if (win && mine && mw && GetCursorPos(&p) && ScreenToClient(win, &p) && GetClientRect(win, &cr) && cr.right > 0 && cr.bottom > 0) { + const int ix = (int)((long long)p.x * dev.swap_extent.width / cr.right) - ((int)dev.swap_extent.width - mw) / 2; + const int iy = (int)((long long)p.y * dev.swap_extent.height / cr.bottom) - ((int)dev.swap_extent.height - mh) / 2; + const bool moved = p.x != last.x || p.y != last.y; + last = p; + if (!down[K_MOUSE]) dragging = -1; + if (dragging >= 0) { + for (int k = 0; k < hits.shown; k++) + if (hits.index[k] == dragging) set_from_x(rows[dragging], ix, hits.ctl[k], scale), changed = true; + } else { + for (int k = 0; k < hits.shown; k++) + if (moved && in(hits.row[k], ix, iy) && selected != hits.index[k]) selected = hits.index[k], changed = true; + if (press[K_MOUSE]) { + if (in(hits.close, ix, iy)) open_now = false, changed = true; + for (int t = 0; t < kTabs; t++) + if (in(hits.tab[t], ix, iy) && t != cur_tab) to_tab(t), changed = true; + for (int k = 0; k < hits.shown; k++) { + if (!in(hits.ctl[k], ix, iy)) continue; + Row& r = rows[hits.index[k]]; + selected = hits.index[k]; + const Kind kd = kind_of(r); + if (kd == K_SLIDER) set_from_x(r, ix, hits.ctl[k], scale), dragging = hits.index[k]; + else if (kd == K_TOGGLE) change(r, +1); + else change(r, ix < (hits.ctl[k].left + hits.ctl[k].right) / 2 ? -1 : +1); + changed = true; + } + } + } + } + } +#endif + if (changed && open_now.load()) redraw_menu(scale); } if (opts.show_fps) { static int64_t next = 0, last_game = 0, last_shown = 0; diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index a6a03905..4c7dbebe 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -17,12 +17,17 @@ struct Options { int ultrawide = 0; // the camera at the monitor's shape when it is wider than 16:9; read once, at the start (host/hos_main.c) int show_fps = 0; int fullscreen = 0; // borderless, at the monitor's size: F11 is the same setting + int mouse_native = 1; // the mouse turns the camera's own angles (host/mouse_native.c); 0: as the right stick + int mouse_sens = 100; // percent: 0.1 degrees a count at 100 (host/mouse_native.c) + int mouse_invert = 0; // up and down swapped }; extern Options opts; void menu_init(); // (the options from the file, once) void menu_tick(); // (each frame shown: the keys, while the game's window has the focus) bool menu_is_open(); +void menu_pad(uint64_t buttons); // (the controllers' buttons, from the game's threads: host/nn_hid.c) +void menu_option_set(int id, int value); // (a setting from the game's side: 4 the mouse's sensitivity) void menu_note_frame(bool game_frame); // (the frame rates the FPS counter shows) /// Over the window's image (TRANSFER_DST_OPTIMAL), w x h: the menu and the FPS counter. slot: the frame in flight, 0 to 2. void menu_record(VkCommandBuffer cmd, VkImage dst, int slot, uint32_t w, uint32_t h); diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index bc797ee1..62575bbf 100644 --- a/gpu/vk/vk_replay.cpp +++ b/gpu/vk/vk_replay.cpp @@ -2994,7 +2994,10 @@ void fill_replay_table(MkwVkRenderer& t) { t.nvn_present = present; t.nvn_register_texture = register_texture_queued; t.nvn_event_signal = event_signal_queued; - t.option = [](int id) -> int { return id == 0 ? opts.realtime : id == 1 ? menu_is_open() : id == 2 ? opts.resolution == 0 : id == 3 ? (opts.resolution == 1 ? 100 : opts.resolution == 2 ? 75 : opts.resolution == 3 ? 67 : opts.resolution == 4 ? 50 : 0) : 0; }; + t.option = [](int id) -> int { return id == 0 ? opts.realtime : id == 1 ? menu_is_open() : id == 2 ? opts.resolution == 0 : id == 3 ? (opts.resolution == 1 ? 100 : opts.resolution == 2 ? 75 : opts.resolution == 3 ? 67 : opts.resolution == 4 ? 50 : 0) : + id == 4 ? opts.mouse_sens : id == 5 ? opts.mouse_invert : id == 6 ? opts.mouse_native : 0; }; + t.menu_pad = menu_pad; + t.option_set = menu_option_set; t.nvn_register_sampler = register_sampler_queued; t.nvn_set_texture_pool_size = set_texture_pool_size; t.nvn_texture_released = texture_released_of; diff --git a/host/display.c b/host/display.c index 9ceaf9c6..caa57b01 100644 --- a/host/display.c +++ b/host/display.c @@ -22,6 +22,8 @@ volatile LONG64 hos_present_count; /* (nvn_native.c: the game's presents) */ static HWND hwnd; static int renderer; /* the renderer presents into the window */ +int rx_option(int id, int otherwise); /* rx_backend.c: 1, the menu is open */ + /* mouse capture: raw motion accumulated here, taken by nn_hid.c */ static volatile LONG mouse_dx, mouse_dy; static volatile LONG mouse_captured; @@ -112,7 +114,8 @@ static void mouse_capture(HWND w, int on, const char *why) { static LRESULT CALLBACK wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { - case WM_TIMER: + case WM_TIMER: /* (hookshot) the menu open: the mouse let go, to point and click in it (vk_menu.cpp) */ + if (mouse_captured && rx_option(1, 0)) mouse_capture(w, 0, "the menu opened"); return 0; case WM_INPUT: { RAWINPUT ri; @@ -134,7 +137,7 @@ static LRESULT CALLBACK wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { case WM_LBUTTONDOWN: InterlockedIncrement(&clicks); if (!renderer) hos_log("mouse: a click before the renderer was up: not captured"); - if (renderer && !mouse_captured) mouse_capture(w, 1, "a click in the window"); + if (renderer && !mouse_captured && !rx_option(1, 0)) mouse_capture(w, 1, "a click in the window"); return 0; case WM_KEYDOWN: if (wp == VK_ESCAPE) mouse_capture(w, 0, "Esc"); diff --git a/host/mouse_native.c b/host/mouse_native.c index 1cd5e974..0d3d5917 100644 --- a/host/mouse_native.c +++ b/host/mouse_native.c @@ -77,11 +77,13 @@ static void turn(uint64_t at, float dpitch, float dyaw) { /* With each read of the controller (nn_hid.c, the game's threads): 0 if the mouse is not captured, 1 if its motion turned the camera, -1 if no camera was found and the motion was given back (the stick's way takes it) */ int hos_mouse_native(void) { - static int invert = -1; - if (invert < 0) { - invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; - hos_log("mouse native: on (HOS_MOUSE_NATIVE=0: the mouse as the right stick)"); + static int env_invert = -1; + if (env_invert < 0) { + env_invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; + hos_log("mouse native: on (the menu's Mouse camera, or HOS_MOUSE_NATIVE=0: the mouse as the right stick)"); } + int rx_option(int id, int otherwise); + const int invert = env_invert || rx_option(5, 0); /* (the menu's Invert vertical) */ int32_t dx, dy; if (!hos_mouse_take(&dx, &dy)) return 0; if (InterlockedExchange(&busy, 1)) { /* (another thread is at it: the motion waits for the next read) */ diff --git a/host/nn_hid.c b/host/nn_hid.c index 9b43908a..a0add0e0 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -74,13 +74,27 @@ static void mouse_sens_save(void) { if (f) { fprintf(f, "%.3f\n", hos_mouse_sens); fclose(f); } } +void rx_option_set(int id, int value); +static int sens_from_env = -1; static void mouse_sens_keys(void) { static int prev_minus, prev_plus; + /* (hookshot) the menu keeps the sensitivity (Camera & Mouse; hookshot_options.ini), in percent: read with each + read; - and = change it there. HOS_MOUSE_SENS still wins. Before: /mouse_sens.txt, moved into the menu once */ + if (sens_from_env < 0) { + sens_from_env = getenv("HOS_MOUSE_SENS") && atof(getenv("HOS_MOUSE_SENS")) > 0; + if (!sens_from_env && hos_mouse_sens > 0 && fabs(hos_mouse_sens - 1.0) > 0.01 && rx_option(4, 100) == 100) + rx_option_set(4, (int)lround(hos_mouse_sens * 100)); + } + if (!sens_from_env) hos_mouse_sens = rx_option(4, 100) / 100.0; const int minus = key(VK_OEM_MINUS) || key(VK_SUBTRACT), plus = key(VK_OEM_PLUS) || key(VK_ADD); - if (minus && !prev_minus) { hos_mouse_sens *= 0.8; mouse_sens_save(); } - if (plus && !prev_plus) { hos_mouse_sens *= 1.25; mouse_sens_save(); } - if (hos_mouse_sens < 0.05) hos_mouse_sens = 0.05; - if (hos_mouse_sens > 20) hos_mouse_sens = 20; + if ((minus && !prev_minus) || (plus && !prev_plus)) { + hos_mouse_sens *= minus && !prev_minus ? 0.8 : 1.25; + if (hos_mouse_sens < 0.1) hos_mouse_sens = 0.1; + if (hos_mouse_sens > 5) hos_mouse_sens = 5; + if (sens_from_env) mouse_sens_save(); + else rx_option_set(4, (int)lround(hos_mouse_sens * 100)); + hos_log("mouse: sensitivity %.2f", hos_mouse_sens); + } prev_minus = minus; prev_plus = plus; } @@ -135,7 +149,7 @@ static void mouse_look_tick(void) { static int invert = -1; static void mouse_setup(void) { /* (once: the sensitivity kept in /mouse_sens.txt, HOS_MOUSE_SENS, the inversion) */ if (invert < 0) { - invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; + invert = getenv("HOS_MOUSE_INVERT_Y") != NULL; /* (and the menu's Invert vertical, read in mouse_stick) */ hos_mouse_sens = 1.0; char path[512]; snprintf(path, sizeof path, "%s/mouse_sens.txt", hos_user_dir ? hos_user_dir : "user"); @@ -156,6 +170,9 @@ static int mouse_stick(int32_t *rx, int32_t *ry, int pointing) { static int captured, was_pointing; mouse_setup(); mouse_sens_keys(); + static int env_invert = -1; + if (env_invert < 0) env_invert = invert; + invert = env_invert || rx_option(5, 0); const int64_t now = hos_now_ns(); if (now >= next_tick) { int32_t dx, dy; @@ -434,7 +451,22 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { if (exclusive < 0) exclusive = getenv("HOS_INPUT_EXCLUSIVE") != NULL; if ((exclusive && npresses > 0 && (hos_now_ns() - script_t0) / 1000000 < script_end_ms + 5000) || !game_in_front()) return; - pads_read(buttons, stick); + { /* (hookshot) the pads go to the menu too (both sticks pressed in open it; it is worked with the d-pad, A, B, L + and R), and while it is open the game gets nothing: before, only the keyboard was kept from it */ + void rx_menu_pad(uint64_t buttons); + uint64_t pad_buttons = 0; + int32_t pad_stick[4] = {0}; + pads_read(&pad_buttons, pad_stick); + rx_menu_pad(pad_buttons); + if (rx_option(1, 0)) { + *buttons = 0; + memset(stick, 0, 4 * sizeof *stick); + return; + } + if ((pad_buttons >> B_LS & 1) && (pad_buttons >> B_RS & 1)) pad_buttons &= ~(1ULL << B_LS | 1ULL << B_RS); + *buttons |= pad_buttons; + stick_max(stick, pad_stick); + } /* (the keys of totk-pc-recomp's docs/controls.md, github.com/valentderah/totk-pc-recomp, MIT; plus Enter for Plus and IJKL as another camera) */ struct { int vk; int b; } kmap[] = { @@ -449,11 +481,8 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { static int armed[2]; /* (hookshot) the mouse turns the camera itself (mouse_native.c); as the right stick while no camera is found, or always with HOS_MOUSE_NATIVE=0 */ - static int native = -1; - if (native < 0) { - const char *e = getenv("HOS_MOUSE_NATIVE"); - native = !e || atoi(e) != 0; - } + const char *native_env = getenv("HOS_MOUSE_NATIVE"); + const int native = native_env ? atoi(native_env) != 0 : rx_option(6, 1); /* (the menu's Mouse camera) */ int hos_mouse_native(void); int mouse_on, as_stick = !native; if (native) { diff --git a/host/rx_backend.c b/host/rx_backend.c index 199b52f5..c2b33568 100644 --- a/host/rx_backend.c +++ b/host/rx_backend.c @@ -317,6 +317,8 @@ int rx_guest_imported(uint64_t lo, uint64_t hi) { } /* (nvn_native.c: the CPU set an event's value) in order with the submits, for the GPU's waits on it */ int rx_option(int id, int otherwise) { return vk && vk->option ? vk->option(id) : otherwise; } +void rx_menu_pad(uint64_t buttons) { if (vk && vk->menu_pad) vk->menu_pad(buttons); } +void rx_option_set(int id, int value) { if (vk && vk->option_set) vk->option_set(id, value); } void rx_nvn_event_signal(uint64_t gpu, uint32_t value) { if (vk && vk->nvn_event_signal) vk->nvn_event_signal(gpu, value); } From 23c48b795b1b8fecd55ac3f1b6e1847c93d5d007 Mon Sep 17 00:00:00 2001 From: xellow78-lgtm Date: Sat, 7 Nov 2026 18:39:53 -0500 Subject: [PATCH 10/10] Controls in the menu: every key, mouse button and pad button can be changed Two new tabs (the tabs are now Display, Performance, Camera, Keyboard, Controller; the menu 540 wide): - Keyboard: each of the game's 16 buttons, the four moves and the four camera directions has two keys (keys or mouse buttons: left, right, middle, 4, 5). Enter, A or a click on a key waits for the new one (the press that began it is let go first; Esc keeps the old one, Backspace clears it, 8 s give up); left and right choose the first or second key; Backspace or the pad's X clears the selected one. A mouse button counts only while the mouse is captured, as before. - Controller: each of the game's buttons can be pressed by any pad button, named by where it is (Bottom face (B / A / Cross) ...). The menu itself keeps working with the pad's usual buttons, so no change can lock it. - Reset rows on both; the long tabs scroll (the selection is kept in view; the mouse wheel scrolls: display.c passes it, option 10). nn_hid.c: the fixed key table, the mouse's Y and ZL and the WASD, arrow and IJKL sticks are now the bindings (rx_option 100 + action * 2 + slot, 200 + button), with the same defaults (checked against the old table); without the Vulkan back end the defaults are used. Rendered on Linux through the GDI stand-in to check the layout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TgQM2zLG8uS929WRQ7nxPQ --- gpu/vk/vk_api.h | 3 +- gpu/vk/vk_menu.cpp | 333 +++++++++++++++++++++++++++++++++++++------ gpu/vk/vk_menu.h | 8 ++ gpu/vk/vk_replay.cpp | 4 +- host/display.c | 6 + host/nn_hid.c | 80 +++++++---- 6 files changed, 363 insertions(+), 71 deletions(-) diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index 9adc0753..60ef53db 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -96,7 +96,8 @@ typedef struct MkwVkRenderer { /* the CPU set an event's value: in order with the submits (the GPU's waits on it) */ void (*nvn_event_signal)(uint64_t gpu, uint32_t value); int (*option)(int id); /* (hookshot, the Insert menu) 0: game time follows the clock, 1: the menu is open, 2: dynamic resolution, 3: its fixed scale in percent, - 4: mouse sensitivity in percent, 5: mouse vertical inverted, 6: native mouse look */ + 4: mouse sensitivity in percent, 5: mouse vertical inverted, 6: native mouse look, + 100 + action * 2 + slot: a key of the controls, 200 + button: the pad button for one (host/nn_hid.c) */ /* a submit with its queue */ void (*nvn_submit_q)(const uint64_t *handles, int n, uint64_t queue); /* (hookshot, the menu) the controllers' buttons (host/nn_hid.c's B_* bits) with each read: they open, close and work it */ diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index dac5190b..fefb23ae 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -58,9 +58,69 @@ const char* tx_sens(int v) { return b; } const char* tx_mousecam(int v) { return v ? "Native" : "As the stick"; } +// (the controls' rows: the text function says which kind they are, kind_of; their values are opts.keys and opts.pads) +const char* tx_keybind(int) { return ""; } +const char* tx_padbind(int) { return ""; } +const char* tx_reset(int) { return "Reset to defaults"; } -const char* tab_names[] = {"Display", "Performance", "Camera & Mouse"}; -constexpr int kTabs = 3; +/// a key's or a mouse button's name, as the Keyboard tab shows it +const char* vk_name(int vk) { + static char b[32]; + if (vk <= 0) return "-"; + if ((vk >= '0' && vk <= '9') || (vk >= 'A' && vk <= 'Z')) { snprintf(b, sizeof b, "%c", vk); return b; } + if (vk >= 0x70 && vk <= 0x87) { snprintf(b, sizeof b, "F%d", vk - 0x70 + 1); return b; } // (F1 to F24) + if (vk >= 0x60 && vk <= 0x69) { snprintf(b, sizeof b, "Num %d", vk - 0x60); return b; } // (the number pad) + switch (vk) { + case VK_LBUTTON: return "Left click"; + case VK_RBUTTON: return "Right click"; + case VK_MBUTTON: return "Middle click"; + case 0x05: return "Mouse 4"; + case 0x06: return "Mouse 5"; + case VK_SPACE: return "Space"; + case VK_SHIFT: return "Shift"; + case VK_CONTROL: return "Ctrl"; + case VK_MENU: return "Alt"; + case VK_TAB: return "Tab"; + case VK_RETURN: return "Enter"; + case VK_CAPITAL: return "Caps Lock"; + case VK_UP: return "Up"; + case VK_DOWN: return "Down"; + case VK_LEFT: return "Left"; + case VK_RIGHT: return "Right"; + case VK_HOME: return "Home"; + case VK_END: return "End"; + case VK_PRIOR: return "Page Up"; + case VK_NEXT: return "Page Down"; + case VK_DELETE: return "Delete"; + case 0xBA: return ";"; + case 0xBB: return "="; + case 0xBC: return ","; + case 0xBD: return "-"; + case 0xBE: return "."; + case 0xBF: return "/"; + case 0xC0: return "`"; + case 0xDB: return "["; + case 0xDC: return "\\"; + case 0xDD: return "]"; + case 0xDE: return "'"; + } + snprintf(b, sizeof b, "Key %d", vk); + return b; +} +/// a pad button by where it is (host/nn_hid.c's B_* bits): the same place on any pad +const char* pad_name(int b) { + static const char* t[16] = {"Right face (A / B / Circle)", "Bottom face (B / A / Cross)", "Top face (X / Y / Triangle)", + "Left face (Y / X / Square)", "Left stick press", "Right stick press", "L / LB / L1", + "R / RB / R1", "ZL / LT / L2", "ZR / RT / R2", "+ / Start / Options", "- / Back / Share", + "D-pad left", "D-pad up", "D-pad right", "D-pad down"}; + return b >= 0 && b < 16 ? t[b] : "-"; +} + +const char* tab_names[] = {"Display", "Performance", "Camera", "Keyboard", "Controller"}; +constexpr int kTabs = 5; +#define KEY_HELP "Enter, A or a click on a key: press the new key or mouse button (Esc keeps the old one). Backspace clears it. " \ + "Left and right: the first or second key." +#define PAD_HELP "Enter, A or a click: press the controller button that should do this (the menu itself keeps the usual buttons)." Row rows[] = { {"Fullscreen", &opts.fullscreen, 0, 1, 1, tx_onoff, @@ -94,9 +154,51 @@ Row rows[] = { "The - and = keys change it in the game too.", 2}, {"Invert vertical", &opts.mouse_invert, 0, 1, 1, tx_onoff, "Swaps up and down for the mouse: moving it forward tilts the camera down.", 2}, + {"Button A", opts.keys[0], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button B", opts.keys[1], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button X", opts.keys[2], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button Y", opts.keys[3], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button L", opts.keys[6], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button R", opts.keys[7], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button ZL", opts.keys[8], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Button ZR", opts.keys[9], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Plus", opts.keys[10], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Minus", opts.keys[11], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Left stick press", opts.keys[4], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Right stick press", opts.keys[5], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Move forward", opts.keys[16], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Move back", opts.keys[17], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Move left", opts.keys[18], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Move right", opts.keys[19], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Camera up", opts.keys[20], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Camera down", opts.keys[21], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Camera left", opts.keys[22], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Camera right", opts.keys[23], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"D-pad left", opts.keys[12], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"D-pad up", opts.keys[13], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"D-pad right", opts.keys[14], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"D-pad down", opts.keys[15], 0, 0, 1, tx_keybind, KEY_HELP, 3}, + {"Reset keys", nullptr, 0, 0, 1, tx_reset, "Puts every key and mouse button back as they came.", 3}, + {"Button A", &opts.pads[0], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button B", &opts.pads[1], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button X", &opts.pads[2], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button Y", &opts.pads[3], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button L", &opts.pads[6], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button R", &opts.pads[7], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button ZL", &opts.pads[8], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Button ZR", &opts.pads[9], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Plus", &opts.pads[10], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Minus", &opts.pads[11], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Left stick press", &opts.pads[4], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Right stick press", &opts.pads[5], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"D-pad left", &opts.pads[12], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"D-pad up", &opts.pads[13], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"D-pad right", &opts.pads[14], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"D-pad down", &opts.pads[15], 0, 0, 1, tx_padbind, PAD_HELP, 4}, + {"Reset buttons", nullptr, 0, 0, 1, tx_reset, "Puts every controller button back as it came.", 4}, }; constexpr int kRows = sizeof rows / sizeof rows[0]; -constexpr int kMenuWidth = 480; // (at a scale of 1: 720 lines) +constexpr int kMenuWidth = 540; // (at a scale of 1: 720 lines) std::atomic open_now{false}; int cur_tab = 0, selected = 0; // (selected: the row's index in rows, on cur_tab) @@ -117,7 +219,12 @@ const char* ini_key(const Row& r) { } void save() { if (FILE* f = fopen(ini_path().c_str(), "w")) { - for (const Row& r : rows) fprintf(f, "%s=%d\n", ini_key(r), *r.value); + for (const Row& r : rows) { + if (r.text == tx_reset) continue; + if (r.text == tx_keybind) fprintf(f, "key_%s_1=%d\nkey_%s_2=%d\n", ini_key(r), r.value[0], ini_key(r), r.value[1]); + else if (r.text == tx_padbind) fprintf(f, "pad_%s=%d\n", ini_key(r), *r.value); + else fprintf(f, "%s=%d\n", ini_key(r), *r.value); + } fprintf(f, "options_version=2\n"); // (game_speed below 2 was only ever the old default, see menu_init) fclose(f); } @@ -222,31 +329,45 @@ void fill(HDC dc, int x, int y, int w, int h, COLORREF c) { // (hookshot) The menu redrawn: tabs, a row a setting with a switch, a slider or arrows, the help under them; in the pause // menu's colours. Only rectangles and text: the POSIX GDI stand-in (host/posix/gdi_posix.c) has nothing else. Where each // part landed is kept for the mouse (hits), in the menu's own pixels. -enum Kind { K_CHOICE, K_TOGGLE, K_SLIDER }; +enum Kind { K_CHOICE, K_TOGGLE, K_SLIDER, K_KEY, K_PAD, K_ACTION }; Kind kind_of(const Row& r) { + if (r.text == tx_keybind) return K_KEY; + if (r.text == tx_padbind) return K_PAD; + if (r.text == tx_reset) return K_ACTION; if (r.text == tx_onoff) return K_TOGGLE; if ((r.hi - r.lo) / r.step >= 8 && r.text != tx_framegen) return K_SLIDER; return K_CHOICE; } -constexpr int kMaxShown = 8; // (rows a tab shows at most: the panel keeps that height) +constexpr int kVisible = 9; // (rows a tab shows at once: the longer ones scroll; the panel keeps that height) +int scroll[kTabs] = {}; // (each tab's first row shown) +int sel_slot = 0; // (the Keyboard tab: the first or the second key) +int capturing = -1, cap_slot = 0; // (the row waiting for a key or a pad button, and which of its keys) +bool cap_released = false; // (everything let go since the capture began: the press that began it is not taken) +int64_t cap_until = 0; struct Hits { RECT tab[kTabs]{}, close{}; - RECT row[kMaxShown]{}, ctl[kMaxShown]{}; - int index[kMaxShown]{}, shown = 0; + RECT row[kVisible]{}, ctl[kVisible]{}, box[kVisible][2]{}; + int index[kVisible]{}, shown = 0; } hits; const COLORREF kBg = RGB(30, 24, 18), kHead = RGB(46, 36, 25), kTabOff = RGB(38, 30, 21), kSel = RGB(72, 56, 36), kGold = RGB(214, 174, 92), kText = RGB(242, 232, 212), kDim = RGB(172, 156, 128), kTrack = RGB(64, 52, 38), kLine = RGB(84, 68, 46); -int shown_rows(int tab, int* out) { +int count_on(int tab) { int n = 0; - for (int i = 0; i < kRows && n < kMaxShown; i++) - if (rows[i].tab == tab) out[n++] = i; + for (const Row& r : rows) n += r.tab == tab; + return n; +} +/// the rows on a tab, from its scroll position, kVisible at most: their indices in rows +int shown_rows(int tab, int* out) { + int n = 0, k = 0; + for (int i = 0; i < kRows && n < kVisible; i++) + if (rows[i].tab == tab && k++ >= scroll[tab]) out[n++] = i; return n; } int most_rows() { - int most = 0, tmp[kMaxShown]; - for (int t = 0; t < kTabs; t++) most = std::max(most, shown_rows(t, tmp)); - return most; + int most = 0; + for (int t = 0; t < kTabs; t++) most = std::max(most, count_on(t)); + return std::min(most, kVisible); } int text_w(HDC dc, const char* t) { SIZE sz{}; @@ -260,13 +381,24 @@ void text_at(HDC dc, int x, int y, const char* t, COLORREF c) { void frame(HDC dc, int x, int y, int w, int h, int t, COLORREF c) { fill(dc, x, y, w, t, c), fill(dc, x, y + h - t, w, t, c), fill(dc, x, y, t, h, c), fill(dc, x + w - t, y, t, h, c); } +/// a box with text in it, the text cut to fit +void box(HDC dc, const RECT& r, const char* t, bool lit, bool waiting, int s) { + fill(dc, r.left, r.top, r.right - r.left, r.bottom - r.top, waiting ? RGB(88, 66, 30) : RGB(40, 32, 23)); + frame(dc, r.left, r.top, r.right - r.left, r.bottom - r.top, std::max(1, s), lit || waiting ? kGold : kLine); + std::string text = t; + const int room = (r.right - r.left) - 12 * s; + while (text.size() > 1 && text_w(dc, text.c_str()) > room) text.pop_back(); + text_at(dc, r.left + ((r.right - r.left) - text_w(dc, text.c_str())) / 2, r.top + ((r.bottom - r.top) - 18 * s) / 2, text.c_str(), + waiting ? kGold : lit ? kText : kDim); +} void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { const int W = (int)w; fill(dc, 0, 0, W, (int)h, kBg); frame(dc, 0, 0, W, (int)h, std::max(1, s), kLine); SetBkMode(dc, TRANSPARENT); HFONT title = font(19 * s, FW_SEMIBOLD), body = font(15 * s, FW_NORMAL), bold = font(15 * s, FW_SEMIBOLD), - small = font(12 * s, FW_NORMAL), help = font(14 * s, FW_NORMAL); + small = font(12 * s, FW_NORMAL), help = font(14 * s, FW_NORMAL), tabf = font(14 * s, FW_NORMAL), + tabb = font(14 * s, FW_SEMIBOLD); HGDIOBJ old = SelectObject(dc, title); // the header: the name, and a close box const int head = 44 * s; @@ -287,13 +419,13 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { const int x = t * tabw, wd = t == kTabs - 1 ? W - x : tabw; hits.tab[t] = {x, ty, x + wd, ty + th}; fill(dc, x, ty, wd, th, t == cur_tab ? kBg : kTabOff); - if (t == cur_tab) fill(dc, x + 12 * s, ty + th - 3 * s, wd - 24 * s, 3 * s, kGold); - SelectObject(dc, t == cur_tab ? bold : body); - text_at(dc, x + (wd - text_w(dc, tab_names[t])) / 2, ty + 9 * s, tab_names[t], t == cur_tab ? kText : kDim); + if (t == cur_tab) fill(dc, x + 10 * s, ty + th - 3 * s, wd - 20 * s, 3 * s, kGold); + SelectObject(dc, t == cur_tab ? tabb : tabf); + text_at(dc, x + (wd - text_w(dc, tab_names[t])) / 2, ty + 10 * s, tab_names[t], t == cur_tab ? kText : kDim); } // the rows - const int top = ty + th + 8 * s, rh = 40 * s, cw = 210 * s, cx = W - 18 * s - cw; - int list[kMaxShown]; + const int top = ty + th + 8 * s, rh = 40 * s, cw = 230 * s, cx = W - 26 * s - cw; + int list[kVisible]; hits.shown = shown_rows(cur_tab, list); for (int k = 0; k < hits.shown; k++) { const Row& r = rows[list[k]]; @@ -301,13 +433,32 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { hits.index[k] = list[k]; hits.row[k] = {8 * s, y, W - 8 * s, y + rh}; hits.ctl[k] = {cx, y, cx + cw, y + rh}; - if (sel) fill(dc, 8 * s, y + 2 * s, W - 16 * s, rh - 4 * s, kSel), fill(dc, 8 * s, y + 2 * s, 4 * s, rh - 4 * s, kGold); + if (sel) fill(dc, 8 * s, y + 2 * s, W - 22 * s, rh - 4 * s, kSel), fill(dc, 8 * s, y + 2 * s, 4 * s, rh - 4 * s, kGold); SelectObject(dc, body); text_at(dc, 22 * s, y + 10 * s, r.name, sel ? kText : RGB(222, 210, 188)); - const int v = *r.value, mid = y + rh / 2; + const int mid = y + rh / 2; switch (kind_of(r)) { + case K_KEY: { + const int bw = (cw - 8 * s) / 2; + for (int slot = 0; slot < 2; slot++) { + hits.box[k][slot] = {cx + slot * (bw + 8 * s), mid - 13 * s, cx + slot * (bw + 8 * s) + bw, mid + 13 * s}; + const bool waiting = capturing == list[k] && cap_slot == slot; + box(dc, hits.box[k][slot], waiting ? "Press a key" : vk_name(r.value[slot]), sel && sel_slot == slot, waiting, s); + } + break; + } + case K_PAD: { + hits.box[k][0] = {cx, mid - 13 * s, cx + cw, mid + 13 * s}; + const bool waiting = capturing == list[k]; + box(dc, hits.box[k][0], waiting ? "Press a button" : pad_name(*r.value), sel, waiting, s); + break; + } + case K_ACTION: + hits.box[k][0] = {cx, mid - 13 * s, cx + cw, mid + 13 * s}; + box(dc, hits.box[k][0], r.text(0), sel, false, s); + break; case K_TOGGLE: { - const int sw = 48 * s, sh = 22 * s, sx = cx + cw - sw, sy = mid - sh / 2; + const int v = *r.value, sw = 48 * s, sh = 22 * s, sx = cx + cw - sw, sy = mid - sh / 2; fill(dc, sx, sy, sw, sh, v ? kGold : kTrack); fill(dc, v ? sx + sw - sh + 3 * s : sx + 3 * s, sy + 3 * s, sh - 6 * s, sh - 6 * s, v ? RGB(40, 30, 18) : kDim); const char* t = r.text(v); @@ -315,7 +466,7 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { break; } case K_SLIDER: { - const int vw = 52 * s, tx = cx, tw2 = cw - vw - 10 * s, ky = mid - 3 * s; + const int v = *r.value, vw = 52 * s, tx = cx, tw2 = cw - vw - 10 * s, ky = mid - 3 * s; const int at = tx + (int)((long long)(v - r.lo) * tw2 / std::max(1, r.hi - r.lo)); fill(dc, tx, ky, tw2, 6 * s, kTrack); fill(dc, tx, ky, at - tx, 6 * s, kGold); @@ -333,11 +484,19 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { text_at(dc, cx + (bw - text_w(dc, "<")) / 2, by + 3 * s, "<", sel ? kGold : kDim); text_at(dc, cx + cw - bw + (bw - text_w(dc, ">")) / 2, by + 3 * s, ">", sel ? kGold : kDim); SelectObject(dc, body); - const char* t = r.text(v); + const char* t = r.text(*r.value); text_at(dc, cx + (cw - text_w(dc, t)) / 2, y + 10 * s, t, sel ? kText : kDim); } } } + { // a scrollbar when the tab has more rows than it shows + const int n = count_on(cur_tab); + if (n > kVisible) { + const int bx = W - 14 * s, bh = kVisible * rh; + fill(dc, bx, top, 4 * s, bh, kTrack); + fill(dc, bx, top + bh * scroll[cur_tab] / n, 4 * s, std::max(8 * s, bh * kVisible / n), kGold); + } + } // the help, then the hints const int help_top = top + most_rows() * rh + 8 * s; fill(dc, 16 * s, help_top, W - 32 * s, std::max(1, s), kLine); @@ -347,13 +506,13 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { const int foot = help_top + 10 * s + help_px + 12 * s; fill(dc, 0, foot, W, (int)h - foot, kHead); SelectObject(dc, small); - text_at(dc, 16 * s, foot + 8 * s, "Mouse: click, drag Keys: arrows, Tab, Enter Pad: D-pad, A, B, L / R", kDim); + text_at(dc, 16 * s, foot + 8 * s, "Mouse: click, drag, wheel Keys: arrows, Tab, Enter Pad: D-pad, A, B, L / R", kDim); text_at(dc, 16 * s, foot + 26 * s, "Close: Esc, Insert, F1, or both sticks pressed in", kDim); std::string ver = HOOKSHOT_VERSION; if (ver.find(" (") != std::string::npos) ver.resize(ver.find(" (")); text_at(dc, W - 16 * s - text_w(dc, ver.c_str()), foot + 26 * s, ver.c_str(), RGB(128, 114, 92)); SelectObject(dc, old); - for (HFONT f : {title, body, bold, small, help}) DeleteObject(f); + for (HFONT f : {title, body, bold, small, help, tabf, tabb}) DeleteObject(f); } char fps_text[48] = ""; char fps_sub[32] = ""; @@ -451,7 +610,7 @@ void menu_init() { if (done) return; done = true; if (FILE* f = fopen(ini_path().c_str(), "r")) { - char line[80]; + char line[96]; bool has_resolution = false; bool v2 = false; int old_dynres = -1; // (the row before Resolution: 0, the full scale, is its 100%; 1 is Auto) @@ -459,8 +618,20 @@ void menu_init() { char* eq = strchr(line, '='); if (!eq) continue; *eq = 0; - for (Row& r : rows) - if (!strcmp(line, ini_key(r))) *r.value = std::min(std::max(atoi(eq + 1), r.lo), r.hi); + for (Row& r : rows) { + if (r.text == tx_reset) continue; + char k[48]; + if (r.text == tx_keybind) { + for (int slot = 0; slot < 2; slot++) { + snprintf(k, sizeof k, "key_%s_%d", ini_key(r), slot + 1); + if (!strcmp(line, k)) r.value[slot] = std::min(std::max(atoi(eq + 1), 0), 254); + } + } else if (r.text == tx_padbind) { + snprintf(k, sizeof k, "pad_%s", ini_key(r)); + if (!strcmp(line, k)) *r.value = std::min(std::max(atoi(eq + 1), -1), 15); + } else if (!strcmp(line, ini_key(r))) + *r.value = std::min(std::max(atoi(eq + 1), r.lo), r.hi); + } has_resolution |= !strcmp(line, "resolution"); v2 |= !strcmp(line, "options_version"); if (!strcmp(line, "dynamic_resolution")) old_dynres = atoi(eq + 1); @@ -530,6 +701,7 @@ bool fps_take(int64_t now, int64_t shown, int64_t game, int64_t* next, int64_t* namespace { /// A row's value moved one step (dir -1 or +1): a switch flips, a slider stops at its ends, a choice goes round void change(Row& r, int dir) { + if (!r.value || r.lo == r.hi) return; int v = *r.value + dir * r.step; if (r.value == &opts.framegen && v == 1) v = dir > 0 ? 2 : 0; // (off, 2x ... 8x) if (kind_of(r) == K_SLIDER) v = std::min(std::max(v, r.lo), r.hi); @@ -548,16 +720,35 @@ int first_on(int tab) { if (rows[i].tab == tab) return i; return 0; } +int position_on(int tab, int row) { // (where a row is among its tab's) + int k = 0; + for (int i = 0; i < row; i++) k += rows[i].tab == tab; + return k; +} +void keep_shown() { // (the tab scrolled so the selected row is in view) + const int k = position_on(cur_tab, selected), n = count_on(cur_tab); + if (k < scroll[cur_tab]) scroll[cur_tab] = k; + if (k >= scroll[cur_tab] + kVisible) scroll[cur_tab] = k - kVisible + 1; + scroll[cur_tab] = std::max(0, std::min(scroll[cur_tab], n - kVisible)); +} void move_sel(int dir) { // (up or down among the rows on cur_tab) - int list[kMaxShown]; - const int n = shown_rows(cur_tab, list); + int list[64], n = 0; + for (int i = 0; i < kRows && n < 64; i++) + if (rows[i].tab == cur_tab) list[n++] = i; int k = 0; while (k < n && list[k] != selected) k++; selected = list[((k + dir) % n + n) % n]; + keep_shown(); } void to_tab(int t) { cur_tab = (t % kTabs + kTabs) % kTabs; - selected = first_on(cur_tab); + int list[kVisible]; + selected = shown_rows(cur_tab, list) ? list[0] : first_on(cur_tab); + sel_slot = 0; +} +void wheel_scroll(int notches) { + const int n = count_on(cur_tab); + scroll[cur_tab] = std::max(0, std::min(scroll[cur_tab] - notches * 3, n - kVisible)); } /// a press that repeats while held: the first at once, then after 350 ms every 60 ms bool repeat(bool down, int64_t now, int64_t& since, int64_t& next) { @@ -566,11 +757,31 @@ bool repeat(bool down, int64_t now, int64_t& since, int64_t& next) { if (now >= next) { next = now + 60000000; return true; } return false; } +void reset_controls(int tab) { // (the Reset rows: the controls of that tab as they came) + const Options fresh; + if (tab == 3) std::memcpy(opts.keys, fresh.keys, sizeof opts.keys); + else std::memcpy(opts.pads, fresh.pads, sizeof opts.pads); + save(); +} +/// activate a row: a switch or a choice steps; a key or a pad button waits for the new one; a reset resets +void activate(int row, int slot, int64_t now) { + Row& r = rows[row]; + switch (kind_of(r)) { + case K_KEY: case K_PAD: + capturing = row, cap_slot = slot, cap_released = false, cap_until = now + 8000000000LL; + break; + case K_ACTION: reset_controls(r.tab); break; + case K_SLIDER: break; + default: change(r, +1); + } +} +std::atomic wheel_notches{0}; } // namespace void menu_pad(uint64_t buttons) { pad_now.store(buttons, std::memory_order_relaxed); } void menu_option_set(int id, int value) { + if (id == 10) { wheel_notches += value / 120; menu_refresh = true; return; } // (host/display.c: the wheel, while open) if (id == 4) opts.mouse_sens = std::min(std::max(value, 10), 500); else return; save(); @@ -580,8 +791,9 @@ void menu_option_set(int id, int value) { void menu_tick() { fullscreen_tick(); // the keyboard (while the game's window is in front), the mouse's left button, and the controllers (host/nn_hid.c) - enum { K_INSERT, K_UP, K_DOWN, K_LEFT, K_RIGHT, K_ESC, K_F1, K_TAB, K_ENTER, K_MOUSE, NKEYS }; - static const int keys[NKEYS] = {VK_INSERT, VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_ESCAPE, VK_F1, VK_TAB, VK_RETURN, VK_LBUTTON}; + enum { K_INSERT, K_UP, K_DOWN, K_LEFT, K_RIGHT, K_ESC, K_F1, K_TAB, K_ENTER, K_MOUSE, K_BACK, K_DEL, NKEYS }; + static const int keys[NKEYS] = {VK_INSERT, VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_ESCAPE, VK_F1, VK_TAB, VK_RETURN, VK_LBUTTON, + VK_BACK, VK_DELETE}; static bool held[NKEYS]; HWND fg = GetForegroundWindow(); DWORD pid = 0; @@ -593,33 +805,60 @@ void menu_tick() { press[i] = down[i] && !held[i]; held[i] = down[i]; } - // (the Switch layout's bits, as host/nn_hid.c reads every pad: A 0, B 1, L 6, R 7, d-pad left 12, up 13, right 14, down 15) + // (the Switch layout's bits, as host/nn_hid.c reads every pad, by position, before the Controller tab's changes: + // A 0, B 1, X 2, L 6, R 7, d-pad left 12, up 13, right 14, down 15) const uint64_t pad = pad_now.load(std::memory_order_relaxed); static uint64_t pad_was; const uint64_t pad_press = pad & ~pad_was; pad_was = pad; auto bit = [](uint64_t b, int n) { return (b >> n & 1) != 0; }; - const bool sticks = bit(pad, 4) && bit(pad, 5) && (bit(pad_press, 4) || bit(pad_press, 5)); // (both sticks pressed in) const int scale = scale_of(dev.swap_extent.height); bool changed = scale != cur_scale || menu_refresh.exchange(false); cur_scale = scale; + const int64_t now = prof_now(); + if (capturing >= 0) { // waiting for a key or a pad button: nothing else works the menu meanwhile + Row& r = rows[capturing]; + int any_key = 0; + for (int vk = 1; vk < 255 && mine; vk++) { + if ((vk >= 0xA0 && vk <= 0xA5) || vk == 0x5B || vk == 0x5C) continue; // (and the Windows keys) // (Shift, Ctrl, Alt as one key each) + if (GetAsyncKeyState(vk) & 0x8000) { any_key = vk; break; } + } + if (!cap_released) cap_released = !any_key && !pad; // (the press that began it let go first) + else if (now > cap_until || (any_key == VK_ESCAPE)) capturing = -1; + else if (kind_of(r) == K_KEY && (any_key == VK_BACK || any_key == VK_DELETE)) r.value[cap_slot] = 0, save(), capturing = -1; + else if (kind_of(r) == K_KEY && any_key && any_key != VK_INSERT && any_key != VK_F1) + r.value[cap_slot] = any_key, save(), capturing = -1; + else if (kind_of(r) == K_PAD && pad) + for (int b = 0; b < 16; b++) + if (bit(pad, b)) { *r.value = b, save(), capturing = -1; break; } + if (capturing < 0) { // (what was pressed is not also taken by the menu below) + for (int i = 0; i < NKEYS; i++) press[i] = false, held[i] = true; + pad_was = ~0ULL; + } + redraw_menu(scale); + return; + } + const bool sticks = bit(pad, 4) && bit(pad, 5) && (bit(pad_press, 4) || bit(pad_press, 5)); // (both sticks pressed in) if (press[K_INSERT] || press[K_F1] || sticks) open_now = !open_now.load(), changed = true; if (open_now.load()) { - const int64_t now = prof_now(); static int64_t since[2], next[2]; const bool left = repeat(down[K_LEFT] || bit(pad, 12), now, since[0], next[0]); const bool right = repeat(down[K_RIGHT] || bit(pad, 14), now, since[1], next[1]); - if (rows[selected].tab != cur_tab) selected = first_on(cur_tab); + if (rows[selected].tab != cur_tab) to_tab(cur_tab); + if (const int n = wheel_notches.exchange(0)) wheel_scroll(n), changed = true; if (press[K_ESC] || bit(pad_press, 1)) open_now = false, changed = true; if (press[K_UP] || bit(pad_press, 13)) move_sel(-1), changed = true; if (press[K_DOWN] || bit(pad_press, 15)) move_sel(+1), changed = true; if (press[K_TAB] || bit(pad_press, 7)) to_tab(cur_tab + 1), changed = true; if (bit(pad_press, 6)) to_tab(cur_tab - 1), changed = true; - if (left || right) change(rows[selected], right ? +1 : -1), changed = true; - if (press[K_ENTER] || bit(pad_press, 0)) { - Row& r = rows[selected]; - if (kind_of(r) != K_SLIDER) change(r, +1), changed = true; + Row& cur = rows[selected]; + if (left || right) { + if (kind_of(cur) == K_KEY) sel_slot = right ? 1 : 0; + else change(cur, right ? +1 : -1); + changed = true; } + if ((press[K_BACK] || press[K_DEL] || bit(pad_press, 2)) && kind_of(cur) == K_KEY) cur.value[sel_slot] = 0, save(), changed = true; + if (press[K_ENTER] || bit(pad_press, 0)) activate(selected, sel_slot, now), changed = true; #ifdef _WIN32 { // the mouse: where it points in the menu's own pixels (the menu sits in the middle of the window's image) static POINT last{-1, -1}; @@ -653,7 +892,13 @@ void menu_tick() { const Kind kd = kind_of(r); if (kd == K_SLIDER) set_from_x(r, ix, hits.ctl[k], scale), dragging = hits.index[k]; else if (kd == K_TOGGLE) change(r, +1); - else change(r, ix < (hits.ctl[k].left + hits.ctl[k].right) / 2 ? -1 : +1); + else if (kd == K_KEY) { + for (int slot = 0; slot < 2; slot++) + if (in(hits.box[k][slot], ix, iy)) sel_slot = slot, activate(selected, slot, now); + } else if (kd == K_PAD || kd == K_ACTION) { + if (in(hits.box[k][0], ix, iy)) activate(selected, 0, now); + } else + change(r, ix < (hits.ctl[k].left + hits.ctl[k].right) / 2 ? -1 : +1); changed = true; } } diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 4c7dbebe..db7b581a 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -20,6 +20,14 @@ struct Options { int mouse_native = 1; // the mouse turns the camera's own angles (host/mouse_native.c); 0: as the right stick int mouse_sens = 100; // percent: 0.1 degrees a count at 100 (host/mouse_native.c) int mouse_invert = 0; // up and down swapped + // (the controls: host/nn_hid.c reads them, rx_option 100 + action * 2 + slot and 200 + button) + // keys: two a action, virtual-key codes (a mouse button's too; 0 none): the 16 buttons in host/nn_hid.c's B_* order + // (A B X Y LS RS L R ZL ZR Plus Minus, d-pad left up right down), move up down left right, camera up down left right + int keys[24][2] = {{'E', 0}, {VK_SHIFT, 0}, {VK_SPACE, 0}, {VK_LBUTTON, 0}, {VK_CONTROL, 0}, {'C', 0}, {'Q', 0}, {'R', 0}, + {'Z', VK_RBUTTON}, {'X', 'F'}, {VK_TAB, VK_RETURN}, {'M', 0}, {'1', 0}, {'2', 0}, {'3', 0}, {'4', 0}, + {'W', 0}, {'S', 0}, {'A', 0}, {'D', 0}, {VK_UP, 'I'}, {VK_DOWN, 'K'}, {VK_LEFT, 'J'}, {VK_RIGHT, 'L'}}; + // pads: the pad button (B_*, by position) that presses each of the game's 16 buttons; -1 none + int pads[16] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; }; extern Options opts; diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index 30dd4a0c..b0055e0f 100644 --- a/gpu/vk/vk_replay.cpp +++ b/gpu/vk/vk_replay.cpp @@ -3093,7 +3093,9 @@ void fill_replay_table(MkwVkRenderer& t) { t.nvn_register_texture = register_texture_queued; t.nvn_event_signal = event_signal_queued; t.option = [](int id) -> int { return id == 0 ? opts.realtime : id == 1 ? menu_is_open() : id == 2 ? opts.resolution == 0 : id == 3 ? (opts.resolution == 1 ? 100 : opts.resolution == 2 ? 75 : opts.resolution == 3 ? 67 : opts.resolution == 4 ? 50 : 0) : - id == 4 ? opts.mouse_sens : id == 5 ? opts.mouse_invert : id == 6 ? opts.mouse_native : 0; }; + id == 4 ? opts.mouse_sens : id == 5 ? opts.mouse_invert : id == 6 ? opts.mouse_native : + id >= 100 && id < 148 ? opts.keys[(id - 100) / 2][(id - 100) % 2] : // (the controls: host/nn_hid.c) + id >= 200 && id < 216 ? opts.pads[id - 200] : 0; }; t.menu_pad = menu_pad; t.option_set = menu_option_set; t.nvn_register_sampler = register_sampler_queued; diff --git a/host/display.c b/host/display.c index caa57b01..cd6480db 100644 --- a/host/display.c +++ b/host/display.c @@ -134,6 +134,12 @@ static LRESULT CALLBACK wndproc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { } return DefWindowProcA(w, msg, wp, lp); } + case WM_MOUSEWHEEL: /* (hookshot) the menu's lists scroll (vk_menu.cpp) */ + if (rx_option(1, 0)) { + void rx_option_set(int id, int value); + rx_option_set(10, (int)(short)HIWORD(wp)); + } + return 0; case WM_LBUTTONDOWN: InterlockedIncrement(&clicks); if (!renderer) hos_log("mouse: a click before the renderer was up: not captured"); diff --git a/host/nn_hid.c b/host/nn_hid.c index a0add0e0..c8b8ff09 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -433,6 +433,41 @@ static void pads_read(uint64_t *buttons, int32_t stick[4]) { stick_max(stick, s); } +/* (hookshot) The controls (the menu's Keyboard and Controller tabs, kept in hookshot_options.ini). Each of the 24 actions + has two keys, a key's or a mouse button's virtual-key code (0: none): the 16 buttons in B_* order, then move up, down, + left and right and the camera's up, down, left and right; rx_option(100 + action * 2 + slot). The defaults are the + menu's (gpu/vk/vk_menu.cpp), and what is used without the Vulkan back end: totk-pc-recomp's keys as before. A mouse + button counts only while the mouse is captured, and one held when the capture began only after it was let go (the + click that captures never reaches the game). Each of the game's 16 buttons is pressed by a pad button (B_*, by + position): rx_option(200 + button), -1 none, itself by default. */ +#ifndef VK_XBUTTON1 +#define VK_XBUTTON1 0x05 +#define VK_XBUTTON2 0x06 +#endif +enum { ACT_MOVE_UP = 16, ACT_MOVE_DOWN, ACT_MOVE_LEFT, ACT_MOVE_RIGHT, ACT_CAM_UP, ACT_CAM_DOWN, ACT_CAM_LEFT, ACT_CAM_RIGHT, N_ACT }; +static const int key_default[N_ACT][2] = { + {'E', 0}, {VK_SHIFT, 0}, {VK_SPACE, 0}, {VK_LBUTTON, 0}, {VK_CONTROL, 0}, {'C', 0}, {'Q', 0}, {'R', 0}, + {'Z', VK_RBUTTON}, {'X', 'F'}, {VK_TAB, VK_RETURN}, {'M', 0}, {'1', 0}, {'2', 0}, {'3', 0}, {'4', 0}, + {'W', 0}, {'S', 0}, {'A', 0}, {'D', 0}, {VK_UP, 'I'}, {VK_DOWN, 'K'}, {VK_LEFT, 'J'}, {VK_RIGHT, 'L'}}; +static int mouse_vk_slot(int vk) { + return vk == VK_LBUTTON ? 0 : vk == VK_RBUTTON ? 1 : vk == VK_MBUTTON ? 2 : vk == VK_XBUTTON1 ? 3 : vk == VK_XBUTTON2 ? 4 : -1; +} +static int mouse_armed[5]; +static void mouse_buttons_arm(int captured) { /* (once a read) */ + static const int vks[5] = {VK_LBUTTON, VK_RBUTTON, VK_MBUTTON, VK_XBUTTON1, VK_XBUTTON2}; + for (int i = 0; i < 5; i++) mouse_armed[i] = captured && (mouse_armed[i] || !key(vks[i])); +} +/* whether an action's key is down: keys always, mouse buttons only with mouse (captured and armed) */ +static int action_down(int a, int mouse) { + for (int slot = 0; slot < 2; slot++) { + const int vk = rx_option(100 + a * 2 + slot, key_default[a][slot]); + if (vk <= 0) continue; + const int m = mouse_vk_slot(vk); + if (m >= 0 ? mouse && mouse_armed[m] && key(vk) : key(vk)) return 1; + } + return 0; +} + static void read_input(uint64_t *buttons, int32_t stick[4]) { *buttons = 0; memset(stick, 0, 4 * sizeof *stick); @@ -464,21 +499,19 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { return; } if ((pad_buttons >> B_LS & 1) && (pad_buttons >> B_RS & 1)) pad_buttons &= ~(1ULL << B_LS | 1ULL << B_RS); - *buttons |= pad_buttons; + uint64_t mapped = 0; /* (the Controller tab: each of the game's buttons from the pad button set for it) */ + for (int i = 0; i < 16; i++) { + const int from = rx_option(200 + i, i); + if (from >= 0 && from < 16 && (pad_buttons >> from & 1)) mapped |= 1ULL << i; + } + *buttons |= mapped; stick_max(stick, pad_stick); } - /* (the keys of totk-pc-recomp's docs/controls.md, github.com/valentderah/totk-pc-recomp, MIT; plus Enter for - Plus and IJKL as another camera) */ - struct { int vk; int b; } kmap[] = { - {VK_SPACE, B_X}, {'E', B_A}, {VK_SHIFT, B_B}, {'Q', B_L}, {'R', B_R}, {'Z', B_ZL}, {'X', B_ZR}, {'F', B_ZR}, - {VK_CONTROL, B_LS}, {'C', B_RS}, {VK_TAB, B_PLUS}, {VK_RETURN, B_PLUS}, {'M', B_MINUS}, {'1', B_LEFT}, - {'2', B_UP}, {'3', B_RIGHT}, {'4', B_DOWN}}; - for (unsigned i = 0; i < sizeof kmap / sizeof *kmap; i++) - if (key(kmap[i].vk)) *buttons |= 1ULL << kmap[i].b; + /* the keys (the Keyboard tab's; by default totk-pc-recomp's docs/controls.md, github.com/valentderah/totk-pc-recomp, + MIT, plus Enter for Plus and IJKL as another camera); the mouse's buttons after the mouse, below */ + for (int a = 0; a < 16; a++) + if (action_down(a, 0)) *buttons |= 1ULL << a; int32_t mouse[4] = {0}; - /* left button Y (attack), right button ZL (shield, lock on), while the mouse is captured; a button held when - the capture began counts only after it was let go (the click that captures never reaches the game) */ - static int armed[2]; /* (hookshot) the mouse turns the camera itself (mouse_native.c); as the right stick while no camera is found, or always with HOS_MOUSE_NATIVE=0 */ const char *native_env = getenv("HOS_MOUSE_NATIVE"); @@ -492,19 +525,16 @@ static void read_input(uint64_t *buttons, int32_t stick[4]) { if (mouse_on < 0) as_stick = 1; } if (as_stick) mouse_on = mouse_stick(&mouse[2], &mouse[3], (int)(*buttons >> B_L & 1)); - if (mouse_on) { - if (as_stick) stick_max(stick, mouse); - const int vk[2] = {VK_LBUTTON, VK_RBUTTON}, b[2] = {B_Y, B_ZL}; - for (int i = 0; i < 2; i++) { - if (!key(vk[i])) armed[i] = 1; - else if (armed[i]) *buttons |= 1ULL << b[i]; - } - } else { - armed[0] = armed[1] = 0; - } - const int cx = (key(VK_RIGHT) || key('L')) - (key(VK_LEFT) || key('J')), - cy = (key(VK_UP) || key('I')) - (key(VK_DOWN) || key('K')); - const int32_t keys[4] = {(key('D') - key('A')) * 32767, (key('W') - key('S')) * 32767, cx * 32767, cy * 32767}; + if (mouse_on && as_stick) stick_max(stick, mouse); + mouse_buttons_arm(mouse_on > 0); + if (mouse_on > 0) + for (int a = 0; a < 16; a++) + if (action_down(a, 1)) *buttons |= 1ULL << a; + const int cx = action_down(ACT_CAM_RIGHT, mouse_on > 0) - action_down(ACT_CAM_LEFT, mouse_on > 0), + cy = action_down(ACT_CAM_UP, mouse_on > 0) - action_down(ACT_CAM_DOWN, mouse_on > 0); + const int32_t keys[4] = {(action_down(ACT_MOVE_RIGHT, mouse_on > 0) - action_down(ACT_MOVE_LEFT, mouse_on > 0)) * 32767, + (action_down(ACT_MOVE_UP, mouse_on > 0) - action_down(ACT_MOVE_DOWN, mouse_on > 0)) * 32767, + cx * 32767, cy * 32767}; stick_max(stick, keys); { const int32_t m = abs(stick[2]) > abs(stick[3]) ? abs(stick[2]) : abs(stick[3]);