From 75af5b791f0889cd355e68298bbe49515660101b Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 17:12:59 -0500 Subject: [PATCH 01/62] Add the fold closure writer, header decisions and reference record apply_closure.py writes the shipped v1.0.11 first-party closure back into runtime-recomp/ and respells staged includes. Bases it rewrites are released from the capsule pin via scripts/fold/released-bases.txt until B8. --- AGENTS.md | 3 +- docs/fold-in/REFERENCE.md | 42 ++ docs/fold-in/header-decisions.md | 47 ++ pyproject.toml | 2 + runtime-recomp/tests/pinned_bytes_tests.py | 27 + scripts/check_pinned_bytes.py | 67 +- scripts/fold/apply_closure.py | 752 +++++++++++++++++++++ scripts/fold/header-variants.json | 442 ++++++++++++ scripts/pattern_guard.py | 3 + 9 files changed, 1373 insertions(+), 12 deletions(-) create mode 100644 docs/fold-in/REFERENCE.md create mode 100644 docs/fold-in/header-decisions.md create mode 100644 scripts/fold/apply_closure.py create mode 100644 scripts/fold/header-variants.json diff --git a/AGENTS.md b/AGENTS.md index b544799d..ad325d74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,4 +71,5 @@ Never reformat or "tidy" them: - `*.patch` files listed in `patches/manifest.json`, and `modding/reference-us-v*/`. - Until the fold deletes the 1.0.7 source capsule: everything under `runtime-recomp/src/` and `runtime-recomp/tests/replay_probe/` that the capsule pins as a base. Real code changes there are fine; formatting - passes and whitespace fixes are not. + passes and whitespace fixes are not. On the fold branch, the bases the fold rewrote are listed in + `scripts/fold/released-bases.txt`, which only `scripts/fold/apply_closure.py` writes; never add a path by hand. diff --git a/docs/fold-in/REFERENCE.md b/docs/fold-in/REFERENCE.md new file mode 100644 index 00000000..8dc3cc3f --- /dev/null +++ b/docs/fold-in/REFERENCE.md @@ -0,0 +1,42 @@ +# Fold reference (v1.0.11, Linux x86_64) + +The fold reproduces tag `Version1.0.11` (`6bdbcde`). The reference is a +materialization of that tag: an unmodified clone, the capsule restored with +`patches/upstream-sync/restore_capsule.py`, and a Linux configure of the full +48-layer chain (`release-prep/v1.0.11/ReleaseOverlay.cmake`) in the +`dkr-ci-linux-full` image. The owned, generated and code-policy inputs are +the ones that reproduce the shipped owned IDs, so +`generated/memory-stability/contracts.json` holds + +- V77 `5e22da8e6095b362ede17c69a2341aae2df24cbf8561c3af57fc86566d11784e` +- V80 `32c82fea3111440434926d5782e174c9f2d036d198f039c5424393a835f948b0` + +Options: `DKR_RELEASE_VERSION=1.0.11`, `DKR_RUNTIME_BUILD_RT64=ON`, +`DKR_RUNTIME_BUILD_SDL3_INPUT_HOST=ON`, `DKR_NETPLAY_WEBRTC=ON`, +`DKR_EXPERIMENTAL_RACE_TEST=ON`, every `*_QUALIFICATION=OFF`, plus the payload +paths. These are still assumptions until the maintainer's per-platform +`CMakeCache.txt` confirms them (plan open question 11). + +The final-state dump (`targets.tsv`) runs after every layer's deferred calls +and covers all 267 targets. It also lists per-source include directories, +options, definitions and `LANGUAGE`/`SKIP_*`. The CMake File API codemodel of +the same configure is kept beside it. + +The ROM-derived payloads can't leave the devops machine, so the reference +lives in the session scratchpad (`fold-in/reference-v1011/`), not in git. + +| File | sha256 | +|---|---| +| `final-sources.json` | `b1724fec0c8ff8a9f985bb2f9da6ef0de6c5e13676a61684978f37d1e7a7ece3` | +| `targets.tsv` | `cf96e30e051582c01187575515892f79bf2ac90d1a3a716b7a6154e4124c8f11` | +| `shipped-compdb.json` | `f01e8c19728fb4d0e2050c02384efc07b5d6a160215d8ac823998c53ed4bb7f4` | +| `compdb-linux.json` | `95cc1dd8393ef29c29e9aa53fececd411526322999e938e5c43423f963cd21b0` | +| `depscan.json` | `4fd709eb1afa0d2cc848d7d2fa83d62713ea620d31d7b51f14677991e4a710bb` | +| `shipped-inputs.txt` | `de929d28697c71c5f63a60c1075841590db44bc9ff7243456f636f4a28629043` | +| `contracts.json` | `00c09f7ab7ac8541b0cf26ce6b64b4014737cbc7085a83ad8e81bd13e8f0a4f9` | +| `closure-files.tar` (every closure file at its staged path) | `e2d9bf779cb0821f778e60fe2d50798d265b7c9e918845a3b818b46912b48eda` | + +Shipped closure: 1368 TUs behind `DKR-R`, `DKR-R-ModWorker`, `DKR-R-Updater` +and `DKR-R-InputHost`. Of these, 837 first-party files, 40 guest transforms, +6 guest-derived files and 11 dependency transforms. The first-party files are +written back by `scripts/fold/apply_closure.py` (step B1). diff --git a/docs/fold-in/header-decisions.md b/docs/fold-in/header-decisions.md new file mode 100644 index 00000000..82e1edc6 --- /dev/null +++ b/docs/fold-in/header-decisions.md @@ -0,0 +1,47 @@ +# Header decisions (fold step B1) + +The v1.0.11 binaries were compiled from staged copies, and 13 headers reached +different TUs in more than one version. A single-copy tree keeps one version +of each. This page says which one and why; `scripts/fold/header-variants.json` +is the machine-readable form (chosen sha256 plus every TU that compiled +against another version), regenerated by +`scripts/fold/apply_closure.py --write-variants` and checked by `--check`. + +The rule (fold plan 2.3): keep the version the defining TU compiled against, +and the upstream-ported version where PRs 66 and 68 require it. The other +TUs are allowed to differ at equivalence level 2 (preprocessed tokens) and +must instead match at level 3 (object code), which shows the declarations +that changed are unused in them. + +## Forwarders (7, plus one of the three `magic_code_package.hpp` copies) + +`mod-runtime-repair` staged a two-line copy of each of these headers, +`#pragma once` and an absolute `#include` of the real SDK2 header. Collapsing +a forwarder into its target leaves every TU's tokens unchanged, so these are +not real splits. Plan 3.1 drops the forwarders. + +| Header | Kept | TUs that went through the forwarder | +|---|---|---| +| `mods/magic_code_game_services.hpp` | SDK2_BASE | `magic_code_game_world.cpp` | +| `mods/magic_code_game_world.hpp` | SDK2 | `magic_code_game_world.cpp` | +| `mods/magic_code_link.hpp` | SDK2 | `magic_code_link.cpp` | +| `mods/magic_code_native_arguments.hpp` | SDK2_BASE | `magic_code_game_world.cpp` | +| `mods/magic_code_vm.hpp` | SDK2 | the five `mods/magic_code_*.cpp` TUs | +| `mods/magic_code_world.hpp` | SDK2 | `magic_code_world.cpp` | +| `mods/sdk2_schema.hpp` | SDK2_BASE | `magic_code_package.cpp` | +| `mods/magic_code_package.hpp` (forwarder copy) | SDK2 | `magic_code_package.cpp` | + +## Real splits (6) + +| Header | Kept | Minority TUs | What differs for them | +|---|---|---|---| +| `mobile_graphics_preset.hpp` | PR 68 (upstream-sync, same as main) | `rt64_renderer.cpp` | gains ``, `preset_anisotropy()` and `matching_preset()`; it calls neither | +| `mods/legacy_character_materialize.hpp` | SDK2 (44 TUs, incl. `legacy_character_materialize.cpp`) | `vehicle_binding.cpp`, `vehicle_resources.cpp` | gains `PreparedCharacter::source_portrait{,_id}` and a `prepare_character()` parameter; neither TU names either | +| `mods/magic_code_dispatch.h` | PR 66 (upstream-sync, same as main) | 154 TUs (1 first-party, the rest guest and prepared payloads) | gains an `#if defined(__APPLE__)` `__thread` block; identical tokens off Apple | +| `mods/magic_code_package.hpp` | SDK2 (38 TUs) | `vehicle_library.cpp`, `vehicle_package.cpp` | `code::Package` gains the `sdk2_*`, `development` and `debug_lines` members; both TUs use the vehicle `Package`, not this one | +| `resident_music.hpp` | music-preview-repair (incl. `resident_music.cpp`) | `mods/online_mod_runtime.cpp` | gains the `PreviewScope` class; unused there | +| `save_manager.hpp` | SHARED_SAVES (incl. `save_manager.cpp`) | `runtime_save_routing.cpp` | gains `StoredSaveKind::CustomRecords`, `content_hash`/`copies` and `stored_adventures(limit, truncated)`; it names none of them | + +The minority TUs of the real splits are the level 3 set. If one of them does +not match at the object level, the shipped binary used the differing +declaration, and that TU gets its own decision here before the fold merges. diff --git a/pyproject.toml b/pyproject.toml index 70408a12..beee5d95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ include = [ "scripts/gen_guest_symbols.py", "scripts/check_includes.py", "scripts/check_registries.py", + "scripts/fold", "scripts/release", ] strict = [ @@ -111,6 +112,7 @@ strict = [ "scripts/gen_guest_symbols.py", "scripts/check_includes.py", "scripts/check_registries.py", + "scripts/fold", "scripts/release", ] exclude = ["build", "extern", "patches", "tools/blender"] diff --git a/runtime-recomp/tests/pinned_bytes_tests.py b/runtime-recomp/tests/pinned_bytes_tests.py index ba46ddaa..75f0148f 100644 --- a/runtime-recomp/tests/pinned_bytes_tests.py +++ b/runtime-recomp/tests/pinned_bytes_tests.py @@ -137,6 +137,33 @@ def test_missing_pinned_file_fails(self): self.assertEqual(status, 1) self.assertIn("missing", err) + def write_capsule_base(self, data): + base = b"first-party base\n" + manifest = {"roles": {"ROLE": {"x.cpp": {"base": "runtime-recomp/src/x.cpp", "base_sha256": sha(base)}}}} + write(self.root / "patches/release-prep/v1/source-capsule-r1/manifest.json", json.dumps(manifest).encode()) + write(self.root / "runtime-recomp/src/x.cpp", data) + + def test_changed_capsule_base_fails(self): + self.write_capsule_base(b"folded bytes\n") + status, err = run_checker(self.root) + self.assertEqual(status, 1) + self.assertIn("runtime-recomp/src/x.cpp: sha256", err) + + def test_released_capsule_base_may_change(self): + self.write_capsule_base(b"folded bytes\n") + write(self.root / check_pinned_bytes.RELEASED_BASES, b"runtime-recomp/src/x.cpp\n") + self.assertEqual(run_checker(self.root), (0, "")) + + def test_released_list_names_only_changed_capsule_bases(self): + self.write_capsule_base(b"first-party base\n") + write(self.root / check_pinned_bytes.RELEASED_BASES, b"runtime-recomp/src/x.cpp\nscripts/other.py\n") + status, err = run_checker(self.root) + self.assertEqual(status, 1) + self.assertIn("runtime-recomp/src/x.cpp: listed in", err) + self.assertIn("matches its pin again", err) + self.assertIn("scripts/other.py: listed in", err) + self.assertIn("not a capsule base", err) + def test_paths_limit_the_check_and_manifests_expand_to_their_files(self): write(self.root / "patches/dep/0001.patch", b"changed\n") self.assertEqual(run_checker(self.root, "modding/reference-us-v77/symbols.ld")[0], 0) diff --git a/scripts/check_pinned_bytes.py b/scripts/check_pinned_bytes.py index f7fb22b2..f2cabb09 100755 --- a/scripts/check_pinned_bytes.py +++ b/scripts/check_pinned_bytes.py @@ -12,7 +12,8 @@ whose file names are their own sha256; * the capsules' "base" files, first-party sources whose CRLF-normalised bytes the capsule manifest pins (``base_sha256``) because capsule.py rebuilds its - roles from them. + roles from them. Bases the fold has rewritten are listed in + scripts/fold/released-bases.txt and are not pinned (see RELEASED_BASES). With no arguments every pin is checked. With paths (what pre-commit passes), only the pins those paths belong to are checked; a manifest or receipt in the @@ -48,9 +49,58 @@ "runtime-recomp/src/game/runtime_graphics_ui.inl", } ) +# Fold transition, steps B1 to B8: the fold writes the shipped bytes over the +# capsule bases. Like DRIFTED_BASES, every one of them still has its pinned +# bytes at the capsule's base commit (34ed57ae), which is where +# restore_capsule.py reads a drifted base from; nothing builds from the capsule +# once the sources are folded. The list is checked both ways: every entry must +# be a capsule base whose bytes no longer match its pin, so it cannot excuse +# any other file. B8 deletes the capsule, the list and this block. +RELEASED_BASES = "scripts/fold/released-bases.txt" CAPSULE = re.compile(r"^patches/release-prep/[^/]+/source-capsule-[^/]+/(?P[0-9a-f]{64})\.[A-Za-z0-9]+$") +def released(root: Path) -> set[str]: + path = root / RELEASED_BASES + if not path.exists(): + return set() + return {line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()} + + +def base_pins(root: Path) -> dict[str, tuple[str, str]]: + """Every capsule base with (expected sha256, manifest), released or not.""" + bases: dict[str, tuple[str, str]] = {} + for manifest_path in sorted(root.glob(CAPSULE_MANIFESTS)): + source = manifest_path.relative_to(root).as_posix() + capsule = json.loads(manifest_path.read_text(encoding="utf-8")) + for entries in capsule["roles"].values(): + for entry in entries.values(): + base = entry.get("base") + if base and base not in DRIFTED_BASES: + bases[base] = (entry["base_sha256"], source) + return bases + + +def canonical_sha(path: Path) -> str | None: + """sha256 of a capsule base as capsule.py canonical() reads it.""" + try: + return hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() + except FileNotFoundError: + return None + + +def released_problems(root: Path) -> list[str]: + """Entries of RELEASED_BASES that are not a capsule base, or whose bytes match their pin again.""" + bases = base_pins(root) + problems: list[str] = [] + for path in sorted(released(root)): + if path not in bases: + problems.append(f"{path}: listed in {RELEASED_BASES} but not a capsule base") + elif canonical_sha(root / path) == bases[path][0]: + problems.append(f"{path}: listed in {RELEASED_BASES} but matches its pin again; remove the entry") + return problems + + def pins(root: Path) -> dict[str, tuple[str, str]]: """Map each pinned path to (expected sha256, file that pins it).""" pinned: dict[str, tuple[str, str]] = {} @@ -68,14 +118,8 @@ def pins(root: Path) -> dict[str, tuple[str, str]]: match = CAPSULE.match(relative) if match: pinned[relative] = (match["sha"], "its file name") - for manifest_path in sorted(root.glob(CAPSULE_MANIFESTS)): - source = manifest_path.relative_to(root).as_posix() - capsule = json.loads(manifest_path.read_text(encoding="utf-8")) - for entries in capsule["roles"].values(): - for entry in entries.values(): - base = entry.get("base") - if base and base not in DRIFTED_BASES: - pinned[base] = (entry["base_sha256"], source) + free = released(root) + pinned.update({base: pin for base, pin in base_pins(root).items() if base not in free}) return pinned @@ -92,8 +136,9 @@ def selected(pinned: dict[str, tuple[str, str]], paths: Iterable[str]) -> list[s def check(root: Path, paths: Iterable[str] | None = None) -> list[str]: """One message per pinned file that is missing or whose sha256 changed.""" pinned = pins(root) - problems: list[str] = [] - for path in sorted(pinned) if paths is None else selected(pinned, paths): + listed = None if paths is None else [Path(p).as_posix().removeprefix("./") for p in paths] + problems = released_problems(root) if listed is None or RELEASED_BASES in listed else [] + for path in sorted(pinned) if listed is None else selected(pinned, listed): expected, source = pinned[path] try: raw = (root / path).read_bytes() diff --git a/scripts/fold/apply_closure.py b/scripts/fold/apply_closure.py new file mode 100644 index 00000000..8583e874 --- /dev/null +++ b/scripts/fold/apply_closure.py @@ -0,0 +1,752 @@ +#!/usr/bin/env python3 +"""Write the shipped first-party closure back into runtime-recomp/ (fold step B1). + +The reference (scratch, never committed) is a materialization of tag +Version1.0.11: a Linux configure of the full 48-layer release chain whose +``final-sources.json`` lists every first-party file the four shipped binaries +compile, at the staged path the chain left it in. This script puts each of +those files at its shipped path under runtime-recomp/ (flat, no module moves): + +* a ``runtime-recomp`` row goes back to its original path; +* a capsule-role file under ``native/{src,include,tests}/`` goes to the same + path under runtime-recomp/ (the roles are staged copies of that tree); +* every other authored or composed file goes to runtime-recomp/src/game/ + (``mods/`` stays a subdirectory), unless OVERRIDES says otherwise. + +Guest-derived files, guest transforms and dependency transforms are left alone +(fold steps B3 and B4). + +The chain made those files visible through staged include directories and +absolute ``#include`` paths. The folded build has neither, so every include +directive in the closure is resolved twice: once the way the reference TU +resolved it (its compile command, the staged tree on disk) and once the way the +folded TU will (the same include path with staged directories mapped back into +runtime-recomp/ and per-layer generated directories dropped). Where the two +disagree the directive is respelled as a path relative to the including file. +Nothing else in a file changes. + +A shipped binary held more than one version of 13 headers. A single-copy tree +keeps one; scripts/fold/header-variants.json names which and lists the TUs that +compiled against another one (docs/fold-in/header-decisions.md explains each). + + apply_closure.py --reference DIR --summary row totals, no writes + apply_closure.py --reference DIR --check resolve everything, no writes + apply_closure.py --reference DIR --write-variants refresh header-variants.json + apply_closure.py --reference DIR write the files + +Writing also lists the capsule bases whose pins the folded bytes break in +scripts/fold/released-bases.txt (read by scripts/check_pinned_bytes.py). + +Exit status: 0 on success, 1 when the totals, variants or include resolution +do not check out (nothing is written then), 2 on usage errors. +""" + +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +from collections.abc import Iterator +import dataclasses +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +import check_pinned_bytes # noqa: E402 + +VARIANTS = Path("scripts/fold/header-variants.json") +RELEASED_BASES = Path(check_pinned_bytes.RELEASED_BASES) + +# Row totals of the v1.0.11 reference (plan 2.3). The script refuses to write +# anything from a final-sources.json that disagrees. +EXPECTED_ROWS = { + ("first-party", "runtime-recomp", "unchanged"): 482, + ("first-party", "runtime-recomp", "changed"): 139, + ("first-party", "authored-verbatim", "added"): 135, + ("first-party", "authored-modified", "added"): 45, + ("first-party", "composed-new", "added"): 36, + ("guest-derived", None, "skipped"): 6, + ("guest-transform", None, "skipped"): 40, + ("dependency-transform", "extern", "skipped"): 11, +} +EXPECTED_SUMMARY = { + "runtime_recomp_paths_changed_vs_main": 112, + "runtime_recomp_paths_identical_to_main": 394, +} + +# Shipped paths that the generic rule would get wrong. The final launcher +# includes the upstream-sync graphics page, which replaces main's dead copy. +OVERRIDES = { + "layer:upstream-sync/graphics.inl": "runtime-recomp/src/game/runtime_graphics_ui.inl", +} +# Composed headers that named a file on the configuring machine by absolute +# path. lobby-stability wrote librecomp's sp.cpp that way for the +# task_submission.cpp wrapper's macro include; librecomp's own include path +# has librecomp/src, so the bare name reaches the same file anywhere. +# (destination, absolute path inside the work tree, portable spelling) +ABSOLUTE_DEFINES = ( + ("runtime-recomp/src/game/sdk_task_source.hpp", "extern/n64-modern-runtime/librecomp/src/sp.cpp", "sp.cpp"), +) + +INCLUDE = re.compile(rb'^[ \t]*#[ \t]*include(?:_next)?[ \t]*([<"])([^>"\r\n]+)[>"]', re.M) + + +@dataclasses.dataclass(frozen=True) +class Row: + kind: str + origin: str | None + staged: Path # absolute, in the reference tree + sha256: str + original: str | None + identical_to_main: bool + + +@dataclasses.dataclass(frozen=True) +class SearchPath: + quote: tuple[Path, ...] + angle: tuple[Path, ...] + forced: tuple[Path, ...] + + +@dataclasses.dataclass +class Reference: + probe: Path # the reference tree: repo/, capsule/, cfg-*/ + build: Path # the reference build directory inside probe + rows: list[Row] + summary: dict[str, object] + compdb: dict[Path, SearchPath] + deps: dict[Path, frozenset[Path]] + layers: frozenset[str] + + +@dataclasses.dataclass(frozen=True) +class Edge: + tu: Path # reference TU + includer: Path # reference file holding the directive + line: int + spelling: str + quoted: bool + target: Path # what the reference resolved it to + + +@dataclasses.dataclass +class Plan: + destinations: dict[Path, str] # reference staged path -> repo-relative destination + contents: dict[str, bytes] # destination -> bytes to write + rewrites: dict[str, list[tuple[int, str, str]]] # destination -> (line, old, new) + variants: dict[str, list[Row]] # destination -> rows with differing bytes + problems: list[str] + notes: list[str] + report: dict[str, dict[str, object]] + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def norm(path: str | Path) -> Path: + return Path(os.path.normpath(path)) + + +def load_reference(directory: Path, root: Path) -> Reference: + data = json.loads((directory / "final-sources.json").read_text(encoding="utf-8")) + rows: list[Row] = [] + for entry in data["files"]: + rows.append( + Row( + kind=entry["kind"], + origin=entry["origin"], + staged=root / entry["final_staged_path"], + sha256=entry["sha256"], + original=entry["original_path"], + identical_to_main=bool(entry.get("identical_to_main")), + ) + ) + probe = Path(os.path.commonpath([str(row.staged) for row in rows])) + while probe.name not in ("", "build") and not (probe / "repo").is_dir(): + probe = probe.parent + builds = sorted( + { + probe / row.staged.relative_to(probe).parts[0] + for row in rows + if row.staged.relative_to(probe).parts[1:2] == ("generated",) + } + ) + if len(builds) != 1: + raise SystemExit(f"apply_closure: rows name {len(builds)} build directories under {probe}: {builds}") + compdb: dict[Path, SearchPath] = {} + for entry in json.loads((directory / "shipped-compdb.json").read_text(encoding="utf-8")): + source = norm(Path(entry["directory"]) / entry["file"]) + compdb[source] = search_path(shlex.split(entry["command"]), Path(entry["directory"])) + deps: dict[Path, frozenset[Path]] = {} + for entry in json.loads((directory / "depscan.json").read_text(encoding="utf-8")): + if entry["deps"] is not None: + deps[norm(entry["source"])] = frozenset(norm(dep) for dep in entry["deps"]) + layers = frozenset( + path.parent.relative_to(probe / "repo" / "patches").as_posix() + for path in (probe / "repo" / "patches").rglob("ReleaseOverlay.cmake") + ) + return Reference(probe, builds[0], rows, data["summary"], compdb, deps, layers) + + +def search_path(args: list[str], cwd: Path) -> SearchPath: + """GCC's include search order for one compile command (system dirs omitted).""" + iquote: list[Path] = [] + include: list[Path] = [] + isystem: list[Path] = [] + after: list[Path] = [] + forced: list[Path] = [] + flags = {"-iquote": iquote, "-I": include, "-isystem": isystem, "-idirafter": after, "-include": forced} + index = 0 + while index < len(args): + arg = args[index] + for flag, bucket in flags.items(): + if arg == flag: + bucket.append(norm(cwd / args[index + 1])) + index += 1 + break + if arg.startswith(flag) and flag in ("-I", "-isystem", "-iquote", "-idirafter"): + bucket.append(norm(cwd / arg[len(flag) :])) + break + index += 1 + system = [path for path in isystem if path not in include] + after + include = [path for path in include if path not in isystem] + return SearchPath(tuple(iquote + include + system), tuple(include + system), tuple(forced)) + + +def directives(data: bytes) -> Iterator[tuple[int, bool, str, tuple[int, int]]]: + """(line, quoted, spelling, byte span of the spelling) per #include.""" + for match in INCLUDE.finditer(data): + line = data.count(b"\n", 0, match.start()) + 1 + yield line, match.group(1) == b'"', match.group(2).decode("utf-8", "surrogateescape"), match.span(2) + + +class Resolver: + """GCC include lookup over the disk plus ``virtual`` files not written yet.""" + + def __init__(self, cache: dict[Path, bool] | None = None) -> None: + self.cache: dict[Path, bool] = cache if cache is not None else {} + self.virtual: set[Path] = set() + + def exists(self, path: Path) -> bool: + if path in self.virtual: + return True + cached = self.cache.get(path) + if cached is None: + cached = self.cache[path] = path.is_file() + return cached + + def __call__(self, spelling: str, quoted: bool, includer_dir: Path, search: SearchPath) -> Path | None: + if spelling.startswith("/"): + candidate = norm(spelling) + return candidate if self.exists(candidate) else None + dirs = ((includer_dir,) + search.quote) if quoted else search.angle + for directory in dirs: + candidate = norm(directory / spelling) + if self.exists(candidate): + return candidate + return None + + +def row_class(row: Row) -> tuple[str, str | None, str]: + if row.kind != "first-party": + return row.kind, row.origin, "skipped" + if row.origin == "runtime-recomp": + return row.kind, row.origin, "unchanged" if row.identical_to_main else "changed" + return row.kind, row.origin, "added" + + +def check_totals(reference: Reference) -> list[str]: + counts = Counter(row_class(row) for row in reference.rows) + problems = [ + f"row total {key}: {counts.get(key, 0)}, expected {value}" + for key, value in EXPECTED_ROWS.items() + if counts.get(key, 0) != value + ] + problems += [f"unexpected row class {key} ({value})" for key, value in counts.items() if key not in EXPECTED_ROWS] + for key, value in EXPECTED_SUMMARY.items(): + if reference.summary.get(key) != value: + problems.append(f"summary {key}: {reference.summary.get(key)}, expected {value}") + return problems + + +def stage_tail(reference: Reference, row: Row) -> tuple[str, list[str]]: + """The row's stage name and its path inside the stage directory.""" + relative = row.staged.relative_to(reference.probe).parts + if relative[:2] == ("capsule", "roles"): + return f"capsule:{relative[2]}", list(relative[3:]) + if relative[0] == reference.build.name and relative[1] == "generated": + return f"layer:{relative[2]}", list(relative[3:]) + if relative[:2] == ("repo", "patches"): + return f"authored:{relative[2]}", list(relative[3:]) + raise ValueError(f"unexpected staged location {row.staged}") + + +def destination(reference: Reference, row: Row, targets: frozenset[str]) -> str: + if row.origin == "runtime-recomp": + assert row.original is not None + return row.original + stage, tail = stage_tail(reference, row) + override = OVERRIDES.get(f"{stage}/{'/'.join(tail)}") + if override: + return override + if tail[:1] == ["native"]: + tail = tail[1:] + if tail[:1] == ["includes"] and len(tail) > 2: + tail = tail[2:] + if tail[:1] and tail[0] in targets: + tail = tail[1:] + if tail[:1] and tail[0] in ("src", "include", "tests"): + return "runtime-recomp/" + "/".join(tail) + if tail[:1] and tail[0] in ("game", "renderer"): + tail = tail[1:] + if len(tail) > 1 and tail[0] != "mods": + raise ValueError(f"no destination rule for {row.staged}") + return "runtime-recomp/src/game/" + "/".join(tail) + + +class Folder: + """Maps reference-tree paths to the folded tree.""" + + def __init__(self, reference: Reference, root: Path, destinations: dict[Path, str]) -> None: + self.reference = reference + self.root = root + self.by_staged = {staged: root / dest for staged, dest in destinations.items()} + + def file(self, path: Path) -> Path | None: + """Where a referenced file lives in the folded tree; None when the fold drops it.""" + if path in self.by_staged: + return self.by_staged[path] + probe = self.reference.probe + if path.is_relative_to(probe / "repo" / "build"): + return path # prepared payloads (visual-model kernels) stay configure inputs + if path.is_relative_to(probe / "repo" / "patches"): + return None # authored layer sources live in runtime-recomp/ once folded + if path.is_relative_to(probe / "repo"): + return self.root / path.relative_to(probe / "repo") + if path.is_relative_to(probe / "capsule" / "roles"): + # Roles copy modding/ verbatim (the address-only SDK kits). + parts = path.relative_to(probe / "capsule" / "roles").parts[1:] + if parts[:1] == ("modding",) and (self.root / Path(*parts)).read_bytes() == path.read_bytes(): + return self.root / Path(*parts) + return None + if self.layer_of(path) is not None: + return None + return path + + def directory(self, path: Path) -> Path | None: + probe = self.reference.probe + if path.is_relative_to(probe / "repo" / "build"): + return path + if path.is_relative_to(probe / "repo" / "patches"): + return None + if path.is_relative_to(probe / "repo"): + return self.root / path.relative_to(probe / "repo") + if path.is_relative_to(probe / "capsule" / "roles"): + parts = path.relative_to(probe / "capsule" / "roles").parts[1:] + if parts[:1] == ("modding",): + return norm(self.root / Path(*parts)) + if parts[:1] == ("native",): + parts = parts[1:] + if parts[:1] and parts[0] in ("src", "include", "tests"): + return norm(self.root / "runtime-recomp" / Path(*parts)) + if parts[:1] == ("renderer",): + parts = parts[1:] + return norm(self.root / "runtime-recomp" / "src" / "game" / Path(*parts)) + if self.layer_of(path) is not None: + return None + return path + + def layer_of(self, path: Path) -> str | None: + generated = self.reference.build / "generated" + if not path.is_relative_to(generated): + return None + parts = path.relative_to(generated).parts + return parts[0] if parts and parts[0] in self.reference.layers else None + + def search(self, search: SearchPath) -> SearchPath: + def mapped(paths: tuple[Path, ...]) -> tuple[Path, ...]: + out: list[Path] = [] + for path in paths: + folded = self.directory(path) + if folded is not None and folded not in out: + out.append(folded) + return tuple(out) + + forced = tuple(f for f in (self.file(path) for path in search.forced) if f is not None) + return SearchPath(mapped(search.quote), mapped(search.angle), forced) + + +def edges(reference: Reference, resolver: Resolver) -> tuple[list[Edge], list[str]]: + """Every include directive each reference TU with a first-party closure followed. + + The walk goes through third-party headers too: some first-party headers are + only reached from a staged dependency header. Directives that resolve + outside the TU's dependency list are a skipped preprocessor branch or a + system header and are ignored. Headers named only through a macro + (``#include MBEDTLS_CONFIG_FILE``) are not followed; they are returned as + notes, and the preprocessed comparison (run_equivalence.sh level 2) covers + them. + """ + first_party = {row.staged for row in reference.rows if row.kind == "first-party"} + parsed: dict[Path, list[tuple[int, bool, str, tuple[int, int]]]] = {} + found: list[Edge] = [] + notes: list[str] = [] + for tu, search in sorted(reference.compdb.items()): + deps = reference.deps.get(tu) + if deps is None or not (tu in first_party or deps & first_party): + continue + seen: set[Path] = set() + stack = [tu, *search.forced] + while stack: + current = stack.pop() + if current in seen or (current != tu and current not in deps and current not in search.forced): + continue + if not resolver.exists(current): + continue # -MG names a header it could not find; nothing to walk + seen.add(current) + if current not in parsed: + parsed[current] = list(directives(current.read_bytes())) + for line, quoted, spelling, _ in parsed[current]: + target = resolver(spelling, quoted, current.parent, search) + if target is None or target not in deps: + continue + found.append(Edge(tu, current, line, spelling, quoted, target)) + stack.append(target) + missing = sorted(dep for dep in deps if dep in first_party and dep not in seen) + notes += [f"{tu}: reached only through a macro include: {dep}" for dep in missing] + return found, notes + + +def load_variants(root: Path) -> dict[str, dict[str, object]]: + path = root / VARIANTS + if not path.exists(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + return dict(data["headers"]) + + +def tu_name(root: Path, folder: Folder, tu: Path) -> str: + """A TU as header-variants.json names it: its folded repo path, + ``staged:/...`` for a guest or dependency copy a layer staged (fold + steps B3, B4), or ``payload:/`` for a prepared-input TU.""" + if tu in folder.by_staged: + return folder.by_staged[tu].relative_to(root).as_posix() + for index, part in enumerate(tu.parts): + if re.fullmatch(r"(owned|model|generated)-v\d+", part): + return "payload:" + "/".join(tu.parts[index:]) + generated = folder.reference.build / "generated" + if tu.is_relative_to(generated): + return "staged:" + tu.relative_to(generated).as_posix() + folded = folder.file(tu) + if folded is not None and folded.is_relative_to(root): + return folded.relative_to(root).as_posix() + return tu.as_posix() + + +def forwards_to(row: Row, targets: list[Row]) -> bool: + """A forwarding header: ``#pragma once`` plus one include of one of ``targets``.""" + lines = [line.strip() for line in row.staged.read_bytes().splitlines() if line.strip()] + return any(lines == [b"#pragma once", b'#include "' + str(t.staged).encode() + b'"'] for t in targets) + + +def variant_report( + reference: Reference, root: Path, folder: Folder, variants: dict[str, list[Row]], chosen: dict[str, Row] +) -> dict[str, dict[str, object]]: + """The computed half of header-variants.json: who saw which other version.""" + users: dict[Path, set[Path]] = defaultdict(set) + for tu, deps in reference.deps.items(): + for dep in deps: + users[dep].add(tu) + report: dict[str, dict[str, object]] = {} + for dest, rows in sorted(variants.items()): + pick = chosen.get(dest) + if pick is None: + continue + others: dict[str, list[Row]] = defaultdict(list) + for row in rows: + if row.sha256 != pick.sha256: + others[row.sha256].append(row) + entries: list[dict[str, object]] = [] + for sha, group in sorted(others.items()): + tus = set[Path]().union(*(users[row.staged] for row in group)) + tus |= {row.staged for row in group if row.staged in reference.compdb} + entries.append( + { + "sha256": sha, + "from": sorted(row.staged.relative_to(reference.probe).as_posix() for row in group), + "forwards_to_chosen": all( + forwards_to(row, [r for r in rows if r.sha256 == pick.sha256]) for row in group + ), + "tus": sorted({tu_name(root, folder, tu) for tu in tus}), + } + ) + kind = "forwarder" if all(entry["forwards_to_chosen"] for entry in entries) else "split" + report[dest] = { + "kind": kind, + "chosen_from": sorted( + row.staged.relative_to(reference.probe).as_posix() for row in rows if row.sha256 == pick.sha256 + ), + "others": entries, + } + return report + + +def plan(reference: Reference, root: Path) -> Plan: + problems = check_totals(reference) + targets = frozenset( + part + for row in reference.rows + for part in row.staged.parts + if part.startswith("DKR") or part in ("rt64", "ultramodern", "librecomp") + ) + folded_rows = [row for row in reference.rows if row.kind == "first-party"] + destinations: dict[Path, str] = {} + for row in folded_rows: + try: + destinations[row.staged] = destination(reference, row, targets) + except ValueError as error: + problems.append(str(error)) + by_destination: dict[str, list[Row]] = defaultdict(list) + for row in folded_rows: + if row.staged in destinations: + by_destination[destinations[row.staged]].append(row) + choices = load_variants(root) + variants: dict[str, list[Row]] = {} + chosen: dict[str, Row] = {} + for dest, rows in sorted(by_destination.items()): + shas = {row.sha256 for row in rows} + if len(shas) == 1: + chosen[dest] = rows[0] + continue + variants[dest] = rows + pick = choices.get(dest, {}).get("chosen_sha256") + match = [row for row in rows if row.sha256 == pick] + if not match: + problems.append(f"{dest}: {len(shas)} shipped versions and no decision in {VARIANTS}") + continue + chosen[dest] = match[0] + problems += [f"{VARIANTS}: {dest} has one shipped version" for dest in choices if dest not in variants] + + # Folded tree as it will exist: the work tree plus every destination. + resolver = Resolver() + folder = Folder(reference, root, destinations) + reference_edges, notes = edges(reference, resolver) + folded = Resolver(resolver.cache) + folded.virtual = {root / dest for dest in chosen} + report = variant_report(reference, root, folder, variants, chosen) + for dest, computed in report.items(): + recorded = {key: value for key, value in choices.get(dest, {}).items() if key in computed} + if recorded != computed: + problems.append(f"{VARIANTS}: {dest} is stale (rerun with --write-variants)") + + contents: dict[str, bytes] = {dest: row.staged.read_bytes() for dest, row in chosen.items()} + wanted: dict[tuple[str, int], dict[str, set[Path]]] = defaultdict(lambda: defaultdict(set)) + deferred: set[str] = set() + for edge in reference_edges: + includer_dest = destinations.get(edge.includer) + expected = folder.file(edge.target) + if includer_dest is None: + # A third-party includer: it keeps its bytes, so a first-party + # header it reaches must be found through the include path alone. + includer = folder.file(edge.includer) + if edge.target not in destinations: + continue + if includer is None: + deferred.add(f"{edge.includer}: staged by a layer, includes {expected} (fold step B3 or B4)") + continue + search = folder.search(reference.compdb[edge.tu]) + actual = folded(edge.spelling, edge.quoted, includer.parent, search) + if actual != expected: + problems.append(f"{includer}:{edge.line}: {edge.spelling} reaches {actual}, not {expected}") + continue + if chosen.get(includer_dest) is None or chosen[includer_dest].staged != edge.includer: + continue # a non-chosen variant's directives; its TUs are listed in header-variants.json + if expected is None: + problems.append( + f"{edge.includer}:{edge.line}: {edge.spelling} resolves to {edge.target}, dropped by the fold" + ) + continue + search = folder.search(reference.compdb[edge.tu]) + actual = folded(edge.spelling, edge.quoted, (root / includer_dest).parent, search) + spelling = edge.spelling + if actual != expected: + spelling = os.path.relpath(expected, (root / includer_dest).parent) + if not edge.quoted or spelling.startswith("/") or expected.is_relative_to(reference.probe): + problems.append(f"{includer_dest}:{edge.line}: cannot respell <{edge.spelling}> to reach {expected}") + continue + wanted[(includer_dest, edge.line)][spelling].add(edge.tu) + notes += sorted(deferred) + + # Absolute staged includes inside branches no Linux TU takes (iOS, Android, + # Windows) have no edge above. Map them by path instead. + for dest, data in contents.items(): + for line, quoted, spelling, _ in directives(data): + if (dest, line) in wanted or not spelling.startswith(str(reference.probe) + "/"): + continue + target = norm(spelling) + mapped = folder.file(target) + if mapped is None and target.is_relative_to(reference.probe / "capsule" / "roles"): + # Not in the Linux closure: a role copy of a file the tree already has. + parent = folder.directory(target.parent) + candidate = None if parent is None else parent / target.name + if candidate is not None and candidate.is_file() and candidate.read_bytes() == target.read_bytes(): + mapped = candidate + if mapped is None or not quoted: + problems.append(f"{dest}:{line}: no folded file matches {spelling}") + continue + wanted[(dest, line)][os.path.relpath(mapped, (root / dest).parent)].add(target) + + rewrites: dict[str, list[tuple[int, str, str]]] = defaultdict(list) + for (dest, line), spellings in sorted(wanted.items()): + if len(spellings) > 1: + detail = "; ".join(f"{s!r} for {len(tus)} TUs" for s, tus in spellings.items()) + problems.append(f"{dest}:{line}: TUs need different spellings: {detail}") + continue + (spelling,) = spellings + data = contents[dest] + for number, _, old, (start, end) in directives(data): + if number == line and old != spelling: + rewrites[dest].append((line, old, spelling)) + for dest, changes in rewrites.items(): + data = contents[dest] + by_line = {line: new for line, _, new in changes} + pieces: list[bytes] = [] + cursor = 0 + for number, _, _, (start, end) in directives(data): + if number in by_line: + pieces += [data[cursor:start], by_line[number].encode("utf-8")] + cursor = end + pieces.append(data[cursor:]) + contents[dest] = b"".join(pieces) + for dest, absolute, portable in ABSOLUTE_DEFINES: + pattern = re.compile(rb'"/[^"\n]*/' + re.escape(absolute.encode()) + rb'"') + data, count = pattern.subn(b'"' + portable.encode() + b'"', contents.get(dest, b"")) + if count != 1: + problems.append(f"{dest}: expected one absolute .../{absolute}, found {count}") + continue + contents[dest] = data + # .gitattributes stores runtime-recomp text as LF (``* text=auto eol=lf``), + # so stray CRLF lines in a staged copy (the character-portrait font files) + # would not survive a commit anyway. Write what git will keep. + for dest, data in contents.items(): + if b"\r\n" in data: + contents[dest] = data.replace(b"\r\n", b"\n") + notes.append(f"{dest}: CRLF lines written as LF (.gitattributes eol=lf)") + return Plan(destinations, contents, dict(rewrites), variants, problems, notes, report) + + +def released_bases(root: Path, written: dict[str, bytes]) -> list[str]: + """Capsule bases whose pin the folded bytes break (scripts/check_pinned_bytes.py).""" + return sorted( + base + for base, (pin, _) in check_pinned_bytes.base_pins(root).items() + if base in written and sha256(written[base].replace(b"\r\n", b"\n")) != pin + ) + + +def write_variants(root: Path, report: dict[str, dict[str, object]]) -> None: + """Keep each decision (chosen_sha256, reason) and recompute the rest.""" + decisions = load_variants(root) + headers: dict[str, dict[str, object]] = {} + for dest, computed in report.items(): + decision = decisions.get(dest, {}) + headers[dest] = { + "chosen_sha256": decision.get("chosen_sha256"), + "reason": decision.get("reason", ""), + **computed, + } + document = { + "schema": 1, + "about": "Headers the v1.0.11 binaries held in more than one version, the one the fold keeps, " + "and the TUs that compiled against another one. Written by scripts/fold/apply_closure.py " + "--write-variants; only chosen_sha256 and reason are edited by hand. " + "See docs/fold-in/header-decisions.md.", + "headers": headers, + } + (root / VARIANTS).write_text(json.dumps(document, indent=1) + "\n", encoding="utf-8") + + +def summary(reference: Reference, result: Plan | None) -> str: + counts = Counter(row_class(row) for row in reference.rows) + lines = ["| Class | Rows | Fold action |", "|---|---|---|"] + actions = { + "unchanged": "nothing", + "changed": "write final bytes to the original path", + "added": "write final bytes to runtime-recomp/ (shipped path)", + "skipped": "not folded as a file (B3 dependency patches, B4 guest hooks)", + } + for key in EXPECTED_ROWS: + kind, origin, state = key + lines.append(f"| {kind} {origin or ''} {state} | {counts.get(key, 0)} | {actions[state]} |") + if result is not None: + lines.append("") + lines.append(f"destinations: {len(set(result.destinations.values()))}") + lines.append(f"files to write: {len(result.contents)}") + lines.append(f"files with respelled includes: {len(result.rewrites)}") + lines.append(f"directives respelled: {sum(len(v) for v in result.rewrites.values())}") + lines.append(f"multi-version headers: {len(result.variants)}") + lines.append(f"notes: {len(result.notes)} (--verbose lists them)") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument("--reference", type=Path, required=True, help="directory holding final-sources.json") + parser.add_argument("--root", type=Path, default=ROOT, help="repository to write into") + parser.add_argument("--reference-root", type=Path, help="what final_staged_path is relative to (default --root)") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--summary", action="store_true", help="print the row table and stop") + mode.add_argument("--check", action="store_true", help="resolve everything and report, write nothing") + parser.add_argument("--verbose", action="store_true", help="list every respelled include") + parser.add_argument( + "--write-variants", action="store_true", help=f"refresh the computed fields of {VARIANTS} and stop" + ) + args = parser.parse_args(argv) + root: Path = args.root.resolve() + reference = load_reference(args.reference, (args.reference_root or root).resolve()) + if args.summary: + print(summary(reference, None)) + problems = check_totals(reference) + for problem in problems: + print(problem, file=sys.stderr) + return 1 if problems else 0 + result = plan(reference, root) + if args.write_variants: + write_variants(root, result.report) + return 0 + print(summary(reference, result)) + if args.verbose: + for dest, changes in sorted(result.rewrites.items()): + for line, old, new in changes: + print(f"{dest}:{line}: {old} -> {new}") + for note in result.notes: + print(f"note: {note}") + for problem in result.problems: + print(problem, file=sys.stderr) + if result.problems: + print(f"apply_closure: {len(result.problems)} problems, nothing written", file=sys.stderr) + return 1 + if args.check: + return 0 + released = released_bases(root, result.contents) + written = 0 + for dest, data in sorted(result.contents.items()): + target = root / dest + if target.exists() and target.read_bytes() == data: + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + written += 1 + (root / RELEASED_BASES).write_text("".join(f"{base}\n" for base in released), encoding="utf-8") + print(f"{len(result.contents)} files in the closure, {written} written; {len(released)} capsule bases released") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fold/header-variants.json b/scripts/fold/header-variants.json new file mode 100644 index 00000000..ea3bd6e5 --- /dev/null +++ b/scripts/fold/header-variants.json @@ -0,0 +1,442 @@ +{ + "schema": 1, + "about": "Headers the v1.0.11 binaries held in more than one version, the one the fold keeps, and the TUs that compiled against another one. Written by scripts/fold/apply_closure.py --write-variants; only chosen_sha256 and reason are edited by hand. See docs/fold-in/header-decisions.md.", + "headers": { + "runtime-recomp/src/game/mobile_graphics_preset.hpp": { + "chosen_sha256": "0df57598bb10b72429159a77a44c30d42f11855f4c903e1af14828f542a0ab2d", + "reason": "PR 68 version (upstream-sync): the launcher graphics page needs matching_preset/preset_anisotropy. rt64_renderer.cpp compiled the pre-PR 68 copy; the delta only adds and two inline functions it does not call.", + "kind": "split", + "chosen_from": [ + "cfg-ref/generated/upstream-sync/mobile_graphics_preset.hpp" + ], + "others": [ + { + "sha256": "9e55df08cd3f4d6b33b86971515e7e9105f2efd3e174f704077903b45a9ecab2", + "from": [ + "capsule/roles/SDK2_BASE/native/src/game/mobile_graphics_preset.hpp" + ], + "forwards_to_chosen": false, + "tus": [ + "runtime-recomp/src/game/rt64_renderer.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/legacy_character_materialize.hpp": { + "chosen_sha256": "7fa418bb9fd4da9b79eab21dd98876f2b05b4496330b809e17c9359bf4eef4fb", + "reason": "SDK2 version, used by 44 TUs including the defining TU legacy_character_materialize.cpp. vehicle_binding.cpp and vehicle_resources.cpp compiled main's copy (DKRCustomVehicles had runtime-recomp/src/game/mods on its include path) without source_portrait; neither names PreparedCharacter or prepare_character.", + "kind": "split", + "chosen_from": [ + "capsule/roles/SDK2/native/src/game/mods/legacy_character_materialize.hpp", + "capsule/roles/SDK2_BASE/native/src/game/mods/legacy_character_materialize.hpp" + ], + "others": [ + { + "sha256": "bd4b2f1104257de526bbe05cbfa1aac0ec0f3b528f908de55d7ac16e5e992397", + "from": [ + "repo/runtime-recomp/src/game/mods/legacy_character_materialize.hpp" + ], + "forwards_to_chosen": false, + "tus": [ + "runtime-recomp/src/game/vehicle_binding.cpp", + "runtime-recomp/src/game/vehicle_resources.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_dispatch.h": { + "chosen_sha256": "d819d4ffb0dac82ea2dbbb9b1555088b3a759e40a0ba2dfa26557a506ec593da", + "reason": "PR 66 version (upstream-sync): adds the __APPLE__ __thread DKR_CODE_TLS block that runtime_code_mods.cpp defines against. The other 155 TUs compiled the pre-PR 66 copy; off Apple the two preprocess to the same tokens.", + "kind": "split", + "chosen_from": [ + "cfg-ref/generated/upstream-sync/magic_code_dispatch.h" + ], + "others": [ + { + "sha256": "c80661027bad9b790ed71fa2a1a446a6e2b82a932396e25c9c9092caff76c8f7", + "from": [ + "capsule/roles/SDK2/native/src/game/mods/magic_code_dispatch.h", + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_dispatch.h" + ], + "forwards_to_chosen": false, + "tus": [ + "payload:owned-v77/guest_0.c", + "payload:owned-v77/guest_1.c", + "payload:owned-v77/guest_10.c", + "payload:owned-v77/guest_12.c", + "payload:owned-v77/guest_13.c", + "payload:owned-v77/guest_14.c", + "payload:owned-v77/guest_16.c", + "payload:owned-v77/guest_17.c", + "payload:owned-v77/guest_18.c", + "payload:owned-v77/guest_19.c", + "payload:owned-v77/guest_2.c", + "payload:owned-v77/guest_20.c", + "payload:owned-v77/guest_21.c", + "payload:owned-v77/guest_22.c", + "payload:owned-v77/guest_24.c", + "payload:owned-v77/guest_25.c", + "payload:owned-v77/guest_29.c", + "payload:owned-v77/guest_3.c", + "payload:owned-v77/guest_30.c", + "payload:owned-v77/guest_4.c", + "payload:owned-v77/guest_5.c", + "payload:owned-v77/guest_6.c", + "payload:owned-v77/guest_9.c", + "payload:owned-v77/magic_code_dispatch.c", + "payload:owned-v80/guest_0.c", + "payload:owned-v80/guest_1.c", + "payload:owned-v80/guest_10.c", + "payload:owned-v80/guest_12.c", + "payload:owned-v80/guest_13.c", + "payload:owned-v80/guest_14.c", + "payload:owned-v80/guest_16.c", + "payload:owned-v80/guest_17.c", + "payload:owned-v80/guest_18.c", + "payload:owned-v80/guest_19.c", + "payload:owned-v80/guest_2.c", + "payload:owned-v80/guest_20.c", + "payload:owned-v80/guest_21.c", + "payload:owned-v80/guest_22.c", + "payload:owned-v80/guest_24.c", + "payload:owned-v80/guest_25.c", + "payload:owned-v80/guest_29.c", + "payload:owned-v80/guest_3.c", + "payload:owned-v80/guest_30.c", + "payload:owned-v80/guest_4.c", + "payload:owned-v80/guest_5.c", + "payload:owned-v80/guest_6.c", + "payload:owned-v80/guest_9.c", + "payload:owned-v80/magic_code_dispatch.c", + "runtime-recomp/src/game/game_main.cpp", + "runtime-recomp/src/game/game_registration.cpp", + "runtime-recomp/src/game/mods/legacy_mod_launch.cpp", + "runtime-recomp/src/game/mods/magic_code_game_world.cpp", + "runtime-recomp/src/game/mods/online_mod_profile.cpp", + "runtime-recomp/src/game/mods/online_mod_runtime.cpp", + "runtime-recomp/src/game/offline_keep.cpp", + "runtime-recomp/src/game/replay_probe_capture.cpp", + "runtime-recomp/src/game/runtime_code_mods.cpp", + "runtime-recomp/src/game/runtime_ui.cpp", + "runtime-recomp/tests/replay_probe/probe_component.c", + "runtime-recomp/tests/replay_probe/probe_driver.cpp", + "staged:achievements/guest77/funcs_14.c", + "staged:achievements/guest80/funcs_14.c", + "staged:all-player-detail/DKRExperimentalOwnedGameV77/guest_26.c", + "staged:all-player-detail/DKRExperimentalOwnedGameV80/guest_26.c", + "staged:all-player-detail/DKRRecompiledFuncsV77/funcs_12.c", + "staged:all-player-detail/DKRRecompiledFuncsV77/funcs_31.c", + "staged:all-player-detail/DKRRecompiledFuncsV80/funcs_12.c", + "staged:all-player-detail/DKRRecompiledFuncsV80/funcs_31.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_0.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_1.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_11.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_13.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_16.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_17.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_18.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_19.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_20.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_21.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_23.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_24.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_25.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_26.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_27.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_28.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_3.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_30.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_32.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_33.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_35.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_36.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_4.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_5.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/funcs_7.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/magic_code_dispatch.c", + "staged:code-dispatch-v77-506a05229edd65b3574e608d73fd615afa346faaec3335d92fbd67318a3e699d/save_layout_functions.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_0.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_1.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_11.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_13.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_16.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_17.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_18.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_19.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_20.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_21.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_23.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_24.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_25.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_26.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_27.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_28.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_3.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_30.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_32.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_33.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_35.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_36.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_4.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_5.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/funcs_7.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/magic_code_dispatch.c", + "staged:code-dispatch-v80-86e0603f5a817ac918e91d4e075f308b86ca1445e980fbad8e90c24374404c71/save_layout_functions.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV77/guest_15.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV77/guest_23.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV77/guest_7.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV77/guest_8.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV80/guest_15.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV80/guest_23.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV80/guest_7.c", + "staged:custom-vehicles/DKRExperimentalOwnedGameV80/guest_8.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_15.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_2.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_22.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_29.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_34.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_8.c", + "staged:custom-vehicles/DKRRecompiledFuncsV77/funcs_9.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_15.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_2.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_22.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_29.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_34.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_8.c", + "staged:custom-vehicles/DKRRecompiledFuncsV80/funcs_9.c", + "staged:mod-runtime-repair/DKRExperimentalOwnedGameV77/guest_11.c", + "staged:mod-runtime-repair/DKRExperimentalOwnedGameV80/guest_11.c", + "staged:mod-runtime-repair/DKRRecompiledFuncsV77/funcs_6.c", + "staged:mod-runtime-repair/DKRRecompiledFuncsV80/funcs_6.c", + "staged:online-vehicle-menu/DKRExperimentalOwnedGameV77/guest_28.c", + "staged:online-vehicle-menu/DKRExperimentalOwnedGameV80/guest_28.c", + "staged:vehicle-seating/DKRExperimentalOwnedGameV77/guest_27.c", + "staged:vehicle-seating/DKRExperimentalOwnedGameV80/guest_27.c", + "staged:vehicle-seating/DKRRecompiledFuncsV77/funcs_10.c", + "staged:vehicle-seating/DKRRecompiledFuncsV80/funcs_10.c" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_game_services.hpp": { + "chosen_sha256": "7678891f8722a9783c102170b31ac8b8787f9acf89bee3a1569cb019c6bfa35d", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_game_services.hpp" + ], + "others": [ + { + "sha256": "4f907fadd92121a19c7e0241a8a2f8c9f9e964abe681c5050c7e2d714c3d8f7c", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_game_services.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_game_world.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_game_world.hpp": { + "chosen_sha256": "df13ed6b3fe4fdbd13a6110a2e26d6414c6f55a6a45be0b6cfca0533becd220a", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2/native/src/game/mods/magic_code_game_world.hpp", + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_game_world.hpp" + ], + "others": [ + { + "sha256": "876d0836ab82e9fcb61e84f261a4a9e31ef3d5660f036c78f7fe5237d21cddb6", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_game_world.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_game_world.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_link.hpp": { + "chosen_sha256": "560ee62b56deea565f291bda677c683d0a0fd684f808364b80ea445ccfd48e7a", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2/native/src/game/mods/magic_code_link.hpp", + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_link.hpp" + ], + "others": [ + { + "sha256": "9a275f242123fdbd6bc1ffd58501c02533b81a75cb554bebb8a7e8c1d95dfd08", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_link.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_link.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_native_arguments.hpp": { + "chosen_sha256": "35ff5aaaa7812fce8fbb2536f1df7391b1370720fc5861c5f27e9d5e599b04a1", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_native_arguments.hpp" + ], + "others": [ + { + "sha256": "61d5961edaf2044eec240dc519223e187ac445a593322488628c058133bd4682", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_native_arguments.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_game_world.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_package.hpp": { + "chosen_sha256": "0ba945c65f765bb176a88de01d26fda2a1a8b153a944ac3480042571caa56354", + "reason": "SDK2 version, used by 38 TUs including magic_code_package.cpp (through the mod-runtime-repair forwarder). vehicle_library.cpp and vehicle_package.cpp compiled main's copy without the sdk2_* Package fields.", + "kind": "split", + "chosen_from": [ + "capsule/roles/SDK2/native/src/game/mods/magic_code_package.hpp", + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_package.hpp" + ], + "others": [ + { + "sha256": "18f09343a177ada9bd446f0ab486aa29fa824ecc848fab3b4576501a180fc47b", + "from": [ + "repo/runtime-recomp/src/game/mods/magic_code_package.hpp" + ], + "forwards_to_chosen": false, + "tus": [ + "runtime-recomp/src/game/vehicle_library.cpp", + "runtime-recomp/src/game/vehicle_package.cpp" + ] + }, + { + "sha256": "7f8b7a0b90f52a719fae8eec9afbcbf2749430c1135f6cc1cf944433691f09f9", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_package.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_package.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_vm.hpp": { + "chosen_sha256": "4e17ef696382008cd230492179c0bf9637b57966e89345c2712121229e0a6f4f", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2/native/src/game/mods/magic_code_vm.hpp", + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_vm.hpp" + ], + "others": [ + { + "sha256": "70fb205773a4495ea4ae9636c222c4108abe8d90081f4d8a14b663a55afe4226", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_vm.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_game_world.cpp", + "runtime-recomp/src/game/mods/magic_code_link.cpp", + "runtime-recomp/src/game/mods/magic_code_package.cpp", + "runtime-recomp/src/game/mods/magic_code_vm.cpp", + "runtime-recomp/src/game/mods/magic_code_world.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/magic_code_world.hpp": { + "chosen_sha256": "efea94b65498d924ef0d1907dc85106198514c5d65e7541bacfc2b0545edb280", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2/native/src/game/mods/magic_code_world.hpp", + "capsule/roles/SDK2_BASE/native/src/game/mods/magic_code_world.hpp" + ], + "others": [ + { + "sha256": "98e001da4bcc36c19b29fc576cb742d3aad83c1c40a6bdade39be72c4ac2e190", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/magic_code_world.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_world.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/mods/sdk2_schema.hpp": { + "chosen_sha256": "af5027b58d21d1c3640adc21ad3f6f9df9d8c91ae6565e5ef8aa505af23198ee", + "reason": "The real header. The mod-runtime-repair copy only forwards to it by absolute path.", + "kind": "forwarder", + "chosen_from": [ + "capsule/roles/SDK2_BASE/native/src/game/mods/sdk2_schema.hpp" + ], + "others": [ + { + "sha256": "0ecfd82ab1fc40ff43a74a512c5121689a5ab713bd31be7475a7fda7e6c1f1ac", + "from": [ + "cfg-ref/generated/mod-runtime-repair/native/mods/sdk2_schema.hpp" + ], + "forwards_to_chosen": true, + "tus": [ + "runtime-recomp/src/game/mods/magic_code_package.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/resident_music.hpp": { + "chosen_sha256": "946ae39bb0dedad218bf2d8e1bc9b5f97e1f36b408dce8546d7ed308ad827276", + "reason": "music-preview-repair version, used by the defining TU resident_music.cpp. online_mod_runtime.cpp compiled the custom-vehicles copy without the PreviewScope class, which it does not use.", + "kind": "split", + "chosen_from": [ + "cfg-ref/generated/music-preview-repair/resident_music.hpp" + ], + "others": [ + { + "sha256": "ca345e512ff03cc4c085418dd720dcdd943e1afe433c4272155a77b35f41f985", + "from": [ + "cfg-ref/generated/custom-vehicles/native/resident_music.hpp" + ], + "forwards_to_chosen": false, + "tus": [ + "runtime-recomp/src/game/mods/online_mod_runtime.cpp" + ] + } + ] + }, + "runtime-recomp/src/game/save_manager.hpp": { + "chosen_sha256": "330c525c25a5597690088dcfa7bc045ec613e06020bd72aa0db40ca2dbec6bde", + "reason": "SHARED_SAVES version, used by the defining TU save_manager.cpp and four others. runtime_save_routing.cpp compiled the SDK2 copy (no CustomRecords kind, content_hash/copies or stored_adventures(limit, truncated)); it names none of them.", + "kind": "split", + "chosen_from": [ + "capsule/roles/SHARED_SAVES/native/src/game/save_manager.hpp" + ], + "others": [ + { + "sha256": "232f52ff0d9e63ae073ee016c7e9bc4d27f3c5e37013d576114bd0c8033cafab", + "from": [ + "capsule/roles/SDK2/native/src/game/save_manager.hpp" + ], + "forwards_to_chosen": false, + "tus": [ + "runtime-recomp/src/game/runtime_save_routing.cpp" + ] + } + ] + } + } +} diff --git a/scripts/pattern_guard.py b/scripts/pattern_guard.py index 1c44fa02..90b4f85a 100644 --- a/scripts/pattern_guard.py +++ b/scripts/pattern_guard.py @@ -182,6 +182,9 @@ class Rule: "scripts/gen_guest_symbols.py": "writes new symbol headers", "scripts/guest_hooks.py": "applies declared hooks to generated guest code", "scripts/generate_replay_probe.py": "reads first-party headers only to collect probe symbol names", + # Fold-only (step B1): writes the shipped closure into runtime-recomp/ once, + # respelling staged #include paths. Deleted with scripts/fold/ after C11. + "scripts/fold/apply_closure.py": "writes the shipped v1.0.11 closure back into the tree once", } GUEST_REWRITE_ALLOWED = {"scripts/guest_hooks.py", "runtime-recomp/cmake/GuestHooks.cmake"} IDENTITY_ALLOWED_SUFFIX = "netplay/legacy_identity.hpp" From 50965765f03f6c005316406af3c439e1b9822ffd Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 17:13:33 -0500 Subject: [PATCH 02/62] Write the shipped v1.0.11 first-party sources into runtime-recomp Output of scripts/fold/apply_closure.py against the v1.0.11 reference: 101 changed files at their original paths, 206 new files at their shipped paths (flat), staged absolute includes respelled relative. Guest and dependency transforms are left for B3/B4. --- architecture/pattern-guard-baseline.txt | 24 +- runtime-recomp/include/dkr/presentation.h | 122 + runtime-recomp/src/game/account_bind.inl | 32 + .../src/game/achievement_catalogue.cpp | 41 + .../src/game/achievement_symbols.hpp | 6 + runtime-recomp/src/game/achievement_ui.inl | 182 ++ runtime-recomp/src/game/achievements.cpp | 228 ++ runtime-recomp/src/game/achievements.hpp | 54 + .../src/game/admission_contract.hpp | 43 + runtime-recomp/src/game/admission_retry.hpp | 21 + runtime-recomp/src/game/assessment.hpp | 59 + runtime-recomp/src/game/build_contract.hpp | 2 + runtime-recomp/src/game/cache_repair.hpp | 32 + runtime-recomp/src/game/camera_actions.cpp | 55 + runtime-recomp/src/game/camera_actions.hpp | 30 + runtime-recomp/src/game/camera_kernel.c | 289 +++ runtime-recomp/src/game/camera_kernel.h | 24 + runtime-recomp/src/game/camera_kernel_guest.h | 7 + .../src/game/camera_obstruction.cpp | 15 +- .../src/game/camera_presentation.cpp | 180 ++ .../src/game/camera_presentation.hpp | 42 + runtime-recomp/src/game/camera_rt64.cpp | 248 ++ runtime-recomp/src/game/camera_rt64.hpp | 57 + runtime-recomp/src/game/camera_runtime.cpp | 56 + runtime-recomp/src/game/camera_runtime.hpp | 23 + runtime-recomp/src/game/camera_scene.cpp | 103 + runtime-recomp/src/game/camera_scene.hpp | 24 + runtime-recomp/src/game/camera_session.cpp | 92 + runtime-recomp/src/game/camera_session.hpp | 43 + runtime-recomp/src/game/camera_settings.cpp | 111 + runtime-recomp/src/game/camera_settings.hpp | 62 + .../src/game/camera_shadow_kernel.h | 20 + runtime-recomp/src/game/camera_snapshot.cpp | 89 + runtime-recomp/src/game/camera_snapshot.hpp | 24 + .../src/game/checkpoint_transfer.cpp | 96 + .../src/game/checkpoint_transfer.hpp | 71 + runtime-recomp/src/game/confirmed_feed.cpp | 50 + runtime-recomp/src/game/confirmed_feed.hpp | 51 + .../src/game/confirmed_observer.cpp | 79 + .../src/game/confirmed_observer.hpp | 63 + runtime-recomp/src/game/connection_ui.inl | 45 + runtime-recomp/src/game/credits_sky_rt64.hpp | 71 + .../src/game/credits_viewport_rt64.hpp | 132 + runtime-recomp/src/game/custom_records.hpp | 57 + runtime-recomp/src/game/custom_tracks.cpp | 104 +- runtime-recomp/src/game/departure.hpp | 31 + runtime-recomp/src/game/detail_batch.inl | 34 + runtime-recomp/src/game/detail_contract.hpp | 45 + runtime-recomp/src/game/detail_metrics.hpp | 26 + runtime-recomp/src/game/detail_observer.inl | 46 + .../src/game/detail_texture_cache.inl | 49 + runtime-recomp/src/game/diagnostics.hpp | 78 + runtime-recomp/src/game/directory_tls.hpp | 61 + runtime-recomp/src/game/directory_ui.inl | 73 + .../src/game/driver_anchor_units.hpp | 15 + .../src/game/enhanced_water_registry.hpp | 72 +- .../src/game/enhanced_water_rt64.hpp | 14 +- runtime-recomp/src/game/f3ddkr_rt64.cpp | 326 ++- runtime-recomp/src/game/f3ddkr_rt64.hpp | 3 +- runtime-recomp/src/game/flow_cadence_rt64.hpp | 47 + runtime-recomp/src/game/flow_history_rt64.hpp | 72 + runtime-recomp/src/game/flow_phase_rt64.hpp | 32 + .../src/game/flow_vertex_scope_rt64.hpp | 43 + runtime-recomp/src/game/game_main.cpp | 102 +- runtime-recomp/src/game/game_registration.cpp | 25 +- .../src/game/intro_caption_rt64.hpp | 217 ++ runtime-recomp/src/game/intro_sky_rt64.hpp | 62 + .../src/game/intro_viewport_rt64.hpp | 78 + runtime-recomp/src/game/invite_handoff.cpp | 39 + runtime-recomp/src/game/invite_handoff.hpp | 29 + runtime-recomp/src/game/invite_ui.inl | 248 ++ runtime-recomp/src/game/linux_keyring.hpp | 56 + .../src/game/liquid_replacement_gate.hpp | 151 ++ runtime-recomp/src/game/lobby_adapter.cpp | 38 + runtime-recomp/src/game/lobby_adapter.hpp | 23 + runtime-recomp/src/game/lobby_context.inl | 76 + runtime-recomp/src/game/lobby_controls.inl | 200 ++ runtime-recomp/src/game/lobby_sidebar.inl | 64 + runtime-recomp/src/game/local_model_rt64.cpp | 122 +- runtime-recomp/src/game/mod_store.cpp | 2166 ++++++++++++++++ runtime-recomp/src/game/mod_store.hpp | 87 + runtime-recomp/src/game/mod_store_install.cpp | 2273 +++++++++++++++++ runtime-recomp/src/game/mod_store_install.hpp | 70 + .../src/game/mod_store_management_ui.inl | 402 +++ runtime-recomp/src/game/mod_store_names.cpp | 272 ++ runtime-recomp/src/game/mod_store_style.inl | 186 ++ runtime-recomp/src/game/mod_store_ui.inl | 783 ++++++ .../src/game/mods/legacy_asset_bus.cpp | 86 +- .../mods/legacy_character_materialize.cpp | 23 +- .../mods/legacy_character_materialize.hpp | 5 +- .../src/game/mods/legacy_mod_format.cpp | 2 +- .../src/game/mods/legacy_mod_launch.cpp | 46 +- .../src/game/mods/legacy_track_catalog.cpp | 46 +- .../game/mods/legacy_track_prepare_job.cpp | 8 +- .../game/mods/legacy_track_prepare_job.hpp | 2 +- .../game/mods/magic_code_execution_policy.hpp | 181 ++ .../src/game/mods/magic_code_game_world.cpp | 80 +- .../src/game/mods/magic_code_game_world.hpp | 45 + .../src/game/mods/magic_code_library.cpp | 14 +- .../src/game/mods/magic_code_library.hpp | 4 +- .../src/game/mods/magic_code_link.cpp | 13 +- .../src/game/mods/magic_code_package.cpp | 50 +- .../src/game/mods/magic_code_package.hpp | 7 + .../game/mods/magic_code_trophy_catalog.cpp | 35 +- .../game/mods/magic_code_trophy_catalog.hpp | 6 +- .../src/game/mods/magic_code_vm.cpp | 6 +- .../src/game/mods/magic_code_world.cpp | 89 +- .../src/game/mods/magic_code_world.hpp | 9 + .../src/game/mods/online_mod_manifest.cpp | 26 +- .../src/game/mods/online_mod_prepare.cpp | 19 +- .../src/game/mods/online_mod_profile.cpp | 134 +- .../src/game/mods/online_mod_runtime.cpp | 77 +- .../src/game/mods/online_mod_sync.cpp | 93 +- .../src/game/mods/sdk2_asset_inventory.inl | 16 + runtime-recomp/src/game/mods/sdk2_bridge.cpp | 136 + runtime-recomp/src/game/mods/sdk2_bridge.hpp | 79 + runtime-recomp/src/game/mods/sdk2_broker.inl | 73 + runtime-recomp/src/game/mods/sdk2_camera.inl | 42 + .../src/game/mods/sdk2_collision.inl | 123 + runtime-recomp/src/game/mods/sdk2_command.hpp | 17 + runtime-recomp/src/game/mods/sdk2_debug.inl | 46 + .../src/game/mods/sdk2_documents.inl | 75 + .../src/game/mods/sdk2_operations.inl | 197 ++ .../src/game/mods/sdk2_overrides.inl | 26 + .../src/game/mods/sdk2_properties.inl | 49 + runtime-recomp/src/game/mods/sdk2_reload.inl | 40 + runtime-recomp/src/game/mods/sdk2_schema.hpp | 48 + .../src/game/mods/sdk2_settings.inl | 19 + .../src/game/mods/sdk2_simulation.inl | 55 + .../src/game/mods/sdk2_tools_camera.inl | 41 + .../src/game/mods/sdk2_tools_console.inl | 85 + .../src/game/mods/sdk2_tools_documents.inl | 67 + .../src/game/mods/sdk2_tools_reload.inl | 24 + .../src/game/mods/sdk2_tools_ui.inl | 297 +++ .../src/game/mods/sdk2_tools_workspace.inl | 100 + .../src/game/mods/sdk2_track_catalogue.inl | 64 + runtime-recomp/src/game/mods/sdk2_tuning.inl | 62 + runtime-recomp/src/game/mods/sdk2_world.inl | 482 ++++ .../src/game/native_resource_safety.hpp | 32 + .../src/game/netplay/direct_session.cpp | 988 +++++-- .../netplay/experimental_confirmed_output.hpp | 8 +- .../game/netplay/experimental_draw_events.h | 6 +- .../src/game/netplay/experimental_eeprom.hpp | 12 + .../netplay/experimental_lobby_admission.cpp | 3 +- .../game/netplay/experimental_magic_codes.hpp | 8 + .../src/game/netplay/experimental_network.cpp | 8 +- .../src/game/netplay/experimental_network.hpp | 2 +- .../netplay/experimental_owner_inputs.cpp | 49 +- .../netplay/experimental_owner_inputs.hpp | 13 +- .../src/game/netplay/experimental_pak.hpp | 12 + .../src/game/netplay/experimental_pump.cpp | 2 +- .../src/game/netplay/experimental_pump.hpp | 2 + .../game/netplay/experimental_rollback.cpp | 35 +- .../game/netplay/experimental_rollback.hpp | 7 +- .../src/game/netplay/experimental_runtime.cpp | 189 +- .../src/game/netplay/experimental_session.cpp | 7 +- .../src/game/netplay/experimental_session.hpp | 2 +- .../game/netplay/experimental_timeline.cpp | 16 +- .../game/netplay/experimental_timeline.hpp | 6 +- .../src/game/netplay/netplay_protocol.cpp | 25 +- .../src/game/netplay/netplay_types.cpp | 8 +- .../src/game/netplay/netplay_types.hpp | 5 +- .../src/game/netplay/online_mod_admission.cpp | 8 +- .../src/game/netplay/online_mod_sync_link.hpp | 3 +- .../src/game/netplay/quick_join_transport.cpp | 6 +- runtime-recomp/src/game/observer_music.hpp | 21 + runtime-recomp/src/game/observer_playback.cpp | 105 + runtime-recomp/src/game/observer_playback.hpp | 58 + runtime-recomp/src/game/offline_keep.cpp | 281 ++ runtime-recomp/src/game/offline_keep.hpp | 6 + runtime-recomp/src/game/online_directory.cpp | 283 ++ runtime-recomp/src/game/online_directory.hpp | 70 + runtime-recomp/src/game/online_view_rt64.cpp | 2 +- runtime-recomp/src/game/owned_observer.cpp | 23 + runtime-recomp/src/game/owned_observer.hpp | 20 + .../src/game/owned_vehicle_calls.inl | 16 + .../src/game/owned_vehicle_menu.hpp | 10 + runtime-recomp/src/game/owner_context.inl | 54 + runtime-recomp/src/game/owner_export.cpp | 6 + runtime-recomp/src/game/owner_export.hpp | 47 + runtime-recomp/src/game/owner_pump.inl | 125 + runtime-recomp/src/game/owner_send.inl | 22 + runtime-recomp/src/game/owner_state.hpp | 43 + .../src/game/package_compatibility.hpp | 214 ++ runtime-recomp/src/game/party.cpp | 171 ++ runtime-recomp/src/game/party.hpp | 72 + runtime-recomp/src/game/party_camera.hpp | 6 + runtime-recomp/src/game/party_codec.cpp | 91 + runtime-recomp/src/game/party_codec.hpp | 33 + runtime-recomp/src/game/party_link.cpp | 101 + runtime-recomp/src/game/party_link.hpp | 119 + runtime-recomp/src/game/party_runtime.cpp | 742 ++++++ runtime-recomp/src/game/party_runtime.hpp | 27 + .../src/game/performance_observation.hpp | 65 + .../src/game/performance_switches.hpp | 17 + runtime-recomp/src/game/policy.hpp | 86 + runtime-recomp/src/game/polish_symbols.hpp | 9 + .../src/game/presentation_identity.cpp | 48 +- .../src/game/presentation_identity.hpp | 2 + .../src/game/presentation_marker_policy.hpp | 2 + .../src/game/presentation_pacer.hpp | 82 + runtime-recomp/src/game/primary_heap.h | 19 + runtime-recomp/src/game/producer_snapshot.cpp | 37 + runtime-recomp/src/game/producer_snapshot.hpp | 97 + runtime-recomp/src/game/record_store.inl | 57 + runtime-recomp/src/game/records_guest.inl | 68 + runtime-recomp/src/game/remove_member.inl | 30 + .../src/game/render_heap_addresses.hpp | 6 + .../src/game/render_heap_support.cpp | 64 + runtime-recomp/src/game/render_provenance.hpp | 57 + .../src/game/replay_probe_capture.cpp | 8 + runtime-recomp/src/game/report_ui.inl | 135 + runtime-recomp/src/game/resident_music.cpp | 113 + runtime-recomp/src/game/resident_music.hpp | 60 + runtime-recomp/src/game/reusable_flow_map.hpp | 66 + runtime-recomp/src/game/rollback_check.cpp | 237 ++ runtime-recomp/src/game/rollback_check.hpp | 48 + runtime-recomp/src/game/rollback_policy.hpp | 50 + runtime-recomp/src/game/rt64_renderer.cpp | 78 +- runtime-recomp/src/game/rt64_renderer.hpp | 4 + .../src/game/runtime_camera_tuning_ui.inl | 573 +---- runtime-recomp/src/game/runtime_code_mods.cpp | 12 +- .../src/game/runtime_code_mods_ui.inl | 8 +- .../src/game/runtime_details_ui.inl | 122 +- .../src/game/runtime_enhancements.cpp | 14 + .../src/game/runtime_graphics_ui.inl | 31 +- .../src/game/runtime_hud_layout.cpp | 21 +- runtime-recomp/src/game/runtime_input.cpp | 24 + runtime-recomp/src/game/runtime_input.hpp | 5 + .../src/game/runtime_legacy_mods.cpp | 190 +- .../src/game/runtime_legacy_mods.hpp | 4 + .../src/game/runtime_mod_library_ui.inl | 34 +- runtime-recomp/src/game/runtime_netplay.cpp | 3 +- .../src/game/runtime_online_mods_ui.inl | 53 +- runtime-recomp/src/game/runtime_online_ui.inl | 415 ++- runtime-recomp/src/game/runtime_platform.cpp | 55 +- .../src/game/runtime_rice_texture_import.cpp | 64 +- .../src/game/runtime_save_routing.cpp | 21 +- .../src/game/runtime_save_routing.hpp | 2 +- runtime-recomp/src/game/runtime_saves_ui.inl | 785 ++---- runtime-recomp/src/game/runtime_stubs.cpp | 22 +- .../src/game/runtime_texture_packs.cpp | 15 +- runtime-recomp/src/game/runtime_ui.cpp | 476 ++-- runtime-recomp/src/game/save_manager.cpp | 193 +- runtime-recomp/src/game/save_manager.hpp | 7 +- runtime-recomp/src/game/scene_load_work.hpp | 16 + runtime-recomp/src/game/scoreboard_wire.inl | 69 + runtime-recomp/src/game/sdk_task_source.hpp | 2 + .../src/game/shared_save_backend.inl | 319 +++ runtime-recomp/src/game/shared_save_jobs.inl | 26 + runtime-recomp/src/game/shared_saves.hpp | 23 + runtime-recomp/src/game/start_gate.hpp | 43 + .../src/game/store_account_page.inl | 105 + runtime-recomp/src/game/store_page_shell.inl | 172 ++ .../src/game/store_player_activity.inl | 269 ++ .../src/game/store_player_details.inl | 185 ++ .../src/game/store_player_library.inl | 361 +++ .../src/game/store_polish_controls.inl | 186 ++ .../src/game/store_polish_modals.inl | 163 ++ runtime-recomp/src/game/store_staff_pages.inl | 637 +++++ .../src/game/store_texture_adapter.inl | 79 + .../src/game/store_track_adapter.inl | 47 + .../src/game/store_track_locator.inl | 107 + runtime-recomp/src/game/store_track_zip.inl | 178 ++ .../src/game/store_vehicle_adapter.cpp | 163 ++ .../src/game/store_vehicle_adapter.hpp | 14 + runtime-recomp/src/game/task_submission.cpp | 23 + .../src/game/texture_binding_index.hpp | 31 + .../src/game/texture_capture_bridge.hpp | 19 + runtime-recomp/src/game/texture_decode.hpp | 91 + runtime-recomp/src/game/texture_dump.cpp | 544 ++++ runtime-recomp/src/game/texture_dump.hpp | 50 + runtime-recomp/src/game/texture_dump_ui.inl | 373 +++ runtime-recomp/src/game/texture_gallery.inl | 129 + runtime-recomp/src/game/title_text_rt64.hpp | 340 +++ .../src/game/vehicle_base_hashes.hpp | 67 + runtime-recomp/src/game/vehicle_binding.cpp | 31 + runtime-recomp/src/game/vehicle_binding.hpp | 12 + .../src/game/vehicle_character_masks.hpp | 78 + runtime-recomp/src/game/vehicle_library.cpp | 270 ++ runtime-recomp/src/game/vehicle_library.hpp | 46 + runtime-recomp/src/game/vehicle_online.hpp | 14 + runtime-recomp/src/game/vehicle_package.cpp | 350 +++ runtime-recomp/src/game/vehicle_package.hpp | 61 + runtime-recomp/src/game/vehicle_protocol.hpp | 15 + runtime-recomp/src/game/vehicle_resources.cpp | 280 ++ runtime-recomp/src/game/vehicle_resources.hpp | 38 + runtime-recomp/src/game/vehicle_rt64.cpp | 339 +++ runtime-recomp/src/game/vehicle_rt64.hpp | 43 + .../src/game/vehicle_seat_alignment.hpp | 36 + runtime-recomp/src/game/vehicle_store_ui.inl | 16 + runtime-recomp/src/game/vehicle_symbols.hpp | 6 + .../src/game/vehicle_texture_scope.hpp | 42 + runtime-recomp/src/game/vehicle_ui.inl | 206 ++ .../src/game/vehicle_wheel_interpolation.hpp | 170 ++ runtime-recomp/src/game/vehicle_world.cpp | 436 ++++ runtime-recomp/src/game/vehicle_world.hpp | 64 + runtime-recomp/src/game/visual_model_kernel.c | 10 +- .../src/game/water_residency_rt64.hpp | 138 + runtime-recomp/src/game/wrapped_input.inl | 198 ++ .../tests/replay_probe/owned_game.hpp | 2 + .../tests/replay_probe/probe_component.c | 11 +- .../tests/replay_probe/probe_component.h | 6 + .../tests/replay_probe/probe_driver.cpp | 214 +- .../tests/replay_probe/probe_eeprom.cpp | 2 +- .../tests/replay_probe/probe_input.cpp | 2 +- .../tests/replay_probe/probe_magic.cpp | 2 +- .../tests/replay_probe/probe_native.c | 26 +- .../tests/replay_probe/probe_pak.cpp | 2 +- scripts/fold/released-bases.txt | 99 + 310 files changed, 29994 insertions(+), 2490 deletions(-) create mode 100644 runtime-recomp/include/dkr/presentation.h create mode 100644 runtime-recomp/src/game/account_bind.inl create mode 100644 runtime-recomp/src/game/achievement_catalogue.cpp create mode 100644 runtime-recomp/src/game/achievement_symbols.hpp create mode 100644 runtime-recomp/src/game/achievement_ui.inl create mode 100644 runtime-recomp/src/game/achievements.cpp create mode 100644 runtime-recomp/src/game/achievements.hpp create mode 100644 runtime-recomp/src/game/admission_contract.hpp create mode 100644 runtime-recomp/src/game/admission_retry.hpp create mode 100644 runtime-recomp/src/game/assessment.hpp create mode 100644 runtime-recomp/src/game/build_contract.hpp create mode 100644 runtime-recomp/src/game/cache_repair.hpp create mode 100644 runtime-recomp/src/game/camera_actions.cpp create mode 100644 runtime-recomp/src/game/camera_actions.hpp create mode 100644 runtime-recomp/src/game/camera_kernel.c create mode 100644 runtime-recomp/src/game/camera_kernel.h create mode 100644 runtime-recomp/src/game/camera_kernel_guest.h create mode 100644 runtime-recomp/src/game/camera_presentation.cpp create mode 100644 runtime-recomp/src/game/camera_presentation.hpp create mode 100644 runtime-recomp/src/game/camera_rt64.cpp create mode 100644 runtime-recomp/src/game/camera_rt64.hpp create mode 100644 runtime-recomp/src/game/camera_runtime.cpp create mode 100644 runtime-recomp/src/game/camera_runtime.hpp create mode 100644 runtime-recomp/src/game/camera_scene.cpp create mode 100644 runtime-recomp/src/game/camera_scene.hpp create mode 100644 runtime-recomp/src/game/camera_session.cpp create mode 100644 runtime-recomp/src/game/camera_session.hpp create mode 100644 runtime-recomp/src/game/camera_settings.cpp create mode 100644 runtime-recomp/src/game/camera_settings.hpp create mode 100644 runtime-recomp/src/game/camera_shadow_kernel.h create mode 100644 runtime-recomp/src/game/camera_snapshot.cpp create mode 100644 runtime-recomp/src/game/camera_snapshot.hpp create mode 100644 runtime-recomp/src/game/checkpoint_transfer.cpp create mode 100644 runtime-recomp/src/game/checkpoint_transfer.hpp create mode 100644 runtime-recomp/src/game/confirmed_feed.cpp create mode 100644 runtime-recomp/src/game/confirmed_feed.hpp create mode 100644 runtime-recomp/src/game/confirmed_observer.cpp create mode 100644 runtime-recomp/src/game/confirmed_observer.hpp create mode 100644 runtime-recomp/src/game/connection_ui.inl create mode 100644 runtime-recomp/src/game/credits_sky_rt64.hpp create mode 100644 runtime-recomp/src/game/credits_viewport_rt64.hpp create mode 100644 runtime-recomp/src/game/custom_records.hpp create mode 100644 runtime-recomp/src/game/departure.hpp create mode 100644 runtime-recomp/src/game/detail_batch.inl create mode 100644 runtime-recomp/src/game/detail_contract.hpp create mode 100644 runtime-recomp/src/game/detail_metrics.hpp create mode 100644 runtime-recomp/src/game/detail_observer.inl create mode 100644 runtime-recomp/src/game/detail_texture_cache.inl create mode 100644 runtime-recomp/src/game/diagnostics.hpp create mode 100644 runtime-recomp/src/game/directory_tls.hpp create mode 100644 runtime-recomp/src/game/directory_ui.inl create mode 100644 runtime-recomp/src/game/driver_anchor_units.hpp create mode 100644 runtime-recomp/src/game/flow_cadence_rt64.hpp create mode 100644 runtime-recomp/src/game/flow_history_rt64.hpp create mode 100644 runtime-recomp/src/game/flow_phase_rt64.hpp create mode 100644 runtime-recomp/src/game/flow_vertex_scope_rt64.hpp create mode 100644 runtime-recomp/src/game/intro_caption_rt64.hpp create mode 100644 runtime-recomp/src/game/intro_sky_rt64.hpp create mode 100644 runtime-recomp/src/game/intro_viewport_rt64.hpp create mode 100644 runtime-recomp/src/game/invite_handoff.cpp create mode 100644 runtime-recomp/src/game/invite_handoff.hpp create mode 100644 runtime-recomp/src/game/invite_ui.inl create mode 100644 runtime-recomp/src/game/linux_keyring.hpp create mode 100644 runtime-recomp/src/game/liquid_replacement_gate.hpp create mode 100644 runtime-recomp/src/game/lobby_adapter.cpp create mode 100644 runtime-recomp/src/game/lobby_adapter.hpp create mode 100644 runtime-recomp/src/game/lobby_context.inl create mode 100644 runtime-recomp/src/game/lobby_controls.inl create mode 100644 runtime-recomp/src/game/lobby_sidebar.inl create mode 100644 runtime-recomp/src/game/mod_store.cpp create mode 100644 runtime-recomp/src/game/mod_store.hpp create mode 100644 runtime-recomp/src/game/mod_store_install.cpp create mode 100644 runtime-recomp/src/game/mod_store_install.hpp create mode 100644 runtime-recomp/src/game/mod_store_management_ui.inl create mode 100644 runtime-recomp/src/game/mod_store_names.cpp create mode 100644 runtime-recomp/src/game/mod_store_style.inl create mode 100644 runtime-recomp/src/game/mod_store_ui.inl create mode 100644 runtime-recomp/src/game/mods/magic_code_execution_policy.hpp create mode 100644 runtime-recomp/src/game/mods/sdk2_asset_inventory.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_bridge.cpp create mode 100644 runtime-recomp/src/game/mods/sdk2_bridge.hpp create mode 100644 runtime-recomp/src/game/mods/sdk2_broker.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_camera.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_collision.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_command.hpp create mode 100644 runtime-recomp/src/game/mods/sdk2_debug.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_documents.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_operations.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_overrides.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_properties.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_reload.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_schema.hpp create mode 100644 runtime-recomp/src/game/mods/sdk2_settings.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_simulation.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tools_camera.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tools_console.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tools_documents.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tools_reload.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tools_ui.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tools_workspace.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_track_catalogue.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_tuning.inl create mode 100644 runtime-recomp/src/game/mods/sdk2_world.inl create mode 100644 runtime-recomp/src/game/native_resource_safety.hpp create mode 100644 runtime-recomp/src/game/observer_music.hpp create mode 100644 runtime-recomp/src/game/observer_playback.cpp create mode 100644 runtime-recomp/src/game/observer_playback.hpp create mode 100644 runtime-recomp/src/game/offline_keep.cpp create mode 100644 runtime-recomp/src/game/offline_keep.hpp create mode 100644 runtime-recomp/src/game/online_directory.cpp create mode 100644 runtime-recomp/src/game/online_directory.hpp create mode 100644 runtime-recomp/src/game/owned_observer.cpp create mode 100644 runtime-recomp/src/game/owned_observer.hpp create mode 100644 runtime-recomp/src/game/owned_vehicle_calls.inl create mode 100644 runtime-recomp/src/game/owned_vehicle_menu.hpp create mode 100644 runtime-recomp/src/game/owner_context.inl create mode 100644 runtime-recomp/src/game/owner_export.cpp create mode 100644 runtime-recomp/src/game/owner_export.hpp create mode 100644 runtime-recomp/src/game/owner_pump.inl create mode 100644 runtime-recomp/src/game/owner_send.inl create mode 100644 runtime-recomp/src/game/owner_state.hpp create mode 100644 runtime-recomp/src/game/package_compatibility.hpp create mode 100644 runtime-recomp/src/game/party.cpp create mode 100644 runtime-recomp/src/game/party.hpp create mode 100644 runtime-recomp/src/game/party_camera.hpp create mode 100644 runtime-recomp/src/game/party_codec.cpp create mode 100644 runtime-recomp/src/game/party_codec.hpp create mode 100644 runtime-recomp/src/game/party_link.cpp create mode 100644 runtime-recomp/src/game/party_link.hpp create mode 100644 runtime-recomp/src/game/party_runtime.cpp create mode 100644 runtime-recomp/src/game/party_runtime.hpp create mode 100644 runtime-recomp/src/game/performance_observation.hpp create mode 100644 runtime-recomp/src/game/performance_switches.hpp create mode 100644 runtime-recomp/src/game/policy.hpp create mode 100644 runtime-recomp/src/game/polish_symbols.hpp create mode 100644 runtime-recomp/src/game/presentation_pacer.hpp create mode 100644 runtime-recomp/src/game/primary_heap.h create mode 100644 runtime-recomp/src/game/producer_snapshot.cpp create mode 100644 runtime-recomp/src/game/producer_snapshot.hpp create mode 100644 runtime-recomp/src/game/record_store.inl create mode 100644 runtime-recomp/src/game/records_guest.inl create mode 100644 runtime-recomp/src/game/remove_member.inl create mode 100644 runtime-recomp/src/game/render_heap_addresses.hpp create mode 100644 runtime-recomp/src/game/render_heap_support.cpp create mode 100644 runtime-recomp/src/game/render_provenance.hpp create mode 100644 runtime-recomp/src/game/report_ui.inl create mode 100644 runtime-recomp/src/game/resident_music.cpp create mode 100644 runtime-recomp/src/game/resident_music.hpp create mode 100644 runtime-recomp/src/game/reusable_flow_map.hpp create mode 100644 runtime-recomp/src/game/rollback_check.cpp create mode 100644 runtime-recomp/src/game/rollback_check.hpp create mode 100644 runtime-recomp/src/game/rollback_policy.hpp create mode 100644 runtime-recomp/src/game/scene_load_work.hpp create mode 100644 runtime-recomp/src/game/scoreboard_wire.inl create mode 100644 runtime-recomp/src/game/sdk_task_source.hpp create mode 100644 runtime-recomp/src/game/shared_save_backend.inl create mode 100644 runtime-recomp/src/game/shared_save_jobs.inl create mode 100644 runtime-recomp/src/game/shared_saves.hpp create mode 100644 runtime-recomp/src/game/start_gate.hpp create mode 100644 runtime-recomp/src/game/store_account_page.inl create mode 100644 runtime-recomp/src/game/store_page_shell.inl create mode 100644 runtime-recomp/src/game/store_player_activity.inl create mode 100644 runtime-recomp/src/game/store_player_details.inl create mode 100644 runtime-recomp/src/game/store_player_library.inl create mode 100644 runtime-recomp/src/game/store_polish_controls.inl create mode 100644 runtime-recomp/src/game/store_polish_modals.inl create mode 100644 runtime-recomp/src/game/store_staff_pages.inl create mode 100644 runtime-recomp/src/game/store_texture_adapter.inl create mode 100644 runtime-recomp/src/game/store_track_adapter.inl create mode 100644 runtime-recomp/src/game/store_track_locator.inl create mode 100644 runtime-recomp/src/game/store_track_zip.inl create mode 100644 runtime-recomp/src/game/store_vehicle_adapter.cpp create mode 100644 runtime-recomp/src/game/store_vehicle_adapter.hpp create mode 100644 runtime-recomp/src/game/task_submission.cpp create mode 100644 runtime-recomp/src/game/texture_binding_index.hpp create mode 100644 runtime-recomp/src/game/texture_capture_bridge.hpp create mode 100644 runtime-recomp/src/game/texture_decode.hpp create mode 100644 runtime-recomp/src/game/texture_dump.cpp create mode 100644 runtime-recomp/src/game/texture_dump.hpp create mode 100644 runtime-recomp/src/game/texture_dump_ui.inl create mode 100644 runtime-recomp/src/game/texture_gallery.inl create mode 100644 runtime-recomp/src/game/title_text_rt64.hpp create mode 100644 runtime-recomp/src/game/vehicle_base_hashes.hpp create mode 100644 runtime-recomp/src/game/vehicle_binding.cpp create mode 100644 runtime-recomp/src/game/vehicle_binding.hpp create mode 100644 runtime-recomp/src/game/vehicle_character_masks.hpp create mode 100644 runtime-recomp/src/game/vehicle_library.cpp create mode 100644 runtime-recomp/src/game/vehicle_library.hpp create mode 100644 runtime-recomp/src/game/vehicle_online.hpp create mode 100644 runtime-recomp/src/game/vehicle_package.cpp create mode 100644 runtime-recomp/src/game/vehicle_package.hpp create mode 100644 runtime-recomp/src/game/vehicle_protocol.hpp create mode 100644 runtime-recomp/src/game/vehicle_resources.cpp create mode 100644 runtime-recomp/src/game/vehicle_resources.hpp create mode 100644 runtime-recomp/src/game/vehicle_rt64.cpp create mode 100644 runtime-recomp/src/game/vehicle_rt64.hpp create mode 100644 runtime-recomp/src/game/vehicle_seat_alignment.hpp create mode 100644 runtime-recomp/src/game/vehicle_store_ui.inl create mode 100644 runtime-recomp/src/game/vehicle_symbols.hpp create mode 100644 runtime-recomp/src/game/vehicle_texture_scope.hpp create mode 100644 runtime-recomp/src/game/vehicle_ui.inl create mode 100644 runtime-recomp/src/game/vehicle_wheel_interpolation.hpp create mode 100644 runtime-recomp/src/game/vehicle_world.cpp create mode 100644 runtime-recomp/src/game/vehicle_world.hpp create mode 100644 runtime-recomp/src/game/water_residency_rt64.hpp create mode 100644 runtime-recomp/src/game/wrapped_input.inl create mode 100644 scripts/fold/released-bases.txt diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index eb348e43..12c8ae62 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -16,7 +16,7 @@ patches/achievements/ReleaseOverlay.cmake PG002 patches/achievements/ReleaseOverlay.cmake PG003 patches/achievements/audit.py PG013 patches/achievements/configure.py PG013 -patches/achievements/inspect_rom.py PG013 +patches/achievements/inspect_rom.py PG004 patches/achievements/native/achievement_catalogue.cpp PG006 patches/achievements/native/achievement_ui.inl PG006 patches/achievements/native/achievements.cpp PG006 @@ -97,7 +97,7 @@ patches/camera-plus/native/camera_snapshot.hpp PG006 patches/camera-plus/native/camera_xr.cpp PG006 patches/camera-plus/native/camera_xr.hpp PG006 patches/camera-plus/package_playtest.py PG013 -patches/camera-plus/scene_kernel_pipeline.py PG013 +patches/camera-plus/scene_kernel_pipeline.py PG004 patches/camera-plus/sdk/dkr/presentation.h PG006 patches/camera-plus/vr_device_patch.py PG013 patches/camera-shadow/NativeOverlay.cmake PG001 @@ -290,7 +290,7 @@ patches/liquid-streaming/NativeOverlay.cmake PG003 patches/liquid-streaming/OWNER-NOTES.txt PG013 patches/liquid-streaming/liquid_replacement_gate.hpp PG006 patches/liquid-streaming/package.py PG013 -patches/liquid-streaming/pipeline.py PG013 +patches/liquid-streaming/pipeline.py PG004 patches/liquid-streaming/water_residency_rt64.hpp PG006 patches/lobby-link-repair/IMPLEMENTATION.txt PG013 patches/lobby-link-repair/PLAYTEST.txt PG013 @@ -554,7 +554,7 @@ patches/race-release-repair/audit.py PG004 patches/race-release-repair/configure.py PG013 patches/race-release-repair/handoff.py PG013 patches/race-release-repair/package.py PG013 -patches/race-release-repair/pipeline.py PG013 +patches/race-release-repair/pipeline.py PG004 patches/race-release-repair/run_checks.py PG013 patches/race-release-repair/tests/runtime_tests.cpp PG006 patches/race-release-repair/transforms.py PG013 @@ -613,7 +613,7 @@ patches/release-prep/v1.0.7/RELEASE-PLAN.txt PG013 patches/release-prep/v1.0.7/ReleaseOverlay.cmake PG001 patches/release-prep/v1.0.7/ReleaseOverlay.cmake PG002 patches/release-prep/v1.0.7/ReleaseOverlay.cmake PG003 -patches/release-prep/v1.0.7/android_store.py PG013 +patches/release-prep/v1.0.7/android_store.py PG004 patches/release-prep/v1.0.7/android_store_compat.hpp PG006 patches/release-prep/v1.0.7/audit.py PG013 patches/release-prep/v1.0.7/capsule.py PG004 @@ -891,10 +891,10 @@ patches/release-prep/v1.0.9/ReleaseOverlay.cmake PG002 patches/release-prep/v1.0.9/ReleaseOverlay.cmake PG003 patches/release-prep/v1.0.9/UPDATER.txt PG013 patches/release-prep/v1.0.9/android_account_input.py PG013 -patches/release-prep/v1.0.9/android_http.py PG013 -patches/release-prep/v1.0.9/android_shared.py PG013 -patches/release-prep/v1.0.9/android_snapshot.py PG013 -patches/release-prep/v1.0.9/android_texture.py PG013 +patches/release-prep/v1.0.9/android_http.py PG004 +patches/release-prep/v1.0.9/android_shared.py PG004 +patches/release-prep/v1.0.9/android_snapshot.py PG004 +patches/release-prep/v1.0.9/android_texture.py PG004 patches/release-prep/v1.0.9/audit.py PG004 patches/release-prep/v1.0.9/documentation/check.py PG004 patches/release-prep/v1.0.9/documentation/content.py PG013 @@ -1225,7 +1225,7 @@ patches/vehicle-original-textures/finalize.py PG013 patches/vehicle-original-textures/native/driver_anchor_units.hpp PG006 patches/vehicle-original-textures/native/package_compatibility.hpp PG006 patches/vehicle-original-textures/package.py PG013 -patches/vehicle-original-textures/pipeline.py PG013 +patches/vehicle-original-textures/pipeline.py PG004 patches/vehicle-original-textures/run_checks.py PG013 patches/vehicle-original-textures/tests/package_checks.cpp PG006 patches/vehicle-seating/FORMAT.txt PG013 @@ -1252,6 +1252,10 @@ patches/viewport-flow/pipeline.py PG004 runtime-recomp/CMakeLists.txt PG007 runtime-recomp/cmake/PerformancePatches.cmake PG007 runtime-recomp/cmake/android/PreRotation.cmake PG007 +runtime-recomp/src/game/achievement_symbols.hpp PG011 +runtime-recomp/src/game/netplay/experimental_runtime.cpp PG010 +runtime-recomp/src/game/polish_symbols.hpp PG011 +runtime-recomp/src/game/vehicle_symbols.hpp PG011 scripts/adapt-water-reference.py PG004 scripts/camera_plus_pipeline.py PG004 scripts/character_portrait_pipeline.py PG004 diff --git a/runtime-recomp/include/dkr/presentation.h b/runtime-recomp/include/dkr/presentation.h new file mode 100644 index 00000000..8264ad8a --- /dev/null +++ b/runtime-recomp/include/dkr/presentation.h @@ -0,0 +1,122 @@ +#ifndef DKR_PRESENTATION_SDK_H +#define DKR_PRESENTATION_SDK_H + +/* Presentation v1. All lengths are bytes, angles are radians, distances are + * game-world units and time is seconds. No field contains a guest/host pointer. + * The host copies these values into a PRIVATE presentation VM. This API is + * deliberately unavailable to gameplay callbacks, including online rollback. + */ +#ifdef DKR_PRESENTATION_HOST +#include +typedef std::uint32_t DkrViewU32; +typedef std::int32_t DkrViewS32; +#else +#include "magic_codes.h" +typedef DkrU32 DkrViewU32; +typedef DkrS32 DkrViewS32; +#endif + +#define DKR_PRESENTATION_VERSION 1U +enum DkrViewCapability { + DKR_VIEW_POSE=1U, DKR_VIEW_PROJECTION=2U, DKR_VIEW_REAR_COVERAGE=4U, + DKR_VIEW_ORBIT_COVERAGE=8U, DKR_VIEW_HIDE_LOCAL_MODEL=16U, + DKR_VIEW_HIDE_RIDER=32U, DKR_VIEW_STEREO=64U, + DKR_VIEW_STATIC_COLLISION=128U, DKR_VIEW_DYNAMIC_COLLISION=256U +}; +enum DkrViewState { + DKR_VIEW_DRIVING=1U, DKR_VIEW_SCRIPTED=2U, DKR_VIEW_CUT=4U, + DKR_VIEW_GROUNDED=8U, DKR_VIEW_DRIFTING=16U, DKR_VIEW_BOOSTING=32U, + DKR_VIEW_IMPACT=64U, DKR_VIEW_ONLINE=128U, DKR_VIEW_INSPECTION_ALLOWED=256U +}; +enum DkrViewAction { + DKR_VIEW_LOOK_BACK=1U, DKR_VIEW_RECENTER=2U, DKR_VIEW_CYCLE=4U, + DKR_VIEW_ZOOM_IN=8U, DKR_VIEW_ZOOM_OUT=16U, + DKR_VIEW_FIRST_PERSON=32U, DKR_VIEW_XR_RECENTER=64U +}; +enum DkrViewMode { + DKR_VIEW_ORIGINAL=0U, DKR_VIEW_CHASE=1U, DKR_VIEW_BONNET=2U, + DKR_VIEW_FIRST=3U, DKR_VIEW_COMFORT_FIRST=4U, + DKR_VIEW_CINEMATIC=5U, DKR_VIEW_ORBIT=6U, DKR_VIEW_VR_DRIVER=7U +}; +enum DkrViewVisibility { + DKR_VIEW_SHOW_MODEL=0U, DKR_VIEW_HIDE_VEHICLE=1U, DKR_VIEW_HIDE_RIDER_ONLY=2U +}; +enum DkrViewQueryStatus { + DKR_VIEW_QUERY_NONE=0U, DKR_VIEW_QUERY_HIT=1U, + DKR_VIEW_QUERY_UNSUPPORTED=2U, DKR_VIEW_QUERY_BUDGET=3U +}; + +typedef struct DkrViewSnapshot { + DkrViewU32 api,size,capabilities,flags; + DkrViewU32 scene,epoch,frame,view; + DkrViewU32 racer_id,racer_generation,vehicle,character; + float dt,time,aspect,speed; + float position[3],velocity[3]; + float yaw,pitch,roll,steer; + float original_position[3],original_yaw,original_pitch,original_roll; + float original_fov,original_near,original_far,boost; + DkrViewU32 reserved[4]; +} DkrViewSnapshot; + +typedef struct DkrViewInput { + DkrViewU32 api,size,held,pressed,released,blocked; + float orbit_x,orbit_y,zoom; + DkrViewU32 reserved[3]; +} DkrViewInput; + +typedef struct DkrViewPose { + DkrViewU32 api,size,view,mode; + DkrViewU32 racer_id,racer_generation,visibility,cut; + float position[3],yaw,pitch,roll; + float vertical_fov,near_plane,far_plane; + DkrViewU32 reserved[3]; +} DkrViewPose; + +typedef struct DkrViewQuery { + DkrViewU32 api,size,view,exclude_racer; + float origin[3],target[3],radius; + DkrViewU32 include_dynamic,reserved[2]; +} DkrViewQuery; + +typedef struct DkrViewHit { + DkrViewU32 api,size,status,reserved; + float fraction,position[3],normal[3]; + DkrViewU32 reserved2; +} DkrViewHit; + +enum DkrViewMath {DKR_VIEW_SIN=0U,DKR_VIEW_COS=1U,DKR_VIEW_ATAN2=2U, + DKR_VIEW_EXP=3U,DKR_VIEW_SQRT=4U}; + +#ifdef DKR_PRESENTATION_HOST +static_assert(sizeof(DkrViewSnapshot)==160); +static_assert(sizeof(DkrViewInput)==48); +static_assert(sizeof(DkrViewPose)==80); +static_assert(sizeof(DkrViewQuery)==56); +static_assert(sizeof(DkrViewHit)==48); +#else +/* Exactly one on_presentation callback is required. It runs once for an + * eligible local view, not once per gameplay tick or per eye. All writes are + * confined to this package's private memory and validated output structures. + * Extra callbacks, gameplay imports, raw references and native hooks are + * rejected before activation. Tracking and graphics devices stay host-owned. + */ +DKR_IMPORT DkrU32 dkr_view_snapshot(DkrViewSnapshot* out) {(void)out;return 0;} +DKR_IMPORT DkrU32 dkr_view_input(DkrViewInput* out) {(void)out;return 0;} +DKR_IMPORT DkrU32 dkr_view_submit(const DkrViewPose* pose) {(void)pose;return 0;} +DKR_IMPORT DkrU32 dkr_view_query(const DkrViewQuery* query,DkrViewHit* hit) {(void)query;(void)hit;return 0;} +/* Float bits avoid platform-dependent O32 mixed floating/integer arguments. */ +DKR_IMPORT DkrU32 dkr_view_math(DkrU32 operation,DkrU32 x,DkrU32 y) {(void)operation;(void)x;(void)y;return 0;} +static inline float dkr_view_real(DkrU32 bits) {union {DkrU32 u;float f;} v;v.u=bits;return v.f;} +static inline DkrU32 dkr_view_bits(float value) {union {DkrU32 u;float f;} v;v.f=value;return v.u;} +static inline float dkr_view_sin(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_SIN,dkr_view_bits(x),0));} +static inline float dkr_view_cos(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_COS,dkr_view_bits(x),0));} +static inline float dkr_view_atan2(float y,float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_ATAN2,dkr_view_bits(y),dkr_view_bits(x)));} +static inline float dkr_view_exp(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_EXP,dkr_view_bits(x),0));} +static inline float dkr_view_sqrt(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_SQRT,dkr_view_bits(x),0));} +_Static_assert(sizeof(DkrViewSnapshot)==160,"presentation snapshot ABI"); +_Static_assert(sizeof(DkrViewInput)==48,"presentation input ABI"); +_Static_assert(sizeof(DkrViewPose)==80,"presentation pose ABI"); +_Static_assert(sizeof(DkrViewQuery)==56,"presentation query ABI"); +_Static_assert(sizeof(DkrViewHit)==48,"presentation hit ABI"); +#endif +#endif diff --git a/runtime-recomp/src/game/account_bind.inl b/runtime-recomp/src/game/account_bind.inl new file mode 100644 index 00000000..54b6b1af --- /dev/null +++ b/runtime-recomp/src/game/account_bind.inl @@ -0,0 +1,32 @@ +// Inside Service's constructor; the account credential stays in Impl. +{ + auto& p=*impl_; + dkr::online::bind_account([&p]{ + std::lock_guard lock(p.mutex); + return dkr::online::Account{!p.token.empty()&&text(p.state.user,"status",16)=="active",p.account,text(p.state.user,"displayName",100)}; + },[&p](std::string method,std::string path,Json input,std::string instance,std::stop_token stop){ + if(!path.starts_with("/v1/online/")||path.find_first_of("\r\n\\#")!=std::string::npos|| + instance.size()!=64||!identifier(instance)||(method!="GET"&&method!="POST"))throw Error("Invalid online account request."); + std::string credential;unsigned generation; + {std::lock_guard lock(p.mutex);credential=p.token;generation=p.account;} + if(credential.empty())throw Error("Sign in with Discord to continue.",401); + Request request{std::string(Api)+path,method,method=="POST"?input.dump():std::string{},credential}; + request.online_instance=std::move(instance); + request.timeout=5000; + try{ + auto result=document(request,stop); + {std::lock_guard lock(p.mutex);if(generation!=p.account||credential!=p.token)throw Error("The signed-in account changed.",401);} + return result; + }catch(const Error& error){ + if(error.status==401){ + p.expire(credential);unsigned refresh_generation=0; + {std::lock_guard lock(p.mutex);if(p.token.empty()&&!p.device.empty()&&p.account==generation+1)refresh_generation=p.account;} + if(refresh_generation)p.queue([&p,refresh_generation](std::stop_token stop){ + {std::lock_guard lock(p.mutex);if(p.account!=refresh_generation||!p.token.empty())return;} + try{p.restore(stop);}catch(const std::exception& error){p.fail(error);} + }); + } + throw; + } + }); +} diff --git a/runtime-recomp/src/game/achievement_catalogue.cpp b/runtime-recomp/src/game/achievement_catalogue.cpp new file mode 100644 index 00000000..805947a6 --- /dev/null +++ b/runtime-recomp/src/game/achievement_catalogue.cpp @@ -0,0 +1,41 @@ +#include "achievements.hpp" +#include +#include +namespace dkr::runtime::achievements { +const std::vector& catalogue() { + static const auto result=[] { + std::vector out; + auto add=[&](std::string id,std::string title,std::string description,std::string group,Kind kind,int arg=0,int target=1,int vehicle=-1,bool mirrored=false,bool restorable=false) { + out.push_back({std::move(id),std::move(title),std::move(description),std::move(group),kind,arg,target,vehicle,mirrored,restorable}); + }; + add("first-balloon","First Balloon","Earn your first adventure balloon.","Adventure",Kind::Balloons,0,1,-1,false,true); + add("banana-ten","Banana Bunch","Hold 10 bananas at once during a race.","Racing",Kind::Power,5,10); + add("tt-amulet","Four Challenges","Complete all four adventure battle challenges.","Adventure",Kind::Amulet,0,4,-1,false,true); + add("wizpig-amulet","Ready for Wizpig","Collect all four Wizpig amulet pieces.","Adventure",Kind::Amulet,1,4,-1,false,true); + const char* key_names[]{"Ancient Lake","Crescent Island","Snowball Valley","Boulder Canyon"}; + for(int i=0;i<4;++i)add("key-"+std::to_string(i),std::string(key_names[i])+" Key","Find the hidden key in "+std::string(key_names[i])+".","Adventure",Kind::Key,i,1,-1,false,true); + add("balloons-39","Beyond the Island","Collect 39 balloons in one adventure.","Adventure",Kind::Balloons,0,39,-1,false,true); + add("balloons-47","Across the Galaxy","Collect all 47 balloons in one adventure.","Adventure",Kind::Balloons,0,47,-1,false,true); + const char* worlds[]{"Dino Domain","Sherbet Island","Snowflake Mountain","Dragon Forest","Future Fun Land"}; + for(int i=0;i<5;++i)add("trophy-"+std::to_string(i),std::string(worlds[i])+" Champion","Win the original "+std::string(worlds[i])+" trophy competition.","Trophies",Kind::Trophy,i,3,-1,false,true); + add("wizpig-one","Wizpig Defeated","Win the first Wizpig race.","Adventure",Kind::Boss,1,1,-1,false,true); + add("wizpig-two","One More for the Road","Win the second Wizpig race.","Adventure",Kind::Boss,32,1,-1,false,true); + add("mirrored-wizpig","Wizpig Strikes Twice","Win the second Wizpig race in Adventure Two.","Adventure Two",Kind::Boss,32,1,-1,true,true); + add("mirrored-47","Mirror Master","Collect all 47 balloons in Adventure Two.","Adventure Two",Kind::Balloons,0,47,-1,true,true); + const char* vehicles[]{"car","hovercraft","plane"}; + for(const auto& c:courses) { + const auto key=std::to_string(c.id);const std::string name=c.name; + add("tt-"+key,name+": T.T. Challenge","Beat T.T.'s full-race time on "+name+" using the "+vehicles[c.vehicle]+" in Time Trial mode.","T.T. Times",Kind::TimeTrial,c.id,c.tt_ticks,c.vehicle); + add("rare-"+key,name+": Developer Challenge","Beat the developer's full-race time on "+name+" using the "+vehicles[c.vehicle]+" in Time Trial mode.","Developer Times",Kind::Developer,c.id,c.developer_centiseconds,c.vehicle); + add("silver-"+key,name+": Mirror Coins","Collect all eight silver coins and finish first in the same Adventure Two race on "+name+".","Adventure Two",Kind::Silver,c.id,8,-1,true,true); + } + const char* powers[]{"Boost","Rocket","Trap","Shield","Magnet"}; + for(int i=0;i<5;++i)add("power-"+std::to_string(i),std::string(powers[i])+" Expert","Obtain the highest-level "+std::string(powers[i])+" power-up.","Racing",Kind::Power,i,2); + const char* drivers[]{"Krunch","Bumper","Tiptup","Conker","Timber","Banjo","Drumstick","Pipsy","T.T.","Diddy Kong"}; + for(int i=0;i<10;++i)add("driver-"+std::to_string(i),std::string(drivers[i])+": Five More","Earn five new balloons as "+std::string(drivers[i])+" without leaving Adventure mode, in one session.","Characters",Kind::Driver,i,5); + std::set ids;for(const auto& d:out)if(!ids.insert(d.id).second)throw std::logic_error("Duplicate achievement ID"); + if(out.size()!=94)throw std::logic_error("The built-in collection must contain all 94 achievements"); + return out; + }();return result; +} +} diff --git a/runtime-recomp/src/game/achievement_symbols.hpp b/runtime-recomp/src/game/achievement_symbols.hpp new file mode 100644 index 00000000..7d9f0fc5 --- /dev/null +++ b/runtime-recomp/src/game/achievement_symbols.hpp @@ -0,0 +1,6 @@ +#pragma once +namespace dkr::runtime::achievements { +struct Symbols {std::uint32_t settings;std::uint32_t mode;std::uint32_t menu;std::uint32_t tracks;std::uint32_t adventure_two;std::uint32_t paused;std::uint32_t slot;std::uint32_t map;std::uint32_t trial;std::uint32_t header;std::uint32_t racers;std::uint32_t tt_time;std::uint32_t staff_ghost;std::uint32_t tt_vehicle;std::uint32_t trophy_world;std::uint32_t trophy_round;}; +inline constexpr Symbols symbols77{0x80123510U,0x801234ecU,0x800df470U,0x800df4b8U,0x800df494U,0x80123515U,0x800df4ccU,0x80121164U,0x8011aef5U,0x800dc91cU,0x8011aeecU,0x8011ae80U,0x800dc734U,0x800dc728U,0x800e0fe8U,0x800e0fecU}; +inline constexpr Symbols symbols80{0x80123a90U,0x80123a6cU,0x800df9f0U,0x800dfa38U,0x800dfa14U,0x80123a95U,0x800dfa4cU,0x801216e4U,0x8011b475U,0x800dce8cU,0x8011b46cU,0x8011b400U,0x800dcca4U,0x800dcc98U,0x800e1568U,0x800e156cU}; +} diff --git a/runtime-recomp/src/game/achievement_ui.inl b/runtime-recomp/src/game/achievement_ui.inl new file mode 100644 index 00000000..1301abe2 --- /dev/null +++ b/runtime-recomp/src/game/achievement_ui.inl @@ -0,0 +1,182 @@ +namespace ach=dkr::runtime::achievements; +int g_achievement_filter=0,g_achievement_sort=0; +std::string g_achievement_category,g_achievement_detail; +std::array g_achievement_search{}; +bool g_achievement_settings=false,g_achievement_reset=false,g_achievement_recognise=false; +bool g_achievement_focus_detail=false; +std::string g_achievement_focus_row; +float g_achievement_return_scroll=0; +ach::Toast g_achievement_toast; +double g_achievement_toast_started=0; +SDL_AudioDeviceID g_achievement_audio=0; + +void AchievementChime() { + if(!g_achievement_audio) { + SDL_AudioSpec wanted{},actual{};wanted.freq=48000;wanted.format=AUDIO_F32SYS;wanted.channels=2;wanted.samples=512; + g_achievement_audio=SDL_OpenAudioDevice(nullptr,0,&wanted,&actual,0); + if(!g_achievement_audio)return; + SDL_PauseAudioDevice(g_achievement_audio,0); + } + std::vector samples(48000*2*3/5); + const float volume=.10F*dkr::runtime::audio::sound_effects_volume(); + for(unsigned i=0;i{523.25F,659.25F,783.99F})value+=std::sin(t*6.2831853F*tone); + samples[2*i]=samples[2*i+1]=value*envelope*volume/3.F; + } + SDL_ClearQueuedAudio(g_achievement_audio);SDL_QueueAudio(g_achievement_audio,samples.data(),Uint32(samples.size()*sizeof(float))); +} +void ReleaseAchievementAudio(){if(g_achievement_audio){SDL_CloseAudioDevice(g_achievement_audio);g_achievement_audio=0;}} +const char* AchievementBadge(const ach::Definition& d) { + switch(d.kind){case ach::Kind::TimeTrial:return "TT";case ach::Kind::Developer:return "DEV"; + case ach::Kind::Silver:return "SC";case ach::Kind::Driver:return "5";case ach::Kind::Key:return "K"; + case ach::Kind::Trophy:return "1";case ach::Kind::Boss:return "W";case ach::Kind::Amulet:return "4"; + case ach::Kind::Power:return "+";default:return "B";} +} +void DrawAchievementBadge(ImVec2 at,float size,const char* text,bool unlocked) { + auto* draw=ImGui::GetWindowDrawList();const ImVec2 center{at.x+size*.5F,at.y+size*.5F}; + draw->AddCircleFilled(center,size*.48F,PaddockRgb(unlocked?0x0CA68F:0x263E4C)); + draw->AddCircle(center,size*.38F,PaddockRgb(unlocked?0xFFCF68:0x718990),0,1.5F); + const auto type=PaddockReading(size*.27F,true,1.F); + const auto width=PaddockMeasure(type,text); + draw->AddText(type.font,type.size,{center.x-width*.5F,center.y-type.size*.5F},PaddockRgb(unlocked?0xFFF6DA:0xB0C9CC),text); +} +bool AchievementToastVisible(){return !g_achievement_toast.id.empty()||ach::notification_active()||!ach::view()->pinned.empty();} +void DrawPinnedAchievement() { + const auto state=ach::view();if(state->pinned.empty())return; + const auto& definitions=ach::catalogue();const auto found=std::find_if(definitions.begin(),definitions.end(),[&](auto& d){return d.id==state->pinned;});if(found==definitions.end())return; + const auto& entry=state->entries[std::size_t(found-definitions.begin())];const auto* viewport=ImGui::GetMainViewport(); + const float width=std::min(330.F,viewport->WorkSize.x-32.F);ImGui::SetNextWindowPos({viewport->WorkPos.x+16.F,viewport->WorkPos.y+16.F}); + ImGui::SetNextWindowSize({width,0});ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,12.F);ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,{14.F,12.F});ImGui::PushStyleColor(ImGuiCol_WindowBg,PaddockRgb(0x0A202D,238)); + ImGui::Begin("Pinned achievement##dkr",nullptr,ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_AlwaysAutoResize|ImGuiWindowFlags_NoInputs|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing|ImGuiWindowFlags_NoNav); + PaddockText(PaddockReading(15.F,true),PaddockRgb(0xFFCB66),found->title,width-28.F); + if(found->target>1&&found->kind!=ach::Kind::TimeTrial&&found->kind!=ach::Kind::Developer){const auto progress=std::to_string(entry.progress)+" / "+std::to_string(found->target);PaddockText(PaddockReading(14.F),PaddockRgb(0xFFF6DA),progress,width-28.F);} + else PaddockText(PaddockReading(13.F),PaddockRgb(0xB0C9CC),found->description,width-28.F); + ImGui::End();ImGui::PopStyleColor();ImGui::PopStyleVar(2); +} +void DrawAchievementToast() { + const auto state=ach::view();const auto& p=state->preferences; + if(!p.notifications){g_achievement_toast={};ach::Toast discard;while(ach::next_toast(discard)){}return;} + // Launcher and in-game overlays use separate ImGui contexts. A shared + // monotonic clock keeps a toast's lifetime correct across that handoff. + const auto time=double(SDL_GetTicks64())*.001; + if(!g_achievement_toast.id.empty()&&time-g_achievement_toast_started>=p.duration)g_achievement_toast={}; + if(g_achievement_toast.id.empty()){if(!ach::next_toast(g_achievement_toast))return;g_achievement_toast_started=time;if(p.sound)AchievementChime();} + const auto* viewport=ImGui::GetMainViewport();const auto origin=viewport->WorkPos;const auto extent=viewport->WorkSize; + const float scale=p.scale,width=std::min(420.F*scale,extent.x-32.F),padding=15.F*scale; + const auto title=PaddockReading(17.F*scale,true,1.25F);const auto label=PaddockReading(13.F*scale,false,1.1F); + const float text_width=std::max(50.F,width-88.F*scale); + const auto title_size=title.font->CalcTextSizeA(title.size,FLT_MAX,text_width,g_achievement_toast.title.c_str()); + const float height=std::max(80.F*scale,title_size.y+label.size+padding*2+5.F); + float x=p.position==0?origin.x+(extent.x-width)*.5F:origin.x+extent.x-width-20.F; + float y=p.position==2?origin.y+20.F:origin.y+extent.y-height-20.F; + const float elapsed=float(time-g_achievement_toast_started); + if(!p.reduced_motion)y+=(p.position==2?-1.F:1.F)*(1.F-PaddockEase(std::min(elapsed/.22F,1.F)))*16.F; + ImGui::SetNextWindowPos({x,y});ImGui::SetNextWindowSize({width,height}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,height*.5F);ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,{padding,padding}); + ImGui::PushStyleColor(ImGuiCol_WindowBg,PaddockRgb(0x0A202D,244));ImGui::PushStyleColor(ImGuiCol_Border,PaddockRgb(0x2C6577)); + ImGui::Begin("Achievement unlocked##dkr-toast",nullptr,ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_NoInputs|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing|ImGuiWindowFlags_NoNav|ImGuiWindowFlags_NoBringToFrontOnFocus); + DrawAchievementBadge({x+padding,y+(height-50.F*scale)*.5F},50.F*scale,"!",true); + auto* draw=ImGui::GetWindowDrawList();const ImVec2 text{x+padding+64.F*scale,y+padding}; + draw->AddText(label.font,label.size,text,PaddockRgb(0xFFCB66),"ACHIEVEMENT UNLOCKED"); + draw->AddText(title.font,title.size,{text.x,text.y+label.size+6.F},PaddockRgb(0xFFF6DA),g_achievement_toast.title.c_str(),nullptr,text_width); + ImGui::End();ImGui::PopStyleColor(2);ImGui::PopStyleVar(2); +} +void AchievementFileDialog(bool importing) { + StartDialogJob([importing]()->std::function { + // The established native folder picker keeps Android/Linux/Windows + // path handling outside the ImGui frame. Backups have one fixed name. + std::filesystem::path folder;std::string error;const bool chosen=PickFolder(folder,error); + return [folder,chosen,error,importing]{if(chosen){const auto path=folder/"DKR-R-Achievements.json";if(importing)ach::import_progress(path);else ach::export_progress(path);}else if(!error.empty())g_support_action_status=error;}; + }); +} +void DrawAchievementPreferences(float width,const ach::View& state) { + if(SettingsButton("Back to achievements")){g_achievement_settings=false;return;} + PaddockGap(18);DetailHeading("Notifications",width);auto p=state.preferences;bool changed=false; + changed|=SettingsCheck("##achievement-popups","Show unlock notifications",&p.notifications,width); + changed|=SettingsCheck("##achievement-chime","Play unlock sound",&p.sound,width); + changed|=SettingsCheck("##achievement-motion","Reduce notification motion",&p.reduced_motion,width); + SettingsLabel("Position",width); + const char* positions[]{"Bottom centre","Bottom right","Top right"}; + ImGui::SetNextItemWidth(std::min(width,300.F));changed|=ImGui::Combo("##achievement-position",&p.position,positions,3); + SettingsLabel("Size",width);ImGui::SetNextItemWidth(std::min(width,360.F));changed|=ImGui::SliderFloat("##achievement-size",&p.scale,.7F,1.6F,"%.1fx"); + SettingsLabel("Duration",width);ImGui::SetNextItemWidth(std::min(width,360.F));changed|=ImGui::SliderFloat("##achievement-duration",&p.duration,2.F,10.F,"%.0f seconds"); + if(changed)ach::preferences(p); + if(SettingsButton("Preview notification"))ach::preview(); + PaddockGap(22);DetailHeading("Your collection",width); + DetailText("Achievements stay separate from your game saves. Starting another adventure will not erase them.",width); + DumpActionRow files(width); + if(files.Button("Export backup"))AchievementFileDialog(false); + if(files.Button("Import backup"))AchievementFileDialog(true); + if(files.Button("Recognise existing progress"))g_achievement_recognise=true; + PaddockGap(16);if(SettingsButton("Reset achievement collection"))g_achievement_reset=true; + PaddockGap(18);DetailText("Challenge objectives inspired by Delmaru's Diddy Kong Racing achievement set. Local DKR-R implementation, wording and badge designs. Not connected to RetroAchievements.",width,kSetMuted); +} +void DrawAchievementsPage(float width) { + const auto state=ach::view();const auto& definitions=ach::catalogue(); + DrawPageHeading("ACHIEVEMENTS");PaddockGap(8); + DetailText("Your challenges. Your collection.",width,kSetMuted,12); + DetailHeading(std::to_string(state->unlocked)+" / 94 unlocked",width); + ImGui::ProgressBar(float(state->unlocked)/94.F,{width,7.F},"");PaddockGap(14); + if(!state->error.empty())DetailText(state->error,width,0xFFAB72); + else if(state->status!="Saved on this device")DetailText(state->status,width,kSetMuted); + if(g_achievement_settings){DrawAchievementPreferences(width,*state);} + else if(!g_achievement_detail.empty()) { + const auto found=std::find_if(definitions.begin(),definitions.end(),[](const auto& d){return d.id==g_achievement_detail;}); + if(found==definitions.end()){g_achievement_detail.clear();return;} + const auto index=std::size_t(found-definitions.begin());const auto& entry=state->entries[index]; + if(g_achievement_focus_detail){ImGui::SetKeyboardFocusHere();g_achievement_focus_detail=false;} + if(SettingsButton("Back to achievements")||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight,false)){g_achievement_focus_row=g_achievement_detail;g_achievement_detail.clear();ImGui::SetScrollY(g_achievement_return_scroll);} + PaddockGap(18);DetailHeading(found->title,width);DetailText(found->description,width); + DetailText(entry.unlocked?(entry.restored?"Recognised from existing progress":entry.modded?"Unlocked during modded play":"Unlocked") : "Not unlocked yet",width,entry.unlocked?0x42D6BC:kSetMuted); + if(entry.when){const auto time=std::time_t(entry.when);std::tm stamp{}; +#ifdef _WIN32 + gmtime_s(&stamp,&time); +#else + gmtime_r(&time,&stamp); +#endif + char when[80]{};std::strftime(when,sizeof(when),"%d %b %Y, %H:%M UTC",&stamp);DetailText(when,width,kSetMuted); + } + if(found->kind==ach::Kind::TimeTrial||found->kind==ach::Kind::Developer){const auto c=std::find_if(ach::courses.begin(),ach::courses.end(),[&](auto& c){return c.id==found->argument;});if(c!=ach::courses.end()){const auto hundredths=found->kind==ach::Kind::TimeTrial?c->tt_ticks*100/60:c->developer_centiseconds;char target[48];std::snprintf(target,sizeof(target),"Target: %d:%02d.%02d",hundredths/6000,(hundredths/100)%60,hundredths%100);DetailText(target,width);}} + else if(found->target>1){DetailText(std::to_string(entry.progress)+" / "+std::to_string(found->target),width);ImGui::ProgressBar(float(entry.progress)/float(found->target),{width,7.F},"");} + if(!entry.unlocked&&SettingsButton(state->pinned==found->id?"Unpin goal":"Pin goal"))ach::pin(state->pinned==found->id?"":found->id); + if(!entry.unlocked&&found->restorable&&entry.progress>=found->target)DetailText("This milestone is already present in your save. Settings lets you recognise existing progress explicitly.",width,kSetMuted); + if(found->kind==ach::Kind::Driver)DetailText("New balloons only. Leaving Adventure or closing the game restarts this challenge.",width,kSetMuted); + if(found->argument<65&&(found->kind==ach::Kind::Silver||found->kind==ach::Kind::Developer||found->kind==ach::Kind::TimeTrial))DetailText("Original course only. Custom courses and custom cups do not count for this goal.",width,kSetMuted); + } else { + if(ImGui::IsKeyPressed(ImGuiKey_GamepadL1,false))g_achievement_filter=(g_achievement_filter+2)%3; + if(ImGui::IsKeyPressed(ImGuiKey_GamepadR1,false))g_achievement_filter=(g_achievement_filter+1)%3; + DumpActionRow actions(width);for(int i=0;i<3;++i){const char* labels[]{"All","Locked","Unlocked"};if(actions.Button(labels[i],false,i==g_achievement_filter?SettingsButtonTone::Selected:SettingsButtonTone::Plain))g_achievement_filter=i;} + if(actions.Button("Settings"))g_achievement_settings=true; + PaddockGap(12);ImGui::SetNextItemWidth(width>=240.F?width-115.F:width);ImGui::InputTextWithHint("##achievement-search","Find an achievement...",g_achievement_search.data(),g_achievement_search.size());if(width>=240.F)ImGui::SameLine(); + if(SettingsButton("Keyboard",100.F))RequestCustomTextKeyboard("Find an achievement",g_achievement_search.data(),g_achievement_search.size(),[](const std::string& value){std::snprintf(g_achievement_search.data(),g_achievement_search.size(),"%s",value.c_str());}); + PaddockGap(10); + std::vector groups{"All categories"};for(const auto& d:definitions)if(std::find(groups.begin(),groups.end(),d.category)==groups.end())groups.push_back(d.category); + ImGui::SetNextItemWidth(std::min(width,260.F));if(ImGui::BeginCombo("##achievement-category",g_achievement_category.empty()?"All categories":g_achievement_category.c_str())){for(unsigned i=0;i entries;std::string search=g_achievement_search.data();std::transform(search.begin(),search.end(),search.begin(),[](unsigned char c){return char(std::tolower(c));}); + for(unsigned i=0;ientries[i];if((g_achievement_filter==1&&e.unlocked)||(g_achievement_filter==2&&!e.unlocked)||(!g_achievement_category.empty()&&d.category!=g_achievement_category))continue;auto text=d.title+" "+d.description;std::transform(text.begin(),text.end(),text.begin(),[](unsigned char c){return char(std::tolower(c));});if(!search.empty()&&text.find(search)==std::string::npos)continue;entries.push_back(i);} + if(g_achievement_sort)std::stable_sort(entries.begin(),entries.end(),[&](unsigned a,unsigned b){return g_achievement_sort==1?state->entries[a].when>state->entries[b].when:double(state->entries[a].progress)/definitions[a].target>double(state->entries[b].progress)/definitions[b].target;}); + PaddockGap(12);if(entries.empty())DetailText("No achievements match those filters.",width,kSetMuted); + const auto heading=PaddockReading(17.F,true,1.3F),body=PaddockReading(14.F,false,1.3F); + for(const auto index:entries){const auto& d=definitions[index];const auto& e=state->entries[index];ImGui::PushID(d.id.c_str()); + const float badge=48.F,text_width=std::max(50.F,width-88.F);const auto wrapped=body.font->CalcTextSizeA(body.size,FLT_MAX,text_width,d.description.c_str());const auto title_size=heading.font->CalcTextSizeA(heading.size,FLT_MAX,text_width,d.title.c_str());const float height=std::max(90.F,title_size.y+wrapped.y+43.F); + if(g_achievement_focus_row==d.id){ImGui::SetKeyboardFocusHere();g_achievement_focus_row.clear();} + const auto at=ImGui::GetCursorScreenPos();if(ImGui::Button("##open",{width,height})){g_achievement_detail=d.id;g_achievement_return_scroll=ImGui::GetScrollY();g_achievement_focus_detail=true;ImGui::SetScrollY(0);} + auto* draw=ImGui::GetWindowDrawList();draw->AddRectFilled(at,{at.x+width,at.y+height},PaddockRgb(0x10364A),12); + draw->AddRect(at,{at.x+width,at.y+height},PaddockRgb(ImGui::IsItemFocused()||ImGui::IsItemHovered()?0xFFCB66:0x296B70),12); + DrawAchievementBadge({at.x+12,at.y+14},badge,AchievementBadge(d),e.unlocked); + const ImVec2 text{at.x+74,at.y+12};draw->AddText(heading.font,heading.size,text,PaddockRgb(0xFFF6DA),d.title.c_str(),nullptr,text_width); + draw->AddText(body.font,body.size,{text.x,text.y+title_size.y+5},PaddockRgb(0xB0C9CC),d.description.c_str(),nullptr,text_width); + const std::string status=e.unlocked?(e.restored?"Recognised":"Unlocked"):e.progress>0?std::to_string(e.progress)+" / "+std::to_string(d.target):"Locked"; + draw->AddText(body.font,body.size,{text.x,at.y+height-body.line-8},PaddockRgb(e.unlocked?0x42D6BC:0xB0C9CC),status.c_str()); + ImGui::PopID();PaddockGap(8); + } + DetailText("A: Open B: Back LB / RB: Filter",width,kSetMuted); + } + if(!state->pinned.empty()){const auto found=std::find_if(definitions.begin(),definitions.end(),[&](auto& d){return d.id==state->pinned;});if(found!=definitions.end())DetailText("Pinned: "+found->title,width,kSetMuted);} + if(g_achievement_reset){ImGui::OpenPopup("Reset achievements##confirm");g_achievement_reset=false;} + if(g_achievement_recognise){ImGui::OpenPopup("Recognise progress##confirm");g_achievement_recognise=false;} + const float confirmation_width=std::max(140.F,std::min(520.F,ImGui::GetMainViewport()->WorkSize.x-80.F)); + if(BeginPaddockModalWindow("Reset achievements##confirm",ImGuiWindowFlags_AlwaysAutoResize)) {DetailText("Reset this achievement collection? Your game saves will not change. A backup is kept.",confirmation_width);DumpActionRow actions(confirmation_width);if(actions.Button("Reset collection")){ach::reset_collection();ImGui::CloseCurrentPopup();}if(actions.Button("Cancel"))ImGui::CloseCurrentPopup();ImGui::EndPopup();} + if(BeginPaddockModalWindow("Recognise progress##confirm",ImGuiWindowFlags_AlwaysAutoResize)) {DetailText("Recognise milestones shown by the current adventure? Time trials and session challenges still need to be played. Recognised entries are labelled, not presented as new gameplay unlocks.",confirmation_width);DumpActionRow actions(confirmation_width);if(actions.Button("Recognise progress")){ach::restore_progress();ImGui::CloseCurrentPopup();}if(actions.Button("Cancel"))ImGui::CloseCurrentPopup();ImGui::EndPopup();} +} diff --git a/runtime-recomp/src/game/achievements.cpp b/runtime-recomp/src/game/achievements.cpp new file mode 100644 index 00000000..5162d76b --- /dev/null +++ b/runtime-recomp/src/game/achievements.cpp @@ -0,0 +1,228 @@ +#include "achievements.hpp" +#include "achievement_symbols.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#include +#include +#endif +namespace dkr::runtime::achievements { +namespace { +using Json=nlohmann::json; +std::atomic online{false};std::atomic owner{0};std::atomic modified{false}; +std::atomic development{false}; +class Memory { + const std::uint8_t* ram_;std::size_t size_; +public: + Memory(const std::uint8_t* ram,std::size_t size):ram_(ram),size_(size){} + bool range(std::uint32_t p,unsigned n)const{return ram_&&p>=0x80000000U&&p<0xa0000000U&&(p&0x1fffffffU)<=size_&&n<=size_-(p&0x1fffffffU);} + unsigned get(std::uint32_t p,unsigned n=4)const {if(!range(p,n))return 0;unsigned v=0;for(unsigned i=0;i(std::chrono::system_clock::now().time_since_epoch()).count();} +Json read(const std::filesystem::path& path) { + std::error_code ec;if(!std::filesystem::exists(path,ec))return Json::object(); + if(std::filesystem::is_symlink(path)||std::filesystem::file_size(path)>512*1024)throw std::runtime_error("Achievement file is unsafe or too large."); + std::ifstream in(path,std::ios::binary);auto doc=Json::parse(in); + if(!doc.is_object()||doc.value("schema",0)!=1||!doc.contains("entries")||!doc["entries"].is_object()||doc["entries"].size()>2048)throw std::runtime_error("Achievement file has an unsupported format."); + for(const auto& [id,e]:doc["entries"].items()) { + if(id.empty()||id.size()>100||!e.is_object())throw std::runtime_error("Invalid achievement entry."); + (void)e.value("unlocked",false);(void)e.value("restored",false);(void)e.value("modded",false); + auto time=e.value("when",std::int64_t(0));auto p=e.value("progress",0); + if(time<0||p<0||p>1000000)throw std::runtime_error("Invalid achievement progress."); + } + return doc; +} +class FileLease { +#ifdef _WIN32 + HANDLE value_=INVALID_HANDLE_VALUE; +#else + int value_=-1; +#endif +public: + explicit FileLease(const std::filesystem::path& path) { + if(std::filesystem::is_symlink(path))throw std::runtime_error("Achievement storage cannot use symbolic links."); +#ifdef _WIN32 + for(unsigned n=0;n<100&&value_==INVALID_HANDLE_VALUE;++n){value_=CreateFileW(path.c_str(),GENERIC_READ|GENERIC_WRITE,0,nullptr,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL,nullptr);if(value_==INVALID_HANDLE_VALUE)std::this_thread::sleep_for(std::chrono::milliseconds(10));} + if(value_==INVALID_HANDLE_VALUE)throw std::runtime_error("Another DKR-R copy is updating achievements. Please try again."); +#else + value_=::open(path.c_str(),O_CREAT|O_RDWR|O_NOFOLLOW,0600); + if(value_<0)throw std::runtime_error("Cannot open the achievement lock."); + if(flock(value_,LOCK_EX)!=0){::close(value_);value_=-1;throw std::runtime_error("Cannot lock achievement progress.");} +#endif + } + ~FileLease(){ +#ifdef _WIN32 + if(value_!=INVALID_HANDLE_VALUE)CloseHandle(value_); +#else + if(value_>=0){flock(value_,LOCK_UN);::close(value_);} +#endif + } +}; +void atomic_write(const std::filesystem::path& path,const Json& doc,bool backup=true) { + std::filesystem::create_directories(path.parent_path()); + if(std::filesystem::is_symlink(path)||std::filesystem::is_symlink(path.parent_path()))throw std::runtime_error("Achievement storage cannot use symbolic links."); + auto pending=path;pending+=".pending"; + auto previous=path;previous+=".backup"; + if(std::filesystem::is_symlink(pending)||std::filesystem::is_symlink(previous))throw std::runtime_error("Achievement storage cannot use symbolic links."); + const auto text=doc.dump(2)+"\n"; + {std::ofstream out(pending,std::ios::binary|std::ios::trunc);out.write(text.data(),std::streamsize(text.size()));out.flush();if(!out)throw std::runtime_error("Cannot write achievement progress.");} + if(backup&&std::filesystem::exists(path)) { + // Never turn a corrupt primary into the last-known-good backup. + (void)read(path); + std::filesystem::copy_file(path,previous,std::filesystem::copy_options::overwrite_existing); + } +#ifdef _WIN32 + if(!MoveFileExW(pending.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))throw std::runtime_error("Cannot commit achievement progress."); +#else + const int file=::open(pending.c_str(),O_RDONLY|O_NOFOLLOW);if(file<0)throw std::runtime_error("Cannot flush achievement progress."); + const auto flushed=::fsync(file);::close(file);if(flushed)throw std::runtime_error("Cannot flush achievement progress."); + std::filesystem::rename(pending,path);const int directory=::open(path.parent_path().c_str(),O_RDONLY|O_DIRECTORY);if(directory>=0){::fsync(directory);::close(directory);} +#endif +} +struct Event {Sample sample;bool reset=false,is_online=false,modded=false;unsigned local=0;std::uint64_t epoch=0;std::uint32_t frame=0;}; +class Service { + std::mutex mutex_;std::condition_variable cv_;bool stopping_=false; + std::deque> jobs_;std::deque toasts_; + std::atomic> published_{std::make_shared()}; + std::thread worker_;std::filesystem::path path_;View state_;Sample previous_,latest_; + bool baseline_=false;Racer adventure_human_;std::array driver_{};std::array,10> earned_balloon_totals_; + std::uint64_t last_epoch_=~0ULL;std::uint32_t last_frame_=~0U;Json retained_=Json::object(); + void publish(){state_.unlocked=unsigned(std::count_if(state_.entries.begin(),state_.entries.end(),[](auto& e){return e.unlocked;}));published_.store(std::make_shared(state_));} + void merge(const Json& doc) { + if(doc.empty())return; + for(unsigned i=0;ivalue("progress",0),0,catalogue()[i].target)); + if(f->value("unlocked",false)&&!e.unlocked)e={true,f->value("restored",false),f->value("modded",false),f->value("when",std::int64_t(0)),e.progress}; + } + } + Json document(){auto doc=retained_;doc["schema"]=1;doc["catalogue"]="dkr-r-94-v1";doc["entries"]=doc.value("entries",Json::object()); + for(unsigned i=0;i=0&&s.slot<3; + if(!same||s.tracks||(s.mode==1&&(s.menu==0||s.menu==3||s.menu==6||s.menu==19))){adventure_human_={};driver_.fill(0);for(auto& v:earned_balloon_totals_)v.clear();} + const Racer* human=nullptr;for(const auto& r:s.racers)if(r.address&&r.player>=0&&r.player<4&&(!event.is_online||unsigned(r.player)==event.local)){human=&r;break;} + if(!human&&same)for(const auto& r:previous_.racers)if(r.address&&r.player>=0&&r.player<4&&(!event.is_online||unsigned(r.player)==event.local)){human=&r;break;} + if(human&&adventure)adventure_human_=*human; + if(!human&&adventure&&adventure_human_.address)human=&adventure_human_; + const int delta=same?s.balloons-previous_.balloons:0; + const bool balloon=adventure&&same&&human&&delta>0&&delta<=2&&previous_.mode==0; + if(balloon&&human->character>=0&&human->character<10){auto& seen=earned_balloon_totals_[human->character];for(int n=previous_.balloons+1;n<=s.balloons;++n)if(seen.insert(n).second)++driver_[human->character];} + bool changed=false; + for(unsigned i=0;i=d.target&&previous_.balloons>(d.argument*2))&3,old=(previous_.trophies>>(d.argument*2))&3;earned=adventure&&same&&human&&value==3&&old<3;break;} + case Kind::Boss:earned=adventure&&same&&human&&(!d.mirrored||s.mirrored)&&(s.bosses&d.argument)&&!(previous_.bosses&d.argument)&&previous_.mode==0;break; + case Kind::Driver:if(adventure){progress=driver_[d.argument];earned=balloon&&progress>=5;}break; + case Kind::Silver: + case Kind::TimeTrial: + case Kind::Developer: + case Kind::Power: + for(unsigned p=0;p3||(event.is_online&&unsigned(r.player)!=event.local)||s.mode!=0||s.paused)continue; + const bool finish=same_race&&r.address==old.address&&r.finished&&!old.finished&&r.total_ticks>0&&s.race_type==0; + if(d.kind==Kind::Power){if(s.race_type!=0&&s.race_type<64)continue;if(d.argument==5){progress=std::max(progress,std::min(r.bananas,10));earned|=r.bananas>=10&&same_race&&r.address==old.address&&old.bananas<10;} + else earned|=r.power==d.argument&&r.power_level>=2&&r.power_count>0&&same_race&&r.address==old.address&&(old.power!=r.power||old.power_level<2||old.power_count==0);continue;} + if(s.custom||s.level!=d.argument)continue; + if(d.kind==Kind::Silver){earned|=finish&&adventure&&s.mirrored&&!s.trial&&r.coins==8&&r.position==1;continue;} + if(!s.trial||s.mirrored||s.laps!=3||r.vehicle!=d.vehicle||!finish)continue; + if(d.kind==Kind::TimeTrial)earned|=r.total_ticks job;{std::unique_lock lock(mutex_);cv_.wait(lock,[&]{return stopping_||!jobs_.empty();});if(jobs_.empty()&&stopping_)return;job=std::move(jobs_.front());jobs_.pop_front();}try{job();}catch(const std::exception& e){state_.error=e.what();state_.status="Achievement progress needs attention";std::fprintf(stderr,"[achievements][storage] %s\n",e.what());publish();}}}){} + ~Service(){{std::lock_guard lock(mutex_);stopping_=true;}cv_.notify_one();worker_.join();} + void post(std::function job){std::lock_guard lock(mutex_);if(jobs_.size()>=8192)throw std::runtime_error("Achievement event queue is full; storage is not keeping up.");jobs_.push_back(std::move(job));cv_.notify_one();} + std::shared_ptr snapshot(){return published_.load();} + void configure(const std::filesystem::path& root){post([this,root]{if(!path_.empty())return;path_=root/"achievements"/"progress-v1.json";std::filesystem::create_directories(path_.parent_path());FileLease lease(path_.parent_path()/"progress.lock"); + Json doc;try{doc=read(path_);}catch(...){auto backup=path_;backup+=".backup";doc=read(backup);if(doc.empty())throw;auto corrupt=path_;corrupt+=".corrupt-"+std::to_string(now());std::filesystem::copy_file(path_,corrupt);atomic_write(path_,doc,false);state_.status="Recovered achievement progress from backup";} + if(!doc.empty()){retained_=doc;merge(doc);state_.pinned=doc.value("pinned",std::string{});if(doc.contains("preferences")){const auto& j=doc["preferences"];auto& p=state_.preferences;p.notifications=j.value("notifications",true);p.sound=j.value("sound",true);p.reduced_motion=j.value("reduced_motion",false);p.position=std::clamp(j.value("position",0),0,2);p.scale=std::clamp(j.value("scale",1.F),.7F,1.6F);p.duration=std::clamp(j.value("duration",5.F),2.F,10.F);}} + publish();std::fprintf(stderr,"[achievements] collection=94 local-only v1; native-event observer ready\n");});} + void sample(Event event){post([this,event]{evaluate(event);});} + void mode(bool developer){post([this,developer]{baseline_=false;state_.status=developer?"Tracking paused for developer tools; your collection is safe":"Saved on this device";publish();});} + void settings(Preferences p){post([this,p]{state_.preferences=p;state_.preferences.position=std::clamp(p.position,0,2);state_.preferences.duration=std::clamp(p.duration,2.F,10.F);state_.preferences.scale=std::clamp(p.scale,.7F,1.6F);persist();publish();});} + void pinned(std::string id){post([this,id=std::move(id)]{state_.pinned=id;persist();publish();});} + void restore(){post([this]{const auto& s=latest_;if(!s.valid||s.tracks||s.custom)throw std::runtime_error("Open a normal adventure before recognising its saved progress."); + for(unsigned i=0;i=d.target;break;case Kind::Key:yes=s.keys&(1<<(d.argument+1));break;case Kind::Amulet:yes=(d.argument?s.wizpig_amulet:s.tt_amulet)==4;break;case Kind::Trophy:yes=((s.trophies>>(2*d.argument))&3)==3;break;case Kind::Boss:yes=s.bosses&d.argument;break;case Kind::Silver:yes=s.mirrored&&d.argument<34&&(s.course_flags[d.argument]&4);break;default:break;}if(yes)award(i,true,true); + }persist();state_.status="Recognised provable adventure progress; race challenges still need gameplay";publish();});} + void reset(){post([this]{state_.entries={};state_.pinned.clear();baseline_=false;driver_.fill(0);persist(false);{std::lock_guard lock(mutex_);toasts_.clear();}publish();});} + void export_to(std::filesystem::path path){post([this,path=std::move(path)]{atomic_write(path,document());state_.status="Achievement backup exported";publish();});} + void import_from(std::filesystem::path path){post([this,path=std::move(path)]{const auto doc=read(path);if(doc.empty())throw std::runtime_error("The achievement backup is empty.");merge(doc);persist();baseline_=false;state_.status="Achievement backup merged; no game saves were changed";publish();});} + void toast_preview(){std::lock_guard lock(mutex_);toasts_.push_back({"preview","Dino Domain Champion",true});} + bool pop(Toast& toast){std::lock_guard lock(mutex_);if(toasts_.empty())return false;toast=std::move(toasts_.front());toasts_.pop_front();return true;} + bool pending(){std::lock_guard lock(mutex_);return !toasts_.empty();} +}; +Service& service(){static Service value;return value;} +std::atomic restart{true}; +} +Sample capture(const std::uint8_t* ram,std::size_t bytes,unsigned revision) noexcept { + Sample s;if(revision!=77&&revision!=80)return s; + const auto& a=revision==80?symbols80:symbols77;Memory g(ram,bytes);s.settings=g.get(a.settings); + if(!g.range(s.settings,0x118))return s; + s.valid=true;s.mode=int(g.get(a.mode));s.menu=int(g.get(a.menu));s.tracks=g.get(a.tracks)!=0;s.mirrored=g.get(a.adventure_two)!=0;s.paused=g.get(a.paused,1)!=0; + s.slot=int(g.get(a.slot));s.level=int(g.get(a.map));s.trial=g.get(a.trial,1)!=0;s.balloons=int(std::int16_t(g.get(g.get(s.settings),2))); + if(s.balloons<0||s.balloons>47){s.valid=false;return s;} + s.keys=int(g.get(s.settings+8,2));s.bosses=int(g.get(s.settings+12,2));s.trophies=int(g.get(s.settings+14,2));s.tt_amulet=int(g.get(s.settings+22,1));s.wizpig_amulet=int(g.get(s.settings+23,1)); + const auto flags=g.get(s.settings+4);if(g.range(flags,34*4))for(unsigned i=0;i<34;++i)s.course_flags[i]=g.get(flags+i*4); + s.trophy_world=int(g.get(a.trophy_world));s.trophy_round=int(g.get(a.trophy_round)); + const auto header=g.get(a.header);if(g.range(header,0x50)){s.laps=int(g.get(header+0x4b,1));s.race_type=int(g.get(header+0x4c,1));} + // Staff headers were checked against the original ROM by inspect_rom.py. + // Use the catalogue's exact native ticks: a stale ghost or a modded ghost + // must not silently move the goalposts for an original-course challenge. + const auto racers=g.get(a.racers);if(s.mode!=0||!g.range(racers,16))return s; + for(unsigned p=0;p<4;++p){const auto object=g.get(racers+p*4);if(!g.range(object,0x68)||g.get(object+0x48,2)!=1)continue; + const auto data=g.get(object+0x64);if(!g.range(data,0x224))continue;auto& r=s.racers[p];r.address=data;r.player=std::int16_t(g.get(data,2));if(r.player<0||r.player>3){r.address=0;continue;} + r.character=int(g.get(data+3,1));r.vehicle=int(g.get(data+0x1d7,1));r.bananas=int(g.get(data+0x185,1));r.power=int(g.get(data+0x172,1));r.power_count=int(g.get(data+0x173,1));r.power_level=int(g.get(data+0x174,1));r.coins=int(g.get(data+0x202,1));r.lap=int(g.get(data+0x193,1));r.finished=int(g.get(data+0x1d8,1));r.position=int(g.get(data+0x1ac,2)); + for(int lap=0;lap36000){r.total_ticks=0;break;}r.total_ticks+=int(time);} + } + return s; +} +void configure(const std::filesystem::path& root){service().configure(root);} +void begin(bool is_online,unsigned local,bool modded,bool developer)noexcept{online=is_online;owner=local;modified=modded;development=developer;restart=true;try{service().mode(developer);}catch(...) {}} +bool online_session()noexcept{return online.load();} +void submit(const Sample& sample,std::uint64_t epoch,std::uint32_t frame)noexcept{if(development)return;try{service().sample({sample,restart.exchange(false),online.load(),modified.load(),owner.load(),epoch,frame});}catch(...){std::fprintf(stderr,"[achievements] Event could not be queued; game state was not changed\n");}} +std::shared_ptr view(){return service().snapshot();} +void preferences(Preferences p){service().settings(p);}void pin(std::string id){service().pinned(std::move(id));} +void restore_progress(){service().restore();}void reset_collection(){service().reset();} +void export_progress(const std::filesystem::path& path){service().export_to(path);}void import_progress(const std::filesystem::path& path){service().import_from(path);} +void preview(){service().toast_preview();}bool next_toast(Toast& toast){return service().pop(toast);}bool notification_active(){return service().pending();} +} diff --git a/runtime-recomp/src/game/achievements.hpp b/runtime-recomp/src/game/achievements.hpp new file mode 100644 index 00000000..9368aaae --- /dev/null +++ b/runtime-recomp/src/game/achievements.hpp @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::achievements { +enum class Kind { Balloons,Key,Amulet,Trophy,Boss,Silver,TimeTrial,Developer,Power,Driver }; +struct Definition {std::string id,title,description,category;Kind kind;int argument=0,target=1,vehicle=-1;bool mirrored=false,restorable=false;}; +struct Course {int id,vehicle;const char* name;int tt_ticks,developer_centiseconds;}; +inline constexpr std::array courses{{ + {5,0,"Ancient Lake",3677,5343},{3,0,"Fossil Canyon",5411,8155}, + {29,0,"Jungle Falls",3750,5413},{7,2,"Hot Top Volcano",5443,8248}, + {13,2,"Everfrost Peak",6480,9763},{6,0,"Walrus Cove",7577,11660}, + {9,0,"Snowball Valley",3985,5781},{28,0,"Frosty Village",5964,8801}, + {8,1,"Whale Bay",4387,6411},{10,0,"Crescent Island",5590,8501}, + {4,1,"Pirate Lagoon",5314,7351},{30,0,"Treasure Caves",3938,5505}, + {20,2,"Windmill Plains",7988,12316},{18,0,"Greenwood Village",6037,9180}, + {19,1,"Boulder Canyon",7341,11115},{31,0,"Haunted Woods",4171,5825}, + {17,2,"Spacedust Alley",7570,12038},{32,0,"Darkmoon Caverns",7923,12568}, + {15,2,"Spaceport Alpha",7742,11500},{33,0,"Star City",7287,11296} +}}; +// Native 60-Hz integer ghost times, checked against the original ROM headers, +// not rounded screen timestamps or a possibly modified/stale live ghost. +struct Racer {std::uint32_t address=0;int player=-1,character=-1,vehicle=-1,bananas=0,power=-1,power_level=0,power_count=0,coins=0,finished=0,position=0,lap=0,total_ticks=0;}; +struct Sample { + bool valid=false,custom=false,tracks=false,mirrored=false,trial=false,paused=false; + int mode=-1,menu=-1,level=-1,slot=-1,balloons=0,keys=0,tt_amulet=0,wizpig_amulet=0,trophies=0,bosses=0,laps=0,race_type=-1,tt_ticks=0,trophy_world=0,trophy_round=0; + std::uint32_t settings=0;std::array course_flags{};std::array racers{}; +}; +struct Entry {bool unlocked=false,restored=false,modded=false;std::int64_t when=0;int progress=0;}; +struct Preferences {bool notifications=true,sound=true,reduced_motion=false;int position=0;float scale=1,duration=5;}; +struct View {std::array entries{};Preferences preferences;std::string pinned,error,status="Saved on this device";unsigned unlocked=0;}; +struct Toast {std::string id,title;bool preview=false;}; +const std::vector& catalogue(); +Sample capture(const std::uint8_t* ram,std::size_t bytes,unsigned revision) noexcept; +void configure(const std::filesystem::path& root); +void begin(bool online,unsigned local_owner=0,bool modded=false,bool development=false) noexcept; +void submit(const Sample&,std::uint64_t epoch=0,std::uint32_t frame=0) noexcept; +std::shared_ptr view(); +void preferences(Preferences); +void pin(std::string id); +void restore_progress(); +void reset_collection(); +void export_progress(const std::filesystem::path&); +void import_progress(const std::filesystem::path&); +void preview(); +bool next_toast(Toast&); +bool notification_active(); +bool online_session() noexcept; +} diff --git a/runtime-recomp/src/game/admission_contract.hpp b/runtime-recomp/src/game/admission_contract.hpp new file mode 100644 index 00000000..29e51dba --- /dev/null +++ b/runtime-recomp/src/game/admission_contract.hpp @@ -0,0 +1,43 @@ +#pragma once +#include "netplay/secure_channel.hpp" +#include +namespace dkr::online::contract { +using Key=dkr::runtime::netplay::secure::Key; +struct Proof {Key capability{},client{};std::uint64_t sender=0,privacy=0;bool watching=false,consumed=false;}; +constexpr bool running_spectator(bool watching,bool allowed,bool enabled,bool running,bool stopping,bool starting){ + return watching&&allowed&&enabled&&running&&!stopping&&!starting; +} +constexpr bool expired_admission(bool expired,bool consumed,bool approved_preparation,bool member){ + return expired&&!(consumed&&(approved_preparation||member)); +} +constexpr bool permits(const Proof& proof,const Key& capability,const Key& client,std::uint64_t privacy, + std::uint64_t sender,bool watching,bool current_scope,bool fresh_connection){ + return current_scope&&proof.capability==capability&&proof.client==client&&proof.privacy==privacy&& + proof.watching==watching&&(proof.consumed?proof.sender==sender:fresh_connection); +} +// Compile-only security invariants; no target/test executable is run. +namespace checks { +constexpr Key capability{1},client{2},wrong{3}; +constexpr Proof pending{capability,client,0,7,false,false}; +constexpr Proof used{capability,client,42,7,false,true}; +static_assert(permits(pending,capability,client,7,42,false,true,true)); +static_assert(!permits(pending,wrong,client,7,42,false,true,true)); +static_assert(!permits(pending,capability,wrong,7,42,false,true,true)); +static_assert(!permits(pending,capability,client,8,42,false,true,true)); +static_assert(!permits(pending,capability,client,7,42,true,true,true)); +static_assert(!permits(pending,capability,client,7,42,false,false,true)); +static_assert(!permits(pending,capability,client,7,42,false,true,false)); +static_assert(permits(used,capability,client,7,42,false,true,false)); +static_assert(!permits(used,capability,client,7,43,false,true,true)); +static_assert(running_spectator(true,true,true,true,false,false)); +static_assert(!running_spectator(false,true,true,true,false,false)); +static_assert(!running_spectator(true,false,true,true,false,false)); +static_assert(!running_spectator(true,true,true,false,false,false)); +static_assert(!running_spectator(true,true,true,true,true,false)); +static_assert(!running_spectator(true,true,true,true,false,true)); +static_assert(!expired_admission(true,true,true,false)); +static_assert(!expired_admission(true,true,false,true)); +static_assert(expired_admission(true,true,false,false)); +static_assert(expired_admission(true,false,true,false)); +} +} diff --git a/runtime-recomp/src/game/admission_retry.hpp b/runtime-recomp/src/game/admission_retry.hpp new file mode 100644 index 00000000..61ad7b1f --- /dev/null +++ b/runtime-recomp/src/game/admission_retry.hpp @@ -0,0 +1,21 @@ +#pragma once +#include + +namespace dkr::spectator_admission { +// Only a watcher uses this policy. A completed confirmed tick clears the +// streak; merely downloading/admitting another checkpoint does not. +class AdmissionRetry { +public: + static constexpr unsigned MaximumFailures = 5; + bool failed(std::uint64_t epoch) noexcept { + if (epoch != epoch_) { epoch_ = epoch; failures_ = 0; } + if (failures_ < MaximumFailures) ++failures_; + return failures_ < MaximumFailures; + } + void progressed() noexcept { failures_ = 0; } + unsigned failures() const noexcept { return failures_; } +private: + std::uint64_t epoch_ = 0; + unsigned failures_ = 0; +}; +} diff --git a/runtime-recomp/src/game/assessment.hpp b/runtime-recomp/src/game/assessment.hpp new file mode 100644 index 00000000..97ea77b9 --- /dev/null +++ b/runtime-recomp/src/game/assessment.hpp @@ -0,0 +1,59 @@ +#pragma once +#include "rollback_check.hpp" +#include +#include + +namespace dkr::runtime::rollback_check { +inline constexpr unsigned ScoringVersion=1,MinimumRacerReplies=90,MinimumSpectatorReplies=60; +struct Assessment {unsigned score=0;const char* label="Not rated";const char* reason="Run a fresh connection check.";bool rated()const{return score!=0;}}; +inline constexpr unsigned weighted_grade(unsigned timing,unsigned reliability,unsigned consistency,unsigned latency){ + return std::clamp((35*timing+35*reliability+20*consistency+10*latency+50)/100,1U,10U); +} +static_assert(weighted_grade(10,10,10,10)==10 && weighted_grade(1,1,1,1)==1); +static_assert(weighted_grade(8,5,8,8)2?v.window-2:0))*1000./30.;} +inline const char* grade_label(unsigned score){return score>=9?"Excellent":score>=7?"Good":score>=5?"Mixed":score>=3?"Poor":"Very poor";} +inline unsigned latency_grade(double ms){return ms<=40?10:ms<=80?9:ms<=120?8:ms<=180?7:ms<=250?5:ms<=400?3:1;} +inline unsigned consistency_grade(double ms){return ms<=4?10:ms<=8?9:ms<=16?8:ms<=25?6:ms<=40?4:ms<=60?2:1;} +inline unsigned reliability_grade(double missing){return missing<=.5?10:missing<=1?9:missing<=2?8:missing<=5?6:missing<=10?4:2;} +inline unsigned timing_grade(double frames){return frames>=3?10:frames>=2?9:frames>=1?8:frames>=0?7:frames>=-1?5:frames>=-2?3:1;} +inline unsigned playback_grade(double ms){return ms<=80?10:ms<=120?9:ms<=180?8:ms<=250?7:ms<=350?5:ms<=500?3:1;} +inline Assessment remote_assessment(const View& v,const Row& r){ + if(!v.complete)return {0,"Not rated","This report is incomplete or no longer current. Run a fresh check."}; + if(!r.supported)return {0,"Not rated","No compatible diagnostic acknowledgement. Update this participant's build and retry."}; + const unsigned minimum=r.spectator?MinimumSpectatorReplies:MinimumRacerReplies; + if(r.repliesr.sent|| + (!r.spectator&&!v.party&&r.repair_replies<60)) + return {0,"Not rated","Not enough completed probes to give a reliable score. Please re-run the test."}; + unsigned total=0;for(const auto& row:v.rows)if(!row.host)total+=row.sent+row.repair_sent; + if(r.blocked>std::max(4U,r.sent/20)||r.errors||v.scheduler_gaps>std::max(3U,total/20)|| + v.final_queued>64*1024||v.peak_queued>64*1024) + return {0,"Retest when quiet","Local scheduling or queue pressure affected this check. This is not a bad-connection verdict."}; + if(!std::isfinite(r.p95)||!std::isfinite(r.median)||!std::isfinite(r.jitter)||r.median<0||r.p9510)score=std::min(score,3U);else if(missing>5)score=std::min(score,5U);else if(missing>2)score=std::min(score,7U); + if(!r.spectator){if(headroom<0)score=std::min(score,4U);if(p95>400)score=std::min(score,5U);else if(p95>250)score=std::min(score,7U);} + const char* reason=r.spectator?"Steady playback timing expected; checkpoint catch-up speed is not benchmarked.":"Stable timing. Smooth racing is expected with the tested settings."; + if(missing>2)reason=r.spectator?"Missing playback probe replies. Viewing may pause; racers are assessed separately.":"Missing probe replies may cause corrections or waiting. Check the connection and retry."; + else if(!r.spectator&&headroom<0)reason="Timing spikes exceed the conservative Rollback allowance. More delay may help, at a responsiveness cost."; + else if(r.jitter>16)reason=r.spectator?"Variable playback timing. You may pause or need to catch up.":"Some timing spikes. Rollback corrections or brief waiting are possible."; + else if(p95>180)reason=r.spectator?"Slower playback timing. Some pauses or catch-up may occur.":"A longer round trip reduces timing headroom. Expect a less immediate online experience."; + return {score,grade_label(score),reason}; +} +inline Assessment race_assessment(const View& v){ + Assessment result{10,"Excellent","Stable racer routes. Smooth racing is expected with the tested settings."};bool found=false; + for(const auto& r:v.rows)if(!r.host&&!r.spectator){ + const auto a=remote_assessment(v,r);if(!a.rated())return {0,"Incomplete","At least one racer is not rated. Re-run the check before relying on a lobby score."}; + found=true;if(a.score + +namespace dkr::lobby_stability { +// Repair only the named private download. Never follow a symlink, replace an +// installed mod or discard unrelated payloads. Retain the damaged file for QA. +inline bool verify_or_repair_download(const std::filesystem::path& input_root,const char* folder, + const dkr::mods::online::Payload& expected,std::stop_token stop) { + using namespace dkr::mods; + using namespace dkr::mods::online; + if(stop.stop_requested())throw Error("Online cache repair cancelled."); + if(!valid_digest(expected.digest))throw Error("Invalid payload repair identity."); + const auto root=private_storage_path(input_root),parent=root/folder,path=parent/expected.digest; + if(!std::filesystem::exists(path))return false; + check_storage(root,true);check_storage(parent,true);check_storage(path,false); + if(std::filesystem::canonical(parent).parent_path()!=std::filesystem::canonical(root))throw Error("Private download repair escaped its cache."); + const auto size=std::filesystem::file_size(path); + if(std::string_view(folder)=="partials"&&size(expected.size)))==expected.digest)return true; + const auto quarantine=root/"quarantined-downloads";std::filesystem::create_directory(quarantine);check_storage(quarantine,true); + std::random_device random; + for(unsigned n=0;n<5;++n) { + const auto reservation=quarantine/(expected.digest+"-"+std::to_string(random())+"-"+std::to_string(random())); + if(!std::filesystem::create_directory(reservation))continue; + check_storage(reservation,true);std::filesystem::rename(path,reservation/expected.digest); + std::fprintf(stderr,"[online-mods][download-repair] payload=%s source=%s preserved-old-file=1\n",expected.digest.c_str(),folder);return false; + } + throw Error("Could not reserve a safe private download repair."); +} +} diff --git a/runtime-recomp/src/game/camera_actions.cpp b/runtime-recomp/src/game/camera_actions.cpp new file mode 100644 index 00000000..a6339ef9 --- /dev/null +++ b/runtime-recomp/src/game/camera_actions.cpp @@ -0,0 +1,55 @@ +#include "camera_actions.hpp" +#include +#include + +namespace dkr::runtime::camera { +namespace { +constexpr unsigned KnownActions=DKR_VIEW_LOOK_BACK|DKR_VIEW_RECENTER|DKR_VIEW_CYCLE| + DKR_VIEW_ZOOM_IN|DKR_VIEW_ZOOM_OUT|DKR_VIEW_FIRST_PERSON|DKR_VIEW_XR_RECENTER; +float finite_axis(float value){return std::isfinite(value)?std::clamp(value,-1.F,1.F):0;} +} +void Actions::publish(unsigned profile,const ActionSample& sample) { + if(profile>=4)return; + std::scoped_lock lock(mutex_);auto& p=ports_[profile]; + auto next=sample;next.held&=KnownActions; + next.orbit_x=finite_axis(next.orbit_x);next.orbit_y=finite_axis(next.orbit_y);next.zoom=finite_axis(next.zoom); + if(p.latest.blocked&&!next.blocked) { + p.suppressed|=next.held;p.neutral=true; + } + // Closing UI while holding a button must not trigger a camera action. + if(next.blocked||!next.connected) { + p.suppressed|=next.held;p.pressed=0;p.mouse_x=p.mouse_y=0; + next.orbit_x=next.orbit_y=next.zoom=0; + } + p.suppressed&=next.held;next.held&=~p.suppressed; + if(next.blocked||!next.connected)next.held=0; + if(p.neutral) { + if(!next.blocked&&std::abs(next.orbit_x)<0.15F&&std::abs(next.orbit_y)<0.15F)p.neutral=false; + else next.orbit_x=next.orbit_y=0; + } + p.pressed|=next.held&~p.latest.held;p.latest=next; +} +void Actions::mouse(unsigned profile,float x,float y) { + if(profile>=4||!std::isfinite(x)||!std::isfinite(y))return; + std::scoped_lock lock(mutex_);auto& p=ports_[profile]; + if(p.latest.blocked||!p.latest.connected)return; + p.mouse_x=std::clamp(p.mouse_x+x,-4096.F,4096.F); + p.mouse_y=std::clamp(p.mouse_y+y,-4096.F,4096.F); +} +DkrViewInput Actions::consume(unsigned profile,float dt) { + DkrViewInput out{};out.api=1;out.size=sizeof(out);out.blocked=1; + if(profile>=4||!std::isfinite(dt)||dt<=0||dt>0.1F)return out; + std::scoped_lock lock(mutex_);auto& p=ports_[profile]; + out.blocked=p.latest.blocked||!p.latest.connected; + if(!out.blocked) { + out.held=p.latest.held;out.pressed=p.pressed; + // Mouse is a delta, stick is a rate. Consume the delta once per batch, + // not once per eye. The mod applies its sensitivity and dt afterward. + out.orbit_x=finite_axis(p.latest.orbit_x+p.mouse_x*0.002F/dt); + out.orbit_y=finite_axis(p.latest.orbit_y+p.mouse_y*0.002F/dt); + out.zoom=p.latest.zoom; + } + p.pressed=0;p.mouse_x=p.mouse_y=0;return out; +} +void Actions::reset() {std::scoped_lock lock(mutex_);ports_={};} +} diff --git a/runtime-recomp/src/game/camera_actions.hpp b/runtime-recomp/src/game/camera_actions.hpp new file mode 100644 index 00000000..635e16de --- /dev/null +++ b/runtime-recomp/src/game/camera_actions.hpp @@ -0,0 +1,30 @@ +#pragma once +#include "camera_presentation.hpp" +#include +#include + +namespace dkr::runtime::camera { +// Normalized camera actions, after the existing host resolves device bindings. +// Never opens controllers or writes the N64 input broker. +struct ActionSample { + std::uint32_t held=0; + float orbit_x=0,orbit_y=0,zoom=0; + bool blocked=true,connected=false; +}; +class Actions final { +public: + void publish(unsigned physical_profile,const ActionSample&); + void mouse(unsigned physical_profile,float x_pixels,float y_pixels); + DkrViewInput consume(unsigned physical_profile,float dt); + void reset(); +private: + struct Port { + ActionSample latest; + std::uint32_t pressed=0,suppressed=0; + float mouse_x=0,mouse_y=0; + bool neutral=true; + }; + std::mutex mutex_; + std::array ports_; +}; +} diff --git a/runtime-recomp/src/game/camera_kernel.c b/runtime-recomp/src/game/camera_kernel.c new file mode 100644 index 00000000..81273059 --- /dev/null +++ b/runtime-recomp/src/game/camera_kernel.c @@ -0,0 +1,289 @@ +/* A renderer-only, bounded copy of the native per-view draw closure. + * NEVER pass live RAM here. No native game import or callback is linked. + * A C-only fence avoids longjmp through C++ owners/destructors. + */ +#include "camera_scene_addresses.h" +#include "funcs.h" +#include "camera_kernel.h" +#include +#include +#include +#include +#include +enum {COMMANDS=0x80800000U,COMMAND_END=0x80900000U, + MATRICES=0x80900000U,MATRIX_END=0x80980000U, + VERTICES=0x80980000U,VERTEX_END=0x80A80000U, + TRIANGLES=0x80A80000U,TRIANGLE_END=0x80B80000U, + STACK=0x80BF0000U}; +#if defined(_MSC_VER) +#define CAMERA_TLS __declspec(thread) +#else +#define CAMERA_TLS _Thread_local +#endif +static CAMERA_TLS struct { + uint8_t* ram;int active;uint32_t operations,scene,view; + float cover,sky_y[4];unsigned sky_active,wave_active,wave_block,wave_selection,full_screen; + dkr_camera_draw_result* result; + struct {uint32_t first,identity;} objects[16]; + unsigned depth; + const char* failure;jmp_buf fence;fenv_t environment; +} drawing; +void dkr_camera_fault(const char* why){drawing.failure=why;longjmp(drawing.fence,1);} +void dkr_camera_step(void){ + if(!drawing.active||++drawing.operations>6000000U)dkr_camera_fault("camera draw operation budget"); +} +void* dkr_camera_access(uint64_t address,unsigned width) { + dkr_camera_step(); + if(addressUINT64_C(0xffffffff80bfffff)|| + (width!=1&&width!=2&&width!=4)||(address&(width-1))) + dkr_camera_fault("camera draw memory span"); + const uint32_t p=(uint32_t)address-0x80000000U; + if(p>0xC00000U-width)dkr_camera_fault("camera draw memory end"); + return drawing.ram+(p^(width==1?3U:width==2?2U:0U)); +} +static uint64_t guest(uint32_t p){return (uint64_t)(int64_t)(int32_t)p;} +static uint32_t read32(uint32_t p){return *(uint32_t*)dkr_camera_access(guest(p),4);} +static uint16_t read16(uint32_t p){return *(uint16_t*)dkr_camera_access(guest(p),2);} +static uint8_t read8(uint32_t p){return *(uint8_t*)dkr_camera_access(guest(p),1);} +static void put32(uint32_t p,uint32_t value){*(uint32_t*)dkr_camera_access(guest(p),4)=value;} +static void put16(uint32_t p,uint16_t value){*(uint16_t*)dkr_camera_access(guest(p),2)=value;} +static void put8(uint32_t p,uint8_t value){*(uint8_t*)dkr_camera_access(guest(p),1)=value;} +static void real(uint32_t p,float value){uint32_t bits;if(!isfinite(value))dkr_camera_fault("camera draw non-finite transform");memcpy(&bits,&value,4);put32(p,bits);} +static float read_real(uint32_t p){const uint32_t bits=read32(p);float value;memcpy(&value,&bits,4);if(!isfinite(value))dkr_camera_fault("camera draw non-finite input");return value;} +static uint32_t mix32(uint32_t p){p^=p>>16;p*=0x7feb352dU;p^=p>>15;p*=0x846ca68bU;p=(p^(p>>16))|1U;return p==UINT32_MAX?0xFFFFFFFDU:p;} +static uint16_t angle(float value){if(!isfinite(value))dkr_camera_fault("camera angle");return (uint16_t)(int32_t)lroundf(remainderf(value,6.28318530718F)*10430.3783505F);} +static void matrix(uint32_t address,uint32_t identity,unsigned root) { + if(addressMATRIX_END-64||(address&63))dkr_camera_fault("camera matrix allocation"); + for(unsigned i=0;imatrix_count;++i) + if(drawing.result->matrices[i].address==address)return; + if(drawing.result->matrix_count==DKR_CAMERA_MAX_MATRICES)dkr_camera_fault("camera matrix metadata budget"); + dkr_camera_matrix* m=&drawing.result->matrices[drawing.result->matrix_count++]; + m->address=address;m->identity=mix32(drawing.scene^(drawing.view*0x9e3779b9U)^identity);m->root=root; +} +unsigned dkr_presentation_world_origin_matrix(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;matrix(read32((uint32_t)ctx->r16),0x43414d52U,1);return 0; +} +unsigned dkr_presentation_object_begin(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;if(drawing.depth==16)dkr_camera_fault("camera draw object nesting"); + const uint32_t object=(uint32_t)ctx->r7; + const uint32_t holder=read32((uint32_t)ctx->r29+0x24); + drawing.objects[drawing.depth].first=read32(holder); + drawing.objects[drawing.depth++].identity=mix32(object^read16(object+0x2C));return 0; +} +unsigned dkr_presentation_object_end(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx;if(!drawing.depth)dkr_camera_fault("camera draw object scope"); + const unsigned slot=--drawing.depth; + const uint32_t first=drawing.objects[slot].first,end=read32(CAMERA_gObjectCurrMatrix); + if(end256*64U||(end-first)%64)dkr_camera_fault("camera object matrix range"); + for(uint32_t p=first;pr12=1;return 0; // Re-evaluate visibility from the local camera. +} +unsigned dkr_legacy_texture_animation(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx;return 1; // Freeze the captured, already advanced material phase. +} +unsigned dkr_skybox_cover_begin(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx; + if(drawing.sky_active)return 0; + const float scale=drawing.cover<=1.75F?drawing.cover:3.0625F/drawing.cover; + for(unsigned i=0;i<4;++i) { + const uint32_t p=CAMERA_gViewProjMatrixF+i*16+4,bits=read32(p); + memcpy(&drawing.sky_y[i],&bits,4);real(p,drawing.sky_y[i]*scale); + } + drawing.sky_active=1;return 0; +} +unsigned dkr_skybox_cover_end(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx;if(!drawing.sky_active)return 0; + for(unsigned i=0;i<4;++i)real(CAMERA_gViewProjMatrixF+i*16+4,drawing.sky_y[i]); + drawing.sky_active=0;return 0; +} +static float sky_y_scale(float cover){return cover<=1.75F?cover:3.0625F/cover;} +static float split_cover(void){return drawing.cover*(read32(CAMERA_gViewportLayout)==1&&!drawing.full_screen?2.F:1.F);} +unsigned dkr_widen_gradient_sky(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;const uint32_t start=(uint32_t)ctx->r3; + const float horizontal=split_cover(); + float vertical=sky_y_scale(horizontal); + if(drawing.full_screen){vertical=fmaxf(1,vertical);if(read32(CAMERA_gViewportLayout)==1)vertical*=2;} + for(unsigned i=0;i<4;++i) { + const uint32_t p=start+i*10; + put16(p,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf((int16_t)read16(p)*horizontal)))); + put16(p+2,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf((int16_t)read16(p+2)*vertical)))); + } + return 0; +} +unsigned dkr_widen_void_primitive(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;const uint32_t start=(uint32_t)ctx->r2-40; + const float lx=read_real(CAMERA_D_8011D4A0),lz=read_real(CAMERA_D_8011D4A4); + const float cx=read_real(CAMERA_D_8011D4AC),cz=read_real(CAMERA_D_8011D4B0),length=lx*lx+lz*lz; + if(length<0.5F||length>1.5F)return 0; + const float scale=split_cover()-1; + for(unsigned i=0;i<4;++i) { + const uint32_t p=start+i*10;const float x=(int16_t)read16(p),z=(int16_t)read16(p+4); + const float lateral=((x-cx)*lx+(z-cz)*lz)/length; + put16(p,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf(x+lx*lateral*scale)))); + put16(p+4,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf(z+lz*lateral*scale)))); + } + return 0; +} +unsigned dkr_presentation_wave_begin(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx;drawing.wave_active=1;drawing.wave_block=0;drawing.wave_selection=256;return 0; +} +unsigned dkr_presentation_wave_end(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx;drawing.wave_active=0;return 0; +} +unsigned dkr_presentation_wave_block(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;drawing.wave_block=(uint32_t)ctx->r2;return 0; +} +unsigned dkr_presentation_wave_selection(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;drawing.wave_selection=read32((uint32_t)ctx->r29+0x104)&255U;return 0; +} +unsigned dkr_presentation_wave_matrix(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;if(!drawing.wave_active||!drawing.wave_block||drawing.wave_selection>25)return 0; + const uint32_t transform=(uint32_t)ctx->r6; + uint32_t identity=drawing.wave_block^mix32(drawing.wave_selection); + for(unsigned i=0;i<4;++i)identity=mix32(identity^read32(transform+i*4)); + identity^=mix32(read32(CAMERA_gWaveController))^mix32(read32(CAMERA_gWaveController+0x28)); + matrix(read32((uint32_t)ctx->r5),identity,2); + drawing.wave_selection=256;return 0; +} +unsigned dkr_fix_car_steering_wheel_material(uint8_t* ram,recomp_context* ctx,...) { + (void)ram; + const uint32_t model=(uint32_t)ctx->r23,batch=(uint32_t)ctx->r2,tex=(uint32_t)ctx->r18; + const uint32_t obj=read32((uint32_t)ctx->r29+0xB4),racer=read32(obj+0x64); + if(read16(obj+0x48)!=1 || !racer || read8(racer+0x1D6)!=0 || + (uint32_t)ctx->r10+1U!=read16(model+0x28) || read8(batch)==255 || + (int32_t)ctx->r17!=4 || (int32_t)ctx->r21!=2 || (uint32_t)ctx->r8!=0x80FU || + read8(tex)!=16 || read8(tex+1)!=16 || (read8(tex+2)&15)!=0 || + (read16(tex+6)&0xC0)!=0xC0)return 0; + const unsigned index=read8(batch),count=read16(model+0x22);const uint32_t table=read32(model); + if(indexr8&=~(gpr)0x800; + return 0; +} +void __ll_to_f_recomp(uint8_t* ram,recomp_context* ctx){ + (void)ram;int64_t value=(int64_t)((ctx->r4<<32)|(uint32_t)ctx->r5);ctx->f0.fl=(float)value; +} +void __f_to_ll_recomp(uint8_t* ram,recomp_context* ctx){ + (void)ram;const double x=ctx->f12.fl; + if(!isfinite(x)||x>=9223372036854775808.0||x< -9223372036854775808.0)dkr_camera_fault("camera integer conversion"); + const int64_t value=(int64_t)x;ctx->r2=(uint64_t)(int64_t)(int32_t)((uint64_t)value>>32);ctx->r3=(uint64_t)(int64_t)(int32_t)value; +} +void do_break(uint32_t pc){(void)pc;dkr_camera_fault("camera native division/overflow");} +void switch_error(const char* name,uint32_t pc,uint32_t table){(void)name;(void)pc;(void)table;dkr_camera_fault("camera native jump table");} +static void call(void (*function)(uint8_t*,recomp_context*),recomp_context* ctx){ + function(drawing.ram,ctx); + if(read32(CAMERA_gTrackDL)>COMMAND_END-16||read32(CAMERA_gTrackMtxPtr)>MATRIX_END|| + read32(CAMERA_gTrackVtxPtr)>VERTEX_END||read32(CAMERA_gTrackTriPtr)>TRIANGLE_END) + dkr_camera_fault("camera per-view draw allocation budget"); +} +int CAMERA_ENTRY(uint8_t* ram,size_t bytes,const dkr_camera_draw_request* request,dkr_camera_draw_result* result) { + if(!result)return 0;memset(result,0,sizeof(*result)); + if(!ram||bytesview>3||drawing.active){ + result->failure="camera draw ownership";return 0; + } + drawing.ram=ram;drawing.operations=drawing.depth=0;drawing.result=result;drawing.failure=0; + drawing.sky_active=drawing.wave_active=0;drawing.cover=1;drawing.full_screen=request->full_screen; + drawing.scene=request->scene;drawing.view=request->view; + if(fegetenv(&drawing.environment)){result->failure="camera floating environment";return 0;} + drawing.active=1; + if(setjmp(drawing.fence)) { + result->failure=drawing.failure;result->operations=drawing.operations; + drawing.active=0;fesetenv(&drawing.environment);return 0; + } + const uint32_t camera=CAMERA_gCameras+request->view*0x44; + for(unsigned i=0;i<3;++i)real(camera+12+i*4,request->position[i]); + put16(camera,angle(3.14159265359F-request->yaw));put16(camera+2,angle(request->pitch));put16(camera+4,angle(request->roll)); + put16(camera+0x38,0);real(camera+0x30,0); + /* The new camera can occupy a different regional visibility cell. Use + its own plane culling, not the old camera's authored visibility bitset. */ + put16(camera+0x34,0xFFFFU); + put32(CAMERA_gActiveCameraID,request->view);put32(CAMERA_gSceneCurrentPlayerID,request->view); + put32(CAMERA_gSceneActiveCamera,camera);put8(CAMERA_gCutsceneCameraActive,0); + put32(CAMERA_gTrackDL,COMMANDS);put32(CAMERA_gTrackMtxPtr,MATRICES); + put32(CAMERA_gTrackVtxPtr,VERTICES);put32(CAMERA_gTrackTriPtr,TRIANGLES); + put32(CAMERA_gSceneRenderSkyDome,1);put8(CAMERA_gDrawLevelSegments,1);put32(CAMERA_gDisableShadows,0); + put32(CAMERA_gModelMatrixStackPos,0); + if(request->hide_object) { + const uint32_t p=request->hide_object; + if(p<0x80000000U||p>0x807FFF80U)dkr_camera_fault("camera local model owner"); + put16(p+6,(uint16_t)(read16(p+6)|0x200U<<(request->view&1U))); + } + const float n=request->near_plane,f=request->far_plane,y=1.F/tanf(request->fov*0.5F); + if(!isfinite(y)||n<=0||f<=n)dkr_camera_fault("camera projection"); + for(unsigned i=0;i<16;++i)real(CAMERA_gPerspectiveMatrixF+i*4,0); + real(CAMERA_gPerspectiveMatrixF,y/(4.F/3.F));real(CAMERA_gPerspectiveMatrixF+20,y); + real(CAMERA_gPerspectiveMatrixF+40,(n+f)/(n-f));real(CAMERA_gPerspectiveMatrixF+44,-1); + real(CAMERA_gPerspectiveMatrixF+56,2*n*f/(n-f)); + put16(CAMERA_perspNorm,(uint16_t)fmaxf(1,fminf(65535,131072.F/(n+f)))); + recomp_context ctx={0};ctx.f_odd=&ctx.f0.u32h;ctx.r29=guest(STACK); + ctx.r4=guest(CAMERA_gPerspectiveMatrixF);ctx.r5=guest(CAMERA_gPerspectiveMatrix);call(mtxf_to_mtx,&ctx); + ctx.r4=request->view;call(apply_fog,&ctx); + ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(viewport_main,&ctx); + /* DKR builds its three camera planes from authored reference points and + the INVERSE VIEW matrix, not the perspective matrix. Widen those side + points for this FOV/canvas, then restore the points after plane setup. */ + float saved_x[4];const unsigned side_offsets[]={0x30,0x3C,0x54,0x60}; + if(!isfinite(request->aspect)||request->aspect<0.1F||request->aspect>16)dkr_camera_fault("camera canvas"); + drawing.cover=fmaxf(1,request->aspect/(4.F/3.F)); + const float canvas=request->aspect*(read32(CAMERA_gViewportLayout)==1&&!request->full_screen?2.F:1.F); + const float cull_scale=fmaxf(1,tanf(request->fov*0.5F)*canvas*1.1F/1.3F); + for(unsigned i=0;i<4;++i) { + const uint32_t p=CAMERA_D_800DC8AC+side_offsets[i];uint32_t bits=read32(p); + memcpy(&saved_x[i],&bits,4);real(p,saved_x[i]*cull_scale); + } + call(func_8002A31C,&ctx); + for(unsigned i=0;i<4;++i)real(CAMERA_D_800DC8AC+side_offsets[i],saved_x[i]); + const uint32_t header=read32(CAMERA_gCurrentLevelHeader2); + ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(mtx_world_origin,&ctx); + if(read32(CAMERA_gScenePlayerViewports)==0) { + if((int8_t)read8(header+0x49)==-1)call(trackbg_render_flashy,&ctx);else call(skydome_render,&ctx); + } else { + ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(mtx_perspective,&ctx); + call(trackbg_render_gradient,&ctx); + ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(func_80067D3C,&ctx); + ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(mtx_world_origin,&ctx); + } + ctx.r4=0;call(initialise_player_viewport_vars,&ctx); // No logic/animation time advance. + /* The retained world-end boundary is AFTER native weather/lens flares. + Redraw only their frozen visual state: never call weather_update, + snow_update, rain_update, rain_sound or rain_lightning. */ + ctx.r4=guest((uint32_t)-1);ctx.r5=guest((uint32_t)-512);call(weather_clip_planes,&ctx); + if((int16_t)read16(header+0x90)>0&&read32(CAMERA_gScenePlayerViewports)==0) { + put32(CAMERA_gCurrWeatherDisplayList,read32(CAMERA_gTrackDL)); + put32(CAMERA_gCurrWeatherMatrix,read32(CAMERA_gTrackMtxPtr)); + put32(CAMERA_gCurrWeatherVertexList,read32(CAMERA_gTrackVtxPtr)); + put32(CAMERA_gCurrWeatherTriList,read32(CAMERA_gTrackTriPtr)); + put32(CAMERA_gWeatherCamera,camera);call(get_camera_matrix,&ctx); + put32(CAMERA_gWeatherCameraMatrix,(uint32_t)ctx.r2); + if(read32(CAMERA_gWeatherType)==0) { + if(read32(CAMERA_gSnowParticleCount)>0) { + put32(CAMERA_gSnowVerts,read32(CAMERA_gSnowVertexData+(read32(CAMERA_gSnowVertexFlip)&1U)*4)); + call(snow_vertices,&ctx);call(snow_render,&ctx); + } + } else { + // Prevent fresh splash spawning while retaining existing splashes. + put32(CAMERA_gRainSplashDelay,1);ctx.r4=0;call(rain_render_splashes,&ctx); + if((int32_t)read32(CAMERA_gLightningFrequency)>255) { + ctx.r4=guest(CAMERA_gCurrWeatherDisplayList);ctx.r5=guest(CAMERA_gCurrWeatherMatrix);call(mtx_ortho,&ctx); + for(unsigned i=0;i<2;++i){ctx.r4=guest(CAMERA_gRainGfx+i*0x18);ctx.r5=0;call(rain_render,&ctx);} + ctx.r4=guest(CAMERA_gCurrWeatherDisplayList);call(rendermode_reset,&ctx); + ctx.r4=guest(CAMERA_gCurrWeatherDisplayList);call(viewport_reset,&ctx); + } + } + put32(CAMERA_gTrackDL,read32(CAMERA_gCurrWeatherDisplayList)); + put32(CAMERA_gTrackMtxPtr,read32(CAMERA_gCurrWeatherMatrix)); + put32(CAMERA_gTrackVtxPtr,read32(CAMERA_gCurrWeatherVertexList)); + put32(CAMERA_gTrackTriPtr,read32(CAMERA_gCurrWeatherTriList)); + } + ctx.r4=guest(camera);call(lensflare_override,&ctx); + ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr); + ctx.r6=guest(CAMERA_gTrackVtxPtr);ctx.r7=guest(camera);call(lensflare_render,&ctx); + const uint32_t end=read32(CAMERA_gTrackDL); + if(request->return_address<0x80000000U||request->return_address>0x807FFFF8U||(request->return_address&7)) + dkr_camera_fault("camera return command"); + put32(end,0x06010000U);put32(end+4,request->return_address&0xFFFFFFU); + result->start=COMMANDS;result->end=end+8;result->operations=drawing.operations; + drawing.active=0;fesetenv(&drawing.environment);return 1; +} diff --git a/runtime-recomp/src/game/camera_kernel.h b/runtime-recomp/src/game/camera_kernel.h new file mode 100644 index 00000000..88fae92d --- /dev/null +++ b/runtime-recomp/src/game/camera_kernel.h @@ -0,0 +1,24 @@ +#ifndef DKR_CAMERA_KERNEL_H +#define DKR_CAMERA_KERNEL_H +#include +#include +enum {DKR_CAMERA_KERNEL_BYTES=0x1000000,DKR_CAMERA_MAX_MATRICES=4096}; +typedef struct dkr_camera_matrix {uint32_t address,identity,root;} dkr_camera_matrix; +typedef struct dkr_camera_draw_request { + uint32_t view,scene,return_address,hide_object,full_screen; + float position[3],yaw,pitch,roll,fov,near_plane,far_plane,aspect; +} dkr_camera_draw_request; +typedef struct dkr_camera_draw_result { + uint32_t start,end,operations,matrix_count; + const char* failure; + dkr_camera_matrix matrices[DKR_CAMERA_MAX_MATRICES]; +} dkr_camera_draw_result; +#ifdef __cplusplus +extern "C" { +#endif +int dkr_camera_v77_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*); +int dkr_camera_v80_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*); +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime-recomp/src/game/camera_kernel_guest.h b/runtime-recomp/src/game/camera_kernel_guest.h new file mode 100644 index 00000000..591b6a83 --- /dev/null +++ b/runtime-recomp/src/game/camera_kernel_guest.h @@ -0,0 +1,7 @@ +#ifndef DKR_CAMERA_KERNEL_GUEST_H +#define DKR_CAMERA_KERNEL_GUEST_H +#include +void* dkr_camera_access(uint64_t address,unsigned width); +void dkr_camera_step(void); +void dkr_camera_fault(const char*); +#endif diff --git a/runtime-recomp/src/game/camera_obstruction.cpp b/runtime-recomp/src/game/camera_obstruction.cpp index 5e8bc9cb..91c6ea77 100644 --- a/runtime-recomp/src/game/camera_obstruction.cpp +++ b/runtime-recomp/src/game/camera_obstruction.cpp @@ -1,6 +1,6 @@ #include "recomp.h" #include "camera_obstruction_guest.h" -#include "modern_camera.hpp" + #include "revision_addresses.hpp" namespace { @@ -25,15 +25,8 @@ extern "C" void dkr_resolve_follow_camera(std::uint8_t* rdram, std::uint32_t fields[8]; if(!follow_camera_fields(fields))return; dkr_cam_resolve(rdram,object,fields); - /* The local Modern camera replaces (or, for look back, turns) the - finished retail pose, then the same clearance pass resolves it. */ - if(dkr::runtime::modern_camera::begin(rdram,ctx,object,fields)) { - dkr_cam_resolve(rdram,object,fields); - dkr::runtime::modern_camera::commit(rdram,fields); - } -} -extern "C" void dkr_modern_camera_restore(std::uint8_t* rdram, recomp_context*) { - std::uint32_t fields[8]; - if(follow_camera_fields(fields))dkr::runtime::modern_camera::restore(rdram,fields); } + +// Retained generated-hook ABI. Camera++ never changes live camera RAM. +extern "C" void dkr_modern_camera_restore(std::uint8_t*, recomp_context*) {} diff --git a/runtime-recomp/src/game/camera_presentation.cpp b/runtime-recomp/src/game/camera_presentation.cpp new file mode 100644 index 00000000..e2bf2172 --- /dev/null +++ b/runtime-recomp/src/game/camera_presentation.cpp @@ -0,0 +1,180 @@ +#include "camera_presentation.hpp" +#include +#include +#include +#include +#include + +namespace dkr::runtime::camera { +namespace { +using namespace mods::code; +[[noreturn]] void fail(const char* reason){throw mods::Error(std::string("Camera mod: ")+reason);} +template void copy_out(Memory& memory,std::uint32_t address,const T& value) { + static_assert(sizeof(T)%4==0); + for(unsigned i=0;i(&value)+i,4); + memory.store(address+i,4,word); + } +} +template T copy_in(const Memory& memory,std::uint32_t address) { + T value{}; + for(unsigned i=0;i(&value)+i,&word,4); + } + return value; +} +bool point(const float* p){return std::all_of(p,p+3,[](float x){return std::isfinite(x)&&std::abs(x)<=1.e7F;});} +template bool zero(const T& words){return std::all_of(std::begin(words),std::end(words),[](auto v){return v==0;});} +std::string text(const Memory& bus,std::uint32_t address) { + std::string out; + for(unsigned i=0;i<97;++i) { + const auto c=char(bus.load(address+i,1));if(!c)return out; + if(i==96)fail("setting name is too long"); + out+=c; + } + fail("invalid setting name"); +} +std::shared_ptr prepare(std::shared_ptr package,const Json& settings,const std::string& revision) { + if(!package||!package->modules.contains("presentation"))fail("package does not declare presentation v1"); + if(!package->dependencies.empty()||!package->tracks.empty())fail("presentation packages cannot depend on gameplay packages or tracks"); + auto linked=link({package},{{package->id,settings}},revision,Program::policy()); + if(!linked->hooks.empty()||!linked->native_functions.empty()||linked->owners.size()!=1)fail("presentation package attempted gameplay access"); + if(linked->resident_bytes>2*mods::MiB)fail("presentation package exceeds its private memory budget"); + if(linked->events.size()!=1||!linked->events.contains("*:on_presentation")||linked->events.at("*:on_presentation").size()!=1) + fail("exactly one on_presentation callback is required"); + return linked; +} +} +mods::code::LinkPolicy Program::policy() { + mods::code::LinkPolicy p; + p.modules={{"core",1},{"memory",1},{"events",1},{"settings",1},{"presentation",1}}; + p.services={"dkr_sdk_version","dkr_setting_number","dkr_view_snapshot","dkr_view_input", + "dkr_view_submit","dkr_view_query","dkr_view_math"}; + p.events={"on_presentation"}; + // No game RAM, native functions, gameplay hooks, resources containing raw + // host paths, audio, clocks, RNG or network services enter this domain. + return p; +} +Program::Program(std::shared_ptr package,mods::code::Json settings,std::string revision): + linked_(prepare(std::move(package),settings,revision)), + ram_(16*mods::MiB+linked_->resident_bytes,0),memory_(ram_,linked_->regions), + settings_(mods::code::configuration(*linked_->owners[0].package,settings)), + entry_(linked_->events.at("*:on_presentation").front()) { + mods::code::install(*linked_,ram_); +} +bool Program::settings(const mods::code::Json& values) noexcept { + if(quarantined_)return false; // Preserve the original fault diagnostic. + try {auto next=mods::code::configuration(package(),values);settings_=std::move(next);error_.clear();return true;} + catch(const std::exception& e){error_=e.what();return false;} +} +void Program::validate(const DkrViewPose& p)const { + if(p.api!=1||p.size!=sizeof(p)||p.view!=snapshot_.view||p.racer_id!=snapshot_.racer_id|| + p.racer_generation!=snapshot_.racer_generation||p.mode>DKR_VIEW_VR_DRIVER||p.cut>1|| + p.visibility>DKR_VIEW_HIDE_RIDER_ONLY||!zero(p.reserved))fail("invalid view ownership or output version"); + if(p.mode==DKR_VIEW_ORIGINAL)return; + if(!(snapshot_.capabilities&DKR_VIEW_POSE))fail("this renderer cannot submit an independent camera"); + if(!point(p.position)||!std::isfinite(p.yaw)||!std::isfinite(p.pitch)||!std::isfinite(p.roll)|| + std::abs(p.yaw)>32||std::abs(p.pitch)>32||std::abs(p.roll)>32)fail("invalid camera pose"); + float distance=0;for(unsigned i=0;i<3;++i){const auto d=p.position[i]-snapshot_.position[i];distance+=d*d;} + if(distance>1000000.F)fail("camera exceeded the local safety envelope"); + if(!std::isfinite(p.vertical_fov)||p.vertical_fov<0.174532925F||p.vertical_fov>2.61799388F|| + !std::isfinite(p.near_plane)||p.near_plane<0.1F||p.near_plane>100|| + !std::isfinite(p.far_plane)||p.far_plane<=p.near_plane||p.far_plane>1.e7F)fail("invalid camera projection"); + if(!(snapshot_.capabilities&DKR_VIEW_PROJECTION)&& + (p.vertical_fov!=snapshot_.original_fov||p.near_plane!=snapshot_.original_near||p.far_plane!=snapshot_.original_far)) + fail("independent projection is unavailable"); + if(p.visibility==DKR_VIEW_HIDE_VEHICLE&&!(snapshot_.capabilities&DKR_VIEW_HIDE_LOCAL_MODEL))fail("per-view vehicle hiding is unavailable"); + if(p.visibility==DKR_VIEW_HIDE_RIDER_ONLY&&!(snapshot_.capabilities&DKR_VIEW_HIDE_RIDER))fail("per-view rider hiding is unavailable"); + if(p.mode==DKR_VIEW_ORBIT&&(!(snapshot_.capabilities&DKR_VIEW_ORBIT_COVERAGE)|| + !(snapshot_.flags&DKR_VIEW_INSPECTION_ALLOWED)||(snapshot_.flags&DKR_VIEW_ONLINE)))fail("unrestricted inspection is unavailable in this session"); + if(p.mode==DKR_VIEW_VR_DRIVER&&!(snapshot_.capabilities&DKR_VIEW_STEREO))fail("stereoscopic headset output is unavailable"); +} +std::optional Program::frame(const DkrViewSnapshot& snapshot,const DkrViewInput& input,const Query& query) { + output_.reset();query_=nullptr;services_=queries_=0; + if(quarantined_)return {}; + bool callback_started=false; + try { + if(snapshot.api!=1||snapshot.size!=sizeof(snapshot)||snapshot.view>=4||!zero(snapshot.reserved)|| + !std::isfinite(snapshot.dt)||snapshot.dt<0||snapshot.dt>0.1F||!std::isfinite(snapshot.time)|| + !std::isfinite(snapshot.aspect)||snapshot.aspect<0.1F||snapshot.aspect>16||!std::isfinite(snapshot.speed)|| + !point(snapshot.position)||!point(snapshot.velocity)||!point(snapshot.original_position)|| + !std::isfinite(snapshot.yaw)||!std::isfinite(snapshot.pitch)||!std::isfinite(snapshot.roll)|| + !std::isfinite(snapshot.steer)||!std::isfinite(snapshot.boost)|| + !std::isfinite(snapshot.original_yaw)||!std::isfinite(snapshot.original_pitch)||!std::isfinite(snapshot.original_roll)|| + !std::isfinite(snapshot.original_fov)||!std::isfinite(snapshot.original_near)||!std::isfinite(snapshot.original_far)) + fail("invalid read-only race snapshot"); + if(input.api!=1||input.size!=sizeof(input)||!zero(input.reserved)||input.blocked>1|| + ((input.held|input.pressed|input.released)&~127U)|| + !std::isfinite(input.orbit_x)||!std::isfinite(input.orbit_y)||!std::isfinite(input.zoom)|| + std::abs(input.orbit_x)>1||std::abs(input.orbit_y)>1||std::abs(input.zoom)>1) + fail("invalid local camera action sample"); + snapshot_=snapshot;input_=input; + if(input_.blocked){input_.held=input_.pressed=0;input_.orbit_x=input_.orbit_y=input_.zoom=0;} + // Include taps latched by the host between presentation intervals. + auto& previous=held_[snapshot.view]; + input_.pressed|=input_.held&~previous;input_.released=previous&~input_.held;previous=input_.held; + if(!(snapshot.flags&DKR_VIEW_DRIVING)||(snapshot.flags&DKR_VIEW_SCRIPTED))return {}; + query_=&query; + mods::code::Registers registers; + registers.integer[29]=std::uint64_t(std::int64_t(std::int32_t(linked_->owners[0].stack))); + mods::code::Budget budget{250000,256,0}; + callback_started=true; + mods::code::execute(memory_,registers,entry_,budget,[this](auto a,auto& c,auto& b){service(a,c,b);}); + query_=nullptr;error_.clear();return output_; + }catch(const std::exception& e){ + query_=nullptr;output_.reset();error_=e.what(); + // A fault may leave half-written private smoothing state. Quarantine + // only this local provider; never retry it every frame or stop a race. + quarantined_=callback_started;return {}; + } +} +void Program::service(std::uint32_t address,mods::code::Registers& c,mods::code::Budget&) { + if(++services_>256)fail("presentation service budget exhausted"); + const auto found=linked_->imports.find(address); + if(found==linked_->imports.end()||found->second.dependency!="*"||found->second.owner!=0||found->second.event) + fail("unavailable presentation import"); + const auto& name=found->second.name; + const auto a=std::uint32_t(c.integer[4]),b=std::uint32_t(c.integer[5]),d=std::uint32_t(c.integer[6]); + if(name=="dkr_sdk_version"){c.integer[2]=1;return;} + if(name=="dkr_setting_number") { + const auto key=text(memory_,a);const auto value=settings_.find(key); + if(value==settings_.end()||!value->is_number())fail("unknown or non-numeric local setting"); + c.floating[0]=std::bit_cast(value->get());return; + } + if(name=="dkr_view_snapshot"){copy_out(memory_,a,snapshot_);c.integer[2]=1;return;} + if(name=="dkr_view_input"){copy_out(memory_,a,input_);c.integer[2]=1;return;} + if(name=="dkr_view_submit") { + if(output_)fail("two camera outputs were submitted for one view"); + const auto pose=copy_in(memory_,a);validate(pose);output_=pose;c.integer[2]=1;return; + } + if(name=="dkr_view_query") { + const auto q=copy_in(memory_,a); + if(q.api!=1||q.size!=sizeof(q)||q.view!=snapshot_.view||q.exclude_racer!=snapshot_.racer_id|| + !zero(q.reserved)||!point(q.origin)||!point(q.target)||!std::isfinite(q.radius)||q.radius<0||q.radius>128||q.include_dynamic>1) + fail("invalid camera clearance query"); + DkrViewHit hit{};hit.api=1;hit.size=sizeof(hit);hit.status=DKR_VIEW_QUERY_UNSUPPORTED; + if(++queries_>8)hit.status=DKR_VIEW_QUERY_BUDGET; + else if(query_&&*query_&&(snapshot_.capabilities&DKR_VIEW_STATIC_COLLISION)&& + (!q.include_dynamic||(snapshot_.capabilities&DKR_VIEW_DYNAMIC_COLLISION)))hit=(*query_)(q); + if(hit.api!=1||hit.size!=sizeof(hit)||hit.status>DKR_VIEW_QUERY_BUDGET||hit.reserved||hit.reserved2|| + !std::isfinite(hit.fraction)||hit.fraction<0||hit.fraction>1||!point(hit.position)||!point(hit.normal)) + fail("invalid retained collision result"); + copy_out(memory_,b,hit);c.integer[2]=hit.status;return; + } + if(name=="dkr_view_math") { + const float x=std::bit_cast(b),y=std::bit_cast(d);float result=0; + if(!std::isfinite(x)||!std::isfinite(y))fail("non-finite presentation arithmetic"); + switch(a) { + case DKR_VIEW_SIN:result=std::sin(x);break;case DKR_VIEW_COS:result=std::cos(x);break; + case DKR_VIEW_ATAN2:result=std::atan2(x,y);break; + case DKR_VIEW_EXP:if(x>20||x<-100)fail("exponential outside presentation range");result=std::exp(x);break; + case DKR_VIEW_SQRT:if(x<0)fail("negative camera distance");result=std::sqrt(x);break; + default:fail("unknown presentation arithmetic operation"); + } + if(!std::isfinite(result))fail("invalid presentation arithmetic result"); + c.integer[2]=std::bit_cast(result);return; + } + fail("unimplemented presentation service"); +} +} diff --git a/runtime-recomp/src/game/camera_presentation.hpp b/runtime-recomp/src/game/camera_presentation.hpp new file mode 100644 index 00000000..35743b26 --- /dev/null +++ b/runtime-recomp/src/game/camera_presentation.hpp @@ -0,0 +1,42 @@ +#pragma once +#include "mods/magic_code_link.hpp" +#include +#include +#include +#ifndef DKR_PRESENTATION_HOST +#define DKR_PRESENTATION_HOST +#endif +#include "dkr/presentation.h" + +namespace dkr::runtime::camera { +// The caller owns one Program on its presentation thread. It must never bind +// live guest RAM here. Only typed copies enter a callback; the query callback +// must use a retained scene, not simulation globals or mutable retail scratch. +class Program final { +public: + using Query=std::function; + Program(std::shared_ptr,mods::code::Json settings,std::string revision); + static mods::code::LinkPolicy policy(); + bool settings(const mods::code::Json&) noexcept; + std::optional frame(const DkrViewSnapshot&,const DkrViewInput&,const Query&); + const std::string& error() const {return error_;} + bool quarantined()const {return quarantined_;} + const mods::code::Package& package()const {return *linked_->owners[0].package;} +private: + std::shared_ptr linked_; + mods::Bytes ram_; + mods::code::Memory memory_; + mods::code::Json settings_; + DkrViewSnapshot snapshot_{}; + DkrViewInput input_{}; + const Query* query_=nullptr; + std::optional output_; + std::uint32_t entry_=0; + std::array held_{}; + unsigned services_=0,queries_=0; + bool quarantined_=false; + std::string error_; + void service(std::uint32_t,mods::code::Registers&,mods::code::Budget&); + void validate(const DkrViewPose&)const; +}; +} diff --git a/runtime-recomp/src/game/camera_rt64.cpp b/runtime-recomp/src/game/camera_rt64.cpp new file mode 100644 index 00000000..2563df1f --- /dev/null +++ b/runtime-recomp/src/game/camera_rt64.cpp @@ -0,0 +1,248 @@ +#include "party_camera.hpp" +#include "camera_scene.hpp" +#include "game_payload.hpp" +#include "camera_rt64.hpp" +#include "revision_addresses.hpp" +#include +#include +#include +#include + +namespace dkr::runtime::camera { +namespace { +std::uint32_t word(std::span bytes,std::uint32_t p) { + p&=0xFFFFFFU;if(p>0x800000U-4)return 0; + std::uint32_t out;std::memcpy(&out,bytes.data()+p,4);return out; +} +std::uint32_t racer_object(std::span bytes,rom::Revision revision,unsigned view) { + const auto& a=revision==rom::Revision::UsV77?revision_addresses::kUsV77:revision_addresses::kUsV80; + auto count=word(bytes,a.NumberOfRacers),list=word(bytes,a.RacersByPort); + if(count>8||list<0x80000000U||list>0x807FFFE0U)return 0; + for(unsigned i=0;i0x807FFF80U)continue; + auto state=word(bytes,object+0x64); + if(state<0x80000000U||state>0x807FFDDCU)continue; + std::int16_t player;std::memcpy(&player,bytes.data()+((state-0x80000000U)^2U),2); + if(player==int(view))return object; + } + return 0; +} +} +void RenderPass::prepare(std::span retained,std::span decoder, + FrameStamp stamp,std::span events,unsigned online_owner, + unsigned physical_profile,bool full_screen) { + finish();ranges_.clear();retained_=retained;decoder_=decoder;ended_=0; + const auto next=activation(); + const bool director=stamp.online&&dkr::runtime::spectators::spectator_director.load(std::memory_order_relaxed); + try { + if(next!=active_){session_.reset();active_=next;failed_views_=0;shadow_owners_.clear();if(next)session_=std::make_unique(next->frozen,next->revision,*next->settings,actions());} + if((!session_&&!director)||retained.size()<0x800000||decoder.size() boundaries; + if(!events.empty()) { + for(const auto& e:events)if(e.kind==DKR_OWNED_ONLINE_VIEW||e.kind==DKR_OWNED_CAMERA_WORLD_END) { + if(!dkr_owned_draw_event_valid(&e))throw mods::Error("Invalid retained camera boundary."); + boundaries.push_back({e.address&0xFFFFFFU,e.token,e.kind==DKR_OWNED_CAMERA_WORLD_END}); + } + } else { + for(const auto& marker:presentation::camera_world_boundaries()) + boundaries.push_back({marker.address&0xFFFFFFU,marker.marker.token, + marker.marker.kind==presentation::PresentationMarkerKind::CameraWorldEnd}); + } + if(boundaries.size()>8)return; + std::sort(boundaries.begin(),boundaries.end(),[](auto a,auto b){return a.address owners; + for(std::size_t i=0;i+1=4||b.address>=e.address||e.address>0x7FFFF8U)return; + if(stamp.online&&b.view!=online_owner)continue; + const auto profile=stamp.online?physical_profile:b.view; + owners.push_back({b.view,profile,profile,stamp.aspect}); + ranges_.push_back({b.address,e.address,b.view,profile,{}}); + } + stamp.capabilities=DKR_VIEW_POSE|DKR_VIEW_PROJECTION|DKR_VIEW_REAR_COVERAGE| + DKR_VIEW_HIDE_LOCAL_MODEL|DKR_VIEW_STATIC_COLLISION; + if(director) { + static thread_local SnapshotReader reader; + static thread_local std::shared_ptr collision; + static thread_local std::uint64_t collision_key=0; + const auto* payload=dkr::runtime::active_payload();if(!payload){ranges_.clear();return;} + const auto revision=payload->revision; + const auto address=revision==rom::Revision::UsV77?0x800DC918U:0x800DCE88U; + const auto model=word(retained,address); + const auto identity=(std::uint64_t(stamp.epoch)<<32)^stamp.scene^model; + if(identity!=collision_key) {collision_key=identity;collision.reset();try{collision=CollisionScene::capture(retained,model,identity);}catch(const std::exception&) {}} + for(auto& range:ranges_) { + const auto state=reader.capture(retained,revision,range.view,stamp); + if(!state||!(state->flags&DKR_VIEW_DRIVING)||(state->flags&DKR_VIEW_SCRIPTED))continue; + const auto& s=*state;auto& p=range.pose;p.api=1;p.size=sizeof(p);p.view=range.view;p.mode=DKR_VIEW_CINEMATIC; + p.racer_id=s.racer_id;p.racer_generation=s.racer_generation;p.visibility=DKR_VIEW_SHOW_MODEL;p.cut=bool(s.flags&DKR_VIEW_CUT); + const float side=(stamp.frame/240U)%2U?1.F:-1.F; + p.position[0]=s.position[0]+std::sin(s.yaw)*160.F+std::cos(s.yaw)*240.F*side; + p.position[1]=s.position[1]+100.F; + p.position[2]=s.position[2]+std::cos(s.yaw)*160.F-std::sin(s.yaw)*240.F*side; + if(collision) { + DkrViewQuery q{};q.api=1;q.size=sizeof(q);q.view=range.view;q.radius=8; + std::copy_n(s.position,3,q.origin);q.origin[1]+=30;std::copy_n(p.position,3,q.target); + const auto hit=collision->query(q); + if(hit.status==DKR_VIEW_QUERY_HIT)for(unsigned axis=0;axis<3;++axis)p.position[axis]=q.origin[axis]+(q.target[axis]-q.origin[axis])*std::max(0.F,hit.fraction-.04F); + else if(hit.status!=DKR_VIEW_QUERY_NONE){std::copy_n(s.original_position,3,p.position);} + } else std::copy_n(s.original_position,3,p.position); + const float dx=s.position[0]-p.position[0],dy=s.position[1]+20.F-p.position[1],dz=s.position[2]-p.position[2]; + p.yaw=std::atan2(-dx,-dz);p.pitch=std::atan2(-dy,std::sqrt(dx*dx+dz*dz));p.roll=0; + p.vertical_fov=s.original_fov;p.near_plane=s.original_near;p.far_plane=s.original_far; + } + } else { + const auto batch=session_->evaluate(retained,stamp,owners); + if(!batch.diagnostic.empty())report(batch.diagnostic); + for(auto& range:ranges_)if(batch.poses[range.view])range.pose=*batch.poses[range.view]; + } + + ranges_.erase(std::remove_if(ranges_.begin(),ranges_.end(),[&](const Range& r){return !r.pose.api||r.pose.mode==DKR_VIEW_ORIGINAL||(failed_views_&(1U<end) { + // Decoders synchronously own/copy all geometry and TMEM bytes. Restore + // before the original HUD/weather or another player's view resumes. + std::memcpy(decoder_.data(),prepared_.data(),0x800000); + ended_=physical;drawing_=nullptr; + } + if(drawing_||ended_==physical)return; + const auto range=std::find_if(ranges_.begin(),ranges_.end(),[&](const Range& r){return r.begin==physical;}); + if(range==ranges_.end())return; + // Preserve local HUD/sky/aspect corrections and readbacks before this + // view. The immutable simulation lease remains read-only throughout. + std::memcpy(prepared_.data(),decoder_.data(),0x800000); + dkr_camera_draw_request request{};request.view=range->view;request.scene=scene_; + request.return_address=range->end|0x80000000U;request.full_screen=full_screen_;request.aspect=aspect_; + const auto& pose=range->pose; + if(pose.visibility==DKR_VIEW_HIDE_VEHICLE)request.hide_object=racer_object(retained_,active_->revision,range->view); + std::copy_n(pose.position,3,request.position);request.yaw=pose.yaw;request.pitch=pose.pitch;request.roll=pose.roll; + request.fov=pose.vertical_fov;request.near_plane=pose.near_plane;request.far_plane=pose.far_plane; + const auto revision=active_?active_->revision:(dkr::runtime::active_payload()?dkr::runtime::active_payload()->revision:rom::Revision::UsV77); + bool okay=revision==rom::Revision::UsV77? + dkr_camera_v77_shadow_draw(decoder_.data(),decoder_.size(),&request,&draw_,&shadows_): + dkr_camera_v80_shadow_draw(decoder_.data(),decoder_.size(),&request,&draw_,&shadows_); + if(okay)try {capture_shadows(*range);}catch(const std::exception& e) { + report(e.what());okay=false;draw_.failure="Camera shadow metadata was rejected."; + } + if(!okay) { + std::memcpy(decoder_.data(),prepared_.data(),0x800000); + failed_views_|=1U<view; + report(draw_.failure?draw_.failure:"Camera scene could not be drawn.");presented(range->profile,false);return; + } + const std::uint32_t branch[2]={0x06010000U,draw_.start&0xFFFFFFU}; + std::memcpy(decoder_.data()+range->begin,branch,8);drawing_=&*range; + presented(range->profile,true); +} +bool RenderPass::replaced_command(std::uint32_t physical)const { + return (ended_&&ended_==physical) || (drawing_&&physical>=drawing_->begin&&physicalend); +} +bool RenderPass::matrix(std::uint32_t physical,presentation::MatrixInterpolation& result)const { + if(!drawing_||physical<0x900000U||physical>=0x980000U)return false; + result={}; + // No address-only vertex matching: the native draw closure can change + // topology from one camera frame to the next. + for(unsigned i=0;ipose.cut?0U:m.identity; + result.camera_root=(m.root&1U)!=0; + result.procedural_water=(m.root&2U)!=0; + result.interpolate_vertices=result.interpolate_texcoords=result.interpolate_tiles=result.procedural_water; + break; + } + return true; +} +// Shadow metadata belongs to this renderer, scene and viewport. Reuse the +// established canonical/rigid-shadow renderer, not arbitrary vertex matching. +namespace { +std::uint64_t new_camera_shadow_history() { + // A renderer/session restart can reuse the same scene/object addresses. + // Allocate an owner lifetime in a host-local namespace, never game state. + static std::atomic next{1}; + const auto serial=next.fetch_add(1,std::memory_order_relaxed); + return serial<0x0100000000000000ULL ? 0xC600000000000000ULL|serial : 0ULL; +} +} +void RenderPass::capture_shadows(const Range& range) { + shadow_markers_.clear(); + const auto valid=[](std::uint32_t p,std::uint32_t size) { + return p>=0x80000000U&&p<=0x80800000U-size; + }; + const auto half=[&](std::uint32_t p) { + std::uint16_t v=0; if(valid(p,2)&&!(p&1))std::memcpy(&v,decoder_.data()+((p&0xFFFFFFU)^2U),2);return v; + }; + const auto byte=[&](std::uint32_t p) { + return valid(p,1)?decoder_[(p&0xFFFFFFU)^3U]:std::uint8_t(0); + }; + const auto number=[&](std::uint32_t p) { + const auto bits=word(decoder_,p);float v;std::memcpy(&v,&bits,4);return v; + }; + for(unsigned i=0;i0x80900000U|| + (source.begin&7)||(source.end&7)||!valid(source.object,0x68)||!valid(source.shadow,0x10)) + throw mods::Error("Camera shadow observation is invalid."); + const auto object=source.object,header=word(decoder_,object+0x40); + const auto kind=half(object+0x48),asset=half(object+0x4A); + const auto start=std::int16_t(half(source.shadow+8)),end=std::int16_t(half(source.shadow+10)); + ShadowMarker marker{};marker.address=source.begin&0xFFFFFFU;marker.begin=true; + marker.batches=std::uint8_t(start>=0&&end>=start? + std::min(end-start,presentation::kMaximumCanonicalShadowBatches):0); + auto owner=std::find_if(shadow_owners_.begin(),shadow_owners_.end(),[&](const ShadowOwner& o) { + return o.object==object&&o.header==header&&o.asset==asset&&o.kind==kind&& + o.view==range.view&&o.continuity==shadow_continuity_[range.view]; + }); + if(owner==shadow_owners_.end()&&shadow_owners_.size()<512U) { + shadow_owners_.push_back({object,header,asset,kind,range.view,shadow_continuity_[range.view],new_camera_shadow_history()}); + owner=shadow_owners_.end()-1; + } + if(owner!=shadow_owners_.end()&&owner->history&&!range.pose.cut) { + marker.identity=presentation::normalise_identity(std::uint32_t(owner->history)^std::uint32_t(owner->history>>32)^0x43534844U); + marker.history=owner->history; + } + int vehicle=-1; + if(kind==1) { + const auto racer=word(decoder_,object+0x64); + if(valid(racer,0x1D8)) { + vehicle=byte(racer+0x1D6); + if(vehicle>2)vehicle=byte(racer+0x1D7); + if(vehicle>2)vehicle=-1; + } + } + const auto texture=word(decoder_,source.shadow+4); + marker.policy=presentation::rigid_shadow_owner_policy(kind,std::int8_t(vehicle),byte(texture),asset&presentation::kObjectAssetIdMask); + marker.motion={{number(object+12),number(object+16),number(object+20)},std::int16_t(half(object+2)),true}; + marker.motion.valid=std::isfinite(marker.motion.position.x)&&std::isfinite(marker.motion.position.y)&& + std::isfinite(marker.motion.position.z); + shadow_markers_.push_back(marker); + marker.address=source.end&0xFFFFFFU;marker.begin=false;shadow_markers_.push_back(marker); + } + std::stable_sort(shadow_markers_.begin(),shadow_markers_.end(),[](const auto& a,const auto& b){return a.address RenderPass::shadows_at(std::uint32_t physical)const { + if(!drawing_||physical<0x800000U||physical>=0x900000U)return {}; + const auto first=std::lower_bound(shadow_markers_.begin(),shadow_markers_.end(),physical, + [](const ShadowMarker& marker,std::uint32_t address){return marker.addressaddress==physical)++last; + return {first,std::size_t(last-first)}; +} + +void RenderPass::finish()noexcept { + shadow_markers_.clear(); + if(drawing_&&decoder_.size()>=0x800000&&prepared_.size()>=0x800000) + std::memcpy(decoder_.data(),prepared_.data(),0x800000); + drawing_=nullptr;ended_=0; +} +} diff --git a/runtime-recomp/src/game/camera_rt64.hpp b/runtime-recomp/src/game/camera_rt64.hpp new file mode 100644 index 00000000..9b0e2d76 --- /dev/null +++ b/runtime-recomp/src/game/camera_rt64.hpp @@ -0,0 +1,57 @@ +#pragma once +#include "camera_runtime.hpp" +#include "camera_kernel.h" +#include "camera_shadow_kernel.h" +#include "presentation_identity.hpp" +#include "netplay/experimental_draw_events.h" + +namespace dkr::runtime::camera { +// One decoder owner, reused sequentially for all local views. Never borrows +// live CPU memory and never creates another rollback DecodeWorkspace. +class RenderPass final { +public: + void prepare(std::span retained,std::span decoder, + FrameStamp,std::span events={},unsigned online_owner=4, + unsigned physical_profile=0,bool full_screen=false); + struct ShadowMarker { + std::uint32_t address=0,identity=0; + std::uint64_t history=0; + std::uint8_t batches=0; + bool begin=false; + presentation::RigidShadowOwnerPolicy policy{}; + presentation::ShadowOwnerMotionSample motion{}; + }; + std::span shadows_at(std::uint32_t physical)const; + void before_command(std::uint32_t physical); + bool replaced_command(std::uint32_t physical)const; + bool matrix(std::uint32_t physical,presentation::MatrixInterpolation&)const; + bool enabled()const{return !ranges_.empty();} + void finish()noexcept; +private: + struct Range {std::uint32_t begin,end;unsigned view,profile;DkrViewPose pose;}; + std::shared_ptr active_; + std::unique_ptr session_; + std::span retained_; + std::span decoder_; + // Immutable restoration journal for the prepared retail bytes, not + // another mutable DecodeWorkspace or an eye/simulation image. + std::vector prepared_; + std::vector ranges_; + struct ShadowOwner { + std::uint32_t object,header,asset,kind,view,continuity; + std::uint64_t history; + }; + void capture_shadows(const Range&); + std::vector shadow_owners_; + std::vector shadow_markers_; + std::array shadow_continuity_{}; + std::array shadow_cut_frame_{{UINT64_MAX,UINT64_MAX,UINT64_MAX,UINT64_MAX}}; + dkr_camera_shadows shadows_{}; + dkr_camera_draw_result draw_{}; + const Range* drawing_=nullptr; + std::uint32_t ended_=0,scene_=0; + std::uint32_t failed_scene_=0,failed_views_=0; + bool full_screen_=false; + float aspect_=4.F/3.F; +}; +} diff --git a/runtime-recomp/src/game/camera_runtime.cpp b/runtime-recomp/src/game/camera_runtime.cpp new file mode 100644 index 00000000..ffe81260 --- /dev/null +++ b/runtime-recomp/src/game/camera_runtime.cpp @@ -0,0 +1,56 @@ +#include "camera_runtime.hpp" +#include +#include + +namespace dkr::runtime::camera { +namespace { +AtomicSnapshot current; +Actions input; +std::array,4> owned{}; +std::mutex error_mutex; +std::string error; +} +std::shared_ptr activation(){return current.load();} +Actions& actions(){return input;} +std::string diagnostic(){std::scoped_lock lock(error_mutex);return error;} +void report(const std::string& message) { + std::scoped_lock lock(error_mutex); + if(error!=message&&!message.empty())std::fprintf(stderr,"[camera++][fallback] %s\n",message.c_str()); + error=message; +} +void begin(const std::filesystem::path& config,const std::string& version,rom::Revision revision,bool online,bool development_tools)noexcept { + reset(); + try { + mods::code::Library library(config/"mods"/"custom-magic-codes",version); + auto set=library.freeze(revision==rom::Revision::UsV77?"us.v77":"us.v80",false,Program::policy().modules,{},true); + if(set.ordered.empty()) { + if(!development_tools)return; + auto next=std::make_shared();next->revision=revision;next->online=online; + next->settings=std::make_shared();std::array defaults;for(auto& value:defaults)value=Json::object();next->settings->publish(defaults); + current.store(std::move(next));return; + } + if(set.ordered.size()!=1)throw mods::Error("Enable only one camera mod."); + auto next=std::make_shared();next->frozen=std::move(set);next->revision=revision;next->online=online; + next->settings=std::make_shared(); + const auto& package=next->frozen.ordered[0]; + next->editor=std::make_shared(package,next->frozen.settings.at(package->id), + next->frozen.data.at(package->id),*next->settings); + // Link/validate without executing. Rejection only disables the camera, + // never the shared race or its save route. + Program checked(package,next->frozen.settings.at(package->id),revision==rom::Revision::UsV77?"us.v77":"us.v80"); + current.store(std::move(next)); + std::fprintf(stderr,"[camera++][ready] provider=%s domain=local-presentation online=%u\n",package->id.c_str(),unsigned(online)); + }catch(const std::exception& e){report(e.what());} +} +void reset()noexcept { + current.store(nullptr);editor_open.store(false);input.reset(); + for(auto& port:owned)port.store(0); + std::scoped_lock lock(error_mutex);error.clear(); +} +void presented(unsigned profile,bool active) { + if(profile<4)owned[profile].store(active?(std::chrono::steady_clock::now()+std::chrono::milliseconds(300)).time_since_epoch().count():0); +} +bool owns_controls(unsigned profile) { + return profile<4&&!editor_open.load()&&owned[profile].load()>std::chrono::steady_clock::now().time_since_epoch().count(); +} +} diff --git a/runtime-recomp/src/game/camera_runtime.hpp b/runtime-recomp/src/game/camera_runtime.hpp new file mode 100644 index 00000000..6c7eb325 --- /dev/null +++ b/runtime-recomp/src/game/camera_runtime.hpp @@ -0,0 +1,23 @@ +#pragma once +#include "camera_session.hpp" +#include +#include + +namespace dkr::runtime::camera { +struct Activation { + mods::code::FrozenSet frozen; + std::shared_ptr settings; + std::shared_ptr editor; + rom::Revision revision=rom::Revision::Unsupported; + bool online=false; +}; +void begin(const std::filesystem::path& config,const std::string& version,rom::Revision,bool online,bool development_tools=false)noexcept; +void reset()noexcept; +std::shared_ptr activation(); +Actions& actions(); +bool owns_controls(unsigned physical_profile); +void presented(unsigned physical_profile,bool active); +std::string diagnostic(); +void report(const std::string&); +inline std::atomic editor_open{false}; +} diff --git a/runtime-recomp/src/game/camera_scene.cpp b/runtime-recomp/src/game/camera_scene.cpp new file mode 100644 index 00000000..28997e54 --- /dev/null +++ b/runtime-recomp/src/game/camera_scene.cpp @@ -0,0 +1,103 @@ +#include "camera_scene.hpp" +#include "camera_obstruction_math.h" +#include +#include +#include + +namespace dkr::runtime::camera { +namespace { +struct Reader { + std::span ram; + std::uint32_t offset(std::uint32_t address,std::size_t count,unsigned alignment)const { + if(address<0x80000000U||address>=0x80800000U||(address&(alignment-1)))throw mods::Error("Invalid retained camera-scene address."); + auto p=address-0x80000000U; + if(p>ram.size()||count>ram.size()-p||count>0x800000U-p)throw mods::Error("Retained camera-scene span exceeds RDRAM."); + return p; + } + std::uint32_t word(std::uint32_t a)const{std::uint32_t v;auto p=offset(a,4,4);std::memcpy(&v,ram.data()+p,4);return v;} + unsigned half(std::uint32_t a)const{std::uint16_t v;auto p=offset(a,2,2);std::memcpy(&v,ram.data()+(p^2U),2);return v;} + unsigned byte(std::uint32_t a)const{return ram[offset(a,1,1)^3U];} + std::array vertex(std::uint32_t a)const { + return {float(std::int16_t(half(a))),float(std::int16_t(half(a+2))),float(std::int16_t(half(a+4)))}; + } +}; +dkr_cam_vec vec(const float* p){return dkr_cam_v(p[0],p[1],p[2]);} +bool finite(const float* p){return std::all_of(p,p+3,[](float x){return std::isfinite(x)&&std::abs(x)<=1.e7F;});} +} +std::shared_ptr CollisionScene::capture(std::span snapshot, + std::uint32_t model,std::uint64_t generation) { + if(snapshot.size()<0x800000U||snapshot.size()%4)throw mods::Error("Camera scene requires an immutable complete retail image."); + Reader r{snapshot};r.offset(model,0x40,4); + const auto count=r.half(model+0x1A),textures=r.half(model+0x18),segments=r.word(model+4),table=r.word(model); + if(!count||count>512||textures>256)throw mods::Error("Camera-scene model exceeds its static capacity."); + r.offset(segments,count*0x44,4);if(textures)r.offset(table,textures*8,4); + auto result=std::shared_ptr(new CollisionScene);result->generation_=generation; + for(unsigned segment=0;segment32767||fc>32767||bc>4096)throw mods::Error("Camera-scene segment exceeds its bounds."); + r.offset(vertices,vc*10,2);r.offset(faces,fc*16,4);r.offset(batches,(bc+1)*12,4); + for(unsigned batch=0;batchend||end>fc||base>vc)throw mods::Error("Invalid camera-scene batch range."); + if(texture!=255&&texture>=textures)throw mods::Error("Invalid camera-scene surface."); + const auto surface=texture==255?255:r.byte(table+texture*8+7); + if(!dkr_cam_solid_batch(r.word(b+8),int(surface)))continue; + Chunk chunk{{1.e9F,1.e9F,1.e9F},{-1.e9F,-1.e9F,-1.e9F},std::uint32_t(result->triangles_.size()),0}; + for(unsigned face=first;facetriangles_.size()>=131072)throw mods::Error("Camera-scene memory budget exceeded."); + Triangle triangle;std::array* corner[]{&triangle.a,&triangle.b,&triangle.c}; + for(unsigned i=0;i<3;++i) { + const auto index=base+r.byte(f+i+1);if(index>=vc)throw mods::Error("Invalid camera-scene triangle vertex."); + *corner[i]=r.vertex(vertices+index*10); + for(unsigned axis=0;axis<3;++axis) { + chunk.lo[axis]=std::min(chunk.lo[axis],(*corner[i])[axis]); + chunk.hi[axis]=std::max(chunk.hi[axis],(*corner[i])[axis]); + } + } + result->triangles_.push_back(triangle);++chunk.count; + } + if(chunk.count) { + if(result->chunks_.size()>=16384)throw mods::Error("Camera-scene batch budget exceeded."); + result->chunks_.push_back(chunk); + } + } + } + return result; +} +std::size_t CollisionScene::bytes()const{return triangles_.size()*sizeof(Triangle)+chunks_.size()*sizeof(Chunk);} +DkrViewHit CollisionScene::query(const DkrViewQuery& q,unsigned budget)const { + DkrViewHit out{};out.api=1;out.size=sizeof(out);out.status=DKR_VIEW_QUERY_UNSUPPORTED; + if(q.api!=1||q.size!=sizeof(q)||q.include_dynamic||!finite(q.origin)||!finite(q.target)|| + !std::isfinite(q.radius)||q.radius<0||q.radius>128||q.reserved[0]||q.reserved[1])return out; + out.status=DKR_VIEW_QUERY_NONE;out.fraction=1; + std::copy_n(q.target,3,out.position); + std::array lo,hi; + for(unsigned i=0;i<3;++i){lo[i]=std::min(q.origin[i],q.target[i])-q.radius;hi[i]=std::max(q.origin[i],q.target[i])+q.radius;} + double best=2;const Triangle* selected=nullptr; + for(const auto& chunk:chunks_) { + bool overlap=true;for(unsigned i=0;i<3;++i)overlap&=lo[i]<=chunk.hi[i]&&hi[i]>=chunk.lo[i]; + if(!overlap)continue; + for(unsigned i=0;ib.data()),vec(selected->a.data())),dkr_cam_sub(vec(selected->c.data()),vec(selected->a.data()))); + const auto length=std::sqrt(dkr_cam_dot(normal,normal)); + if(length>1.e-8) { + normal=dkr_cam_scale(normal,1/length); + if(dkr_cam_dot(normal,dkr_cam_sub(vec(q.origin),vec(selected->a.data())))<0)normal=dkr_cam_scale(normal,-1); + out.normal[0]=float(normal.x);out.normal[1]=float(normal.y);out.normal[2]=float(normal.z); + } + } + return out; +} +} diff --git a/runtime-recomp/src/game/camera_scene.hpp b/runtime-recomp/src/game/camera_scene.hpp new file mode 100644 index 00000000..fc4f21b4 --- /dev/null +++ b/runtime-recomp/src/game/camera_scene.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "camera_presentation.hpp" +#include +#include + +namespace dkr::runtime::camera { +// Immutable copied STATIC collision geometry. No VM or graphics decoder owns +// a reference to the producer's RAM. Dynamic doors/platforms are intentionally +// not claimed by this adapter; callers must not set the dynamic capability. +class CollisionScene final { +public: + static std::shared_ptr capture(std::span snapshot, + std::uint32_t level_model,std::uint64_t scene_generation); + DkrViewHit query(const DkrViewQuery&,unsigned face_budget=8192)const; + std::uint64_t generation()const {return generation_;} + std::size_t bytes()const; +private: + struct Triangle {std::array a,b,c;}; + struct Chunk {std::array lo,hi;std::uint32_t first,count;}; + std::vector triangles_; + std::vector chunks_; + std::uint64_t generation_=0; +}; +} diff --git a/runtime-recomp/src/game/camera_session.cpp b/runtime-recomp/src/game/camera_session.cpp new file mode 100644 index 00000000..0a9f54ec --- /dev/null +++ b/runtime-recomp/src/game/camera_session.cpp @@ -0,0 +1,92 @@ +#include "camera_session.hpp" +#include "mods/sdk2_bridge.hpp" +#include +#include + +namespace dkr::runtime::camera { +PresentationSession::PresentationSession(const mods::code::FrozenSet& selected,rom::Revision revision, + const SettingsChannel& settings,Actions& actions):revision_(revision),settings_(settings),actions_(actions) { + if(selected.ordered.empty())return; // Original DKR, no local provider. + if(selected.ordered.size()!=1)throw mods::Error("Enable only one camera provider."); + if(revision!=rom::Revision::UsV77&&revision!=rom::Revision::UsV80) + throw mods::Error("Camera presentation does not support this Game Pak."); + const auto& package=selected.ordered[0]; + const auto configured=selected.settings.find(package->id); + program_=std::make_unique(package,configured==selected.settings.end()?Json::object():configured->second, + revision==rom::Revision::UsV77?"us.v77":"us.v80"); +} +ViewBatch PresentationSession::evaluate(std::span retained,const FrameStamp& stamp, + std::span owners) { + ViewBatch batch; + if(owners.size()>4)return batch; + if(program_&&program_->quarantined())batch.diagnostic=program_->error(); + try { + const auto configuration=settings_.current(); + if(!configuration)throw mods::Error("Camera profiles are not ready."); + unsigned seen_views=0,seen_physical=0; + for(const auto& owner:owners) { + if(owner.view>=4||owner.physical_profile>=4||owner.settings_profile>=4|| + !std::isfinite(owner.aspect)||owner.aspect<0.1F||owner.aspect>16|| + (seen_views&(1U< inputs{}; + bool driving=false; + for(const auto& owner:owners) { + frame.aspect=owner.aspect; + inputs[owner.view]=actions_.consume(owner.physical_profile,frame.dt); + auto state=reader_.capture(retained,revision_,owner.view,frame); + batch.snapshots[owner.view]=state; + driving|=state&&(state->flags&DKR_VIEW_DRIVING)&&!(state->flags&DKR_VIEW_SCRIPTED); + } + // No mesh rebuilding or mod execution in Track Select/title menus. + // Consume their local taps instead of replaying them in the race. + if(!driving)return batch; + if(frame.capabilities&DKR_VIEW_STATIC_COLLISION) { + if(retained.size()<0x800000U)throw mods::Error("Camera presentation requires a complete retained image."); + const auto address=revision_==rom::Revision::UsV77?0x800DC918U:0x800DCE88U; + std::uint32_t model;std::memcpy(&model,retained.data()+address-0x80000000U,4); + if(!scene_attempted_||scene_!=stamp.scene||epoch_!=stamp.epoch||model_!=model) { + collision_.reset();scene_error_.clear();scene_attempted_=true; + scene_=stamp.scene;epoch_=stamp.epoch;model_=model; + try {collision_=CollisionScene::capture(retained,model,(std::uint64_t(epoch_)<<32U)|scene_);} + catch(const std::exception& e){scene_error_=e.what();} + } + if(!collision_)frame.capabilities&=~std::uint32_t(DKR_VIEW_STATIC_COLLISION); + } + // This implementation has no retained moving-platform/door adapter. + // Never accidentally promote static queries to dynamic capability. + frame.capabilities&=~std::uint32_t(DKR_VIEW_DYNAMIC_COLLISION); + const Program::Query query=[scene=collision_](const DkrViewQuery& request) { + if(scene)return scene->query(request); + DkrViewHit hit{};hit.api=1;hit.size=sizeof(hit);hit.status=DKR_VIEW_QUERY_UNSUPPORTED;return hit; + }; + for(const auto& owner:owners) { + frame.aspect=owner.aspect; + auto state=batch.snapshots[owner.view]; + if(!state)continue; // No racer/menu/scripted fallback, not a fault. + state->capabilities=frame.capabilities; + batch.snapshots[owner.view]=state; + auto input=inputs[owner.view]; + batch.xr_recenter|=(input.pressed&DKR_VIEW_XR_RECENTER)!=0; + // Explicit tool ownership temporarily suspends Camera++ only for + // this local viewport. Releasing it restores the existing provider. + const auto local_camera=dkr::sdk2::camera_state(owner.view); + if(local_camera.cut&&!local_camera.enabled)state->flags|=DKR_VIEW_CUT; + if(auto pose=dkr::sdk2::camera_frame(*state,input,retained)){batch.poses[owner.view]=*pose;dkr::sdk2::debug_frame(*state,batch.poses[owner.view],unsigned(owners.size()));continue;} + if(!program_||program_->quarantined()){dkr::sdk2::debug_frame(*state,{},unsigned(owners.size()));continue;} + if(!program_->settings(configuration->views[owner.settings_profile])) + throw mods::Error(program_->error()); + batch.poses[owner.view]=program_->frame(*state,input,query); + dkr::sdk2::debug_frame(*state,batch.poses[owner.view],unsigned(owners.size())); + if(program_->quarantined()) { + batch.poses={};batch.diagnostic=program_->error();return batch; + } + } + batch.diagnostic=scene_error_; + }catch(const std::exception& e){batch.poses={};batch.diagnostic=e.what();} + return batch; +} +} diff --git a/runtime-recomp/src/game/camera_session.hpp b/runtime-recomp/src/game/camera_session.hpp new file mode 100644 index 00000000..9e6c8905 --- /dev/null +++ b/runtime-recomp/src/game/camera_session.hpp @@ -0,0 +1,43 @@ +#pragma once +#include "camera_settings.hpp" +#include "camera_actions.hpp" +#include "camera_snapshot.hpp" +#include "camera_scene.hpp" +#include "mods/magic_code_library.hpp" + +namespace dkr::runtime::camera { +struct ViewOwner { + unsigned view=0; // Actual native racer/controller identity. + unsigned physical_profile=0; // Device owner; not assumed to equal view. + unsigned settings_profile=0; // Local saved profile; never a remote slot. + float aspect=4.F/3.F; +}; +struct ViewBatch { + std::array,4> poses; + std::array,4> snapshots; + bool xr_recenter=false; + std::string diagnostic; +}; +// Presentation-thread owner, one local program and one state set per session. +// evaluate() reads a retained image, never the live/rewindable CPU. Evaluate +// ONCE before rendering a batch; both eyes consume the same submitted anchor. +// No mutable decoder is created by this owner and no GPU lifetime is changed. +class PresentationSession final { +public: + PresentationSession(const mods::code::FrozenSet& presentation,rom::Revision, + const SettingsChannel&,Actions&); + ViewBatch evaluate(std::span retained,const FrameStamp&, + std::span owners); + bool active()const{return bool(program_)&&!program_->quarantined();} +private: + rom::Revision revision_; + const SettingsChannel& settings_; + Actions& actions_; + SnapshotReader reader_; + std::unique_ptr program_; + std::shared_ptr collision_; + std::uint32_t scene_=0,epoch_=0,model_=0; + bool scene_attempted_=false; + std::string scene_error_; +}; +} diff --git a/runtime-recomp/src/game/camera_settings.cpp b/runtime-recomp/src/game/camera_settings.cpp new file mode 100644 index 00000000..ca84928f --- /dev/null +++ b/runtime-recomp/src/game/camera_settings.cpp @@ -0,0 +1,111 @@ +#include "camera_settings.hpp" +#include +#include + +namespace dkr::runtime::camera { +namespace { +[[noreturn]] void invalid(const char* text){throw mods::Error(std::string("Camera settings: ")+text);} +std::string label(const Json& value,const char* key) { + const auto& text=value.at(key); + if(!text.is_string()||text.get_ref().empty()|| + text.get_ref().size()>160||text.get_ref().find('\0')!=std::string::npos) + invalid("invalid editor label"); + return text.get(); +} +} +void SettingsChannel::publish(const std::array& views) { + auto next=std::make_shared();next->views=views; + if(++generation_==0)++generation_; + next->generation=generation_;current_.store(std::move(next)); +} +SettingsEditor::SettingsEditor(std::shared_ptr package,const Json& base, + const Json& documents,SettingsChannel& channel):package_(std::move(package)),channel_(channel), + documents_(mods::code::structured_data(documents)) { + if(!package_||!package_->modules.contains("presentation"))invalid("not a presentation package"); + const auto defaults=mods::code::configuration(*package_,Json::object()); + for(auto& view:draft_)view=mods::code::configuration(*package_,base); + if(documents_.contains("camera_profiles"))draft_=decode(documents_.at("camera_profiles")); + applied_=saved_=draft_; + const auto resource=package_->resources.find("camera_editor"); + if(resource==package_->resources.end())invalid("missing editor schema"); + const auto schema=mods::code::parse_document(resource->second,128*1024); + if(!schema.contains("groups")||!schema["groups"].is_array()||schema["groups"].size()>16) + invalid("invalid editor groups"); + std::map options; + for(const auto& option:package_->options)options.emplace(option.at("id").get(),option); + std::set seen; + for(const auto& group:schema["groups"]) { + EditorGroup entry;entry.name=label(group,"name"); + const auto& fields=group.at("options"); + if(!fields.is_array()||fields.empty()||fields.size()>128)invalid("invalid group options"); + for(const auto& field:fields) { + EditorField out;out.id=label(field,"id");out.name=label(field,"name"); + const auto found=options.find(out.id); + if(found==options.end()||!seen.insert(out.id).second)invalid("unknown or duplicate editor option"); + const auto& canonical=found->second;out.type=canonical.at("type").get(); + if(field.value("type",std::string("Number"))!=out.type)invalid("editor type differs from executable manifest"); + if(mods::code::configuration(*package_,Json{{out.id,field.at("default")}}).at(out.id)!=defaults.at(out.id)) + invalid("editor default differs from executable manifest"); + if(out.type=="Number") { + out.minimum=canonical.at("min").get();out.maximum=canonical.at("max").get(); + if(field.at("min")!=canonical.at("min")||field.at("max")!=canonical.at("max")) + invalid("editor bounds differ from executable manifest"); + out.step=field.value("step",out.maximum-out.minimum<=2?0.05:out.maximum-out.minimum<=10?0.1:1.0); + if(!std::isfinite(out.step)||out.step<=0||out.step>1.e7)invalid("invalid editor step"); + } else if(out.type=="Enum") { + if(field.at("options")!=canonical.at("options"))invalid("editor choices differ from executable manifest"); + out.choices=canonical.at("options").get>(); + } else if(out.type!="String")invalid("unsupported editor field type"); + entry.fields.push_back(std::move(out)); + } + groups_.push_back(std::move(entry)); + } + if(seen.size()!=options.size())invalid("editor omits executable settings"); + const auto presets=schema.value("presets",Json::array()); + if(!presets.is_array()||presets.size()>32)invalid("invalid presets"); + std::set names; + for(const auto& preset:presets) { + EditorPreset out;out.name=label(preset,"name"); + if(!names.insert(out.name).second)invalid("duplicate preset name"); + out.defaults=preset.value("defaults",false);out.values=preset.at("values"); + (void)mods::code::configuration(*package_,out.values); + presets_.push_back(std::move(out)); + } + channel_.publish(applied_); +} +std::array SettingsEditor::decode(const Json& document)const { + if(!document.is_object()||document.value("schema",0)!=1||!document.contains("views")|| + !document["views"].is_array()||document["views"].size()!=4)invalid("invalid saved view profiles"); + std::array profiles; + for(unsigned i=0;i<4;++i)profiles[i]=mods::code::configuration(*package_,document["views"][i]); + return profiles; +} +bool SettingsEditor::edit(unsigned view,std::string_view id,const Json& value) { + try { + if(view>=4)invalid("invalid local player"); + auto next=draft_[view];next[std::string(id)]=value; + next=mods::code::configuration(*package_,next);draft_[view]=std::move(next);error_.clear();return true; + }catch(const std::exception& e){error_=e.what();return false;} +} +bool SettingsEditor::preset(unsigned view,unsigned index) { + try { + if(view>=4||index>=presets_.size())invalid("invalid preset selection"); + const auto& preset=presets_[index]; + auto next=preset.defaults?mods::code::configuration(*package_,Json::object()):draft_[view]; + for(const auto& [key,value]:preset.values.items())next[key]=value; + draft_[view]=mods::code::configuration(*package_,next);error_.clear();return true; + }catch(const std::exception& e){error_=e.what();return false;} +} +void SettingsEditor::reset(unsigned view) {draft_.at(view)=mods::code::configuration(*package_,Json::object());} +void SettingsEditor::apply() {channel_.publish(draft_);applied_=draft_;error_.clear();} +void SettingsEditor::revert() {draft_=saved_;apply();} +Json SettingsEditor::save_document()const { + auto out=documents_;out["camera_profiles"]={{"schema",1},{"views",applied_}}; + return mods::code::structured_data(out); +} +void SettingsEditor::saved(const Json& document) { + auto checked=mods::code::structured_data(document); + auto profiles=decode(checked.at("camera_profiles")); + documents_=std::move(checked);saved_=std::move(profiles); +} +} diff --git a/runtime-recomp/src/game/camera_settings.hpp b/runtime-recomp/src/game/camera_settings.hpp new file mode 100644 index 00000000..a390a204 --- /dev/null +++ b/runtime-recomp/src/game/camera_settings.hpp @@ -0,0 +1,62 @@ +#pragma once +#include "camera_presentation.hpp" +#include "atomic_snapshot.hpp" +#include +#include + +namespace dkr::runtime::camera { +using Json=mods::code::Json; +struct EditorField { + std::string id,name,type; + double minimum=0,maximum=0,step=1; + std::vector choices; +}; +struct EditorGroup {std::string name;std::vector fields;}; +struct EditorPreset {std::string name;bool defaults=false;Json values;}; +struct LocalConfiguration { + std::uint64_t generation=0; + std::array views; +}; +// One UI writer publishes immutable configurations; the presentation owner +// acquires one for a whole view batch, never halfway through a stereo pair. +class SettingsChannel final { +public: + std::shared_ptr current()const{return current_.load();} + void publish(const std::array&); +private: + std::uint64_t generation_=0; + AtomicSnapshot current_; +}; +// Generic presentation editor model. Storage belongs to LibraryJobs, never +// this model or the guest mod. No filesystem work occurs while drawing UI. +class SettingsEditor final { +public: + SettingsEditor(std::shared_ptr,const Json& base,const Json& documents, + SettingsChannel&); + const std::vector& groups()const{return groups_;} + const std::vector& presets()const{return presets_;} + const Json& draft(unsigned view)const{return draft_.at(view);} + bool edit(unsigned view,std::string_view id,const Json&); + bool preset(unsigned view,unsigned preset); + void reset(unsigned view); + void revert(); + void apply(); + bool pending()const{return draft_!=applied_;} + bool unsaved()const{return applied_!=saved_;} + const std::string& error()const{return error_;} + // Schedule LibraryJobs::data with this document. Acknowledge ONLY after + // successful storage, using this exact captured document. Newer edits + // made while the worker ran must remain correctly marked unsaved. + Json save_document()const; + void saved(const Json& exact_document); +private: + std::shared_ptr package_; + SettingsChannel& channel_; + Json documents_; + std::array draft_,applied_,saved_; + std::vector groups_; + std::vector presets_; + std::string error_; + std::array decode(const Json&)const; +}; +} diff --git a/runtime-recomp/src/game/camera_shadow_kernel.h b/runtime-recomp/src/game/camera_shadow_kernel.h new file mode 100644 index 00000000..16397e07 --- /dev/null +++ b/runtime-recomp/src/game/camera_shadow_kernel.h @@ -0,0 +1,20 @@ +#ifndef DKR_CAMERA_SHADOW_KERNEL_H +#define DKR_CAMERA_SHADOW_KERNEL_H +#include "camera_kernel.h" +enum { DKR_CAMERA_MAX_SHADOWS = 128 }; +typedef struct dkr_camera_shadow { + uint32_t begin, end, object, shadow; +} dkr_camera_shadow; +typedef struct dkr_camera_shadows { + uint32_t count; + dkr_camera_shadow entries[DKR_CAMERA_MAX_SHADOWS]; +} dkr_camera_shadows; +#ifdef __cplusplus +extern "C" { +#endif +int dkr_camera_v77_shadow_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*,dkr_camera_shadows*); +int dkr_camera_v80_shadow_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*,dkr_camera_shadows*); +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime-recomp/src/game/camera_snapshot.cpp b/runtime-recomp/src/game/camera_snapshot.cpp new file mode 100644 index 00000000..6e7aca33 --- /dev/null +++ b/runtime-recomp/src/game/camera_snapshot.cpp @@ -0,0 +1,89 @@ +#include "camera_snapshot.hpp" +#include "revision_addresses.hpp" +#include "camera_clearance_metadata.h" +#include +#include +#include +#include + +namespace dkr::runtime::camera { +namespace { +constexpr float Pi=3.14159265358979323846F; +struct Reader { + std::span ram; + std::uint32_t offset(std::uint32_t address,unsigned count,unsigned alignment)const { + if(address<0x80000000U||address>=0x80800000U||(address&(alignment-1)))throw mods::Error("Invalid camera snapshot address."); + auto off=address-0x80000000U; + if(off>ram.size()||count>ram.size()-off||count>0x800000U-off)throw mods::Error("Camera snapshot span exceeds retail memory."); + return off; + } + std::uint32_t word(std::uint32_t address)const{auto off=offset(address,4,4);std::uint32_t value;std::memcpy(&value,ram.data()+off,4);return value;} + unsigned half(std::uint32_t address)const{auto off=offset(address,2,2);std::uint16_t value;std::memcpy(&value,ram.data()+(off^2U),2);return value;} + unsigned byte(std::uint32_t address)const{return ram[offset(address,1,1)^3U];} + float real(std::uint32_t address)const{auto value=std::bit_cast(word(address));if(!std::isfinite(value))throw mods::Error("Non-finite camera snapshot value.");return value;} + float angle(std::uint32_t address)const{return float(std::int16_t(half(address)))*(Pi/32768.F);} +}; +float wrap(float value){return std::remainder(value,2*Pi);} +} +std::optional SnapshotReader::capture(std::span ram, + rom::Revision revision,unsigned view,const FrameStamp& frame) { + if(view>=4||ram.size()<0x800000U||ram.size()%4|| + (revision!=rom::Revision::UsV77&&revision!=rom::Revision::UsV80)|| + !std::isfinite(frame.dt)||frame.dt<0||frame.dt>0.1F||!std::isfinite(frame.time)|| + !std::isfinite(frame.aspect)||frame.aspect<0.1F||frame.aspect>16)return {}; + try { + Reader r{ram};const auto& table=revision==rom::Revision::UsV77?revision_addresses::kUsV77:revision_addresses::kUsV80; + const auto count=r.word(table.NumberOfRacers),list=r.word(table.RacersByPort); + if(!count||count>8)return {}; + r.offset(list,count*4,4); + std::uint32_t object=0,racer=0; + // Lists can be reordered by race position and custom rosters. Match + // the actual native player identity, not list entry zero/the host. + for(unsigned i=0;i0?1.F:0.F; + out.original_fov=r.real(table.CurrentCameraFov)*Pi/180.F; + // Verified decomp CAMERA_NEAR / CAMERA_FAR, unchanged by the SDK. + out.original_near=10;out.original_far=15000; + const auto mode=r.half(camera+0x36),layout=r.word(table.ViewportLayout); + const auto dialogue=revision==rom::Revision::UsV77?0x8011D586U:0x8011DB06U; + const bool scripted=r.byte(table.CutsceneCameraActive)||r.half(dialogue); + if(scripted)out.flags|=DKR_VIEW_SCRIPTED; + if(!scripted&&dkr_world_projection_eligible(std::int32_t(r.word(table.GameMode)),r.byte(header+0x4C),layout,1)&& + (mode==0||mode==1||mode==4)&&out.vehicle<=2)out.flags|=DKR_VIEW_DRIVING; + if(std::int8_t(r.byte(racer+0x1E2))>0||std::int8_t(r.byte(racer+0x1E5))>0||out.vehicle==2)out.flags|=DKR_VIEW_GROUNDED; + if(std::int8_t(r.byte(racer+0x1E6)))out.flags|=DKR_VIEW_DRIFTING; + if(out.boost>0)out.flags|=DKR_VIEW_BOOSTING; + if(std::abs(r.real(camera+0x30))>0.1F)out.flags|=DKR_VIEW_IMPACT; + if(frame.online)out.flags|=DKR_VIEW_ONLINE; + if(frame.inspection&&!frame.online)out.flags|=DKR_VIEW_INSPECTION_ALLOWED; + if(changed||frame.cut||frame.frame + +namespace dkr::runtime::camera { +struct FrameStamp { + std::uint32_t scene=0,epoch=0,frame=0; + float time=0,dt=0,aspect=4.F/3.F; + std::uint32_t capabilities=0; + bool online=false,cut=false,inspection=false; +}; +// Reads an ALREADY RETAINED image, never the live CPU's concurrent RAM. The +// normal CPU and rollback producer must publish at their own safe boundaries. +// Binding identities are presentation-local and reacquired from the roster. +class SnapshotReader final { +public: + std::optional capture(std::span retained, + rom::Revision,unsigned view,const FrameStamp&); +private: + struct Binding {std::uint32_t object=0,racer=0,scene=0,epoch=0,generation=0,frame=0;}; + std::array bindings_{}; +}; +} diff --git a/runtime-recomp/src/game/checkpoint_transfer.cpp b/runtime-recomp/src/game/checkpoint_transfer.cpp new file mode 100644 index 00000000..ce7ddb96 --- /dev/null +++ b/runtime-recomp/src/game/checkpoint_transfer.cpp @@ -0,0 +1,96 @@ +#include "checkpoint_transfer.hpp" +#include +#include + +namespace dkr::runtime::spectators { +namespace { +void put(std::vector& b,std::uint64_t v,unsigned n){for(unsigned i=0;i>(8*i)));} +std::uint64_t get(std::span b,std::size_t& p,unsigned n){ + if(p>b.size()||n>b.size()-p)throw std::runtime_error("Incomplete spectator checkpoint message."); + std::uint64_t v=0;for(unsigned i=0;i encode_offer(const CheckpointOffer& o){ + if(!valid_offer(o))throw std::runtime_error("Invalid spectator checkpoint offer."); + std::vector b;b.reserve(80); + for(auto v:{o.transfer,o.match,o.schema,o.control_generation,o.next.epoch})put(b,v,8); + put(b,o.next.frame,4);put(b,o.bytes,4);b.insert(b.end(),o.digest.begin(),o.digest.end());put(b,o.magic_actions,4);return b; +} +bool decode_offer(std::span b,CheckpointOffer& out,std::string& error){ + if(b.size()!=84)return no(error,"Invalid spectator checkpoint offer length."); + std::size_t p=0;CheckpointOffer o; + o.transfer=get(b,p,8);o.match=get(b,p,8);o.schema=get(b,p,8);o.control_generation=get(b,p,8);o.next.epoch=get(b,p,8); + o.next.frame=std::uint32_t(get(b,p,4));o.bytes=std::uint32_t(get(b,p,4));std::copy(b.begin()+48,b.begin()+80,o.digest.begin());p=80;o.magic_actions=std::uint32_t(get(b,p,4)); + if(!valid_offer(o))return no(error,"Invalid spectator checkpoint offer identity or size."); + out=o;error.clear();return true; +} +std::vector encode_chunk(const CheckpointChunk& c){ + if(!c.transfer||c.bytes.empty()||c.bytes.size()>Chunk||c.offset%Chunk||c.offset>=ConfirmedFeed::MaximumState|| + c.bytes.size()>ConfirmedFeed::MaximumState-c.offset)throw std::runtime_error("Invalid spectator checkpoint chunk."); + std::vector b;b.reserve(12+c.bytes.size());put(b,c.transfer,8);put(b,c.offset,4);b.insert(b.end(),c.bytes.begin(),c.bytes.end());return b; +} +bool decode_chunk(std::span b,CheckpointChunk& out,std::string& error){ + if(b.size()<=12||b.size()>12+Chunk)return no(error,"Invalid spectator checkpoint chunk length."); + std::size_t p=0;CheckpointChunk c;c.transfer=get(b,p,8);c.offset=std::uint32_t(get(b,p,4)); + if(!c.transfer||c.offset%Chunk||c.offset>=ConfirmedFeed::MaximumState||b.size()-12>ConfirmedFeed::MaximumState-c.offset) + return no(error,"Invalid spectator checkpoint chunk bounds."); + c.bytes.assign(b.begin()+12,b.end());out=std::move(c);error.clear();return true; +} +bool CheckpointReceiver::begin(CheckpointOffer o,std::uint64_t match,std::uint64_t schema, + std::size_t bytes,Clock::time_point now,std::string& error){ + if(!hash_||!valid_offer(o)||o.match!=match||o.schema!=schema||o.bytes!=bytes) + return no(error,"Spectator checkpoint does not match this game, content or snapshot layout."); + if(offer_&&o==*offer_){error.clear();return true;} + if(o.transfer<=retired_||(offer_&&o.transfer<=offer_->transfer))return no(error,"Spectator checkpoint offer is stale."); + // Stage both allocations before retiring an intact in-flight transfer. + auto state=std::make_shared>(o.bytes); + std::vector present((std::size_t(o.bytes)+Chunk-1)/Chunk,false); + if(offer_)retired_=std::max(retired_,offer_->transfer); + offer_=o;state_=std::move(state);present_=std::move(present);received_=0;started_=progress_=now;error.clear();return true; +} +CheckpointReceiver::Result CheckpointReceiver::receive(const CheckpointChunk& c,Clock::time_point now,std::string& error){ + if(!offer_||c.transfer!=offer_->transfer){error="Spectator checkpoint chunk belongs to a retired transfer.";return Result::Stale;} + if(expired(now)){cancel();error="Spectator checkpoint transfer timed out; racers are unaffected.";return Result::Rejected;} + if(c.offset%Chunk||c.offset>=offer_->bytes||c.bytes.size()!=std::min(Chunk,std::size_t(offer_->bytes-c.offset))){ + error="Spectator checkpoint chunk has invalid bounds.";return Result::Rejected; + } + const auto index=c.offset/Chunk; + if(present_[index]){ + if(!std::equal(c.bytes.begin(),c.bytes.end(),state_->begin()+c.offset)){ + cancel();error="Conflicting spectator checkpoint data was discarded.";return Result::Rejected; + } + error.clear();return Result::Duplicate; + } + std::copy(c.bytes.begin(),c.bytes.end(),state_->begin()+c.offset);present_[index]=true;received_+=c.bytes.size();progress_=now; + error.clear();return received_==offer_->bytes?Result::Complete:Result::Accepted; +} +std::optional CheckpointReceiver::take_verified(std::string& error){ + if(!offer_||received_!=offer_->bytes){error="Spectator checkpoint is incomplete.";return {};} + // This runs on the transfer owner, never on a racing/graphics callback. + if(hash_(*state_)!=offer_->digest){cancel();error="Spectator checkpoint integrity check failed.";return {};} + Baseline b{offer_->next,offer_->match,offer_->schema,offer_->control_generation,offer_->digest,state_,offer_->magic_actions}; + cancel();error.clear();return b; +} +bool CheckpointReceiver::expired(Clock::time_point now)const{ + return offer_&&(nowstd::chrono::seconds(30)||now-started_>std::chrono::minutes(5)); +} +void CheckpointReceiver::cancel(){if(offer_)retired_=std::max(retired_,offer_->transfer);offer_.reset();state_.reset();present_.clear();received_=0;} +bool CheckpointSender::begin(std::uint64_t transfer,std::shared_ptr b){ + if(!b||!b->state||b->state->size()>UINT32_MAX)return false; + CheckpointOffer o{transfer,b->match,b->schema,b->control_generation,b->next,std::uint32_t(b->state->size()),b->digest,b->magic_actions}; + if(!valid_offer(o))return false; + offer_=o;state_=b->state;offset_=0;return true; +} +std::optional CheckpointSender::next_chunk()const{ + if(!state_||offset_>=state_->size())return {}; + const auto count=std::min(Chunk,state_->size()-offset_); + return CheckpointChunk{offer_.transfer,std::uint32_t(offset_),{state_->begin()+std::ptrdiff_t(offset_),state_->begin()+std::ptrdiff_t(offset_+count)}}; +} +void CheckpointSender::sent(){if(state_)offset_+=std::min(Chunk,state_->size()-offset_);} +} diff --git a/runtime-recomp/src/game/checkpoint_transfer.hpp b/runtime-recomp/src/game/checkpoint_transfer.hpp new file mode 100644 index 00000000..543584da --- /dev/null +++ b/runtime-recomp/src/game/checkpoint_transfer.hpp @@ -0,0 +1,71 @@ +#pragma once +#include "confirmed_feed.hpp" +#include "party_codec.hpp" +#include +#include +#include + +namespace dkr::runtime::spectators { +using Digest=std::array; +using HashState=std::function)>; +struct CheckpointOffer { + std::uint64_t transfer=0,match=0,schema=0,control_generation=0; + Cursor next; + std::uint32_t bytes=0; + Digest digest{}; + std::uint32_t magic_actions=0; + bool operator==(const CheckpointOffer&)const=default; +}; +struct CheckpointChunk { + std::uint64_t transfer=0; + std::uint32_t offset=0; + std::vector bytes; +}; +std::vector encode_offer(const CheckpointOffer& offer); +bool decode_offer(std::span bytes,CheckpointOffer& out,std::string& error); +std::vector encode_chunk(const CheckpointChunk& chunk); +bool decode_chunk(std::span bytes,CheckpointChunk& out,std::string& error); +bool valid_offer(const CheckpointOffer& offer); + +// Only the authenticated host may feed this receiver. One bounded allocation +// per observer, not one allocation per datagram. Duplicate chunks must agree +// byte-for-byte. An invalid transfer cannot replace the last verified state. +class CheckpointReceiver final { +public: + using Clock=std::chrono::steady_clock; + enum class Result {Accepted,Duplicate,Complete,Stale,Rejected}; + explicit CheckpointReceiver(HashState hash):hash_(std::move(hash)){} + bool begin(CheckpointOffer offer,std::uint64_t expected_match,std::uint64_t expected_schema, + std::size_t expected_state_bytes,Clock::time_point now,std::string& error); + Result receive(const CheckpointChunk& chunk,Clock::time_point now,std::string& error); + std::optional take_verified(std::string& error); + bool expired(Clock::time_point now)const; + bool active()const{return offer_.has_value();} + std::size_t received_bytes()const{return received_;} + void cancel(); +private: + HashState hash_; + std::optional offer_; + std::shared_ptr> state_; + std::vector present_; + Clock::time_point started_{},progress_{}; + std::uint64_t retired_=0; + std::size_t received_=0; +}; +// Off-owner delivery can retain this immutable reference without reading live +// guest RAM. Fair scheduling decides when next_chunk() is submitted; it does +// not sleep or enqueue the whole checkpoint ahead of gameplay inputs. +class CheckpointSender final { +public: + bool begin(std::uint64_t transfer,std::shared_ptr baseline); + std::optional next_chunk()const; + void sent(); + const CheckpointOffer& offer()const{return offer_;} + bool complete()const{return state_&&offset_==state_->size();} + void clear(){state_.reset();offset_=0;offer_={};} +private: + CheckpointOffer offer_{}; + std::shared_ptr> state_; + std::size_t offset_=0; +}; +} diff --git a/runtime-recomp/src/game/confirmed_feed.cpp b/runtime-recomp/src/game/confirmed_feed.cpp new file mode 100644 index 00000000..b548a679 --- /dev/null +++ b/runtime-recomp/src/game/confirmed_feed.cpp @@ -0,0 +1,50 @@ +#include "confirmed_feed.hpp" +#include + +namespace dkr::runtime::spectators { +namespace { +std::size_t size(const ConfirmedFrame& f){return 48+f.command.size();} +} +void ConfirmedFeed::clear(){baseline_.reset();tail_.clear();next_={};bytes_=0;} +FeedResult ConfirmedFeed::baseline(Baseline next) { + if(!next.match||!next.schema||!next.next.epoch||next.next.frame==UINT32_MAX||!next.control_generation||!next.state||next.state->empty()||next.state->size()>MaximumState|| + std::all_of(next.digest.begin(),next.digest.end(),[](auto v){return !v;}))return FeedResult::Invalid; + if(baseline_&&(next.match!=baseline_->match||next.schema!=baseline_->schema||next.control_generationcontrol_generation||next.next.epochnext.epoch|| + (next.next.epoch==baseline_->next.epoch&&next.next.framenext.frame)))return FeedResult::Stale; + if(baseline_&&next.next==baseline_->next){ + if(next.digest!=baseline_->digest||next.magic_actions!=baseline_->magic_actions)return FeedResult::Invalid; + if(next.control_generation==baseline_->control_generation)return FeedResult::Duplicate; + } + auto retained=std::make_shared(std::move(next)); + while(!tail_.empty()&&(tail_.front().cursor.epoch!=retained->next.epoch||tail_.front().cursor.framenext.frame)) { + bytes_-=size(tail_.front());tail_.pop_front(); + } + if(tail_.empty()||tail_.front().cursor!=retained->next){tail_.clear();bytes_=0;next_=retained->next;} + baseline_=std::move(retained);return FeedResult::Accepted; +} +FeedResult ConfirmedFeed::append(ConfirmedFrame frame) { + if(!baseline_||!frame.cursor.epoch||frame.command.size()>MaximumCommand||frame.cursor.frame==UINT32_MAX)return FeedResult::Invalid; + if(frame.cursor.epochMaximumTail)return FeedResult::Capacity; + while(!tail_.empty()&&(tail_.size()>=MaximumFrames||bytes_>MaximumTail-cost)){bytes_-=size(tail_.front());tail_.pop_front();} + ++next_.frame;bytes_+=cost;tail_.push_back(std::move(frame));return FeedResult::Accepted; +} +bool ConfirmedFeed::covers(Cursor first)const { + return baseline_&&first.epoch==next_.epoch&&first.frame<=next_.frame&& + (first==next_||(!tail_.empty()&&first.frame>=tail_.front().cursor.frame)); +} +std::vector ConfirmedFeed::after(Cursor first,std::size_t maximum)const { + std::vector result;if(!covers(first))return result; + maximum=std::min(maximum,120);result.reserve(maximum); + for(const auto& f:tail_)if(f.cursor.frame>=first.frame){if(result.size()==maximum)break;result.push_back(f);} + return result; +} +} diff --git a/runtime-recomp/src/game/confirmed_feed.hpp b/runtime-recomp/src/game/confirmed_feed.hpp new file mode 100644 index 00000000..d06e3aa6 --- /dev/null +++ b/runtime-recomp/src/game/confirmed_feed.hpp @@ -0,0 +1,51 @@ +#pragma once +#include "party.hpp" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::spectators { +struct PortInput { + std::uint16_t buttons=0; + std::int8_t x=0,y=0; + bool operator==(const PortInput&)const=default; +}; +struct ConfirmedFrame { + Cursor cursor; + std::array inputs{}; + std::vector command; + bool scene_boundary=false; + bool operator==(const ConfirmedFrame&)const=default; +}; +struct Baseline { + Cursor next; + std::uint64_t match=0,schema=0,control_generation=0; + std::array digest{}; + std::shared_ptr> state; + std::uint32_t magic_actions=0; +}; +enum class FeedResult {Accepted,Duplicate,Stale,Gap,Invalid,Capacity}; +// Contains confirmed data only. The simulation owner publishes here; a +// separate transport worker owns compression and per-spectator delivery. +// One immutable checkpoint is shared, not copied for each watching member. +class ConfirmedFeed final { +public: + static constexpr std::size_t MaximumState=128U*1024U*1024U; + static constexpr std::size_t MaximumCommand=1024,MaximumFrames=3600,MaximumTail=4U*1024U*1024U; + FeedResult baseline(Baseline next); + FeedResult append(ConfirmedFrame frame); + std::shared_ptr current()const{return baseline_;} + std::vector after(Cursor first,std::size_t maximum=30)const; + bool covers(Cursor first)const; + Cursor next()const{return next_;} + void clear(); +private: + std::shared_ptr baseline_; + std::deque tail_; + Cursor next_{}; + std::size_t bytes_=0; +}; +} diff --git a/runtime-recomp/src/game/confirmed_observer.cpp b/runtime-recomp/src/game/confirmed_observer.cpp new file mode 100644 index 00000000..05cefe1f --- /dev/null +++ b/runtime-recomp/src/game/confirmed_observer.cpp @@ -0,0 +1,79 @@ +#include "confirmed_observer.hpp" +#include +#include +#include + +namespace dkr::runtime::spectators { +namespace {thread_local ConfirmedFrameBinding::Sink frame_sink;} +ConfirmedFrameBinding::ConfirmedFrameBinding(Sink sink):previous_(std::move(frame_sink)){frame_sink=std::move(sink);} +ConfirmedFrameBinding::~ConfirmedFrameBinding(){frame_sink=std::move(previous_);} +namespace {thread_local ConfirmedExport* active=nullptr;std::mutex registry_mutex;std::unordered_map> registry;} +std::shared_ptr acquire_export(std::uint64_t match){ + if(!match)return {}; + std::scoped_lock lock(registry_mutex); + std::erase_if(registry,[](const auto& entry){return entry.second.expired();}); + auto& ref=registry[match];auto result=ref.lock();if(!result){result=std::make_shared(match);ref=result;}return result; +} +void ConfirmedExport::requested(bool enabled)noexcept{ + const auto before=requested_.exchange(enabled,std::memory_order_acq_rel); + if(enabled&&!before)baseline_requested_.store(true,std::memory_order_release); +} +bool ConfirmedExport::wants_baseline()const noexcept{return requested_.load(std::memory_order_acquire)&&baseline_requested_.load(std::memory_order_acquire);} +void ConfirmedExport::begin_capture(Cursor next)noexcept{ + try{std::scoped_lock lock(mutex_);capture_=next;captured_tail_.clear();}catch(...){baseline_requested_.store(true,std::memory_order_release);} +} +void ConfirmedExport::publish_baseline(Cursor next,std::uint64_t schema,std::uint64_t controls, + std::shared_ptr> bytes,Digest hash)noexcept{ + if(!requested_.load(std::memory_order_acquire))return; + try{ + std::scoped_lock lock(mutex_); + const auto result=feed_.baseline({next,match_,schema,controls,hash,std::move(bytes)}); + if(result!=FeedResult::Accepted&&result!=FeedResult::Duplicate){failure_="The confirmed spectator checkpoint was rejected.";return;} + if(capture_==next){ + for(auto& f:captured_tail_)if(f.cursor.epoch==next.epoch&&f.cursor.frame>=next.frame){ + const auto added=feed_.append(std::move(f)); + if(added!=FeedResult::Accepted&&added!=FeedResult::Duplicate){capture_.reset();captured_tail_.clear();baseline_requested_.store(true,std::memory_order_release);return;} + } + } + capture_.reset();captured_tail_.clear(); + failure_.clear();baseline_requested_.store(false,std::memory_order_release); + }catch(...){baseline_requested_.store(true,std::memory_order_release);} +} +void ConfirmedExport::committed(ConfirmedFrame frame)noexcept{ + if(!requested_.load(std::memory_order_acquire))return; + try{ + std::scoped_lock lock(mutex_); + if(capture_&&frame.cursor.epoch==capture_->epoch&&frame.cursor.frame>=capture_->frame){ + if(captured_tail_.size()>=ConfirmedFeed::MaximumFrames){capture_.reset();captured_tail_.clear();baseline_requested_.store(true,std::memory_order_release);} + else captured_tail_.push_back(frame); + } + if(!feed_.current())return; + const auto result=feed_.append(std::move(frame)); + if(result!=FeedResult::Accepted&&result!=FeedResult::Duplicate){baseline_requested_.store(true,std::memory_order_release);return;} + }catch(...){baseline_requested_.store(true,std::memory_order_release);} +} +std::shared_ptr ConfirmedExport::baseline()const{std::scoped_lock lock(mutex_);return feed_.current();} +std::vector ConfirmedExport::after(Cursor c,std::size_t n)const{std::scoped_lock lock(mutex_);return feed_.after(c,n);} +Cursor ConfirmedExport::next()const{std::scoped_lock lock(mutex_);return feed_.next();} +bool ConfirmedExport::covers(Cursor c)const{std::scoped_lock lock(mutex_);return feed_.covers(c);} +void ConfirmedExport::resync()noexcept{baseline_requested_.store(true,std::memory_order_release);} +std::string ConfirmedExport::failure()const{std::scoped_lock lock(mutex_);return failure_;} +ConfirmedExportBinding::ConfirmedExportBinding(ConfirmedExport* stream)noexcept:previous_(active){active=stream;} +ConfirmedExportBinding::~ConfirmedExportBinding(){active=previous_;} +void observe_confirmed(std::uint64_t epoch,std::uint32_t frame,const std::array& inputs, + std::span command,bool boundary)noexcept{ + if(!frame_sink&&(!active||!active->enabled()))return; + try{ + ConfirmedFrame out{{epoch,frame},inputs,{},boundary}; + if(!command.empty()){ + if(command.size()!=16)return; + if(std::any_of(command.begin(),command.end(),[](auto word){return word!=0;})){ + out.command.reserve(64); + for(auto word:command)for(unsigned i=0;i<4;++i)out.command.push_back(std::uint8_t(word>>(8*i))); + } + } + if(frame_sink)frame_sink(out); + if(active&&active->enabled())active->committed(std::move(out)); + }catch(...){if(active)active->resync();} +} +} diff --git a/runtime-recomp/src/game/confirmed_observer.hpp b/runtime-recomp/src/game/confirmed_observer.hpp new file mode 100644 index 00000000..4692f0af --- /dev/null +++ b/runtime-recomp/src/game/confirmed_observer.hpp @@ -0,0 +1,63 @@ +#pragma once +#include "confirmed_feed.hpp" +#include "checkpoint_transfer.hpp" +#include +#include + +namespace dkr::runtime::spectators { +// Export is opt-in. With no watching member, the racing path allocates no +// checkpoint copy, command vector or frame-history entry for spectators. +class ConfirmedExport final { +public: + explicit ConfirmedExport(std::uint64_t match):match_(match){} + void requested(bool enabled)noexcept; + bool enabled()const noexcept{return requested_.load(std::memory_order_acquire);} + bool wants_baseline()const noexcept; + void begin_capture(Cursor next)noexcept; + void publish_baseline(Cursor next,std::uint64_t schema,std::uint64_t controls, + std::shared_ptr> bytes,Digest hash)noexcept; + void committed(ConfirmedFrame frame)noexcept; + std::shared_ptr baseline()const; + std::vector after(Cursor first,std::size_t maximum=30)const; + Cursor next()const; + bool covers(Cursor first)const; + void resync()noexcept; + std::string failure()const; +private: + std::uint64_t match_=0; + std::atomic requested_{false},baseline_requested_{true}; + mutable std::mutex mutex_; + ConfirmedFeed feed_; + std::optional capture_; + std::deque captured_tail_; + std::string failure_; +}; +// Binding and callback run on the SAME simulation owner. The thread-local +// pointer is never serialized, retained by transport or invoked on a GPU. +class ConfirmedExportBinding final { +public: + explicit ConfirmedExportBinding(ConfirmedExport* stream)noexcept; + ~ConfirmedExportBinding(); + ConfirmedExportBinding(const ConfirmedExportBinding&)=delete; + ConfirmedExportBinding& operator=(const ConfirmedExportBinding&)=delete; +private: + ConfirmedExport* previous_; +}; +// Called by Driver only after successful irreversible commit, with actual +// inputs and the actual deterministic SDK2 command. Exceptions are contained +// here: a broken/slow watcher cannot stop a racing commit. +void observe_confirmed(std::uint64_t epoch,std::uint32_t frame, + const std::array& inputs, + std::span command,bool boundary)noexcept; +class ConfirmedFrameBinding final { +public: + using Sink=std::function; + explicit ConfirmedFrameBinding(Sink sink); + ~ConfirmedFrameBinding(); + ConfirmedFrameBinding(const ConfirmedFrameBinding&)=delete; +private:Sink previous_; +}; +// The registry owns no raw session pointer. A launch's host owner and its +// transport share this bounded stream; weak entries expire after retirement. +std::shared_ptr acquire_export(std::uint64_t match); +} diff --git a/runtime-recomp/src/game/connection_ui.inl b/runtime-recomp/src/game/connection_ui.inl new file mode 100644 index 00000000..ef75ac66 --- /dev/null +++ b/runtime-recomp/src/game/connection_ui.inl @@ -0,0 +1,45 @@ +// Connection panel only. The accepted lobby cards, code plate, invitations, +// moderation and spectator/role controls remain composed by the prior layer. +void OlRequestConnectionTest(){ + std::string error; + if(!dkr::runtime::netplay::session().request_connection_test(error))OlNotify(error); + else OlNotify("Checking Rollback input timing. Please wait about 10 seconds."); +} +bool OlCanTestConnection(const OnlineFrame& frame){ + using dkr::runtime::netplay::ConnectionState; + return frame.view.host&&!frame.busy&&!OlPartyLive()&& + (frame.view.state==ConnectionState::Hosting||frame.view.state==ConnectionState::Lobby); +} +bool OlHasTestResults(const OnlineFrame&){return g_ol_rollback_check.complete;} +std::string OlTestCountdown(const OnlineFrame& frame){return std::to_string((frame.view.connection_test_remaining_ms+999U)/1000U)+" seconds";} +std::string OlRollbackMs(double value){char text[32];std::snprintf(text,sizeof(text),"%.1f ms",value);return text;} +void DrawOlRollbackResults(const OnlineFrame&); +void DrawOlConnection(float width,const OnlineFrame& frame){ + using namespace dkr::runtime::netplay; + DrawOlFeature(width,"Rollback connection","",[&](float inner){ + PaddockGap(12.F);OlParagraph(OlRulesLine(frame.view.room.rules),16.F,kOlCream,inner,true); + OlParagraph("Checks the active Rollback transport at the game's 30 Hz input cadence. It does not run a race or change your settings.",14.F,kOlSoft,inner); + if(!g_ol_rollback_check.status.empty()){PaddockGap(12.F);OlParagraph(g_ol_rollback_check.status,15.F,kOlAmber,inner);} + if(OlPartyActive()){ + for(const auto& m:g_ol_party.link.party.members){ + std::string timing=m.id==g_ol_party.link.local?"This PC":"Measuring lobby RTT..."; + for(const auto& metric:g_ol_party.metrics)if(metric.member==m.id&&metric.count)timing=std::to_string(metric.rtts[(metric.index-1)%16])+" ms lobby RTT"; + if(!frame.view.host&&m.host&&g_ol_party.host_rtt)timing=std::to_string(g_ol_party.host_rtt)+" ms to host"; + if(!frame.view.host&&!m.host&&m.id!=g_ol_party.link.local)timing="Measured by the host"; + PaddockGap(8.F);OlParagraph(m.name+(m.role==lobby_party::Role::Spectator?" (spectator)":" (racer)")+" - "+timing,14.F,kOlSoft,inner); + } + }else if(frame.view.network_rtt_ms)OlParagraph("Lobby RTT: "+std::to_string(frame.view.network_rtt_ms)+" ms. Run the Rollback check for input-channel timings.",14.F,kOlSoft,inner); + const auto live=experimental::runtime_view(); + if(live.active){ + PaddockGap(12.F);OlParagraph("Live Rollback: frame "+std::to_string(live.frame)+", confirmed "+std::to_string(live.confirmed)+ + ", corrections "+std::to_string(live.corrections)+", replayed "+std::to_string(live.replayed)+".",14.F,kOlInk,inner); + if(!live.status.empty())OlParagraph(live.status,14.F,kOlSoft,inner); + }else if(frame.view.state==ConnectionState::Running)OlParagraph("The race is running. Preflight checks are disabled until everyone returns to the lobby.",14.F,kOlSoft,inner); + if(frame.view.host){PaddockGap(14.F);const OlDisabled disabled(!OlCanTestConnection(frame));if(OlButton("Check Rollback connection",OlTone::Plain))OlRequestConnectionTest();} + else OlParagraph("The host can run a shared Rollback check while everyone is in the lobby. All participants need the updated build.",14.F,kOlSoft,inner); + if(g_ol_rollback_check.active){PaddockGap(10.F);OlParagraph("Checking... "+OlTestCountdown(frame),15.F,kOlAmber,inner);} + if(g_ol_rollback_check.complete){PaddockGap(12.F);if(OlButton("View Rollback report"))g_online_page.open_results=true;} + }); +} +void DrawOlPartyConnection(float width,const OnlineFrame& frame){DrawOlConnection(width,frame);} +void DrawOlPartyResults(const OnlineFrame& frame){DrawOlRollbackResults(frame);} diff --git a/runtime-recomp/src/game/credits_sky_rt64.hpp b/runtime-recomp/src/game/credits_sky_rt64.hpp new file mode 100644 index 00000000..8b8d2c0d --- /dev/null +++ b/runtime-recomp/src/game/credits_sky_rt64.hpp @@ -0,0 +1,71 @@ +#pragma once +#include "revision_addresses.hpp" +#include "hle/rt64_state.h" +#include "hle/rt64_rsp.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::presentation { +// The existing lower-void widening hook explicitly excludes layout 0. Credits +// use that single camera, but now expose a wide viewport. Apply the same lateral +// basis expansion to decoder-local vertices, never to the authored guest mesh. +class CreditsSky { + static constexpr std::array,2> symbols_{{ + {0x800DC924U,0x8011D480U,0x8011D488U,0x8011D4B4U,0x8011D4B6U}, + {0x800DCE94U,0x8011DA00U,0x8011DA08U,0x8011DA34U,0x8011DA36U} + }}; + std::uint32_t begin_=0,end_=0; + float lateral_x_=0,lateral_z_=0,centre_x_=0,centre_z_=0,length_=1,cover_=1; + static std::uint32_t word(const std::uint8_t* ram,std::uint32_t address) { + std::uint32_t value;std::memcpy(&value,ram+(address&0x7FFFFCU),4);return value; + } + static unsigned byte(const std::uint8_t* ram,std::uint32_t address) {return ram[(address&0x7FFFFFU)^3U];} + static unsigned half(const std::uint8_t* ram,std::uint32_t address) { + std::uint16_t value;std::memcpy(&value,ram+((address&0x7FFFFFU)^2U),2);return value; + } +public: + void begin(const RT64::State& state,float aspect,bool enabled) { + begin_=end_=0; + if(!enabled || !std::isfinite(aspect) || aspect<=4.F/3.F || + word(state.RDRAM,revision_addresses::ViewportLayout)!=0)return; + const bool v80=revision_addresses::CurrentMenuId==revision_addresses::kUsV80.CurrentMenuId; + const auto& symbols=symbols_[v80?1:0]; + if(!word(state.RDRAM,symbols[0]))return; // gVoidData: allocation still alive. + const auto flip=byte(state.RDRAM,symbols[3]); + const auto count=half(state.RDRAM,symbols[4]); + if(flip>1 || count>24 || count%4)return; + const auto first=word(state.RDRAM,symbols[1]+(1-flip)*4)&0x1FFFFFFFU; + const auto tail=word(state.RDRAM,symbols[2])&0x1FFFFFFFU; + const auto end=std::uint64_t(tail)+count*10U; + // void_generate_primitive flushes every 24 vertices. Its final cursor + // plus count covers ALL batches, not merely the last submitted batch. + if(!first || first>=0x800000U || tail0x800000U || + end-first>2000 || (tail-first)%10)return; + const auto value=[&](std::uint32_t p){return std::bit_cast(word(state.RDRAM,p));}; + lateral_x_=value(revision_addresses::VoidLateralX); + lateral_z_=value(revision_addresses::VoidLateralZ); + centre_x_=value(revision_addresses::VoidCentreX); + centre_z_=value(revision_addresses::VoidCentreZ); + length_=lateral_x_*lateral_x_+lateral_z_*lateral_z_; + if(!std::isfinite(length_) || length_<0.5F || length_>1.5F || + !std::isfinite(centre_x_) || !std::isfinite(centre_z_))return; + cover_=std::clamp(aspect/(4.F/3.F),1.F,8.F); + begin_=first;end_=std::uint32_t(end); + } + void vertex(std::uint32_t address,RT64::RSP::Vertex& v) const { + address&=0x1FFFFFFFU; + if(!begin_ || address=end_ || (address-begin_)%10)return; + const float lateral=((float(v.x)-centre_x_)*lateral_x_+(float(v.z)-centre_z_)*lateral_z_)/length_; + const float expansion=lateral*(cover_-1.F); + v.x=std::int16_t(std::clamp(std::lround(float(v.x)+expansion*lateral_x_),-32768L,32767L)); + v.z=std::int16_t(std::clamp(std::lround(float(v.z)+expansion*lateral_z_),-32768L,32767L)); + static thread_local bool notice=false; + if(!notice){std::fprintf(stderr,"[credits][sky] lower backdrop widened along camera lateral basis; horizon unchanged\n");notice=true;} + } +}; +} diff --git a/runtime-recomp/src/game/credits_viewport_rt64.hpp b/runtime-recomp/src/game/credits_viewport_rt64.hpp new file mode 100644 index 00000000..0563d49c --- /dev/null +++ b/runtime-recomp/src/game/credits_viewport_rt64.hpp @@ -0,0 +1,132 @@ +#pragma once +#include "hle/rt64_state.h" +#include "hle/rt64_rdp.h" +#include "hle/rt64_rsp.h" +#include "hle/rt64_workload_queue.h" +#include +#include +#include + +namespace dkr::runtime::presentation { + +// The immutable task must belong to MENU_CREDITS, and the command must also +// match its reviewed inset. Credits have the same inset, so geometry alone +// is NOT enough to admit this correction. No changes to retail RAM or timers. +class CreditsViewport { + bool enabled_=false; + float cover_=1.F; + bool picture_=false; + int top_=0,bottom_=0,height_=240; + static bool inset(int left,int top,int right,int bottom) { + return left==0 && (right==319*4 || right==320*4) && + ((top==40*4 && bottom==196*4) || (top==38*4 && bottom==224*4)); + } +public: + void begin(bool enabled,float aspect) { + enabled_=enabled;picture_=false;top_=bottom_=0;height_=240; + cover_=std::clamp(std::isfinite(aspect)?aspect/(4.F/3.F):1.F,1.F,8.F); + } + bool enabled() const {return enabled_;} + void scissor(RT64::State& state) { + if(!enabled_)return; + auto& r=state.rdp->scissorRectStack[state.rdp->scissorStackSize-1]; + picture_=inset(r.ulx,r.uly,r.lrx,r.lry); + if(!picture_) { + // Ordinary 2D/transition resets use the inclusive 319/239 bounds. + // Keep their native aspect reference complete, without widening + // arbitrary partial UI scissors or changing their projection. + if(r.ulx==0 && r.uly==0 && (r.lrx==319*4 || r.lrx==320*4) && + (r.lry==239*4 || r.lry==240*4 || r.lry==287*4 || r.lry==288*4)) { + r.lrx=320*4;r.lry=r.lry>240*4?288*4:240*4; + state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); + } + return; + } + top_=r.uly;bottom_=r.lry; + height_=top_==40*4 ? 240 : 288; + r.ulx=0;r.uly=0;r.lrx=320*4;r.lry=height_*4; + // Reveal the complete picture behind the existing copyright text. + // Its later 2D viewport is separate and keeps the original text layout. + auto& rdp=*state.rdp; + rdp.extended.scissorLeftOriginStack[rdp.scissorStackSize-1]=G_EX_ORIGIN_NONE; + rdp.extended.scissorRightOriginStack[rdp.scissorStackSize-1]=G_EX_ORIGIN_NONE; + state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); + } + void viewport(RT64::State& state) const { + if(!enabled_ || !picture_)return; + auto& rsp=*state.rsp; + auto& v=rsp.viewportStack[rsp.viewportStackSize-1]; + const float authored_center=(top_+bottom_)/8.F+0.5F; + // Match only the custom 3D intro viewport, not viewport_reset's + // ordinary 2D copyright viewport. Preserve X/Y scales and handedness. + if(std::abs(std::abs(float(v.scale.x))-160.F)>0.01F || + std::abs(float(v.translate.x)-160.5F)>0.01F || + std::abs(float(v.translate.y)-authored_center)>0.01F)return; + // Retail's inclusive rectangle gives a half-pixel X centre (160.5). + // Keeping that would still leave viewport.rect().ulx > framebuffer.ulx + // and make RT64 refuse full-width expansion despite a fixed scissor. + v.translate.x=160.F; + v.translate.y=height_/2.F; + rsp.viewportChanged=true; + static thread_local bool notice=false; + if(!notice) { + std::fprintf(stderr,"[credits][viewport] full-frame cinematic; borders removed; original scene proportions\n"); + notice=true; + } + } + bool fill(RT64::State& state,const RT64::DisplayList& command) const { + if(!enabled_ || state.rdp->colorImage.width!=320)return false; + const int left=command.p1(12,12),top=command.p1(0,12); + const int right=command.p0(12,12),bottom=command.p0(0,12); + if(!inset(left,top,right,bottom))return false; + auto& rdp=*state.rdp; + state.flush(); + const auto at=rdp.scissorStackSize-1; + const auto previous=rdp.scissorRectStack[at]; + const auto left_origin=rdp.extended.scissorLeftOriginStack[at]; + const auto right_origin=rdp.extended.scissorRightOriginStack[at]; + const auto aspect=rdp.extended.global.rectAspect; + const int height=top==40*4?240:288; + const int gutter=int(std::ceil(640.F*(cover_-1.F)))+8; + // Rectangle origins alone cannot escape a still-native draw scissor. + // Use the same signed window-domain clip as the accepted font path. + auto& clip=rdp.scissorRectStack[at]; + clip.ulx=-gutter;clip.uly=0;clip.lrx=1280+gutter;clip.lry=height*4; + rdp.extended.scissorLeftOriginStack[at]=rdp.extended.scissorRightOriginStack[at]=G_EX_ORIGIN_NONE; + state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); + rdp.setRectAspect(G_EX_ASPECT_ADJUST); + RT64::ExtendedAlignment alignment{}; + alignment.leftOrigin=alignment.rightOrigin=G_EX_ORIGIN_NONE; + rdp.fillRect(-gutter,0,1280+gutter,height*4,alignment); + state.flush(); + // Preserve the signed per-draw scissor/rectangle for widescreen, but + // never expose overscan to native RAM/depth readback or target sizing. + auto& workload=state.ext.workloadQueue->workloads[state.ext.workloadQueue->writeCursor]; + normalise_native_bounds(workload.fbPairs[workload.currentFramebufferPairIndex()],height); + rdp.scissorRectStack[at]=previous; + rdp.extended.scissorLeftOriginStack[at]=left_origin; + rdp.extended.scissorRightOriginStack[at]=right_origin; + state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); + rdp.setRectAspect(aspect); + static thread_local bool notice=false; + if(!notice){std::fprintf(stderr,"[credits][fade] signed full-window rectangle and scissor; original opacity; horizontal raster-edge overlap; native readback bounds\n");notice=true;} + return true; + } + template static void normalise_native_bounds(Pair& pair,int height) { + if(pair.colorImage.width!=320)return; + const RT64::FixedRect native(0,0,320*4,height*4); + const auto constrain=[&](auto& rect) { + if(rect.isNull())return; + rect=rect.intersection(native); + if(rect.isNull() || rect.isEmpty())rect.reset(); + }; + constrain(pair.drawColorRect); + constrain(pair.drawDepthRect); + // Aggregate extent only, not the saved per-draw signed clipping. + if(!pair.scissorRect.isNull())pair.scissorRect=native; + } + template void normalise(Pair& pair) const { + if(enabled_)normalise_native_bounds(pair,height_); + } +}; +} diff --git a/runtime-recomp/src/game/custom_records.hpp b/runtime-recomp/src/game/custom_records.hpp new file mode 100644 index 00000000..ed9a4eb3 --- /dev/null +++ b/runtime-recomp/src/game/custom_records.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::legacy {class ModWorld;} + +namespace dkr::runtime::saves::records { +using Bytes=std::vector; +struct Record { + std::string key;unsigned vehicle=0; + std::uint16_t lap=0,lap_name=0x7fff,course=0,course_name=0x7fff; +}; +using Map=std::map,Record>; +inline std::uint64_t checksum(std::span bytes) { + std::uint64_t h=14695981039346656037ULL;for(auto b:bytes){h^=b;h*=1099511628211ULL;}return h; +} +inline void append_integer(Bytes& out,std::uint64_t value,unsigned n) {while(n--){out.push_back(std::uint8_t(value));value>>=8;}} +inline std::uint64_t integer(std::span bytes,unsigned at,unsigned n) { + std::uint64_t value=0;for(unsigned i=0;i bytes,Map& map) { + if(bytes.size()<20||!std::equal(bytes.begin(),bytes.begin()+8,"DKRREC1\0"))return false; + const auto count=integer(bytes,8,4); + if(count>8192||bytes.size()!=20+count*76||checksum(bytes.subspan(20))!=integer(bytes,12,8))return false; + Map next; + for(unsigned at=20;at2||!std::all_of(r.key.begin(),r.key.end(),[](char c){return (c>='0'&&c<='9')||(c>='a'&&c<='f');}))return false; + r.lap=std::uint16_t(integer(bytes,at+68,2));r.lap_name=std::uint16_t(integer(bytes,at+70,2)); + r.course=std::uint16_t(integer(bytes,at+72,2));r.course_name=std::uint16_t(integer(bytes,at+74,2)); + if(!next.emplace(std::pair{r.key,r.vehicle},r).second)return false; + } + map=std::move(next);return true; +} +inline bool valid(std::span bytes) {Map map;return decode(bytes,map);} +// Normal guest hooks enqueue copies only. No disk access during replay. +bool begin(const std::filesystem::path& path,bool restore,std::string& error); +Bytes seed(); +void submit(Bytes bytes); +bool finish(std::string& error); +bool persist(const std::filesystem::path& path,std::span bytes,std::string& error); +Bytes snapshot(legacy::ModWorld& world,std::uint8_t* ram); +} diff --git a/runtime-recomp/src/game/custom_tracks.cpp b/runtime-recomp/src/game/custom_tracks.cpp index 43d1a9ae..65ddcb5a 100644 --- a/runtime-recomp/src/game/custom_tracks.cpp +++ b/runtime-recomp/src/game/custom_tracks.cpp @@ -1,3 +1,4 @@ +#include "mod_store_install.hpp" #include "custom_tracks.hpp" #include "custom_track_files.hpp" @@ -1322,23 +1323,11 @@ std::string lower_extension(const std::filesystem::path& path) { // One entry out of a zip, into `bytes`. False when the zip or entry is missing. // Used for the pack stamp; the payloads a track ships are plain files. -bool read_zip_entry(const std::filesystem::path& zip_path, - const char* entry_name, +#include "store_track_zip.inl" + +bool read_zip_entry(const std::filesystem::path& path, const char* name, std::vector& bytes) { - mz_zip_archive zip{}; - if (mz_zip_reader_init_file(&zip, zip_path.string().c_str(), 0) == MZ_FALSE) { - return false; - } - std::size_t size = 0; - void* data = mz_zip_reader_extract_file_to_heap(&zip, entry_name, &size, 0); - mz_zip_reader_end(&zip); - if (data == nullptr) { - return false; - } - const auto* first = static_cast(data); - bytes.assign(first, first + size); - mz_free(data); - return true; + return StoreReadTrackZipEntry(path, name, bytes); } // The textureDigest stamped into a -hd.zip by the Blender export @@ -1361,81 +1350,12 @@ bool read_hd_pack_digest(const std::filesystem::path& archive, // Unpacks every entry of `zip_path` under `destination`. Rejects absolute // paths and `..` so an archive can never write outside the target directory. -bool extract_zip(const std::filesystem::path& zip_path, - const std::filesystem::path& destination, - std::string& error) { - mz_zip_archive zip{}; - if (mz_zip_reader_init_file(&zip, zip_path.string().c_str(), 0) == MZ_FALSE) { - error = "the .zip could not be opened"; - return false; - } - const mz_uint count = mz_zip_reader_get_num_files(&zip); - for (mz_uint index = 0; index < count; ++index) { - mz_zip_archive_file_stat stat{}; - if (mz_zip_reader_file_stat(&zip, index, &stat) == MZ_FALSE) { - continue; - } - std::string name = stat.m_filename; - if (name.empty() || name.front() == '/' || name.front() == '\\' || - name.find("..") != std::string::npos || - (name.size() > 1U && name[1] == ':')) { - mz_zip_reader_end(&zip); - error = "the .zip contains an unsafe path"; - return false; - } - const std::filesystem::path target = - destination / std::filesystem::u8path(name); - std::error_code code; - if (mz_zip_reader_is_file_a_directory(&zip, index) == MZ_TRUE) { - std::filesystem::create_directories(target, code); - continue; - } - std::filesystem::create_directories(target.parent_path(), code); - if (mz_zip_reader_extract_to_file( - &zip, index, target.string().c_str(), 0) == MZ_FALSE) { - mz_zip_reader_end(&zip); - error = "the .zip could not be fully extracted"; - return false; - } - } - mz_zip_reader_end(&zip); - return true; +bool extract_zip(const std::filesystem::path& path, const std::filesystem::path& destination, + std::string& error, std::stop_token stop = {}) { + return StoreExtractTrackZip(path, destination, error, stop); } -// In an unpacked zip under `root`, finds the .dkrmap. A manifest.json at the -// root means the zip was a bare .dkrmap (no wrapped pack). Otherwise the track -// is one folder down and the wrapper's -hd.zip is a *.zip beside it, -// still under `root`. Returns false when no manifest is found. -bool locate_unpacked_track(const std::filesystem::path& root, - std::filesystem::path& track_dir, - std::filesystem::path& hd_pack) { - std::error_code code; - if (std::filesystem::is_regular_file(root / "manifest.json", code)) { - track_dir = root; - return true; - } - for (const auto& item : - std::filesystem::directory_iterator(root, code)) { - if (item.is_directory(code) && - std::filesystem::is_regular_file( - item.path() / "manifest.json", code)) { - track_dir = item.path(); - break; - } - } - if (track_dir.empty()) { - return false; - } - for (const auto& item : - std::filesystem::directory_iterator(root, code)) { - if (item.is_regular_file(code) && - lower_extension(item.path()) == ".zip") { - hd_pack = item.path(); - break; - } - } - return true; -} +#include "store_track_locator.inl" // The level header a track ships, if any (the last LEVEL_HEADERS entry). const Entry* level_header_of(const Track& track) { @@ -2387,9 +2307,9 @@ bool install(const std::filesystem::path& source, std::string& error, error = "That .zip is not a track: " + extract_error + "."; return false; } - if (!locate_unpacked_track(temp_root, track_dir, wrapped_hd_pack)) { + if (!locate_unpacked_track(temp_root, track_dir, wrapped_hd_pack, &extract_error)) { discard_install_temp(temp_root); - error = "That .zip does not contain a .dkrmap track."; + error = extract_error.empty() ? "That .zip does not contain a .dkrmap track." : extract_error; return false; } } else if (!std::filesystem::is_directory(source, code)) { @@ -2641,3 +2561,5 @@ void reload() { } } // namespace dkr::runtime::custom_tracks + +#include "store_track_adapter.inl" diff --git a/runtime-recomp/src/game/departure.hpp b/runtime-recomp/src/game/departure.hpp new file mode 100644 index 00000000..9929d8e2 --- /dev/null +++ b/runtime-recomp/src/game/departure.hpp @@ -0,0 +1,31 @@ +#pragma once +#include "party_link.hpp" +#include +#include +#include +#include +#include + +namespace dkr::runtime::party_departure { +using MemberId=spectators::MemberId; +inline constexpr auto SilenceLimit=std::chrono::seconds(10); +inline constexpr auto CloseGrace=std::chrono::milliseconds(400); +struct Close {MemberId member=0;bool acknowledged=false;}; +struct Registry {std::mutex mutex;std::map entries;}; +inline Registry& registry(){static auto* value=new Registry;return *value;} +inline void begin(const void* owner,MemberId id){auto& r=registry();std::scoped_lock lock(r.mutex);r.entries[owner]={id,false};} +inline bool waiting(const void* owner){auto& r=registry();std::scoped_lock lock(r.mutex);return r.entries.contains(owner);} +inline bool acknowledged(const void* owner){auto& r=registry();std::scoped_lock lock(r.mutex);const auto p=r.entries.find(owner);return p!=r.entries.end()&&p->second.acknowledged;} +inline void acknowledge(const void* owner,MemberId id){auto& r=registry();std::scoped_lock lock(r.mutex);const auto p=r.entries.find(owner);if(p!=r.entries.end()&&p->second.member==id)p->second.acknowledged=true;} +inline void forget(const void* owner){auto& r=registry();std::scoped_lock lock(r.mutex);r.entries.erase(owner);} +// Outer room, sender, key and replay validation are still owned by DirectSession. +// A receipt names the closing member, never a controller port or a new owner. +inline std::vector notice(unsigned stage,MemberId id){ + std::vector b{'D','K','L','V',1,std::uint8_t(stage)}; + for(unsigned i=0;i<8;++i)b.push_back(std::uint8_t(id>>(i*8)));return b; +} +inline bool notice(std::span b,unsigned stage,MemberId id){ + if(!id||b.size()!=14||b[0]!='D'||b[1]!='K'||b[2]!='L'||b[3]!='V'||b[4]!=1||b[5]!=stage)return false; + MemberId value=0;for(unsigned i=0;i<8;++i)value|=MemberId(b[6+i])<<(i*8);return value==id; +} +} diff --git a/runtime-recomp/src/game/detail_batch.inl b/runtime-recomp/src/game/detail_batch.inl new file mode 100644 index 00000000..262376ba --- /dev/null +++ b/runtime-recomp/src/game/detail_batch.inl @@ -0,0 +1,34 @@ +// Included inside the checked model submission scope. At most 30 vertices fit +// the original 32-slot RSP cache; commands, culling and texture boundaries are +// never crossed. Mixed driver/head matrices are uploaded as separate runs. +std::array batch; +unsigned corners=0,batch_flags=0,batch_texture=0; +const auto submit_batch=[&] { + if(!corners)return; + rsp.textureState.on=std::uint8_t(batch_texture); + rsp.clearGeometryMode(rsp.cullBothMask); + if(!(batch_flags&0x40U))rsp.setGeometryMode(rsp.viewportStack[rsp.viewportStackSize-1].scale.x>0 ? + (rsp.cullBothMask&~rsp.cullFrontMask):rsp.cullFrontMask); + for(unsigned v=0;vsteps) { + if(step.kind==Step::Command){submit_batch();command(state,step.w0,step.w1);continue;} + if(driver_mask&&driver_mask->hide[step.source_triangle])continue; + if(corners&&(corners==batch.size()||batch_flags!=step.w0||batch_texture!=step.w1))submit_batch(); + if(!corners){batch_flags=step.w0;batch_texture=step.w1;} + for(const auto& vertex:step.corners)batch[corners++]=vertex; +} +submit_batch(); diff --git a/runtime-recomp/src/game/detail_contract.hpp b/runtime-recomp/src/game/detail_contract.hpp new file mode 100644 index 00000000..b2eb4eb7 --- /dev/null +++ b/runtime-recomp/src/game/detail_contract.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include +#include +#include "netplay/experimental_draw_events.h" + +namespace dkr::runtime::detail { +// Eight racers, four views and separate opaque/transparent submissions. +// Preparation is shared by matching pose requests; GPU draws remain per view. +inline constexpr unsigned Racers=8,Views=4,Passes=2,Observations=256; +inline constexpr unsigned ModelTriangles=8192,PacketSteps=32768; +inline constexpr unsigned PreparedSteps=Racers*Passes*PacketSteps; +inline constexpr unsigned ViewTriangles=Racers*Passes*ModelTriangles; +inline constexpr unsigned KernelOperations=Racers*Passes*2000000U; +inline constexpr unsigned TextureBegin=0xF00000U,TextureEnd=0x1000000U; +std::span ordinary_events() noexcept; + +struct Evidence { + std::uint32_t scene=0,object=0; + unsigned view=0,source=0,selected=0,layer=0; + bool effective=false; + const char* reason="submitted"; +}; +// Bounded per-scene/change diagnostics. Unlike the previous process-wide +// first-24 budget, a later race and its other viewports get fresh evidence. +inline void note(Evidence e) noexcept { + struct Seen {std::uint32_t object=0;unsigned view=0,layer=0,source=0,selected=0;bool effective=false;std::uint32_t reason=0;}; + struct State {std::uint32_t scene=~0U;unsigned reports=0,calls=0;std::array seen{};}; + static thread_local State state; + if(state.scene!=e.scene){state={};state.scene=e.scene;} + std::uint32_t reason=2166136261U; + for(const char* p=e.reason;*p;++p){reason^=static_cast(*p);reason*=16777619U;} + ++state.calls;bool changed=true; + for(auto& row:state.seen)if(!row.object||(row.object==e.object&&row.view==e.view&&row.layer==e.layer)) { + changed=!row.object||row.source!=e.source||row.selected!=e.selected||row.effective!=e.effective||row.reason!=reason; + row={e.object,e.view,e.layer,e.source,e.selected,e.effective,reason};break; + } + if(changed&&(state.reports<64||state.calls%4096==0)) { + ++state.reports; + std::fprintf(stderr,"[graphics][all-player-detail] scene=%u view=%u object=%08X layer=%u source-lod=%u selected-lod=%u effective=%u reason=%s; all-player-detail-v1\n", + e.scene,e.view,e.object,e.layer,e.source,e.selected,unsigned(e.effective),e.reason); + } +} +} diff --git a/runtime-recomp/src/game/detail_metrics.hpp b/runtime-recomp/src/game/detail_metrics.hpp new file mode 100644 index 00000000..4c2c8d92 --- /dev/null +++ b/runtime-recomp/src/game/detail_metrics.hpp @@ -0,0 +1,26 @@ +#pragma once +#include +#include +namespace dkr::runtime::detail { +struct MicroScope { + double& total; + std::chrono::steady_clock::time_point start=std::chrono::steady_clock::now(); + ~MicroScope(){total+=std::chrono::duration(std::chrono::steady_clock::now()-start).count();} +}; +struct FrameMetrics { + using Clock=std::chrono::steady_clock; + Clock::time_point reported=Clock::now(); + unsigned scene=~0U,frames=0,observations=0; + double prepare_us=0,submit_us=0; + void begin(unsigned next,unsigned count,unsigned& poses,unsigned& uploads,unsigned& batches,unsigned& hits,unsigned& captures) { + const auto now=Clock::now(); + if(scene==next&&frames&&now-reported>=std::chrono::seconds(2)) { + std::fprintf(stderr,"[graphics][detail-cost] scene=%u frames=%u prepare-avg-ms=%.3f submit-avg-ms=%.3f observations-avg=%.1f poses=%u vertex-uploads=%u batches=%u material-cache-hits=%u material-captures=%u; batched-detail-v2\n", + scene,frames,prepare_us/(frames*1000.0),submit_us/(frames*1000.0),double(observations)/frames,poses,uploads,batches,hits,captures); + *this={};scene=next;reported=now;poses=uploads=batches=hits=captures=0; + } + if(scene!=next){*this={};scene=next;reported=now;poses=uploads=batches=hits=captures=0;} + ++frames;observations+=count; + } +}; +} diff --git a/runtime-recomp/src/game/detail_observer.inl b/runtime-recomp/src/game/detail_observer.inl new file mode 100644 index 00000000..e0dec818 --- /dev/null +++ b/runtime-recomp/src/game/detail_observer.inl @@ -0,0 +1,46 @@ +// Included in the task identity implementation: recording and submission use +// its existing lock, buffer parity and immutable OSTask ownership key. +namespace dkr::runtime::detail { +std::span ordinary_events() noexcept { + return g_active_owned_task?std::span{}:std::span{g_active_detail}; +} +} +extern "C" void dkr_detail_mesh_begin(std::uint8_t* ram,recomp_context* ctx,std::uint32_t* words) noexcept { + dkr_owned_draw_event e{};words[0]=0; + using namespace dkr::runtime; + if(!ram||!ctx||!enhancements::modern_presentation_enabled()||!netplay::external_side_effects_allowed()||!netplay::session().runtime_view().active)return; + const auto range=[](std::uint32_t p,unsigned bytes){return p>=0x80000000U&&p<0x80800000U&&bytes<=0x80800000U-p;}; + const auto word=[&](std::uint32_t p){return range(p,4)&&!(p&3U)?ReadU32(ram,p):0U;}; + const auto byte=[&](std::uint32_t p){return range(p,1)?unsigned(ReadU8(ram,p)):0U;}; + const auto half=[&](std::uint32_t p){if(!range(p,2)||(p&1U))return 0U;return byte(p)*256U+byte(p+1);}; + const auto object=std::uint32_t(ctx->r5); + if(!range(object,0x80)||half(object+0x48)!=1)return; // Ghost variants keep their native model. + const auto header=word(object+0x40),slots=word(object+0x68),racer=word(object+0x64); + if(!range(header,0x78)||!range(racer,0x224))return; + const auto count=byte(header+0x55),index=byte(object+0x3a); + if(!count||count>32||index>=count||!range(slots,count*4))return; + unsigned first=0;std::uint32_t highest=0; + for(;firstr29); + if(!range(start,8)||(start&7)||viewport>3||!range(stack+16,4))return; + const auto head=word(stack+16);if(head>1)return; + e.kind=DKR_OWNED_LOCAL_MODEL;e.address=start;e.token=viewport; + e.parameters[1]=object;e.parameters[2]=highest;e.parameters[3]=half(object+0x18); + e.parameters[4]=byte(object+0x3b);e.parameters[5]=std::uint32_t(ctx->r7); + e.parameters[6]=ctx->r6!=0;e.parameters[7]=head;e.parameters[8]=half(racer+0x16a);e.parameters[9]=word(intensity); + static_assert(sizeof(e)==19*sizeof(std::uint32_t));std::memcpy(words,&e,sizeof(e)); +} +extern "C" void dkr_detail_mesh_end(std::uint8_t* ram,recomp_context*,std::uint32_t* words) noexcept { + dkr_owned_draw_event e{};std::memcpy(&e,words,sizeof(e));if(!e.kind)return; + const auto dl=dkr::runtime::revision_addresses::selected_revision()==dkr::runtime::rom::Revision::UsV80?0x8011B40CU:0x8011AE8CU; + e.parameters[0]=ReadU32(ram,dl); + if(dkr_owned_draw_event_valid(&e)) { + std::scoped_lock lock(g_identity_mutex);auto& rows=g_detail_maps[g_recording_buffer&1U]; + if(rows.size() bytes;}; +struct RetainedTexture { + std::uint32_t header=0;std::uint64_t checked=0; + std::shared_ptr texture; + std::vector inputs; +}; +std::vector retained_textures; +std::uint64_t texture_frame=0; +std::uint32_t texture_scene=0; +std::size_t retained_texture_bytes=0; +std::shared_ptr retain_texture(const Reader& r,std::uint32_t header) { + for(auto& entry:retained_textures)if(entry.header==header) { + bool matches=true; + if(entry.checked!=texture_frame)for(const auto& input:entry.inputs) { + r.offset(input.physical,input.bytes.size()); + if(std::memcmp(r.ram.data()+input.physical,input.bytes.data(),input.bytes.size())){matches=false;break;} + } + if(matches){entry.checked=texture_frame;++texture_hits;return entry.texture;} + retained_texture_bytes-=entry.texture->bytes.size();entry={};break; + } + auto tex=std::make_shared(netplay::experimental::capture_local_texture(ram,header)); + ++texture_captures; + RetainedTexture entry;entry.header=header;entry.checked=texture_frame;entry.texture=tex; + const auto dependency=[&](std::uint32_t address,std::size_t bytes) { + const auto physical=r.offset(address,bytes); + if(physical>=0x800000U||bytes>0x800000U-physical)throw std::runtime_error("material cache input escapes source snapshot"); + entry.inputs.push_back({physical,{r.ram.begin()+physical,r.ram.begin()+physical+bytes}}); + }; + const auto native_bytes=r.half(header+0x16),count=r.half(header+0xA); + const auto commands=r.word(header+0xC); + dependency(header,native_bytes);dependency(commands,count*8U); + for(unsigned c=0;ccommands[c][0]>>24)==0xFD&&tex->commands[c][1]>=native_bytes) { + const auto begin=tex->commands[c][1];std::size_t end=tex->bytes.size(); + for(const auto& words:tex->commands)if((words[0]>>24)==0xFD&&words[1]>begin)end=std::min(end,std::size_t(words[1])); + if(end<=begin)throw std::runtime_error("material cache palette extent"); + dependency(r.word(commands+c*8+4)&0xFFFFFFU,end-begin); + } + // Bounded persistent storage. A large mod still renders; only optional + // cross-frame caching is bypassed when the retained cache is full. + if(tex->bytes.size()<=4U*1024U*1024U-retained_texture_bytes) { + auto empty=std::find_if(retained_textures.begin(),retained_textures.end(),[](const auto& e){return !e.header;}); + if(empty!=retained_textures.end()){retained_texture_bytes+=tex->bytes.size();*empty=std::move(entry);} + else if(retained_textures.size()<256){retained_texture_bytes+=tex->bytes.size();retained_textures.push_back(std::move(entry));} + } + return tex; +} diff --git a/runtime-recomp/src/game/diagnostics.hpp b/runtime-recomp/src/game/diagnostics.hpp new file mode 100644 index 00000000..ae6f7794 --- /dev/null +++ b/runtime-recomp/src/game/diagnostics.hpp @@ -0,0 +1,78 @@ +#pragma once +#include "policy.hpp" +#include +#include +#include +#include +#include + +namespace dkr::lobby_stability { +struct Diagnostics { + std::string attempt,role,manifest,stage,package,error; + int phase=-1; + bool running=false; + std::uint64_t received=0,total=0,wire=0,cached=0,retries=0; + std::uint64_t sampled_wire=0; + double bytes_per_second=0; + Clock::time_point began=Clock::now(),phase_began=began,progress=began; + Clock::time_point sampled_at=began; +}; +inline std::mutex diagnostics_mutex; +inline std::map diagnostics; +inline std::atomic sequence{0}; +inline std::atomic bootstrap_match{0}; +inline std::atomic bootstrap_events{0}; +inline void begin_bootstrap(std::uint64_t match){bootstrap_events.store(0);bootstrap_match.store(match);} +inline void end_bootstrap(){bootstrap_match.store(0);} +inline void bootstrap_event(const char* event,std::uint32_t scheduler,std::uint32_t task,std::uint64_t generation=0) { + const auto match=bootstrap_match.load();if(!match)return; + auto count=bootstrap_events.load(); + while(count<64&&!bootstrap_events.compare_exchange_weak(count,count+1)){} + if(count>=64)return; + std::fprintf(stderr,"[lobby-start][scheduler] match=%llu event=%s scheduler=%08X task=%08X generation=%llu\n", + static_cast(match),event,scheduler,task,static_cast(generation)); +} +inline std::string printable(std::string_view text) { + std::string out;for(char c:text.substr(0,1024))out+=static_cast(c)>=32?c:' ';return out; +} +inline void begin(const void* owner,std::string_view role) { + std::lock_guard lock(diagnostics_mutex); + if(diagnostics.size()>=16&&!diagnostics.contains(owner))diagnostics.erase(diagnostics.begin()); + auto& d=diagnostics[owner];d={};d.role=role;d.running=true; + d.attempt=std::to_string(Clock::now().time_since_epoch().count())+"-"+std::to_string(++sequence); + std::fprintf(stderr,"[lobby-join] attempt=%s role=%s event=begin\n",d.attempt.c_str(),d.role.c_str()); +} +inline void erase(const void* owner){std::lock_guard lock(diagnostics_mutex);diagnostics.erase(owner);} +inline void retire(const void* owner){std::lock_guard lock(diagnostics_mutex);diagnostics[owner].running=false;} +inline Diagnostics snapshot(const void* owner){std::lock_guard lock(diagnostics_mutex);const auto at=diagnostics.find(owner);return at==diagnostics.end()?Diagnostics{}:at->second;} +inline void stage(const void* owner,int phase,std::string_view label,std::string_view error={}) { + std::lock_guard lock(diagnostics_mutex);auto& d=diagnostics[owner]; + if(d.attempt.empty() || (phase==0&&label.empty()&&error.empty()) || (d.phase==phase&&d.stage==label&&error.empty()))return; + const auto now=Clock::now();if(d.phase!=phase){d.phase=phase;d.phase_began=now;} + d.stage=printable(label);if(!error.empty())d.error=printable(error); + std::fprintf(stderr,"[lobby-join] attempt=%s role=%s phase=%d elapsed-ms=%lld stage=%s package=%s received=%llu total=%llu wire=%llu cache=%llu repairs=%llu error=%s\n", + d.attempt.c_str(),d.role.c_str(),phase,static_cast(std::chrono::duration_cast(now-d.began).count()), + d.stage.c_str(),d.package.c_str(),static_cast(d.received),static_cast(d.total), + static_cast(d.wire),static_cast(d.cached),static_cast(d.retries),d.error.c_str()); +} +inline void manifest(const void* owner,std::string_view value) { + std::lock_guard lock(diagnostics_mutex);auto& d=diagnostics[owner];if(d.manifest==value)return;d.manifest=value; + std::fprintf(stderr,"[lobby-join] attempt=%s manifest=%s\n",d.attempt.c_str(),d.manifest.c_str()); +} +inline void package(const void* owner,std::string_view value,std::string_view name) { + std::lock_guard lock(diagnostics_mutex);auto& d=diagnostics[owner];if(d.package==value)return;d.package=value; + std::fprintf(stderr,"[lobby-join] attempt=%s payload=%s name=%s\n",d.attempt.c_str(),d.package.c_str(),printable(name).c_str()); +} +inline void counters(const void* owner,std::uint64_t received,std::uint64_t total,std::uint64_t wire,std::uint64_t cached,std::uint64_t retries) { + std::lock_guard lock(diagnostics_mutex);auto& d=diagnostics[owner]; + if(received>d.received)d.progress=Clock::now();d.received=received;d.total=total;d.wire=wire;d.cached=cached;d.retries=retries; + const auto now=Clock::now();const auto seconds=std::chrono::duration(now-d.sampled_at).count(); + if(seconds>=1){d.bytes_per_second=double(wire-d.sampled_wire)/seconds;d.sampled_wire=wire;d.sampled_at=now;} +} +inline std::string report(const void* owner) { + const auto d=snapshot(owner);std::ostringstream out; + out<<"Attempt: "< +#include +#include +#include +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + +namespace dkr::online { +// Same public GTS Root R4 fallback as the accepted Quick Join TLS repair. +inline constexpr std::string_view DirectoryFallbackRoot=R"PEM(-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi +QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR +HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 +p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE----- +)PEM"; +inline std::string directory_ca_bundle(){ + if(const char* configured=std::getenv("SSL_CERT_FILE");configured&&*configured){ + std::error_code error;if(std::filesystem::is_regular_file(configured,error))return configured; + } +#ifdef _WIN32 + // libdatachannel requires an explicit CA source on Windows. Export only + // public ROOT certificates; no personal certificates/private keys are read. + struct Store {HCERTSTORE value=CertOpenSystemStoreW(0,L"ROOT");~Store(){if(value)CertCloseStore(value,0);}} store; + std::string bundle; + if(store.value){ + PCCERT_CONTEXT certificate=nullptr; + while((certificate=CertEnumCertificatesInStore(store.value,certificate))!=nullptr){ + DWORD size=0; + if(!CryptBinaryToStringA(certificate->pbCertEncoded,certificate->cbCertEncoded,CRYPT_STRING_BASE64HEADER,nullptr,&size)||size>1024*1024)continue; + std::string pem(size,'\0'); + if(CryptBinaryToStringA(certificate->pbCertEncoded,certificate->cbCertEncoded,CRYPT_STRING_BASE64HEADER,pem.data(),&size)){ + while(!pem.empty()&&pem.back()=='\0')pem.pop_back();bundle+=pem;bundle+='\n'; + } + } + } + if(!bundle.empty())return bundle; +#else + for(const char* path:{"/etc/ssl/certs/ca-certificates.crt","/etc/ssl/cert.pem","/etc/pki/tls/certs/ca-bundle.crt","/etc/ssl/ca-bundle.pem"}){ + std::error_code error;if(std::filesystem::is_regular_file(path,error))return path; + } +#endif + return std::string(DirectoryFallbackRoot); +} +} diff --git a/runtime-recomp/src/game/directory_ui.inl b/runtime-recomp/src/game/directory_ui.inl new file mode 100644 index 00000000..883780d6 --- /dev/null +++ b/runtime-recomp/src/game/directory_ui.inl @@ -0,0 +1,73 @@ +// Uses the retained launcher's native controls, cards, fonts and focus handling. +bool g_directory_friends_view=false; +const char* DirectoryPhase(const std::string& phase){ + if(phase=="starting")return "Starting";if(phase=="testing")return "Testing connection"; + if(phase=="loading")return "Loading";if(phase=="racing")return "Racing"; + if(phase=="returning")return "Returning to lobby";return "Waiting"; +} +void DrawOlDirectoryAccount(float inner){ + const auto online=dkr::online::view();const auto account=dkr::store::service().view(); + OlParagraph(online->signed_in?"Discord: "+online->account_name:"Sign in with Discord for public lobbies.",14.F,kOlSoft,inner); + PaddockGap(8.F); + if(!online->signed_in){ + if(account->signing_in){if(OlButton("Cancel sign-in"))dkr::store::service().cancel_login();} + else if(OlButton("Sign in with Discord",OlTone::Go)){dkr::online::connect(true);dkr::store::service().login();} + bool remember=account->remember; +#ifdef _WIN32 + const bool secure_remember=true; +#else + const bool secure_remember=dkr::online::keyring::available(); +#endif + if(secure_remember){PaddockGap(8.F);if(OlSwitchRow("##directory-remember","Keep me signed in","Protected by this computer's credential store.",&remember,inner,false))dkr::store::service().remember(remember);} + else OlParagraph("Session-only sign-in. A desktop keyring is unavailable.",12.5F,kOlSoft,inner); + if(account->signing_in){PaddockGap(8.F);OlParagraph(account->status,12.5F,kOlSoft,inner);} + if(!account->error.empty()){PaddockGap(8.F);OlParagraph(account->error,12.5F,kOlError,inner);} + }else if(online->enabled&&online->connected){if(OlButton("Disconnect Online"))dkr::online::connect(false);} + else if(online->enabled){if(OlButton("Reconnect Online",OlTone::Go))dkr::online::connect(true);} + else if(OlButton("Connect Online",OlTone::Go))dkr::online::connect(true); + if(online->signed_in&&account->status=="Signed in with Discord."&&!account->error.empty()){ + PaddockGap(8.F);OlParagraph(account->error,12.5F,kOlError,inner); + } + if(!online->error.empty()){PaddockGap(8.F);OlParagraph(online->error,12.5F,kOlError,inner);} + if(online->joining){PaddockGap(8.F);OlParagraph("Contacting the host...",14.F,kOlSoft,inner);if(OlButton("Cancel join"))dkr::online::cancel_join();} +} +const char* DirectoryVisibilityNote(){return dkr::online::view()->public_desired? + "Visible worldwide to signed-in players.":"Unlisted. Friends or a lobby code are required.";} +float DirectoryVisibilityHeight(float width){return OlParagraphHeight("Lobby visibility",14.F,width,true,1.4F)+6.F+44.F+8.F+ + OlParagraphHeight(DirectoryVisibilityNote(),15.F,width)+10.F;} +void DrawOlDirectoryVisibility(float width,bool disabled){ + const auto snapshot=dkr::online::view();int value=snapshot->public_desired?1:0; + {const OlDisabled locked(disabled); + if(OlSelect("directory-visibility","Lobby visibility",&value,{"Private","Public"},width)){ + if(value&&dkr::runtime::netplay::friend_service().appear_offline())OlNotify("Turn off Appear offline before publishing a public lobby."); + else dkr::online::visibility(value!=0); + } + } + PaddockGap(8.F);OlParagraph(DirectoryVisibilityNote(),15.F,kOlSoft,width);PaddockGap(10.F); +} +void DrawOlPublicLobbies(float width,const OnlineFrame& frame){ + const auto snapshot=dkr::online::view(); + DrawOlFeature(width,"Public lobbies","Find players worldwide. The host still approves entry.",[&](float inner){ + if(!snapshot->connected){OlParagraph(snapshot->status,15.F,kOlSoft,inner);return;} + if(snapshot->rooms.empty()){OlParagraph("No public lobbies are available. Create a lobby and choose Public to host one.",15.F,kOlSoft,inner);return;} + for(const auto& lobby:snapshot->rooms){ + const bool compatible=lobby.compatibility==dkr::online::compatibility(); + const bool can_join=frame.launcher&&frame.rom_ready&&!frame.active&&!snapshot->joining&&compatible; + OlRoom room;room.id="public-"+lobby.id;room.title=lobby.title;room.tag=DirectoryPhase(lobby.phase); + room.summary=std::to_string(lobby.racers)+" / "+std::to_string(lobby.max_racers)+" racers"; + if(lobby.max_spectators)room.summary+=" | "+std::to_string(lobby.spectators)+" / "+std::to_string(lobby.max_spectators)+" spectators"; + room.note="Hosted by "+lobby.host; + if(!compatible)room.error="A different DKR-R network build is required."; + else if(lobby.locked)room.note+=". The lobby is locked."; + else if(lobby.phase=="racing"&&lobby.can_spectate)room.note+=". Spectator places are available."; + else if(lobby.phase!="waiting")room.note+=". Joining is paused while the lobby changes state."; + const bool watch=!lobby.can_join&&lobby.can_spectate; + room.action=watch?"Spectate":"Request to join"; + room.action_disabled=!can_join||(!lobby.can_join&&!lobby.can_spectate); + if(can_join&&lobby.can_join&&lobby.can_spectate)room.secondary="Spectate"; + PaddockGap(16.F);const auto pressed=DrawOlRoom(room,inner); + if(pressed==OlRoomPress::Action||pressed==OlRoomPress::Secondary) + dkr::online::request_join(lobby.id,{},watch||pressed==OlRoomPress::Secondary); + } + }); +} diff --git a/runtime-recomp/src/game/driver_anchor_units.hpp b/runtime-recomp/src/game/driver_anchor_units.hpp new file mode 100644 index 00000000..44e822d4 --- /dev/null +++ b/runtime-recomp/src/game/driver_anchor_units.hpp @@ -0,0 +1,15 @@ +#pragma once +#include "vehicle_seat_alignment.hpp" +#include + +namespace dkr::runtime::vehicles::original_textures { +inline mods::vehicles::Vec3 model_anchor(const mods::vehicles::Package& car,unsigned base,float native_scale) { + if(!std::isfinite(native_scale)||native_scale<=0)throw mods::Error("Custom vehicle: invalid native driver scale."); + // Only the explicit revision-1 contract marks raw native anchors. Older + // per-driver exports and calibrated legacy shared seats retain their + // accepted Studio-unit conversion. Never infer units from model bounds. + if(car.per_driver() && car.authored_anchors)return car.driver_anchors.at(base); + const auto old=seating::anchor(car,base); + return {old.x/native_scale,old.y/native_scale,old.z/native_scale}; +} +} diff --git a/runtime-recomp/src/game/enhanced_water_registry.hpp b/runtime-recomp/src/game/enhanced_water_registry.hpp index f9378ece..bb5be1d8 100644 --- a/runtime-recomp/src/game/enhanced_water_registry.hpp +++ b/runtime-recomp/src/game/enhanced_water_registry.hpp @@ -6,8 +6,11 @@ #include "flowing_water_mapping.hpp" #include #include +#include #include #include +#include "texture_binding_index.hpp" +#include "performance_observation.hpp" namespace dkr::runtime::water::enhanced { @@ -131,8 +134,18 @@ constexpr WaveSymbols wave_symbols(bool us_v80) { } class Registry { + std::vector liquid_headers_; + void add_liquid_header(std::uint32_t header) { + if(liquid_headers_.size()<256 && std::find(liquid_headers_.begin(),liquid_headers_.end(),header)==liquid_headers_.end()) + liquid_headers_.push_back(header); + } +public: + const auto& liquid_headers() const {return liquid_headers_;} +private: struct WetTriangle { Vec3 a,b,c; }; std::vector surfaces_; + struct FlowVertices {std::uint32_t end; std::array period;}; + std::map flow_vertices_; std::vector flow_triangles_; std::vector wet_triangles_; struct VerticalImage {std::uint32_t begin,end;Kind kind;std::array scroll_period;}; @@ -143,6 +156,15 @@ class Registry { std::vector lava_emitters_; std::shared_ptr glow_geometry_; public: + std::array flow_vertex_period(std::uint32_t source,std::uint32_t count) const { + source &= 0x1FFFFFFFU; + if(!count)return {}; + auto it=flow_vertices_.upper_bound(source); + if(it==flow_vertices_.begin())return {}; + --it; + if((source-it->first)%10 || std::uint64_t(source)+std::uint64_t(count)*10>it->second.end)return {}; + return it->second.period; + } const std::shared_ptr& glow_geometry() const {return glow_geometry_;} std::span lava_emitters() const { return lava_emitters_; } const FlowTriangle* flow_triangle(std::uint32_t source) const { @@ -236,15 +258,18 @@ class Registry { } void rebuild(Memory m,std::uint32_t model,std::uint32_t cache,std::uint32_t texture_count, std::uint32_t procedural_batch,std::optional metadata={},bool collect_rays=false, - bool preserve_flow_phase=false,const TextureScrolls& scrolls={},bool collect_glow=false) { + bool preserve_flow_phase=false,const TextureScrolls& scrolls={},bool collect_glow=false,bool animation_only=false) { + surgical::Scope registry_timing(surgical::Region::Registry); const auto previous_flows=preserve_flow_phase ? std::move(flow_triangles_) : std::vector{}; flow_triangles_.clear(); + flow_vertices_.clear(); surfaces_.clear(); wet_triangles_.clear(); vertical_images_.clear(); procedural_.reset(); lava_emitters_.clear(); const auto previous_rays=std::move(ray_geometry_); const auto previous_glow=std::move(glow_geometry_); - auto glow=collect_glow ? std::make_shared() : nullptr; - auto rays=collect_rays ? std::make_shared() : nullptr; + liquid_headers_.clear(); + auto glow=collect_glow && !animation_only ? std::make_shared() : nullptr; + auto rays=collect_rays && !animation_only ? std::make_shared() : nullptr; std::vector falling_vertices; bool ray_limit=false; std::array opacity_indices{}; @@ -265,6 +290,7 @@ class Registry { const Kind kind=id==1241 ? Kind::Fountain : (id==317 || id==1177 || id==1391) ? Kind::Waterfall : Kind::None; if(kind==Kind::None || !ptr || !m.valid(ptr,32))continue; + add_liquid_header(ptr); const auto frames=(std::max)(1U,std::uint32_t(m.u16(ptr+0x12)>>8)); const auto stride=m.u16(ptr+0x16); if(frames>256 || stride<32 || !m.valid(ptr,std::uint64_t(frames)*stride))continue; @@ -279,6 +305,11 @@ class Registry { {float(m.u8(header)),float(m.u8(header+1))}}); } } + std::optional binding_index; + if(surgical::texture_index() && nt>=8 && texture_count>=32) { + try {binding_index.emplace(m,cache,texture_count,texture_3d_id);} + catch(const std::bad_alloc&) {binding_index.reset();} // Exact original lookup is the fallback. + } std::vector materials(nt); std::uint32_t total_batches=0,total_triangles=0; for(std::uint32_t t=0;tmaterials[t]; - for(std::uint32_t c=0;cfind(ptr); + else for(std::uint32_t c=0;c8192) {surfaces_.clear();flow_triangles_.clear();wet_triangles_.clear();procedural_.reset();return;} + if(++total_batches>8192) {surfaces_.clear();flow_triangles_.clear();flow_vertices_.clear();wet_triangles_.clear();procedural_.reset();return;} const auto batch=batches+b*12; const auto texture=m.u8(batch); const auto v=m.u16(batch+2),f=m.u16(batch+4); const auto ve=m.u16(batch+14),fe=m.u16(batch+16); if((texture>=nt && texture!=255) || ve<=v || ve>nv || fe<=f || fe>nf) continue; total_triangles+=fe-f; - if(total_triangles>262144) {surfaces_.clear();flow_triangles_.clear();wet_triangles_.clear();procedural_.reset();return;} + if(total_triangles>262144) {surfaces_.clear();flow_triangles_.clear();flow_vertices_.clear();wet_triangles_.clear();procedural_.reset();return;} auto e=texture==255 ? MaterialEvidence{} : materials[texture]; e.flags=m.u32(batch+8); e.geometry_valid=true; auto kind=classify(e); + if(kind!=Kind::None && texture period{float(m.u8(header))*8.F,float(m.u8(header+1))*8.F}; + if(period[0]>=8 && period[1]>=8) { + const auto begin=(verts+v*10)&0x1FFFFFFFU,end=(verts+ve*10)&0x1FFFFFFFU; + auto [at,inserted]=flow_vertices_.emplace(begin,FlowVertices{end,period}); + if(!inserted && (at->second.end!=end || at->second.period!=period))at->second.period={}; + } + } + // Vanilla animation needs only validated batch ranges and + // texture periods. Do not build flow meshes, shoreline data, + // opacity masks, emitters, ray geometry or lighting caches. + if(animation_only) { + Surface animation{(tris+f*16)&0x1FFFFFFFU,(tris+fe*16)&0x1FFFFFFFU, + batch&0x1FFFFFFFU,kind,0,{}}; + if((kind==Kind::Waterfall || kind==Kind::Fountain) && texture period{float(m.u8(header)),float(m.u8(header+1))}; + if(period[0]>=1 && period[1]>=1) + animation.scroll_period=period; + } + // Retain non-flowing ranges too: an excluded/reused level + // texture must not inherit the object-image fallback. + surfaces_.push_back(animation); + continue; + } // Native, immutable level geometry only. Never collect racers, // billboards, transparent overlays or a truncated blocker mesh. const auto glow_texture_flags=texture==255 ? 0 : m.u16(m.u32(textures+texture*8)+6); diff --git a/runtime-recomp/src/game/enhanced_water_rt64.hpp b/runtime-recomp/src/game/enhanced_water_rt64.hpp index 11353121..10cde72c 100644 --- a/runtime-recomp/src/game/enhanced_water_rt64.hpp +++ b/runtime-recomp/src/game/enhanced_water_rt64.hpp @@ -18,7 +18,7 @@ inline std::uint32_t wave_batch_symbol() { return wave_symbols(revision_addresses::selected_revision()==rom::Revision::UsV80).batch; } -inline const Registry& task_registry(RT64::State& state,std::uint32_t scene) { +inline const Registry& task_registry(RT64::State& state,std::uint32_t scene,bool animation_only=false) { struct Cache { Registry registry; std::array key{}; @@ -29,21 +29,21 @@ inline const Registry& task_registry(RT64::State& state,std::uint32_t scene) { static thread_local Cache cache; Memory m(std::span(state.RDRAM,0x800000)); const auto model=m.u32(level_model_symbol()); - const bool lighting=lava_lighting_enabled.load()!=0 && lava_lighting.load()>0; + const bool lighting=!animation_only && lava_lighting_enabled.load()!=0 && lava_lighting.load()>0; const bool glow=lighting; - const bool rays=hardware_ray_capability.load(std::memory_order_relaxed)==1 && + const bool rays=!animation_only && hardware_ray_capability.load(std::memory_order_relaxed)==1 && requested_reflection.load(std::memory_order_relaxed)==Reflection::Hardware && reflection_strength.load(std::memory_order_relaxed)>0; const std::array key{scene,model,m.u32(revision_addresses::TextureCache), - m.u32(revision_addresses::NumberOfLoadedTextures),m.u32(wave_batch_symbol()),(rays ? 1U : 0U)|(glow ? 2U : 0U)}; + m.u32(revision_addresses::NumberOfLoadedTextures),m.u32(wave_batch_symbol()),(rays ? 1U : 0U)|(glow ? 2U : 0U)|(animation_only ? 4U : 0U)}; const auto revision=revision_addresses::selected_revision(); - const auto scrolls=texture_scrolls(m,m.u32(revision_addresses::ObjectList),m.u32(revision_addresses::ObjectCount)); + const auto scrolls=animation_only ? TextureScrolls{} : texture_scrolls(m,m.u32(revision_addresses::ObjectList),m.u32(revision_addresses::ObjectCount)); if(cache.key!=key || cache.revision!=revision || cache.scrolls!=scrolls) { const bool preserve_flow_phase=cache.key[0]==scene && cache.key[1]==model && cache.revision==revision; cache.key=key;cache.revision=revision;cache.scrolls=scrolls; cache.registry.rebuild(m,key[1],key[2],key[3],key[4], - legacy::renderer_water_for_level(static_cast(m.u32(revision_addresses::CurrentMapId))),rays,preserve_flow_phase,scrolls,glow); - if(key[4] && cache.trace_lines<64) { + legacy::renderer_water_for_level(static_cast(m.u32(revision_addresses::CurrentMapId))),rays,preserve_flow_phase,scrolls,glow,animation_only); + if(!animation_only && key[4] && cache.trace_lines<64) { ++cache.trace_lines; const auto symbols=wave_symbols(revision==rom::Revision::UsV80); std::fprintf(stderr,"[water][wave-reference] scene=%u map=%u batch=%08X liquid=%u subdivisions=%u players=%u buffer=%08X\n", diff --git a/runtime-recomp/src/game/f3ddkr_rt64.cpp b/runtime-recomp/src/game/f3ddkr_rt64.cpp index 9d4a820b..904e8c74 100644 --- a/runtime-recomp/src/game/f3ddkr_rt64.cpp +++ b/runtime-recomp/src/game/f3ddkr_rt64.cpp @@ -1,4 +1,8 @@ +#include "render_provenance.hpp" #include "f3ddkr_rt64.hpp" +#include "vehicle_rt64.hpp" +#include "mods/sdk2_bridge.hpp" +#include "camera_rt64.hpp" #include "f3ddkr_execution_policy.hpp" #include "postrace_viewport_rt64.hpp" @@ -16,11 +20,23 @@ #include "split_screen_rt64.hpp" #include "online_view_rt64.hpp" #include "water_uv_rt64.hpp" +#include "flow_vertex_scope_rt64.hpp" +#include "intro_viewport_rt64.hpp" +#include "credits_viewport_rt64.hpp" +#include "credits_sky_rt64.hpp" +#include "intro_caption_rt64.hpp" +#include "intro_sky_rt64.hpp" +#include "water_residency_rt64.hpp" +#include "presentation_pacer.hpp" +#include "title_text_rt64.hpp" #include "water_performance.hpp" #include "water_scroll_policy.hpp" #include "track_select_draw_policy.hpp" #if defined(DKR_ENHANCED_WATER) #include "enhanced_water_rt64.hpp" +#include "flow_phase_rt64.hpp" +#include "flow_cadence_rt64.hpp" +#include "flow_history_rt64.hpp" #endif #include "local_scenery_rt64.hpp" #if defined(DKR_LOCAL_MODEL_DETAIL) @@ -93,7 +109,7 @@ using dkr::runtime::presentation::PresentationMarkerKind; constexpr std::uint32_t kHudPassMarkerBeginMode = 1U; constexpr float kSplitViewportCoverQuantisation = 1024.0F; constexpr std::uint32_t kRDRAMAddressMask = 0x00FFFFFFU; -constexpr std::uint32_t kRDRAMSize = 0x00800000U; +thread_local std::uint32_t kRDRAMSize = 0x00800000U; // widened only inside an authorized private camera decode const std::uint32_t& kCurrentMenuIdAddress = dkr::runtime::revision_addresses::CurrentMenuId; constexpr std::uint32_t kScratchVertexAddress = 0x007FE000U; @@ -103,6 +119,20 @@ constexpr float kRigidActorShadowBaseLift = 0.025F; std::atomic g_completed_tasks{0}; std::uint32_t g_logged_counted_errors = 0; +void ReportMalformedInput(RT64::State* state,RT64::DisplayList* command,std::uint64_t task,std::uint32_t payload=0,std::uint32_t bytes=0) { + static unsigned reports=0;if(reports++>=8||!state||!state->RDRAM||!command)return; + const auto address=reinterpret_cast(command)-reinterpret_cast(state->RDRAM); + if(address>kRDRAMSize-8)return; + std::fprintf(stderr,"[render][invalid-input] task=%llu dl=0x%06X words=(%08X,%08X); immutable task snapshot; command validation retained\n", + static_cast(task),unsigned(address),command->w0,command->w1); + const auto word=[&](std::uint32_t p) {std::uint32_t value=0;p&=0x1FFFFFFFU; + if(p<=0x7FFFFCU)std::memcpy(&value,state->RDRAM+p,4);return value;}; + dkr::runtime::render_provenance::report(task,unsigned(address),payload,bytes, + dkr::runtime::presentation::task_scene_generation(), + word(dkr::runtime::revision_addresses::CurrentMapId),word(dkr::runtime::revision_addresses::CurrentMenuId)); +} + + struct CanonicalShadowSlotHistory { dkr::runtime::presentation::ShadowTexcoordSample texcoord{}; RT64::RSP::Vertex vertex{}; @@ -327,6 +357,10 @@ std::uint64_t dkr::runtime::completed_f3ddkr_task_count() { } struct dkr::runtime::F3DDKRRT64Bridge::StateData { + camera::RenderPass* camera=nullptr; + vehicles::RenderPass* cars=nullptr; + std::optional camera_shadow; + std::array camera_matrices{}; struct HudAlignmentScope { RT64::ExtendedAlignment previous_scissor{}; RT64::ExtendedAlignment previous_rect_alignment{}; @@ -374,12 +408,21 @@ struct dkr::runtime::F3DDKRRT64Bridge::StateData { std::uint32_t matrix_offset = 0; float world_window_aspect = 4.0F / 3.0F; + presentation::IntroViewport intro_viewport; + presentation::IntroCaption intro_caption; + presentation::IntroSky intro_sky; + presentation::CreditsViewport credits_viewport; + presentation::CreditsSky credits_sky; + presentation::TitleTextViewport title_text; presentation::WorldProjection clearance_projection{}; presentation::WorldDepthRemap clearance_remap{}; #if defined(DKR_ENHANCED_WATER) hlslpp::float4x4 water_camera_vp{}; bool water_camera_valid = false; float water_time = 0.0F; + water::FlowFractions flow_fractions{}; + unsigned flow_loads=0,flow_faces=0,fractional_faces=0,manual_faces=0,held_faces=0,image_changes=0; + std::array manual_flow_slots{}; const water::enhanced::Registry* water_registry = nullptr; const water::enhanced::LavaGlowCache* lava_glow_cache = nullptr; float lava_glow_step = 0; @@ -524,12 +567,10 @@ void dkr::runtime::F3DDKRRT64Bridge::FinishShadowScope( bool magic_carpet_shadow_active = false; float rigid_actor_shadow_lift = kRigidActorShadowBaseLift; const std::size_t rigid_source_batch_count = data.shadow_batch_count; - const auto rigid_policy = - dkr::runtime::presentation::shadow_owner_rigid_policy( - data.shadow_history_key); - const auto owner_motion = - dkr::runtime::presentation::shadow_owner_motion_sample( - data.shadow_history_key); + const auto rigid_policy = data.camera_shadow ? data.camera_shadow->policy : + dkr::runtime::presentation::shadow_owner_rigid_policy(data.shadow_history_key); + const auto owner_motion = data.camera_shadow ? data.camera_shadow->motion : + dkr::runtime::presentation::shadow_owner_motion_sample(data.shadow_history_key); if (rigid_policy.required() && (history->rigid_owner_policy.kind != rigid_policy.kind || history->rigid_owner_policy.vehicle != rigid_policy.vehicle || @@ -1374,7 +1415,7 @@ void dkr::runtime::F3DDKRRT64Bridge::FinishShadowScope( std::span( current_batches.data(), data.shadow_batch_count)); const auto shadow_group = data.interpolation_groups.active_group(); - const std::uint8_t shadow_aspect = ActiveAspectMode( + const std::uint8_t shadow_aspect = data.camera_shadow ? G_EX_ASPECT_ADJUST : ActiveAspectMode( data.interpolation_groups, data.matrix_aspect_override_active, data.matrix_aspect_override); @@ -1679,7 +1720,7 @@ void dkr::runtime::F3DDKRRT64Bridge::FinishShadowScope( shadow_group.identity, page, history->interpolation_epoch), interpolate_page_vertices, false, false, shadow_aspect, - interpolate_page_vertices); + interpolate_page_vertices, 0U, data.camera_shadow.has_value()); rsp.modelViewProjChanged = true; auto& workload = state->ext.workloadQueue ->workloads[state->ext.workloadQueue->writeCursor]; @@ -1782,7 +1823,7 @@ void dkr::runtime::F3DDKRRT64Bridge::FinishShadowScope( SelectInterpolationGroup( rsp, shadow_group.identity, shadow_group.interpolate_vertices, shadow_group.interpolate_texcoords, shadow_group.interpolate_tiles, - shadow_aspect); + shadow_aspect, false, 0U, data.camera_shadow.has_value()); rsp.modelViewProjChanged = true; data.pending_shadow_batch = {}; @@ -1796,10 +1837,23 @@ void dkr::runtime::F3DDKRRT64Bridge::FinishShadowScope( void dkr::runtime::F3DDKRRT64Bridge::process(RT64::Application& application, const OSTask& task, LocalSceneryRT64Pass* scenery,online_view::Pass* online, - LocalModelRT64Pass* models) { + LocalModelRT64Pass* models,camera::RenderPass* camera) { + struct CameraDecodeScope { + camera::RenderPass* pass; + ~CameraDecodeScope(){if(pass)pass->finish();kRDRAMSize=0x800000U;} + } camera_scope{camera}; + kRDRAMSize=camera&&camera->enabled()?0x1000000U:0x800000U; + const auto car_resources=mods::vehicles::render_resources(); + const bool cars_requested=car_resources&&!car_resources->library.packages.empty(); + if(cars_requested||models)kRDRAMSize=0x1000000U; + vehicles::RenderPass car_pass(*application.state,car_resources,[](RT64::RSP& rsp,std::uint32_t id,bool vertices){ + SelectInterpolationGroup(rsp,id,vertices,false,false,G_EX_ASPECT_AUTO,false,0U,true); + }); active_ = this; StateData fresh{}; + fresh.cars=&car_pass; fresh.task_count = data_->task_count + 1; + fresh.camera=camera; fresh.local_scenery=scenery; fresh.local_models=models; fresh.online_view=online; @@ -1817,6 +1871,26 @@ void dkr::runtime::F3DDKRRT64Bridge::process(RT64::Application& application, *data_ = fresh; RT64::State* state = application.state.get(); + if(surgical::observing()) { + unsigned surgical_water_quality=0; +#if defined(DKR_ENHANCED_WATER) + surgical_water_quality=unsigned(water::enhanced::requested_quality.load(std::memory_order_relaxed)); +#endif + surgical::context(presentation::task_scene_generation(),ReadU32(state->RDRAM,revision_addresses::CurrentMapId), + ReadU32(state->RDRAM,kCurrentMenuIdAddress),application.userConfig.refreshRateTarget, + surgical_water_quality); + surgical::publish_context(presentation::task_scene_generation(),ReadU32(state->RDRAM,revision_addresses::CurrentMapId),ReadU32(state->RDRAM,kCurrentMenuIdAddress)); + } + surgical::Scope decode_timing(surgical::Region::Decode); + data_->title_text.begin(*state,data_->world_window_aspect, + enhancements::modern_presentation_enabled() && enhancements::fit_to_window_enabled()); + data_->credits_viewport.begin(enhancements::modern_presentation_enabled() && + enhancements::fit_to_window_enabled() && ReadU32(state->RDRAM,kCurrentMenuIdAddress)==25U,data_->world_window_aspect); + data_->credits_sky.begin(*state,data_->world_window_aspect,data_->credits_viewport.enabled()); + data_->intro_caption.begin(*state,enhancements::modern_presentation_enabled(),data_->world_window_aspect); + data_->intro_sky.begin(*state,enhancements::modern_presentation_enabled() && enhancements::fit_to_window_enabled()); + data_->intro_viewport.begin(enhancements::modern_presentation_enabled() && + enhancements::fit_to_window_enabled() && ReadU32(state->RDRAM,kCurrentMenuIdAddress)==1U); #if defined(DKR_ENHANCED_WATER) // A rejected/aborted display list must not leak its optical material into // the next task's shadows, HUD or ordinary scenery. @@ -1824,10 +1898,24 @@ void dkr::runtime::F3DDKRRT64Bridge::process(RT64::Application& application, state->flush();state->waterMaterial={}; state->updateDrawStatusAttribute(RT64::DrawAttribute::WaterMaterial); } - if(enhancements::modern_presentation_enabled() && - water::enhanced::requested_quality.load(std::memory_order_relaxed)!=water::enhanced::Quality::Original) { - data_->water_registry=&water::enhanced::task_registry(*state,presentation::task_scene_generation()); + const bool enhanced_water=water::enhanced::requested_quality.load(std::memory_order_relaxed)!=water::enhanced::Quality::Original; + if(!enhanced_water)state->waterRayGeometry.reset(); + if(enhancements::modern_presentation_enabled()) { + data_->water_registry=&water::enhanced::task_registry(*state,presentation::task_scene_generation(),!enhanced_water); data_->water_time=float(presentation::task_presentation_time()); + data_->flow_fractions=water::capture_flow_fractions(*state); + water::enhanced::Memory flow_memory(std::span(state->RDRAM,0x800000)); + water::residency().prepare(*state,*data_->water_registry,presentation::task_scene_generation(),!enhanced_water); + water::flow_history().begin(presentation::task_scene_generation(), + flow_memory.u32(water::enhanced::level_model_symbol()),presentation::task_presentation_time(), + presentation::task_interpolation_allowed(),state->ext.textureCache->getReplacementGeneration()); + static thread_local bool animation_notice=false; + if(!animation_notice) { + std::fprintf(stderr,"[water][animation] independent vanilla flow interpolation; optics remain optional; native triangle UV history and fractional scroll phase\n"); + animation_notice=true; + } + } + if(data_->water_registry && enhanced_water) { struct GlowTaskCache { water::enhanced::LavaGlowCache cache; std::uint32_t scene=~0U; @@ -1966,6 +2054,11 @@ void dkr::runtime::F3DDKRRT64Bridge::process(RT64::Application& application, data_->matrix_aspect_override_active = false; data_->matrix_aspect_override = G_EX_ASPECT_AUTO; data_->background_fill_stretch_active = false; +#if defined(DKR_ENHANCED_WATER) + if(enhancements::modern_presentation_enabled())water::report_flow_cadence(application, + presentation::task_scene_generation(),presentation::task_interpolation_allowed(), + data_->flow_loads,data_->flow_faces,data_->fractional_faces,data_->manual_faces,data_->held_faces,data_->image_changes); +#endif g_completed_tasks.store(data_->task_count, std::memory_order_release); if(data_->online_view)data_->online_view->finish(*state); if (water::enabled()) { @@ -1995,6 +2088,10 @@ bool dkr::runtime::F3DDKRRT64Bridge::ValidateCommand( if (active_ == nullptr || state == nullptr || command == nullptr) return false; auto& data = *active_->data_; if (data.task_rejected) return false; + if(data.camera) { + const auto base=reinterpret_cast(state->RDRAM),p=reinterpret_cast(command); + if(p>=base&&p-base<=kRDRAMSize-8)data.camera->before_command(std::uint32_t(p-base)); + } const auto check = data.command_budget.validate( reinterpret_cast(state->RDRAM), reinterpret_cast(command), kRDRAMSize, @@ -2036,8 +2133,13 @@ void dkr::runtime::F3DDKRRT64Bridge::Dispatch( ApplyPresentationMarkers(state, command); const std::uint8_t opcode = static_cast(command->w0 >> 24U); + if((command->w0&0xffff0000U)==0xb0ca0000U) { + if(!active_->data_->cars||!active_->data_->cars->marker(command->w0&0xffffU,command->w1))RejectTask(display_list); + return; + } const Handler handler = active_->original_handlers_[opcode]; if (handler == nullptr || handler == &Dispatch) { + ReportMalformedInput(state,command,active_->data_->task_count); if (g_logged_counted_errors++ < 64U) { std::fprintf(stderr, "[boot][f3ddkr] rejected unsupported opcode=0x%02X " @@ -2049,6 +2151,28 @@ void dkr::runtime::F3DDKRRT64Bridge::Dispatch( RejectTask(display_list); return; } + if(opcode==kFullSyncOpcode && active_->data_->title_text.has_images()) { + state->flush(); + auto& workload=state->ext.workloadQueue->workloads[state->ext.workloadQueue->writeCursor]; + for(unsigned i=0;idata_->title_text.normalise(workload.fbPairs[i]); + } + if(opcode==kDisplayListOpcode) { + active_->data_->title_text.prepare(*state,command); + active_->data_->intro_caption.prepare(*state,command); + } + if(opcode==kFullSyncOpcode && active_->data_->credits_viewport.enabled()) { + state->flush(); + auto& workload=state->ext.workloadQueue->workloads[state->ext.workloadQueue->writeCursor]; + for(unsigned i=0;idata_->credits_viewport.normalise(workload.fbPairs[i]); + } + if(opcode==kFullSyncOpcode && active_->data_->intro_viewport.enabled()) { + state->flush(); + auto& workload=state->ext.workloadQueue->workloads[state->ext.workloadQueue->writeCursor]; + for(unsigned i=0;idata_->intro_viewport.normalise(workload.fbPairs[i]); + } if (opcode == kFullSyncOpcode && ((active_->data_->online_view&&active_->data_->online_view->focused()) || active_->data_->split_color_image_count != 0U || active_->data_->hud_color_image_count != 0U || @@ -2123,6 +2247,13 @@ void dkr::runtime::F3DDKRRT64Bridge::Dispatch( profile::event(profile::Event::DecodedProjections,projections); profile::event(profile::Event::DecodedDrawCalls,calls); } +#if defined(DKR_ENHANCED_WATER) + if(opcode==kFullSyncOpcode) { + auto& workload=state->ext.workloadQueue->workloads[state->ext.workloadQueue->writeCursor]; + if(active_->data_->water_registry)water::residency().publish(&workload,workload.submissionFrame); + else water::streaming::publish(&workload,workload.submissionFrame,{}); + } +#endif handler(state, display_list); auto& data=*active_->data_; if(opcode==kSetScissorOpcode && data.track_preview_pending) { @@ -2134,6 +2265,12 @@ void dkr::runtime::F3DDKRRT64Bridge::Dispatch( state->updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); data.track_preview_pending=false; } + if(opcode==kSetScissorOpcode)data.intro_viewport.scissor(*state); + else if(opcode==kMoveMemOpcode && command->p0(16,8)==kViewportMoveMemType) + data.intro_viewport.viewport(*state); + if(opcode==kSetScissorOpcode)data.credits_viewport.scissor(*state); + else if(opcode==kMoveMemOpcode && command->p0(16,8)==kViewportMoveMemType) + data.credits_viewport.viewport(*state); AdjustSplitViewportCommand(state, command, opcode); if(data.online_view)data.online_view->after_command(*state,opcode==kSetScissorOpcode, opcode==kMoveMemOpcode&&command->p0(16,8)==kViewportMoveMemType); @@ -2220,7 +2357,7 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationMarkers( online->before_command(*state,static_cast(command-rdram_begin)); } #if defined(DKR_LOCAL_MODEL_DETAIL) - if(auto* models=active_->data_->local_models) { + if(auto* models=active_->data_->local_models;models&&(!active_->data_->online_view||!active_->data_->online_view->suppress_rectangle(*state))) { models->draw_at(*state,static_cast(command-rdram_begin), [](RT64::RSP& rsp,std::uint32_t id){ const auto& data=*active_->data_; @@ -2239,11 +2376,27 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationMarkers( data.matrix_aspect_override_active,data.matrix_aspect_override),false,0U,true); }); } + if(auto* camera=active_->data_->camera) { + for(const auto& marker:camera->shadows_at(std::uint32_t(command-rdram_begin))) { + auto& data=*active_->data_; + if(marker.begin) { + data.camera_shadow=marker; + ApplyPresentationGroup(state,kPresentationGroupShadowMode,0,marker.batches,PresentationMarkerKind::Geometry); + } else { + ApplyPresentationGroup(state,0,0,0,PresentationMarkerKind::Geometry); + data.camera_shadow.reset(); + } + } + } const auto markers = dkr::runtime::presentation::active_presentation_markers( static_cast(command - rdram_begin)); for (std::size_t index = 0U; index < markers.count; ++index) { const auto& marker = markers.markers[index]; + if(marker.kind==PresentationMarkerKind::CameraWorldBegin||marker.kind==PresentationMarkerKind::CameraWorldEnd)continue; + if(active_->data_->camera&&active_->data_->camera->replaced_command(std::uint32_t(command-rdram_begin))&& + marker.kind!=PresentationMarkerKind::SplitViewport&&marker.kind!=PresentationMarkerKind::HudPass&& + marker.kind!=PresentationMarkerKind::HudWidget&&marker.kind!=PresentationMarkerKind::HudRect)continue; if(marker.kind==PresentationMarkerKind::TrackSelectPreview) { auto& data=*active_->data_; const auto& b=marker.hud_transform; @@ -2340,15 +2493,16 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationGroup( } const auto apply_active_group = [&]() { const auto active_group = data.interpolation_groups.active_group(); + const bool camera_visual=data.camera_matrices[std::min(data.selected_matrix,2U)]; SelectInterpolationGroup( *state->rsp, active_group.identity, active_group.interpolate_vertices, active_group.interpolate_texcoords, active_group.interpolate_tiles, - ActiveAspectMode(data.interpolation_groups, + camera_visual ? G_EX_ASPECT_ADJUST : ActiveAspectMode(data.interpolation_groups, data.matrix_aspect_override_active, data.matrix_aspect_override), false, - active_group.procedural_water ? active_group.water_scroll_tag : 0U); + active_group.procedural_water ? active_group.water_scroll_tag : 0U,camera_visual); state->rsp->modelViewProjChanged = true; dkr::runtime::presentation::select_split_world_projection( *state->rsp, @@ -2438,12 +2592,13 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationGroup( data.track_ui_cover=presentation_token; } - if (kind == PresentationMarkerKind::BackgroundAspect) { + if (kind == PresentationMarkerKind::BackgroundAspect || + (kind==PresentationMarkerKind::TrackSelectLensFlare&&presentation_token==1U)) { // Background-only rectangles may cover the host width. They do // not alter the matrix aspect used by world or HUD geometry. data.rect_aspect_mode = G_EX_ASPECT_STRETCH; data.background_fill_stretch_active = - presentation_token <= 2U ? presentation_token : 0U; + presentation_token <= 2U ? presentation_token : 0U; // postrace-lens-window-v1 } else { // Framed views and fixed game UI preserve their own aspect. data.matrix_aspect_override_active = true; @@ -2579,6 +2734,14 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationGroup( left_origin = right_origin = G_EX_ORIGIN_NONE; hud_alignment.leftOrigin = hud_alignment.rightOrigin = G_EX_ORIGIN_NONE; } + // Screenshot visibility is local to the decoder HUD scope. Keep + // every native HUD update, DMA, state command and completion intact. + // The existing scope pop restores the world/next viewport scissor. + if(dkr::sdk2::hud_hidden.load(std::memory_order_relaxed)) { + rect.ulx=rect.uly=rect.lrx=rect.lry=0; + left_origin=right_origin=G_EX_ORIGIN_NONE; + hud_alignment.leftOrigin=hud_alignment.rightOrigin=G_EX_ORIGIN_NONE; + } state->rdp->setScissorAlign(hud_alignment); scope.changed_scissor = true; state->updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); @@ -2671,7 +2834,8 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationGroup( // Billboards, vehicle parts, surfaces and level segments remain // matrix-relative: folding in the selected identity distinguishes their // semantic owners and carries the logical camera continuity boundary. - const std::uint32_t scoped_identity = + const std::uint32_t scoped_identity = mode==kPresentationGroupShadowMode&&data.camera_shadow + ? data.camera_shadow->identity : dkr::runtime::interpolation::scope_identity_uses_selected_matrix( static_cast(mode)) ? dkr::runtime::presentation::with_camera_continuity( @@ -2700,7 +2864,7 @@ void dkr::runtime::F3DDKRRT64Bridge::ApplyPresentationGroup( data.pending_shadow_batches = {}; const std::uint32_t scene_generation = dkr::runtime::presentation::task_scene_generation(); - data.shadow_history_key = + data.shadow_history_key = data.camera_shadow ? data.camera_shadow->history : presentation_token != 0U && scene_generation != 0U ? (static_cast(scene_generation) << 16U) | presentation_token @@ -2776,6 +2940,7 @@ void dkr::runtime::F3DDKRRT64Bridge::Matrix( else if(data.online_view) { matrix_group.camera_root=presentation::matrix_interpolation(address).camera_root; } + data.camera_matrices[index]=data.camera&&data.camera->matrix(address,matrix_group); data.interpolation_groups.load_matrix( index, matrix_group.identity, matrix_group.interpolate_vertices, matrix_group.interpolate_texcoords, matrix_group.interpolate_tiles, @@ -2785,11 +2950,11 @@ void dkr::runtime::F3DDKRRT64Bridge::Matrix( active_group.interpolate_vertices, active_group.interpolate_texcoords, active_group.interpolate_tiles, - ActiveAspectMode( + data.camera_matrices[index]?G_EX_ASPECT_ADJUST:ActiveAspectMode( data.interpolation_groups, data.matrix_aspect_override_active, data.matrix_aspect_override), false, - active_group.procedural_water ? active_group.water_scroll_tag : 0U); + active_group.procedural_water ? active_group.water_scroll_tag : 0U,data.camera_matrices[index]); rsp.matrix(address, static_cast( active_->gbi_->constants[F3DENUM::G_MTX_LOAD])); // F3DDKR submits combined MVPs as MODEL matrices. Adjust ONLY their clip-Z @@ -2818,6 +2983,7 @@ void dkr::runtime::F3DDKRRT64Bridge::Matrix( data.water_camera_valid &= std::isfinite(float(data.water_camera_vp[row][col])); } #endif + if(data.cars)data.cars->matrix(index); } void dkr::runtime::F3DDKRRT64Bridge::FillRect( @@ -2825,6 +2991,8 @@ void dkr::runtime::F3DDKRRT64Bridge::FillRect( assert(active_ != nullptr); StateData& data = *active_->data_; if(data.online_view&&data.online_view->suppress_rectangle(*state))return; + if(data.intro_viewport.fill(*state,**display_list))return; + if(data.credits_viewport.fill(*state,**display_list))return; if (!data.background_fill_stretch_active) { RT64::GBI_RDP::fillRect(state, display_list); return; @@ -2851,6 +3019,10 @@ void dkr::runtime::F3DDKRRT64Bridge::FillRect( void dkr::runtime::F3DDKRRT64Bridge::HudTextureRect( RT64::State* state, RT64::DisplayList** display_list) { + if(active_->data_->intro_caption.draw(*state,display_list,[&](std::uint32_t address,unsigned count) { + RunCommands(state,reinterpret_cast(state->fromRDRAM(address&0x1FFFFFFFU)),count); + }))return; + if(active_->data_->title_text.draw(*state,display_list))return; const bool flip = ((*display_list)->w0 >> 24U) == G_TEXRECTFLIP; const auto& data = *active_->data_; const bool online_widget=data.online_view&&data.online_view->widget_active(); @@ -2978,6 +3150,10 @@ void dkr::runtime::F3DDKRRT64Bridge::Vertex( vertex.color.g = ReadU8(state->RDRAM, address + 7U); vertex.color.b = ReadU8(state->RDRAM, address + 8U); vertex.color.a = ReadU8(state->RDRAM, address + 9U); + if(!canonical_shadow) { + data.credits_sky.vertex(address,vertex); + data.intro_sky.vertex(address,vertex); + } if (canonical_shadow) { data.pending_shadow_batch.vertices[i] = vertex; } else { @@ -3029,7 +3205,26 @@ void dkr::runtime::F3DDKRRT64Bridge::Vertex( hud_transform,online_widget?2.5F:1.5F); rsp.modelViewProjChanged = true; } + // Match each authored flowing-water vertex load independently of other + // terrain. Window/aspect changes can change a segment's total vertex + // count; RT64 otherwise drops all UV matching for that shared transform. +#if defined(DKR_ENHANCED_WATER) + const auto stable_flow_period=data.water_registry ? data.water_registry->flow_vertex_period(source,count) : std::array{}; + water::FlowVertexScope flow_scope(rsp,source,count,destination, + stable_flow_period[0]>=1 && stable_flow_period[1]>=1 && + presentation::task_interpolation_allowed() && data.water_camera_valid && + !data.billboard && !data.shadow_scope_active && !data.hud_alignment_depth && + !transformed_hud && !data.interpolation_groups.active_group().procedural_water && + !data.interpolation_groups.has_rejected_scope(), + water::enhanced::requested_quality.load(std::memory_order_relaxed)==water::enhanced::Quality::Original); +#endif rsp.setVertex(kScratchVertexAddress, count, destination); +#if defined(DKR_ENHANCED_WATER) + data.flow_loads+=flow_scope.active(); + for(unsigned i=0;iready() && data.water_camera_valid && !data.billboard && data.hud_widget_depth==0 && !data.shadow_scope_active) { @@ -3110,6 +3305,7 @@ void dkr::runtime::F3DDKRRT64Bridge::Triangle(RT64::State* state, static_cast(data.task_count), source, i, vertices[0], vertices[1], vertices[2]); } + ReportMalformedInput(state,*display_list,data.task_count,source,count*16U); RejectTask(display_list); return; } @@ -3232,13 +3428,35 @@ void dkr::runtime::F3DDKRRT64Bridge::Triangle(RT64::State* state, const bool procedural_water = data.interpolation_groups.active_group().procedural_water; #if defined(DKR_ENHANCED_WATER) - if(data.water_registry) { + if(data.water_registry && water::enhanced::requested_quality.load(std::memory_order_relaxed)!=water::enhanced::Quality::Original) { water::enhanced::apply_material(*state,*data.water_registry,source,count,procedural_water, presentation::task_scene_generation(),data.water_camera_vp,data.water_camera_valid,data.water_time,data.water_camera_address); } else if(state->waterMaterial.identity.x>0) { - state->waterMaterial={}; + state->flush();state->waterMaterial={}; state->updateDrawStatusAttribute(RT64::DrawAttribute::WaterMaterial); } +#endif +#if defined(DKR_ENHANCED_WATER) + // Collect wrap periods independently of optical replacement. The existing + // queued RT64 UV matcher consumes this immutable per-vertex metadata in + // BOTH modes, so disabling RTX cannot disable authored texture scrolling. + std::array flow_period{},flow_fraction{}; + if(data.water_registry && data.water_camera_valid && + presentation::task_interpolation_allowed() && !data.hud_alignment_depth && + !data.hud_widget_depth && !data.interpolation_groups.has_rejected_scope()) { + const auto surface=data.water_registry->find(source,count); + if(surface) { + if(surface->kind==water::enhanced::Kind::Waterfall || surface->kind==water::enhanced::Kind::Fountain) + // Level texture scrolls wrap at dimension << 8 before the + // triangle decoder converts to UV/32. Optical flow mapping + // retains its own texture-pixel units. + flow_period={surface->scroll_period[0]*8.F,surface->scroll_period[1]*8.F}; + if(flow_period[0]>=1 && flow_period[1]>=1) { + water::enhanced::Memory m(std::span(state->RDRAM,0x800000)); + if(m.valid(surface->batch,12))flow_fraction=data.flow_fractions[m.u8(surface->batch)]; + } + } else flow_period=data.water_registry->vertical_scroll_period(state->rdp->texture.address); + } #endif const bool skip_equal_uv = procedural_water && water::equal_uv_enabled(); const bool coalesce_water = procedural_water && water::coalesce_enabled(); @@ -3288,6 +3506,18 @@ void dkr::runtime::F3DDKRRT64Bridge::Triangle(RT64::State* state, for (std::size_t corner = 0; corner < vertices.size(); ++corner) { skipped += water::set_texcoord(rsp, draw, vertices[corner], s[corner], t[corner], skip_equal_uv); +#if defined(DKR_ENHANCED_WATER) + // set_texcoord first restores raw authored ST on the actual + // current/cloned vertex. Adding the remainder here never + // accumulates across shared corners, triangles or tasks. + if((flag&0x80U)==0 && flow_period[0]>=1 && flow_period[1]>=1) { + const auto at=std::size_t(rsp.indices[vertices[corner]])*2; + if(at+1waterMaterial.identity.x>0 && state->waterMaterial.optics.w>0.5F) { @@ -3298,7 +3528,46 @@ void dkr::runtime::F3DDKRRT64Bridge::Triangle(RT64::State* state, } } #endif - rsp.drawIndexedTri(vertices[0], vertices[1], vertices[2]); +#if defined(DKR_ENHANCED_WATER) + const auto flow_face_begin=draw.faceIndices.size(); +#endif +#if defined(DKR_ENHANCED_WATER) + // Static original water carries its own source-triangle UV history. + // Its isolated groups have texcoord matching SKIP, and no period tags + // are published for this path: the queued matcher cannot overwrite it. + const bool manual_flow=flow_period[0]>=1 && flow_period[1]>=1 && + data.manual_flow_slots[vertices[0]] && data.manual_flow_slots[vertices[1]] && data.manual_flow_slots[vertices[2]]; + if(manual_flow) { + water::FlowCorners uv{}; + for(unsigned corner=0;corner<3;++corner) { + const auto at=std::size_t(rsp.indices[vertices[corner]])*2; + uv[corner]={draw.tcFloats[at],draw.tcFloats[at+1]}; + } + const auto motion=water::flow_history().observe(address,uv,flow_period,state->rdp->texture.address); + ++data.manual_faces;data.held_faces+=motion.matched&&motion.held;data.image_changes+=motion.image_changed; + for(unsigned corner=0;corner<3;++corner) { + const auto at=std::size_t(rsp.indices[vertices[corner]])*2; + if(at+1suppress(address))rsp.drawIndexedTri(vertices[0], vertices[1], vertices[2]); +#if defined(DKR_ENHANCED_WATER) + if(flow_period[0]>=1 && flow_period[1]>=1) { + ++data.flow_faces; + if((flag&0x80U)==0 && (flow_fraction[0]!=0 || flow_fraction[1]!=0))++data.fractional_faces; + } + if(!manual_flow && flow_period[0]>=1 && flow_period[1]>=1 && draw.faceIndices.size()==flow_face_begin+3) { + for(unsigned corner=0;corner<3;++corner) { + const auto vertex=draw.faceIndices[flow_face_begin+corner]; + if(vertex(data.task_count), address, i, count, commands[i].w0, commands[i].w1); } + ReportMalformedInput(state,*display_list,data.task_count,address,count*8U); RejectTask(display_list); return; } @@ -3442,11 +3712,11 @@ void dkr::runtime::F3DDKRRT64Bridge::MoveWord( active_group.interpolate_vertices, active_group.interpolate_texcoords, active_group.interpolate_tiles, - ActiveAspectMode( + data.camera_matrices[data.selected_matrix]?G_EX_ASPECT_ADJUST:ActiveAspectMode( data.interpolation_groups, data.matrix_aspect_override_active, data.matrix_aspect_override), false, - active_group.procedural_water ? active_group.water_scroll_tag : 0U); + active_group.procedural_water ? active_group.water_scroll_tag : 0U,data.camera_matrices[data.selected_matrix]); state->rsp->modelViewProjChanged = true; } else { RT64::GBI_F3D::moveWord(state, display_list); diff --git a/runtime-recomp/src/game/f3ddkr_rt64.hpp b/runtime-recomp/src/game/f3ddkr_rt64.hpp index 6245c6ea..4776ca73 100644 --- a/runtime-recomp/src/game/f3ddkr_rt64.hpp +++ b/runtime-recomp/src/game/f3ddkr_rt64.hpp @@ -17,6 +17,7 @@ namespace dkr::runtime { class LocalSceneryRT64Pass; class LocalModelRT64Pass; namespace online_view {class Pass;} +namespace camera {class RenderPass;} std::uint64_t completed_f3ddkr_task_count(); @@ -30,7 +31,7 @@ class F3DDKRRT64Bridge { void process(RT64::Application& application, const OSTask& task, LocalSceneryRT64Pass* scenery=nullptr,online_view::Pass* online=nullptr, - LocalModelRT64Pass* models=nullptr); + LocalModelRT64Pass* models=nullptr,camera::RenderPass* camera=nullptr); private: using Handler = void (*)(RT64::State*, RT64::DisplayList**); diff --git a/runtime-recomp/src/game/flow_cadence_rt64.hpp b/runtime-recomp/src/game/flow_cadence_rt64.hpp new file mode 100644 index 00000000..4604cfec --- /dev/null +++ b/runtime-recomp/src/game/flow_cadence_rt64.hpp @@ -0,0 +1,47 @@ +#pragma once +#include "hle/rt64_application.h" +#include "hle/rt64_shared_queue_resources.h" +#include "runtime_platform.hpp" +#include +#include +#include +#include + +namespace dkr::runtime::water { +// No GPU queries, readbacks or per-triangle clocks. One bounded line per five +// seconds while flowing water is submitted, plus a new baseline per scene. +inline void report_flow_cadence(const RT64::Application& application, + std::uint32_t scene,bool allowed,unsigned loads,unsigned faces,unsigned fractional, + unsigned manual,unsigned held,unsigned image_changes) { + using Clock=std::chrono::steady_clock; + struct Window { + Clock::time_point start{}; + std::uint64_t presented=0,interpolated=0,tasks=0,approved=0,loads=0,faces=0,fractional=0; + std::uint64_t manual=0,held=0,image_changes=0; + std::uint32_t scene=~0U; + }; + static thread_local Window window; + if(!application.sharedQueueResources)return; + const auto presented=application.sharedQueueResources->totalPresentations.load(std::memory_order_relaxed); + const auto interpolated=application.sharedQueueResources->totalInterpolatedPresentations.load(std::memory_order_relaxed); + const auto now=Clock::now(); + if(window.scene!=scene || window.start==Clock::time_point{} || presented(now-window.start).count(); + if(elapsed<5)return; + if(window.faces) { + int width=0,height=0; + if(auto* w=static_cast(platform::sdl_window()))SDL_GetWindowSize(w,&width,&height); + std::fprintf(stderr,"[water][cadence] scene=%u viewport=%dx%d tasks=%.1f presents=%.1f interpolated=%.1f target=%u sidecars=%llu/%llu flow-loads=%llu flow-faces=%llu fractional-faces=%llu manual-faces=%llu held-faces=%llu image-changes=%llu\n", + scene,width,height,window.tasks/elapsed,(presented-window.presented)/elapsed, + (interpolated-window.interpolated)/elapsed,unsigned(application.userConfig.refreshRateTarget), + static_cast(window.approved),static_cast(window.tasks), + static_cast(window.loads),static_cast(window.faces), + static_cast(window.fractional),static_cast(window.manual), + static_cast(window.held),static_cast(window.image_changes)); + } + window={now,presented,interpolated,0,0,0,0,0,0,0,0,scene}; +} +} diff --git a/runtime-recomp/src/game/flow_history_rt64.hpp b/runtime-recomp/src/game/flow_history_rt64.hpp new file mode 100644 index 00000000..5f787b33 --- /dev/null +++ b/runtime-recomp/src/game/flow_history_rt64.hpp @@ -0,0 +1,72 @@ +#pragma once +#include "waterfall_scroll_policy.hpp" +#include +#include +#include +#include +#include "reusable_flow_map.hpp" +#include "performance_switches.hpp" + +namespace dkr::runtime::water { +using FlowCorners=std::array,3>; +struct FlowMotion {FlowCorners velocity{};bool matched=false,held=false,image_changed=false;}; + +// Only reviewed static triangle addresses enter this renderer-owned history. +// UV phase is world-surface state: changing viewport/camera/terrain ordering +// must not pause it. RT64 already uploads and interpolates tcVelFloats; use +// that existing channel without changing any shader or dependency source. +templateclass Table> class BasicFlowHistory { + struct Sample {FlowCorners uv;std::array period;FlowMotion motion;std::uint32_t image;}; + Table previous_,current_; + double time_=-1; + std::uint32_t scene_=~0U,model_=0; + std::uint64_t material_generation_=0; +public: + void begin(std::uint32_t scene,std::uint32_t model,double time,bool allowed,std::uint64_t material_generation=0) { + if(!allowed || !std::isfinite(time) || time<0 || scene!=scene_ || model!=model_ || + time0.25 || material_generation!=material_generation_) { + previous_.clear();current_.clear();time_=-1; + } + scene_=scene;model_=model;material_generation_=material_generation; + if(!allowed)return; + if(time!=time_) { + previous_.swap(current_);current_.clear();time_=time; + } + } + FlowMotion observe(std::uint32_t source,FlowCorners uv,std::array period,std::uint32_t image) { + if(time_<0 || !valid_waterfall_period(period[0]) || !valid_waterfall_period(period[1]))return {}; + for(auto& corner:uv)for(float value:corner)if(!std::isfinite(value))return {}; + if(auto it=current_.find(source);it!=current_.end()) { + // Repeated views of one immutable triangle share this sample. + // Conflicting data at one source is not a safe interpolation key. + return it->second.uv==uv && it->second.period==period && it->second.image==image ? it->second.motion : FlowMotion{}; + } + if(current_.size()>=16384)return {}; + FlowMotion motion{}; + if(auto it=previous_.find(source);it!=previous_.end() && it->second.period==period) { + motion.matched=true;motion.held=true;motion.image_changed=it->second.image!=image; + for(unsigned corner=0;corner<3;++corner)for(unsigned axis=0;axis<2;++axis) { + const float velocity=waterfall_uv_velocity(it->second.uv[corner][axis],uv[corner][axis],period[axis]); + motion.velocity[corner][axis]=velocity; + if(std::abs(velocity)>0.000001F)motion.held=false; + } + } + current_.emplace(source,Sample{uv,period,motion,image}); + return motion; + } +}; +templateusing LegacyFlowMap=std::unordered_map; +class FlowHistory { + BasicFlowHistory legacy_; + BasicFlowHistory reusable_; +public: + void begin(std::uint32_t scene,std::uint32_t model,double time,bool allowed,std::uint64_t generation=0) { + if(surgical::flow_reuse())reusable_.begin(scene,model,time,allowed,generation); + else legacy_.begin(scene,model,time,allowed,generation); + } + FlowMotion observe(std::uint32_t source,FlowCorners uv,std::array period,std::uint32_t image) { + return surgical::flow_reuse()?reusable_.observe(source,uv,period,image):legacy_.observe(source,uv,period,image); + } +}; +inline FlowHistory& flow_history() {static thread_local FlowHistory history;return history;} +} diff --git a/runtime-recomp/src/game/flow_phase_rt64.hpp b/runtime-recomp/src/game/flow_phase_rt64.hpp new file mode 100644 index 00000000..22706455 --- /dev/null +++ b/runtime-recomp/src/game/flow_phase_rt64.hpp @@ -0,0 +1,32 @@ +#pragma once +#include "enhanced_water_registry.hpp" +#include "revision_addresses.hpp" +#include + +namespace dkr::runtime::water { +using FlowFractions=std::array,256>; + +// Retail adds speed*updateRate, emits accumulator>>2 into triangle ST, then +// retains accumulator&3. The float decoder must include that retained quarter +// of an ST unit or a slow current alternates a stationary frame with a jump. +// Observe only this task's immutable bytes; never change the simulation phase. +inline FlowFractions capture_flow_fractions(const RT64::State& state) { + FlowFractions result{}; + enhanced::Memory m(std::span(state.RDRAM,0x800000)); + const auto list=m.u32(revision_addresses::ObjectList); + const auto count=m.u32(revision_addresses::ObjectCount); + if(!list || count>4096 || !m.valid(list,std::uint64_t(count)*4))return result; + for(std::uint32_t i=0;i=256)continue; + result[texture][0]+=float(m.u16(scroll+8)&3U)/128.F; + result[texture][1]+=float(m.u16(scroll+10)&3U)/128.F; + } + return result; +} +} diff --git a/runtime-recomp/src/game/flow_vertex_scope_rt64.hpp b/runtime-recomp/src/game/flow_vertex_scope_rt64.hpp new file mode 100644 index 00000000..50e0fd4b --- /dev/null +++ b/runtime-recomp/src/game/flow_vertex_scope_rt64.hpp @@ -0,0 +1,43 @@ +#pragma once +#include "presentation_identity.hpp" +#include "hle/rt64_rsp.h" + +namespace dkr::runtime::water { + +// A vertex load is the immutable unit of DKR's authored static flow geometry. +// Preserve all camera/aspect/component rules, changing only its pairing key. +// Do not re-emit geometry, change guest memory or enable optical effects. +class FlowVertexScope { + RT64::RSP& rsp_; + RT64::TransformGroup previous_{}; + bool active_=false; +public: + FlowVertexScope(RT64::RSP& rsp,std::uint32_t source,std::uint32_t count, + std::uint32_t destination,bool enabled,bool explicit_uv=false) : rsp_(rsp) { + if(!enabled || rsp.extended.modelMatrixIdStackSize<1)return; + auto& group=rsp.extended.modelMatrixIdStack[rsp.extended.modelMatrixIdStackSize-1]; + if(group.matrixId==G_EX_ID_IGNORE || group.matrixId==G_EX_ID_AUTO)return; + previous_=group; + group.matrixId=presentation::normalise_identity(group.matrixId ^ 0x464C4F57U ^ + presentation::mix_identity(source) ^ presentation::mix_identity((count<<8U)|destination)); + group.ordering=G_EX_ORDER_LINEAR; + if(explicit_uv)group.texcoordInterpolation=G_EX_COMPONENT_SKIP; + rsp.extended.modelMatrixIdStackChanged=true; + rsp.modelViewProjChanged=true; + active_=true; + } + FlowVertexScope(const FlowVertexScope&)=delete; + FlowVertexScope& operator=(const FlowVertexScope&)=delete; + bool active() const {return active_;} + void restore() { + if(!active_)return; + rsp_.extended.modelMatrixIdStack[rsp_.extended.modelMatrixIdStackSize-1]=previous_; + rsp_.extended.modelMatrixIdStackChanged=true; + // The next ordinary vertex load must not be appended to the flow's + // transform range after its ID has been restored. + rsp_.modelViewProjChanged=true; + active_=false; + } + ~FlowVertexScope(){restore();} +}; +} diff --git a/runtime-recomp/src/game/game_main.cpp b/runtime-recomp/src/game/game_main.cpp index c01cb379..94612973 100644 --- a/runtime-recomp/src/game/game_main.cpp +++ b/runtime-recomp/src/game/game_main.cpp @@ -1,3 +1,10 @@ +#include "build_contract.hpp" +#include "diagnostics.hpp" +#include "rollback_policy.hpp" +#include "invite_handoff.hpp" +#include "party_link.hpp" +#include "custom_records.hpp" +#include "shared_saves.hpp" #include "game_registration.hpp" #include "revision_addresses.hpp" #include "null_renderer.hpp" @@ -42,6 +49,8 @@ #include "rt64_renderer.hpp" #include "runtime_texture_packs.hpp" #include "runtime_ui.hpp" +#include "achievements.hpp" +#include "camera_runtime.hpp" #include "runtime_hud_layout.hpp" #include #endif @@ -164,6 +173,7 @@ std::vector RecoverySaveLocations(const char* executable_ std::error_code ec; const auto executable = std::filesystem::absolute(std::filesystem::u8path(executable_argument), ec); if (!ec) roots.push_back(executable.parent_path() / "dkr-runtime-data"); + roots.push_back(dkr::runtime::portable::application_directory() / "dkr-runtime-data"); #if defined(_WIN32) if (const char* app_data = std::getenv("APPDATA"); app_data && *app_data) roots.emplace_back(std::filesystem::path(app_data) / "DKRPort"); @@ -580,6 +590,7 @@ int DkrMain(int argc, char** argv) { }); #endif dkr::runtime::startup_performance::mark("process-entry"); + std::fprintf(stderr,"[lobby-stability][build] version=%s contract=%s\n",DKR_RELEASE_VERSION,DKR_LOBBY_STABILITY_CONTRACT); #if defined(_WIN32) SetUnhandledExceptionFilter(RuntimeCrashFilter); if (argc == 4 && std::string_view(argv[1]) == "--self-test-install-layout") { @@ -1081,7 +1092,7 @@ int DkrMain(int argc, char** argv) { }, .presentation_allowed_callback = []() { #if defined(DKR_EXPERIMENTAL_RACE_TEST) - if(dkr_experimental_bootstrap_active())return false; + if(dkr_experimental_bootstrap_active())return dkr::lobby_stability::bootstrap_audio_worker_allowed(); #endif return dkr::runtime::netplay::external_side_effects_allowed(); }}; @@ -1142,6 +1153,7 @@ int DkrMain(int argc, char** argv) { dkr::runtime::rev_a_asset_mutex::reset_statistics(); dkr::runtime::legacy::begin_session(nullptr); dkr::runtime::code_mods::reset(); + dkr::runtime::camera::reset(); bool selecting_save_owner = false; std::shared_ptr online_mods; try { @@ -1161,22 +1173,57 @@ int DkrMain(int argc, char** argv) { } else dkr::runtime::legacy::begin_prepared(prepared_mods); const auto online_save = dkr::runtime::netplay::session().runtime_view(); const auto code_set=online_mods?online_mods->code():prepared_mods->code; - if(code_set&&online_save.active&&(!online_save.launch_descriptor||online_save.launch_descriptor->synchronization!= + dkr::runtime::achievements::begin(online_save.active,0,code_set&&!code_set->ordered.empty(),dkr::runtime::spectators::party_enabled(&dkr::runtime::netplay::session())||(code_set&&std::any_of(code_set->ordered.begin(),code_set->ordered.end(),[](const auto& p){return p->development;}))); + if(code_set&&!code_set->ordered.empty()&&online_save.active&&(!online_save.launch_descriptor||online_save.launch_descriptor->synchronization!= dkr::runtime::netplay::SynchronizationMode::ExperimentalRollback)) - throw dkr::mods::Error("Custom Magic Codes require the owned Experimental Rollback engine online."); + throw dkr::mods::Error("Custom Magic Codes require the owned Rollback engine online."); + dkr::runtime::camera::begin(config_directory,DKR_RELEASE_VERSION,registered_revision,online_save.active,code_set&&std::any_of(code_set->ordered.begin(),code_set->ordered.end(),[](const auto& p){return p->api==2&&p->modules.contains("presentation2");})); dkr::runtime::code_mods::begin(code_set,registered_revision, online_save.launch_descriptor?online_save.launch_descriptor->match_id:0x444B52434F444531ULL,online_save.active); - selecting_save_owner = true; + const bool development_code=code_set&&std::any_of(code_set->ordered.begin(),code_set->ordered.end(),[](const auto& p){return p->development;}); + const bool development_session=online_save.active?(online_save.launch_descriptor&&online_save.launch_descriptor->development):development_code; + if(development_code&&online_save.active&&!development_session)throw dkr::mods::Error("Enable Development lobby before using development code mods online."); + const auto developer_folder=development_session?std::filesystem::path("development")/(code_set?code_set->identity.substr(0,24):"online-"+std::to_string(online_save.launch_descriptor->match_id)):prepared_mods->save_subfolder; + const auto developer_paks=development_session?config_directory/"saves"/developer_folder/"paks":prepared_mods->pak_directory; + if(development_session&&!online_save.active&& + !dkr::runtime::saves::shared::prepare_development(config_directory,developer_folder,rom_error))throw dkr::mods::Error(rom_error); + selecting_save_owner = true; + if(dkr::runtime::spectators::party_enabled(&dkr::runtime::netplay::session())) { + auto party=dkr::runtime::spectators::party_link(&dkr::runtime::netplay::session()); + std::vector seed;{std::scoped_lock lock(party->mutex);seed=party->seed;} + if(seed.size()!=512)throw dkr::mods::Error("Party bootstrap is missing its verified save seed."); + dkr::runtime::spectators::party_bootstrap_seed(seed); + ultramodern::set_save_storage_callbacks({dkr::runtime::spectators::party_bootstrap_path, + dkr::runtime::spectators::party_bootstrap_load,dkr::runtime::spectators::party_bootstrap_write}); + dkr::runtime::pak::begin_session_directory({}); + std::filesystem::path records; + if(online_save.host) {std::vector active;std::filesystem::path owner; + if(!dkr::runtime::saves::read_online_adventure(true,online_save.launch_descriptor->match_id,active,owner,rom_error)||active!=seed)throw dkr::mods::Error("The host online seed changed during party boot: "+rom_error); + records=owner.parent_path()/"custom-records-v1.bin";} + if(!dkr::runtime::saves::records::begin(records,false,rom_error))throw dkr::mods::Error(rom_error); + } else { + dkr::runtime::spectators::party_bootstrap_seed({}); if (!dkr::runtime::saves::prepare_runtime_save_context(config_directory, - prepared_mods->save_subfolder, prepared_mods->pak_directory, + developer_folder, developer_paks, online_save.active, online_save.host, online_save.launch_descriptor ? online_save.launch_descriptor->match_id : 0, - online_save.online_save_hash, rom_error)) throw dkr::mods::Error(rom_error); + online_save.online_save_hash, rom_error,development_session)) throw dkr::mods::Error(rom_error); + { + const auto path=dkr::runtime::saves::runtime_save_path(); + const auto utf8=path.generic_u8string(); + std::fprintf(stderr,"[save][selected] owner=%s path=%s progress=%s\n", + online_save.active?"online":development_session?"development":"personal", + std::string(utf8.begin(),utf8.end()).c_str(),dkr::runtime::saves::shared::describe(path).c_str()); + } ultramodern::set_save_storage_callbacks({ dkr::runtime::saves::runtime_save_path, dkr::runtime::saves::load_runtime_save, dkr::runtime::saves::commit_runtime_save}); dkr::runtime::pak::begin_session_directory(dkr::runtime::saves::runtime_pak_directory()); + if(!dkr::runtime::saves::records::begin(development_session?std::filesystem::path{}: + dkr::runtime::saves::runtime_save_path().parent_path()/"custom-records-v1.bin", + !development_session&&(!online_save.active||online_save.host),rom_error))throw dkr::mods::Error(rom_error); + } } catch(const std::exception& error) { std::fprintf(stderr,"[legacy][launch] %s\n",error.what()); #if DKR_RUNTIME_HAS_RT64 @@ -1184,9 +1231,16 @@ int DkrMain(int argc, char** argv) { // No guest producer exists yet. A locked/invalid route is a // recoverable refusal, never permission to open an offline fallback. dkr::runtime::pak::begin_session_directory({}); - dkr::runtime::saves::retire_runtime_save_context(); + {std::string records_error;if(!dkr::runtime::saves::records::finish(records_error)) { + std::fprintf(stderr,"[save][custom-records] %s\n",records_error.c_str()); +#if DKR_RUNTIME_HAS_RT64 + dkr::runtime::ui::report_mod_error(records_error); +#endif + }} + dkr::runtime::saves::retire_runtime_save_context(); dkr::runtime::legacy::begin_session(nullptr); dkr::runtime::code_mods::reset(); + dkr::runtime::camera::reset(); prepared_mods.reset(); if (dkr::runtime::netplay::session().active()) dkr::runtime::netplay::session().fail_runtime_start(std::string("Save protection: ") + error.what()); @@ -1246,6 +1300,7 @@ int DkrMain(int argc, char** argv) { #endif std::fprintf(stderr, "[boot] runtime initialized; waiting for first safe VI state\n"); + dkr::runtime::invites::game_running(true); std::atomic runtime_finished{false}; #if defined(__ANDROID__) dkr::runtime::graphics_health::reset(); @@ -1279,6 +1334,9 @@ int DkrMain(int argc, char** argv) { admitted_lobby->fail_runtime_start(rom_error.empty()?"This ROM does not have an owned rollback payload.":rom_error); dkr::runtime::platform::shutdown();return 5; } + std::fprintf(stderr,"[lobby-start][bootstrap] match=%llu local=%u players=%u resources=%s event=begin-retail-bootstrap\n", + static_cast(owned_descriptor->match_id),unsigned(initial_online.local_slot),unsigned(owned_descriptor->player_count),online_mods?online_mods->fingerprint().c_str():"stock"); + dkr::lobby_stability::begin_bootstrap(owned_descriptor->match_id); local_bootstrap_session=std::make_unique(); dkr::runtime::netplay::bind_external_session(local_bootstrap_session.get()); // Local construction has neutral virtual controls. It may neither @@ -1341,11 +1399,17 @@ int DkrMain(int argc, char** argv) { if (dkr::runtime::host_tasks::registry.take_stall(stalled, task_check_now)) { char message[256]; std::snprintf(message, sizeof(message), - "Task %llu (%08X) has made no progress for 15 seconds at %s (pending=%u). " + "Task %llu (%08X, type=%u) has made no progress for 15 seconds at %s (pending=%u). " "It may still finish. No completion has been forced.", - static_cast(stalled.token.generation), stalled.token.address, + static_cast(stalled.token.generation), stalled.token.address,stalled.type, dkr::runtime::host_tasks::stage_name(stalled.stage), stalled.pending); std::fprintf(stderr, "[host-task][stall] %s\n", message); +#if defined(DKR_EXPERIMENTAL_RACE_TEST) + if(owned_launch)std::fprintf(stderr,"[lobby-start][bootstrap] match=%llu bootstrap-active=%u elapsed-ms=%lld task-type=%u task-generation=%llu stage=%s\n", + static_cast(owned_descriptor->match_id),unsigned(dkr_experimental_bootstrap_active()), + static_cast(std::chrono::duration_cast(task_check_now-runtime_started_at).count()),stalled.type, + static_cast(stalled.token.generation),dkr::runtime::host_tasks::stage_name(stalled.stage)); +#endif #if defined(__ANDROID__) if (task_stalls_reported == 0) dkr::runtime::android::report_task_stall(message); #endif @@ -1400,8 +1464,12 @@ int DkrMain(int argc, char** argv) { if(owned_launch) { dkr::runtime::netplay::bind_external_session(admitted_lobby); local_bootstrap_session.reset(); + dkr::lobby_stability::end_bootstrap(); auto bootstrap=dkr_experimental_bootstrap_take_state(); - if(runtime_failure==nullptr) { + std::fprintf(stderr,"[lobby-start][bootstrap] match=%llu event=retail-bootstrap-ended elapsed-ms=%lld memory-bytes=%zu error=%s\n", + static_cast(owned_descriptor->match_id),static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now()-runtime_started_at).count()), + bootstrap.memory.size(),bootstrap.error.c_str()); + if(runtime_failure==nullptr&&dkr::runtime::ui::lifecycle_request()!=dkr::runtime::ui::LifecycleRequest::StopGame) { std::string owned_error; bool owned_ok=false; try { @@ -1421,7 +1489,7 @@ int DkrMain(int argc, char** argv) { owned_failed=true; std::fprintf(stderr,"[rollback][owned] %s\n",owned_error.c_str()); admitted_lobby->fail_runtime_start(owned_error); - dkr::runtime::ui::report_mod_error("Experimental online play stopped safely: "+owned_error); + dkr::runtime::ui::report_mod_error("Online play stopped safely: "+owned_error); } } return_from_owned=true; @@ -1433,7 +1501,16 @@ int DkrMain(int argc, char** argv) { #endif dkr::runtime::pak::begin_session_directory({}); const auto save_failure = dkr::runtime::saves::runtime_save_failure(); + std::string invite_records_failure; + {std::string records_error;if(!dkr::runtime::saves::records::finish(records_error)) { + invite_records_failure=records_error; + std::fprintf(stderr,"[save][custom-records] %s\n",records_error.c_str()); +#if DKR_RUNTIME_HAS_RT64 + dkr::runtime::ui::report_mod_error(records_error); +#endif + }} dkr::runtime::saves::retire_runtime_save_context(); + dkr::runtime::invites::game_running(false); #if DKR_RUNTIME_HAS_RT64 if (!save_failure.empty()) dkr::runtime::ui::report_mod_error("Save protection: " + save_failure); #endif @@ -1442,6 +1519,7 @@ int DkrMain(int argc, char** argv) { const auto code_failure=dkr::runtime::code_mods::failure(); if(!code_failure.empty()){if(!mod_failure.empty())mod_failure+="; ";mod_failure+=code_failure;} dkr::runtime::code_mods::reset(); + dkr::runtime::camera::reset(); dkr::runtime::legacy::begin_session(nullptr); if (registered_revision == dkr::runtime::rom::Revision::UsV80) { const auto mutex_stats = @@ -1480,6 +1558,8 @@ int DkrMain(int argc, char** argv) { if(!mod_failure.empty())dkr::runtime::ui::report_mod_error("Custom content stopped safely: "+mod_failure); std::fprintf(stderr, "[boot][stop] game stopped; returning to launcher\n"); + dkr::runtime::invites::finish_return(!save_failure.empty()?save_failure:!invite_records_failure.empty()?invite_records_failure:!mod_failure.empty()?mod_failure: + owned_failed?std::string("The previous online game did not finish safely. Review its error before joining."):std::string{}); dkr::runtime::ui::reset_lifecycle_request(); const bool software_launcher_return = dkr::runtime::launcher_surface_recovery( owned_failed, return_from_owned, !mod_failure.empty() || !save_failure.empty()); diff --git a/runtime-recomp/src/game/game_registration.cpp b/runtime-recomp/src/game/game_registration.cpp index dcc00954..18643dda 100644 --- a/runtime-recomp/src/game/game_registration.cpp +++ b/runtime-recomp/src/game/game_registration.cpp @@ -1,3 +1,4 @@ +#include "party_link.hpp" #include "game_registration.hpp" #include "game_payload.hpp" @@ -47,7 +48,14 @@ void RunDkrEntrypoint(std::uint8_t* rdram, recomp_context* context) { } dkr::runtime::saves::reset_runtime_online_save_status(); - if (!dkr::runtime::saves::runtime_save_context_ready()) { + const bool private_party_boot= +#if defined(DKR_EXPERIMENTAL_RACE_TEST) + dkr_experimental_bootstrap_active()&&dkr::runtime::spectators::party_bootstrap_active(); +#else + false; +#endif + // The party owns a verified memory-only seed, not a personal save route. + if (!dkr::runtime::saves::runtime_save_context_ready()&&!private_party_boot) { std::fprintf(stderr, "[save][boot] %s\n", dkr::runtime::saves::runtime_save_failure().c_str()); ultramodern::quit(); return; } @@ -79,18 +87,11 @@ void RunDkrEntrypoint(std::uint8_t* rdram, recomp_context* context) { } std::fprintf(stderr, "[netplay][save] isolated online EEPROM activated\n"); - } else if(const auto mods=dkr::runtime::legacy::prepared_launch();mods && (mods->session||mods->code)) { - ultramodern::change_save_file(mods->save_subfolder.generic_u8string(),u8"dkr.us.v77"); - if(ultramodern::get_save_file_path().lexically_normal()!=mods->save_path.lexically_normal()) { - std::fprintf(stderr,"[legacy][save] Refusing guest entry: isolated save route did not activate.\n"); - ultramodern::quit();return; - } - std::fprintf(stderr,"[legacy][save] isolated offline mod-set EEPROM activated\n"); } else { - // A game can return to this launcher and start again in the same - // process. Explicitly leave any prior online route before a local - // launch so single-player always reopens its ordinary EEPROM. - ultramodern::change_save_file(u8"", u8"dkr.us.v77"); + // runtime_save_context_ready() verified the actual loaded EEPROM + // against its immutable launch owner above. Keep that exact route: + // ordinary, isolated mod-set, or isolated development save. + std::fprintf(stderr,"[save][boot] frozen offline EEPROM verified; no mutable route switch\n"); } dkr::runtime::code_mods::boot(rdram,recomp::mem_size); payload->entrypoint(rdram, context); diff --git a/runtime-recomp/src/game/intro_caption_rt64.hpp b/runtime-recomp/src/game/intro_caption_rt64.hpp new file mode 100644 index 00000000..ba1f8811 --- /dev/null +++ b/runtime-recomp/src/game/intro_caption_rt64.hpp @@ -0,0 +1,217 @@ +#pragma once +#include "polish_symbols.hpp" +#include "revision_addresses.hpp" +#include "hle/rt64_state.h" +#include "hle/rt64_rdp.h" +#include "hle/rt64_rsp.h" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::presentation { +// Recognise only the actual white SMALLFONT copyright run in MENU_LOGOS. +// Font texels, metrics and load commands come from this immutable task. +class IntroCaption { + struct Glyph {const RT64::DisplayList* command=nullptr;int x=0,y=0,w=0,h=0,s=0,t=0,atlas=0;}; + std::array glyphs_{}; + std::array original_{}; + unsigned count_=0,original_count_=0; + const std::uint8_t* ram_=nullptr; + const std::array* symbols_=nullptr; + std::uint32_t font_=0; + bool enabled_=false,drawn_=false; + int left_=0,top_=0,right_=0,bottom_=0; + float cover_=1; + static constexpr std::string_view copyright_="(C) COPYRIGHT RARE 1997"; + static constexpr std::string_view credit_="RECOMPILED BY THATGUYMCD!"; + static bool valid(std::uint32_t p,std::uint64_t n) {p&=0x1FFFFFFFU;return p>0 && p<0x800000 && n<=0x800000U-p;} + unsigned byte(std::uint32_t p) const {return ram_[(p&0x7FFFFF)^3];} + unsigned half(std::uint32_t p) const {std::uint16_t v;std::memcpy(&v,ram_+((p&0x7FFFFF)^2),2);return v;} + std::uint32_t word(std::uint32_t p) const {std::uint32_t v;std::memcpy(&v,ram_+(p&0x7FFFFC),4);return v;} + bool texture(unsigned atlas) const { + if(atlas>=32)return false; + const auto header=word(font_+0x80+atlas*4); + if(!valid(header,32))return false; + const auto commands=word(header+12),count=half(header+10); + if(!count || count>32 || !valid(commands,std::uint64_t(count)*8))return false; + for(unsigned i=0;i>24; + if(op!=0xFD && op!=0xF5 && op!=0xF2 && op!=0xF3 && op!=0xF4 && + op!=0xF0 && op!=0xE6 && op!=0xE7 && op!=0xE8)return false; + if(op==0xFD && !valid(word(p+4),1))return false; + } + return true; + } + int width(std::string_view text) const { + int result=0; + const bool compact=word((*symbols_)[4])!=0; + for(unsigned char ch:text) { + const auto p=font_+0x100+(ch-32)*8; + int step=ch==32 ? half(font_+0x24) : half(font_+0x20)?half(font_+0x20):byte(p+1); + if(compact && step)--step; + result+=step; + } + return result; + } + bool line(std::string_view text,int y) { + int x=160-(width(text)>>1); + const bool compact=word((*symbols_)[4])!=0; + for(unsigned char ch:text) { + if(ch==32){x+=half(font_+0x24);continue;} + const auto p=font_+0x100+(ch-32)*8; + const auto atlas=byte(p); + if(count_>=glyphs_.size() || !texture(atlas) || !byte(p+6) || !byte(p+7))return false; + auto& g=glyphs_[count_++]; + g={nullptr,x+int(byte(p+2)),y+int(byte(p+3)),int(byte(p+6)),int(byte(p+7)),int(byte(p+4))*32,int(byte(p+5))*32,int(atlas)}; + left_=std::min(left_,g.x);top_=std::min(top_,g.y); + right_=std::max(right_,g.x+g.w);bottom_=std::max(bottom_,g.y+g.h); + int step=half(font_+0x20)?half(font_+0x20):byte(p+1); + if(compact && step)--step; + x+=step; + } + return true; + } +public: + void begin(const RT64::State& state,bool modern,float aspect) { + enabled_=false;count_=original_count_=0;drawn_=false;ram_=state.RDRAM; + if(!modern || word(revision_addresses::CurrentMenuId)!=1)return; + cover_=std::clamp(std::isfinite(aspect)?aspect/(4.F/3.F):1.F,1.F,8.F); + symbols_=&polish_symbols[revision_addresses::CurrentMenuId==revision_addresses::kUsV80.CurrentMenuId?1:0]; + const auto fonts=word((*symbols_)[2]); + if(word((*symbols_)[3])<3 || word((*symbols_)[3])>32 || !valid(fonts,3*0x400))return; + font_=fonts+0x400; // SMALLFONT = 1. + if(!half(font_+0x22) || half(font_+0x22)>20)return; + for(unsigned i=0;ip0(16,1) || + (state.rsp->fromSegmented(command->w1)&0x1FFFFFFFU)!=((*symbols_)[0]&0x1FFFFFFFU))return; + count_=original_count_=0;drawn_=false; + const auto offset=reinterpret_cast(command)-ram_; + if(offset<0 || std::uint64_t(offset)+8>0x800000 || (offset&7))return; + bool modes=false,ended=false; + unsigned n=0; + int atlas=-1; + std::array raw{}; + std::uint32_t env=0,prim=0; + for(unsigned i=1;i<256 && std::uint64_t(offset)+(i+1)*8<=0x800000;++i) { + const auto& c=command[i];const auto op=c.w0>>24; + if(op==0xB7 && c.w1==0x205){ended=true;break;} + if(op==0xE7)continue; + if(op==0xFA){prim=c.w1;continue;} + if(op==0xFB){env=c.w1;continue;} + if(op==7) { + if((c.w1&0x1FFFFFFFU)==((*symbols_)[1]&0x1FFFFFFFU) && c.p0(16,8)==2)modes=true; + else { + atlas=-1; + for(unsigned a=0;a<32;++a) { + const auto h=word(font_+0x80+a*4); + if(valid(h,32) && (word(h+12)&0x1FFFFFFFU)==(c.w1&0x1FFFFFFFU) && half(h+10)==c.p0(16,8)) {atlas=int(a);break;} + } + if(atlas<0)return; + } + continue; + } + if(op!=0xE4 || !modes || atlas<0 || env!=0xFFFFFFFFU || (prim&0xFFFFFF00U)!=0xFFFFFF00U || + n==raw.size() || std::uint64_t(offset)+(i+3)*8>0x800000 || + command[i+1].w0!=0xB3000000U || command[i+2].w0!=0xB2000000U || + command[i+2].w1!=0x04000400U || c.p1(24,3)!=0)return; + raw[n++]={&c,int(c.p1(12,12))/4,int(c.p1(0,12))/4, + (int(c.p0(12,12))-int(c.p1(12,12)))/4,(int(c.p0(0,12))-int(c.p1(0,12)))/4, + int(command[i+1].p1(16,16)),int(command[i+1].p1(0,16)),atlas};i+=2; + } + if(!ended || !n)return; + // Build the exact expected run, including spaces/compact alignment. + int x=160-(width(copyright_)>>1),base_y=0; + unsigned letter=0; + for(unsigned char ch:copyright_) { + if(ch==32){x+=half(font_+0x24);continue;} + if(letter>=n)return; + const auto p=font_+0x100+(ch-32)*8; + const auto& g=raw[letter]; + if(!letter)base_y=g.y-int(byte(p+3)); + if(g.x!=x+int(byte(p+2)) || g.y!=base_y+int(byte(p+3)) || + g.w!=int(byte(p+6)) || g.h!=int(byte(p+7)) || g.s!=int(byte(p+4))*32 || + g.t!=int(byte(p+5))*32 || g.atlas!=int(byte(p)))return; + original_[letter++]=g.command; + int step=half(font_+0x20)?half(font_+0x20):byte(p+1); + if(word((*symbols_)[4]) && step)--step; + x+=step; + } + if(letter!=n || base_y<190 || base_y>252)return; + left_=320;right_=0;top_=288;bottom_=0; + if(!line(copyright_,base_y) || !line(credit_,base_y+int(half(font_+0x22))+3)) {count_=0;return;} + left_-=7;right_+=7;top_-=5;bottom_+=5; + if(left_<4 || right_>316 || bottom_>284){count_=0;return;} + original_count_=n; + } + template bool draw(RT64::State& state,RT64::DisplayList** cursor,Load load) { + if(!enabled_ || !count_ || !original_count_ || state.rdp->colorImage.width!=320)return false; + if(std::find(original_.begin(),original_.begin()+original_count_,*cursor)==original_.begin()+original_count_)return false; + if(!drawn_) { + drawn_=true; + auto& r=*state.rdp; + state.flush(); + const auto other=r.otherMode; + const auto comb=r.colorCombinerStack[r.colorCombinerStackSize-1]; + const auto env=r.envColorStack[r.envColorStackSize-1]; + const auto aspect=r.extended.global.rectAspect; + const auto alpha=std::uint32_t(std::clamp(std::lround(float(r.primColorStack[r.primColorStackSize-1].w)*255.F),0L,255L)); + const auto original_header=word(font_+0x80+glyphs_[0].atlas*4); + r.setRectAspect(G_EX_ASPECT_ADJUST); + load((*symbols_)[1]+16,2);r.setEnvColor(std::uint32_t(std::lround(float(alpha)*.68F))); + RT64::ExtendedAlignment align{};align.leftOrigin=align.rightOrigin=G_EX_ORIGIN_NONE; + // One convex mesh with four identically tessellated arcs. This + // avoids per-strip viewport rounding and uneven corner coverage. + constexpr unsigned steps=12,points=4*(steps+1); + constexpr float radius=4.F,pi=3.14159265359F; + std::array,points> rim{}; + const std::array,4> centres{{ + {float(left_)+radius,float(top_)+radius},{float(right_)-radius,float(top_)+radius}, + {float(right_)-radius,float(bottom_)-radius},{float(left_)+radius,float(bottom_)-radius}}}; + for(unsigned corner=0;corner<4;++corner)for(unsigned i=0;i<=steps;++i) { + const float angle=pi+float(corner)*pi*.5F+float(i)*pi*.5F/steps; + rim[corner*(steps+1)+i]={centres[corner][0]+std::cos(angle)*radius, + centres[corner][1]+std::sin(angle)*radius}; + } + std::array positions{},colours{}; + std::array uv{}; + const float middle_x=(left_+right_)*.5F,middle_y=(top_+bottom_)*.5F; + for(unsigned triangle=0;triangle,3> vertices{{{middle_x,middle_y},rim[triangle],rim[(triangle+1)%points]}}; + for(unsigned v=0;v<3;++v) { + const auto at=triangle*12+v*4; + positions[at]=160.F+(vertices[v][0]-160.F)/cover_; + positions[at+1]=vertices[v][1];positions[at+3]=1; + for(unsigned c=0;c<4;++c)colours[at+c]=1; + } + } + r.drawTris(points,positions.data(),uv.data(),colours.data(),0,0); + state.flush(); + r.setOtherMode(other.H,other.L);r.setCombine((std::uint64_t(comb.H)<<32)|comb.L); + r.setEnvColor(0xFFFFFFFFU); + int atlas=-1; + for(unsigned i=0;i +#include +#include + +namespace dkr::runtime::presentation { +// Extend only the lower half of the actual intro skydome, in decoder-local +// vertices. Its equator/horizon, upper sky, logos and world projection stay put. +class IntroSky { + std::uint32_t begin_=0,end_=0; + float lower_scale_=1,bottom_=0,radius_=0; +public: + void begin(const RT64::State& state,bool enabled) { + begin_=end_=0; + water::enhanced::Memory m(std::span(state.RDRAM,0x800000)); + if(!enabled || m.u32(revision_addresses::CurrentMenuId)!=1)return; + const auto& symbols=polish_symbols[revision_addresses::CurrentMenuId==revision_addresses::kUsV80.CurrentMenuId?1:0]; + const auto obj=m.u32(symbols[6]); + if(!obj || !m.valid(obj,0x6C))return; + const unsigned index=m.u8(obj+0x3A); + const auto instances=m.u32(obj+0x68); + if(index>=8 || !instances || !m.valid(instances,(index+1)*4))return; + const auto instance=m.u32(instances+index*4); + if(!instance || !m.valid(instance,0x24))return; + const auto model=m.u32(instance); + if(!model || !m.valid(model,0x80))return; + const auto count=m.u16(model+0x24); + auto verts=m.u32(obj+0x44); + if(!verts)verts=m.u32(model+4); + if(!count || count>4096 || !verts || !m.valid(verts,std::uint64_t(count)*10))return; + bottom_=32767;radius_=0; + for(unsigned i=0;i140)return; + // tan(FOV/2) describes the exposed vertical cone. The original inset + // hid the bottom ring; use a conservative bounded guard beyond it. + lower_scale_=std::clamp(2.F*std::tan(fov*0.00872664626F)/std::tan(30.F*0.01745329252F),2.F,8.F); + begin_=verts&0x1FFFFFFFU;end_=begin_+count*10; + } + void vertex(std::uint32_t address,RT64::RSP::Vertex& v) const { + address&=0x1FFFFFFFU; + if(!begin_ || address=end_ || (address-begin_)%10)return; + float y=v.y; + if(y<0)y*=lower_scale_; + else if(bottom_>=0 && y<=bottom_+1.F)y=bottom_-radius_*lower_scale_; + else return; + v.y=std::int16_t(std::clamp(std::lround(y),-32768L,32767L)); + static thread_local bool notice=false; + if(!notice){std::fprintf(stderr,"[intro][sky] FOV-aware lower-dome coverage; equator and world projection preserved\n");notice=true;} + } +}; +} diff --git a/runtime-recomp/src/game/intro_viewport_rt64.hpp b/runtime-recomp/src/game/intro_viewport_rt64.hpp new file mode 100644 index 00000000..f27a9c02 --- /dev/null +++ b/runtime-recomp/src/game/intro_viewport_rt64.hpp @@ -0,0 +1,78 @@ +#pragma once +#include "hle/rt64_state.h" +#include "hle/rt64_rdp.h" +#include "hle/rt64_rsp.h" +#include +#include + +namespace dkr::runtime::presentation { + +// The immutable task must belong to MENU_LOGOS, and the command must also +// match its reviewed inset. Credits have the same inset, so geometry alone +// is NOT enough to admit this correction. No changes to retail RAM or timers. +class IntroViewport { + bool enabled_=false; + bool picture_=false; + int top_=0,bottom_=0,height_=240; + static bool inset(int left,int top,int right,int bottom) { + return left==0 && (right==319*4 || right==320*4) && + ((top==40*4 && bottom==196*4) || (top==38*4 && bottom==224*4)); + } +public: + void begin(bool enabled) {enabled_=enabled;picture_=false;top_=bottom_=0;height_=240;} + bool enabled() const {return enabled_;} + void scissor(RT64::State& state) { + if(!enabled_)return; + auto& r=state.rdp->scissorRectStack[state.rdp->scissorStackSize-1]; + picture_=inset(r.ulx,r.uly,r.lrx,r.lry); + if(!picture_)return; + top_=r.uly;bottom_=r.lry; + height_=top_==40*4 ? 240 : 288; + r.ulx=0;r.uly=0;r.lrx=320*4;r.lry=height_*4; + // Reveal the complete picture behind the existing copyright text. + // Its later 2D viewport is separate and keeps the original text layout. + auto& rdp=*state.rdp; + rdp.extended.scissorLeftOriginStack[rdp.scissorStackSize-1]=G_EX_ORIGIN_NONE; + rdp.extended.scissorRightOriginStack[rdp.scissorStackSize-1]=G_EX_ORIGIN_NONE; + state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); + } + void viewport(RT64::State& state) const { + if(!enabled_ || !picture_)return; + auto& rsp=*state.rsp; + auto& v=rsp.viewportStack[rsp.viewportStackSize-1]; + const float authored_center=(top_+bottom_)/8.F+0.5F; + // Match only the custom 3D intro viewport, not viewport_reset's + // ordinary 2D copyright viewport. Preserve X/Y scales and handedness. + if(std::abs(std::abs(float(v.scale.x))-160.F)>0.01F || + std::abs(float(v.translate.x)-160.5F)>0.01F || + std::abs(float(v.translate.y)-authored_center)>0.01F)return; + // Retail's inclusive rectangle gives a half-pixel X centre (160.5). + // Keeping that would still leave viewport.rect().ulx > framebuffer.ulx + // and make RT64 refuse full-width expansion despite a fixed scissor. + v.translate.x=160.F; + v.translate.y=height_/2.F; + rsp.viewportChanged=true; + static thread_local bool notice=false; + if(!notice) { + std::fprintf(stderr,"[intro][viewport] full-frame picture; copyright text retained; logo proportions preserved\n"); + notice=true; + } + } + bool fill(RT64::State& state,const RT64::DisplayList& command) const { + if(!enabled_ || state.rdp->colorImage.width!=320)return false; + const int left=command.p1(12,12),top=command.p1(0,12); + const int right=command.p0(12,12),bottom=command.p0(0,12); + if(!inset(left,top,right,bottom))return false; + RT64::ExtendedAlignment alignment{}; + alignment.leftOrigin=G_EX_ORIGIN_LEFT; + alignment.rightOrigin=G_EX_ORIGIN_RIGHT; + const int height=top==40*4 ? 240 : 288; + state.rdp->fillRect(0,0,0,height*4,alignment); + return true; + } + template void normalise(Pair& pair) const { + if(!enabled_ || pair.colorImage.width!=320 || pair.scissorRect.isNull())return; + pair.scissorRect.ulx=0;pair.scissorRect.lrx=320*4; + } +}; +} diff --git a/runtime-recomp/src/game/invite_handoff.cpp b/runtime-recomp/src/game/invite_handoff.cpp new file mode 100644 index 00000000..a081d8bc --- /dev/null +++ b/runtime-recomp/src/game/invite_handoff.cpp @@ -0,0 +1,39 @@ +#include "invite_handoff.hpp" +#include "netplay/direct_session.hpp" +#include "runtime_netplay.hpp" +#include +#include + +namespace dkr::runtime::invites { +namespace { +std::atomic live{false},modal{false},release{false},cancel{false}; +std::atomic shown{0},focus{0}; +std::mutex mutex; +ReturnView returning; +} +void game_running(bool v){live.store(v,std::memory_order_release);} +bool game_running(){return live.load(std::memory_order_acquire);} +bool begin_return(){std::scoped_lock lock(mutex);if(returning.phase!=ReturnPhase::Idle||!game_running())return false;returning={ReturnPhase::Stopping,{}};return true;} +bool return_pending(){std::scoped_lock lock(mutex);return returning.phase==ReturnPhase::Stopping;} +void finish_return(std::string error){ + game_running(false); + {std::scoped_lock lock(mutex);if(returning.phase!=ReturnPhase::Stopping)return;} + // Keep the immutable old session/save route alive until every producer has + // retired. A save failure does not permit admission into a different room. + if(error.empty())netplay::session().disconnect("Leaving this lobby to accept an invitation."); + std::scoped_lock lock(mutex);returning={error.empty()?ReturnPhase::Ready:ReturnPhase::Failed,std::move(error)}; +} +ReturnView return_view(){std::scoped_lock lock(mutex);return returning;} +void clear_return(){std::scoped_lock lock(mutex);if(returning.phase!=ReturnPhase::Stopping)returning={};} +void dialog(bool v){if(modal.exchange(v,std::memory_order_acq_rel)&&!v)release=true;} +bool dialog(){return modal.load(std::memory_order_acquire);} +bool capture(){return dialog()||release.load(std::memory_order_acquire);} +bool release_pending(){return release.load(std::memory_order_acquire);} +void fence_inputs(){release.store(true,std::memory_order_release);} +void release_if_clear(bool clear){if(clear&&!dialog())release=false;} +void visible(std::uint64_t id){shown.store(id,std::memory_order_release);} +void focus_visible(){focus.store(shown.load(std::memory_order_acquire),std::memory_order_release);} +std::uint64_t take_focus(){return focus.exchange(0,std::memory_order_acq_rel);} +void cancel_dialog(){cancel.store(true,std::memory_order_release);} +bool take_cancel(){return cancel.exchange(false,std::memory_order_acq_rel);} +} diff --git a/runtime-recomp/src/game/invite_handoff.hpp b/runtime-recomp/src/game/invite_handoff.hpp new file mode 100644 index 00000000..b2440fc0 --- /dev/null +++ b/runtime-recomp/src/game/invite_handoff.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include + +namespace dkr::runtime::invites { +enum class ReturnPhase { Idle, Stopping, Ready, Failed }; +struct ReturnView { ReturnPhase phase=ReturnPhase::Idle; std::string error; }; +void game_running(bool value); +bool game_running(); +bool begin_return(); +bool return_pending(); +// Main/event owner only, after runtime/renderer/save retirement. Never called +// from an ImGui callback or a network worker. +void finish_return(std::string error); +ReturnView return_view(); +void clear_return(); +void dialog(bool value); +bool dialog(); +bool capture(); +bool release_pending(); +void fence_inputs(); +void release_if_clear(bool clear); +void visible(std::uint64_t invite); +void focus_visible(); +std::uint64_t take_focus(); +void cancel_dialog(); +bool take_cancel(); +} diff --git a/runtime-recomp/src/game/invite_ui.inl b/runtime-recomp/src/game/invite_ui.inl new file mode 100644 index 00000000..4d85c69b --- /dev/null +++ b/runtime-recomp/src/game/invite_ui.inl @@ -0,0 +1,248 @@ +// UI-owned context survives the game/launcher ImGui context switch. Only IDs +// enter visible notifications; admission keys remain in the private intent. +namespace invite_flow=dkr::runtime::invites; +using LobbyInvitation=dkr::runtime::netplay::FriendLobbyInviteView; +enum class InviteStep { None, Prompt, Stopping, Queued, Joining, Error }; +struct InviteNotice {std::uint64_t id=0;std::string owner;std::chrono::steady_clock::time_point started{};}; +struct InviteIntent { + InviteStep step=InviteStep::None; + std::optional invitation; + bool watch=false,restore_overlay=false,open=false,armed=false,cancelled=false; + std::string error; + std::chrono::steady_clock::time_point started{}; +}; +std::deque g_invite_notices; +InviteIntent g_invite_intent; +bool g_invite_launcher_rom_ready=false; +bool InviteResourcesBusy() { + const auto installs=dkr::store::installations().view(); + bool textures=false;{std::lock_guard lock(g_texture_import_mutex);textures=g_texture_import_state.running;} + return !installs->ready||installs->busy||g_legacy_imports.snapshot().busy||g_code_library.view()->busy|| + g_vehicle_library.view()->busy||g_mod_launch.snapshot().modal||textures||DialogJobRunning(); +} + +std::optional FindLobbyInvitation(std::uint64_t id,const std::string& owner,std::string& error) { + using namespace dkr::runtime::netplay; + const auto snapshot=friend_service().snapshot(); + std::optional found; + for(const auto& i:snapshot->incoming_lobby_invites)if(i.invite_id==id&&(owner.empty()||i.friend_identity==owner)) { + if(found){error="That invitation is ambiguous. Open your invitation list again.";return {};} + found=i; + } + if(!found||found->status!=FriendLobbyInviteStatus::Delivered||found->expires_unix<=std::uint64_t(std::time(nullptr))) { + error="That invitation has expired or was cancelled.";return {}; + } + if(found->compatibility!=DKR_NETWORK_RELEASE_VERSION){error="You and your friend need the same DKR-R build.";return {};} + if(std::any_of(snapshot->friends.begin(),snapshot->friends.end(),[&](const auto& f){return f.identity==found->friend_identity&&f.blocked;})) { + error="Invitations from this player are blocked.";return {}; + } + if(found->lobby_code.empty()||found->admission==secure::Key{}){error="This invitation no longer has a valid admission token.";return {};} + error.clear();return found; +} +void HideInviteNotice(std::uint64_t id) { + std::erase_if(g_invite_notices,[&](const auto& n){return n.id==id;}); + if(!g_invite_notices.empty())g_invite_notices.front().started=std::chrono::steady_clock::now(); + invite_flow::visible(g_invite_notices.empty()?0:g_invite_notices.front().id); +} +bool InviteNoticeArrival(const LobbyInvitation& invitation) { + if(std::any_of(g_invite_notices.begin(),g_invite_notices.end(),[&](const auto& n){return n.id==invitation.invite_id&&n.owner==invitation.friend_identity;}))return true; + if(g_invite_notices.size()>=8)return false; + g_invite_notices.push_back({invitation.invite_id,invitation.friend_identity,std::chrono::steady_clock::now()}); + invite_flow::visible(g_invite_notices.front().id);return true; +} +void CloseInviteIntent() { + const bool restore=g_invite_intent.restore_overlay; + if(g_invite_intent.invitation)g_invite_intent.invitation->admission.fill(0); + g_invite_intent={};invite_flow::dialog(false);invite_flow::clear_return();(void)invite_flow::take_cancel(); + if(invite_flow::game_running())g_overlay_visible.store(restore,std::memory_order_release); +} +void InviteFailure(std::string error) { + if(g_invite_intent.step==InviteStep::None)g_invite_intent.restore_overlay=g_overlay_visible.load(); + g_invite_intent.error=std::move(error);g_invite_intent.step=InviteStep::Error; + g_invite_intent.open=true;g_invite_intent.armed=false;invite_flow::dialog(true); + if(invite_flow::game_running())g_overlay_visible=true; +} +void QueueInviteJoin() { + g_invite_intent.step=InviteStep::Queued;g_invite_intent.started=std::chrono::steady_clock::now(); + g_invite_intent.open=false;invite_flow::dialog(false);invite_flow::fence_inputs(); + g_play_offline_after_leave=false;g_online_mod_host_request.reset(); + if(g_online_mod_sync)g_online_mod_sync->cancel();g_online_mod_watching=false;g_online_mod_notice.clear(); + g_page_navigation_request=kPageOnlineMp;g_online_page.section=OlSection::Play; + g_online_page.open_match_end=false;g_online_failure_modal_requested=g_online_failure_modal_active=false; + g_online_error_notification.clear();g_online_action_status="Preparing to join your friend's lobby..."; + if(dkr::runtime::netplay::session().presentation_view().state==dkr::runtime::netplay::ConnectionState::Failed) + dkr::runtime::netplay::session().disconnect("Accepting a new lobby invitation."); +} +void RequestLobbyInvitation(std::uint64_t id,const std::string& owner) { + if(g_invite_intent.step!=InviteStep::None)return; + std::string error;auto invitation=FindLobbyInvitation(id,owner,error); + if(!invitation){InviteFailure(error);return;} + const auto session=dkr::runtime::netplay::session().presentation_view(); + if(dkr::runtime::netplay::session().presentation_active()&&NormalizeOnlineInvite(session.invite)==NormalizeOnlineInvite(invitation->lobby_code)) { + g_page_navigation_request=kPageOnlineMp;g_overlay_page=kPageOnlineMp;g_online_page.section=OlSection::Lobby; + if(invite_flow::game_running())g_overlay_visible=true;HideInviteNotice(id);return; + } + if(!invite_flow::game_running()&&!g_invite_launcher_rom_ready){InviteFailure("Choose a valid ROM in Play, then accept this invitation again.");return;} + if(!invite_flow::game_running()&&InviteResourcesBusy()){InviteFailure("Finish the current mod, save or file dialog first, then accept this invitation again.");return;} + g_invite_intent.invitation=std::move(invitation);g_invite_intent.watch=g_online_join_watching; + g_invite_intent.restore_overlay=g_overlay_visible.load();g_invite_intent.started=std::chrono::steady_clock::now(); + HideInviteNotice(id); + if(invite_flow::game_running()||dkr::runtime::netplay::session().presentation_active()) { + g_invite_intent.step=InviteStep::Prompt;g_invite_intent.open=true;invite_flow::dialog(true);g_overlay_visible=true; + }else QueueInviteJoin(); +} +void DeclineLobbyInvitation(std::uint64_t id,const std::string& owner) { + if(g_invite_intent.step!=InviteStep::None)return; + std::string error;if(!FindLobbyInvitation(id,owner,error)){InviteFailure(error);return;} + LobbyInvitation declined; + if(!dkr::runtime::netplay::friend_service().respond_lobby_invite(id,false,declined,error))InviteFailure(error); + else HideInviteNotice(id); +} +void PumpInviteLauncher(bool rom_ready,bool manifest_ready) { + g_invite_launcher_rom_ready=rom_ready; + const auto returned=invite_flow::return_view(); + if(g_invite_intent.step==InviteStep::Stopping&&returned.phase!=invite_flow::ReturnPhase::Stopping) { + invite_flow::clear_return(); + if(g_invite_intent.cancelled){CloseInviteIntent();return;} + if(returned.phase!=invite_flow::ReturnPhase::Ready){InviteFailure(returned.error.empty()?"The previous game did not finish safely. The invitation was not joined.":returned.error);return;} + QueueInviteJoin(); + } + if(g_invite_intent.step!=InviteStep::Queued&&g_invite_intent.step!=InviteStep::Joining)return; + using namespace dkr::runtime::netplay; + auto& online=session();const auto view=online.presentation_view(); + if(g_invite_intent.step==InviteStep::Joining) { + if(view.state==ConnectionState::Lobby||view.state==ConnectionState::Loading||view.state==ConnectionState::Running){CloseInviteIntent();return;} + if(view.state==ConnectionState::Failed){InviteFailure(view.status.empty()?"The lobby could not be joined.":view.status);return;} + if(dkr::online::view()->joining)return; + if(view.state==ConnectionState::Offline){InviteFailure("Joining was cancelled. You are still in the launcher.");return;} + return; // Mod downloads and code consent retain their original UI. + } + if(!g_invite_intent.invitation){InviteFailure("The pending invitation was lost. Please accept it again.");return;} + std::string error;auto current=FindLobbyInvitation(g_invite_intent.invitation->invite_id,g_invite_intent.invitation->friend_identity,error); + if(!current){InviteFailure(error);return;} + if(!rom_ready){InviteFailure("The selected ROM is unavailable. Choose it in Play and accept the invitation again.");return;} + if(!manifest_ready||SharedSaveBusy()||InviteResourcesBusy())return; + if(g_online_mod_sync&&g_online_mod_sync->snapshot().phase!=dkr::mods::online::SyncPhase::Idle) { + g_online_mod_sync->cancel(); + if(std::chrono::steady_clock::now()-g_invite_intent.started>std::chrono::seconds(60))InviteFailure("The previous mod job is still closing. The new lobby was not joined. Try again once it finishes."); + return; + } + if(online.active()){InviteFailure("Another lobby became active. Leave it before accepting this invitation again.");return;} + g_online_join_watching=g_invite_intent.watch;ConfigurePartyJoin(); + g_online_page.join_attempted=true;g_online_page.join_error.clear(); + if(!online.join_friend_invite(current->lobby_code,g_online_player_name,current->admission,error)){InviteFailure(error);return;} + g_joining_friend_invite=*current;g_invite_intent.step=InviteStep::Joining; + g_online_action_status="Joining "+current->friend_display_name+"'s lobby..."; +} +bool InviteUiVisible(){return !g_invite_notices.empty()||g_invite_intent.step!=InviteStep::None;} +void DrawInviteInbox(float width) { + const auto snapshot=dkr::runtime::netplay::friend_service().snapshot(); + bool heading=false; + for(const auto& i:snapshot->incoming_lobby_invites)if(i.status==dkr::runtime::netplay::FriendLobbyInviteStatus::Delivered&&i.expires_unix>std::uint64_t(std::time(nullptr))) { + if(!heading){ImGui::TextUnformatted("LOBBY INVITATIONS");ImGui::Dummy({0,8});heading=true;} + ImGui::PushID(std::to_string(i.invite_id).c_str()); + ImGui::TextWrapped("%s invited you to their lobby.",i.friend_display_name.c_str()); + ImGui::BeginDisabled(g_invite_intent.step!=InviteStep::None); + if(ImGui::Button("Accept invitation",{std::max(1.F,std::min(width,280.F)),42}))RequestLobbyInvitation(i.invite_id,i.friend_identity); + ImGui::EndDisabled();ImGui::PopID();ImGui::Dummy({0,8}); + } + if(heading)ImGui::Separator(); +} +std::string InviteMenuShortcut() { + using namespace dkr::runtime::input; + const auto c=shortcut_controller_binding(ShortcutAction::ToggleOverlay); + auto name=[](ShortcutBinding b,bool controller){ + if(b.primary==kUnbound)return std::string{}; + auto n=controller?controller_binding_name(b.primary):keyboard_binding_name(b.primary); + if(b.secondary!=kUnbound)n+=" + "+(controller?controller_binding_name(b.secondary):keyboard_binding_name(b.secondary));return n; + }; + std::string label="F1 / Esc";const auto keyboard=name(shortcut_keyboard_binding(ShortcutAction::ToggleOverlay),false); + if(!keyboard.empty())label+=" / "+keyboard;const auto controller=name(c,true);if(!controller.empty())label+=" / "+controller; + return label+": respond"; +} +void DrawInviteUi() { + const auto now=std::chrono::steady_clock::now(); + if(const auto focus=invite_flow::take_focus())RequestLobbyInvitation(focus,{}); + constexpr const char* popup="Lobby invitation"; + if(g_invite_intent.open){ImGui::OpenPopup(popup);g_invite_intent.open=false;} + const bool prompt=g_invite_intent.step==InviteStep::Prompt,error=g_invite_intent.step==InviteStep::Error; + if(prompt||error) { + ImGui::SetNextWindowSize({std::min(580.F,std::max(260.F,ImGui::GetIO().DisplaySize.x-36.F)),0},ImGuiCond_Appearing); + if(BeginPaddedModal(popup,ImGuiWindowFlags_AlwaysAutoResize)) { + bool held=false; + for(int key=ImGuiKey_NamedKey_BEGIN;keyfriend_display_name.c_str()); + if(session.host&&dkr::runtime::netplay::session().presentation_active())ImGui::TextWrapped("You are hosting. This will close your lobby for everyone."); + else if(invite_flow::game_running()&&dkr::runtime::netplay::session().presentation_active()&&!dkr::runtime::spectators::party_watching(&dkr::runtime::netplay::session()))ImGui::TextWrapped("Leaving your racing place will end the current match under its normal departure rules."); + else if(invite_flow::game_running()&&!dkr::runtime::netplay::session().presentation_active())ImGui::TextWrapped("This stops the game for all local players. Progress since the last save may be lost."); + ImGui::Dummy({0,8});int role=g_invite_intent.watch?1:0; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + if(ImGui::Combo("##invite-role",&role,"Racer\0Watch Race\0"))g_invite_intent.watch=role!=0; + } + ImGui::Dummy({0,12});const auto width=ImGui::GetContentRegionAvail().x; + ImGui::BeginDisabled(!g_invite_intent.armed); + const bool clicked_back=ImGui::Button(error?"Close":"Keep playing",{width,42}); + const bool back=g_invite_intent.armed&&(clicked_back||invite_flow::take_cancel()); + ImGui::SetItemDefaultFocus(); + if(back){CloseInviteIntent();ImGui::CloseCurrentPopup();} + if(!error&&g_invite_intent.step==InviteStep::Prompt) { + ImGui::Dummy({0,8}); + if(ImGui::Button("Leave and join",{width,42})) { + std::string failure;const auto current=FindLobbyInvitation(g_invite_intent.invitation->invite_id,g_invite_intent.invitation->friend_identity,failure); + if(!current){InviteFailure(failure);} + else if(invite_flow::game_running()) { + if(invite_flow::begin_return()) { + g_invite_intent.step=InviteStep::Stopping;invite_flow::dialog(false);g_play_offline_after_leave=false; + g_lifecycle_request.store(dkr::runtime::ui::LifecycleRequest::StopGame,std::memory_order_release); + g_overlay_visible=false;ImGui::CloseCurrentPopup(); + } + } else { + dkr::runtime::netplay::session().disconnect("Leaving this lobby to accept an invitation.");QueueInviteJoin();ImGui::CloseCurrentPopup(); + } + } + } + ImGui::EndDisabled();ImGui::EndPopup(); + } + } + if(g_invite_intent.step==InviteStep::Stopping||g_invite_intent.step==InviteStep::Queued) { + ImGui::SetNextWindowPos({ImGui::GetIO().DisplaySize.x*.5F,20},ImGuiCond_Always,{.5F,0}); + ImGui::SetNextWindowBgAlpha(.94F); + if(ImGui::Begin("##invite-progress",nullptr,ImGuiWindowFlags_AlwaysAutoResize|ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing)) { + ImGui::TextUnformatted(g_invite_intent.step==InviteStep::Stopping?"Returning safely to the launcher...":"Preparing to join your friend's lobby..."); + if(ImGui::Button("Cancel joining")) { + if(g_invite_intent.step==InviteStep::Stopping)g_invite_intent.cancelled=true; + else CloseInviteIntent(); + } + }ImGui::End(); + } + if(g_invite_notices.empty())return; + const auto notice=g_invite_notices.front();std::string failure;const auto invitation=FindLobbyInvitation(notice.id,notice.owner,failure); + if(!invitation){HideInviteNotice(notice.id);return;} + const auto screen=ImGui::GetIO().DisplaySize;const float width=std::min(460.F,std::max(270.F,screen.x-36.F)); + const int position=std::clamp(g_friend_online_notification_position,0,3); + ImVec2 at{18,18+OverlayStackOffset(position,true,true)},pivot{0,0}; + if(position==1||position==3){at.x=screen.x-18;pivot.x=1;} + if(position>=2){at.y=screen.y-18-OverlayStackOffset(position,true,true);pivot.y=1;} + ImGui::SetNextWindowPos(at,ImGuiCond_Always,pivot);ImGui::SetNextWindowSize({width,0}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,14);ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,{18,14}); + ImGui::SetNextWindowBgAlpha(.94F); + if(ImGui::Begin("##actionable-invite",nullptr,ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_NoMove|ImGuiWindowFlags_NoNav|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing)) { + ImGui::TextColored(kWarm,"LOBBY INVITE");ImGui::PushTextWrapPos(ImGui::GetCursorPosX()+width-36); + ImGui::TextWrapped("%s invited you to their lobby.",invitation->friend_display_name.c_str()); + ImGui::TextWrapped("%s",InviteMenuShortcut().c_str());ImGui::PopTextWrapPos();ImGui::Dummy({0,8}); + const float button=(ImGui::GetContentRegionAvail().x-ImGui::GetStyle().ItemSpacing.x*2)/3; + ImGui::BeginDisabled(g_invite_intent.step!=InviteStep::None); + if(ImGui::Button("Accept",{button,40}))RequestLobbyInvitation(notice.id,notice.owner);ImGui::SameLine(); + if(ImGui::Button("Decline",{button,40}))DeclineLobbyInvitation(notice.id,notice.owner);ImGui::SameLine(); + if(ImGui::Button("Later",{button,40}))HideInviteNotice(notice.id); + ImGui::EndDisabled(); + if(ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup)&&!g_invite_notices.empty())g_invite_notices.front().started=now; + else if(now-notice.started>std::chrono::seconds(10)&&g_invite_intent.step==InviteStep::None)HideInviteNotice(notice.id); + }ImGui::End();ImGui::PopStyleVar(2); +} diff --git a/runtime-recomp/src/game/linux_keyring.hpp b/runtime-recomp/src/game/linux_keyring.hpp new file mode 100644 index 00000000..58c2ddfb --- /dev/null +++ b/runtime-recomp/src/game/linux_keyring.hpp @@ -0,0 +1,56 @@ +#pragma once +#ifndef _WIN32 +#include +#include +#include +#include +#include + +// Optional system Secret Service. No refresh credential is written to disk. +// Dynamically loaded so AppImage also runs on desktops without a keyring. +namespace dkr::online::keyring { +struct Api { + void* library=dlopen("libsecret-1.so.0",RTLD_NOW|RTLD_LOCAL); + using New=void*(*)(const char*,int,...); + using Store=int(*)(const void*,const char*,const char*,const char*,void*,void**,...); + using Lookup=char*(*)(const void*,void*,void**,...); + using Clear=int(*)(const void*,void*,void**,...); + New schema=library?reinterpret_cast(dlsym(library,"secret_schema_new")):nullptr; + Store store=library?reinterpret_cast(dlsym(library,"secret_password_store_sync")):nullptr; + Lookup lookup=library?reinterpret_cast(dlsym(library,"secret_password_lookup_sync")):nullptr; + Clear clear=library?reinterpret_cast(dlsym(library,"secret_password_clear_sync")):nullptr; + void(*unref)(void*)=library?reinterpret_cast(dlsym(library,"secret_schema_unref")):nullptr; + void(*free_password)(char*)=library?reinterpret_cast(dlsym(library,"secret_password_free")):nullptr; + void(*free_error)(void*)=library?reinterpret_cast(dlsym(library,"g_error_free")):nullptr; + void*(*cancel_new)()=library?reinterpret_cast(dlsym(library,"g_cancellable_new")):nullptr; + void(*cancel)(void*)=library?reinterpret_cast(dlsym(library,"g_cancellable_cancel")):nullptr; + void(*object_unref)(void*)=library?reinterpret_cast(dlsym(library,"g_object_unref")):nullptr; + bool available()const{return schema&&store&&lookup&&clear&&unref&&free_password&&free_error&&cancel_new&&cancel&&object_unref;} +}; +inline Api& api(){static auto* p=new Api;return *p;} +inline bool available(){return api().available();} +struct Request { + Api& a=api();void* schema=nullptr;void* cancellable=nullptr;std::jthread timer; + Request(){if(!a.available())return; + schema=a.schema("com.positron.DKRR.Launcher",0,"profile",0,nullptr);cancellable=a.cancel_new(); + if(schema&&cancellable)timer=std::jthread([this](std::stop_token stop){ + std::mutex mutex;std::condition_variable_any cv;std::unique_lock lock(mutex); + cv.wait_for(lock,stop,std::chrono::seconds(8),[]{return false;});if(!stop.stop_requested())a.cancel(cancellable); + }); + } + ~Request(){timer.request_stop();if(timer.joinable())timer.join();if(schema)a.unref(schema);if(cancellable)a.object_unref(cancellable);} + bool ready()const{return schema&&cancellable;} +}; +inline bool store(const std::string& profile,const std::string& value){Request r;if(!r.ready())return false;void* error=nullptr; + bool ok=r.a.store(r.schema,nullptr,"DKR-R Discord sign-in",value.c_str(),r.cancellable,&error,"profile",profile.c_str(),nullptr)!=0; + if(error)r.a.free_error(error);return ok; +} +inline std::string load(const std::string& profile){Request r;if(!r.ready())return {};void* error=nullptr; + char* password=r.a.lookup(r.schema,r.cancellable,&error,"profile",profile.c_str(),nullptr); + std::string value=password?password:"";if(password)r.a.free_password(password);if(error)r.a.free_error(error);return value; +} +inline void clear(const std::string& profile){Request r;if(!r.ready())return;void* error=nullptr; + r.a.clear(r.schema,r.cancellable,&error,"profile",profile.c_str(),nullptr);if(error)r.a.free_error(error); +} +} +#endif diff --git a/runtime-recomp/src/game/liquid_replacement_gate.hpp b/runtime-recomp/src/game/liquid_replacement_gate.hpp new file mode 100644 index 00000000..03056e30 --- /dev/null +++ b/runtime-recomp/src/game/liquid_replacement_gate.hpp @@ -0,0 +1,151 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::water::streaming { +struct Member { std::uint64_t hash=0; std::uint32_t layer=0; std::string path; }; +struct Family { std::vector members; bool complete=true; }; +struct Selection { + const void* map=nullptr; + std::uint64_t era=0; + std::uint32_t scene=0; + std::vector groups; + bool force_native=false; +}; +using SelectionPtr=std::shared_ptr; +struct Cache { std::uint64_t epoch=1,evictions=0,era=0; bool overflow=false; }; +struct Publication { SelectionPtr selection; std::uint64_t frame=0; }; +struct Registry { + std::mutex mutex; + std::unordered_map caches; + std::unordered_map workloads; + std::uint64_t next_era=1; +}; +inline Registry& registry() { static auto* value=new Registry; return *value; } +inline Cache& cache_locked(Registry& r,const void* map) { + auto [it,inserted]=r.caches.try_emplace(map); + if(inserted)it->second.era=r.next_era++; + return it->second; +} +inline void forget(const void* map) { + auto& r=registry();std::scoped_lock lock(r.mutex);r.caches.erase(map); + for(auto i=r.workloads.begin();i!=r.workloads.end();) + if(i->second.selection && i->second.selection->map==map)i=r.workloads.erase(i);else ++i; +} +inline void changed(const void* map) { + auto& r=registry();std::scoped_lock lock(r.mutex);++cache_locked(r,map).epoch; +} +inline void evicted(const void* map) { + auto& r=registry();std::scoped_lock lock(r.mutex);auto& c=cache_locked(r,map);++c.epoch;++c.evictions; +} +inline std::uint64_t eviction_epoch(const void* map) { + auto& r=registry();std::scoped_lock lock(r.mutex);return cache_locked(r,map).evictions; +} + +// One immutable selection per prepared scene. Queue slots retain that selection +// until reused; loading a new scene cannot change a queued older workload. +inline SelectionPtr select_families(const void* map,std::uint32_t scene,std::vector families) { + auto result=std::make_shared();result->map=map;result->scene=scene; + {auto& r=registry();std::scoped_lock lock(r.mutex);result->era=cache_locked(r,map).era;} + std::size_t members=0;for(const auto& f:families)members+=f.members.size(); + if(families.size()>128 || members>4096) {result->force_native=true;return result;} + // A shared replacement hash cannot be HD for one active animation and + // native for another. Merge connected families before deciding readiness. + std::vector parent(families.size()); + for(std::size_t i=0;i owners; + for(std::size_t i=0;isecond); + } + std::unordered_map groups; + for(std::size_t i=0;igroups.size()); + if(added)result->groups.push_back({}); + auto& group=result->groups[it->second];group.complete &= families[i].complete; + for(auto& m:families[i].members)group.members.push_back(std::move(m)); + } + for(auto& group:result->groups) { + std::sort(group.members.begin(),group.members.end(),[](const auto& a,const auto& b){return a.hash=64) { + // Fail closed under an unexpected queue population, with bounded + // metadata. Never evict the selection of an in-flight workload. + cache_locked(r,selection->map).overflow=true;return; + } + r.workloads.insert_or_assign(workload,Publication{std::move(selection),frame}); +} +inline Publication& current_workload() {static thread_local Publication value;return value;} +class WorkloadScope { + Publication previous_; +public: + WorkloadScope(const void* workload,std::uint64_t frame):previous_(current_workload()) { + auto& r=registry();std::scoped_lock lock(r.mutex);const auto i=r.workloads.find(workload); + current_workload()=(i!=r.workloads.end() && i->second.frame==frame)?i->second:Publication{}; + } + ~WorkloadScope(){current_workload()=std::move(previous_);} + WorkloadScope(const WorkloadScope&)=delete; +}; +struct Frame { + std::unordered_set blocked; + SelectionPtr selection; + const void* map=nullptr; + std::uint64_t epoch=~0ULL,era=0; + std::uint32_t source_version=0,descriptor_version=0; + bool force_native=false,overflow=false; +}; +inline auto& frames(){static thread_local std::unordered_map value;return value;} +template +void capture(const void* renderer,Map& map,Replacements& replacements,Versions& versions,std::uint32_t& global) { + auto& frame=frames()[renderer];const auto selected=current_workload().selection; + auto& r=registry();std::scoped_lock lock(r.mutex); + const auto at=r.caches.find(&map.replacementMap); + const auto epoch=at==r.caches.end()?0:at->second.epoch; + const auto era=at==r.caches.end()?0:at->second.era; + const bool overflow=at!=r.caches.end() && at->second.overflow; + if(frame.map!=&map || frame.selection!=selected || frame.epoch!=epoch || frame.era!=era || + frame.source_version!=global || frame.overflow!=overflow) { + frame.map=↦frame.selection=selected;frame.epoch=epoch;frame.era=era; + frame.source_version=global;frame.blocked.clear();frame.force_native=overflow;frame.overflow=overflow; + if(selected && selected->map==&map.replacementMap) { + frame.force_native |= selected->force_native; + for(const auto& group:selected->groups) { + bool ready=group.complete && selected->era==era; + for(const auto& member:group.members) + if(member.path.empty() || !map.replacementMap.getFromRelativePath(member.layer,member.path))ready=false; + if(!ready)for(const auto& member:group.members)frame.blocked.insert(member.hash); + } + } + ++frame.descriptor_version; + } + global=frame.descriptor_version; + for(const auto& [hash,index]:map.hashMap)if(indexsecond.force_native || i->second.blocked.find(hash)!=i->second.blocked.end()); +} +inline void release_frame(const void* renderer){frames().erase(renderer);} +} diff --git a/runtime-recomp/src/game/lobby_adapter.cpp b/runtime-recomp/src/game/lobby_adapter.cpp new file mode 100644 index 00000000..c9508512 --- /dev/null +++ b/runtime-recomp/src/game/lobby_adapter.cpp @@ -0,0 +1,38 @@ +#include "lobby_adapter.hpp" +#include + +namespace dkr::runtime::lobby_ui { +namespace { +struct RemovalRegistry {std::mutex mutex;std::map removals;}; +RemovalRegistry& removal_registry(){static auto* value=new RemovalRegistry;return *value;} +} +Snapshot snapshot(const void* session) { + const auto p=spectators::party_link(session); + std::scoped_lock lock(p->mutex); + Snapshot out; + out.link={p->enabled,p->host,p->admitted,p->running,p->stopping,p->local,p->party.snapshot(), + p->ready,p->status,p->camera,p->target,p->swap_requests}; + out.room=p->room_id;out.maximum_racers=std::clamp(p->maximum_racers,2U,4U); + out.launching=bool(p->launch);out.host_rtt=p->rtt_ms; + out.save_ready=p->enabled&&p->admitted&&!p->stopping&&p->seed.size()==512&&p->save_generation!=0; + out.pending_watch=p->pending_watch; + for(const auto& peer:p->peers)out.metrics.push_back({peer.id,peer.rtts,peer.rtt_count,peer.rtt_index}); + return out; // No credentials, transport handles or mutable guest data escape. +} +bool request_removal(const void* session,const Snapshot& seen,spectators::MemberId id,std::string& error) { + const auto p=spectators::party_link(session); + {std::scoped_lock lock(p->mutex); + if(!p->enabled||!p->host||p->room_id!=seen.room||p->party.snapshot().generation!=seen.link.party.generation|| + id==p->local||!p->party.find(id)||p->party.snapshot().handover) { + error="The lobby changed. Open this player's menu again.";return false; + }} + auto& registry=removal_registry();std::scoped_lock lock(registry.mutex); + if(!registry.removals.contains(session)&®istry.removals.size()>=8)registry.removals.erase(registry.removals.begin()); + registry.removals[session]={seen.room,seen.link.party.generation,id};error.clear();return true; +} +bool take_removal(const void* session,Removal& result) { + auto& registry=removal_registry();std::scoped_lock lock(registry.mutex);const auto it=registry.removals.find(session); + if(it==registry.removals.end())return false; + result=it->second;registry.removals.erase(it);return true; +} +} diff --git a/runtime-recomp/src/game/lobby_adapter.hpp b/runtime-recomp/src/game/lobby_adapter.hpp new file mode 100644 index 00000000..e8078ce0 --- /dev/null +++ b/runtime-recomp/src/game/lobby_adapter.hpp @@ -0,0 +1,23 @@ +#pragma once +#include "party_link.hpp" + +namespace dkr::runtime::lobby_ui { +struct Metric { + spectators::MemberId member=0; + std::array rtts{}; + unsigned count=0,index=0; +}; +struct Snapshot { + spectators::LinkView link; + std::uint64_t room=0; + bool save_ready=false,launching=false; + unsigned maximum_racers=4; + std::uint16_t host_rtt=0; + std::vector metrics; + std::unordered_map pending_watch; +}; +Snapshot snapshot(const void* session); +struct Removal { std::uint64_t room=0,generation=0; spectators::MemberId member=0; }; +bool request_removal(const void* session,const Snapshot&,spectators::MemberId,std::string& error); +bool take_removal(const void* session,Removal&); +} diff --git a/runtime-recomp/src/game/lobby_context.inl b/runtime-recomp/src/game/lobby_context.inl new file mode 100644 index 00000000..57bb4629 --- /dev/null +++ b/runtime-recomp/src/game/lobby_context.inl @@ -0,0 +1,76 @@ +namespace lobby_ui=dkr::runtime::lobby_ui; +namespace lobby_party=dkr::runtime::spectators; +lobby_ui::Snapshot g_ol_party; +bool OlPartyActive(){return g_ol_party.link.enabled&&g_ol_party.link.admitted;} +bool OlPartyWatching(){ + for(const auto& m:g_ol_party.link.party.members)if(m.id==g_ol_party.link.local)return m.role==lobby_party::Role::Spectator; + return false; +} +bool OlPartyLive(){return OlPartyActive()&&(g_ol_party.launching||g_ol_party.link.running);} +unsigned OlPartySpectators(){return unsigned(std::count_if(g_ol_party.link.party.members.begin(),g_ol_party.link.party.members.end(),[](const auto& m){return m.role==lobby_party::Role::Spectator;}));} +const lobby_party::Member* OlPartyMember(lobby_party::MemberId id){ + for(const auto& m:g_ol_party.link.party.members)if(m.id==id)return &m;return nullptr; +} +std::string OlPartyHostName(){const auto* m=OlPartyMember(g_ol_party.link.party.host);return m?m->name:"Host";} +dkr::runtime::rollback_check::View g_ol_rollback_check; +bool g_ol_member_open=false,g_ol_manage_open=false,g_ol_return_open=false,g_ol_remove_open=false; +lobby_party::MemberId g_ol_member_id=0; +std::uint64_t g_ol_member_room=0,g_ol_member_generation=0; +void OlPartyOpenMember(lobby_party::MemberId id){ + g_ol_member_id=id;g_ol_member_room=g_ol_party.room;g_ol_member_generation=g_ol_party.link.party.generation;g_ol_member_open=true; +} +void OlPartyProject(dkr::runtime::netplay::SessionView& view) { + using namespace dkr::runtime::netplay; + const auto previous_room=g_ol_party.room; + g_ol_party=lobby_ui::snapshot(&session()); + if(previous_room!=g_ol_party.room){g_ol_member_open=g_ol_manage_open=g_ol_return_open=g_ol_remove_open=false;} + g_ol_rollback_check=dkr::runtime::rollback_check::snapshot(&session()); + view.connection_test_active=g_ol_rollback_check.active; + view.connection_test_remaining_ms=g_ol_rollback_check.remaining_ms; + view.connection_test_results={}; + view.connection_test_result_generation=g_ol_rollback_check.complete?g_ol_rollback_check.revision:0; + // A terminal transport failure must not be projected back to Lobby + // just because the retained party is marked stopping. + if(view.state==ConnectionState::Failed||view.state==ConnectionState::Offline){ + g_ol_party.link.enabled=g_ol_party.link.admitted=false; + g_ol_rollback_check.active=g_ol_rollback_check.complete=false; + view.connection_test_active=false;view.connection_test_remaining_ms=0;view.connection_test_result_generation=0; + return; + } + if(!OlPartyActive())return; + view.room.players={};view.online_save_ready={};view.local_slot=255; + view.local_online_save_ready=g_ol_party.save_ready; + for(unsigned port=0;port<4;++port){ + const auto* m=OlPartyMember(g_ol_party.link.party.controls[port].member);if(!m||m->role!=lobby_party::Role::Racer)continue; + auto& player=view.room.players[port];player.occupied=player.compatible=m->connected;player.host=m->host; + player.slot=std::uint8_t(port);player.peer_id=std::to_string(m->id);player.display_name=m->name; + const auto ready=g_ol_party.link.ready.find(m->id);player.ready=ready!=g_ol_party.link.ready.end()&&ready->second; + player.loaded=g_ol_party.link.running;view.online_save_ready[port]=g_ol_party.save_ready; + if(m->id==g_ol_party.link.local)view.local_slot=std::uint8_t(port); + for(const auto& metric:g_ol_party.metrics)if(metric.member==m->id&&metric.count){player.ping_ms=metric.rtts[(metric.index-1)%16];} + if(!g_ol_party.link.host&&m->host)player.ping_ms=g_ol_party.host_rtt; + } + // A host may be watching; host identity does not imply ownership of P1. + view.host=g_ol_party.link.host; + if(g_ol_party.link.stopping)view.state=view.host?ConnectionState::Hosting:ConnectionState::Lobby; + else if(g_ol_party.link.running)view.state=ConnectionState::Running; + else if(g_ol_party.launching)view.state=ConnectionState::Loading; + +} +bool OlPartyHasSpace(const OnlineFrame& frame){return OlPartyActive()&&frame.view.host&&!frame.view.lobby_locked&&!g_ol_party.link.stopping&&!g_ol_rollback_check.active&&g_ol_party.link.party.members.size()second)return OlPartySpectators()(frame.view.room.rules.maximum_players,2,4); +} +void DrawOlPartySpectators(float width,const OnlineFrame&); +void DrawOlPartyStart(float width,const OnlineFrame&); +void DrawOlPartyConnection(float width,const OnlineFrame&); +void DrawOlPartyResults(const OnlineFrame&); +void DrawOlPartyDialogs(const OnlineFrame&); +bool OlPartyChanging(const OnlineFrame&); +void DrawOlLobbyToolbar(float,const OnlineFrame&); diff --git a/runtime-recomp/src/game/lobby_controls.inl b/runtime-recomp/src/game/lobby_controls.inl new file mode 100644 index 00000000..b863e011 --- /dev/null +++ b/runtime-recomp/src/game/lobby_controls.inl @@ -0,0 +1,200 @@ +// Presentation only: retain the original lobby panel, code plate and cards. +// Actions below use authenticated party APIs, never edit controller leases. +void DrawOlLobbyToolbar(float width,const OnlineFrame& frame) { + const auto origin=ImGui::GetCursorScreenPos();float x=0,y=0; + const std::array labels{"Check Rollback connection","Invite friends"}; + for(unsigned i=0;i0&&x+w>width){x=0;y+=48.F;} + ImGui::SetCursorScreenPos({origin.x+x,origin.y+y}); + {const OlDisabled disabled(i==0?!OlCanTestConnection(frame):!frame.has_space); + if(OlButton(labels[i],OlTone::Plain,w)){if(i==0)OlRequestConnectionTest();else g_online_page.section=OlSection::Friends;}} + x+=w+8.F; + } + ImGui::SetCursorScreenPos(origin);ImGui::Dummy({width,y+40.F}); +} +bool OlPartyChanging(const OnlineFrame& frame){ + return g_ol_party.link.stopping||g_ol_rollback_check.active||bool(g_ol_party.link.party.handover)|| + frame.view.launch_countdown_active||(g_ol_party.launching&&!g_ol_party.link.running); +} +bool OlPartyRole(lobby_party::MemberId id,bool racer){ + auto& online=dkr::runtime::netplay::session();const auto link=lobby_party::party_link(&online);std::string error;bool ok=false; + {std::scoped_lock lock(link->mutex); + if(link->room_id!=g_ol_party.room||link->party.snapshot().generation!=g_ol_party.link.party.generation) + error="The lobby changed. Choose the player again."; + else ok=lobby_party::party_waiting_role(&online,id,racer,error);} + OlNotify(ok?(racer?"Racing place assigned. Racers must ready again.":"Moved to spectators. Racers must ready again."):error);return ok; +} +bool OlPartySwap(unsigned port,lobby_party::MemberId outgoing,lobby_party::MemberId incoming,bool approve){ + auto& online=dkr::runtime::netplay::session();const auto link=lobby_party::party_link(&online);bool valid=false; + {std::scoped_lock lock(link->mutex); + valid=link->enabled&&link->room_id==g_ol_party.room&&port<4&& + link->party.snapshot().generation==g_ol_party.link.party.generation&& + link->party.snapshot().controls[port].member==outgoing&&!link->party.snapshot().handover; + if(valid){if(approve)lobby_party::party_approve_swap(&online,port,incoming);else lobby_party::party_request_swap(&online,port,incoming);}} + OlNotify(valid?(approve?"Player swap approval sent. Waiting for the safe handover.":"Racing-place request sent for host approval."): + "The racing place changed. Choose it again.");return valid; +} +void DrawOlPartySpectators(float width,const OnlineFrame& frame){ + if(!OlPartyActive())return; + PaddockGap(18.F);const auto rule=ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddRectFilled(rule,{rule.x+width,rule.y+1.F},PaddockCol(0x2F6A86));PaddockGap(14.F); + const auto at=ImGui::GetCursorScreenPos(); + const char* action=OlPartyWatching()?(OlPartyLive()?"Request a place":"View players"):"Manage players"; + const float button=OlButtonWidth(action);const bool pair=width-button-12.F>=230.F; + OlParagraph("SPECTATORS "+std::to_string(OlPartySpectators())+" / 8",17.F,kOlCream,pair?width-button-12.F:width,true); + const float label_height=ImGui::GetItemRectSize().y; + ImGui::SetCursorScreenPos(pair?ImVec2{at.x+width-button,at.y}:ImVec2{at.x,at.y+label_height+8.F}); + {const OlDisabled disabled(OlPartyChanging(frame));if(OlButton(action))g_ol_manage_open=true;} + ImGui::SetCursorScreenPos(at);ImGui::Dummy({width,pair?std::max(40.F,label_height):label_height+48.F});PaddockGap(10.F); + const auto chips=ImGui::GetCursorScreenPos();float x=0,y=0;unsigned count=0; + for(const auto& m:g_ol_party.link.party.members)if(m.role==lobby_party::Role::Spectator){ + ++count;ImGui::PushID(std::to_string(m.id).c_str()); + const std::string name=m.name+(m.id==g_ol_party.link.local?" (you)":"")+(m.host?" (host)":""); + const std::string shown=PaddockEllipsize(OlRead(15.F,true),name,std::max(20.F,std::min(width,300.F)-32.F)); + const float w=std::min(width,OlButtonWidth(shown.c_str())); + if(x>0&&x+w>width){x=0;y+=48.F;} + ImGui::SetCursorScreenPos({chips.x+x,chips.y+y}); + if(OlButton(shown.c_str(),OlTone::Plain,w,name.c_str()))OlPartyOpenMember(m.id); + x+=w+8.F;ImGui::PopID(); + } + ImGui::SetCursorScreenPos(chips); + if(count)ImGui::Dummy({width,y+40.F}); + else OlParagraph("No spectators yet. Friends can join with Watch Race.",14.F,kOlSoft,width); + if(OlPartyWatching()){ + PaddockGap(14.F);static const std::vector modes{"All splits","Follow player","T.T.-style director"}; + int camera=int(g_ol_party.link.camera); + if(OlSelect("spectator-camera","Watch camera",&camera,modes,width)) + lobby_party::party_camera(&dkr::runtime::netplay::session(),lobby_party::WatchCamera(camera),g_ol_party.link.target); + if(camera==int(lobby_party::WatchCamera::Follow)){ + std::vector names;std::vector ports;int selected=0; + for(unsigned p=0;p<4;++p)if(const auto* m=OlPartyMember(g_ol_party.link.party.controls[p].member)){ + if(p==g_ol_party.link.target)selected=int(ports.size());ports.push_back(p);names.push_back("P"+std::to_string(p+1)+" - "+m->name); + } + PaddockGap(10.F); + if(!ports.empty()&&OlSelect("spectator-target","Follow racer",&selected,names,width)) + lobby_party::party_camera(&dkr::runtime::netplay::session(),lobby_party::WatchCamera::Follow,ports[std::size_t(selected)]); + } + PaddockGap(8.F);OlParagraph("D-pad left/right: racer. C-up: camera. No controller port is assigned to a spectator.",14.F,kOlSoft,width); + } + if(g_ol_party.link.party.handover){PaddockGap(12.F);OlParagraph("Preparing player swap. Waiting for the confirmed handover boundary.",15.F,kOlAmber,width,true);} + if(frame.view.host&&OlPartyLive()&&!g_ol_party.link.swaps.empty()){ + PaddockGap(14.F);OlPanelLook look;look.fill=0x3A2C14;look.ring=0xC08A2C;look.ring_width=1.F; + DrawOlCard(width,look,[&](float inner){ + OlParagraph("PLAYER SWAP REQUESTS",17.F,kOlAmber,inner,true); + for(const auto& [port,incoming]:g_ol_party.link.swaps){ + const auto* m=OlPartyMember(incoming);if(!m||port>=4)continue; + const auto outgoing=g_ol_party.link.party.controls[port].member; + PaddockGap(10.F);OlParagraph(m->name+" wants to take P"+std::to_string(port+1)+".",15.F,kOlCream,inner,true); + OlParagraph("Same racer, vehicle and race progress. The host approves the safe handover.",14.F,kOlSoft,inner); + ImGui::PushID(int(port));ImGui::PushID(std::to_string(incoming).c_str()); + {const OlDisabled disabled(OlPartyChanging(frame));if(OlButton("Approve swap",OlTone::Go))OlPartySwap(port,outgoing,incoming,true);} + ImGui::PopID();ImGui::PopID(); + } + PaddockGap(10.F);if(OlButton("Cancel pending swap"))lobby_party::party_cancel_swap(&dkr::runtime::netplay::session()); + }); + } + if(!g_ol_party.link.status.empty()){PaddockGap(10.F);OlParagraph(g_ol_party.link.status,14.F,kOlSoft,width);} +} +void DrawOlPartyStart(float width,const OnlineFrame& frame){ + const bool live=OlPartyLive(),watcher=OlPartyWatching(),host=frame.view.host; + OlPanelLook look;look.fill=0x061D2C;look.ring=0xFFFFFF;look.ring_alpha=18U;look.ring_width=1.F;look.radii=PaddockRound(10.F); + DrawOlCard(width,look,[&](float inner){ + if(g_ol_party.link.stopping)OlParagraph("Returning safely to the lobby. Please wait for saving and game retirement.",16.F,kOlCream,inner,true); + else OlParagraph(watcher?"You are watching. No racing controller is assigned to you.": + g_ol_party.link.running?"Race running. Racing-place swaps keep the racer, car and progress.":"Loading the race. Waiting for the confirmed game state.",16.F,kOlCream,inner,true); + PaddockGap(12.F); + const bool pair=(host||watcher)&&inner>=390.F;const float button=pair?(inner-10.F)*.5F:inner;const auto at=ImGui::GetCursorScreenPos(); + {const OlDisabled disabled(frame.launcher||!g_ol_party.link.running||g_ol_party.link.stopping); + if(OlCta(watcher?"BACK TO WATCHING":"BACK TO MY RACE",OlCtaColour::Green,button, + frame.launcher||!g_ol_party.link.running||g_ol_party.link.stopping)) + dkr::runtime::ui::toggle_overlay();} + if(host||watcher){ + ImGui::SetCursorScreenPos(pair?ImVec2{at.x+button+10.F,at.y}:ImVec2{at.x,at.y+62.F}); + const bool disabled=OlPartyChanging(frame)||(!host&&watcher&&!live); + const bool waiting_ready=g_ol_party.save_ready&& + std::count_if(frame.view.room.players.begin(),frame.view.room.players.end(),[](const auto& p){return p.occupied;})>=2&& + std::none_of(frame.view.room.players.begin(),frame.view.room.players.end(),[](const auto& p){return p.occupied&&!p.ready;}); + if(OlCta(host?(live?"RETURN TO LOBBY":"START RACE"):(live?"REQUEST A PLACE":"WAITING FOR HOST"),host?OlCtaColour::Red:OlCtaColour::Blue,button, + disabled||(!live&&!waiting_ready))){ + if(host){if(live)g_ol_return_open=true;else lobby_party::party_start(&dkr::runtime::netplay::session());} + else g_ol_manage_open=true; + } + } + }); +} +void DrawOlPartyDialogs(const OnlineFrame& frame){ + constexpr const char* member_popup="Player options###restored-lobby-member"; + constexpr const char* manage_popup="Players and spectators###restored-lobby-manage"; + constexpr const char* return_popup="Return everyone to the lobby?###restored-lobby-return"; + constexpr const char* remove_popup="Remove this player?###restored-lobby-remove"; + static std::uint64_t dialog_room=0; + if(!OlPartyActive()||(dialog_room&&dialog_room!=g_ol_party.room)){ + g_ol_member_open=g_ol_manage_open=g_ol_return_open=g_ol_remove_open=false; + // A disconnected/replaced room must not leave an invisible modal + // blocking the launcher or allow a confirmation to target a new room. + for(const auto* name:{member_popup,manage_popup,return_popup,remove_popup}) + if(BeginOlDialog(name)){ImGui::CloseCurrentPopup();ImGui::EndPopup();} + dialog_room=0;return; + } + dialog_room=g_ol_party.room; + if(std::exchange(g_ol_member_open,false))ImGui::OpenPopup(member_popup); + if(std::exchange(g_ol_manage_open,false))ImGui::OpenPopup(manage_popup); + if(std::exchange(g_ol_return_open,false))ImGui::OpenPopup(return_popup); + if(std::exchange(g_ol_remove_open,false))ImGui::OpenPopup(remove_popup); + if(BeginOlDialog(member_popup)){ + const float width=std::max(1.F,ImGui::GetContentRegionAvail().x);const auto* m=OlPartyMember(g_ol_member_id); + const bool same=m&&g_ol_member_room==g_ol_party.room&&g_ol_member_generation==g_ol_party.link.party.generation; + if(!same)OlParagraph("The lobby changed. Close this menu and choose the player again.",16.F,kOlSoft,width); + else { + OlParagraph(m->name,20.F,kOlCream,width,true);PaddockGap(12.F); + OlParagraph(m->role==lobby_party::Role::Spectator?"Spectator - no racing controller port.":"Racer - controller ownership follows the confirmed lobby roster.",15.F,kOlSoft,width); + PaddockGap(12.F); + {const OlDisabled disabled(OlPartyChanging(frame)); + if(!OlPartyLive()&&frame.view.host){if(OlButton(m->role==lobby_party::Role::Spectator?"Make racer":"Make spectator"))if(OlPartyRole(m->id,m->role==lobby_party::Role::Spectator))ImGui::CloseCurrentPopup();} + if(OlPartyLive()&&OlPartyWatching()&&m->role==lobby_party::Role::Racer&&m->port){ + if(OlButton("Request this racing place"))if(OlPartySwap(*m->port,m->id,g_ol_party.link.local,false))ImGui::CloseCurrentPopup(); + } + if(OlPartyLive()&&!OlPartyWatching()&&m->role==lobby_party::Role::Spectator){ + if(const auto* me=OlPartyMember(g_ol_party.link.local);me&&me->port) + if(OlButton("Offer my place to this spectator"))if(OlPartySwap(*me->port,me->id,m->id,false))ImGui::CloseCurrentPopup(); + } + if(frame.view.host&&m->id!=g_ol_party.link.local){PaddockGap(8.F);if(OlButton("Remove from lobby",OlTone::Danger)){g_ol_remove_open=true;ImGui::CloseCurrentPopup();}} + } + } + PaddockGap(14.F);if(OlButton("Back")||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)||ImGui::IsKeyPressed(ImGuiKey_Escape))ImGui::CloseCurrentPopup(); + if(ImGui::IsWindowAppearing())ImGui::SetItemDefaultFocus();ImGui::EndPopup(); + } + if(BeginOlDialog(manage_popup)){ + const float width=std::max(1.F,ImGui::GetContentRegionAvail().x); + OlParagraph(OlPartyWatching()&&OlPartyLive()?"Choose a racer to request their racing place.":"Choose a player for role and racing-place controls.",15.F,kOlSoft,width);PaddockGap(12.F); + const float height=std::min(62.F*g_ol_party.link.party.members.size(),std::max(100.F,ImGui::GetIO().DisplaySize.y*.6F-140.F)); + ImGui::BeginChild("##restored-party-members",{width,height},false); + for(const auto& m:g_ol_party.link.party.members){ + ImGui::PushID(std::to_string(m.id).c_str());const float inner=std::max(1.F,ImGui::GetContentRegionAvail().x); + const std::string label=m.name+(m.role==lobby_party::Role::Spectator?" (spectator)":" (P"+std::to_string(m.port.value_or(0)+1)+")"); + const std::string shown=PaddockEllipsize(OlRead(16.F,true),label,std::max(20.F,inner-32.F)); + if(OlButton(shown.c_str(),OlTone::Plain,inner,label.c_str())){OlPartyOpenMember(m.id);ImGui::CloseCurrentPopup();} + PaddockGap(10.F);ImGui::PopID(); + } + ImGui::EndChild();PaddockGap(14.F); + if(OlButton("Back")||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)||ImGui::IsKeyPressed(ImGuiKey_Escape))ImGui::CloseCurrentPopup(); + if(ImGui::IsWindowAppearing())ImGui::SetItemDefaultFocus();ImGui::EndPopup(); + } + if(BeginOlDialog(return_popup)){ + ImGui::TextWrapped("The race will stop safely and everyone will return to this lobby. The lobby stays open."); + const int action=DrawOlDialogActions({{"KEEP RACING"},{"RETURN TO LOBBY",true}}); + if(action==1&&frame.view.host)lobby_party::party_stop(&dkr::runtime::netplay::session(),"The host returned everyone to the lobby."); + if(action>=0||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)||ImGui::IsKeyPressed(ImGuiKey_Escape))ImGui::CloseCurrentPopup();ImGui::EndPopup(); + } + if(BeginOlDialog(remove_popup)){ + const auto* m=OlPartyMember(g_ol_member_id); + ImGui::TextWrapped("Remove %s from this lobby? Removing a racer during a race returns the remaining players safely to the lobby.",m?m->name.c_str():"this player"); + const int action=DrawOlDialogActions({{"CANCEL"},{"REMOVE PLAYER",true}}); + if(action==1){std::string error; + if(!m||g_ol_member_room!=g_ol_party.room||g_ol_member_generation!=g_ol_party.link.party.generation)OlNotify("The lobby changed. Choose the player again."); + else if(!lobby_ui::request_removal(&dkr::runtime::netplay::session(),g_ol_party,m->id,error))OlNotify(error); + } + if(action>=0||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)||ImGui::IsKeyPressed(ImGuiKey_Escape))ImGui::CloseCurrentPopup();ImGui::EndPopup(); + } +} diff --git a/runtime-recomp/src/game/lobby_sidebar.inl b/runtime-recomp/src/game/lobby_sidebar.inl new file mode 100644 index 00000000..c290c2f5 --- /dev/null +++ b/runtime-recomp/src/game/lobby_sidebar.inl @@ -0,0 +1,64 @@ +// Native launcher cards, typography and control IDs are retained. This file +// changes placement only; directory discovery and join behavior stay owned by +// their existing handlers. +void DrawOlLobbyFilters(float width) { + static const std::vector roles{"Racer", "Watch Race"}; + int role = g_online_join_watching ? 1 : 0; + if (OlSelect("browse-join-role", "Join as", &role, roles, width)) + g_online_join_watching = role != 0; + PaddockGap(12.F); + static const std::vector sources{"Public", "Friends"}; + int source = g_directory_friends_view ? 1 : 0; + if (OlSelect("directory-source", "Browse lobbies", &source, sources, width)) + g_directory_friends_view = source != 0; +} + +void DrawOlSidebarReadiness(float width, const OnlineFrame& frame) { + DrawOlCard(width, OlPanelLook{}, [&](float inner) { + OlHeading("Before you race", 24.F, kOlCream, inner); + PaddockGap(16.F); + auto row = [&](int kind, const char* title, const std::string& description, + const char* action, int target) { + PaddockBox box(inner, {10.F, 10.F}); + const auto origin = ImGui::GetCursorScreenPos(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + DrawOlCheckIcon(draw, {origin.x + 14.F, origin.y + 14.F}, kind); + const float text_width = std::max(1.F, box.Inner() - 40.F); + ImGui::SetCursorScreenPos({origin.x + 40.F, origin.y}); + ImGui::BeginGroup(); + PaddockText(OlRead(15.F, true, 1.4F), PaddockRgb(0xFFFFFF), title, text_width, true); + OlParagraph(description, 14.F, kind == 1 ? kOlError : kOlSoft, text_width); + if (action) { + PaddockGap(4.F); + if (OlLink(action)) { + if (target == kPageModsHacks) g_mods_page.entry_section = kModsSectionMagic; + g_page_navigation_request = target; + } + } + ImGui::EndGroup(); + const float height = std::max(28.F, ImGui::GetItemRectMax().y - origin.y); + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy({box.Inner(), height}); + box.End([&](ImDrawList* target_draw, ImVec2 a, ImVec2 b) { + PaddockFill(target_draw, a, b, PaddockRound(8.F), PaddockCol(kind == 1 ? 0x2E1F22U : 0x0D2F45U)); + }); + }; + row(frame.rom_ready ? 0 : 1, "Game ROM", frame.rom_ready ? "Loaded and ready" : "Not loaded yet", + frame.rom_ready ? nullptr : "Choose ROM", kPagePlay); + PaddockGap(8.F); + unsigned codes = 0; + for (auto bits = dkr::runtime::magic_codes::selected_mask(); bits; bits &= bits - 1U) ++codes; + row(2, "Magic Codes", codes ? std::to_string(codes) + " enabled." : "None enabled.", + frame.active ? nullptr : "Change", kPageModsHacks); + PaddockGap(8.F); + row(2, "Adventure save", "Uses the host's online save. Offline progress stays separate.", nullptr, -1); + PaddockGap(16.F); + static const std::vector profiles{ + "Player 1 profile", "Player 2 profile", "Player 3 profile", "Player 4 profile"}; + if (OlSelect("local-profile", "Your controls", &g_online_input_profile, profiles, inner, frame.active)) { + g_online_input_profile = std::clamp(g_online_input_profile, 0, 3); + dkr::runtime::platform::set_online_input_profile(static_cast(g_online_input_profile)); + SaveSettings(); + } + }); +} diff --git a/runtime-recomp/src/game/local_model_rt64.cpp b/runtime-recomp/src/game/local_model_rt64.cpp index 4af03b61..133474fe 100644 --- a/runtime-recomp/src/game/local_model_rt64.cpp +++ b/runtime-recomp/src/game/local_model_rt64.cpp @@ -1,4 +1,7 @@ #include "local_model_rt64.hpp" +#include "detail_contract.hpp" +#include "detail_metrics.hpp" +#include "vehicle_rt64.hpp" #include "visual_model_kernel.h" #include "native_draw_scope_rt64.hpp" #include "runtime_enhancements.hpp" @@ -38,6 +41,7 @@ struct Step { enum Kind {Command,Triangle} kind=Command; std::uint32_t w0=0,w1=0; std::array corners{}; + unsigned source_triangle=0; }; struct Packet {std::vector steps;dkr_visual_model_result model{};unsigned triangles=0;}; bool rdp_opcode(unsigned op) { @@ -90,6 +94,17 @@ struct LocalModelRT64Pass::Impl { struct Image {std::uint32_t source=0,count=0,address=0;std::shared_ptr texture;}; std::vector observations; std::vector images; + // Sorted, merged successful replacement spans. Query cost is logarithmic + // instead of scanning every racer/scenery observation per triangle. + std::vector> suppressed; + detail::FrameMetrics profile; + unsigned kernel_builds=0,vertex_uploads=0,batches=0,texture_hits=0,texture_captures=0; + void suppress(std::uint32_t first,std::uint32_t last) { + auto at=std::lower_bound(suppressed.begin(),suppressed.end(),first,[](const auto& row,auto value){return row.secondfirst<=last){first=std::min(first,at->first);last=std::max(last,at->second);at=suppressed.erase(at);} + suppressed.insert(at,{first,last}); + } + #include "detail_texture_cache.inl" std::span ram; std::uint32_t scene=0,cursor=kTextureBegin; unsigned draws=0,logged=0; @@ -102,12 +117,16 @@ struct LocalModelRT64Pass::Impl { // Reuse allocations across frames and poses across split-screen views. // No pointer to reusable guest scratch is retained in a compiled packet. std::array cache; + std::array racer_cache; + std::size_t racer_cached=0,racer_steps=0; + unsigned racer_operations=0; + std::array racer_triangles{}; std::uint32_t texture(const Reader& r,std::uint32_t source,unsigned count,const dkr_visual_model_result& model) { for(unsigned i=0;i(netplay::experimental::capture_local_texture(ram,p)); + auto tex=retain_texture(r,p); const auto bytes=(tex->bytes.size()+7U)&~std::size_t(7U); if(bytes>kTextureEnd-cursor)throw std::runtime_error("local model texture arena full"); const auto address=cursor;cursor+=std::uint32_t(bytes); @@ -116,25 +135,32 @@ struct LocalModelRT64Pass::Impl { } throw std::runtime_error("local model texture ownership"); } - const Packet* build(const dkr_owned_draw_event& e) { + const Packet* build(const dkr_owned_draw_event& e,bool extra) { + auto packets=extra?std::span{cache}:std::span{racer_cache}; + auto& packet_count=extra?cached:racer_cached; + auto& operations=extra?kernel_operations:racer_operations; + auto& prepared_steps=extra?packet_steps:racer_steps; + const auto operation_limit=extra?12000000U:detail::KernelOperations; + const auto step_limit=extra?65536U:detail::PreparedSteps; const auto& p=e.parameters; dkr_visual_model_request request{p[1],p[2],p[3],p[4],p[5],p[6],p[7],p[9]}; - for(std::size_t i=0;i=12000000U || packet_steps>=65536)return nullptr; - auto& entry=cache[cached++];entry.request=request;entry.valid=false; + for(std::size_t i=0;i=operation_limit||prepared_steps>=step_limit)throw std::runtime_error(extra?"scenery preparation capacity":"racer preparation capacity"); + auto& entry=packets[packet_count++];entry.request=request;entry.valid=false; auto& packet=entry.packet; auto kernel=revision_addresses::selected_revision()==rom::Revision::UsV80?dkr_visual_v80_model:dkr_visual_v77_model; packet.steps.clear();packet.triangles=0; // Failed packets count too: malformed late commands must not bypass // the frame's allocation budget by leaving many large partial vectors. - struct Charge {std::size_t& total;const Packet& packet;~Charge(){total+=packet.steps.size();}} charge{packet_steps,packet}; + struct Charge {std::size_t& total;const Packet& packet;~Charge(){total+=packet.steps.size();}} charge{prepared_steps,packet}; + ++kernel_builds; if(!kernel(ram.data(),ram.size(),&request,&packet.model)) { if(logged++<8)std::fprintf(stderr,"[graphics][local-detail] retained original mesh: %s\n",packet.model.failure?packet.model.failure:"invalid model"); - kernel_operations+=packet.model.operations; + operations+=packet.model.operations; return nullptr; } - kernel_operations+=packet.model.operations; + operations+=packet.model.operations; Reader r{ram};std::array vertices{};std::array valid{}; unsigned matrix=1,vertex_cursor=0,triangles=0; for(unsigned i=0;i8192)throw std::runtime_error("local model triangle budget");triangles+=count; for(unsigned tri=0;tri>16)&15U; + Step step;step.source_triangle=(source-packet.model.triangles)/16+tri;step.kind=Step::Triangle;step.w0=r.byte(source+tri*16);step.w1=(w0>>16)&15U; for(unsigned c=0;c<3;++c) { const auto n=r.byte(source+tri*16+1+c);if(n>=32||!valid[n])throw std::runtime_error("local model triangle reference"); step.corners[c]=vertices[n];step.corners[c].s=std::int16_t(r.half(source+tri*16+4+c*4));step.corners[c].t=std::int16_t(r.half(source+tri*16+6+c*4)); @@ -185,7 +211,7 @@ struct LocalModelRT64Pass::Impl { } } else if(op==0xB6||op==0xB7||op==0xE7)packet.steps.push_back({Step::Command,w0,w1}); else throw std::runtime_error("local model geometry opcode"); - if(packet.steps.size()>32768 || packet_steps+packet.steps.size()>65536) + if(packet.steps.size()>32768 || prepared_steps+packet.steps.size()>step_limit) throw std::runtime_error("local model packet budget"); } packet.triangles=triangles;entry.valid=triangles!=0; @@ -195,11 +221,18 @@ struct LocalModelRT64Pass::Impl { LocalModelRT64Pass::LocalModelRT64Pass():impl_(std::make_unique()) {} LocalModelRT64Pass::~LocalModelRT64Pass()=default; void LocalModelRT64Pass::prepare(std::span events,std::span ram,std::uint32_t scene) noexcept { - auto& p=*impl_;p.observations.clear();p.images.clear();p.ram=ram;p.scene=scene;p.cursor=kTextureBegin;p.draws=0; + auto& p=*impl_; + p.profile.begin(scene,unsigned(events.size()),p.kernel_builds,p.vertex_uploads,p.batches,p.texture_hits,p.texture_captures); + detail::MicroScope prepare_cost{p.profile.prepare_us}; + if(p.texture_scene!=scene||p.texture_frame==UINT64_MAX){p.retained_textures.clear();p.retained_texture_bytes=0;p.texture_scene=scene;p.texture_frame=0;} + ++p.texture_frame;p.suppressed.clear(); + p.observations.clear();p.images.clear();p.ram=ram;p.scene=scene;p.cursor=kTextureBegin;p.draws=0; p.cached=p.packet_steps=p.kernel_operations=0; - p.submitted_triangles=0; + p.submitted_triangles=0;p.racer_cached=p.racer_steps=p.racer_operations=0;p.racer_triangles.fill(0); std::size_t retained=0;for(const auto& entry:p.cache)retained+=entry.packet.steps.capacity(); if(retained>131072)for(auto& entry:p.cache)std::vector().swap(entry.packet.steps); + retained=0;for(const auto& entry:p.racer_cache)retained+=entry.packet.steps.capacity(); + if(retained>detail::PreparedSteps*2U)for(auto& entry:p.racer_cache)std::vector().swap(entry.packet.steps); const bool maximum=enhancements::maximum_detail_enabled(); p.settings={enhancements::presentation_profile(),enhancements::scenery_retention_mode(),enhancements::view_distance_multiplier(), enhancements::keep_hub_scenery_requested(),enhancements::keep_track_scenery_requested(),enhancements::keep_minigame_scenery_requested(), @@ -208,9 +241,15 @@ void LocalModelRT64Pass::prepare(std::span events,st if(!p.enabled)return; try { for(const auto& e:events)if(maximum && e.kind==DKR_OWNED_LOCAL_MODEL) { - if(!dkr_owned_draw_event_valid(&e)||p.observations.size()==128){p.enabled=false;return;} + if(!dkr_owned_draw_event_valid(&e)||p.observations.size()==detail::Observations){p.enabled=false;return;} p.observations.push_back({e}); } + std::stable_sort(p.observations.begin(),p.observations.end(),[](const auto& a,const auto& b){return a.e.address,4> drawn; for(const auto& e:events)if(e.kind==DKR_OWNED_LOCAL_OBJECT&&dkr_owned_draw_event_valid(&e))drawn[e.token].push_back(e.parameters[0]); @@ -257,20 +296,28 @@ void LocalModelRT64Pass::prepare(std::span events,st if(a.e.address!=b.e.address)return a.e.addressb.squared_distance; }); - }catch(...){p.enabled=false;} + }catch(const std::exception& error){ + std::erase_if(p.observations,[](const auto& o){return o.extra;}); + detail::note({scene,0,0,0,0,1,maximum,error.what()}); + }catch(...){std::erase_if(p.observations,[](const auto& o){return o.extra;});} } bool LocalModelRT64Pass::replaces_triangle(std::uint32_t command)const noexcept { const auto& p=*impl_;if(!p.enabled)return false; - for(const auto& o:p.observations)if(o.replaced && !o.extra && command>=o.e.address-0x80000000U && commandsecond; } void LocalModelRT64Pass::draw_at(RT64::State& state,std::uint32_t command_address,IdentitySelector select) { auto& p=*impl_;if(!p.enabled||!select)return; - for(auto& o:p.observations) { - if(o.consumed||o.e.address-0x80000000U!=command_address)continue;o.consumed=true; + const auto address=command_address+0x80000000U; + auto found=std::lower_bound(p.observations.begin(),p.observations.end(),address,[](const auto& o,auto value){return o.e.addresse.address!=address)return; + detail::MicroScope submit_cost{p.profile.submit_us}; + for(;found!=p.observations.end()&&found->e.address==address;++found) { + auto& o=*found;if(o.consumed)continue;o.consumed=true; auto& rsp=*state.rsp; if(state.rdp->colorCombinerStackSize>=RDP_EXTENDED_STACK_SIZE || state.rdp->primColorStackSize>=RDP_EXTENDED_STACK_SIZE || state.rdp->envColorStackSize>=RDP_EXTENDED_STACK_SIZE)continue; const Packet* packet=nullptr; + const mods::vehicles::ModelProfile* driver_mask=nullptr; auto transform=hlslpp::float4x4::identity(); std::uint32_t environment=0xFFFFFF00U,primitive=0xFFFFFF00U|o.alpha; try { @@ -288,8 +335,17 @@ void LocalModelRT64Pass::draw_at(RT64::State& state,std::uint32_t command_addres (std::uint32_t(r.byte(shading+6))<<8)|r.byte(shading+7); } } - packet=p.build(o.e);if(!packet || packet->triangles>32768U-p.submitted_triangles)continue; - }catch(const std::exception& error){if(p.logged++<8)std::fprintf(stderr,"[graphics][local-detail] retained original mesh: %s\n",error.what());continue;} + packet=p.build(o.e,o.extra); + if(!packet){detail::note({p.scene,o.e.parameters[1],o.e.token,0,0,1,true,"invalid prepared model; original retained"});continue;} + auto& triangles=o.extra?p.submitted_triangles:p.racer_triangles[o.e.token]; + const auto limit=o.extra?32768U:detail::ViewTriangles; + if(packet->triangles>limit-triangles){detail::note({p.scene,o.e.parameters[1],o.e.token,0,0,1,true,"view triangle capacity; original retained"});continue;} + if(const auto* cars=vehicles::active_render_pass();!o.extra&&cars) + driver_mask=cars->detail_profile(o.e.parameters[1],o.e.parameters[2]); + if(driver_mask)for(const auto& step:packet->steps) + if(step.kind==Step::Triangle&&step.source_triangle>=driver_mask->hide.size()) + throw std::runtime_error("high-detail driver mask exceeds its own model"); + }catch(const std::exception& error){detail::note({p.scene,o.e.parameters[1],o.e.token,0,0,1,true,error.what()});continue;} // All spans, uploads and references have been checked before the first // GPU draw. No callback, allocator or guest interpreter runs below. NativeDrawScope restore(state); @@ -299,22 +355,14 @@ void LocalModelRT64Pass::draw_at(RT64::State& state,std::uint32_t command_addres state.rdp->setEnvColor(environment); } if(o.e.parameters[7])rsp.modelMatrixStack[2]=hlslpp::mul(head_matrix(packet->model,std::int16_t(o.e.parameters[8])),rsp.modelMatrixStack[1]); - select(rsp,presentation::normalise_identity(p.scene^o.e.parameters[1]^0x4D4F444CU^(o.e.token*0x9E3779B9U))); - for(const auto& step:packet->steps) { - if(step.kind==Step::Command){command(state,step.w0,step.w1);continue;} - rsp.textureState.on=std::uint8_t(step.w1); - rsp.clearGeometryMode(rsp.cullBothMask); - if(!(step.w0&0x40U))rsp.setGeometryMode(rsp.viewportStack[rsp.viewportStackSize-1].scale.x>0 ? - (rsp.cullBothMask&~rsp.cullFrontMask):rsp.cullFrontMask); - for(unsigned c=0;c<3;++c) { - const auto& v=step.corners[c];std::memcpy(state.RDRAM+kVertices+c*sizeof(v.vertex),&v.vertex,sizeof(v.vertex)); - rsp.modelMatrixStackSize=int(v.matrix+1);rsp.modelViewProjChanged=true;rsp.modelViewProjInserted=false; - rsp.setVertex(kVertices+c*sizeof(v.vertex),1,c); - rsp.modifyVertex(std::uint16_t(c),G_MWO_POINT_ST,(std::uint32_t(std::uint16_t(v.s))<<16)|std::uint16_t(v.t)); - } - rsp.drawIndexedTri(0,1,2); - } - state.flush();o.replaced=true;++p.draws;p.submitted_triangles+=packet->triangles; + select(rsp,presentation::normalise_identity(p.scene^o.e.parameters[1]^presentation::mix_identity(o.e.parameters[2])^(o.e.parameters[7]<<28)^0x4D4F444CU^(o.e.token*0x9E3779B9U))); + #include "detail_batch.inl" + state.flush();o.replaced=true;++p.draws; + if(!o.extra)p.suppress(o.e.address-0x80000000U,o.e.parameters[0]-0x80000000U); + if(o.extra)p.submitted_triangles+=packet->triangles;else p.racer_triangles[o.e.token]+=packet->triangles; + if(!o.extra)detail::note({p.scene,o.e.parameters[1],o.e.token,Reader{p.ram}.byte(o.e.parameters[1]+0x3a),0,1,true,"highest resident presentation mesh"}); + if(!o.extra&&p.logged++<16)std::fprintf(stderr,"[graphics][local-detail] highest-loaded driver instance=%08X object=%08X car-mask=%u triangles=%u; independent-driver-detail-v1\n", + o.e.parameters[2],o.e.parameters[1],unsigned(driver_mask!=nullptr),packet->triangles); } } } diff --git a/runtime-recomp/src/game/mod_store.cpp b/runtime-recomp/src/game/mod_store.cpp new file mode 100644 index 00000000..6c8e8895 --- /dev/null +++ b/runtime-recomp/src/game/mod_store.cpp @@ -0,0 +1,2166 @@ +#include "online_directory.hpp" +#include "linux_keyring.hpp" +#include "store_vehicle_adapter.hpp" +#include "mod_store.hpp" +#include "mod_store_install.hpp" +#include "stb/stb_image.h" +#include "updates/update_core.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#else +#include +#include +#include +#endif + +namespace dkr::store { +namespace { +constexpr const char* Api = "https://dkr-mod-library-api.ross-macgregorbars.workers.dev"; +constexpr const char* Site = "https://positron-portal.com"; +constexpr std::uint64_t MaxFile = 2ULL * 1024 * 1024 * 1024; +constexpr std::size_t MaxJson = 4 * 1024 * 1024, MaxImage = 8 * 1024 * 1024, Chunk = 4 * 1024 * 1024; +using Clock = std::chrono::steady_clock; +struct Error : std::runtime_error { + unsigned status; + Json details; + Error(std::string message, unsigned s = 0, Json context = Json::object()) + : std::runtime_error(std::move(message)), status(s), details(std::move(context)) {} +}; +std::string lower(std::string value) { + for (auto& c : value) + if (c >= 'A' && c <= 'Z') + c += 32; + return value; +} +bool prefix(std::string_view value, std::string_view start) { + return value.starts_with(start); +} +bool identifier(std::string_view value) { + return !value.empty() && value.size() <= 160 && std::all_of(value.begin(), value.end(), [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + }); +} +bool hash_string(std::string_view value) { + return value.size() == 64 && std::all_of(value.begin(), value.end(), + [](char c) { return (c >= 'a' && c <= 'f') || (c >= '0' && c <= '9'); }); +} +std::string encode(std::string_view value) { + static const char hex[] = "0123456789ABCDEF"; + std::string out; + for (unsigned char c : value) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || + c == '.' || c == '~') + out += char(c); + else { + out += '%'; + out += hex[c >> 4]; + out += hex[c & 15]; + } + } + return out; +} +std::string base64(std::span bytes) { + static const char t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + std::string out; + unsigned bits = 0, count = 0; + for (auto b : bytes) { + bits = (bits << 8) | b; + count += 8; + while (count >= 6) { + count -= 6; + out += t[(bits >> count) & 63]; + } + } + if (count) + out += t[(bits << (6 - count)) & 63]; + return out; +} +std::string nonce() { + std::array bytes{}; +#ifdef _WIN32 + if (BCryptGenRandom(nullptr, bytes.data(), ULONG(bytes.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) < 0) + throw Error("Secure sign-in could not start."); +#else + int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); + if (fd < 0) + throw Error("Secure sign-in could not start."); + std::size_t done = 0; + while (done < bytes.size()) { + auto n = read(fd, bytes.data() + done, bytes.size() - done); + if (n <= 0) { + close(fd); + throw Error("Secure sign-in could not start."); + } + done += std::size_t(n); + } + close(fd); +#endif + return base64(bytes); +} +std::string challenge(const std::string& value) { + auto digest = dkr::updates::unhex( + dkr::updates::hash(std::span(reinterpret_cast(value.data()), value.size())), 32); + return base64(digest); +} +void write_json(const std::filesystem::path& path, const Json& value) { + std::filesystem::create_directories(path.parent_path()); + dkr::updates::durable_write(path, value.dump()); +} +Json read_json(const std::filesystem::path& path) { + try { + return Json::parse(dkr::updates::read_text(path, MaxJson)); + } catch (...) { + return Json::object(); + } +} +void save_device(const std::filesystem::path& root, const std::string& token) { +#ifdef _WIN32 + DATA_BLOB input{DWORD(token.size()), reinterpret_cast(const_cast(token.data()))}, output{}; + if (!CryptProtectData(&input, L"DKR-R Mod Store", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output)) + throw Error("Windows could not remember this sign-in securely."); + std::unique_ptr memory(output.pbData, LocalFree); + dkr::updates::durable_write(root / "device.bin", + std::string_view(reinterpret_cast(output.pbData), output.cbData)); +#else + if(!dkr::online::keyring::store(root.generic_string(),token)) + throw Error("The desktop keyring could not remember sign-in. You are signed in for this session only."); +#endif +} +std::string load_device(const std::filesystem::path& root) { +#ifdef _WIN32 + try { + const auto bytes = dkr::updates::read_text(root / "device.bin", 16384); + DATA_BLOB input{DWORD(bytes.size()), reinterpret_cast(const_cast(bytes.data()))}, output{}; + if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output)) + return {}; + std::unique_ptr memory(output.pbData, LocalFree); + std::string token(reinterpret_cast(output.pbData), output.cbData); + return token.size() == 43 && identifier(token) ? token : std::string{}; + } catch (...) { + return {}; + } +#else + auto token=dkr::online::keyring::load(root.generic_string()); + return token.size()==43&&identifier(token)?token:std::string{}; +#endif +} +struct Response { + unsigned status = 0; + std::string body, type, etag, range, location, url; + std::uint64_t length = 0; +}; +struct Request { + std::string url, method = "GET", body, token, type = "application/json", etag; + std::uint64_t offset = 0, limit = MaxJson; + unsigned timeout = 12000; + std::string online_instance; +}; +using Sink = std::function; + +#ifdef _WIN32 +struct Internet { + HINTERNET h = nullptr; + ~Internet() { + if (h) + WinHttpCloseHandle(h); + } + operator HINTERNET() const { + return h; + } +}; +std::wstring wide(std::string_view value) { + if (value.size() > 65535) + throw Error("Request text is too long."); + int n = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), int(value.size()), nullptr, 0); + if (!n) + throw Error("Request text has invalid encoding."); + std::wstring out(n, 0); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), int(value.size()), out.data(), n); + return out; +} +std::string header(HINTERNET request, DWORD field) { + DWORD bytes = 0; + WinHttpQueryHeaders(request, field, WINHTTP_HEADER_NAME_BY_INDEX, nullptr, &bytes, WINHTTP_NO_HEADER_INDEX); + if (bytes > 65536) + throw Error("Response headers are too large."); + if (!bytes) + return {}; + std::wstring raw(bytes / sizeof(wchar_t), 0); + if (!WinHttpQueryHeaders(request, field, WINHTTP_HEADER_NAME_BY_INDEX, raw.data(), &bytes, WINHTTP_NO_HEADER_INDEX)) + return {}; + std::string out; + for (wchar_t c : raw) + if (c) + out += c < 128 ? char(c) : '?'; + return out; +} +Response http_once(const Request& r, std::stop_token stop, const Sink& sink) { + if (!trusted_url(r.url, prefix(r.url, Api))) + throw Error("The library returned an unapproved file address."); + auto url = wide(r.url); + URL_COMPONENTS parts{}; + parts.dwStructSize = sizeof(parts); + parts.dwHostNameLength = parts.dwUrlPathLength = parts.dwExtraInfoLength = DWORD(-1); + if (!WinHttpCrackUrl(url.c_str(), DWORD(url.size()), 0, &parts) || parts.nScheme != INTERNET_SCHEME_HTTPS || + parts.nPort != 443) + throw Error("Use the secure library address."); + Internet session{WinHttpOpen(L"DKR-R-ModStore/1.0.7-beta8", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)}; + if (!session.h) + throw Error("The secure connection could not start."); + WinHttpSetTimeouts(session, 4000, 4000, int(r.timeout), int(r.timeout)); + DWORD tls = WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2; +#ifdef WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_3 + tls |= WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_3; +#endif + if (!WinHttpSetOption(session, WINHTTP_OPTION_SECURE_PROTOCOLS, &tls, sizeof(tls))) + throw Error("The secure connection could not start."); + std::wstring host(parts.lpszHostName, parts.dwHostNameLength), path(parts.lpszUrlPath, parts.dwUrlPathLength); + if (parts.dwExtraInfoLength) + path.append(parts.lpszExtraInfo, parts.dwExtraInfoLength); + Internet connection{WinHttpConnect(session, host.c_str(), 443, 0)}; + const auto method = wide(r.method); + Internet request{connection.h + ? WinHttpOpenRequest(connection, method.c_str(), path.c_str(), nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) + : nullptr}; + if (!request.h) + throw Error("The library connection could not open."); + DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; + WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)); + DWORD disable = WINHTTP_DISABLE_COOKIES; + WinHttpSetOption(request, WINHTTP_OPTION_DISABLE_FEATURE, &disable, sizeof(disable)); + std::string headers = "Accept: */*\r\n"; + if (prefix(r.url, Api)) { + headers += "Origin: https://positron-portal.com\r\n"; + if(!r.online_instance.empty())headers+="X-DKRR-Instance: "+r.online_instance+"\r\n"; + if (!r.token.empty()) { + if (r.token.size() != 43 || !identifier(r.token)) + throw Error("Sign in again to continue."); + headers += "Authorization: Bearer " + r.token + "\r\n"; + } + } else { + if (!r.token.empty()) + throw Error("Credentials cannot be sent to a file host."); + headers += "Referer: https://positron-portal.com/dkr-r/index.html\r\n"; + } + if (r.method == "POST") + headers += "Content-Type: " + r.type + "\r\n"; + if (r.offset) { + headers += "Range: bytes=" + std::to_string(r.offset) + "-\r\n"; + if (!r.etag.empty() && r.etag.find_first_of("\r\n") == std::string::npos) + headers += "If-Range: " + r.etag + "\r\n"; + } + auto wh = wide(headers); + if (stop.stop_requested() || + !WinHttpSendRequest(request, wh.c_str(), DWORD(wh.size()), + r.body.empty() ? WINHTTP_NO_REQUEST_DATA : const_cast(r.body.data()), + DWORD(r.body.size()), DWORD(r.body.size()), 0) || + !WinHttpReceiveResponse(request, nullptr)) + throw Error("Could not reach the library. Check your connection and retry."); + Response response; + response.url = r.url; + DWORD bytes = sizeof(response.status); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &response.status, &bytes, WINHTTP_NO_HEADER_INDEX)) + throw Error("Invalid library response."); + response.type = lower(header(request, WINHTTP_QUERY_CONTENT_TYPE)); + response.etag = header(request, WINHTTP_QUERY_ETAG); + response.range = header(request, WINHTTP_QUERY_CONTENT_RANGE); + response.location = header(request, WINHTTP_QUERY_LOCATION); + const auto length = header(request, WINHTTP_QUERY_CONTENT_LENGTH); + if (!length.empty() && length.size() <= 20 && + std::all_of(length.begin(), length.end(), [](char c) { return c >= '0' && c <= '9'; })) { + try { + response.length = std::stoull(length); + } catch (...) { + throw Error("Invalid download size."); + } + } + std::array buffer{}; + std::uint64_t total = 0; + for (;;) { + if (stop.stop_requested()) + throw Error("Transfer paused."); + DWORD read = 0; + if (!WinHttpReadData(request, buffer.data(), DWORD(buffer.size()), &read)) + throw Error("The connection was interrupted. Retry to continue."); + if (!read) + break; + if (read > r.limit - total) + throw Error("The library response exceeds its allowed size."); + total += read; + if (sink && response.status >= 200 && response.status < 300) + sink(buffer.data(), read, response); + else if (response.body.size() + read <= std::min(r.limit, MaxImage)) + response.body.append(buffer.data(), read); + else + throw Error("The library response is too large."); + } + return response; +} +#else +Response http_once(const Request& r, std::stop_token stop, const Sink& sink) { + if (!trusted_url(r.url, prefix(r.url, Api))) + throw Error("Unapproved library address."); + static const bool initialized = [] { return curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK; }(); + if (!initialized) + throw Error("The secure connection could not start."); + struct Cleanup { + CURL* curl = curl_easy_init(); + curl_slist* headers = nullptr; + ~Cleanup() { + if (headers) + curl_slist_free_all(headers); + if (curl) + curl_easy_cleanup(curl); + } + } cleanup; + if (!cleanup.curl) + throw Error("The connection could not start."); + Response out; + out.url = r.url; + struct Transfer { + const Request* request; + Response* response; + const Sink* sink; + std::stop_token stop; + std::uint64_t total = 0; + std::exception_ptr error; + }; + Transfer transfer{&r, &out, &sink, stop}; + auto add = [&](std::string value) { cleanup.headers = curl_slist_append(cleanup.headers, value.c_str()); }; + if (prefix(r.url, Api)) { + add("Origin: https://positron-portal.com"); + if(!r.online_instance.empty())add("X-DKRR-Instance: "+r.online_instance); + if (!r.token.empty() && r.token.size() == 43 && identifier(r.token)) + add("Authorization: Bearer " + r.token); + } else { + if (!r.token.empty()) + throw Error("Credentials cannot be sent to a file host."); + add("Referer: https://positron-portal.com/dkr-r/index.html"); + } + if (r.method == "POST") + add("Content-Type: " + r.type); + if (r.offset && !r.etag.empty() && r.etag.find_first_of("\r\n") == r.etag.npos) + add("If-Range: " + r.etag); + curl_easy_setopt(cleanup.curl, CURLOPT_URL, r.url.c_str()); + curl_easy_setopt(cleanup.curl, CURLOPT_USERAGENT, "DKR-R-ModStore/1.0.7-beta8"); + curl_easy_setopt(cleanup.curl, CURLOPT_HTTPHEADER, cleanup.headers); + curl_easy_setopt(cleanup.curl, CURLOPT_CONNECTTIMEOUT_MS, 4000L); + curl_easy_setopt(cleanup.curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(cleanup.curl, CURLOPT_LOW_SPEED_TIME, 15L); + curl_easy_setopt(cleanup.curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(cleanup.curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(cleanup.curl, CURLOPT_PROTOCOLS_STR, "https"); + if (r.method == "POST") { + curl_easy_setopt(cleanup.curl, CURLOPT_POST, 1L); + curl_easy_setopt(cleanup.curl, CURLOPT_POSTFIELDS, r.body.data()); + curl_easy_setopt(cleanup.curl, CURLOPT_POSTFIELDSIZE_LARGE, curl_off_t(r.body.size())); + } + std::string range; + if (r.offset) { + range = std::to_string(r.offset) + "-"; + curl_easy_setopt(cleanup.curl, CURLOPT_RANGE, range.c_str()); + } + curl_easy_setopt(cleanup.curl, CURLOPT_HEADERDATA, &transfer); + curl_easy_setopt( + cleanup.curl, CURLOPT_HEADERFUNCTION, + +[](char* data, std::size_t size, std::size_t n, void* context) -> std::size_t { + auto& t = *static_cast(context); + const auto bytes = size * n; + try { + std::string line(data, bytes); + if (prefix(line, "HTTP/")) { + *t.response = Response{}; + t.response->url = t.request->url; + const auto at = line.find(' '); + if (at != line.npos) + t.response->status = unsigned(std::stoul(line.substr(at + 1))); + } + const auto colon = line.find(':'); + if (colon != line.npos) { + auto key = lower(line.substr(0, colon)); + auto value = line.substr(colon + 1); + while (!value.empty() && (value.back() == '\r' || value.back() == '\n')) + value.pop_back(); + while (!value.empty() && value.front() == ' ') + value.erase(value.begin()); + if (key == "content-type") + t.response->type = lower(value); + if (key == "etag") + t.response->etag = value; + if (key == "content-range") + t.response->range = value; + if (key == "location") + t.response->location = value; + if (key == "content-length" && !value.empty() && value.size() <= 20 && + std::all_of(value.begin(), value.end(), [](char c) { return c >= '0' && c <= '9'; })) + t.response->length = std::stoull(value); + } + return bytes; + } catch (...) { + t.error = std::current_exception(); + return 0; + } + }); + curl_easy_setopt(cleanup.curl, CURLOPT_WRITEDATA, &transfer); + curl_easy_setopt( + cleanup.curl, CURLOPT_WRITEFUNCTION, + +[](char* data, std::size_t size, std::size_t n, void* context) -> std::size_t { + auto& t = *static_cast(context); + const auto bytes = size * n; + try { + if (t.stop.stop_requested() || bytes > t.request->limit - t.total) + throw Error("Transfer stopped or exceeded its size limit."); + t.total += bytes; + if (*t.sink && t.response->status >= 200 && t.response->status < 300) + (*t.sink)(data, bytes, *t.response); + else { + if (t.response->body.size() + bytes > std::min(t.request->limit, MaxImage)) + throw Error("Response too large."); + t.response->body.append(data, bytes); + } + return bytes; + } catch (...) { + t.error = std::current_exception(); + return 0; + } + }); + curl_easy_setopt(cleanup.curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(cleanup.curl, CURLOPT_XFERINFODATA, &transfer); + curl_easy_setopt( + cleanup.curl, CURLOPT_XFERINFOFUNCTION, +[](void* p, curl_off_t, curl_off_t, curl_off_t, curl_off_t) -> int { + return static_cast(p)->stop.stop_requested() ? 1 : 0; + }); + const auto code = curl_easy_perform(cleanup.curl); + if (transfer.error) + std::rethrow_exception(transfer.error); + if (code != CURLE_OK) + throw Error("The library connection was interrupted. Retry to continue."); + return out; +} +#endif +Response http(Request request, std::stop_token stop, const Sink& sink = {}) { + // Match browser file redirects only within the existing pinned public-file + // roots. API credentials are never forwarded through a redirect. + for (unsigned hops = 0;; ++hops) { + auto response = http_once(request, stop, sink); + response.url = request.url; + if (response.status != 301 && response.status != 302 && response.status != 303 && response.status != 307 && + response.status != 308) + return response; + if (hops == 3 || request.method != "GET" || !request.token.empty() || prefix(request.url, Api)) + throw Error("The library returned an unsupported redirect.", response.status); + auto next = response.location; + if (next.starts_with('/') && !next.starts_with("//")) + next = std::string(Site) + next; + else if (!next.starts_with("https://") && next.find(':') == next.npos && !next.starts_with("//")) + next = request.url.substr(0, request.url.find_last_of('/') + 1) + next; + if (!trusted_url(next)) + throw Error("The download redirects outside the approved library.", response.status); + request.url = std::move(next); + } +} +bool webpage(const char* bytes, std::size_t count) { + auto sample = lower(std::string(bytes, std::min(count, 512))); + const auto first = sample.find_first_not_of(" \r\n\t\xEF\xBB\xBF"); + if (first == sample.npos) + return false; + sample.erase(0, first); + return prefix(sample, "(length, 16); ++i) { + const auto value = static_cast(data[i]); + signature += hex[value >> 4]; + signature += hex[value & 15]; + } + auto url = response.url; + if (const auto query = url.find_first_of("?#"); query != url.npos) + url.resize(query); + return Error(MissingPackage, response.status, + {{"httpStatus", response.status}, + {"contentType", response.type}, + {"contentLength", response.length}, + {"prefixHex", signature}, + {"servedUrl", url}}); +} +Json document(const Request& request, std::stop_token stop) { + const auto response = http(request, stop); + if (response.type.find("application/json") == std::string::npos) + throw Error("The library service needs its launcher update installed.", response.status); + Json data; + try { + data = Json::parse(response.body); + } catch (...) { + throw Error("The library returned an invalid response.", response.status); + } + if (response.status < 200 || response.status >= 300) { + const auto message = data.contains("error") ? text(data["error"], "message", 1000) : std::string{}; + throw Error(message.empty() ? "The library could not complete this request." : message, response.status); + } + if (!data.is_object()) + throw Error("The library returned invalid data."); + return data; +} +bool package_signature(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + std::array bytes{}; + input.read(reinterpret_cast(bytes.data()), bytes.size()); + const auto n = input.gcount(); + const auto ext = lower(path.extension().string()); + if (ext == ".xdelta") + return n >= 3 && bytes[0] == 0xd6 && bytes[1] == 0xc3 && bytes[2] == 0xc4; + if (ext == ".bps") + return n >= 4 && std::string_view(reinterpret_cast(bytes.data()), 4) == "BPS1"; + if (ext == ".ips") + return n >= 5 && std::string_view(reinterpret_cast(bytes.data()), 5) == "PATCH"; + if (ext == ".7z") + return n >= 6 && bytes[0] == '7' && bytes[1] == 'z' && bytes[2] == 0xbc && bytes[3] == 0xaf && + bytes[4] == 0x27 && bytes[5] == 0x1c; + return n >= 4 && bytes[0] == 'P' && bytes[1] == 'K'; +} +} // namespace + +namespace { +std::string upload_fingerprint(const std::filesystem::path& path, std::uint64_t size) { + const auto prefix = "dkr-upload-v1\n" + std::to_string(size) + "\n"; + std::vector bytes(prefix.begin(), prefix.end()); + const auto count = std::size_t(std::min(65536, size)); + std::ifstream file(path, std::ios::binary); + if (!file) + throw Error("The selected file could not be read."); + std::vector block(count); + file.read(block.data(), std::streamsize(count)); + if (std::size_t(file.gcount()) != count) + throw Error("The selected file changed."); + bytes.insert(bytes.end(), block.begin(), block.end()); + file.clear(); + file.seekg(std::streamoff(size - count)); + file.read(block.data(), std::streamsize(count)); + if (std::size_t(file.gcount()) != count) + throw Error("The selected file changed."); + bytes.insert(bytes.end(), block.begin(), block.end()); + return dkr::updates::hash(bytes); +} +} // namespace +std::string text(const Json& object, const char* key, std::size_t max) { + if (!object.is_object()) + return {}; + const auto found = object.find(key); + if (found == object.end() || !found->is_string()) + return {}; + std::string value = found->get(); + if (value.size() > max) + value.resize(max); + value.erase( + std::remove_if(value.begin(), value.end(), [](unsigned char c) { return c < 32 && c != '\n' && c != '\t'; }), + value.end()); + return value; +} +bool staff(const Json& user) { + const auto role = text(user, "role", 16); + return text(user, "status", 16) == "active" && (role == "moderator" || role == "admin"); +} +bool admin(const Json& user) { + return staff(user) && text(user, "role", 16) == "admin"; +} +bool trusted_url(const std::string& url, bool api) { + if (url.size() > 2048 || url.find_first_of("\\\r\n\t#") != url.npos || url.find('\0') != url.npos) + return false; + const auto lowered = lower(url); + if (lowered.find("%2f") != lowered.npos || lowered.find("%5c") != lowered.npos || + lowered.find("%00") != lowered.npos || lowered.find("%25") != lowered.npos || + lowered.find("%2e") != lowered.npos || lowered.find("/../") != lowered.npos || + lowered.find("/./") != lowered.npos) + return false; + return api ? prefix(url, std::string(Api) + "/") + : prefix(url, std::string(Site) + "/dkr-r/images/") || + prefix(url, std::string(Site) + "/dkr-r/downloads/") || + prefix(url, std::string(Site) + "/dkr-r/assets/"); +} + +struct Service::Impl { + mutable std::mutex mutex; + std::mutex download_persist_mutex; + std::condition_variable_any cv; + Snapshot state; + std::atomic> published{std::make_shared()}; + std::filesystem::path root; + std::string version, token, device, browser; + unsigned account = 0; + bool configured = false, names_queued = false, gameplay = false; + void preferences() { + int layout; + bool remember; + { + std::lock_guard lock(mutex); + layout = state.layout; + remember = state.remember; + } + write_json(root / "preferences.json", {{"layout", layout}, {"remember", remember}}); + } + using Work = std::function; + std::deque work; + std::deque image_queue; + std::map> images; + std::map upload_proofs; + std::set image_pending; + std::map image_retry; + std::atomic stop_upload{false}; + std::stop_source download_stop, login_stop; + std::map preparation_stops; + // Workers are last: joined before the objects captured by their callbacks. + std::jthread worker, image_worker, download_worker, auth_worker; + void publish() { + ++state.generation; + published.store(std::make_shared(state)); + } + void update(const std::function& fn) { + std::lock_guard lock(mutex); + fn(state); + publish(); + } + bool queue(Work fn) { + std::lock_guard lock(mutex); + if (work.size() >= 32) { + state.error = "Wait for the current library request to finish."; + publish(); + return false; + } + work.push_back(std::move(fn)); + cv.notify_all(); + return true; + } + void fail(const std::exception& error) { + update([&](Snapshot& s) { + s.loading = false; + s.error = error.what(); + }); + } + void download_report(const std::string& release, const std::string& title, const std::exception& error, + std::string url = {}) noexcept { + try { + // Public file URLs only; omit query strings and fragments. + if (!trusted_url(url)) + url.clear(); + if (const auto query = url.find_first_of("?#"); query != url.npos) + url.resize(query); + const auto timestamp = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + Json report = { + {"schema", 1}, {"timeUnixMs", timestamp}, {"build", version}, {"release", release}, {"mod", title}, + {"url", url}, {"error", error.what()}}; + if (const auto* detail = dynamic_cast(&error)) + report["response"] = detail->details; + write_json(root / "last-download-error.json", report); + } catch (...) { /* A diagnostic must never replace the original failure. */ + } + } + Json get(std::string path, std::stop_token stop, bool private_request = false) { + std::string credential; + { + std::lock_guard lock(mutex); + if (private_request) + credential = token; + } + try { + return document({std::string(Api) + path, "GET", {}, credential}, stop); + } catch (const Error& error) { + if (error.status == 401 && !credential.empty()) + expire(credential); + throw; + } + } + Json post(std::string path, Json value, std::stop_token stop, bool private_request = true) { + std::string credential; + { + std::lock_guard lock(mutex); + if (private_request) + credential = token; + } + if (private_request && credential.empty()) + throw Error("Sign in with Discord to continue."); + try { + return document({std::string(Api) + path, "POST", value.dump(), credential}, stop); + } catch (const Error& error) { + if (error.status == 401 && !credential.empty()) + expire(credential); + throw; + } + } + void expire(const std::string& credential) { + std::lock_guard lock(mutex); + if (credential != token) + return; + token.clear(); + ++account; + state.user = nullptr; + state.personal = Json::object(); + state.staff = Json::object(); + state.admin = Json::object(); + state.uploads = Json::array(); + state.assets = Json::array(); + state.upload = Json::object(); + state.uploading = false; + state.loading = state.writing = false; + upload_proofs.clear(); + state.last_action.clear(); + state.last_result = nullptr; + stop_upload = true; + publish(); + } + void persist_downloads() { + std::lock_guard persist_lock(download_persist_mutex); + Json rows = Json::array(); + { + std::lock_guard lock(mutex); + for (const auto& d : state.downloads) + rows.push_back({{"item", d.item}, + {"release", d.release}, + {"title", d.title}, + {"category", d.category}, + {"version", d.version}, + {"state", d.state}, + {"sha256", d.digest}, + {"bytes", d.total}, + {"intent", d.intent}, + {"instance", d.instance}}); + } + write_json(root / "downloads.json", {{"schema", 1}, {"jobs", rows}}); + } + Json paged(std::string path, const char* field, std::stop_token stop, bool private_request = false) { + Json all = Json::array(); + std::set cursors, ids; + std::string cursor; + std::size_t total_bytes = 0; + for (unsigned page = 0; page < 100; ++page) { + auto data = get(path + (cursor.empty() ? "" : "?cursor=" + encode(cursor)), stop, private_request); + if (!data.contains(field) || !data[field].is_array() || data[field].size() > 1000) + throw Error("Invalid catalogue page."); + for (auto& item : data[field]) { + auto id = text(item, "id", 160); + if (!identifier(id)) + throw Error("Invalid catalogue record."); + if (ids.insert(id).second) { + total_bytes += item.dump().size(); + if (total_bytes > MaxJson - 1024) + throw Error("The library response exceeds its safe cache size."); + all.push_back(item); + } + } + cursor = text(data, "nextCursor", 2048); + if (cursor.empty()) + return all; + if (!cursors.insert(cursor).second || all.size() > 10000) + throw Error("The catalogue is too large or has invalid pagination."); + } + throw Error("The catalogue is too large."); + } + Json private_pages(const std::string& path, std::initializer_list fields, + const std::string& credential, unsigned epoch, std::stop_token stop) { + Json result = Json::object(); + for (const char* field : fields) + result[field] = Json::array(); + std::map> ids; + std::set cursors; + std::string cursor; + std::size_t bytes = 0; + for (unsigned page = 0; page < 100; ++page) { + { + std::lock_guard lock(mutex); + if (epoch != account) + throw Error("The signed-in account changed."); + } + Json data; + try { + data = document({std::string(Api) + path + (cursor.empty() ? "" : "?cursor=" + encode(cursor)), + "GET", + {}, + credential}, + stop); + } catch (const Error& error) { + if (error.status == 401) + expire(credential); + throw; + } + if (data.contains("user")) + result["user"] = data["user"]; + for (const char* field : fields) { + if (!data.contains(field) || !data[field].is_array()) + throw Error("Invalid account page."); + for (const auto& row : data[field]) { + const auto id = text(row, "id", 160); + if (!identifier(id)) + throw Error("Invalid account record."); + if (ids[field].insert(id).second) { + bytes += row.dump().size(); + if (bytes > MaxJson) + throw Error("The account response exceeds its safe size."); + result[field].push_back(row); + } + } + } + cursor = text(data, "nextCursor", 2048); + if (cursor.empty()) + return result; + if (!cursors.insert(cursor).second) + throw Error("Invalid account pagination."); + } + throw Error("The account has too many pages to load safely."); + } + void refresh(std::stop_token stop) { + update([](Snapshot& s) { + s.loading = true; + s.error.clear(); + }); + auto items = paged("/v1/catalogue", "items", stop); + for (const auto& row : items) { + if (!identifier(text(row, "id", 160)) || text(row, "title", 200).empty()) + throw Error("Invalid mod listing."); + } + write_json(root / "catalogue.json", {{"schema", 1}, {"items", items}}); + update([&](Snapshot& s) { + s.items = std::make_shared(std::move(items)); + s.cached = false; + s.loading = false; + s.status = "Library up to date."; + }); + refresh_library_names(root.parent_path(), *published.load()->items, stop); + private_refresh(stop); + } + void private_refresh(std::stop_token stop) { + unsigned epoch; + std::string credential; + { + std::lock_guard lock(mutex); + epoch = account; + credential = token; + } + if (credential.empty()) { + restore(stop); + return; + } + auto me = private_pages("/v1/me", {"requests", "reviews"}, credential, epoch, stop); + auto user = me.value("user", Json{}); + if (text(user, "status", 16) != "active") + throw Error("This account is unavailable.", 401); + Json staff_data = Json::object(), admin_data = Json::object(); + if (staff(user)) + staff_data = private_pages("/v1/staff", {"requests", "reports", "reviews"}, credential, epoch, stop); + if (admin(user)) + admin_data = private_pages("/v1/admin", {"accounts", "drafts", "audit", "items"}, credential, epoch, stop); + else if (staff(user)) + admin_data = + private_pages("/v1/catalogue-tools", {"accounts", "drafts", "audit", "items"}, credential, epoch, stop); + std::lock_guard lock(mutex); + if (epoch != account) + return; + if (text(state.user, "role", 16) != text(user, "role", 16)) { + stop_upload = true; + upload_proofs.clear(); + state.uploads = Json::array(); + state.assets = Json::array(); + state.upload = Json::object(); + state.uploading = false; + } + state.user = std::move(user); + state.personal = std::move(me); + state.staff = std::move(staff_data); + state.admin = std::move(admin_data); + publish(); + } + void restore(std::stop_token stop) { + unsigned epoch; + std::string saved; + { + std::lock_guard lock(mutex); + epoch = account; + saved = device; + } + if (saved.empty()) + return; + auto response = post("/auth/launcher/refresh", {{"device", saved}}, stop, false); + const auto session = text(response, "token", 43); + const auto user = response.value("user", Json{}); + if (session.size() != 43 || !identifier(session) || text(user, "status", 16) != "active") + return; + { + std::lock_guard lock(mutex); + if (epoch != account) + return; + token = session; + state.user = user; + publish(); + } + private_refresh(stop); + } + void run(std::stop_token stop) { + for (;;) { + Work task; + { + std::unique_lock lock(mutex); + cv.wait(lock, stop, [&] { return !work.empty(); }); + if (stop.stop_requested()) + return; + task = std::move(work.front()); + work.pop_front(); + } + try { + task(stop); + } catch (const Error& error) { + fail(error); + } catch (const std::exception& error) { + fail(error); + } + } + } + void load_image(const std::string& url, std::stop_token stop) { + const auto key = dkr::updates::hash(std::span(reinterpret_cast(url.data()), url.size())); + const auto path = root / "images" / key; + std::string bytes; + { + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + if (!ec && size <= MaxImage) + bytes = dkr::updates::read_text(path, MaxImage); + } + if (bytes.empty()) { + auto r = http({url, "GET", {}, {}, "application/json", {}, 0, MaxImage}, stop); + if (r.status != 200 || + (!prefix(r.type, "image/png") && !prefix(r.type, "image/jpeg") && !prefix(r.type, "image/webp"))) + throw Error("Cover image unavailable."); + bytes = std::move(r.body); + std::filesystem::create_directories(path.parent_path()); + dkr::updates::durable_write(path, bytes); + } + int width = 0, height = 0, channels = 0; + if (!stbi_info_from_memory(reinterpret_cast(bytes.data()), int(bytes.size()), &width, &height, + &channels) || + width < 1 || height < 1 || width > 4096 || height > 4096 || + std::uint64_t(width) * height > 16 * 1024 * 1024) + throw Error("Cover image exceeds its safe size."); + auto* pixels = stbi_load_from_memory(reinterpret_cast(bytes.data()), int(bytes.size()), &width, + &height, &channels, 4); + if (!pixels) + throw Error("Cover image could not be decoded."); + std::unique_ptr memory(pixels, stbi_image_free); + auto image = std::make_shared(); + const double scale = std::min(1.0, 512.0 / double(std::max(width, height))); + image->width = std::max(1U, unsigned(width * scale)); + image->height = std::max(1U, unsigned(height * scale)); + image->rgba.resize(std::size_t(image->width) * image->height * 4); + for (unsigned y = 0; y < image->height; ++y) + for (unsigned x = 0; x < image->width; ++x) + for (unsigned c = 0; c < 4; ++c) + image->rgba[(std::size_t(y) * image->width + x) * 4 + c] = + pixels[(std::size_t(y * unsigned(height) / image->height) * width + + x * unsigned(width) / image->width) * + 4 + + c]; + std::lock_guard lock(mutex); + if (images.size() >= 32) + images.erase(images.begin()); + images[url] = std::move(image); + } + void image_run(std::stop_token stop) { + for (;;) { + std::string url; + { + std::unique_lock lock(mutex); + cv.wait(lock, stop, [&] { return !image_queue.empty(); }); + if (stop.stop_requested()) + return; + url = std::move(image_queue.front()); + image_queue.pop_front(); + } + try { + load_image(url, stop); + } catch (...) { /* A missing cover never blocks the catalogue. */ + std::lock_guard lock(mutex); + image_retry[url] = Clock::now() + std::chrono::minutes(5); + } + std::lock_guard lock(mutex); + image_pending.erase(url); + } + } + void download_run(std::stop_token stop) { + for (;;) { + Download job; + { + std::unique_lock lock(mutex); + cv.wait(lock, stop, [&] { + return !gameplay && std::any_of(state.downloads.begin(), state.downloads.end(), + [](const Download& d) { return d.state == "Queued"; }); + }); + if (stop.stop_requested()) + return; + auto it = std::find_if(state.downloads.begin(), state.downloads.end(), + [](const Download& d) { return d.state == "Queued"; }); + job = *it; + it->state = "Downloading"; + download_stop = std::stop_source{}; + publish(); + } + try { + persist_downloads(); + } catch (const std::exception& error) { + fail(error); + } + auto cancelled = download_stop.get_token(); + std::stop_callback retire(stop, [&] { download_stop.request_stop(); }); + auto change = [&](const std::function& fn) { + update([&](Snapshot& s) { + for (auto& d : s.downloads) + if (d.key == job.key) + fn(d); + }); + }; + std::string source_url; + try { + auto integrity = get("/v1/launcher/releases/" + job.release, cancelled); + job.digest = text(integrity, "sha256", 64); + if (!hash_string(job.digest) || !integrity.contains("bytes") || + !integrity["bytes"].is_number_unsigned()) + throw Error("This release is not ready for verified installation."); + job.total = integrity["bytes"].get(); + const auto url = text(integrity, "url", 2048), name = text(integrity, "fileName", 180); + source_url = url; + if (job.total < 1 || job.total > MaxFile || !trusted_url(url) || name.empty() || + name.find_first_of("\\/:*?\"<>|") != name.npos || name == "." || name == "..") + throw Error("The release has invalid file details."); + const auto extension = lower(std::filesystem::path(name).extension().string()); + const std::set formats = {".zip", ".rtz", ".dkrmap", ".xdelta", ".nrm", + ".dkrcode", ".bps", ".ips", ".7z", ".dkrkart", ".dkrhover", ".dkrplane"}; + if (!formats.contains(extension)) + throw Error("This package format is not supported."); + const auto folder = root / "downloads" / job.digest; + std::filesystem::create_directories(folder); + // Legacy and texture importers use the source basename. Give + // them the listing title, not a transport/cache placeholder. + job.file = folder / std::filesystem::u8path(package_filename(job.title, name)); + auto partial = folder / "download.part"; + auto marker = folder / "download.json"; + std::error_code ec; + if (std::filesystem::is_symlink(folder) || std::filesystem::is_symlink(job.file) || + std::filesystem::is_symlink(partial)) + throw Error("The download location is unsafe."); + if (recover_cached_package(folder, job.file, job.digest, job.total, cancelled)) { + if (!package_signature(job.file)) + throw Error("The cached file is not a valid mod package."); + change([&](Download& d) { + d.file = job.file; + d.digest = job.digest; + d.total = d.received = job.total; + d.state = "Ready to install"; + }); + continue; + } + auto meta = read_json(marker); + std::uint64_t offset = std::filesystem::file_size(partial, ec); + if (ec) + offset = 0; + std::string etag = text(meta, "etag", 512); + if (text(meta, "sha256", 64) != job.digest || text(meta, "url", 2048) != url || offset > job.total) { + offset = 0; + etag.clear(); + } + if (offset == job.total) { + if (dkr::updates::hash_file(partial, MaxFile) == job.digest) { + dkr::updates::replace_file(partial, job.file); + if (!package_signature(job.file)) + throw Error("The saved file is not a valid mod package."); + change([&](Download& d) { + d.file = job.file; + d.digest = job.digest; + d.total = d.received = job.total; + d.state = "Ready to install"; + d.error.clear(); + }); + continue; + } + offset = 0; + etag.clear(); + write_json(marker, Json::object()); + } + const auto space = std::filesystem::space(folder, ec); + if (ec || space.available < job.total - offset + 16 * 1024 * 1024) + throw Error("There is not enough free space for this download."); + auto write_progress = Clock::now(); + bool opened = false; + std::ofstream file; + std::uint64_t received = offset; + auto response = http( + {url, "GET", {}, {}, "application/octet-stream", etag, offset, job.total, 15000}, cancelled, + [&](const char* bytes, std::size_t count, const Response& headers) { + if (!opened) { + // Some hosts serve patches as text/plain. Inspect the + // bytes, then require full size, checksum and format; + // a MIME label alone is not a package signature. + if (webpage(bytes, count)) + throw download_page_error(headers, bytes, count); + if (offset) { + const auto expected = "bytes " + std::to_string(offset) + "-"; + if (headers.status != 206 || !prefix(headers.range, expected) || + (!etag.empty() && headers.etag != etag)) { + write_json(marker, Json::object()); + throw Error("This server could not resume safely. Retry to restart the download."); + } + } else if (headers.status != 200) + throw Error("The download server returned an invalid response."); + file.open(partial, std::ios::binary | (offset ? std::ios::app : std::ios::trunc)); + if (!file) + throw Error("The download could not be saved."); + write_json(marker, {{"sha256", job.digest}, {"url", url}, {"etag", headers.etag}}); + opened = true; + } + if (count > job.total - received) + throw Error("The package is larger than its published size."); + file.write(bytes, std::streamsize(count)); + if (!file) + throw Error("The download could not be saved."); + received += count; + if (Clock::now() - write_progress > std::chrono::milliseconds(100)) { + change([&](Download& d) { + d.received = received; + d.total = job.total; + }); + write_progress = Clock::now(); + } + }); + file.close(); + if (response.status != 200 && response.status != 206) + throw Error("The file is unavailable on the download server.", response.status); + if (received != job.total) + throw Error("The download ended early. Retry to continue."); + change([](Download& d) { d.state = "Verifying"; }); + if (dkr::updates::hash_file(partial, MaxFile) != job.digest) { + write_json(marker, Json::object()); + throw Error("The downloaded file did not match the published checksum. It was not installed."); + } + dkr::updates::replace_file(partial, job.file); + if (!package_signature(job.file)) + throw Error("The file contents do not match its package format."); + change([&](Download& d) { + d.state = "Ready to install"; + d.file = job.file; + d.digest = job.digest; + d.received = d.total = job.total; + d.error.clear(); + }); + } catch (const std::exception& error) { + if (!cancelled.stop_requested()) + download_report(job.release, job.title, error, source_url); + change([&](Download& d) { + d.state = cancelled.stop_requested() ? (gameplay ? "Paused for gameplay" : "Paused") : "Failed"; + d.error = cancelled.stop_requested() && gameplay ? "Downloads resume when you return to the launcher." : error.what(); + }); + } + } + } +}; + +Service::Service() : impl_(std::make_unique()) { +#include "account_bind.inl" +} +Service::~Service() { + dkr::online::shutdown_account(); + auto& p = *impl_; + p.login_stop.request_stop(); + p.download_stop.request_stop(); + p.stop_upload = true; + p.worker.request_stop(); + p.image_worker.request_stop(); + p.download_worker.request_stop(); + p.auth_worker.request_stop(); + p.cv.notify_all(); +} +Service& service() { + static Service value; + return value; +} +std::string download_key(const std::string& item, const std::string& release, const std::string& instance) { + return item + "_" + release + (instance.empty() ? "" : "_" + instance); +} +void Service::gameplay(bool active) { + auto& p=*impl_; + { + std::lock_guard lock(p.mutex);p.gameplay=active; + if(active)p.download_stop.request_stop(); + else for(auto& d:p.state.downloads)if(d.state=="Paused for gameplay") {d.state="Queued";d.error.clear();} + p.publish(); + } + p.cv.notify_all(); +} +void Service::configure(const std::filesystem::path& config, std::string version) { + auto& p = *impl_; + if (p.configured) + return; + p.configured = true; + p.root = config / "mod-store"; + p.version = std::move(version); + p.worker = std::jthread([&p](std::stop_token stop) { p.run(stop); }); + p.image_worker = std::jthread([&p](std::stop_token stop) { p.image_run(stop); }); + p.download_worker = std::jthread([&p](std::stop_token stop) { p.download_run(stop); }); + p.queue([&p](std::stop_token stop) { + const auto preferences = read_json(p.root / "preferences.json"), cached = read_json(p.root / "catalogue.json"); + const auto saved = read_json(p.root / "downloads.json"); + p.update([&](Snapshot& s) { + if (preferences.value("layout", 0) == 1) + s.layout = 1; + s.remember = preferences.contains("remember") && preferences["remember"] == true; +#ifndef _WIN32 + if(!dkr::online::keyring::available())s.remember = false; +#endif + if (cached.contains("items") && cached["items"].is_array()) { + s.items = std::make_shared(cached["items"]); + s.cached = true; + } + if (saved.contains("jobs") && saved["jobs"].is_array() && saved["jobs"].size() <= 64) + for (const auto& row : saved["jobs"]) { + Download job; + job.item = text(row, "item", 160); + job.release = text(row, "release", 160); + job.title = text(row, "title", 200); + job.category = text(row, "category", 32); + job.version = text(row, "version", 80); + job.intent = text(row, "intent", 32); + job.instance = text(row, "instance", 512); + if (!identifier(job.item) || !identifier(job.release)) + continue; + job.key = download_key(job.item, job.release, job.instance); + job.state = "Paused"; + job.error = "Resume to check or reuse the saved download."; + s.downloads.push_back(std::move(job)); + } + }); + refresh_library_names(p.root.parent_path(), *p.published.load()->items, stop); + if (p.published.load()->remember) { + const auto device = load_device(p.root); + { + std::lock_guard lock(p.mutex); + p.device = device; + } + if (!device.empty()) + p.restore(stop); + } + }); + p.queue([&p](std::stop_token stop) { p.refresh(stop); }); +} +std::shared_ptr Service::view() const { + return impl_->published.load(); +} +void Service::layout(int value) { + auto& p = *impl_; + value = value == 1 ? 1 : 0; + p.update([&](Snapshot& s) { s.layout = value; }); + p.queue([&p, value](std::stop_token) { p.preferences(); }); +} +void Service::remember(bool remember) { +#ifndef _WIN32 + if(!dkr::online::keyring::available())remember = false; +#endif + auto& p = *impl_; + p.update([&](Snapshot& s) { s.remember = remember; }); + p.queue([&p](std::stop_token) { p.preferences(); }); +} +void Service::refresh() { + auto& p = *impl_; + p.queue([&p](std::stop_token stop) { p.refresh(stop); }); +} +bool Service::refresh_names() { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (!p.configured || p.names_queued || p.work.size() >= 32) + return false; + p.names_queued = true; + } + if (!p.queue([&p](std::stop_token stop) { + try { + refresh_library_names(p.root.parent_path(), *p.published.load()->items, stop); + } catch (...) { /* Optional label repair must never block later jobs. */ + } + std::lock_guard lock(p.mutex); + p.names_queued = false; + })) { + std::lock_guard lock(p.mutex); + p.names_queued = false; + return false; + } + return true; +} +void Service::reviews(std::string item) { + if (!identifier(item)) + return; + auto& p = *impl_; + p.queue([&p, item](std::stop_token stop) { + auto rows = p.paged("/v1/mods/" + item + "/reviews", "reviews", stop); + p.update([&](Snapshot& s) { + if (s.reviews.size() >= 16 && !s.reviews.contains(item)) + s.reviews.erase(s.reviews.begin()); + s.reviews[item] = std::move(rows); + }); + }); +} +void Service::action(std::string name, Json input) { + static const std::set allowed = { + "review.save", "review.report", "report.resolve", "request.submit", + "request.resubmit", "request.start", "request.note", "request.decide", + "request.publish", "catalogue.save", "catalogue.publish", "catalogue.archive", + "catalogue.update", "admin.role", "account.profile", "account.delete"}; + if (!allowed.contains(name)) + return; + auto& p = *impl_; + unsigned epoch; + std::string credential; + { + std::lock_guard lock(p.mutex); + if (p.state.loading || p.state.writing) { + p.state.error = "Wait for the current library request to finish."; + p.publish(); + return; + } + epoch = p.account; + credential = p.token; + p.state.loading = true; + p.state.writing = true; + p.publish(); + } + const bool queued = p.queue([&p, name = std::move(name), input = std::move(input), epoch, + credential](std::stop_token stop) { + struct WritingFinish { + Impl& p; + unsigned epoch; + ~WritingFinish() { + try { + p.update([&](Snapshot& s) { + if (p.account == epoch) + s.loading = s.writing = false; + }); + } catch (...) { + } + } + } writing_finish{p, epoch}; + { + std::lock_guard lock(p.mutex); + if (epoch != p.account) + throw Error("The signed-in account changed. Reopen this form."); + } + if (credential.empty()) + throw Error("Sign in with Discord to continue."); + Json response; + try { + response = document( + {std::string(Api) + "/v1/actions/" + name, "POST", Json{{"input", input}}.dump(), credential}, stop); + } catch (const Error& error) { + if (error.status == 401) + p.expire(credential); + throw; + } + { + std::lock_guard lock(p.mutex); + if (epoch != p.account) + return; + } + p.update([&](Snapshot& s) { + s.status = "Your change was saved."; + s.error.clear(); + s.last_action = name; + s.last_result = response.value("result", Json{}); + ++s.mutation; + }); + if (name == "account.delete") { + p.expire(credential); + std::error_code ignored; + std::filesystem::remove(p.root / "device.bin", ignored); + std::lock_guard lock(p.mutex); + p.device.clear(); + p.state.loading = p.state.writing = false; + p.state.status = "Library account deleted. Installed mods are unchanged."; + p.publish(); + return; + } + std::string verification_warning; + if ((name == "catalogue.publish" || name == "request.publish") && response.contains("result") && + response["result"].is_object()) { + const auto& listing = response["result"]; + if (listing.contains("releases") && listing["releases"].is_array()) + for (const auto& release : listing["releases"]) { + const auto url = text(release, "fileUrl", 2048), id = text(release, "id", 160); + Json proof; + { + std::lock_guard lock(p.mutex); + if (p.account != epoch) + return; + auto found = p.upload_proofs.find(url); + if (found != p.upload_proofs.end()) + proof = found->second; + } + if (proof.is_object() && identifier(id)) { + if (text(release, "sha256", 64) == text(proof, "sha256", 64) && + release.value("size", 0ULL) == proof.value("bytes", 0ULL)) + break; + proof["expectedRevision"] = listing.value("revision", 0); + try { + document( + {std::string(Api) + "/v1/launcher/releases/" + id, "POST", proof.dump(), credential}, + stop); + } catch (...) { + verification_warning = + "The listing was published, but its download verification needs to be " + "registered. Do not publish again; use Verify current release."; + } + break; + } + } + } + try { + p.refresh(stop); + p.update([&](Snapshot& s) { + s.writing = false; + if (!verification_warning.empty()) + s.error = verification_warning; + }); + } catch (...) { + p.update([](Snapshot& s) { + s.loading = false; + s.writing = false; + s.error = "Your change was saved, but the view could not refresh. Refresh the library; do not submit " + "it again."; + }); + } + }); + if (!queued) + p.update([](Snapshot& s) { + s.writing = false; + s.loading = false; + }); +} +void Service::login() { + auto& p = *impl_; + if (p.auth_worker.joinable()) { + if (view()->signing_in) + return; + p.auth_worker.join(); + } + p.login_stop = std::stop_source{}; + p.update([](Snapshot& s) { + s.signing_in = true; + s.error.clear(); + s.status = "Opening Discord in your browser..."; + }); + p.auth_worker = std::jthread([&p](std::stop_token lifetime) { + std::stop_callback retire(lifetime, [&] { p.login_stop.request_stop(); }); + const auto stop = p.login_stop.get_token(); + try { + const auto verifier = nonce(), state = "dkrnative_" + nonce(); + auto flow = p.post("/auth/launcher/start", {{"challenge", challenge(verifier)}, {"clientState", state}}, + stop, false); + const auto id = text(flow, "flow", 43), url = text(flow, "browserUrl", 2048); + if (!identifier(id) || id.size() != 43 || !trusted_url(url, true) || + !prefix(url, std::string(Api) + "/auth/launcher/authorize?")) + throw Error("Invalid Discord sign-in address."); + { + std::lock_guard lock(p.mutex); + p.browser = url; + p.state.status = "Check sign-in code " + text(flow, "verificationCode", 8) + " in your browser."; + p.publish(); + } + bool remember; + { + std::lock_guard lock(p.mutex); + remember = p.state.remember; + } + Json proof = {{"flow", id}, {"verifier", verifier}, {"clientState", state}, {"remember", remember}}; + const auto end = Clock::now() + std::chrono::minutes(9); + while (!stop.stop_requested() && Clock::now() < end) { + std::mutex wait_mutex; + std::condition_variable_any wait_cv; + std::unique_lock wait_lock(wait_mutex); + wait_cv.wait_for(wait_lock, stop, std::chrono::seconds(4), [] { return false; }); + if (stop.stop_requested()) + break; + auto result = p.post("/auth/launcher/poll", proof, stop, false); + if (result.value("pending", false)) + continue; + const auto token = text(result, "token", 43); + if (token.size() != 43 || !identifier(token) || + text(result.value("user", Json{}), "status", 16) != "active") + throw Error("Discord sign-in returned an invalid session."); + const auto device = text(result, "device", 43); + if (stop.stop_requested()) { + try { + document({std::string(Api) + "/auth/launcher/logout", "POST", Json{{"device", device}}.dump(), + token}, + std::stop_token{}); + } catch (...) { + } + throw Error("Sign-in was cancelled."); + } + std::string remember_warning; + if (remember && device.size() == 43 && identifier(device)) + try { + save_device(p.root, device); + } catch (const std::exception& error) { + remember_warning = error.what(); + } + // Account cancellation remains authoritative across keyring prompts. + if(stop.stop_requested()){ + try{Request revoke{std::string(Api)+"/auth/launcher/logout","POST",Json{{"device",device}}.dump(),token}; + revoke.timeout=5000;document(revoke,std::stop_token{});}catch(...){} +#ifdef _WIN32 + std::error_code cleanup;std::filesystem::remove(p.root/"device.bin",cleanup); +#else + dkr::online::keyring::clear(p.root.generic_string()); +#endif + throw Error("Sign-in was cancelled."); + } + { + std::lock_guard lock(p.mutex); + p.token = token; + p.device = device; + ++p.account; + p.upload_proofs.clear(); + p.state.personal = Json::object(); + p.state.staff = Json::object(); + p.state.admin = Json::object(); + p.state.uploads = Json::array(); + p.state.assets = Json::array(); + p.state.upload = Json::object(); + p.state.uploading = false; + p.state.user = result["user"]; + p.state.signing_in = false; + p.state.status = "Signed in with Discord."; + p.state.error = remember_warning; + if(!remember_warning.empty())p.state.remember=false; + p.publish(); + } + p.queue([&p](std::stop_token s) { p.private_refresh(s); }); + return; + } + try { + p.post("/auth/launcher/cancel", proof, std::stop_token{}, false); + } catch (...) { + } + throw Error("Discord sign-in was cancelled or expired."); + } catch (const std::exception& error) { + p.update([&](Snapshot& s) { + s.signing_in = false; + s.error = error.what(); + }); + } + }); +} +void Service::cancel_login() { + impl_->login_stop.request_stop(); +} +void Service::logout() { + auto& p = *impl_; + p.login_stop.request_stop(); + p.stop_upload = true; + std::string token, device; + { + std::lock_guard lock(p.mutex); + token = std::move(p.token); + device = std::move(p.device); + ++p.account; + p.browser.clear(); + p.state.user = nullptr; + p.state.personal = Json::object(); + p.state.staff = Json::object(); + p.state.admin = Json::object(); + p.state.uploads = Json::array(); + p.state.assets = Json::array(); + p.upload_proofs.clear(); + p.state.upload = Json::object(); + p.state.uploading = false; + p.state.loading = p.state.writing = false; + p.state.status = "Signed out."; + p.state.last_action.clear(); + p.state.last_result = nullptr; + p.publish(); + } + p.queue([&p, token, device](std::stop_token stop) { + std::error_code error; + std::filesystem::remove(p.root / "device.bin", error); +#ifndef _WIN32 + dkr::online::keyring::clear(p.root.generic_string()); +#endif + try { + if (!token.empty() || !device.empty()) + document({std::string(Api) + "/auth/launcher/logout", "POST", Json{{"device", device}}.dump(), token}, + stop); + } catch (...) { + } + }); +} +void Service::download(const Json& item, const Json& release, std::string intent, std::string instance) { + if (!hash_string(text(release, "sha256", 64)) && staff(view()->user)) { + verify_release(item, release, true); + return; + } + auto& p = *impl_; + Download d; + d.item = text(item, "id", 160); + d.release = text(release, "id", 160); + d.title = text(item, "title", 200); + d.category = text(item, "category", 32); + d.version = text(release, "version", 80); + d.intent = std::move(intent); + d.instance = std::move(instance); + if (!identifier(d.item) || !identifier(d.release)) + return; + d.key = download_key(d.item, d.release, d.instance); + p.update([&](Snapshot& s) { + auto found = + std::find_if(s.downloads.begin(), s.downloads.end(), [&](const auto& v) { return v.key == d.key; }); + if (found == s.downloads.end()) { + if(s.downloads.size()>=64)std::erase_if(s.downloads,[](const Download& old){return old.state=="Installed";}); + if (s.downloads.size() >= 64) { + s.error = "The download queue is full."; + return; + } + s.downloads.push_back(d); + } else if (found->state == "Failed" || found->state == "Paused" || found->state == "Installed" || + found->state == "Ready to install" || found->state == "Install failed") { + found->state = "Queued"; + found->error.clear(); + found->version = d.version; + found->intent = d.intent; + found->instance = d.instance; + } + }); + p.cv.notify_all(); +} +void Service::verify_release(const Json& item, const Json& release, bool download_after) { + const auto id = text(release, "id", 160), url = text(release, "fileUrl", 2048), + name = text(release, "fileName", 180), title = text(item, "title", 200); + if (!identifier(id) || !trusted_url(url) || !item.contains("revision") || !item["revision"].is_number_integer()) + return; + const auto revision = item["revision"].get(); + auto& p = *impl_; + unsigned epoch; + std::string credential; + const auto key = text(item, "id", 160) + "_" + id; + std::stop_token preparation; + { + std::lock_guard lock(p.mutex); + if (!staff(p.state.user)) + return; + epoch = p.account; + credential = p.token; + if (download_after) { + auto found = std::find_if(p.state.downloads.begin(), p.state.downloads.end(), + [&](const auto& d) { return d.key == key; }); + if (found != p.state.downloads.end() && found->state != "Failed" && found->state != "Paused") + return; + if (found == p.state.downloads.end()) { + if (p.state.downloads.size() >= 64) { + p.state.error = "The download queue is full."; + p.publish(); + return; + } + Download d; + d.key = key; + d.item = text(item, "id", 160); + d.release = id; + d.title = text(item, "title", 200); + d.category = text(item, "category", 32); + d.version = text(release, "version", 80); + p.state.downloads.push_back(std::move(d)); + found = std::prev(p.state.downloads.end()); + } + found->state = "Preparing download"; + found->error.clear(); + p.preparation_stops[key] = std::stop_source{}; + preparation = p.preparation_stops[key].get_token(); + p.publish(); + } + } + const bool queued = p.queue([&p, id, url, name, title, revision, epoch, credential, key, download_after, + preparation](std::stop_token lifetime) { + std::stop_source linked; + std::stop_callback retired(lifetime, [&] { linked.request_stop(); }); + std::stop_callback paused(preparation, [&] { linked.request_stop(); }); + const auto stop = linked.get_token(); + auto finish = [&](const std::filesystem::path& file, const std::string& digest, std::uint64_t bytes) { + p.update([&](Snapshot& s) { + if (download_after) + for (auto& d : s.downloads) + if (d.key == key) { + d.file = file; + d.digest = digest; + d.received = d.total = bytes; + d.state = stop.stop_requested() ? "Paused" : "Queued"; + } + p.preparation_stops.erase(key); + s.status = "Download prepared."; + }); + p.cv.notify_all(); + }; + try { + { + std::lock_guard lock(p.mutex); + if (epoch != p.account) + throw Error("The staff account changed."); + } + // A catalogue snapshot can predate another staff member's preparation. + // Reuse its manifest/cache instead of downloading the same file again. + if (download_after) { + try { + const auto ready = p.get("/v1/launcher/releases/" + id, stop); + if (hash_string(text(ready, "sha256", 64))) { + finish({}, text(ready, "sha256", 64), ready.value("bytes", 0ULL)); + return; + } + } catch (const Error& error) { + if (error.status != 409) + throw; + } + } + const auto directory = p.root / "verification" / id; + std::filesystem::create_directories(directory); + const auto extension = lower(std::filesystem::path(name).extension().string()); + const std::set allowed = {".zip", ".rtz", ".dkrmap", ".xdelta", ".nrm", + ".dkrcode", ".bps", ".ips", ".7z", ".dkrkart", ".dkrhover", ".dkrplane"}; + if (!allowed.contains(extension)) + throw Error("This package format cannot be verified."); + const auto target = directory / std::filesystem::u8path(package_filename(title, name)); + if (std::filesystem::is_symlink(directory) || std::filesystem::is_symlink(target)) + throw Error("Unsafe verification directory."); + std::ofstream output(target, std::ios::binary | std::ios::trunc); + if (!output) + throw Error("The verification file could not be saved."); + std::uint64_t bytes = 0; + std::uint64_t next_space_check = 0; + auto last = Clock::now(); + const auto result = http({url, "GET", {}, {}, "application/octet-stream", {}, 0, MaxFile, 15000}, stop, + [&](const char* data, std::size_t length, const Response& response) { + if (response.status != 200) + throw Error("This mod's download file is unavailable.", response.status); + if (!bytes && webpage(data, length)) + throw download_page_error(response, data, length); + if (response.length > MaxFile) + throw Error("This mod package exceeds the download size limit."); + if (bytes >= next_space_check) { + std::error_code error; + const auto space = std::filesystem::space(directory, error); + if (error || space.available < 16ULL * 1024 * 1024 + length) + throw Error("Not enough free space to verify this release."); + next_space_check = bytes + 8ULL * 1024 * 1024; + } + output.write(data, std::streamsize(length)); + if (!output) + throw Error("The verification file could not be saved."); + bytes += length; + if (Clock::now() - last > std::chrono::milliseconds(250)) { + p.update([&](Snapshot& s) { + for (auto& d : s.downloads) + if (download_after && d.key == key) { + d.received = bytes; + d.total = response.length; + } + s.status = + "Checking release file: " + std::to_string(bytes / 1024) + " KiB"; + }); + last = Clock::now(); + } + }); + output.close(); + if (result.status != 200 || !bytes || !package_signature(target)) + throw Error("The hosted package could not be verified."); + const auto digest = dkr::updates::hash_file(target, MaxFile); + { + std::lock_guard lock(p.mutex); + if (epoch != p.account) + throw Error("The staff account changed. Verification was not published."); + } + document({std::string(Api) + "/v1/launcher/releases/" + id, "POST", + Json{{"sha256", digest}, {"bytes", bytes}, {"expectedRevision", revision}}.dump(), credential}, + stop); + const auto cache = p.root / "downloads" / digest; + std::filesystem::create_directories(cache); + const auto saved = cache / std::filesystem::u8path(package_filename(title, name)); + if (!std::filesystem::is_symlink(cache) && !std::filesystem::exists(saved)) + dkr::updates::replace_file(target, saved); + if (download_after) { + finish(saved, digest, bytes); + p.queue([&p](std::stop_token refresh_stop) { p.refresh(refresh_stop); }); + return; + } + p.update([](Snapshot& s) { + s.status = "Release checksum published. This release can now be installed from the store."; + }); + p.refresh(stop); + } catch (const std::exception& error) { + if (!stop.stop_requested()) + p.download_report(id, title, error, url); + p.update([&](Snapshot& s) { + p.preparation_stops.erase(key); + for (auto& d : s.downloads) + if (download_after && d.key == key) { + d.state = stop.stop_requested() ? "Paused" : "Failed"; + d.error = error.what(); + } + }); + if (download_after) + return; // Transfer failures belong to this mod, not every store tab. + throw; + } + }); + if (!queued && download_after) + p.update([&](Snapshot& s) { + p.preparation_stops.erase(key); + for (auto& d : s.downloads) + if (d.key == key) { + d.state = "Failed"; + d.error = "Retry when the library is no longer busy."; + } + }); +} +void Service::pause_download(std::string key) { + auto& p = *impl_; + p.update([&](Snapshot& s) { + for (auto& d : s.downloads) + if (d.key == key) { + if (d.state == "Preparing download") { + const auto found = p.preparation_stops.find(key); + if (found != p.preparation_stops.end()) + found->second.request_stop(); + } + if (d.state == "Downloading") + p.download_stop.request_stop(); + else if (d.state == "Queued") + d.state = "Paused"; + } + }); +} +void Service::resume_download(std::string key) { + const auto snapshot = view(); + for (const auto& job : snapshot->downloads) + if (job.key == key && (job.state == "Paused" || job.state == "Failed")) + for (const auto& item : *snapshot->items) + if (text(item, "id", 160) == job.item && item.contains("releases") && item["releases"].is_array()) + for (const auto& release : item["releases"]) + if (text(release, "id", 160) == job.release) { + download(item, release, job.intent, job.instance); + return; + } + auto& p = *impl_; + p.update([&](Snapshot& s) { + for (auto& d : s.downloads) + if (d.key == key && (d.state == "Paused" || d.state == "Failed")) { + d.state = "Queued"; + d.error.clear(); + } + }); + p.cv.notify_all(); +} +void Service::clear_completed_history() { + auto& p = *impl_; + p.queue([&p](std::stop_token) { + p.update([](Snapshot& s) { + std::erase_if(s.downloads, [](const Download& job) { return job.state == "Installed"; }); + }); + // History only: cache files, receipts, installed packages and saves stay. + p.persist_downloads(); + }); +} +void Service::clear_messages() { + impl_->update([](Snapshot& s) { s.status.clear(); s.error.clear(); }); +} +bool Service::begin_install(std::string key) { + auto& p = *impl_; + bool result = false; + p.update([&](Snapshot& s) { + for (auto& d : s.downloads) + if (d.key == key && (d.state == "Ready to install" || d.state == "Install failed")) { + d.state = "Installing"; + result = true; + } + }); + return result; +} +void Service::installed(std::string key, bool success, std::string message) { + impl_->update([&](Snapshot& s) { + for (auto& d : s.downloads) + if (d.key == key) { + const bool cancelled=message.starts_with("Installation cancelled."); + d.state = success ? "Installed" : cancelled ? "Ready to install" : "Install failed"; + d.error = success || cancelled ? "" : message; + } + s.status = std::move(message); + }); + impl_->queue([p = impl_.get()](std::stop_token stop) { + p->persist_downloads(); + refresh_library_names(p->root.parent_path(), *p->published.load()->items, stop); + }); +} +void Service::request_image(std::string url) { + auto& p = *impl_; + if (!trusted_url(url)) + return; + std::lock_guard lock(p.mutex); + if (p.images.contains(url) || p.image_pending.contains(url) || p.image_queue.size() >= 16 || + (p.image_retry.contains(url) && p.image_retry[url] > Clock::now())) + return; + p.image_pending.insert(url); + p.image_queue.push_back(std::move(url)); + p.cv.notify_all(); +} +std::shared_ptr Service::image(const std::string& url) const { + std::lock_guard lock(impl_->mutex); + auto it = impl_->images.find(url); + return it == impl_->images.end() ? nullptr : it->second; +} +std::string Service::take_browser_url() { + std::lock_guard lock(impl_->mutex); + return std::exchange(impl_->browser, {}); +} +void Service::load_staff_files() { + auto& p = *impl_; + unsigned epoch; + { + std::lock_guard lock(p.mutex); + epoch = p.account; + } + p.queue([&p, epoch](std::stop_token stop) { + auto uploads = p.get("/v1/uploads", stop, true), assets = p.get("/v1/assets", stop, true); + std::lock_guard lock(p.mutex); + if (epoch != p.account) + return; + p.state.uploads = uploads.value("uploads", Json::array()); + p.state.assets = assets.value("files", Json::array()); + p.publish(); + }); +} +void Service::pause_upload() { + impl_->stop_upload = true; +} +void Service::cancel_upload(std::string id) { + if (!identifier(id)) + return; + auto& p = *impl_; + p.stop_upload = true; + p.queue([&p, id](std::stop_token stop) { + p.post("/v1/uploads/" + id + "/cancel", Json::object(), stop); + p.update([](Snapshot& s) { s.status = "Upload cancellation requested."; }); + }); +} +void Service::upload(std::filesystem::path source, std::string kind, std::string category, std::string resume_id) { + auto& p = *impl_; + if (!resume_id.empty() && !identifier(resume_id)) + return; + p.stop_upload = false; + unsigned epoch; + std::string credential; + { + std::lock_guard lock(p.mutex); + if (p.state.uploading || !staff(p.state.user)) + return; + p.state.uploading = true; + p.publish(); + epoch = p.account; + credential = p.token; + } + const bool queued = p.queue([&p, source = std::move(source), kind = std::move(kind), category = std::move(category), + resume_id, epoch, credential](std::stop_token stop) mutable { + struct Finish { + Impl& p; + unsigned epoch; + ~Finish() { + try { + p.update([&](Snapshot& s) { + if (p.account == epoch) + s.uploading = false; + }); + } catch (...) { + } + } + } finish{p, epoch}; + auto current = [&] { + std::lock_guard lock(p.mutex); + if (epoch != p.account || !staff(p.state.user)) + throw Error("The signed-in staff account changed. Reopen the uploader."); + }; + current(); + auto call = [&](const std::string& path, const Json* body = nullptr) { + current(); + Json result; + try { + result = document( + {std::string(Api) + path, body ? "POST" : "GET", body ? body->dump() : "", credential}, stop); + } catch (const Error& error) { + if (error.status == 401) + p.expire(credential); + throw; + } + current(); + return result; + }; + if(kind=="download" && vehicle_store_extension(lower(source.extension().string()))) { + if(!vehicle_store_category(category)) throw Error("Choose Vehicles before uploading a vehicle export."); + source=prepare_vehicle_store_upload(source,p.root/"vehicle-uploads",stop); + } + const auto size = std::filesystem::file_size(source); + if (!size || size > (kind == "image" ? 20ULL * 1024 * 1024 : MaxFile)) + throw Error("This file exceeds the upload size limit."); + const auto digest = dkr::updates::hash_file(source, MaxFile); + const auto fingerprint = upload_fingerprint(source, size); + auto settings = call("/v1/uploads/settings"); + if (!settings.value("enabled", false) || !settings.value("configured", false)) + throw Error("Staff uploads are not configured on the website."); + std::string id = resume_id; + Json job; + if (id.empty()) { + const auto random = nonce(); + const auto client = + dkr::updates::hash(std::span(reinterpret_cast(random.data()), random.size())) + .substr(0, 32); + const Json creation = {{"fileName", source.filename().string()}, + {"size", size}, + {"kind", kind}, + {"category", category}, + {"fingerprint", fingerprint}, + {"clientId", client}}; + job = call("/v1/uploads", &creation).at("upload"); + id = text(job, "id", 160); + } else + job = call("/v1/uploads/" + id).at("upload"); + if (!identifier(id) || job.value("size", 0ULL) != size || text(job, "fingerprint", 64) != fingerprint || + text(job, "kind", 32) != kind || text(job, "category", 32) != category) + throw Error("Choose the original unchanged file to resume this upload."); + std::ifstream input(source, std::ios::binary); + if (!input) + throw Error("The upload file could not open."); + auto offset = job.value("offset", 0ULL); + const auto chunk = job.value("chunkBytes", 0U); + if (offset > size || !chunk || chunk > Chunk) + throw Error("Invalid upload progress from the server."); + std::string buffer(chunk, '\0'); + if (offset) { + auto saved = call("/v1/uploads/" + id); + const auto parts = saved.value("resumeChunks", Json::array()); + if (!parts.is_array() || parts.size() > 512) + throw Error("Invalid saved upload verification."); + std::uint64_t end = 0; + for (const auto& part : parts) { + const std::uint64_t at = part.value("offset", MaxFile); + const std::uint64_t length = part.value("size", 0ULL); + if (at != end || !length || length != std::min(chunk, size - end) || + length > offset - end) + throw Error("Invalid saved upload verification."); + input.read(buffer.data(), std::streamsize(length)); + if (std::uint64_t(input.gcount()) != length || + dkr::updates::hash(std::span(reinterpret_cast(buffer.data()), + std::size_t(length))) != text(part, "sha256", 64)) + throw Error("The selected file differs from the saved upload."); + end += length; + } + if (end != offset) + throw Error("The saved upload prefix could not be verified."); + } + while (offset < size && !stop.stop_requested() && !p.stop_upload) { + current(); + const auto bytes = std::size_t(std::min(chunk, size - offset)); + input.seekg(std::streamoff(offset)); + input.read(buffer.data(), std::streamsize(bytes)); + if (std::size_t(input.gcount()) != bytes) + throw Error("The selected upload file changed."); + auto response = + document({std::string(Api) + "/v1/uploads/" + id + "/chunk?offset=" + std::to_string(offset), + "POST", + buffer.substr(0, bytes), + credential, + "application/octet-stream", + {}, + 0, + MaxJson, + 120000}, + stop); + current(); + job = response.at("upload"); + const auto next = job.value("offset", 0ULL); + if (next != offset + bytes) + throw Error("The server did not confirm this upload chunk. Resume to check progress."); + offset = next; + p.update([&](Snapshot& s) { + if (p.account != epoch) + return; + s.upload = job; + s.status = "Uploading " + source.filename().string(); + }); + } + if (offset == size) { + if (dkr::updates::hash_file(source, MaxFile) != digest) + throw Error("The selected file changed during upload."); + const auto empty = Json::object(); + job = call("/v1/uploads/" + id + "/complete", &empty).at("upload"); + current(); + if (!job.contains("asset") || !job["asset"].is_object() || !trusted_url(text(job["asset"], "url", 2048)) || + job["asset"].value("size", 0ULL) != size) + throw Error("The completed upload has invalid file details."); + job["fullSha256"] = digest; + if (kind == "download") { + const Json proof = {{"sha256", digest}, {"bytes", size}}; + call("/v1/launcher/uploads/" + id + "/integrity", &proof); + } + { + std::lock_guard lock(p.mutex); + if (p.account == epoch && kind == "download") { + if (p.upload_proofs.size() >= 16) + p.upload_proofs.erase(p.upload_proofs.begin()); + p.upload_proofs[text(job["asset"], "url", 2048)] = {{"sha256", digest}, {"bytes", size}}; + } + } + p.update([&](Snapshot& s) { + if (p.account != epoch) + return; + s.upload = job; + s.status = "Upload complete. Review the file before publishing."; + }); + } else + p.update([&](Snapshot& s) { + if (p.account != epoch) + return; + s.upload = job; + s.status = "Upload paused. Select the same file to resume."; + }); + }); + if (!queued) + p.update([](Snapshot& s) { s.uploading = false; }); +} +} // namespace dkr::store diff --git a/runtime-recomp/src/game/mod_store.hpp b/runtime-recomp/src/game/mod_store.hpp new file mode 100644 index 00000000..6505b138 --- /dev/null +++ b/runtime-recomp/src/game/mod_store.hpp @@ -0,0 +1,87 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::store { +using Json = nlohmann::json; +struct Download { + std::string key, item, release, title, category, state = "Queued", error, digest, version, intent, instance; + std::filesystem::path file; + std::uint64_t received = 0, total = 0; +}; +struct Image { + unsigned width = 0, height = 0; + std::vector rgba; +}; +struct Snapshot { + std::shared_ptr items = std::make_shared(Json::array()); + Json user = nullptr, personal = Json::object(), staff = Json::object(), admin = Json::object(); + Json reviews = Json::object(), uploads = Json::array(), assets = Json::array(), upload = Json::object(); + std::vector downloads; + std::string status, error, last_action; + Json last_result; + std::uint64_t generation = 0, mutation = 0; + bool loading = false, signing_in = false, cached = false, remember = false, uploading = false, writing = false; + int layout = 0; +}; +// No network/disk/decoder activity in view(). All mutations are background +// jobs, with immutable UI snapshots and account-generation fencing. +class Service { + public: + Service(); + ~Service(); + Service(const Service&) = delete; + Service& operator=(const Service&) = delete; + void configure(const std::filesystem::path&, std::string version); + std::shared_ptr view() const; + void refresh(); + void gameplay(bool active); + bool refresh_names(); + void layout(int); + void remember(bool); + void reviews(std::string item); + void action(std::string name, Json input); + void login(); + void cancel_login(); + void logout(); + void download(const Json& item, const Json& release, std::string intent = {}, std::string instance = {}); + void verify_release(const Json& item, const Json& release, bool download_after = false); + void pause_download(std::string key); + void resume_download(std::string key); + void clear_completed_history(); + void clear_messages(); + bool begin_install(std::string key); + void installed(std::string key, bool success, std::string message); + void request_image(std::string url); + std::shared_ptr image(const std::string& url) const; + void load_staff_files(); + void upload(std::filesystem::path source, std::string kind, std::string category, std::string resume_id = {}); + void pause_upload(); + void cancel_upload(std::string id); + std::string take_browser_url(); + + private: + struct Impl; + std::unique_ptr impl_; +}; +Service& service(); +std::string text(const Json&, const char* key, std::size_t max = 4096); +bool staff(const Json&); +bool admin(const Json&); +bool trusted_url(const std::string&, bool api = false); +// Presentation only: never rewrite installed artifacts or their online proofs. +std::string package_filename(const std::string& title, const std::string& published); +std::string download_key(const std::string& item, const std::string& release, const std::string& instance = {}); +bool generic_package_name(const std::string&); +std::uint64_t library_names_generation(); +std::string library_display_name(const std::string& digest, const std::string& category, const std::string& fallback); +std::string texture_display_name(const std::filesystem::path&, const std::string& fallback); +void refresh_library_names(const std::filesystem::path& config, const Json& items, std::stop_token); +} // namespace dkr::store diff --git a/runtime-recomp/src/game/mod_store_install.cpp b/runtime-recomp/src/game/mod_store_install.cpp new file mode 100644 index 00000000..acd0114e --- /dev/null +++ b/runtime-recomp/src/game/mod_store_install.cpp @@ -0,0 +1,2273 @@ +#include "store_vehicle_adapter.hpp" +#include "mod_store_install.hpp" +#include "custom_tracks.hpp" +#include "mods/legacy_mod_library.hpp" +#include "mods/magic_code_library.hpp" +#include "runtime_texture_packs.hpp" +#include "updates/update_core.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#include +#include +#include +#endif + +namespace dkr::store { +namespace { +namespace fs = std::filesystem; +using Clock = std::chrono::steady_clock; +constexpr std::uint64_t MaxPackage = 2ULL * 1024 * 1024 * 1024, MaxTree = 8ULL * 1024 * 1024 * 1024; +struct Failure : std::runtime_error { + using std::runtime_error::runtime_error; +}; +struct StoreLease { +#ifdef _WIN32 + HANDLE handle = INVALID_HANDLE_VALUE; +#else + int handle = -1; +#endif + explicit StoreLease(const fs::path& path) { +#ifdef _WIN32 + handle = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (handle == INVALID_HANDLE_VALUE) + throw Failure("Another DKR-R copy is changing this mod library. Finish there, then retry."); + BY_HANDLE_FILE_INFORMATION info{}; + if (!GetFileInformationByHandle(handle, &info) || + (info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)) || + info.nNumberOfLinks != 1) { + CloseHandle(handle); + handle = INVALID_HANDLE_VALUE; + throw Failure("The mod operation lock is unsafe."); + } +#else + handle = open(path.c_str(), O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600); + if (handle < 0 || flock(handle, LOCK_EX | LOCK_NB)) { + if (handle >= 0) + close(handle); + handle = -1; + throw Failure("Another DKR-R copy is changing this mod library. Finish there, then retry."); + } + struct stat info{}; + if (fstat(handle, &info) || !S_ISREG(info.st_mode) || info.st_nlink != 1 || info.st_uid != geteuid()) { + close(handle); + handle = -1; + throw Failure("The mod operation lock is unsafe."); + } +#endif + } + ~StoreLease() { +#ifdef _WIN32 + if (handle != INVALID_HANDLE_VALUE) + CloseHandle(handle); +#else + if (handle >= 0) + close(handle); +#endif + } +}; +void sync_directory(const fs::path& path) { +#ifndef _WIN32 + const int handle = open(path.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + const bool ok = handle >= 0 && fsync(handle) == 0; + if (handle >= 0) + close(handle); + if (!ok) + throw Failure("Could not safely flush mod storage. The installation was not committed."); +#else + (void)path; // File flushes and write-through renames provide the Windows barrier. +#endif +} +void sync_file(const fs::path& path) { +#ifdef _WIN32 + const auto handle = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + const bool ok = handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle); + if (handle != INVALID_HANDLE_VALUE) + CloseHandle(handle); +#else + const int handle = open(path.c_str(), O_WRONLY | O_NOFOLLOW | O_CLOEXEC); + const bool ok = handle >= 0 && fsync(handle) == 0; + if (handle >= 0) + close(handle); +#endif + if (!ok) + throw Failure("Could not safely flush the mod copy. Check the storage device."); +} +std::string path_text(const fs::path& path) { + const auto s = path.generic_u8string(); + return {s.begin(), s.end()}; +} +std::string uuid() { + std::random_device random; + std::array bytes{}; + for (auto& b : bytes) + b = std::uint8_t(random()); + return dkr::updates::hash(bytes).substr(0, 32); +} +bool hash_string(const std::string& s) { + return s.size() == 64 && s.find_first_not_of("0123456789abcdef") == s.npos; +} +bool instance_id(const std::string& s) { + return s.size() == 32 && s.find_first_not_of("0123456789abcdef") == s.npos; +} +bool path_exists(const fs::path& p) { + std::error_code e; + return fs::symlink_status(store_io_path(p), e).type() != fs::file_type::not_found && !e; +} +void ordinary(const fs::path& input, bool directory) { + const auto path = store_io_path(input); + const auto s = fs::symlink_status(path); + if (fs::is_symlink(s) || (directory ? !fs::is_directory(s) : !fs::is_regular_file(s))) + throw Failure("The mod storage contains an unsafe or missing path."); +#ifdef _WIN32 + const auto flags = GetFileAttributesW(path.c_str()); + if (flags == INVALID_FILE_ATTRIBUTES || flags & FILE_ATTRIBUTE_REPARSE_POINT) + throw Failure("Mod storage must not contain junctions or reparse points."); +#endif +} +fs::path child(const fs::path& root, const std::string& relative) { + auto p = fs::u8path(relative); + if (relative.empty() || p.is_absolute() || p.has_root_name()) + throw Failure("Invalid managed mod path."); + for (const auto& part : p) + if (part == "." || part == ".." || path_text(part).find(':') != std::string::npos) + throw Failure("Invalid managed mod path component."); + auto at = root; + ordinary(root, true); + for (auto it = p.begin(); it != p.end(); ++it) { + at /= *it; + if (path_exists(at)) + ordinary(at, fs::is_directory(store_io_path(at))); + } + return at; +} +void directories(const fs::path& path) { + if (path_exists(path)) { + ordinary(path, true); + return; + } + directories(path.parent_path()); + fs::create_directory(store_io_path(path)); + sync_directory(store_io_path(path.parent_path())); + ordinary(path, true); +} +Json read(const fs::path& path, std::size_t max = 16 * 1024 * 1024) { + if (!path_exists(path)) + return Json::object(); + ordinary(path, false); + return Json::parse(dkr::updates::read_text(store_io_path(path), max)); +} +void write(const fs::path& path, const Json& j) { + directories(path.parent_path()); + dkr::updates::durable_write(store_io_path(path), j.dump()); +} +std::uint64_t tree_size(const fs::path& input) { + const auto path = store_io_path(input); + ordinary(path, fs::is_directory(path)); + if (fs::is_regular_file(path)) + return fs::file_size(path); + std::uint64_t total = 0; + std::size_t count = 0; + for (const auto& e : fs::recursive_directory_iterator(path)) { + ordinary(e.path(), e.is_directory()); + if (++count > 250000) + throw Failure("This mod has too many files."); + if (e.is_regular_file()) { + const auto n = e.file_size(); + if (n > MaxTree - total) + throw Failure("This mod exceeds its storage limit."); + total += n; + } + } + return total; +} +void copy_plain(const fs::path& source_path, const fs::path& target_path, std::stop_token stop = {}, + bool durable = true) { + const auto source = store_io_path(source_path), target = store_io_path(target_path); + ordinary(source, fs::is_directory(source)); + if (stop.stop_requested()) + throw Failure("Mod operation cancelled."); + if (path_exists(target)) + throw Failure("A private staging destination is already in use."); + directories(target.parent_path()); + if (fs::is_directory(source)) { + fs::create_directory(target); + for (const auto& e : fs::directory_iterator(source)) + copy_plain(e.path(), target / e.path().filename(), stop, durable); + if (durable) + sync_directory(target); + } else { + std::ifstream in(source, std::ios::binary); + std::ofstream out(target, std::ios::binary | std::ios::trunc); + if (!in || !out) + throw Failure("Could not open mod storage for copying."); + const auto capacity = std::min(1024 * 1024, std::max(1, fs::file_size(source))); + std::vector buffer(static_cast(capacity)); + std::uint64_t total = 0; + while (in) { + if (stop.stop_requested()) + throw Failure("Mod operation cancelled."); + in.read(buffer.data(), std::streamsize(buffer.size())); + const auto n = in.gcount(); + if (n < 0 || std::uint64_t(n) > MaxTree - total) + throw Failure("A mod file exceeds its storage limit."); + total += std::uint64_t(n); + out.write(buffer.data(), n); + if (!out) + throw Failure("Could not copy the mod. Check free disk space."); + } + if (!in.eof()) + throw Failure("The source mod could not be read completely."); + out.close(); + if (!out || total != fs::file_size(source)) + throw Failure("The mod copy was incomplete."); + if (durable) { + sync_file(target); + sync_directory(target.parent_path()); + } + } +} +std::string tree_hash(const fs::path& input, std::stop_token stop = {}) { + const auto path = store_io_path(input); + if (fs::is_regular_file(path)) + return dkr::updates::hash_file(path, MaxTree); + std::map files; + tree_size(path); + for (const auto& e : fs::recursive_directory_iterator(path)) { + if (stop.stop_requested()) + throw Failure("Mod operation cancelled."); + if (e.is_regular_file()) + files[path_text(e.path().lexically_relative(path))] = dkr::updates::hash_file(e.path(), MaxTree); + } + const auto bytes = Json(files).dump(); + return dkr::updates::hash(std::span(reinterpret_cast(bytes.data()), bytes.size())); +} +std::string track_hash(const fs::path& input, std::stop_token stop = {}) { + const auto path = store_io_path(input); + std::map files; + tree_size(path); + for (const auto& e : fs::recursive_directory_iterator(path)) { + if (stop.stop_requested()) + throw Failure("Mod operation cancelled."); + const auto name = path_text(e.path().lexically_relative(path)); + // This first-party activation marker is not authored track content. + if (name == ".online-keep-state") + continue; + if (e.is_regular_file()) + files[name] = dkr::updates::hash_file(e.path(), MaxTree); + } + const auto bytes = Json(files).dump(); + return dkr::updates::hash(std::span(reinterpret_cast(bytes.data()), bytes.size())); +} +std::string name_stamp(const fs::path& input) { + const auto path = store_io_path(input); + std::map entries; + ordinary(path, fs::is_directory(path)); + if (fs::is_regular_file(path)) + entries["file"] = std::to_string(fs::file_size(path)) + ":" + + std::to_string(fs::last_write_time(path).time_since_epoch().count()); + else + for (const auto& e : fs::directory_iterator(path)) { + ordinary(e.path(), false); + if (entries.size() >= 200005) + return {}; + entries[path_text(e.path().filename())] = + std::to_string(e.file_size()) + ":" + std::to_string(e.last_write_time().time_since_epoch().count()); + } + const auto bytes = Json(entries).dump(); + return dkr::updates::hash(std::span(reinterpret_cast(bytes.data()), bytes.size())); +} +std::string track_stamp(const fs::path& input) { + const auto path = store_io_path(input); + std::map entries; + tree_size(path); + for (const auto& e : fs::recursive_directory_iterator(path)) + if (e.is_regular_file()) + entries[path_text(e.path().lexically_relative(path))] = + std::to_string(e.file_size()) + ":" + std::to_string(e.last_write_time().time_since_epoch().count()); + const auto bytes = Json(entries).dump(); + return dkr::updates::hash(std::span(reinterpret_cast(bytes.data()), bytes.size())); +} +void discard(const fs::path& root, const fs::path& path) { + const auto relative = path.lexically_relative(root); + if (relative.empty() || relative == "." || *relative.begin() == ".." || relative.is_absolute()) + throw Failure("Refused to remove a path outside the mod transaction."); + if (path_exists(path)) { + tree_size(path); + fs::remove_all(store_io_path(path)); + } +} +std::string name_key(std::string s) { + std::string out; + for (unsigned char c : s) + if (c >= 128 || std::isalnum(c)) + out += char(std::tolower(c)); + return out; +} +bool managed_target(const std::string& s) { + // Explicit ownership roots only. Never saves, ROMs, authoring folders or + // arbitrary publisher-supplied paths. + if (s.empty() || s.back() == '/' || s.find("..") != s.npos || s.find_first_of("\\:") != s.npos) + return false; + const auto leaf_after = [&](std::string_view prefix) { + if (!s.starts_with(prefix)) + return std::string{}; + auto leaf = s.substr(prefix.size()); + if (leaf.empty() || leaf[0] == '.' || leaf.find('/') != leaf.npos) + return std::string{}; + return leaf; + }; + if (!leaf_after("texture-packs/").empty() || !leaf_after("custom-tracks/").empty()) + return true; + for (const auto* prefix : {"mods/legacy/prepared/", "mods/legacy/prepared-characters/", "mods/legacy/reviews/"}) + if (hash_string(leaf_after(prefix))) + return true; + const auto car = leaf_after("mods/vehicles/packages/"); + for(const auto* suffix : {".dkrkart", ".dkrhover", ".dkrplane"}) + if(car.ends_with(suffix) && hash_string(car.substr(0, car.size()-std::char_traits::length(suffix)))) return true; + const auto portrait = leaf_after("mods/vehicles/portraits/"); + if(portrait.ends_with(".rgba") && hash_string(portrait.substr(0,portrait.size()-5))) return true; + if(s=="mods/vehicles/library.json") return true; + const auto code = leaf_after("mods/custom-magic-codes/packages/"); + if (code.ends_with(".dkrcode") && hash_string(code.substr(0, code.size() - 8))) + return true; + return s == "mods/custom-magic-codes/library.json" || s == "texture-packs.ini" || + s == "texture-packs-index-v1.json" || s == "custom-tracks-enabled.json" || s == "custom-tracks-state.txt" || + s == "mods/legacy/track-catalog.json" || s == "mods/legacy/character-catalog.json" || + s == "mods/legacy/track-library.json" || s == "mods/legacy/character-library.json"; +} +std::string canonical_member_path(const Json& member) { + if (!member.is_object() || !member.contains("path") || !member["path"].is_string()) + return {}; + auto path = member["path"].get(); + if (path.size() > 4096 || std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32; })) + return {}; + // Native Windows inventory used backslashes in beta 8. Receipts use one + // portable representation; do not resolve dot components or absolute paths. + std::replace(path.begin(), path.end(), '\\', '/'); + return path; +} +bool managed_member(const Json& member, const std::string& path) { + if (!managed_target(path)) + return false; + const auto kind = text(member, "kind", 40); + if(kind=="vehicle") { + const auto digest=text(member,"digest",64); + if(!hash_string(digest)||text(member,"id",256)!="vehicle:"+digest||text(member,"key",512)!="vehicle:"+digest) return false; + for(const auto* suffix:{".dkrkart",".dkrhover",".dkrplane"}) + if(path=="mods/vehicles/packages/"+digest+suffix) return true; + return false; + } + return (kind == "texture" && path.starts_with("texture-packs/")) || + (kind == "track" && path.starts_with("custom-tracks/")) || + (kind == "code" && path.starts_with("mods/custom-magic-codes/packages/")) || + (kind == "legacy-track" && path.starts_with("mods/legacy/prepared/")) || + (kind == "legacy-character" && path.starts_with("mods/legacy/prepared-characters/")); +} +std::string member_path_identity(const Json& member) { + auto path = canonical_member_path(member); +#ifdef _WIN32 + for (auto& c : path) + if (c >= 'A' && c <= 'Z') + c = char(c - 'A' + 'a'); +#endif + return path; +} +bool same_native_member(const Json& member, const Json& local) { + if (text(member, "key", 512) != text(local, "key", 512)) + return false; + // Track IDs are game identities, not folder ownership. Two imported + // directories can contain the same ID; never silently adopt/delete one + // merely because the native scanner selected the other. + return text(member, "kind", 40) != "track" || member_path_identity(member) == member_path_identity(local); +} +void validate_members(Json& record) { + if (!record.contains("members") || !record["members"].is_array() || record["members"].size() > 1024) + throw Failure("An installation receipt is invalid; files were left untouched."); + for (auto& member : record["members"]) { + const auto path = canonical_member_path(member); + if (!managed_member(member, path)) + throw Failure("An installation receipt has an unsafe path (" + text(member, "kind", 40) + + "): " + text(member, "path", 240) + ". Files were left untouched."); + if (text(member, "key", 512).empty()) + throw Failure("An installation receipt has an invalid member."); + member["path"] = path; + } +} +void validate_registry(Json& value) { + if (!value.is_object() || value.value("schema", 0) != 1 || !value.contains("records") || + !value["records"].is_array() || value["records"].size() > 4096) + throw Failure("The mod installation registry needs recovery; it was not replaced."); + std::set ids; + for (auto& record : value["records"]) { + const auto id = text(record, "instance", 32); + if (!instance_id(id) || !ids.insert(id).second) + throw Failure("An installation receipt is invalid; files were left untouched."); + validate_members(record); + if (record.contains("previous")) + validate_members(record["previous"]); + } + if (value.contains("ignored") && (!value["ignored"].is_array() || value["ignored"].size() > 8192)) + throw Failure("The installation registry has an invalid unlink list."); +} +} // namespace + +Json current_release(const Json& item) { + if (!item.contains("releases") || !item["releases"].is_array()) + return Json::object(); + const auto explicit_id = text(item, "currentReleaseId", 160); + for (const auto& r : item["releases"]) + if ((!explicit_id.empty() && text(r, "id", 160) == explicit_id) || + (explicit_id.empty() && text(r, "fileUrl", 2048) == text(item, "fileUrl", 2048) && + text(r, "version", 80) == text(item, "version", 80))) + return r; + return Json::object(); // Never silently choose an arbitrary older release. +} + +bool recover_cached_package(const fs::path& folder, const fs::path& target, const std::string& hash, + std::uint64_t bytes, std::stop_token stop) { + ordinary(folder, true); + if (target.parent_path() != folder || !hash_string(hash)) + throw Failure("Invalid package cache request."); + if (path_exists(target) && fs::hard_link_count(target) == 1 && fs::file_size(target) == bytes && + dkr::updates::hash_file(target, MaxPackage) == hash) + return true; + std::vector candidates; + for (const auto& e : fs::directory_iterator(folder)) { + if (candidates.size() > 64) + throw Failure("The package cache has too many aliases."); + if (e.is_regular_file() && e.path().extension() == target.extension() && e.file_size() == bytes) + candidates.push_back(e.path()); + } + for (const auto& candidate : candidates) { + ordinary(candidate, false); + if (fs::space(folder).available < bytes + 32ULL * 1024 * 1024) + throw Failure("Not enough free space to repair the package cache safely."); + const auto incoming = folder / ("verified-" + uuid() + ".tmp"); + try { + copy_plain(candidate, incoming, stop); + if (dkr::updates::hash_file(incoming, MaxPackage) != hash) { + discard(folder, incoming); + continue; + } + dkr::updates::replace_file(incoming, target); + return true; + } catch (...) { + if (path_exists(incoming)) + discard(folder, incoming); + throw; + } + } + return false; +} + +struct InstallManager::Impl { + mutable std::mutex mutex; + std::condition_variable_any cv; + std::atomic> published{std::make_shared()}; + InstallView state; + fs::path config, root, worker_path; + std::string version; + Json catalogue = Json::array(), inventory = Json::array(), registry = {{"schema", 1}, {"records", Json::array()}}; + Json journal = Json::object(); + StorePrepared prepared; + Download proposed; + fs::path transaction; + bool configured = false, safe = false, refreshing = false; + std::unique_ptr lease; + Json warnings = Json::array(); + std::map> track_verified; + std::deque> jobs; + std::jthread worker; + void publish() { + ++state.generation; + published.store(std::make_shared(state)); + } + void update(const std::function& fn) { + std::lock_guard lock(mutex); + fn(state); + publish(); + } + bool queue(std::function job) { + std::lock_guard lock(mutex); + if (jobs.size() >= 16) + return false; + jobs.push_back(std::move(job)); + cv.notify_all(); + return true; + } + void progress(float value, std::string stage) { + update([&](InstallView& s) { + s.progress = value; + s.stage = std::move(stage); + }); + } + void run(std::stop_token stop); + void refresh(std::stop_token stop); + void prepare(std::vector, std::stop_token); + void commit(std::string mode, std::string instance, std::stop_token); + void recover(); + void rollback(); + void save(); + void begin_journal(); + void protect(const std::string& relative); + void retire_group(const std::string& relative, std::stop_token); + std::pair protect_metadata(const Json& members, const Json& outputs); + void put(const fs::path& source, const std::string& relative, std::stop_token); + void restore_native(bool tracks = true, bool textures = true); + Json candidates() const; + void retire_legacy(const Json& old, const Json& members, std::stop_token stop); + void remove_instance(std::string instance, bool remove_files, std::stop_token); + void load_registry() { + auto saved = read(root / "installations.json"); + if (saved.empty()) + saved = {{"schema", 1}, {"records", Json::array()}}; + validate_registry(saved); + // A failed reconciliation must not leave unsaved receipts in memory, + // especially when there is no registry file yet on a first install. + registry = std::move(saved); + } + void prune(); +}; +InstallManager::InstallManager() : impl_(std::make_unique()) {} +InstallManager::~InstallManager() { + impl_->worker.request_stop(); + impl_->cv.notify_all(); +} +InstallManager& installations() { + static InstallManager value; + return value; +} +std::shared_ptr InstallManager::view() const { + return impl_->published.load(); +} +void InstallManager::Impl::run(std::stop_token stop) { + for (;;) { + std::function job; + { + std::unique_lock lock(mutex); + cv.wait(lock, stop, [&] { return !jobs.empty(); }); + if (stop.stop_requested()) + return; + job = std::move(jobs.front()); + jobs.pop_front(); + } + try { + job(stop); + } catch (const std::exception& e) { + std::string message = e.what(); + bool recovery_failed = false; + try { + if (!journal.empty()) + rollback(); + else if (!transaction.empty() && path_exists(transaction) && + !path_exists(transaction / "journal.json") && path_exists(transaction / "preparing.json")) + discard(root / "transactions", transaction); + } catch (const std::exception& r) { + recovery_failed = true; + message += " Recovery required: " + std::string(r.what()); + } + lease.reset(); + update([&](InstallView& s) { + s.error = message; + s.busy = false; + s.choosing = false; + s.completed_key = proposed.key; + s.succeeded = false; + if (recovery_failed) + s.ready = false; + ++s.completion; + }); + } + } +} +void InstallManager::configure(fs::path config, std::string version, fs::path worker) { + auto& p = *impl_; + if (p.configured) + return; + p.configured = true; + p.config = fs::absolute(config).lexically_normal(); + p.root = p.config / "mod-store"; + p.version = std::move(version); + p.worker_path = std::move(worker); + p.update([](InstallView& s) { + s.busy = true; + s.stage = "Checking mod installations"; + }); + p.worker = std::jthread([&p](std::stop_token stop) { p.run(stop); }); + p.queue([&p](std::stop_token) { + directories(p.root); + directories(p.root / "transactions"); + directories(p.root / "history"); + p.lease = std::make_unique(p.root / "install.lock"); + p.recover(); + p.load_registry(); + p.prune(); + p.lease.reset(); + p.update([&](InstallView& s) { + s.records = p.registry["records"]; + s.ready = true; + s.busy = false; + s.completed_key.clear(); + ++s.completion; + }); + }); +} +void InstallManager::safe_to_write(bool value) { + std::lock_guard lock(impl_->mutex); + impl_->safe = value; +} +void InstallManager::retry_recovery() { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (p.state.busy) + return; + p.state.busy = true; + p.state.error.clear(); + p.publish(); + } + p.queue([&p](std::stop_token) { + p.proposed = {}; + p.lease = std::make_unique(p.root / "install.lock"); + p.recover(); + p.load_registry(); + p.prune(); + p.lease.reset(); + p.update([&](InstallView& s) { + s.ready = true; + s.busy = false; + s.records = p.registry["records"]; + s.completed_key.clear(); + ++s.completion; + }); + }); +} +void InstallManager::refresh(Json items, Json inventory) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (!p.configured || !p.state.ready || p.refreshing || p.state.busy) + return; + p.refreshing = true; + } + if (!p.queue([&p, items = std::move(items), inventory = std::move(inventory)](std::stop_token stop) { + struct Done { + Impl& p; + ~Done() { + std::lock_guard lock(p.mutex); + p.refreshing = false; + } + } done{p}; + p.catalogue = items; + p.inventory = Json::array(); + for (auto member : inventory) { + const auto path = canonical_member_path(member); + // External/native-only resources remain owned by their own + // importer, never adopted, replaced or removed by the Store. + if (!managed_member(member, path)) + continue; + member["path"] = path; + p.inventory.push_back(std::move(member)); + } + try { + p.refresh(stop); + } catch (const std::exception& e) { + try { + p.load_registry(); + } catch (...) { /* Preserve the original diagnostic. */ + } + p.update([&](InstallView& s) { s.error = e.what(); }); + } + })) { + std::lock_guard lock(p.mutex); + p.refreshing = false; + } +} +bool InstallManager::propose(Download job, std::vector roms) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (!p.configured || !p.safe || p.state.busy || !p.state.ready) + return false; + p.state.busy = true; + p.state.choosing = false; + p.state.error.clear(); + p.state.result.clear(); + p.state.progress = 0; + p.publish(); + } + if (!p.queue([&p, job = std::move(job), roms = std::move(roms)](std::stop_token stop) { + p.proposed = job; + p.prepare(roms, stop); + })) { + p.update([](InstallView& s) { s.busy = false; }); + return false; + } + return true; +} +void InstallManager::decide(std::string mode, std::string instance) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (!p.state.choosing || (mode != "cancel" && !p.safe)) + return; + p.state.choosing = false; + p.publish(); + } + p.queue([&p, mode = std::move(mode), instance = std::move(instance)](std::stop_token stop) { + p.commit(mode, instance, stop); + }); +} + +void InstallManager::Impl::save() { + validate_registry(registry); + write(root / "installations.json", registry); +} +void InstallManager::Impl::begin_journal() { + // Validate the rollback snapshot BEFORE publishing anything. An unrelated + // receipt problem must never make an otherwise recoverable install unsafe. + validate_registry(registry); + // Version 2 adds write-ahead directory retirement. Older builds stop on an + // unfinished v2 journal rather than incorrectly skipping a pending move. + journal = {{"schema", 2}, + {"id", path_text(transaction.filename())}, + {"phase", "preparing"}, + {"registry", registry}, + {"actions", Json::array()}}; + write(transaction / "journal.json", journal); +} +void InstallManager::Impl::protect(const std::string& relative) { + if (!managed_target(relative)) + throw Failure("This mod does not own its requested destination."); + for (const auto& action : journal["actions"]) + if (action["target"] == relative) + return; + const auto target = child(config, relative); + const auto backup = "backup/" + std::to_string(journal["actions"].size()); + Json action = {{"target", relative}, {"backup", backup}, {"existed", path_exists(target)}, {"ready", false}}; + // There is nothing to copy for a new destination. One durable write is + // sufficient to record its removal on rollback, before any mutation. + if (!action["existed"].get()) { + action["ready"] = true; + journal["actions"].push_back(action); + write(transaction / "journal.json", journal); + return; + } + journal["actions"].push_back(action); + write(transaction / "journal.json", journal); + if (path_exists(target)) { + const auto size = tree_size(target); + if (fs::space(transaction).available < size + 32ULL * 1024 * 1024) + throw Failure("Not enough free space to protect the current mod."); + copy_plain(target, child(transaction, backup)); + const auto proof = tree_hash(target); + if (proof != tree_hash(child(transaction, backup))) + throw Failure("The mod backup did not pass verification."); + journal["actions"].back()["content"] = proof; + } + journal["actions"].back()["ready"] = true; + write(transaction / "journal.json", journal); +} +void InstallManager::Impl::retire_group(const std::string& relative, std::stop_token stop) { + if (!managed_target(relative) || + !(relative.starts_with("mods/legacy/prepared/") || relative.starts_with("mods/legacy/prepared-characters/"))) + throw Failure("Invalid legacy retirement destination."); + const auto target = child(config, relative); + for (const auto& action : journal["actions"]) + if (text(action, "target", 4096) == relative) { + // A caller may already have independently protected this target. + discard(config, target); + return; + } + const auto backup = "backup/" + std::to_string(journal["actions"].size()); + const auto proof = tree_hash(target, stop); + directories(transaction / "backup"); + journal["actions"].push_back({{"target", relative}, + {"backup", backup}, + {"existed", true}, + {"ready", false}, + {"capture", "move"}, + {"content", proof}}); + // Write ahead: recovery also understands a move that completed just before + // the ready flag was flushed. The old immutable group stays recoverable. + write(transaction / "journal.json", journal); + if (stop.stop_requested()) + throw Failure("Mod replacement cancelled before retirement."); + dkr::updates::replace_file(store_io_path(target), store_io_path(child(transaction, backup))); + sync_directory(store_io_path(target.parent_path())); + journal["actions"].back()["ready"] = true; + write(transaction / "journal.json", journal); +} +std::pair InstallManager::Impl::protect_metadata(const Json& members, const Json& outputs) { + bool tracks = false, textures = false, legacy_tracks = false, characters = false, code = false, vehicles = false; + const auto consider = [&](const Json& entry) { + const auto kind = text(entry, "kind", 40); + tracks |= kind == "track"; + textures |= kind == "texture"; + legacy_tracks |= kind == "legacy-track"; + characters |= kind == "legacy-character"; + code |= kind == "code"; + vehicles |= kind == "vehicle" || kind == "vehicle-portrait"; + }; + for (const auto& member : members) + consider(member); + for (const auto& output : outputs) + consider(output); + if (textures) { + // Join an earlier pack-index scan only if this operation uses textures. + dkr::runtime::texture_packs::configure(config); + protect("texture-packs.ini"); + protect("texture-packs-index-v1.json"); + } + if (tracks) { + protect("custom-tracks-enabled.json"); + protect("custom-tracks-state.txt"); + } + if (legacy_tracks) { + protect("mods/legacy/track-catalog.json"); + protect("mods/legacy/track-library.json"); + } + if (characters) { + protect("mods/legacy/character-catalog.json"); + protect("mods/legacy/character-library.json"); + } + if (vehicles) protect("mods/vehicles/library.json"); + if (code) + protect("mods/custom-magic-codes/library.json"); + return {tracks, textures}; +} +void InstallManager::Impl::put(const fs::path& source, const std::string& relative, std::stop_token stop) { + if (!managed_target(relative)) + throw Failure("Unsafe mod output destination."); + const auto target = child(config, relative); + if (path_exists(target) && tree_hash(source, stop) == tree_hash(target, stop)) + return; + const auto size = tree_size(source); + if (fs::space(transaction).available < size * 2 + 32ULL * 1024 * 1024) + throw Failure("Not enough free space to replace this mod safely."); + protect(relative); + const auto incoming = target.parent_path() / (".store-incoming-" + path_text(transaction.filename())); + if (path_exists(incoming)) + throw Failure("An earlier private import needs recovery."); + journal["incoming"] = path_text(incoming.lexically_relative(config)); + write(transaction / "journal.json", journal); + copy_plain(source, incoming, stop); + if (tree_hash(source, stop) != tree_hash(incoming, stop)) + throw Failure("The prepared mod copy did not pass verification."); + if (path_exists(target)) + discard(config, target); + dkr::updates::replace_file(store_io_path(incoming), store_io_path(target)); + journal.erase("incoming"); + write(transaction / "journal.json", journal); +} +void InstallManager::Impl::restore_native(bool tracks, bool textures) { + if (tracks) + dkr::runtime::custom_tracks::reload(); + if (textures) + dkr::runtime::texture_packs::configure(config); +} +void InstallManager::Impl::rollback() { + if (journal.empty()) + return; + if (journal.value("phase", std::string{}) == "committed") { + journal = Json::object(); + return; + } + for (auto it = journal["actions"].rbegin(); it != journal["actions"].rend(); ++it) { + const auto relative = text(*it, "target", 4096); + if (!managed_target(relative)) + throw Failure("Unsafe recovery target; files were left untouched."); + const bool moving = it->value("capture", std::string{}) == "move"; + if (!it->value("ready", false) && !moving) + continue; + const auto target = child(config, relative), backup = child(transaction, text(*it, "backup", 256)); + if (moving && !it->value("ready", false) && !path_exists(backup)) { + if (!path_exists(target)) + throw Failure("An interrupted retirement is missing its original and recovery group."); + continue; // The durable intent was written, but the move did not run. + } + if (it->value("existed", false)) { + if (!path_exists(backup)) + throw Failure("A protected mod backup is missing."); + if (!hash_string(text(*it, "content", 64)) || tree_hash(backup) != text(*it, "content", 64)) + throw Failure("A protected mod backup has changed. Files were left in place for recovery."); + // Copy, do not consume the backup: recovery remains repeatable if + // power is lost while restoring the next member of the bundle. + const auto incoming = target.parent_path() / (".store-restore-" + path_text(transaction.filename())); + if (path_exists(incoming)) + discard(config, incoming); + copy_plain(backup, incoming); + if (tree_hash(backup) != tree_hash(incoming)) + throw Failure("Restored mod backup did not match."); + if (path_exists(target)) + discard(config, target); + dkr::updates::replace_file(store_io_path(incoming), store_io_path(target)); + } else if (path_exists(target)) + discard(config, target); + } + if (journal.contains("incoming")) { + const auto incoming = child(config, text(journal, "incoming", 4096)); + if (path_text(incoming.filename()) == ".store-incoming-" + path_text(transaction.filename())) + discard(config, incoming); + } + registry = journal.at("registry"); + validate_registry(registry); + save(); + journal["phase"] = "rolled-back"; + write(transaction / "journal.json", journal); + restore_native(); + journal = Json::object(); +} +void InstallManager::Impl::recover() { + for (const auto& entry : fs::directory_iterator(root / "transactions")) { + if (!entry.is_directory() || !instance_id(path_text(entry.path().filename()))) + continue; + ordinary(entry.path(), true); + auto saved = read(entry.path() / "journal.json"); + if (saved.empty() || saved.value("phase", std::string{}) == "committed" || + saved.value("phase", std::string{}) == "rolled-back") + continue; + const auto schema = saved.value("schema", 0); + if ((schema != 1 && schema != 2) || text(saved, "id", 32) != path_text(entry.path().filename()) || + !saved.contains("actions") || !saved["actions"].is_array() || saved["actions"].size() > 2048) + throw Failure("An unfinished mod transaction needs manual recovery."); + validate_registry(saved.at("registry")); + std::set targets; + for (std::size_t i = 0; i < saved["actions"].size(); ++i) { + const auto& action = saved["actions"][i]; + const auto target = text(action, "target", 4096); + if (!managed_target(target) || !targets.insert(target).second || + text(action, "backup", 256) != "backup/" + std::to_string(i) || !action.contains("existed") || + !action["existed"].is_boolean() || !action.contains("ready") || !action["ready"].is_boolean()) + throw Failure("An unfinished transaction has invalid recovery paths; files were left untouched."); + if (action.value("ready", false) && action.value("existed", false) && + !hash_string(text(action, "content", 64))) + throw Failure("An unfinished transaction has no valid backup proof. Files were left untouched."); + const auto capture = text(action, "capture", 16); + if (!capture.empty() && (schema != 2 || capture != "move" || !action.value("existed", false) || + !hash_string(text(action, "content", 64)) || + !(target.starts_with("mods/legacy/prepared/") || + target.starts_with("mods/legacy/prepared-characters/")))) + throw Failure("An unfinished transaction has an invalid retirement intent."); + } + if (saved.contains("incoming")) { + const auto incoming = fs::u8path(text(saved, "incoming", 4096)); + bool allowed = false; + for (const auto& action : saved["actions"]) + if (incoming == fs::u8path(text(action, "target", 4096)).parent_path() / + (".store-incoming-" + path_text(entry.path().filename()))) + allowed = true; + if (!allowed) + throw Failure("Invalid interrupted mod staging path; files were left untouched."); + } + transaction = entry.path(); + journal = std::move(saved); + rollback(); + } +} +void InstallManager::Impl::prune() { + // Never delete an unknown directory. Keep every current/previous edition's + // transaction, plus the twenty newest recoverable completed operations. + std::set keep; + for (const auto& r : registry["records"]) { + keep.insert(text(r, "transaction", 32)); + if (r.contains("previous")) + keep.insert(text(r["previous"], "transaction", 32)); + } + std::vector> old; + for (const auto& e : fs::directory_iterator(root / "transactions")) { + const auto id = path_text(e.path().filename()); + if (!instance_id(id) || !e.is_directory() || keep.contains(id)) + continue; + ordinary(e.path(), true); + const auto j = read(e.path() / "journal.json"); + if (j.empty()) { + const auto preparation = read(e.path() / "preparing.json"); + if (preparation.value("id", std::string{}) == id) + discard(root / "transactions", e.path()); + } else if ((j.value("schema", 0) == 1 || j.value("schema", 0) == 2) && text(j, "id", 32) == id && + (j.value("phase", std::string{}) == "committed" || j.value("phase", std::string{}) == "rolled-back")) + old.emplace_back(e.last_write_time(), e.path()); + } + std::sort(old.begin(), old.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); + for (std::size_t i = 20; i < old.size(); ++i) + discard(root / "transactions", old[i].second); +} +void InstallManager::Impl::refresh(std::stop_token stop) { + if (!inventory.is_array() || !catalogue.is_array()) + return; + // Serialise receipt changes between launcher copies as well as threads. + std::unique_ptr metadata_lease; + if (!lease) { + metadata_lease = std::make_unique(root / "install.lock"); + load_registry(); + } + bool changed = false; + const auto names = read(root / "installed-names.json"); + for (auto& local : inventory) { + const auto relative = text(local, "path", 4096); + if (text(local, "kind", 40) == "texture" && !relative.empty() && names.contains("textures")) { + const auto key = path_text(config / fs::u8path(relative)); + if (names["textures"].contains(key)) { + const auto& proof = names["textures"][key]; + try { + if (text(proof, "stamp", 64) == name_stamp(child(config, relative))) + local["digest"] = text(proof, "sha256", 64); + } catch (...) { /* Leave unproven content unlinked. */ + } + } + } + } + // Older versions did not write track receipts. Reconstruct a proof from + // the verified cached package, never from a similar folder or display name. + for (const auto& item : catalogue) { + if (text(item, "category", 32) != "tracks") + continue; + for (const auto& release : item.value("releases", Json::array())) { + const auto digest = text(release, "sha256", 64); + if (!hash_string(digest) || !text(release, "format", 32).empty() && text(release, "format", 32) != ".zip" && + text(release, "format", 32) != ".dkrmap") + continue; + bool need = false; + for (const auto& local : inventory) + if (text(local, "kind", 40) == "track" && text(local, "digest", 64).empty()) + need = true; + if (!need) + break; + auto proof = registry.value("trackProofs", Json::object()).value(digest, Json::object()); + if (proof.empty()) { + const auto folder = root / "downloads" / digest; + if (!path_exists(folder)) + continue; + const auto file = + folder / fs::u8path(package_filename(text(item, "title", 200), text(release, "fileName", 180))); + if (!recover_cached_package(folder, file, digest, release.value("size", 0ULL), stop)) + continue; + const auto scratch = root / "transactions" / uuid(); + directories(scratch); + write(scratch / "preparing.json", {{"id", path_text(scratch.filename())}}); + try { + const auto track = prepare_store_track(file, scratch / "payload", text(item, "title", 200), stop); + proof = {{"id", text(track.native, "trackId", 256)}, + {"content", track_hash(fs::u8path(text(track.outputs.front(), "source", 4096)), stop)}}; + registry["trackProofs"][digest] = proof; + changed = true; + discard(root / "transactions", scratch); + } catch (...) { + discard(root / "transactions", scratch); + continue; + } + } + for (auto& local : inventory) + if (text(local, "kind", 40) == "track" && text(local, "id", 256) == text(proof, "id", 256)) { + const auto path = child(config, text(local, "path", 4096)); + if (path_exists(path)) { + const auto stamp = track_stamp(path), key = path_text(path); + if (!track_verified.contains(key) || track_verified[key].first != stamp) + track_verified[key] = {stamp, track_hash(path, stop)}; + if (track_verified[key].second == text(proof, "content", 64)) + local["digest"] = digest; + } + } + } + } + // Adopt only exact proofs. Similar names remain unlinked duplicate hints. + for (const auto& item : catalogue) { + const auto item_id = text(item, "id", 160), category = text(item, "category", 32); + if (!item.contains("releases") || !item["releases"].is_array()) + continue; + for (const auto& release : item["releases"]) { + const auto digest = text(release, "sha256", 64); + if (!hash_string(digest)) + continue; + Json members = Json::array(); + for (const auto& local : inventory) { + if (text(local, "digest", 64) != digest) + continue; + if (registry.contains("ignored") && std::find(registry["ignored"].begin(), registry["ignored"].end(), + local["key"]) != registry["ignored"].end()) + continue; + const auto kind = text(local, "kind", 40); + if ((category == "textures" && kind != "texture") || (category == "magic" && kind != "code") || + (category == "characters" && kind != "legacy-character") || + (vehicle_store_category(category) && kind != "vehicle") || + (category == "tracks" && kind != "track" && kind != "legacy-track")) + continue; + bool owned = false; + for (const auto& record : registry["records"]) + if (!record.value("stored", false)) + for (const auto& member : record.value("members", Json::array())) + if (same_native_member(member, local)) { + owned = true; + break; + } + if (!owned) + members.push_back(local); + } + if (members.empty()) + continue; + const auto adopt = [&](const Json& owned) { + auto members_with_proofs = owned; + for (auto& m : members_with_proofs) { + const auto path = text(m, "path", 4096); + if (!path.empty() && path_exists(child(config, path))) + m["content"] = text(m, "kind", 40) == "track" ? track_hash(child(config, path), stop) + : tree_hash(child(config, path), stop); + } + Json record = {{"instance", uuid()}, + {"item", item_id}, + {"release", text(release, "id", 160)}, + {"title", text(item, "title", 200)}, + {"category", category}, + {"version", text(release, "version", 80)}, + {"publishedAt", text(release, "createdAt", 80)}, + {"digest", digest}, + {"members", members_with_proofs}, + {"follow", true}, + {"skip", ""}, + {"origin", "existing verified installation"}, + {"stored", false}}; + const auto file = + root / "downloads" / digest / + fs::u8path(package_filename(text(item, "title", 200), text(release, "fileName", 180))); + if (path_exists(file)) { + record["file"] = path_text(file.lexically_relative(config)); + record["bytes"] = release.value("size", 0ULL); + } + registry["records"].push_back(record); + }; + if (category == "textures") + for (const auto& member : members) + adopt(Json::array({member})); + else + adopt(members); + changed = true; + } + } + Json updates = Json::array(), duplicates = Json::array(); + std::map groups, name_groups; + std::set unique_native; + for (const auto& local : inventory) { + const auto logical = text(local, "kind", 40) + ":" + text(local, "id", 256); + if (text(local, "kind", 40).starts_with("legacy-") && !unique_native.insert(logical).second) + continue; + const auto key = text(local, "digest", 64); + if (hash_string(key)) { + if (!groups.contains(key)) + groups[key] = Json::array(); + groups[key].push_back(local); + } + const auto name = text(local, "kind", 40) + ":" + name_key(text(local, "name", 200)); + if (!name_groups.contains(name)) + name_groups[name] = Json::array(); + name_groups[name].push_back(local); + } + for (const auto& [key, group] : groups) + if (group.size() > 1) + duplicates.push_back({{"identity", key}, {"entries", group}, {"exact", hash_string(key)}}); + for (const auto& [name, group] : name_groups) + if (group.size() > 1) { + const auto first = text(group.front(), "digest", 64); + const bool exact = hash_string(first) && std::all_of(group.begin(), group.end(), [&](const Json& v) { + return text(v, "digest", 64) == first; + }); + if (!exact) + duplicates.push_back({{"identity", name}, {"entries", group}, {"exact", false}}); + } + for (auto& record : registry["records"]) { + if (stop.stop_requested()) + return; + unsigned present = 0, total = 0; + bool another_track_copy = false; + for (auto& member : record["members"]) { + ++total; + bool found = false; + for (const auto& local : inventory) + if (same_native_member(member, local) && + (text(local, "path", 4096).empty() || path_exists(child(config, text(local, "path", 4096))))) { + ++present; + found = true; + for (const auto* field : + {"enabled", "hidden", "priority", "settings", "data", "approved", "selected", "path", "group"}) + if (!(text(member, "kind", 40) == "track" && std::string_view(field) == "path") && + local.contains(field) && (!member.contains(field) || member[field] != local[field])) { + member[field] = local[field]; + changed = true; + } + break; + } + if (!found && !record.value("stored", false) && text(member, "kind", 40) == "track" && + hash_string(text(member, "content", 64))) { + const auto path = child(config, text(member, "path", 4096)); + if (path_exists(path)) { + const auto stamp = track_stamp(path), key = path_text(path); + if (!track_verified.contains(key) || track_verified[key].first != stamp) + track_verified[key] = {stamp, track_hash(path, stop)}; + // A duplicate can shadow a correctly installed directory. + // Count it only with the original verified content proof, + // retaining its exact owned path instead of stealing the + // currently active copy's folder. + if (track_verified[key].second == text(member, "content", 64)) { + ++present; + another_track_copy |= std::any_of(inventory.begin(), inventory.end(), [&](const Json& local) { + return text(local, "key", 512) == text(member, "key", 512) && + !same_native_member(member, local); + }); + } + } + } + } + if (record.value("anotherTrackCopyActive", false) != another_track_copy) { + record["anotherTrackCopyActive"] = another_track_copy; + changed = true; + } + const std::string status = record.value("stored", false) ? "Stored edition" + : present == total && total ? "Installed" + : present ? "Needs repair" + : "Missing"; + if (record.value("state", std::string{}) != status) { + record["state"] = status; + changed = true; + } + for (const auto& item : catalogue) + if (text(item, "id", 160) == text(record, "item", 160)) { + auto latest = current_release(item); + if (latest.empty() || !hash_string(text(latest, "sha256", 64)) || !record.value("follow", true) || + text(latest, "id", 160) == text(record, "release", 160) || + text(latest, "id", 160) == text(record, "skip", 160) || + text(latest, "sha256", 64) == text(record, "digest", 64) || status == "Missing") + continue; + bool downgrade = false; + try { + downgrade = dkr::mods::code::Version::parse(text(latest, "version", 80)) < + dkr::mods::code::Version::parse(text(record, "version", 80)); + } catch (...) { + const auto old = text(record, "publishedAt", 80), now = text(latest, "createdAt", 80); + downgrade = !old.empty() && !now.empty() && now < old; + } + updates.push_back({{"instance", record["instance"]}, + {"item", item}, + {"release", latest}, + {"current", record["version"]}, + {"title", record["title"]}, + {"downgrade", downgrade}, + {"codePermission", text(record, "category", 32) == "magic"}}); + } + } + Json unlinked = Json::array(); + for (const auto& local : inventory) { + bool owned = false; + for (const auto& record : registry["records"]) + for (const auto& m : record.value("members", Json::array())) + if (same_native_member(m, local)) + owned = true; + if (!owned) + unlinked.push_back(local); + } + if (changed) + save(); + update([&](InstallView& s) { + const auto identity = [](const Json& rows) { + std::set ids; + for (const auto& row : rows) + ids.insert(text(row, "instance", 32) + ":" + text(row["release"], "id", 160)); + return ids; + }; + if (identity(s.updates) != identity(updates)) + ++s.updates_revision; + s.records = registry["records"]; + s.updates = std::move(updates); + s.duplicates = std::move(duplicates); + s.unlinked = std::move(unlinked); + }); +} +Json InstallManager::Impl::candidates() const { + Json found = Json::array(); + for (const auto& record : registry["records"]) { + bool strong = text(record, "item", 160) == proposed.item || text(record, "digest", 64) == proposed.digest; + bool possible = name_key(text(record, "title", 200)) == name_key(proposed.title) && + text(record, "category", 32) == proposed.category; + if (strong || possible) { + auto row = record; + row["exact"] = strong; + found.push_back(row); + } + } + for (const auto& local : inventory) { + bool identity = false; + if (text(local, "kind", 40) == "track" && text(local, "id", 256) == text(prepared.native, "trackId", 256)) + identity = true; + for (const auto& member : prepared.native.value("members", Json::array())) + if (text(member, "kind", 40) == text(local, "kind", 40) && + text(member, "id", 256) == text(local, "id", 256)) + identity = true; + const bool strong = identity || text(local, "digest", 64) == proposed.digest; + bool possible = name_key(text(local, "name", 200)) == name_key(proposed.title); + if(text(local,"kind",40)=="vehicle") for(const auto& incoming:prepared.native.value("members",Json::array())) + possible |= text(incoming,"kind",40)=="vehicle" && incoming.value("type",0U)==local.value("type",0U) && + name_key(text(incoming,"name",240))==name_key(text(local,"name",240)); + if (!strong && !possible) + continue; + bool already = false; + for (const auto& record : found) + for (const auto& member : record.value("members", Json::array())) + if (same_native_member(member, local)) + already = true; + if (!already) + found.push_back({{"instance", "local:" + text(local, "key", 512)}, + {"title", local["name"]}, + {"version", "Local copy"}, + {"members", Json::array({local})}, + {"exact", strong}, + {"stored", false}}); + } + return found; +} +void InstallManager::Impl::prepare(std::vector roms, std::stop_token stop) { + lease = std::make_unique(root / "install.lock"); + load_registry(); + if (!hash_string(proposed.digest) || proposed.file.parent_path() != root / "downloads" / proposed.digest || + fs::file_size(proposed.file) != proposed.total || + dkr::updates::hash_file(proposed.file, MaxPackage) != proposed.digest) + throw Failure("The cached package is missing or has changed. Download it again before installing."); + transaction = root / "transactions" / uuid(); + directories(transaction); + write(transaction / "preparing.json", {{"id", path_text(transaction.filename())}}); + prepared = {}; + journal = Json::object(); + warnings = Json::array(); + progress(.02F, "Inspecting the downloaded package"); + auto extension = proposed.file.extension().string(); + for (auto& c : extension) + c = char(std::tolower(static_cast(c))); + const bool legacy_patch=extension==".xdelta"||extension==".bps"||extension==".ips"; + bool historical_legacy_archive=false; + if(vehicle_store_category(proposed.category)&&extension==".zip") + for(const auto& receipt:registry["records"]) for(const auto* edition:{&receipt,receipt.contains("previous")?&receipt["previous"]:&receipt}) + if(text(*edition,"digest",64)==proposed.digest) + for(const auto& m:edition->value("members",Json::array())) + historical_legacy_archive |= text(m,"kind",40).starts_with("legacy-"); + if(vehicle_store_extension(extension)||(vehicle_store_category(proposed.category)&&!legacy_patch&&!historical_legacy_archive)) { + if(!vehicle_store_category(proposed.category)) throw Failure("This vehicle is listed under the wrong Store category. Ask its publisher to choose Vehicles."); + progress(.05F,"Validating vehicle models, portraits and driver seats"); + prepared=prepare_store_vehicle(proposed.file,transaction/"payload",stop); + } else if (extension == ".nrm" || extension == ".dkrcode") { + const auto bytes = dkr::mods::read_file(proposed.file, dkr::mods::code::MaxPackage); + const auto package = dkr::mods::code::inspect(bytes, version); + const auto payload = transaction / "payload" / "code.dkrcode"; + copy_plain(proposed.file, payload, stop); + prepared.outputs.push_back({{"kind", "code"}, + {"source", path_text(payload)}, + {"target", "mods/custom-magic-codes/packages/" + package->digest + ".dkrcode"}, + {"id", package->id}}); + prepared.native = { + {"codeId", package->id}, + {"codeDigest", package->digest}, + {"members", Json::array({{{"kind", "code"}, + {"id", package->id}, + {"digest", package->digest}, + {"path", "mods/custom-magic-codes/packages/" + package->digest + ".dkrcode"}, + {"key", "code:" + package->digest}, + {"name", package->name}}})}}; + } else if (proposed.category == "textures") { + prepared = prepare_store_texture(proposed.file, transaction / "payload", proposed.title, stop, + [&](float p, std::string note) { progress(.05F + .7F * p, std::move(note)); }); + } else if (proposed.category == "characters" || historical_legacy_archive || extension == ".xdelta" || + extension == ".bps" || extension == ".ips") { + if (roms.empty()) + throw Failure("Add the patch's required original ROM in Play before preparing this mod."); + const auto library_root = transaction / "legacy"; + dkr::mods::ModLibrary library; + library.configure(library_root, worker_path); + const auto deadline = Clock::now() + std::chrono::minutes(20); + auto wait = [&] { + for (;;) { + if (stop.stop_requested() || Clock::now() > deadline) { + library.cancel(); + throw Failure( + "Legacy preparation was cancelled or timed out. The installed version was not changed."); + } + library.tick(); + const auto view = library.snapshot(); + progress(.1F + .65F * (view.total ? float(view.completed) / float(view.total) : 0.F), view.stage); + if (!view.busy) { + if (!view.succeeded) + throw Failure(view.result); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + }; + wait(); + if (!library.import_file(proposed.file, std::move(roms))) + throw Failure("The private legacy importer could not start."); + wait(); + const auto view = library.snapshot(); + Json members = Json::array(); + std::set groups; + for (const bool characters : {false, true}) { + const auto snapshot = characters ? view.characters : view.tracks; + if (!snapshot) + continue; + for (const auto& item : snapshot->tracks) { + const auto prefix = characters ? "prepared-characters/" : "prepared/"; + const auto relative = std::string(prefix) + item.group; + if (groups.insert(relative).second) + prepared.outputs.push_back({{"kind", "legacy-group"}, + {"source", path_text(library_root / fs::u8path(relative))}, + {"target", "mods/legacy/" + relative}}); + members.push_back({{"kind", characters ? "legacy-character" : "legacy-track"}, + {"id", item.id}, + {"key", std::string(characters ? "legacy-character:" : "legacy-track:") + item.id + + ":" + item.revision}, + {"revision", item.revision}, + {"digest", item.patch}, + {"name", item.name}, + {"group", item.group}, + {"path", "mods/legacy/" + relative}, + {"slot", characters ? item.base_character : item.carrier}}); + } + } + const auto reviews = library_root / "reviews"; + if (path_exists(reviews)) + for (const auto& entry : fs::directory_iterator(reviews)) + if (entry.is_directory() && hash_string(path_text(entry.path().filename()))) + prepared.outputs.push_back( + {{"kind", "legacy-review"}, + {"source", path_text(entry.path())}, + {"target", "mods/legacy/reviews/" + path_text(entry.path().filename())}}); + if (members.empty()) + throw Failure("This patch did not produce a supported character or track."); + prepared.native["members"] = members; + } else { + prepared = prepare_store_track(proposed.file, transaction / "payload", proposed.title, stop); + const auto hd = text(prepared.native, "hdSource", 4096); + if (!hd.empty()) { + auto textures = + prepare_store_texture(fs::u8path(hd), transaction / "hd", proposed.title + " HD textures", stop, + [&](float p, std::string note) { progress(.15F + .55F * p, std::move(note)); }); + for (auto& output : textures.outputs) { + output["owner"] = prepared.native["trackId"]; + output["textureDigest"] = prepared.native["hdDigest"]; + prepared.outputs.push_back(output); + } + } + } + for (auto& output : prepared.outputs) { + const auto source = fs::u8path(text(output, "source", 4096)); + if (source.lexically_relative(transaction).empty() || *source.lexically_relative(transaction).begin() == "..") + throw Failure("Prepared content escaped its private transaction."); + output["content"] = tree_hash(source, stop); + output["bytes"] = tree_size(source); + } + if (stop.stop_requested()) + throw Failure("Installation cancelled before publishing content."); + const auto found = candidates(); + bool modified = false, identity_changed = false; + if (!proposed.instance.empty()) { + bool valid = false; + for (const auto& candidate : found) + if (text(candidate, "instance", 512) == proposed.instance && candidate.value("exact", false)) { + valid = true; + for (const auto& m : candidate.value("members", Json::array())) { + const auto incoming = text(m, "kind", 40) == "track" ? text(prepared.native, "trackId", 256) + : text(m, "kind", 40) == "code" ? text(prepared.native, "codeId", 256) + : std::string{}; + if (!incoming.empty() && incoming != text(m, "id", 256)) + identity_changed = true; + } + if (!candidate.value("stored", false)) + for (const auto& m : candidate.value("members", Json::array())) { + const auto path = text(m, "path", 4096), expected = text(m, "content", 64); + if (path.empty() || expected.empty()) + continue; + const auto file = child(config, path); + if (!path_exists(file) || (text(m, "kind", 40) == "track" ? track_hash(file, stop) + : tree_hash(file, stop)) != expected) + modified = true; + } + } + if (!valid) + throw Failure("The selected installation changed. Review the mod again before replacing it."); + if (!modified && !identity_changed) { + commit("replace", proposed.instance, stop); + return; + } + } + if (found.empty()) { + commit("install", {}, stop); + return; + } + update([&](InstallView& s) { + s.choosing = true; + s.progress = .8F; + s.stage = "Waiting for your duplicate choice"; + s.proposal = {{"job", proposed.key}, + {"title", proposed.title}, + {"version", proposed.version}, + {"candidates", found}, + {"modified", modified}, + {"identityChanged", identity_changed}, + {"code", prepared.native.contains("codeId")}, + {"canRunCopies", proposed.category == "textures" || prepared.native.value("vehicle",false)}}; + }); +} +void InstallManager::Impl::retire_legacy(const Json& old, const Json& members, std::stop_token stop) { + for (const bool characters : {false, true}) { + const auto kind = characters ? "legacy-character" : "legacy-track"; + const auto prefix = characters ? "prepared-characters/" : "prepared/"; + std::set retired, groups; + for (const auto& m : old.value("members", Json::array())) { + if (text(m, "kind", 40) != kind) + continue; + bool keep = false; + for (const auto& n : members) + if (text(n, "kind", 40) == kind && text(n, "id", 256) == text(m, "id", 256) && + text(n, "path", 4096) == text(m, "path", 4096)) + keep = true; + for (const auto& r : registry["records"]) + if (text(r, "instance", 512) != text(old, "instance", 512) && !r.value("stored", false)) + for (const auto& n : r.value("members", Json::array())) + if (text(n, "kind", 40) == kind && text(n, "id", 256) == text(m, "id", 256)) + keep = true; + if (!keep) { + retired.insert(text(m, "id", 256)); + groups.insert(text(m, "path", 4096)); + } + } + if (retired.empty()) + continue; + const auto private_root = transaction / (characters ? "retire-characters" : "retire-tracks"); + directories(private_root); + std::set partial; + for (const auto& relative : groups) { + if (!relative.starts_with(std::string("mods/legacy/") + prefix)) + throw Failure("Invalid old legacy group ownership."); + const auto group = child(config, relative); + const auto name = path_text(group.filename()); + if (!hash_string(name)) + throw Failure("Invalid legacy group identity."); + const auto manifest = child(group, "prepared.json"); + if (dkr::updates::hash_file(manifest, 1024 * 1024) != name) + throw Failure("The old legacy group receipt has changed; its files were kept."); + const auto receipt = read(manifest, 1024 * 1024); + const auto entries = receipt.value(characters ? "characters" : "tracks", Json::array()); + if (entries.is_array() && !entries.empty() && + std::all_of(entries.begin(), entries.end(), + [&](const Json& entry) { return retired.contains(text(entry, "id", 256)); })) { + // No survivors: retain the old immutable directory by a + // journaled same-volume move, not hundreds of tiny file copies. + retire_group(relative, stop); + continue; + } + partial.insert(relative); + protect(relative); + // Scratch work can be discarded after interruption. Only the real + // backup and published replacement need per-file durable flushes. + copy_plain(group, private_root / prefix / fs::u8path(name), stop, false); + } + std::vector surviving; + if (!partial.empty()) { + dkr::mods::TrackCatalog catalog(characters ? dkr::mods::TrackCatalog::Kind::Character + : dkr::mods::TrackCatalog::Kind::Track); + catalog.configure(private_root, worker_path); + const auto deadline = Clock::now() + std::chrono::minutes(5); + auto wait = [&] { + while (catalog.snapshot()->busy) { + if (stop.stop_requested() || Clock::now() > deadline) { + catalog.cancel(); + throw Failure("Legacy replacement cancelled before publication."); + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + if (!catalog.snapshot()->succeeded) + throw Failure(catalog.snapshot()->result); + }; + wait(); + for (const auto& id : retired) { + if (std::none_of(catalog.snapshot()->tracks.begin(), catalog.snapshot()->tracks.end(), + [&](const auto& item) { return item.id == id; })) + continue; + if (!catalog.remove(id)) + throw Failure("Could not prepare a legacy replacement."); + wait(); + } + std::set survivors; + const auto prepared_root = private_root / prefix; + if (path_exists(prepared_root)) + for (const auto& e : fs::directory_iterator(prepared_root)) + if (e.is_directory() && hash_string(path_text(e.path().filename()))) { + const auto relative = "mods/legacy/" + std::string(prefix) + path_text(e.path().filename()); + put(e.path(), relative, stop); + survivors.insert(relative); + } + for (const auto& relative : partial) + if (!survivors.contains(relative)) + discard(config, child(config, relative)); + surviving = catalog.snapshot()->tracks; + } + const auto enabled_path = + std::string("mods/legacy/") + (characters ? "character-catalog.json" : "track-catalog.json"); + auto enabled = read(child(config, enabled_path)); + if (enabled.contains("enabled") && enabled["enabled"].is_array()) { + auto kept = Json::array(); + for (const auto& id : enabled["enabled"]) + if (!retired.contains(id.get()) || + std::any_of(members.begin(), members.end(), [&](const Json& m) { + return text(m, "kind", 40) == kind && text(m, "id", 256) == id.get(); + })) + kept.push_back(id); + enabled["enabled"] = kept; + write(child(config, enabled_path), enabled); + } + const auto metadata_path = + std::string("mods/legacy/") + (characters ? "character-library.json" : "track-library.json"); + auto metadata = read(child(config, metadata_path)); + if (metadata.contains("items") && metadata["items"].is_object()) { + for (const auto& id : retired) + if (!std::any_of(members.begin(), members.end(), [&](const Json& m) { + return text(m, "kind", 40) == kind && text(m, "id", 256) == id; + })) + metadata["items"].erase(id); + write(child(config, metadata_path), metadata); + } + for (auto& r : registry["records"]) + for (auto& m : r["members"]) + for (const auto& survivor : surviving) + if (text(m, "id", 256) == survivor.id && text(m, "revision", 32) == survivor.revision) { + m["group"] = survivor.group; + m["path"] = "mods/legacy/" + std::string(prefix) + survivor.group; + m["content"] = tree_hash(child(config, m["path"].get()), stop); + } + } +} +void InstallManager::Impl::commit(std::string mode, std::string instance, std::stop_token stop) { + if (mode == "cancel") { + if (path_exists(transaction)) + discard(root / "transactions", transaction); + lease.reset(); + update([&](InstallView& s) { + s.busy = false; + s.choosing = false; + s.succeeded = false; + s.completed_key = proposed.key; + s.result = "Installation cancelled. Your installed mods were not changed."; + ++s.completion; + }); + return; + } + { + std::lock_guard lock(mutex); + if (!safe) + throw Failure("Return to the launcher and leave the online lobby before installing mods."); + } + if (mode != "install" && mode != "replace" && mode != "add") + throw Failure("Invalid installation choice."); + Json old = Json::object(); + if (mode == "replace") { + for (const auto& candidate : candidates()) + if (text(candidate, "instance", 512) == instance) + old = candidate; + if (old.empty()) + throw Failure("The selected duplicate is no longer available."); + } + if (proposed.intent == "activate") { + for (const auto& r : registry["records"]) + if (text(r, "item", 160) == proposed.item && !r.value("stored", false) && + text(r, "instance", 32) != instance) + old = r; + if (old.value("stored", false)) { + for (const auto& candidate : candidates()) + if (candidate.value("exact", false) && !candidate.value("stored", false) && + text(candidate, "instance", 512).starts_with("local:")) { + old = candidate; + break; + } + } + } + progress(.82F, "Protecting the existing installation"); + begin_journal(); + Json members = prepared.native.value("members", Json::array()); + std::pair native_changes{false, false}; + const bool stored = (mode == "add" && proposed.category != "textures" && !prepared.native.value("vehicle",false)) || + (old.value("stored", false) && proposed.intent != "activate"); + if (proposed.intent == "activate") + for (auto& r : registry["records"]) + if (text(r, "item", 160) == proposed.item && text(r, "instance", 32) != instance) + r["stored"] = true; + if (!stored) { + auto affected = members; + for (const auto& member : old.value("members", Json::array())) + affected.push_back(member); + progress(.83F, "Saving recovery information"); + native_changes = protect_metadata(affected, prepared.outputs); + // put(), legacy retirement and obsolete-resource removal protect their + // actual mutation targets. Do not copy an unchanged multi-GB texture + // tree just because its source archive was downloaded again. + for (auto& output : prepared.outputs) { + progress(.85F, "Installing verified files: " + proposed.title); + const auto kind = text(output, "kind", 40); + auto target = text(output, "target", 4096); + Json previous = Json::object(); + if (kind == "texture" || kind == "track") { + for (const auto& m : old.value("members", Json::array())) { + if (text(m, "kind", 40) != kind) + continue; + if (kind == "texture" && !text(output, "owner", 256).empty() && + text(m, "owner", 256) != text(output, "owner", 256)) + continue; + if (kind == "track" && text(m, "id", 256) != text(output, "id", 256)) + warnings.push_back("The publisher changed this track's ID. Cups referencing the previous ID " + "may need the track selected again. The previous edition is retained."); + previous = m; + target = text(m, "path", 4096); + break; + } + if (target.empty()) { + const auto folder = kind == "texture" ? "texture-packs/" : "custom-tracks/"; + target = std::string(folder) + text(output, "filename", 256); + const auto initial = fs::u8path(target); + for (unsigned n = 2; path_exists(child(config, target)); ++n) { + if (n > 10000) + throw Failure("Too many copies of this mod are installed."); + target = + path_text(initial.parent_path() / (path_text(initial.stem()) + "-" + std::to_string(n) + + path_text(initial.extension()))); + } + } + } + output["target"] = target; + const auto source = fs::u8path(text(output, "source", 4096)); + if (tree_hash(source, stop) != text(output, "content", 64)) + throw Failure("Prepared mod content changed before publication."); + if (path_exists(child(config, target))) { + const auto current = tree_hash(child(config, target), stop); + if ((kind == "legacy-group" || kind == "legacy-review" || kind == "code" || kind == "vehicle" || kind == "vehicle-portrait") && + current != text(output, "content", 64)) + throw Failure("An immutable mod identity has different content. Existing files were kept."); + bool shared = false; + for (const auto& r : registry["records"]) + if (text(r, "instance", 32) != instance && !r.value("stored", false)) + for (const auto& m : r.value("members", Json::array())) + if (text(m, "path", 4096) == target) + shared = true; + if (shared && current != text(output, "content", 64)) { + if (kind != "texture") + throw Failure("Another installed package uses this game identity. Keep the update as a " + "separate edition or remove the conflicting copy first."); + const auto initial = fs::u8path(target); + for (unsigned n = 2; path_exists(child(config, target)); ++n) { + if (n > 10000) + throw Failure("Too many copies of this texture pack are installed."); + target = + path_text(initial.parent_path() / (path_text(initial.stem()) + "-" + std::to_string(n) + + path_text(initial.extension()))); + } + output["target"] = target; + } + } + put(source, target, stop); + if (kind == "texture") { + output["absolute"] = path_text(child(config, target)); + publish_store_texture(output, previous); + const auto packs = dkr::runtime::texture_packs::snapshot(true); + for (const auto& pack : packs) + if (pack.path.lexically_normal() == child(config, target)) + members.push_back({{"key", "texture:" + pack.id}, + {"kind", "texture"}, + {"id", pack.id}, + {"name", proposed.title}, + {"path", target}, + {"digest", proposed.digest}, + {"content", output["content"]}, + {"enabled", pack.enabled}, + {"hidden", pack.hidden}, + {"priority", pack.priority}, + {"owner", text(output, "owner", 256)}}); + } else if (kind == "track") { + dkr::runtime::custom_tracks::reload(); + const auto id = text(output, "id", 256); + if (previous.contains("enabled")) { + std::string error; + if (!dkr::runtime::custom_tracks::set_enabled(id, previous.value("enabled", true), error)) + throw Failure(error); + } + members.push_back({{"key", "track:" + id}, + {"kind", "track"}, + {"id", id}, + {"name", text(output, "name", 200)}, + {"path", target}, + {"digest", proposed.digest}, + {"content", output["content"]}, + {"enabled", previous.value("enabled", true)}}); + } + } + if(prepared.native.value("vehicle",false)) { + progress(.92F,"Updating the vehicle library"); + Json other_owners=Json::array(); + for(const auto& r:registry["records"]) if(text(r,"instance",32)!=instance&&!r.value("stored",false)) + for(const auto& m:r.value("members",Json::array())) other_owners.push_back(m); + members=publish_store_vehicles(config,prepared,old.value("members",Json::array()),other_owners,stop); + } + // Code admission uses the existing verifier and trust model. Changed + // executable bytes are NEVER approved or enabled by a Store update. + if (prepared.native.contains("codeId")) { + progress(.92F, "Updating Custom Magic Code settings"); + dkr::mods::code::Library library(config / "mods/custom-magic-codes", version); + Json previous = Json::object(); + for (const auto& local : inventory) + if (text(local, "kind", 40) == "code" && local.value("selected", false) && + text(local, "id", 256) == text(prepared.native, "codeId", 256)) + previous = local; + for (const auto& m : old.value("members", Json::array())) + if (text(m, "kind", 40) == "code" && text(m, "id", 256) != text(prepared.native, "codeId", 256)) + warnings.push_back("The publisher changed the code mod's ID. Its previous settings and data remain " + "in the protected backup; review the new mod's defaults before approval."); + const auto payload = dkr::mods::read_file(fs::u8path(text(prepared.outputs.front(), "source", 4096)), + dkr::mods::code::MaxPackage); + const auto package = dkr::mods::code::inspect(payload, version); + const auto digest = library.import(payload); + if (digest != proposed.digest) + throw Failure("Prepared code package differs from its verified Store release."); + bool replacing_selected = old.empty(); + for (const auto& m : old.value("members", Json::array())) + if (text(m, "digest", 64) == text(previous, "digest", 64)) + replacing_selected = true; + if (!previous.empty() && replacing_selected && text(previous, "digest", 64) != digest) { + library.select(digest); + Json values = Json::object(); + for (const auto& [key, value] : previous.value("settings", Json::object()).items()) { + auto trial = values; + trial[key] = value; + try { + dkr::mods::code::configuration(*package, trial); + values = std::move(trial); + } catch (...) { + warnings.push_back( + "Setting '" + key + + "' changed in this release. Its original value is kept in the transaction backup."); + } + } + library.settings(digest, values); + if (previous.contains("data")) + library.data(digest, previous["data"]); + } + // Retire the replaced catalogue edition even when another edition + // (not this one) was selected. Never remove another receipt's blob. + for (const auto& m : old.value("members", Json::array())) + if (text(m, "kind", 40) == "code" && text(m, "digest", 64) != digest) { + bool shared = false; + for (const auto& r : registry["records"]) + if (text(r, "instance", 32) != instance && !r.value("stored", false)) + for (const auto& n : r.value("members", Json::array())) + if (text(n, "digest", 64) == text(m, "digest", 64)) + shared = true; + if (!shared) + library.remove(text(m, "digest", 64)); + } + } + if (prepared.native.contains("members") && !prepared.native.contains("codeId") && !prepared.native.value("vehicle",false)) { + progress(.93F, "Updating the legacy library"); + // Build survivor groups in private storage. The live library never + // receives a second, nested removal journal during Store commit. + retire_legacy(old, members, stop); + for (auto& n : members) + for (const auto& m : old.value("members", Json::array())) + if (text(n, "kind", 40) == text(m, "kind", 40) && n.value("slot", -1) == m.value("slot", -2) && + text(n, "revision", 32) == text(m, "revision", 32)) { + const auto kind = text(n, "kind", 40); + dkr::mods::TrackCatalog catalog(kind == "legacy-character" + ? dkr::mods::TrackCatalog::Kind::Character + : dkr::mods::TrackCatalog::Kind::Track); + catalog.configure(config / "mods/legacy", worker_path); + auto wait = [&] { + const auto deadline = Clock::now() + std::chrono::minutes(5); + while (catalog.snapshot()->busy) { + if (stop.stop_requested() || Clock::now() > deadline) { + catalog.cancel(); + throw Failure( + "Preserving the previous mod settings timed out. The update was rolled back."); + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + if (!catalog.snapshot()->succeeded) + throw Failure(catalog.snapshot()->result); + }; + wait(); + if (m.value("hidden", false)) { + if (!catalog.set_hidden(text(n, "id", 256), true)) + throw Failure("Could not preserve hidden state."); + wait(); + } else if (m.value("enabled", false)) { + if (!catalog.set_enabled(text(n, "id", 256), true)) + throw Failure("Could not preserve enabled state."); + wait(); + } + n["enabled"] = m.value("enabled", false); + n["hidden"] = m.value("hidden", false); + } + } + // Bundled resources can disappear in a publisher's next version. + // Retire only this receipt's obsolete outputs, not other owners' files. + for (const auto& m : old.value("members", Json::array())) { + const auto kind = text(m, "kind", 40), path = text(m, "path", 4096); + if ((kind != "texture" && kind != "track") || path.empty()) + continue; + bool keep = false; + for (const auto& n : members) + if (text(n, "path", 4096) == path) + keep = true; + for (const auto& r : registry["records"]) + if (text(r, "instance", 32) != instance && !r.value("stored", false)) + for (const auto& n : r.value("members", Json::array())) + if (text(n, "path", 4096) == path) + keep = true; + if (!keep && path_exists(child(config, path))) { + protect(path); + discard(config, child(config, path)); + } + } + progress(.96F, "Refreshing the affected library"); + restore_native(native_changes.first, native_changes.second); + } + if (!stored) + for (auto& m : members) { + const auto path = text(m, "path", 4096); + if (!path.empty()) + m["content"] = text(m, "kind", 40) == "track" ? track_hash(child(config, path), stop) + : tree_hash(child(config, path), stop); + } + progress(.98F, "Saving the installation receipt"); + Json record = {{"instance", mode == "replace" && instance_id(instance) ? instance : uuid()}, + {"item", proposed.item}, + {"release", proposed.release}, + {"title", proposed.title}, + {"category", proposed.category}, + {"version", proposed.version}, + {"digest", proposed.digest}, + {"members", members}, + {"follow", old.value("follow", true)}, + {"skip", ""}, + {"origin", "Mod Store"}, + {"stored", stored}, + {"state", stored ? "Stored edition" : "Installed"}, + {"file", path_text(proposed.file.lexically_relative(config))}, + {"bytes", proposed.total}, + {"transaction", path_text(transaction.filename())}, + {"warnings", warnings}, + {"prepared", {{"outputs", prepared.outputs}, {"native", prepared.native}}}}; + for (const auto& item : catalogue) + if (text(item, "id", 160) == proposed.item) + for (const auto& release : item.value("releases", Json::array())) + if (text(release, "id", 160) == proposed.release) + record["publishedAt"] = text(release, "createdAt", 80); + if (!old.empty()) { + old.erase("previous"); + old.erase("exact"); + record["previous"] = old; + } + if (mode == "replace" && instance_id(instance)) + for (auto it = registry["records"].begin(); it != registry["records"].end();) + if (text(*it, "instance", 32) == instance) + it = registry["records"].erase(it); + else + ++it; + registry["records"].push_back(record); + save(); + journal["phase"] = "committed"; + write(transaction / "journal.json", journal); + journal = Json::object(); + lease.reset(); + progress(1.F, stored ? "Edition saved" : "Mod installed"); + update([&](InstallView& s) { + s.records = registry["records"]; + s.busy = false; + s.choosing = false; + s.succeeded = true; + s.completed_key = proposed.key; + s.result = + stored + ? "New edition saved. Use it from Installed mods; only one edition of this game identity can be active." + : prepared.native.contains("codeId") ? "Code mod installed. Changed code needs approval before it can run." + : "Mod installed."; + if (!warnings.empty()) + s.result += " Some settings need review; their previous values are backed up."; + ++s.completion; + }); +} +void InstallManager::policy(std::string instance, bool follow, std::string skip) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (p.state.busy || !p.state.ready) + return; + p.state.busy = true; + p.state.error.clear(); + p.state.stage = "Saving update preferences"; + p.publish(); + } + if (!p.queue([&p, instance = std::move(instance), follow, skip = std::move(skip)](std::stop_token stop) { + p.proposed = {}; + p.lease = std::make_unique(p.root / "install.lock"); + p.load_registry(); + for (auto& r : p.registry["records"]) + if (text(r, "instance", 32) == instance) { + r["follow"] = follow; + r["skip"] = skip; + } + p.save(); + p.refresh(stop); + p.lease.reset(); + p.update([](InstallView& s) { s.busy = false; }); + })) + p.update([](InstallView& s) { s.busy = false; }); +} +void InstallManager::link(std::string local_key, std::string item_id) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (p.state.busy || !p.state.ready) + return; + p.state.busy = true; + p.state.error.clear(); + p.state.stage = "Linking installed mod"; + p.publish(); + } + if (!p.queue([&p, local_key = std::move(local_key), item_id = std::move(item_id)](std::stop_token stop) { + p.proposed = {}; + p.lease = std::make_unique(p.root / "install.lock"); + p.load_registry(); + for (const auto& item : p.catalogue) + if (text(item, "id", 160) == item_id) + for (const auto& local : p.inventory) + if (text(local, "key", 512) == local_key) { + for (const auto& r : p.registry["records"]) + if (!r.value("stored", false)) + for (const auto& m : r["members"]) + if (same_native_member(m, local)) + throw Failure( + "This local installation is already linked to a Store listing."); + const auto kind = text(local, "kind", 40), category = text(item, "category", 32); + if (!((kind == "code" && category == "magic") || + (kind == "texture" && category == "textures") || + (kind == "vehicle" && vehicle_store_category(category)) || + (kind == "legacy-character" && category == "characters") || + ((kind == "track" || kind == "legacy-track") && category == "tracks"))) + throw Failure("Choose a Store listing for the same type of mod."); + // Explicit user association is not a proof of a particular release. + p.registry["records"].push_back({{"instance", uuid()}, + {"item", item_id}, + {"title", text(item, "title", 200)}, + {"category", text(item, "category", 32)}, + {"version", "Local version"}, + {"release", ""}, + {"digest", text(local, "digest", 64)}, + {"members", Json::array({local})}, + {"follow", true}, + {"skip", ""}, + {"stored", false}, + {"origin", "Manually linked"}}); + if (p.registry.contains("ignored")) { + auto kept = Json::array(); + for (const auto& key : p.registry["ignored"]) + if (key != local_key) + kept.push_back(key); + p.registry["ignored"] = kept; + } + p.save(); + p.refresh(stop); + p.lease.reset(); + p.update([](InstallView& s) { s.busy = false; }); + return; + } + p.lease.reset(); + p.update([](InstallView& s) { s.busy = false; }); + })) + p.update([](InstallView& s) { s.busy = false; }); +} +void InstallManager::forget(std::string instance, bool remove_files) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (!p.safe || p.state.busy || !p.state.ready) + return; + p.state.busy = true; + p.state.error.clear(); + p.publish(); + } + if (!p.queue([&p, instance = std::move(instance), remove_files](std::stop_token stop) { + p.remove_instance(instance, remove_files, stop); + })) + p.update([](InstallView& s) { s.busy = false; }); +} +void InstallManager::Impl::remove_instance(std::string instance, bool remove_files, std::stop_token stop) { + proposed = {}; + lease = std::make_unique(root / "install.lock"); + load_registry(); + Json old = Json::object(); + for (const auto& record : registry["records"]) + if (text(record, "instance", 32) == instance) + old = record; + if (instance.starts_with("local:")) + for (const auto& local : inventory) + if ("local:" + text(local, "key", 512) == instance) + old = {{"instance", instance}, {"members", Json::array({local})}, {"stored", false}}; + if (old.empty()) + throw Failure("This installation is no longer available."); + { + std::lock_guard lock(mutex); + if (!safe) + throw Failure("Leave the game or online session before removing mods."); + } + transaction = root / "transactions" / uuid(); + directories(transaction); + begin_journal(); + if (instance.starts_with("local:") && remove_files) { + const auto selected = old["members"].front(); + for (auto& record : registry["records"]) { + auto kept = Json::array(); + for (const auto& m : record["members"]) + if (!(text(m, "kind", 40) == text(selected, "kind", 40) && + text(m, "id", 256) == text(selected, "id", 256) && + (text(m, "kind", 40) != "track" || member_path_identity(m) == member_path_identity(selected)))) + kept.push_back(m); + record["members"] = kept; + } + } + if (!remove_files) { + if (!registry.contains("ignored")) + registry["ignored"] = Json::array(); + for (const auto& member : old["members"]) + registry["ignored"].push_back(member["key"]); + } + if (remove_files && !old.value("stored", false)) { + const auto native_changes = protect_metadata(old["members"], Json::array()); + retire_legacy(old, Json::array(), stop); + for (const auto& member : old["members"]) { + const auto kind = text(member, "kind", 40), path = text(member, "path", 4096); + bool shared = false; + for (const auto& record : registry["records"]) + if (text(record, "instance", 32) != instance && !record.value("stored", false)) + for (const auto& other : record.value("members", Json::array())) + if (same_native_member(member, other) || (!path.empty() && text(other, "path", 4096) == path)) + shared = true; + if (shared) + continue; + const bool known = std::any_of(inventory.begin(), inventory.end(), + [&](const Json& local) { return same_native_member(member, local); }); + if (!known) { + // A stale receipt must not delete a new, unrelated installation + // which later reused the same directory or derived native ID. + const auto expected = text(member, "content", 64); + if (kind == "code" || path.empty() || !hash_string(expected) || !path_exists(child(config, path))) + continue; + const auto actual = + kind == "track" ? track_hash(child(config, path), stop) : tree_hash(child(config, path), stop); + if (actual != expected) + continue; + } + if (kind == "vehicle") { + remove_store_vehicle(config,member,stop); + } else if (kind == "code") { + dkr::mods::code::Library library(config / "mods/custom-magic-codes", version); + library.remove(text(member, "digest", 64)); + } else if ((kind == "texture" || kind == "track") && !path.empty() && path_exists(child(config, path))) { + protect(path); + if (kind == "track") { + if (known) { + std::string error; + if (!dkr::runtime::custom_tracks::uninstall(text(member, "id", 256), error)) + throw Failure(error); + } else { + // The owned, proof-checked copy is shadowed by another + // native track with the same ID. Delete only this exact + // directory, never the other copy chosen by ID. + discard(config, child(config, path)); + } + } else + discard(config, child(config, path)); + } + } + restore_native(native_changes.first, native_changes.second); + } + for (auto it = registry["records"].begin(); it != registry["records"].end();) + if (text(*it, "instance", 32) == instance) + it = registry["records"].erase(it); + else + ++it; + save(); + journal["phase"] = "committed"; + write(transaction / "journal.json", journal); + journal = Json::object(); + lease.reset(); + update([&](InstallView& s) { + s.records = registry["records"]; + s.busy = false; + s.succeeded = true; + s.completed_key.clear(); + s.result = remove_files ? "Selected installation removed. Its protected copy is recoverable in Mod Store " + "history; saves and source downloads are kept." + : "Store association removed. The installed mod was kept."; + ++s.completion; + }); +} +bool InstallManager::activate(std::string instance, bool previous, std::vector roms) { + auto& p = *impl_; + { + std::lock_guard lock(p.mutex); + if (!p.safe || p.state.busy || !p.state.ready) + return false; + p.state.busy = true; + p.state.error.clear(); + p.publish(); + } + if (!p.queue([&p, instance = std::move(instance), previous, roms = std::move(roms)](std::stop_token stop) { + Json record; + for (const auto& r : p.registry["records"]) + if (text(r, "instance", 32) == instance) + record = r; + if (record.is_null()) + throw Failure("That mod edition is no longer registered."); + const auto selected = previous ? record.value("previous", Json::object()) : record; + if (selected.empty()) + throw Failure("No previous edition is available."); + Download job; + job.item = text(selected, "item", 160); + job.release = text(selected, "release", 160); + job.title = text(selected, "title", 200); + job.category = text(selected, "category", 32); + job.version = text(selected, "version", 80); + job.digest = text(selected, "digest", 64); + job.total = selected.value("bytes", 0ULL); + job.file = child(p.config, text(selected, "file", 4096)); + job.key = job.item + "_" + job.release; + job.instance = instance; + job.intent = "activate"; + p.proposed = job; + p.prepare(roms, stop); + })) { + p.update([](InstallView& s) { s.busy = false; }); + return false; + } + return true; +} +} // namespace dkr::store diff --git a/runtime-recomp/src/game/mod_store_install.hpp b/runtime-recomp/src/game/mod_store_install.hpp new file mode 100644 index 00000000..aafb9d81 --- /dev/null +++ b/runtime-recomp/src/game/mod_store_install.hpp @@ -0,0 +1,70 @@ +#pragma once +#include "mod_store.hpp" +#include +#include +#include + +namespace dkr::store { +inline std::filesystem::path store_io_path(const std::filesystem::path& path) { +#ifdef _WIN32 + auto preferred = std::filesystem::absolute(path).lexically_normal(); + preferred.make_preferred(); + const auto absolute = preferred.native(); + if (absolute.starts_with(L"\\\\?\\")) + return preferred; + if (absolute.starts_with(L"\\\\")) + return std::filesystem::path(L"\\\\?\\UNC\\" + absolute.substr(2)); + return std::filesystem::path(L"\\\\?\\" + absolute); +#else + return path; +#endif +} +struct InstallView { + Json records = Json::array(), updates = Json::array(), duplicates = Json::array(), unlinked = Json::array(); + Json proposal = Json::object(); + std::string stage, error, result, completed_key; + std::uint64_t generation = 0, completion = 0, updates_revision = 0; + float progress = 0; + bool busy = false, choosing = false, succeeded = false, ready = false; +}; +// All filesystem/decoder work belongs to this serial background service. +// UI supplies immutable native-library inventory and an explicit safe-to-write +// lease. No proposal can mutate game content before its duplicate decision. +class InstallManager { + public: + InstallManager(); + ~InstallManager(); + void configure(std::filesystem::path config, std::string version, std::filesystem::path worker); + std::shared_ptr view() const; + void refresh(Json items, Json inventory); + bool propose(Download job, std::vector roms); + void decide(std::string mode, std::string instance = {}); + void policy(std::string instance, bool follow, std::string skip = {}); + void forget(std::string instance, bool remove_files = false); + void link(std::string local_key, std::string item); + bool activate(std::string instance, bool previous, std::vector roms); + void safe_to_write(bool); + void retry_recovery(); + + private: + struct Impl; + std::unique_ptr impl_; +}; +InstallManager& installations(); +Json current_release(const Json& item); +// Read-only linked-cache input is copied to a fresh ordinary file, then the +// normal strict verifier checks the copy. No global updater guard is relaxed. +bool recover_cached_package(const std::filesystem::path& folder, const std::filesystem::path& target, + const std::string& digest, std::uint64_t bytes, std::stop_token); +struct StorePrepared { + Json outputs = Json::array(); + Json native = Json::object(); +}; +// Narrow staged first-party adapters. These only prepare private output. +StorePrepared prepare_store_texture(const std::filesystem::path& source, const std::filesystem::path& stage, + const std::string& title, std::stop_token, + const std::function&); +StorePrepared prepare_store_track(const std::filesystem::path& source, const std::filesystem::path& stage, + const std::string& title, std::stop_token); +void publish_store_texture(const Json& output, const Json& previous); +} // namespace dkr::store diff --git a/runtime-recomp/src/game/mod_store_management_ui.inl b/runtime-recomp/src/game/mod_store_management_ui.inl new file mode 100644 index 00000000..58d2fc61 --- /dev/null +++ b/runtime-recomp/src/game/mod_store_management_ui.inl @@ -0,0 +1,402 @@ +// Shared native-library inventory. Called only when a native catalogue changes +// or a bounded idle refresh is due; no file scan runs in the frame loop. +std::string StoreLower(std::string value) { + for (auto& c : value) + c = char(std::tolower(static_cast(c))); + return value; +} +std::vector StoreOriginalRoms() { + std::vector result; + for (const auto& entry : LoadRomCatalog()) { + result.push_back(entry.path); + if (result.size() == 8) + break; + } + return result; +} +StoreJson StoreNativeInventory() { + StoreJson result = StoreJson::array(); + const auto relative = [](const std::filesystem::path& path) { + // The track reader returns \\?\ paths on Windows. Compare both sides + // in that native representation before deriving the portable receipt. + const auto value = dkr::store::store_io_path(path) + .lexically_relative(dkr::store::store_io_path(g_config_directory)) + .generic_u8string(); + return std::string(value.begin(), value.end()); + }; + const auto legacy = g_legacy_imports.snapshot(); + for (const bool characters : {false, true}) { + const auto catalog = characters ? legacy.characters : legacy.tracks; + if (!catalog) + continue; + for (const auto& item : catalog->tracks) + result.push_back({{"kind", characters ? "legacy-character" : "legacy-track"}, + {"key", std::string(characters ? "legacy-character:" : "legacy-track:") + item.id + ":" + + item.revision}, + {"id", item.id}, + {"name", item.name}, + {"revision", item.revision}, + {"digest", item.patch}, + {"group", item.group}, + {"slot", characters ? item.base_character : item.carrier}, + {"enabled", item.enabled}, + {"hidden", item.hidden}, + {"path", std::string("mods/legacy/") + + (characters ? "prepared-characters/" : "prepared/") + item.group}, + {"bytes", item.managed_bytes}}); + } + for (const auto& car : StoreVehicleInventory()) result.push_back(car); + for (const auto& item : g_code_library.view()->items) + result.push_back({{"kind", "code"}, + {"key", "code:" + item.digest}, + {"id", item.id}, + {"name", item.name}, + {"digest", item.digest}, + {"path", "mods/custom-magic-codes/packages/" + item.digest + ".dkrcode"}, + {"version", item.version}, + {"enabled", item.enabled}, + {"selected", item.selected}, + {"approved", item.approved}, + {"settings", item.settings}, + {"data", item.data}}); + for (const auto& pack : dkr::runtime::texture_packs::snapshot(true)) + if (pack.compatible) + result.push_back({{"kind", "texture"}, + {"key", "texture:" + pack.id}, + {"id", pack.id}, + {"name", pack.name}, + {"path", relative(pack.path)}, + {"enabled", pack.enabled}, + {"hidden", pack.hidden}, + {"priority", pack.priority}, + {"owner", pack.owner_track_id}, + {"bytes", pack.managed_size_bytes}}); + for (const auto& track : dkr::runtime::custom_tracks::tracks()) + if (dkr::runtime::custom_tracks::is_installed(track)) + result.push_back({{"kind", "track"}, + {"key", "track:" + track.id}, + {"id", track.id}, + {"name", track.name}, + {"path", relative(track.source)}, + {"enabled", track.enabled}}); + return result; +} +void StoreManagerInstall(const dkr::store::Download& job, bool locked) { + if (locked || StoreVehiclesBusy() || g_legacy_imports.snapshot().busy || g_code_library.view()->busy || TexturePackImportRunning() || + DialogJobRunning()) + return; + if (dkr::store::installations().propose(job, StoreOriginalRoms())) { + dkr::store::service().begin_install(job.key); + g_mod_store.pending_key = job.key; + } +} +void StoreQueueRelease(const StoreJson& item, const StoreJson& release, std::string intent = {}, + std::string instance = {}) { + const auto key = dkr::store::download_key(StoreText(item, "id", 160), StoreText(release, "id", 160), instance); + g_mod_store.install_after_download.insert(key); + dkr::store::service().download(item, release, std::move(intent), std::move(instance)); +} +void StoreManagerPump() { + auto& manager = dkr::store::installations(); + const auto view = manager.view(); + const bool safe = !dkr::runtime::netplay::session().active() && !g_online_mod_host_request.has_value() && + !g_mod_launch.snapshot().modal && !g_legacy_imports.snapshot().busy && + !g_code_library.view()->busy && !StoreVehiclesBusy() && !TexturePackImportRunning() && !DialogJobRunning(); + manager.safe_to_write(safe); + static std::uint64_t completion = 0, last_code = UINT64_MAX, last_texture = UINT64_MAX, last_names = UINT64_MAX; + static std::shared_ptr last_tracks, last_characters; + static const void* last_items = nullptr; + static std::shared_ptr last_vehicles; + static bool refresh_pending = false; + static auto last_refresh = std::chrono::steady_clock::time_point{}; + const auto now = std::chrono::steady_clock::now(); + if (view->completion != completion) { + completion = view->completion; + if (!view->completed_key.empty()) + dkr::store::service().installed(view->completed_key, view->succeeded, + view->succeeded ? view->result + : view->error.empty() ? view->result + : view->error); + g_mod_store.pending_key.clear(); + refresh_pending = true; + last_refresh = std::chrono::steady_clock::time_point{}; + } + if (refresh_pending && safe && !view->busy) { + g_legacy_imports.refresh(); + g_code_library.refresh(); + StoreRefreshVehicles(); + refresh_pending = false; + return; // Wait for fresh native snapshots before reconciling receipts. + } + const auto store = dkr::store::service().view(); + const auto legacy = g_legacy_imports.snapshot(); + const auto vehicles = StoreVehiclesSnapshot(); + const auto code = g_code_library.view()->generation, texture = dkr::runtime::texture_packs::generation(), + names = dkr::store::library_names_generation(); + if (safe && view->ready && !view->busy && + (vehicles != last_vehicles || legacy.tracks != last_tracks || legacy.characters != last_characters || last_code != code || + last_texture != texture || last_names != names || last_items != store->items.get() || + now - last_refresh > std::chrono::seconds(30))) { + last_vehicles = vehicles; + last_tracks = legacy.tracks; + last_characters = legacy.characters; + last_code = code; + last_texture = texture; + last_names = names; + last_items = store->items.get(); + last_refresh = now; + manager.refresh(*store->items, StoreNativeInventory()); + } + static auto next_check = now + std::chrono::minutes(30); + if (safe && !view->busy && now >= next_check) { + next_check = now + std::chrono::minutes(30); + dkr::store::service().refresh(); + } +} +bool StoreBeginManagerModal(const char* name, float width, float height) { + const auto* viewport = ImGui::GetMainViewport(); + const ImVec2 size{std::min(width, std::max(1.F, viewport->WorkSize.x - 24.F)), + std::min(height, std::max(1.F, viewport->WorkSize.y - 24.F))}; + ImGui::SetNextWindowPos( + {viewport->WorkPos.x + viewport->WorkSize.x * .5F, viewport->WorkPos.y + viewport->WorkSize.y * .5F}, + ImGuiCond_Always, {.5F, .5F}); + ImGui::SetNextWindowSize(size, ImGuiCond_Always); + // Do not route through BeginPaddedModal: its desktop fallback replaces the + // caller's minimum-width constraint with zero. Wrapped/custom-drawn content + // then feeds a near-zero available width back into AlwaysAutoResize. + return BeginPaddockModalWindow(name, + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse, + true); +} +bool StoreModalBack() { + return ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight) || ImGui::IsKeyPressed(ImGuiKey_Escape)); +} +void StoreModalBody(const char* id, float width, std::initializer_list footer) { + // Reserve the actual wrapped footer height, including on smaller displays. + const auto look = PaddockLook(PaddockButtonKind::Plain); + const auto type = PaddockReading(look.font_px, true, look.line); + float x = 0, y = 0, row = 0; + for (const auto* label : footer) { + const float w = std::min(width, std::max({90.F, PaddockButtonWidth(label, PaddockButtonKind::Plain), + PaddockButtonWidth(label, PaddockButtonKind::Primary)})); + if (x > 0 && x + w > width) { + x = 0; + y += row + 8.F; + row = 0; + } + const auto lines = PaddockWrap(type, label, std::max(1.F, w - look.padding_x * 2.F - 2.F)); + const auto primary = PaddockLook(PaddockButtonKind::Primary); + const auto primary_type = PaddockReading(primary.font_px, true, primary.line); + const auto primary_lines = PaddockWrap(primary_type, label, std::max(1.F, w - primary.padding_x * 2.F - 2.F)); + row = std::max({row, 44.F, type.line * lines.size() + look.padding_y * 2.F + 2.F, + primary_type.line * primary_lines.size() + primary.padding_y * 2.F + 2.F}); + x += w + 8.F; + } + const float reserved = y + row + 9.F + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild(id, {0, std::max(1.F, ImGui::GetContentRegionAvail().y - reserved)}, false); +} +void StoreManagerModals() { + PaddockFlatScope flat; + const auto view = dkr::store::installations().view(); + static std::string choice_job; + static int selected = 0; + if (view->choosing) { + const auto job = StoreText(view->proposal, "job", 512); + if (job != choice_job || !ImGui::IsPopupOpen("Mod already installed")) { + choice_job = job; + selected = 0; + ImGui::OpenPopup("Mod already installed"); + } + } + if (StoreBeginManagerModal("Mod already installed", 760.F, 580.F)) { + if (!view->choosing) { + // A worker failure/cancellation must not leave a stale modal above + // the launcher with no actionable proposal. + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + const float width = std::max(1.F, ImGui::GetContentRegionAvail().x); + const auto candidates = view->proposal.value("candidates", StoreJson::array()); + const bool valid = candidates.is_array() && !candidates.empty(); + StoreModalBody("Duplicate details", width, {"Replace & reinstall", "Add as new", "Cancel"}); + const float body_width = std::max(1.F, ImGui::GetContentRegionAvail().x); + StoreHeading(StoreText(view->proposal, "title", 200), body_width); + if (valid) { + selected = std::clamp(selected, 0, int(candidates.size() - 1)); + StoreNote(candidates[selected].value("exact", false) + ? "An existing installation matches this mod." + : "Possible duplicate: the names match, but the content is not confirmed identical.", + body_width); + for (int i = 0; i < int(candidates.size()); ++i) { + const auto& item = candidates[i]; + if (StoreButton(std::string(i == selected ? "> " : "") + StoreText(item, "title", 200) + " - " + + StoreText(item, "version", 80) + "##candidate" + std::to_string(i), + body_width)) + selected = i; + } + StoreNote("Incoming version: " + StoreText(view->proposal, "version", 80), body_width); + if (view->proposal.value("modified", false)) + StoreNote( + "The installed files have changed locally. Replace only if you want to discard those edits. " + "A protected copy will be retained.", + body_width); + if (view->proposal.value("code", false)) + StoreNote( + "Changed code requires your approval before it can run. Compatible settings and user data " + "are retained.", + body_width); + if (!view->proposal.value("canRunCopies", false)) + StoreNote("Add as new keeps a separate edition. Only one edition of the same game identity can be " + "in use.", + body_width); + if (view->proposal.value("identityChanged", false)) + StoreNote("The publisher changed this mod's internal identity. Replace only if intended; cups or " + "settings using the old identity may need attention. The old edition is retained.", + body_width); + } else { + StoreNote("This installation choice is no longer available. Cancel and retry the download.", + body_width); + } + ImGui::EndChild(); + // Cancel is outside the scroll area and never disabled by missing data. + std::vector>> actions; + if (valid) { + actions = { + {"Replace & reinstall", + [&] { + dkr::store::installations().decide("replace", + StoreText(candidates[selected], "instance", 512)); + ImGui::CloseCurrentPopup(); + }}, + {"Add as new", + [] { + dkr::store::installations().decide("add"); + ImGui::CloseCurrentPopup(); + }}, + }; + } + actions.push_back({"Cancel", [] { + dkr::store::installations().decide("cancel"); + ImGui::CloseCurrentPopup(); + }}); + StoreActionLine(actions, width); + if (StoreModalBack()) { + dkr::store::installations().decide("cancel"); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + static std::uint64_t offered = 0; + if (!view->busy && !view->choosing && !view->updates.empty() && view->updates_revision != offered && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && g_mod_store.popup_request.empty() && + !g_mod_store.remove_requested && !g_mod_store.link_requested) { + offered = view->updates_revision; + ImGui::OpenPopup("Mod updates available"); + } + if (StoreBeginManagerModal("Mod updates available", 760.F, 340.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Update details", width, {"Review updates", "Later"}); + const float body_width = std::max(1.F, ImGui::GetContentRegionAvail().x); + StoreHeading("Updates for your installed mods", body_width); + StoreNote(std::to_string(view->updates.size()) + + " update(s) available. Nothing changes until you choose an update.", + body_width); + StoreNote("Code updates need approval before running. Your working versions are kept if preparation fails.", + body_width); + ImGui::EndChild(); + StoreActionLine({{"Review updates", + [] { + g_mods_page.entry_section = 4; + g_mods_page.section = 4; + g_page_navigation_request = kPageModsHacks; + g_mod_store.page = 5; + ImGui::CloseCurrentPopup(); + }}, + {"Later", [] { ImGui::CloseCurrentPopup(); }}}, + width); + if (StoreModalBack()) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + if (g_mod_store.remove_requested) { + g_mod_store.remove_requested = false; + ImGui::OpenPopup("Remove installed mod?"); + } + if (StoreBeginManagerModal("Remove installed mod?", 680.F, 380.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Removal details", width, {"Remove installation", "Keep files, unlink Store", "Cancel"}); + const float body_width = std::max(1.F, ImGui::GetContentRegionAvail().x); + StoreHeading("Remove this installation?", body_width); + StoreNote(g_mod_store.remove_title, body_width); + StoreNote("Only this local installation is removed. Saves, original downloads and copies owned by other " + "packages are kept. A protected copy is retained for recovery.", + body_width); + ImGui::EndChild(); + StoreActionLine({{"Remove installation", + [] { + if (!dkr::store::installations().view()->busy) { + dkr::store::installations().forget(g_mod_store.remove_instance, true); + ImGui::CloseCurrentPopup(); + } + }}, + {"Keep files, unlink Store", + [] { + if (!dkr::store::installations().view()->busy) { + dkr::store::installations().forget(g_mod_store.remove_instance, false); + ImGui::CloseCurrentPopup(); + } + }}, + {"Cancel", [] { ImGui::CloseCurrentPopup(); }}}, + width); + if (StoreModalBack()) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + if (g_mod_store.link_requested) { + g_mod_store.link_requested = false; + g_mod_store.fields["link-search"].clear(); + ImGui::OpenPopup("Link installed mod"); + } + if (StoreBeginManagerModal("Link installed mod", 760.F, 640.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Store listings", width, {"Cancel"}); + const float body_width = std::max(1.F, ImGui::GetContentRegionAvail().x); + StoreHeading("Choose its Store listing", body_width); + StoreNote("This links update notifications. It does not claim your local files match a published release, or " + "replace anything now.", + body_width); + StoreField("link-search", "Find a Store mod", body_width, 200); + const auto query = StoreLower(g_mod_store.fields["link-search"]); + unsigned shown = 0, matching = 0; + for (const auto& item : *dkr::store::service().view()->items) { + if (!query.empty() && StoreLower(StoreText(item, "title", 200)).find(query) == std::string::npos) + continue; + ++matching; + if (++shown > unsigned(StoreLimit("link-list"))) + continue; + if (StoreButton(StoreText(item, "title", 200) + "##link" + StoreText(item, "id", 160), body_width)) { + dkr::store::installations().link(g_mod_store.link_key, StoreText(item, "id", 160)); + ImGui::CloseCurrentPopup(); + } + } + StoreMore("link-list", matching, body_width); + ImGui::EndChild(); + if (StoreButton("Cancel")) + ImGui::CloseCurrentPopup(); + if (StoreModalBack()) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} +const StoreJson* StoreInstalledRelease(const StoreJson& item, const StoreJson& record) { + if (item.contains("releases")) + for (const auto& release : item["releases"]) + if (StoreText(release, "id", 160) == StoreText(record, "release", 160)) + return &release; + return nullptr; +} +#include "store_player_library.inl" diff --git a/runtime-recomp/src/game/mod_store_names.cpp b/runtime-recomp/src/game/mod_store_names.cpp new file mode 100644 index 00000000..2fef08b6 --- /dev/null +++ b/runtime-recomp/src/game/mod_store_names.cpp @@ -0,0 +1,272 @@ +#include "common/rt64_filesystem.h" +#include "common/rt64_filesystem_zip.h" +#include "mod_store.hpp" +#include "mod_store_install.hpp" +#include "runtime_texture_packs.hpp" +#include "updates/update_core.hpp" +#include +#include +#include +#include +#include + +namespace dkr::runtime::rice_texture { +bool matches_converted_archive(RT64::FileSystem&, const std::filesystem::path&, std::stop_token); +} + +namespace dkr::store { +namespace { +struct Names { + std::map mods, textures; + bool operator==(const Names&) const = default; +}; +std::mutex names_mutex; +Names names; +std::atomic names_generation{1}; +std::string utf8(const std::filesystem::path& p) { + const auto s = p.lexically_normal().generic_u8string(); + return {s.begin(), s.end()}; +} +std::string folded(std::string s) { + for (auto& c : s) + if (c >= 'A' && c <= 'Z') + c += 32; + return s; +} +bool digest(const std::string& s) { + return s.size() == 64 && s.find_first_not_of("0123456789abcdef") == s.npos; +} +Json read(const std::filesystem::path& path) { + try { + if (std::filesystem::is_symlink(path)) + return Json::object(); + return Json::parse(dkr::updates::read_text(path, 4 * 1024 * 1024)); + } catch (...) { + return Json::object(); + } +} +std::string stamp(const std::filesystem::path& path, std::stop_token stop) { + // Cheap invalidation of a display-only association. No cached name is an + // asset proof. Actual matching below compares every decoded texture pixel. + std::map entries; + if (std::filesystem::is_symlink(path)) + return {}; + if (std::filesystem::is_regular_file(path)) { + entries["file"] = std::to_string(std::filesystem::file_size(path)) + ":" + + std::to_string(std::filesystem::last_write_time(path).time_since_epoch().count()); + } else { + for (const auto& entry : std::filesystem::directory_iterator(path)) { + if (stop.stop_requested() || entries.size() >= 200005 || entry.is_symlink()) + return {}; + if (!entry.is_regular_file()) + return {}; + entries[utf8(entry.path().filename())] = std::to_string(entry.file_size()) + ":" + + std::to_string(entry.last_write_time().time_since_epoch().count()); + } + } + const auto value = Json(entries).dump(); + return dkr::updates::hash(std::span(reinterpret_cast(value.data()), value.size())); +} +void publish(Names value) { + std::lock_guard lock(names_mutex); + if (value == names) + return; + names = std::move(value); + ++names_generation; +} +} // namespace + +bool generic_package_name(const std::string& text) { + const auto value = folded(text); + if (value == "package") + return true; + return value.starts_with("package-") && value.size() > 8 && value.find_first_not_of("0123456789", 8) == value.npos; +} + +std::string package_filename(const std::string& title, const std::string& published) { + const auto extension = folded(std::filesystem::u8path(published).extension().string()); + std::string stem; + for (unsigned char c : title) { + if (c < 32 || c == 127 || std::string_view("\\/:*?\"<>|").find(char(c)) != std::string_view::npos) + stem += '_'; + else + stem += char(c); + } + if (stem.size() > 112) { + std::size_t end = 112; + while (end && (static_cast(stem[end]) & 0xC0) == 0x80) + --end; + stem.resize(end); + } + while (!stem.empty() && (stem.back() == '.' || stem.back() == ' ')) + stem.pop_back(); + while (!stem.empty() && stem.front() == ' ') + stem.erase(stem.begin()); + if (stem.empty() || generic_package_name(stem)) + stem = std::filesystem::u8path(published).stem().string(); + if (stem.empty() || stem == "." || stem == "..") + stem = "Downloaded mod"; + const auto device = folded(stem.substr(0, stem.find('.'))); + if (device == "con" || device == "prn" || device == "aux" || device == "nul" || + (device.size() == 4 && (device.starts_with("com") || device.starts_with("lpt")) && device[3] >= '0' && + device[3] <= '9')) + stem = "_" + stem; + return stem + extension; +} + +std::uint64_t library_names_generation() { + return names_generation.load(); +} +std::string library_display_name(const std::string& hash, const std::string& category, const std::string& fallback) { + if (!generic_package_name(fallback)) + return fallback; + std::lock_guard lock(names_mutex); + const auto found = names.mods.find(category + ":" + hash); + return found == names.mods.end() ? fallback : found->second; +} +std::string texture_display_name(const std::filesystem::path& path, const std::string& fallback) { + std::lock_guard lock(names_mutex); + const auto found = names.textures.find(utf8(path)); + return found == names.textures.end() ? fallback : found->second; +} + +void refresh_library_names(const std::filesystem::path& config, const Json& items, std::stop_token stop) { + if (!items.is_array()) + return; + Names next; + const auto root = config / "mod-store"; + const auto saved = read(root / "installed-names.json"); + Json records = Json::object(); + struct Candidate { + std::string title, file, hash; + }; + std::map candidates; + std::map verified_sources; + std::set ambiguous; + for (const auto& item : items) { + const auto title = text(item, "title", 200), category = text(item, "category", 32); + if (title.empty() || !item.contains("releases") || !item["releases"].is_array()) + continue; + for (const auto& release : item["releases"]) { + const auto hash = text(release, "sha256", 64), file = text(release, "fileName", 180); + if (!digest(hash) || file.empty() || file.find_first_of("\\/:*?\"<>|") != file.npos) + continue; + const auto key = category + ":" + hash; + auto [entry, added] = next.mods.emplace(key, title); + if (!added && entry->second != title) + ambiguous.insert(key); + if (category == "textures") + candidates.emplace(hash, Candidate{title, file, hash}); + } + } + for (const auto& key : ambiguous) { + next.mods.erase(key); + if (key.starts_with("textures:")) + candidates.erase(key.substr(9)); + } + // Keep current texture labels visible during the background audit; the + // final publication drops associations whose installed data has changed. + { + Names first = next; + { + std::lock_guard lock(names_mutex); + first.textures = names.textures; + } + publish(std::move(first)); + } + try { + for (const auto& pack : dkr::runtime::texture_packs::snapshot(true)) { + if (stop.stop_requested()) + return; + if (pack.origin != dkr::runtime::texture_packs::Origin::User || !pack.compatible) + continue; + const auto path = pack.path.lexically_normal(); + if (path.parent_path() != (config / "texture-packs").lexically_normal()) + continue; + const auto key = utf8(path); + const bool have_saved = + saved.contains("textures") && saved["textures"].is_object() && saved["textures"].contains(key); + const auto path_stem = utf8(path.stem()); + bool relevant = have_saved || generic_package_name(path_stem); + for (const auto& [sha, candidate] : candidates) { + const auto label = + utf8(std::filesystem::u8path(package_filename(candidate.title, candidate.file)).stem()); + relevant |= path_stem == label || path_stem.starts_with(label + "-"); + } + if (!relevant) + continue; // Never walk unrelated installed packs. + const auto proof = stamp(path, stop); + if (proof.empty()) + continue; + if (have_saved) { + const auto& row = saved["textures"][key]; + const auto title = text(row, "title", 200); + if (text(row, "stamp", 64) == proof && digest(text(row, "sha256", 64)) && !title.empty()) { + next.textures[key] = title; + records[key] = row; + continue; + } + } + std::string matched, hash; + for (const auto& [sha, candidate] : candidates) { + if (stop.stop_requested()) + return; + const auto label = std::filesystem::u8path(package_filename(candidate.title, candidate.file)).stem(); + if (!generic_package_name(path_stem) && path_stem != utf8(label) && + !path_stem.starts_with(utf8(label) + "-")) + continue; + const auto directory = root / "downloads" / sha; + if (std::filesystem::is_symlink(directory)) + continue; + std::filesystem::path source; + if (verified_sources.contains(sha)) + source = verified_sources.at(sha); + else + for (const auto& name : {package_filename(candidate.title, candidate.file), candidate.file, + "package" + std::filesystem::path(candidate.file).extension().string()}) { + const auto file = directory / std::filesystem::u8path(name); + if (!std::filesystem::is_symlink(file) && std::filesystem::is_regular_file(file)) { + source = file; + break; + } + } + if (source.empty()) + continue; + const auto canonical=directory/std::filesystem::u8path(package_filename(candidate.title,candidate.file)); + if(!verified_sources.contains(sha)) { + if(!recover_cached_package(directory,canonical,sha,std::filesystem::file_size(source),stop))continue; + source=canonical; + } + if (!verified_sources.contains(sha) && + dkr::updates::hash_file(source, 2ULL * 1024 * 1024 * 1024) != sha) + continue; + verified_sources.emplace(sha, source); + bool same = false; + if (std::filesystem::is_regular_file(path)) + same = dkr::updates::hash_file(path, 4ULL * 1024 * 1024 * 1024) == sha; + else { + auto archive = RT64::FileSystemZip::create(source, ""); + same = archive && dkr::runtime::rice_texture::matches_converted_archive(*archive, path, stop); + } + if (!same) + continue; + if (!matched.empty() && matched != candidate.title) { + matched.clear(); + break; + } + matched = candidate.title; + hash = sha; + } + if (!matched.empty()) { + next.textures[key] = matched; + records[key] = {{"title", matched}, {"sha256", hash}, {"stamp", proof}}; + } + } + if (stop.stop_requested()) + return; + dkr::updates::durable_write(root / "installed-names.json", Json{{"schema", 1}, {"textures", records}}.dump()); + } catch (...) { /* Cosmetic repair cannot break the library or sign-in. */ + } + publish(std::move(next)); +} +} // namespace dkr::store diff --git a/runtime-recomp/src/game/mod_store_style.inl b/runtime-recomp/src/game/mod_store_style.inl new file mode 100644 index 00000000..fc45938b --- /dev/null +++ b/runtime-recomp/src/game/mod_store_style.inl @@ -0,0 +1,186 @@ +// Portal palette and bounded, controller-navigable layouts. No child windows: +// the launcher owns the single page scroll surface. +struct StorePanel { + ImVec2 origin; + float width, padding = 16.F, minimum; + ImDrawList* draw; + ImDrawListSplitter layers; + StorePanel(float requested, bool selected = false, float min_height = 0) + : origin(ImGui::GetCursorScreenPos()), width(std::max(1.F, requested)), minimum(min_height), + draw(ImGui::GetWindowDrawList()), selected_(selected) { + layers.Split(draw, 2); + layers.SetCurrentChannel(draw, 1); + ImGui::SetCursorScreenPos({origin.x + padding, origin.y + padding}); + ImGui::BeginGroup(); + } + float inner() const { + return std::max(1.F, width - 2 * padding); + } + float bottom() const { + return origin.y + minimum - padding; + } + ~StorePanel() { + ImGui::EndGroup(); + const float height = std::max(minimum, ImGui::GetCursorScreenPos().y - origin.y + padding); + layers.SetCurrentChannel(draw, 0); + PaddockPanelStyle style; + style.radii = {10.F, 23.F, 10.F, 10.F}; + style.fill = PaddockRgb(selected_ ? 0x123F56 : 0x0B304B); + style.border = PaddockRgb(selected_ ? 0xFFD28A : 0x8E794C); + style.border_width = selected_ ? 2.F : 1.F; + style.drop = PaddockRgb(0x041822); + style.drop_offset = 4.F; + PaddockPanel(draw, origin, {origin.x + width, origin.y + height}, style); + layers.Merge(draw); + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy({width, height}); + PaddockGap(16.F); + } + + private: + bool selected_; +}; +struct StoreFormLayout { + bool columns = false; + float width; + StoreFormLayout(const char* id, float available, float maximum = 680.F) { + columns = available >= maximum + 280.F && + ImGui::BeginTable(id, 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoSavedSettings, + {std::min(available, maximum + 360.F), 0}); + if (columns) { + ImGui::TableSetupColumn("Form", ImGuiTableColumnFlags_WidthFixed, maximum); + ImGui::TableSetupColumn("Summary", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + } + width = std::min(maximum, ImGui::GetContentRegionAvail().x); + } + void sidebar(const std::function& content) { + if (columns) { + ImGui::TableSetColumnIndex(1); + content(ImGui::GetContentRegionAvail().x); + ImGui::TableSetColumnIndex(0); + } + } + ~StoreFormLayout() { + if (columns) + ImGui::EndTable(); + } +}; +void StoreBadge(const std::string& label, float width, PaddockTone tone = PaddockTone::Neutral) { + const auto size = PaddockBadgeSize(label); + if (size.x <= width) { + PaddockBadge(ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos(), label, tone); + ImGui::Dummy(size); + } else + StoreNote(label, width); + PaddockGap(8.F); +} +std::string StoreCategory(const StoreJson& item) { + for (unsigned i = 1; i < kStoreCategoryIds.size(); ++i) + if (StoreText(item, "category", 32) == kStoreCategoryIds[i]) + return kStoreCategoryNames[i]; + return "Mod"; +} +std::string StoreRating(const StoreJson& item) { + if (item.contains("rating") && item["rating"].is_object()) { + const auto& rating = item["rating"]; + const int count = StoreInt(rating, "count"); + if (count && rating.contains("average") && rating["average"].is_number()) { + char value[100]; + std::snprintf(value, sizeof(value), "%.1f / 5 | %d review%s", + std::clamp(rating["average"].get(), 0., 5.), count, count == 1 ? "" : "s"); + return value; + } + } + return "No reviews yet"; +} +std::string StoreSummary(const StoreJson& item) { + auto value = StoreText(item, "summary", 4000); + if (value.size() > 135) { + auto end = value.rfind(' ', 132); + if (end == value.npos || end < 60) + end = 132; + while (end && (static_cast(value[end]) & 0xC0) == 0x80) + --end; + value = value.substr(0, end) + "..."; + } + return value; +} +int StoreTabLine(const std::vector& labels, int selected, float width, const char* id) { + if (labels.empty()) + return 0; + selected = std::clamp(selected, 0, int(labels.size()) - 1); + ImGui::PushID(id); + const auto start = ImGui::GetCursorScreenPos(); + float x = 0, y = 0, row_height = 0; + for (int i = 0; i < int(labels.size()); ++i) { + const auto kind = i == selected ? PaddockButtonKind::Primary : PaddockButtonKind::Plain; + const float size = std::min(width, PaddockButtonWidth(labels[i].c_str(), kind)); + if (x > 0 && x + size > width) { + x = 0; + y += row_height + 8; + row_height = 0; + } + ImGui::SetCursorScreenPos({start.x + x, start.y + y}); + ImGui::PushID(i); + if (PaddockButton((labels[i] + "###tab").c_str(), kind, size, 44.F)) + selected = i; + StoreControlFocus(); + row_height = std::max(row_height, ImGui::GetItemRectSize().y); + ImGui::PopID(); + x += size + 8; + } + ImGui::SetCursorScreenPos({start.x, start.y + y + row_height + 16}); + ImGui::PopID(); + return selected; +} +void StoreFieldPair(const char* key_a, const char* label_a, const char* key_b, const char* label_b, float width, + std::size_t maximum_a = 100, std::size_t maximum_b = 100) { + if (width >= 620.F && + ImGui::BeginTable(key_a, 2, ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoSavedSettings, {width, 0})) { + ImGui::TableNextColumn(); + StoreField(key_a, label_a, ImGui::GetContentRegionAvail().x, maximum_a); + ImGui::TableNextColumn(); + StoreField(key_b, label_b, ImGui::GetContentRegionAvail().x, maximum_b); + ImGui::EndTable(); + } else { + StoreField(key_a, label_a, width, maximum_a); + StoreField(key_b, label_b, width, maximum_b); + } +} +std::string StoreDate(const StoreJson& row, const char* key = "updatedAt") { + auto date = StoreText(row, key, 80); + if (date.size() >= 10) + return date.substr(0, 10); + return date.empty() ? "Not supplied" : date; +} +void StoreNotice(const std::string& title, const std::string& message, float width, bool error = false) { + StorePanel panel(std::min(width, 980.F), error); + if (!title.empty()) + StoreHeading(title, panel.inner(), 18.F); + StoreNote(message, panel.inner(), !error); +} +void StoreTags(const StoreJson& row, float width) { + if (!row.contains("tags") || !row["tags"].is_array()) + return; + const auto start = ImGui::GetCursorScreenPos(); + float x = 0, height = 0; + unsigned shown = 0; + for (const auto& tag : row["tags"]) { + if (!tag.is_string() || shown == 2) + break; + const auto label = tag.get(); + const auto size = PaddockBadgeSize(label); + if (x + size.x > width) + continue; + PaddockBadge(ImGui::GetWindowDrawList(), {start.x + x, start.y}, label, PaddockTone::Neutral); + x += size.x + 6.F; + height = std::max(height, size.y); + ++shown; + } + if (height) { + ImGui::Dummy({width, height}); + PaddockGap(8.F); + } +} diff --git a/runtime-recomp/src/game/mod_store_ui.inl b/runtime-recomp/src/game/mod_store_ui.inl new file mode 100644 index 00000000..44beb781 --- /dev/null +++ b/runtime-recomp/src/game/mod_store_ui.inl @@ -0,0 +1,783 @@ +// Native Mod Store: included only in the staged launcher translation unit. +// No browser engine, remote script, store texture or network worker enters RT64. +namespace { +using StoreJson = dkr::store::Json; +struct ModStoreUi { + int page = 0, category = 0, sort = 0, detail_tab = 0, staff_tab = 0, rating = 5, request_kind = 0; + int visible = 12, request_category = 1, request_relation = 0, request_page = 0, account_page = 0, + assets_visible = 24; + int installed_section = 0, installed_category = 0, installed_state = 0, installed_sort = 0, download_section = 0, + download_filter = 0, editor_step = 0, staff_filter = 0, gallery_index = 0; + std::string installed_selected, update_selected, update_detail, my_request, staff_report, staff_listing, + upload_selection, message_page, last_error, last_status, nav_screen; + std::map limits; + struct Position { + float scroll = 0; + ImGuiID focus = 0; + }; + std::map positions; + ImGuiID restore_focus = 0, page_focus = 0; + ImGuiID last_row_action = 0; + bool focus_first = false, restoring = false, picker_upload = false, review_editing = false, request_picker = false, + body_navigation = false; + std::function pending_navigation; + StoreJson gallery = StoreJson::array(), release_details = StoreJson::object(); + std::string gallery_title, release_title, popup_request, confirm_title, confirm_message, confirm_label, + confirm_identity; + std::function confirmation; + std::map texture_used; + std::set frame_images; + std::uint64_t image_frame = 0; + std::uint64_t editor_generation = 0, pending_editor_generation = 0; + bool opened = false, detail = false, featured = false, metadata = false, preview = false, related_open = false, + editing = false, dirty = false, previewed = false, request_credit = false; + bool checks[4]{}; + std::string selected, request, editor, report, upload_kind = "download", upload_category = "tracks", pending_key; + std::string identity, last_saved, screen, request_target, filter_key; + std::string remove_instance, remove_title; + bool remove_requested = false; + std::string link_key; + bool link_requested = false; + std::set selected_updates; + std::set install_after_download; + unsigned pending_type = 0; + std::uint64_t pending_generation = 0; + std::uint64_t mutation = 0; + StoreJson draft = StoreJson::object(); + std::map fields; + std::map textures; + std::vector retired_textures; + std::set failed_covers; +} g_mod_store; +void StoreControlFocus() { + if (!g_mod_store.body_navigation || (ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup)) + return; + const auto id = ImGui::GetItemID(); + if (g_mod_store.restore_focus == id || g_mod_store.focus_first) { + ImGui::SetFocusID(id, ImGui::GetCurrentWindow()); + g_mod_store.restore_focus = 0; + g_mod_store.focus_first = false; + } + if (ImGui::IsItemFocused()) + g_mod_store.page_focus = id; +} +SDL_Renderer* g_mod_store_renderer = nullptr; +constexpr std::array kStoreCategoryNames = { + "All", "Tracks", "Characters", "Vehicles", "Custom Magic Codes", "Texture packs"}; +constexpr std::array kStoreCategoryIds = {"", "tracks", "characters", "legacy", "magic", "textures"}; + +std::string StoreText(const StoreJson& row, const char* key, std::size_t max = 4096) { + return dkr::store::text(row, key, max); +} +const StoreJson& StoreItems(const dkr::store::Snapshot& store) { + return *store.items; +} +int StoreInt(const StoreJson& value, const char* key, int fallback = 0) { + if (!value.is_object() || !value.contains(key) || !value[key].is_number_integer()) + return fallback; + const auto number = value[key].get(); + return number >= 0 && number <= INT_MAX ? int(number) : fallback; +} +const StoreJson* StoreFind(const StoreJson& rows, const std::string& id) { + if (rows.is_array()) + for (const auto& row : rows) + if (StoreText(row, "id", 160) == id) + return &row; + return nullptr; +} +void StoreNote(const std::string& value, float width, bool muted = true) { + PaddockText(PaddockReading(14.F, false, 1.45F), PaddockRgb(muted ? 0xBDCFD9 : 0xFFF4D3), value, width); +} +void StoreHeading(const std::string& value, float width, float size = 23.F) { + PaddockHeading(value.c_str(), size, 0xFFCD67, width); + PaddockGap(9.F); +} +bool StoreButton(const std::string& label, float width = 0, bool primary = false) { + width = std::min( + width > 0 ? width + : PaddockButtonWidth(label.c_str(), primary ? PaddockButtonKind::Primary : PaddockButtonKind::Plain), + std::max(1.F, ImGui::GetContentRegionAvail().x)); + const bool pressed = + PaddockButton(label.c_str(), primary ? PaddockButtonKind::Primary : PaddockButtonKind::Plain, width, 44.F); + StoreControlFocus(); + return pressed; +} +bool StoreCheck(const std::string& label, bool& value, float width) { + ImGui::PushID(label.c_str()); + ImGui::BeginGroup(); + const bool changed = ImGui::Checkbox("##value", &value); + StoreControlFocus(); + const float box = ImGui::GetItemRectSize().x; + const float check_height = ImGui::GetItemRectSize().y; + ImGui::SameLine(0, 8.F); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.F, (check_height - 20.3F) * .5F)); + const auto* end = ImGui::FindRenderedTextEnd(label.c_str()); + StoreNote(std::string(label.c_str(), end), std::max(1.F, width - box - 8.F), false); + ImGui::EndGroup(); + ImGui::PopID(); + return changed; +} +void StoreActionLine(const std::vector>>& actions, float width) { + const auto start = ImGui::GetCursorScreenPos(); + float x = 0, y = 0, height = 0; + for (const auto& [label, action] : actions) { + const float w = std::min(width, std::max(90.F, PaddockButtonWidth(label.c_str(), PaddockButtonKind::Plain))); + if (x > 0 && x + w > width) { + x = 0; + y += height + 8; + height = 0; + } + ImGui::SetCursorScreenPos({start.x + x, start.y + y}); + if (StoreButton(label, w)) + action(); + height = std::max(height, ImGui::GetItemRectSize().y); + x += w + 8; + } + ImGui::SetCursorScreenPos({start.x, start.y + y + height + 9}); +} +void StoreField(const char* key, const char* label, float width, std::size_t maximum = 500, bool multiline = false, bool review = false) { + width = std::max(1.F, std::min(width, ImGui::GetContentRegionAvail().x)); + auto& value = g_mod_store.fields[key]; + ImGui::PushID(key); + const auto open_keyboard = [&] { + const std::string name = key, identity = g_mod_store.identity; + RequestCustomTextKeyboard(label, value, maximum + 1, [name, identity](const std::string& accepted) { + if (g_mod_store.identity == identity) { + g_mod_store.fields[name] = accepted; + if (name.starts_with("draft-")) { + g_mod_store.dirty = true; + g_mod_store.previewed = false; + } + } + }); + }; + const float keyboard = std::max(104.F, PaddockButtonWidth("Keyboard", PaddockButtonKind::Plain)); + if (width >= keyboard + 150.F) { + const auto origin = ImGui::GetCursorScreenPos(); + StoreNote(label, width - keyboard - 10.F); + const float bottom = ImGui::GetCursorScreenPos().y; + ImGui::SetCursorScreenPos({origin.x + width - keyboard, origin.y}); + if (StoreButton("Keyboard", keyboard)) + open_keyboard(); + ImGui::SetCursorScreenPos({origin.x, std::max(bottom, ImGui::GetCursorScreenPos().y) + 4.F}); + } else { + StoreNote(label, width); + if (StoreButton("Keyboard", std::min(width, keyboard))) + open_keyboard(); + } + const float edit = width; + ImGui::SetNextItemWidth(edit); + std::vector buffer(maximum + 1, 0); + const auto copy = std::min(maximum, value.size()); + std::memcpy(buffer.data(), value.data(), copy); + if (review) + ImGui::PushFont(PaddockReading(14.F, false, 1.45F).font); + const bool changed = review && multiline + ? DkrReviewInputText("##text", buffer.data(), buffer.size(), {edit, ImGui::GetTextLineHeight() * 6.F + ImGui::GetStyle().FramePadding.y * 2.F}) + : multiline ? ImGui::InputTextMultiline("##text", buffer.data(), buffer.size(), {edit, 90.F}) + : ImGui::InputText("##text", buffer.data(), buffer.size()); + if (review) + ImGui::PopFont(); + const bool controller_keyboard = ImGui::IsItemFocused() && ImGui::IsKeyPressed(ImGuiKey_GamepadFaceDown) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); + StoreControlFocus(); + if (changed) { + value = buffer.data(); + if (std::string(key).starts_with("draft-")) { + g_mod_store.dirty = true; + g_mod_store.previewed = false; + } + } + if (controller_keyboard) + open_keyboard(); + ImGui::PopID(); + PaddockGap(9.F); +} +int StoreCombo(const char* id, int current, const std::vector& labels, float width) { + if (labels.empty()) + return 0; + current = std::clamp(current, 0, int(labels.size() - 1)); + StoreNote(id, width); + PaddockGap(4.F); + ImGui::PushID(id); + ImGui::SetNextItemWidth(std::min(width, ImGui::GetContentRegionAvail().x)); + const bool open = ImGui::BeginCombo("##value", labels[current].c_str()); + StoreControlFocus(); + if (open) { + for (int i = 0; i < int(labels.size()); ++i) + if (ImGui::Selectable(labels[i].c_str(), i == current)) + current = i; + ImGui::EndCombo(); + } + ImGui::PopID(); + PaddockGap(9.F); + return current; +} +#include "mod_store_style.inl" +#include "store_polish_controls.inl" +void ReleaseModStoreTextures() { + ReleaseVehicleTextures(); + for (auto& [url, texture] : g_mod_store.textures) + if (texture) + SDL_DestroyTexture(texture); + g_mod_store.textures.clear(); + g_mod_store.texture_used.clear(); + g_mod_store.frame_images.clear(); + for (auto* texture : g_mod_store.retired_textures) + SDL_DestroyTexture(texture); + g_mod_store.retired_textures.clear(); + g_mod_store_renderer = nullptr; +} +void StoreCover(const StoreJson& row, float width, float height = 0, bool contain = false) { + height = height > 0 ? height : width * .53F; + if (!ImGui::IsRectVisible({width, height})) { + ImGui::Dummy({width, height}); + PaddockGap(9.F); + return; + } + auto url = StoreText(row, "image", 2048); + if (url.empty()) + url = StoreText(row, "imageUrl", 2048); + // These are website asset aliases, not missing external URLs. + if (url.empty() || url == "pattern" || url == "pattern-two") + url = "https://positron-portal.com/dkr-r/assets/" + + (url == "pattern-two" ? std::string("pattern-two") : std::string("pattern")) + ".png"; + else if (url.starts_with("assets/") || url.starts_with("images/")) + url = "https://positron-portal.com/dkr-r/" + url; + else if (url.starts_with("/dkr-r/")) + url = "https://positron-portal.com" + url; + height = height > 0 ? height : width * .53F; + const auto at = ImGui::GetCursorScreenPos(); + SDL_Texture* texture = nullptr; + g_mod_store.frame_images.insert(url); + g_mod_store.texture_used[url] = g_mod_store.image_frame; + if (g_mod_store_renderer && dkr::store::trusted_url(url)) { + auto found = g_mod_store.textures.find(url); + if (found != g_mod_store.textures.end()) + texture = found->second; + else if (auto image = dkr::store::service().image(url)) { + if (g_mod_store.textures.size() >= 32) { + auto old = g_mod_store.textures.end(); + for (auto it = g_mod_store.textures.begin(); it != g_mod_store.textures.end(); ++it) + if (!g_mod_store.frame_images.contains(it->first) && + (old == g_mod_store.textures.end() || + g_mod_store.texture_used[it->first] < g_mod_store.texture_used[old->first])) + old = it; + // Draw data may still reference this image until presentation. + if (old != g_mod_store.textures.end()) { + g_mod_store.retired_textures.push_back(old->second); + g_mod_store.texture_used.erase(old->first); + g_mod_store.textures.erase(old); + } + } + if (g_mod_store.textures.size() < 32) + texture = SDL_CreateTexture(g_mod_store_renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STATIC, + int(image->width), int(image->height)); + if (texture) { + if (SDL_UpdateTexture(texture, nullptr, image->rgba.data(), int(image->width * 4))) { + SDL_DestroyTexture(texture); + texture = nullptr; + } else { + SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); + g_mod_store.textures[url] = texture; + } + } + } else + dkr::store::service().request_image(url); + } + if (texture) { + const auto image = dkr::store::service().image(url); + const float source = image ? float(image->width) / float(image->height) : 1.8F, + dest = width / std::max(1.F, height); + ImVec2 a{0, 0}, b{1, 1}; + ImVec2 image_min = at, image_max{at.x + width, at.y + height}; + if (contain) { + ImGui::GetWindowDrawList()->AddRectFilled(at, image_max, PaddockCol(0x041822), 6.F); + if (source > dest) { + const float view = width / source; + image_min.y += (height - view) * .5F; + image_max.y = image_min.y + view; + } else { + const float view = height * source; + image_min.x += (width - view) * .5F; + image_max.x = image_min.x + view; + } + } else if (source > dest) { + const float visible = dest / source; + a.x = (1 - visible) * .5F; + b.x = 1 - a.x; + } else { + const float visible = source / dest; + a.y = (1 - visible) * .5F; + b.y = 1 - a.y; + } + ImGui::GetWindowDrawList()->AddImage(reinterpret_cast(texture), image_min, image_max, a, b); + } else + ImGui::GetWindowDrawList()->AddRectFilled(at, {at.x + width, at.y + height}, PaddockCol(0x184459), 8.F); + ImGui::Dummy({width, height}); + PaddockGap(9.F); +} +std::vector StoreFiltered(const dkr::store::Snapshot& store) { + std::vector rows; + std::string search = g_mod_store.fields["search"]; + std::transform(search.begin(), search.end(), search.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + if (StoreItems(store).is_array()) + for (const auto& row : StoreItems(store)) { + if (row.contains("archived") && row["archived"] == true) + continue; + if (g_mod_store.category && StoreText(row, "category", 32) != kStoreCategoryIds[g_mod_store.category]) + continue; + std::string match = StoreText(row, "title", 200) + " " + StoreText(row, "author", 200); + if (row.contains("tags") && row["tags"].is_array()) + for (const auto& tag : row["tags"]) + if (tag.is_string()) + match += " " + tag.get(); + std::transform(match.begin(), match.end(), match.begin(), + [](unsigned char c) { return char(std::tolower(c)); }); + if (search.empty() || match.find(search) != std::string::npos) + rows.push_back(&row); + } + std::stable_sort(rows.begin(), rows.end(), [&](const auto* a, const auto* b) { + if (g_mod_store.sort == 1) + return StoreText(*a, "title", 200) < StoreText(*b, "title", 200); + if (g_mod_store.sort == 2) { + auto score = [](const auto& row) { + if (!row.contains("rating") || !row["rating"].is_object()) + return 0.; + const auto& r = row["rating"]; + return r.contains("average") && r["average"].is_number() ? r["average"].template get() : 0.; + }; + const auto x = score(*a), y = score(*b); + if (x != y) + return x > y; + } else if (g_mod_store.sort == 0) { + const bool x = a->contains("featured") && (*a)["featured"] == true, + y = b->contains("featured") && (*b)["featured"] == true; + if (x != y) + return x > y; + } + return StoreText(*a, "updatedAt", 80) > StoreText(*b, "updatedAt", 80); + }); + return rows; +} +StoreJson StoreCurrentRelease(const StoreJson& row) { + return dkr::store::current_release(row); +} +void StoreOpenMod(const StoreJson& row, bool detail) { + g_mod_store.review_editing = false; + g_mod_store.selected = StoreText(row, "id", 160); + g_mod_store.detail = detail; + g_mod_store.detail_tab = 0; + dkr::store::service().reviews(g_mod_store.selected); +} +#include "mod_store_management_ui.inl" +#include "store_polish_modals.inl" +void StoreInstall(const dkr::store::Download& job, bool locked) { + StoreManagerInstall(job, locked); +} +void StorePumpInstall() { + StoreManagerPump(); +} +void PumpModStoreLauncher() { + StoreSyncIdentity(*dkr::store::service().view()); + ++g_mod_store.image_frame; + g_mod_store.frame_images.clear(); + static std::uint64_t named_texture_generation = UINT64_MAX; + const auto texture_generation = dkr::runtime::texture_packs::generation(); + if (named_texture_generation != texture_generation && dkr::store::service().refresh_names()) { + named_texture_generation = texture_generation; + } + for (auto* texture : g_mod_store.retired_textures) + SDL_DestroyTexture(texture); + g_mod_store.retired_textures.clear(); + StorePumpInstall(); + StoreManagerModals(); + StorePolishModals(); + const auto url = dkr::store::service().take_browser_url(); + if (!url.empty()) + SDL_OpenURL(url.c_str()); +} +void StoreDownloadButton(const StoreJson& row, const StoreJson& release, float width, bool compact = false) { + const auto digest = StoreText(release, "sha256", 64); + const auto managed = dkr::store::installations().view(); + bool installed_release = false, update_available = false; + std::string repair_instance, installed_instance, update_instance; + for (const auto& record : managed->records) { + if (StoreText(record, "item", 160) != StoreText(row, "id", 160) || StoreText(record, "digest", 64) != digest) + continue; + const auto state = StoreText(record, "state", 80); + if (state == "Installed" || record.value("stored", false)) { + installed_release = true; + installed_instance = StoreText(record, "instance", 32); + } else if (state == "Needs repair" || state == "Missing") + repair_instance = StoreText(record, "instance", 32); + } + for (const auto& update : managed->updates) + if (StoreText(update["item"], "id", 160) == StoreText(row, "id", 160)) { + update_available = true; + update_instance = StoreText(update, "instance", 32); + } + const bool selected_update = + update_available && StoreText(release, "id", 160) == StoreText(StoreCurrentRelease(row), "id", 160); + if (installed_release || selected_update) { + if (StoreButton(selected_update ? "Update available" : compact ? "Installed" : "Installed - manage", width)) { + g_mod_store.page = 4; + g_mod_store.installed_section = selected_update ? 1 : 0; + if (selected_update) + g_mod_store.update_detail = update_instance; + else + g_mod_store.installed_selected = installed_instance; + } + return; + } + if (!repair_instance.empty()) { + if (StoreButton("Repair install", width)) + StoreQueueRelease(row, release, "reinstall", repair_instance); + return; + } + const auto snapshot = dkr::store::service().view(); + const auto id = StoreText(row, "id", 160), version = StoreText(release, "id", 160); + const dkr::store::Download* found = nullptr; + for (const auto& job : snapshot->downloads) + if (job.item == id && job.release == version) { + found = &job; + break; + } + const bool retry = found && (found->state == "Failed" || found->state == "Paused"); + const bool ready = found && (found->state == "Ready to install" || found->state == "Install failed" || + found->state == "Installed"); + const bool installed = false; // Native receipts, never download history, establish installation. + const auto label = installed ? "Manage mod" + : ready ? "Install mod" + : retry ? "Retry download" + : found ? "View download" + : compact ? "Install" + : "Download & install"; + ImGui::BeginDisabled(version.empty()); + if (StoreButton(std::string(label) + "##download", width, true)) { + if (installed) { + g_mods_page.section = 0; + const auto category = StoreText(row, "category", 32); + g_mods_page.category = category == "magic" ? 2 : category == "characters" ? 1 : category == "legacy" || category == "vehicles" ? 3 : 0; + } else if (ready) { + StoreQueueRelease(row, release); + } else if (!found || retry) { + g_mod_store.install_after_download.insert(id + "_" + version); + dkr::store::service().download(row, release); + } else + g_mod_store.page = 2; + } + ImGui::EndDisabled(); + if (compact && !found && ImGui::IsItemHovered()) + ImGui::SetTooltip("Download, check and import this mod. Code permissions still need your approval."); + if (found && !installed && found->state != "Install failed") { + PaddockGap(5.F); + if (found->total && found->state != "Failed" && found->state != "Paused") + ImGui::ProgressBar(std::clamp(float(double(found->received) / double(found->total)), 0.F, 1.F), + {width, 7.F}, ""); + StoreNote(found->state == "Failed" ? "Download unavailable - see Downloads" + : found->state == "Installed" ? "Previously installed download" + : found->state, + width); + } +} +void StoreStartQueuedInstall(bool locked) { + if (locked || dkr::store::installations().view()->busy || !g_mod_store.pending_key.empty() || + StoreVehiclesBusy() || g_legacy_imports.snapshot().busy || g_code_library.view()->busy || TexturePackImportRunning() || + DialogJobRunning()) + return; + const auto store = dkr::store::service().view(); + for (const auto& job : store->downloads) { + if (!g_mod_store.install_after_download.contains(job.key)) + continue; + if (job.state == "Failed" || job.state == "Paused") { + g_mod_store.install_after_download.erase(job.key); + continue; + } + if (job.state == "Ready to install" || job.state == "Install failed") { + StoreInstall(job, false); + if (!g_mod_store.pending_key.empty()) + g_mod_store.install_after_download.erase(job.key); + return; + } + } +} +void StoreCardCover(const StoreJson& row, float width, float height, bool detail) { + const auto start = ImGui::GetCursorScreenPos(); + StoreCover(row, width, height); + const auto after = ImGui::GetCursorScreenPos(); + ImGui::SetCursorScreenPos(start); + const auto press = PaddockBeginPress("##cover", {width, height}); + PaddockEndPress(press, 8.F); + const auto label = StoreCategory(row); + if (PaddockBadgeSize(label).x <= width - 16.F) + PaddockBadge(ImGui::GetWindowDrawList(), {start.x + 8.F, start.y + 8.F}, label, PaddockTone::Neutral); + if (press.pressed) + StoreOpenMod(row, detail); + ImGui::SetCursorScreenPos(after); +} +void StoreGridCard(const StoreJson& row, float width, float title_height, float body_height) { + StorePanel card(width); + const auto inner = card.inner(); + StoreCardCover(row, inner, inner / 1.8F, true); + const auto title_start = ImGui::GetCursorScreenPos(); + const auto title_press = PaddockBeginPress("##title", {inner, title_height}); + PaddockTextStyle title_style; + title_style.colour = PaddockApply(PaddockRgb(title_press.hover > .1F ? 0xFFCD67 : 0xFFF6DA)); + PaddockTextAt(ImGui::GetWindowDrawList(), PaddockSign(22.F, 1.25F), title_start, inner, + StoreText(row, "title", 200), title_style); + PaddockEndPress(title_press, 4.F); + if (title_press.pressed) + StoreOpenMod(row, true); + PaddockGap(6.F); + StoreNote("by " + StoreText(row, "author", 100), inner); + PaddockGap(7.F); + StoreTags(row, inner); + ImGui::SetCursorScreenPos({title_start.x, std::max(ImGui::GetCursorScreenPos().y, title_start.y + body_height)}); + ImGui::Separator(); + PaddockGap(9.F); + if (inner >= 240.F && + ImGui::BeginTable("card-footer", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoSavedSettings, + {inner, 0})) { + ImGui::TableSetupColumn("Rating", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Install", ImGuiTableColumnFlags_WidthFixed, 130.F); + ImGui::TableNextColumn(); + StoreNote(StoreRating(row), ImGui::GetContentRegionAvail().x); + ImGui::TableNextColumn(); + StoreDownloadButton(row, StoreCurrentRelease(row), ImGui::GetContentRegionAvail().x, true); + ImGui::EndTable(); + } else { + StoreNote(StoreRating(row), inner); + PaddockGap(7.F); + StoreDownloadButton(row, StoreCurrentRelease(row), inner); + } +} +#include "store_player_details.inl" +void StoreBrowser(float width, const dkr::store::Snapshot& store) { + if (g_mod_store.detail) { + if (const auto* row = StoreFind(StoreItems(store), g_mod_store.selected)) + StoreDetails(*row, width, store, false); + else { + StoreNote("This listing is no longer available.", width); + if (StoreButton("Back to library")) + g_mod_store.detail = false; + } + return; + } + if (width >= 720.F && + ImGui::BeginTable("store-search-sort", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoSavedSettings, + {width, 0})) { + ImGui::TableSetupColumn("Search", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Sort", ImGuiTableColumnFlags_WidthFixed, 240.F); + ImGui::TableNextColumn(); + StoreField("search", "Search mods, creators or tags", ImGui::GetContentRegionAvail().x, 160); + ImGui::TableNextColumn(); + g_mod_store.sort = + StoreCombo("Sort by", g_mod_store.sort, {"Featured", "Name", "Top rated", "Recently updated"}, + ImGui::GetContentRegionAvail().x); + ImGui::EndTable(); + } else { + StoreField("search", "Search mods, creators or tags", width, 160); + g_mod_store.sort = StoreCombo("Sort by", g_mod_store.sort, + {"Featured", "Name", "Top rated", "Recently updated"}, std::min(width, 260.F)); + } + if (width >= 700.F) { + const auto chosen = StoreTabLine({"Library grid", "Browse and inspect"}, store.layout, std::min(width, 400.F), + "store-view-tabs"); + if (chosen != store.layout) + dkr::store::service().layout(chosen); + } else { + const auto chosen = StoreCombo("View", store.layout, {"Library grid", "Browse and inspect"}, width); + if (chosen != store.layout) + dkr::store::service().layout(chosen); + } + PaddockGap(12.F); + std::vector categories; + constexpr const char* short_categories[] = {"All downloads", "Tracks", "Characters", + "Vehicles", "Magic Codes", "Texture packs"}; + for (int i = 0; i < 6; ++i) { + std::size_t count = 0; + for (const auto& item : StoreItems(store)) + if (!(item.contains("archived") && item["archived"] == true) && + (i == 0 || StoreText(item, "category", 32) == kStoreCategoryIds[i])) + ++count; + categories.push_back(std::string(short_categories[i]) + " (" + std::to_string(count) + ")"); + } + const auto category = StoreTabLine(categories, g_mod_store.category, width, "store-category-tabs"); + if (category != g_mod_store.category) { + g_mod_store.category = category; + g_mod_store.visible = 12; + } + const auto filter_key = g_mod_store.fields["search"] + ":" + std::to_string(g_mod_store.category) + ":" + + std::to_string(g_mod_store.sort); + if (filter_key != g_mod_store.filter_key) { + g_mod_store.filter_key = filter_key; + g_mod_store.visible = 12; + } + auto rows = StoreFiltered(store); + StoreNote(std::to_string(rows.size()) + " mods" + (store.cached ? " - saved catalogue" : ""), width); + PaddockGap(12.F); + if (rows.empty()) { + StoreNotice(store.loading ? "Loading the library" : "No mods found", + store.loading ? "The catalogue is on its way." : "Try another search or category.", width); + if (!store.loading && StoreButton("Clear filters")) { + g_mod_store.fields["search"].clear(); + g_mod_store.category = 0; + } + return; + } + if (std::none_of(rows.begin(), rows.end(), + [&](const auto* row) { return StoreText(*row, "id", 160) == g_mod_store.selected; })) + g_mod_store.selected = StoreText(*rows.front(), "id", 160); + const int limit = std::min(int(rows.size()), g_mod_store.visible); + if (store.layout == 1 && width >= 760.F) { + if (ImGui::BeginTable("store-inspect", 2, ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Mods", ImGuiTableColumnFlags_WidthStretch, .75F); + ImGui::TableSetupColumn("Details", ImGuiTableColumnFlags_WidthStretch, 1.25F); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const float list_width = ImGui::GetContentRegionAvail().x; + for (int i = 0; i < limit; ++i) { + const auto& row = *rows[i]; + ImGui::PushID(StoreText(row, "id", 160).c_str()); + { + StorePanel card(list_width, StoreText(row, "id", 160) == g_mod_store.selected); + const float inner = card.inner(); + if (inner >= 240.F && + ImGui::BeginTable("inspect-row", 2, + ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoSavedSettings, + {inner, 0})) { + ImGui::TableSetupColumn("Cover", ImGuiTableColumnFlags_WidthFixed, 88.F); + ImGui::TableSetupColumn("Mod", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextColumn(); + StoreCardCover(row, 88.F, 64.F, false); + ImGui::TableNextColumn(); + if (StoreButton(StoreText(row, "title", 200) + "###select-mod", + ImGui::GetContentRegionAvail().x)) + StoreOpenMod(row, false); + StoreNote("by " + StoreText(row, "author", 100), ImGui::GetContentRegionAvail().x); + ImGui::EndTable(); + } else { + if (StoreButton(StoreText(row, "title", 200) + "###select-mod", inner)) + StoreOpenMod(row, false); + StoreNote("by " + StoreText(row, "author", 100), inner); + } + } + ImGui::PopID(); + } + ImGui::TableSetColumnIndex(1); + if (const auto* row = StoreFind(StoreItems(store), g_mod_store.selected)) + StoreDetails(*row, ImGui::GetContentRegionAvail().x, store, true); + ImGui::EndTable(); + } + } else { + const int columns = store.layout == 1 ? 1 : std::clamp(int((width + 16.F) / 290.F), 1, 4); + if (ImGui::BeginTable("store-grid", columns, + ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoSavedSettings, {width, 0})) { + for (int base = 0; base < limit; base += columns) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const float text_width = std::max(1.F, ImGui::GetContentRegionAvail().x - 32.F); + float title_height = 0, author_height = 0; + for (int j = base; j < std::min(limit, base + columns); ++j) { + title_height = + std::max(title_height, PaddockTextHeight(PaddockSign(22.F, 1.25F), + StoreText(*rows[j], "title", 200), text_width)); + author_height = std::max(author_height, + PaddockTextHeight(PaddockReading(14.F, false, 1.45F), + "by " + StoreText(*rows[j], "author", 100), text_width)); + } + for (int j = base; j < std::min(limit, base + columns); ++j) { + ImGui::TableSetColumnIndex(j - base); + const auto& row = *rows[j]; + ImGui::PushID(StoreText(row, "id", 160).c_str()); + StoreGridCard(row, ImGui::GetContentRegionAvail().x, title_height, + title_height + author_height + PaddockBadgeSize("x").y + 32.F + + 6.F * ImGui::GetStyle().ItemSpacing.y); + ImGui::PopID(); + } + } + ImGui::EndTable(); + } + } + if (limit < int(rows.size()) && StoreButton("Show more mods", std::min(240.F, width))) + g_mod_store.visible = std::min(10000, g_mod_store.visible + 12); +} +#include "store_player_activity.inl" +void StoreLoadDraft(const StoreJson& row) { + ++g_mod_store.editor_generation; + g_mod_store.editor_step = 0; + g_mod_store.editing = true; + g_mod_store.metadata = false; + g_mod_store.preview = false; + g_mod_store.related_open = false; + g_mod_store.draft = row; + const auto id = StoreText(row, "id", 160); + g_mod_store.editor = id.starts_with("draft_") ? id : std::string{}; + g_mod_store.dirty = g_mod_store.editor.empty(); + g_mod_store.previewed = false; + for (const char* key : + {"title", "author", "summary", "image", "version", "fileUrl", "fileName", "format", "requirements", "install"}) + g_mod_store.fields[std::string("draft-") + key] = StoreText(row, key, 6000); + g_mod_store.featured = row.contains("featured") && row["featured"] == true; + g_mod_store.fields["draft-category"] = StoreText(row, "category", 32); + g_mod_store.fields["draft-tags"].clear(); + if (row.contains("tags") && row["tags"].is_array()) + for (const auto& tag : row["tags"]) + if (tag.is_string()) { + if (!g_mod_store.fields["draft-tags"].empty()) + g_mod_store.fields["draft-tags"] += ", "; + g_mod_store.fields["draft-tags"] += tag.get(); + } +} +void StoreEnsureEditor() { + if (!g_mod_store.editing) { + g_mod_store.request.clear(); + StoreLoadDraft(StoreJson::object()); + g_mod_store.fields["draft-category"] = g_mod_store.upload_category; + } +} +StoreJson StoreDraft() { + auto value = g_mod_store.draft; + if (g_mod_store.editor.empty()) + value.erase("id"); + for (const char* key : + {"title", "author", "summary", "image", "version", "fileUrl", "fileName", "format", "requirements", "install"}) + value[key] = g_mod_store.fields[std::string("draft-") + key]; + value["category"] = g_mod_store.fields["draft-category"].empty() ? "tracks" : g_mod_store.fields["draft-category"]; + value["featured"] = g_mod_store.featured; + value["tags"] = StoreJson::array(); + std::stringstream stream(g_mod_store.fields["draft-tags"]); + std::string tag; + while (std::getline(stream, tag, ',')) { + const auto first = tag.find_first_not_of(" \t"), last = tag.find_last_not_of(" \t"); + if (first != tag.npos) + value["tags"].push_back(tag.substr(first, last - first + 1)); + } + return value; +} +void StoreChooseUpload(std::string kind, std::string resume = {}) { + const auto category = g_mod_store.upload_category; + StartDialogJob([kind, resume, category]() -> std::function { + if (NFD_Init() != NFD_OKAY) + return [] {}; + nfdu8char_t* result = nullptr; + const nfdfilteritem_t image[] = {{"Cover image", "png,jpg,jpeg,webp"}}, + packages[] = {{"Mod package", "zip,rtz,dkrmap,dkrkart,dkrhover,dkrplane,xdelta,nrm,dkrcode,bps,ips,7z"}}; + const auto dialog = NFD_OpenDialogU8(&result, kind == "image" ? image : packages, 1, nullptr); + std::filesystem::path path; + if (dialog == NFD_OKAY) { + path = std::filesystem::u8path(result); + NFD_FreePathU8(result); + } + NFD_Quit(); + return [path, kind, resume, category] { + if (!path.empty()) + dkr::store::service().upload(path, kind, category, resume); + }; + }); +} +#include "store_staff_pages.inl" +#include "store_account_page.inl" +#include "store_page_shell.inl" +} // namespace diff --git a/runtime-recomp/src/game/mods/legacy_asset_bus.cpp b/runtime-recomp/src/game/mods/legacy_asset_bus.cpp index 368a7c9b..df1c507f 100644 --- a/runtime-recomp/src/game/mods/legacy_asset_bus.cpp +++ b/runtime-recomp/src/game/mods/legacy_asset_bus.cpp @@ -1,3 +1,5 @@ +#include +#include #include "legacy_asset_bus.hpp" namespace dkr::mods { @@ -14,40 +16,76 @@ void AssetBus::Read::copy_to_guest(std::span rdram,std::uint32_t a if(!mount) throw Error("Virtual asset read has no owner."); mount->directory()->copy_to_guest(section,offset,rdram,address,length); } +namespace { +// Preserve AssetBus/Mount's admitted ABI. The shared_ptr control block owns +// this derived object, including pins to earlier identical section owners. +// All mounts in this composed bus are made here; no RTTI or raw downcast of +// caller-owned objects is involved. This metadata is prepared, never replayed. +struct SharedSectionMount final : AssetBus::Mount { + std::array hashes; + std::vector> pins; +}; +} std::shared_ptr AssetBus::mount(std::shared_ptr directory) { - if(!directory) throw Error("Cannot mount an absent immutable directory."); + if(!directory)throw Error("Cannot mount an absent immutable directory."); + auto mounted=std::make_shared(); + mounted->directory_=std::move(directory);mounted->pins.reserve(50); + const auto fingerprint=mounted->directory_->fingerprint(); + // Exact canonical section bytes, not package names, exporter metadata or + // record counts. Only one bounded section copy exists during hashing. + for(unsigned s=0;s<50;++s) + mounted->hashes[s]=sha256(mounted->directory_->read(s,0,mounted->directory_->section_size(s))); std::lock_guard lock(mutex_); - const auto fingerprint=directory->fingerprint(); - if(const auto found=banks_.find(fingerprint);found!=banks_.end()) { - if(auto live=found->second.owner.lock()) return live; - auto remount=std::make_shared(); - remount->directory_=std::move(directory);remount->bases_=found->second.bases; - // Binding is to actual base+override bytes, not just a package title. - for(unsigned s=0;s<50;++s) - if(remount->directory_->section_size(s)!=found->second.sizes[s]) + auto banks=banks_;auto ranges=ranges_; + auto previous=banks.find(fingerprint); + if(previous!=banks.end()) { + if(auto live=previous->second.owner.lock())return live; + for(unsigned s=0;s<50;++s) { + if(mounted->directory_->section_size(s)!=previous->second.sizes[s]) throw Error("An existing bank fingerprint changed section sizes."); - found->second.owner=remount;return remount; + const auto base=previous->second.bases[s]; + auto& range=ranges.at(base); + if(auto owner=banks.at(range.fingerprint).owner.lock())mounted->pins.push_back(std::move(owner)); + else range={fingerprint,s}; // Revive exactly the same immutable bytes. + } + mounted->bases_=previous->second.bases;previous->second.owner=mounted; + banks_.swap(banks);ranges_.swap(ranges);return mounted; } - if(banks_.size()>=MaxBanks) throw Error("This game session has reached its mounted-content limit."); - Bank bank; - std::uint64_t next=next_; + if(banks.size()>=MaxBanks)throw Error("This game session has reached its mounted-content limit."); + Bank bank;std::uint64_t next=next_,reused=0;unsigned shared=0; for(unsigned s=0;s<50;++s) { - bank.sizes[s]=directory->section_size(s); + bank.sizes[s]=mounted->directory_->section_size(s); + std::shared_ptr donor;std::uint32_t best=End; + // Section zero stays unique and monotonic: allocation_address() is the + // admitted world-clone ordering key. All other sections share ONLY + // with the same section number, length and complete SHA-256 identity. + if(s)for(const auto& [id,existing]:banks) { + if(existing.sizes[s]!=bank.sizes[s]||existing.bases[s]>=best)continue; + const auto live=existing.owner.lock();if(!live)continue; + const auto typed=std::static_pointer_cast(live); + if(typed->hashes[s]!=mounted->hashes[s])continue; + donor=live;best=existing.bases[s]; + } + if(donor) { + bank.bases[s]=best;mounted->pins.push_back(std::move(donor)); + reused+=bank.sizes[s];++shared;continue; + } bank.bases[s]=static_cast(next); - // A guard page makes a one-past-section pointer distinguishable from - // the next section, including for empty sections and zero-byte reads. - next+=((bank.sizes[s]+4095U)&~std::uint64_t(4095U))+4096U; - if(next>End) throw Error("This game session has exhausted its virtual asset address space."); + next+=((std::uint64_t(bank.sizes[s])+4095U)&~std::uint64_t(4095U))+4096U; + if(next>End)throw Error("This game session has exhausted its virtual asset address space."); + ranges.emplace(bank.bases[s],Range{fingerprint,s}); } - auto mounted=std::make_shared(); - mounted->directory_=std::move(directory);mounted->bases_=bank.bases;bank.owner=mounted; - // Stage the small indexes so allocation failure cannot publish half a bank. - auto banks=banks_;auto ranges=ranges_; - banks.emplace(fingerprint,bank); - for(unsigned s=0;s<50;++s) ranges.emplace(bank.bases[s],Range{fingerprint,s}); + mounted->bases_=bank.bases;bank.owner=mounted; + banks.emplace(fingerprint,std::move(bank)); + // Publish only after every allocation, hash, owner pin and index succeeds. + // Stale pointers never fall through to cartridge DMA and no different + // bytes reuse a retired address. Read still pins its exact section owner. banks_.swap(banks);ranges_.swap(ranges);next_=static_cast(next); + std::fprintf(stderr,"[online-mods][asset-addresses] banks=%zu shared-sections=%u shared-bytes=%llu reserved-bytes=%u section-zero-unique=1\n", + banks_.size(),shared,static_cast(reused),next_-Begin); return mounted; } + std::optional AssetBus::resolve(std::uint32_t address,std::size_t length) const { if(!is_virtual(address)) return std::nullopt; std::lock_guard lock(mutex_); diff --git a/runtime-recomp/src/game/mods/legacy_character_materialize.cpp b/runtime-recomp/src/game/mods/legacy_character_materialize.cpp index 49544caf..aba54c7e 100644 --- a/runtime-recomp/src/game/mods/legacy_character_materialize.cpp +++ b/runtime-recomp/src/game/mods/legacy_character_materialize.cpp @@ -122,7 +122,7 @@ bool character_portrait_changed(View replacement,View original) { } return false; } -PreparedCharacter prepare_character(View original,View target,std::string patch_digest,unsigned base_character,bool behaviour_enabled) { +PreparedCharacter prepare_character(View original,View target,std::string patch_digest,unsigned base_character,bool behaviour_enabled,bool legacy_portrait_layout) { const auto analysis=analyze(original,target,patch_digest,behaviour_enabled); if(!analysis.blockers.empty())throw Error(analysis.blockers.front()); const auto found=std::find_if(analysis.character_roots.begin(),analysis.character_roots.end(), @@ -132,6 +132,10 @@ PreparedCharacter prepare_character(View original,View target,std::string patch_ const AssetImage stock(original,analysis.source_revision),image(target,analysis.source_revision); PreparedCharacter result;result.root=*found;result.source_revision=analysis.source_revision; result.base_fingerprint=sha256(original); + if(legacy_portrait_layout) { + constexpr unsigned previous[]{122,123,125,126,124,127,129,128,130,131}; + result.root.portrait=previous[base_character]; + } std::set active_headers; std::size_t total=0; std::function visit=[&](unsigned section,unsigned id) { @@ -222,7 +226,13 @@ CharacterNamespace allocate_characters(std::shared_ptr stock,st throw Error("Character assets have invalid original Game Pak provenance."); if(character.root.content_id==previous)continue; previous=character.root.content_id; - const auto& portrait=character.records.at({4,character.root.portrait}); + constexpr unsigned portrait_ids[]{122,123,124,126,125,127,128,129,130,131}; + const bool recovered=!character.source_portrait.empty(); + if(recovered && (character.source_portrait_id!=portrait_ids[character.root.base_character] || character.source_portrait.size()>4*MiB)) + throw Error("Invalid recovered source portrait."); + const auto portrait_id=recovered?character.source_portrait_id:character.root.portrait; + const auto& portrait=recovered?character.source_portrait:character.records.at({4,character.root.portrait}); + validate_texture_record(portrait); const auto decoded_portrait=portrait[0x1d]?inflate_asset(View(portrait).subspan(32),4*MiB):Bytes{}; // Include display-list/palette/alignment overhead, not just packed PNG // or ROM bytes. Banks stay resident for previously queued audio. @@ -277,6 +287,11 @@ CharacterNamespace allocate_characters(std::shared_ptr stock,st } AllocatedCharacter slot{character.root.content_id,character.root.name,character.root.base_character, remap.at({4,character.root.portrait}),{}}; + if(recovered) { + if(next[4]>=32767)throw Error("Recovered portrait namespace is full."); + slot.portrait=next[4]++; + result.additions.emplace(AssetKey{4,slot.portrait},portrait); + } for(unsigned i=0;i<3;++i)slot.headers[i]=remap.at({34,character.root.headers[i]}); slot.stage_header=remap.at({34,character.stage_header}); slot.stage_source_header=character.stage_header; @@ -285,7 +300,7 @@ CharacterNamespace allocate_characters(std::shared_ptr stock,st slot.behaviour=character.root.behaviour; // Presentation provenance is computed from verified records, not a mod // name or a donor guess. Never change content IDs or isolated-save paths. - slot.custom_portrait=character_portrait_changed(portrait,stock->record(4,character.root.portrait)); + slot.custom_portrait=character_portrait_changed(portrait,stock->record(4,portrait_id)); slot.portrait_identity=sha256(portrait); slot.selection_audio_identity=sha256(character.audio.control)+sha256(character.audio.samples); slot.race_audio_identity=sha256(character.race_audio.control)+sha256(character.race_audio.samples); @@ -294,7 +309,7 @@ CharacterNamespace allocate_characters(std::shared_ptr stock,st } // AssetBank owns the final table/count/size checks, even for callers that // construct a preparation result directly rather than using the worker. - std::string roster_ids;for(const auto& c:result.characters)roster_ids+=c.id; + std::string roster_ids;for(const auto& c:result.characters)roster_ids+=c.id+":portrait-source-v1:"+c.portrait_identity; result.roster_identity=sha256(View(reinterpret_cast(roster_ids.data()),roster_ids.size())); const auto composite=result.apply(stock); const auto caps=legacy_asset_cache_capacities(true,static_cast(composite->record_count(4)),static_cast(composite->record_count(2)), diff --git a/runtime-recomp/src/game/mods/legacy_character_materialize.hpp b/runtime-recomp/src/game/mods/legacy_character_materialize.hpp index 940c5821..d1b7f018 100644 --- a/runtime-recomp/src/game/mods/legacy_character_materialize.hpp +++ b/runtime-recomp/src/game/mods/legacy_character_materialize.hpp @@ -16,8 +16,11 @@ struct PreparedCharacter { unsigned stage_header=0; CharacterAudio audio; CharacterRaceAudio race_audio; + // Recovered only by the authenticated source-review loader. + Bytes source_portrait; + unsigned source_portrait_id=0; }; -PreparedCharacter prepare_character(View original,View reconstructed,std::string patch_digest,unsigned base_character,bool behaviour_enabled=true); +PreparedCharacter prepare_character(View original,View reconstructed,std::string patch_digest,unsigned base_character,bool behaviour_enabled=true,bool legacy_portrait_layout=false); // Revalidate persisted dependency graphs without trusting their manifest hashes. // Returns the canonical content identity after complete bounds/closure checks. std::string validate_prepared_character(const PreparedCharacter&,const AssetBank& original); diff --git a/runtime-recomp/src/game/mods/legacy_mod_format.cpp b/runtime-recomp/src/game/mods/legacy_mod_format.cpp index 9d3967bd..30d33260 100644 --- a/runtime-recomp/src/game/mods/legacy_mod_format.cpp +++ b/runtime-recomp/src/game/mods/legacy_mod_format.cpp @@ -358,7 +358,7 @@ Analysis analyze(View base, View target, const std::string& patch_digest, bool b } if(!haunter && !yooka) { constexpr unsigned roots[]{2,3,4,5,6,7,8,9,1,0}; - constexpr unsigned portraits[]{122,123,125,126,124,127,129,128,130,131}; + constexpr unsigned portraits[]{122,123,124,126,125,127,128,129,130,131}; const auto skin_changed=[&](unsigned texture) { if(!changed.contains({2,texture}))return false; if(texture>=before.records(2).size())return true; diff --git a/runtime-recomp/src/game/mods/legacy_mod_launch.cpp b/runtime-recomp/src/game/mods/legacy_mod_launch.cpp index 0119caca..433167b5 100644 --- a/runtime-recomp/src/game/mods/legacy_mod_launch.cpp +++ b/runtime-recomp/src/game/mods/legacy_mod_launch.cpp @@ -1,4 +1,6 @@ +#include "../shared_saves.hpp" #include "legacy_mod_launch.hpp" +#include "vehicle_binding.hpp" #include "legacy_track_catalog.hpp" #include "../dkr_save_codec.hpp" #include "../custom_tracks.hpp" @@ -54,6 +56,8 @@ std::shared_ptr prepare_mod_launch( // The offline catalog is never consulted for online launches. The lobby's // separately verified, frozen RuntimeResources supply online assets. if(online)return out; + std::string personal_error; + if(!dkr::runtime::saves::shared::prepare(config,personal_error))throw Error(personal_error); auto report=[&](const char* message){if(stop.stop_requested())throw Error("Game launch cancelled.");if(progress)progress(message);}; const auto code_root=config/"mods"/"custom-magic-codes"; if(std::filesystem::exists(code_root/"library.json")) { @@ -66,42 +70,32 @@ std::shared_ptr prepare_mod_launch( } auto finish=[&]() -> std::shared_ptr { if(out->code) { - if(std::any_of(out->code->ordered.begin(),out->code->ordered.end(),[](const auto& p){return p->modules.contains("trophies")||!p->tracks.empty();})) { + if(std::any_of(out->code->ordered.begin(),out->code->ordered.end(),[](const auto& p){return (p->api==2&&p->modules.contains("catalogues2"))||p->modules.contains("trophies")||!p->tracks.empty();})) { std::vector labs; for(const auto& track:dkr::runtime::custom_tracks::tracks())if(track.enabled)labs.push_back(track.source); // Header IDs are not published until the retail boot later. // Derive them now in the SAME enabled-track scan order that // offline table assembly uses, not online's canonical order // and not a stale/empty previous-session global ID map. - auto courses=code::trophy::installed_catalogue(rom,config/"mods"/"legacy",labs,stop,false); + code::Json sdk2_inventory; + auto courses=code::trophy::installed_catalogue(rom,config/"mods"/"legacy",labs,stop,false,&sdk2_inventory); auto rebound=std::make_shared(*out->code); - code::trophy::rebind(*rebound,courses,online::asset_revision(dkr::runtime::rom::inspect(rom).revision));out->code=std::move(rebound); + code::trophy::rebind(*rebound,courses,online::asset_revision(dkr::runtime::rom::inspect(rom).revision),sdk2_inventory);out->code=std::move(rebound); } const auto identity="dkr-offline-code-session-1\n"+out->fingerprint+"\n"+out->code->identity; out->fingerprint=sha256(View(reinterpret_cast(identity.data()),identity.size())); } - if(out->fingerprint.empty())return out; - out->save_subfolder=std::filesystem::path("mods")/out->fingerprint; - out->save_path=config/"saves"/out->save_subfolder/"dkr.us.v77.bin"; - out->pak_directory=config/"saves"/out->save_subfolder/"paks"; - report("Preparing separate modded-session saves"); - ordinary(config);child(config/"saves");child(config/"saves"/"mods");child(out->save_path.parent_path());child(out->pak_directory); - using namespace dkr::runtime::saves; - if(!std::filesystem::exists(out->save_path)) { - const auto original=config/"saves"/"dkr.us.v77.bin"; - auto seed=codec::blank_bytes(); - if(std::filesystem::exists(original)) { - regular(original);seed=read_file(original,codec::kImageSize); - if(!codec::validate(seed))throw Error("The single-player Adventure save is invalid. It was not changed or copied into a modded session."); - } - write_new_file(out->save_path,seed); - } - regular(out->save_path); - if(!codec::validate(read_file(out->save_path,codec::kImageSize)))throw Error("The separate modded-session save is invalid. Restore it before starting this mod set."); + out->save_subfolder.clear(); + out->save_path=config/"saves"/"dkr.us.v77.bin"; + out->pak_directory=config; + std::string save_error; + if(!dkr::runtime::saves::shared::prepare(config,save_error))throw Error(save_error); report("Custom mods ready");return out; }; const auto root=config/"mods"/"legacy"; - if(!TrackCatalog::has_enabled(root))return finish(); + report("Preparing custom cars"); + auto cars=vehicles::Library(config/"mods"/"vehicles").freeze(stop); + if(!TrackCatalog::has_enabled(root)&&cars.packages.empty())return finish(); report("Validating the original Game Pak"); auto bytes=read_file(rom,MaxImage);canonicalize_rom(bytes); const auto stock=AssetBank::stock(std::move(bytes)); @@ -116,9 +110,15 @@ std::shared_ptr prepare_mod_launch( // Character IDs are allocated first. Publish dkrmap IDs against that // namespace, then retain the same immutable artwork in every scene bank. auto artwork=std::make_shared(publish_dkrmap_artwork(names?names->apply(stock):stock)); + auto vehicle_resources=vehicles::prepare_resources(stock, + AssetBank::append_artwork(names?names->apply(stock):stock,*artwork),names,std::move(cars)); auto session=std::make_shared(stock,names,std::vector{}, - [artwork](std::shared_ptr bank){return AssetBank::append_artwork(std::move(bank),*artwork);}); + [artwork,vehicle_resources](std::shared_ptr bank){ + return vehicle_resources->apply(AssetBank::append_artwork(std::move(bank),*artwork)); + }); + vehicles::bind_resources(session,vehicle_resources); std::vector identities; + if(!vehicle_resources->library.packages.empty())identities.push_back("vehicles:"+vehicle_resources->identity); for(auto& track:tracks) { report("Preparing custom Track Select previews"); identities.push_back("track:"+track.root.content_id+":"+track.bank->fingerprint()); diff --git a/runtime-recomp/src/game/mods/legacy_track_catalog.cpp b/runtime-recomp/src/game/mods/legacy_track_catalog.cpp index 12658614..ceb309bc 100644 --- a/runtime-recomp/src/game/mods/legacy_track_catalog.cpp +++ b/runtime-recomp/src/game/mods/legacy_track_catalog.cpp @@ -608,7 +608,7 @@ std::vector TrackCatalog::load_selected_characters(std::span< std::vector result;std::set loaded; std::map reviews; std::map audio_blobs; - std::size_t cached_audio_bytes=0; + std::size_t cached_audio_bytes=0,source_portrait_bytes=0; std::array originals{};bool audio_checked=false; for(const auto& selected_source:sources) { const auto root=private_storage_path(selected_source.library);const auto& item=selected_source.item; @@ -643,6 +643,40 @@ std::vector TrackCatalog::load_selected_characters(std::span< if(package)throw Error("Ambiguous character source audio.");package=&p; } if(!package)throw Error("The retained character audio does not match this import."); + // A v2 artifact can contain the neighbouring donor's portrait. + // Authenticate its retained source image independently; do not + // rewrite the canonical model identity or its isolated save path. + constexpr unsigned portrait_ids[]{122,123,124,126,125,127,128,129,130,131}; + const auto portrait_id=portrait_ids[item.base_character]; + if(character.root.portrait!=portrait_id) { + const auto& source_records=package->at("records"); + if(!source_records.is_array() || source_records.size()>65536) + throw Error("Character source portrait record list is invalid."); + const json* portrait_record=nullptr; + for(const auto& record:source_records) + if(record.at("section")==4 && record.at("record")==portrait_id) { + if(portrait_record)throw Error("Ambiguous retained character portrait."); + portrait_record=&record; + } + // Unchanged source records inherit the verified original. + const auto original_portrait=stock->record(4,portrait_id); + character.source_portrait.assign(original_portrait.begin(),original_portrait.end()); + if(portrait_record) { + const auto hash=field(*portrait_record,"sha256"); + if(!digest(hash))throw Error("Invalid retained portrait digest."); + const auto file=source/"blobs"/hash; + if(std::filesystem::is_symlink(file) || !std::filesystem::is_regular_file(file)) + throw Error("Retained portrait is missing. Prepare this character's source import again."); + auto bytes=read_file(file,4*MiB); + if(sha256(bytes)!=hash || bytes.size()!=portrait_record->at("size").get()) + throw Error("Retained portrait failed its source integrity check."); + character.source_portrait=std::move(bytes); + } + // Decode/validate every frame now, on the preparation worker. + character_portrait_changed(character.source_portrait,original_portrait); + character.source_portrait_id=portrait_id; + } + std::array audio; constexpr unsigned ids[]{2,3,7}; // US 1.0 and 1.1 share the original control/sample banks. The two @@ -687,6 +721,16 @@ std::vector TrackCatalog::load_selected_characters(std::span< throw Error("Retained race audio belongs to a different character import."); character.race_audio=prepare_character_race_audio(audio[0],audio[1],audio[2],item.base_character); } + constexpr unsigned correct_portraits[]{122,123,124,126,125,127,128,129,130,131}; + const auto correct_portrait=correct_portraits[character.root.base_character]; + if(character.root.portrait!=correct_portrait && character.source_portrait.empty()) { + const auto bytes=stock->record(4,correct_portrait); + character.source_portrait.assign(bytes.begin(),bytes.end()); + character.source_portrait_id=correct_portrait; + } + if(character.source_portrait.size()>32*MiB-source_portrait_bytes) + throw Error("Recovered character portraits exceed the presentation memory budget."); + source_portrait_bytes+=character.source_portrait.size(); result.push_back(std::move(character)); if(result.size()>MaxActiveStageCharacters)throw Error("The character library exceeds the native presentation identity range."); } diff --git a/runtime-recomp/src/game/mods/legacy_track_prepare_job.cpp b/runtime-recomp/src/game/mods/legacy_track_prepare_job.cpp index fbbdb437..9746983e 100644 --- a/runtime-recomp/src/game/mods/legacy_track_prepare_job.cpp +++ b/runtime-recomp/src/game/mods/legacy_track_prepare_job.cpp @@ -31,7 +31,7 @@ Bytes reconstruct(View source,View patch) { } static void prepare_imported_content(const std::filesystem::path& review, const std::vector& owned_roms, - const std::filesystem::path& destination,const ProgressCallback& progress,bool characters,const ImportPreparation* preparation,bool behaviour_enabled) { + const std::filesystem::path& destination,const ProgressCallback& progress,bool characters,const ImportPreparation* preparation,bool behaviour_enabled,bool legacy_portrait_layout=false) { using nlohmann::json; if(owned_roms.empty() || owned_roms.size()>8)throw Error("Track preparation needs its imported source Game Pak."); directory(review);directory(review/"patches");directory(destination.parent_path()); @@ -121,7 +121,7 @@ static void prepare_imported_content(const std::filesystem::path& review, try { // Complete preparation and validation precede publication. A mixed // patch only contributes its explicitly owned character resources. - auto prepared=prepare_character(base,target,patch_id,root.base_character,behaviour_enabled); + auto prepared=prepare_character(base,target,patch_id,root.base_character,behaviour_enabled,legacy_portrait_layout); if(prepared.root.name.starts_with("Custom Racer ") && package.contains("display_name")) { const auto label=package.at("display_name").get(); if(!label.empty() && label.size()<=120 && std::none_of(label.begin(),label.end(),[](unsigned char c){return c<32;})) @@ -164,7 +164,7 @@ void prepare_imported_tracks(const std::filesystem::path& review,const std::vect prepare_imported_content(review,roms,destination,progress,false,preparation,behaviour_enabled); } void prepare_imported_characters(const std::filesystem::path& review,const std::vector& roms, - const std::filesystem::path& destination,const ProgressCallback& progress,const ImportPreparation* preparation,bool behaviour_enabled) { - prepare_imported_content(review,roms,destination,progress,true,preparation,behaviour_enabled); + const std::filesystem::path& destination,const ProgressCallback& progress,const ImportPreparation* preparation,bool behaviour_enabled,bool legacy_portrait_layout) { + prepare_imported_content(review,roms,destination,progress,true,preparation,behaviour_enabled,legacy_portrait_layout); } } diff --git a/runtime-recomp/src/game/mods/legacy_track_prepare_job.hpp b/runtime-recomp/src/game/mods/legacy_track_prepare_job.hpp index b63c87d1..ceb47d21 100644 --- a/runtime-recomp/src/game/mods/legacy_track_prepare_job.hpp +++ b/runtime-recomp/src/game/mods/legacy_track_prepare_job.hpp @@ -12,5 +12,5 @@ void prepare_imported_tracks(const std::filesystem::path& review, void prepare_imported_characters(const std::filesystem::path& review, const std::vector& owned_roms, const std::filesystem::path& fresh_destination, - const ProgressCallback& progress={},const ImportPreparation* preparation=nullptr,bool behaviour_enabled=true); + const ProgressCallback& progress={},const ImportPreparation* preparation=nullptr,bool behaviour_enabled=true,bool legacy_portrait_layout=false); } diff --git a/runtime-recomp/src/game/mods/magic_code_execution_policy.hpp b/runtime-recomp/src/game/mods/magic_code_execution_policy.hpp new file mode 100644 index 00000000..d7bf12e7 --- /dev/null +++ b/runtime-recomp/src/game/mods/magic_code_execution_policy.hpp @@ -0,0 +1,181 @@ +#pragma once + +// Execution-only bookkeeping. No checkpoint/guest ABI, package API or Budget +// layout changes. A transaction is private to one World on its calling thread; +// native re-entry shares it, including when a different Budget& was supplied. +#include "magic_code_vm.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::mods::code::execution { +inline constexpr std::string_view Policy = "per-owner-work-v2"; +enum class Phase { Simulation, Drawing, Loading }; +inline thread_local Phase phase = Phase::Simulation; +struct PhaseScope { + Phase previous = phase; + explicit PhaseScope(Phase next) { phase = next; } + ~PhaseScope() { phase = previous; } +}; +inline const char* phase_name(Phase value) { + switch(value) { + case Phase::Loading: return "loading"; + case Phase::Drawing: return "drawing"; + default: return "simulation"; + } +} +inline Phase event_phase(std::string_view name) { + return name == "on_boot" || name == "on_scene_load" || name == "on_scene_loaded" || + name == "on_scene_unload" || name == "on_reload" || + name == "on_cleanup" ? Phase::Loading : phase; +} +inline Phase function_phase(std::string_view name) { + if(name.find("render") != name.npos || name.starts_with("draw_") || + name.starts_with("hud_")) return Phase::Drawing; + if(name.find("init") != name.npos || name.find("load") != name.npos || + name.find("setup") != name.npos || name.starts_with("generate_")) + return Phase::Loading; + return Phase::Simulation; +} +struct Counts { + std::uint64_t instructions = 0, native = 0, sdk = 0, cross = 0; +}; +struct OwnerWork { + std::string_view id; + std::uint64_t generation = 0; + std::uint64_t instruction_limit = 0, remaining_instructions = 0; + unsigned native_limit = 0, remaining_native = 0; + Counts counts; +}; +struct Context { + const void* world = nullptr; + Budget* budget = nullptr; + Phase phase = Phase::Simulation; + std::string_view label; + std::array owners{}; + std::size_t owner = 128; + unsigned callbacks = 0, nesting = 0, maximum_nesting = 0; + Counts total; + std::uint64_t generation = 0; + void reset(const void* next_world,Budget& next_budget,Phase next_phase,std::string_view next_label) { + world=next_world;budget=&next_budget;phase=next_phase;label=next_label; + owner=owners.size();callbacks=nesting=maximum_nesting=0;total={}; + if(++generation==0) { for(auto& work:owners)work.generation=0;generation=1; } + } +}; +inline thread_local Context* current = nullptr; +inline thread_local std::unique_ptr idle_context; +inline bool active(const void* world) { return current&¤t->world==world; } +class Transaction { + Context* previous_ = current; + std::unique_ptr own_; +public: + Transaction(const void* world, Budget& budget, std::string_view label) { + if(!current || current->world != world) { + own_=std::move(idle_context); + if(!own_)own_=std::make_unique(); + own_->reset(world,budget,phase,label); + current = own_.get(); + } + } + Budget& budget() const { return *current->budget; } + ~Transaction() { + if(!own_) return; + // Sparse local telemetry only. Counters, not wall time, enforce work. + static std::atomic reports{0}; + if((own_->label == "on_boot" || own_->total.sdk > 256 || + own_->total.native > 256) && reports.fetch_add(1) < 24) { + std::fprintf(stderr, + "[custom-magic-codes][work] policy=per-owner-work-v2 phase=%s event=%.*s " + "callbacks=%u instructions=%llu sdk=%llu native=%llu cross=%llu depth=%u; " + "SDK helpers do not consume native game-call allowance\n", + phase_name(own_->phase), int(own_->label.size()), own_->label.data(), + own_->callbacks, static_cast(own_->total.instructions), + static_cast(own_->total.sdk), + static_cast(own_->total.native), + static_cast(own_->total.cross), own_->maximum_nesting); + } + current = previous_; + if(!idle_context)idle_context=std::move(own_); + } +}; +class OwnerScope { + Context& context_; + std::size_t previous_, owner_; + void save(std::size_t owner) { + if(owner >= context_.owners.size()) return; + auto& work = context_.owners[owner]; + work.remaining_instructions = context_.budget->instructions; + work.remaining_native = context_.budget->native_calls; + } + void select(std::size_t owner) { + const auto& work = context_.owners[owner]; + context_.budget->instructions = work.remaining_instructions; + context_.budget->native_calls = work.remaining_native; + } +public: + OwnerScope(std::size_t owner, std::string_view id, unsigned instructions, unsigned native) + : context_(*current), previous_(context_.owner), owner_(owner) { + if(owner >= context_.owners.size()) throw Error("Custom Magic Code: invalid execution owner"); + auto& work = context_.owners[owner]; + if(work.generation!=context_.generation) { + work.generation=context_.generation;work.id=id;work.counts={}; + const unsigned multiplier = context_.phase == Phase::Loading ? 8U : + context_.phase == Phase::Drawing ? 2U : 1U; + work.instruction_limit = std::uint64_t(instructions) * multiplier; + work.native_limit = std::max(512U,native * 4U) * + (context_.phase == Phase::Loading ? 8U : 1U); + work.remaining_instructions = work.instruction_limit; + work.remaining_native = work.native_limit; + } + if(previous_ != owner) { save(previous_); select(owner); context_.owner = owner; } + ++context_.callbacks; + context_.maximum_nesting = std::max(context_.maximum_nesting,++context_.nesting); + } + const OwnerWork& work() const { return context_.owners[owner_]; } + ~OwnerScope() { + --context_.nesting; + if(previous_ != owner_) { + save(owner_); + context_.owner = previous_; + if(previous_ < context_.owners.size()) select(previous_); + } + } +}; +inline void instruction() { + if(current && current->owner < current->owners.size()) { + ++current->owners[current->owner].counts.instructions; + ++current->total.instructions; + } +} +inline void gateway(bool sdk) { + if(current && current->owner < current->owners.size()) { + if(sdk) { ++current->owners[current->owner].counts.sdk; ++current->total.sdk; } + else { ++current->owners[current->owner].counts.cross; ++current->total.cross; } + } +} +inline void native(Budget& supplied) { + auto& budget = current ? *current->budget : supplied; + if(!budget.native_calls) + throw Error("Custom Magic Code: native game-call safety limit exceeded"); + --budget.native_calls; + if(current && current->owner < current->owners.size()) { + ++current->owners[current->owner].counts.native; + ++current->total.native; + } +} +inline std::string diagnostic() { + if(!current || current->owner >= current->owners.size()) return {}; + const auto& work = current->owners[current->owner]; + return " [work-policy=per-owner-work-v2 phase=" + std::string(phase_name(current->phase)) + + " instruction-work=" + std::to_string(work.instruction_limit-current->budget->instructions) + "/" + std::to_string(work.instruction_limit) + + " vm-instructions=" + std::to_string(work.counts.instructions) + + " sdk=" + std::to_string(work.counts.sdk) + " native=" + std::to_string(work.counts.native) + + "/" + std::to_string(work.native_limit) + " cross=" + std::to_string(work.counts.cross) + + " chain-depth=" + std::to_string(current->maximum_nesting) + "]"; +} +} // namespace dkr::mods::code::execution diff --git a/runtime-recomp/src/game/mods/magic_code_game_world.cpp b/runtime-recomp/src/game/mods/magic_code_game_world.cpp index 983a8a88..a5bb27c3 100644 --- a/runtime-recomp/src/game/mods/magic_code_game_world.cpp +++ b/runtime-recomp/src/game/mods/magic_code_game_world.cpp @@ -5,6 +5,10 @@ #include #include #include +#include +#include + +#include "magic_code_execution_policy.hpp" namespace dkr::mods::code { namespace { @@ -37,8 +41,24 @@ void write_call(Memory& bus,std::uint32_t address,const Registers& c) { bus.store(address+512,8,c.hi);bus.store(address+520,8,c.lo);bus.store(address+528,4,c.fcsr); } } +#include "sdk2_world.inl" +#include "sdk2_operations.inl" +#include "sdk2_collision.inl" +#include "sdk2_track_catalogue.inl" +#include "sdk2_simulation.inl" +#include "sdk2_documents.inl" +#include "sdk2_tuning.inl" +#include "sdk2_overrides.inl" +#include "sdk2_reload.inl" +#include "sdk2_settings.inl" LinkPolicy GameWorld::policy(DispatchTable table,bool online) { auto p=World::core_policy();p.online=online; + p.modules.insert({{"reflection2",2},{"entities2",2},{"transactions2",2},{"inspection2",2},{"tools-ui2",2},{"operations2",2},{"live-settings2",2},{"catalogues2",2},{"collision2",2},{"events2",2}}); + p.services.insert({"dkr2_capability","dkr2_session","dkr2_property","dkr2_entities","dkr2_read","dkr2_edit","dkr2_symbols","dkr2_memory_read","dkr2_ui_publish","dkr2_action_next","dkr2_operation_available","dkr2_operation","dkr2_setting_set","dkr2_setting_reset","dkr2_catalogue","dkr2_cast","dkr2_events","dkr2_event_next","dkr2_debug"}); + p.modules.emplace("racer-tuning2",2);p.services.insert({"dkr2_tune","dkr2_freeze_cpus"}); + p.services.insert("dkr2_setting_apply");p.events.insert("on_live_setting"); + p.modules.emplace("reload2",2);p.events.insert({"on_cleanup","on_reload"}); + p.modules.emplace("overrides2",2);p.services.insert("dkr2_override"); p.modules.emplace("game-hooks",1); p.modules.emplace("game-events",1); p.modules.emplace("game",1); @@ -58,7 +78,8 @@ LinkPolicy GameWorld::policy(DispatchTable table,bool online) { // An empty table is the library's capability preview. Actual admission // still requires all four reviewed handlers in the selected CPU closure. if(table.entries.empty()||std::count_if(table.entries.begin(),table.entries.end(),[](const auto& e){return e.curated_call;})==4)p.modules.emplace("audio",1); - p.events.insert({"on_frame","on_frame_end"}); + p.events.insert({"on_frame","on_frame_end","on_tool"}); + p.modules.emplace("simulation2",2);p.modules.emplace("track-tools2",2);p.modules.emplace("documents2",2);p.modules.emplace("presentation2",2);p.services.insert({"dkr2_control","dkr2_route","dkr2_document_publish","dkr2_document_read","dkr2_document_size","dkr2_document_accept"}); p.raw_game_access=!table.entries.empty(); for(const auto& entry:table.entries) { p.functions.insert(entry.address); @@ -81,6 +102,22 @@ GameWorld::GameWorld(const FrozenSet& frozen,std::string_view revision,DispatchT world_(link(frozen.ordered,frozen.settings,revision,policy(table,online),frozen.data),seed, [this](auto address,auto& registers,auto& budget){native(address,registers,budget);}, [this](auto name,auto owner,auto& bus,auto& registers,auto& budget){return service(name,owner,bus,registers,budget);}) { + sdk2_active_=std::any_of(frozen.ordered.begin(),frozen.ordered.end(),[](const auto& p){return p->api==2;}); + if(sdk2_active_)sdk2_modules_=policy(table_,online_).modules; + if(sdk2_active_)for(const auto& p:frozen.ordered)if(p->api==2){sdk2_budget_.instructions+=p->sdk2_instructions;sdk2_budget_.native_calls+=p->sdk2_native_calls;} + sdk2_development_=!online_&&std::any_of(frozen.ordered.begin(),frozen.ordered.end(),[](const auto& p){return p->development;}); + for(const auto& p:frozen.ordered)if(p->api==2) { + const auto data=frozen.data.find(p->id); + if(data!=frozen.data.end()&&data->second.contains("_sdk2_catalogue")) { + const auto courses=trophy::read_catalogue(data->second.at("_sdk2_catalogue").at("courses")); + if(!sdk2_courses_.empty()&&sdk2_courses_!=courses)throw Error("SDK 2 packages disagree on their frozen resource catalogue."); + sdk2_courses_=courses; + const auto& inventory=data->second.at("_sdk2_catalogue").at("inventory"); + if(!sdk2_inventory_.empty()&&sdk2_inventory_!=inventory)throw Error("SDK 2 packages disagree on their admitted resource inventory."); + sdk2_inventory_=inventory; + } + } + for(const auto& cup:trophy::menu_entries(frozen))sdk2_cups_.push_back({{"id",cup.id},{"name",cup.name}}); if(world_.linked().identity!=frozen.identity)throw Error("Frozen code configuration changed before game construction."); if(!std::is_sorted(table_.entries.begin(),table_.entries.end(),[](const auto& a,const auto& b){return a.addressapi==2&&p->modules.contains("racer-tuning2");}))active=true; + if(sdk2_active_&&entry.name&&std::string_view(entry.name)=="light_update_shading"&&std::any_of(frozen.ordered.begin(),frozen.ordered.end(),[](const auto& p){return p->api==2&&p->modules.contains("overrides2");}))active=true; if(active)hook_mask_[(entry.address&0x7fffffU)>>5]|=std::uint8_t(1U<<((entry.address>>2)&7U)); } } bool GameWorld::service(std::string_view name,std::size_t owner,Memory& bus,Registers& c,Budget& budget) { + if(sdk2_service(name,owner,bus,c,budget))return true; if(game_memory_service(name,bus,c,budget,[this](auto name){return symbol(name);}))return true; const auto a=std::uint32_t(c.integer[4]),b=std::uint32_t(c.integer[5]); if(name=="dkr_hud_text") { @@ -307,7 +348,7 @@ void GameWorld::write(recomp_context& context,const Registers& registers) { for(unsigned i=0;i<32;i+=2)std::memcpy(bytes+i*sizeof(fpr),®isters.floating[i],sizeof(std::uint64_t)); context.hi=registers.hi;context.lo=registers.lo;context.mips3_float_mode=0;context.f_odd=&context.f0.u32h; } -void GameWorld::native(std::uint32_t address,Registers& registers,Budget&,bool curated) { +void GameWorld::native(std::uint32_t address,Registers& registers,Budget& budget,bool curated) { const auto found=std::lower_bound(table_.entries.begin(),table_.entries.end(),address, [](const auto& entry,auto value){return entry.addressaddress!=address||(found->protected_call&&!(curated&&found->curated_call))||!found->function) @@ -318,6 +359,7 @@ void GameWorld::native(std::uint32_t address,Registers& registers,Budget&,bool c if(!native_bus_)throw Error("Game call has no RDRAM binding.");auto& bus=*native_bus_; const auto native_stack=owner->native_stack-native_depth_*8192; for(unsigned i=0;i<16;++i)bus.store(native_stack+i*4,4,bus.load(stack+i*4,4)); + execution::native(budget); ++native_depth_;struct Depth{unsigned& value;~Depth(){--value;}}depth{native_depth_}; recomp_context context{};write(context,registers);context.r29=std::int32_t(native_stack); if(invoke_)invoke_(found->function,ram_.data(),&context); @@ -326,19 +368,32 @@ void GameWorld::native(std::uint32_t address,Registers& registers,Budget&,bool c } void GameWorld::attach(std::span ram,bool initial) { std::lock_guard lock(mutex_);world_.attach(ram,initial);ram_=ram; + if(initial&&sdk2_enabled()) { + std::vector> packages;for(const auto& o:linked().owners)packages.push_back(o.package); + dkr::sdk2::begin(packages,online_); + } auto regions=linked().regions;regions.push_back({0x80000000U,8*unsigned(MiB),true,false}); native_bus_=std::make_unique(ram,std::move(regions)); + if(initial)for(const auto& o:linked().owners)if(o.package->api==2) { + native_bus_->store(o.services+sdk2::Header,4,sdk2::Magic);native_bus_->store(o.services+sdk2::Header+4,4,1); + } } -void GameWorld::initialize(){std::lock_guard lock(mutex_);Budget budget;world_.initialize(budget);} +void GameWorld::initialize(){std::lock_guard lock(mutex_);Budget budget=sdk2_budget_;execution::PhaseScope phase(execution::Phase::Loading);world_.initialize(budget);} void GameWorld::event(unsigned event,const Registers& args) { std::lock_guard lock(mutex_); if(!event||event>=std::size(Events))throw Error("Unknown game lifecycle event."); + sdk2_boundary(event); + const auto previous_phase=sdk2_phase_; + sdk2_phase_=event==DKR_CODE_EVENT_FRAME||event==DKR_CODE_EVENT_FRAME_END?2:1; + struct Phase{unsigned& value;unsigned previous;~Phase(){value=previous;}} phase{sdk2_phase_,previous_phase}; if(event==DKR_CODE_EVENT_FRAME)for(const auto& owner:linked().owners)native_bus_->store(owner.services+1024,4,0); - if(nested_==0)budget_=Budget{}; + if(nested_==0&&!execution::active(&world_))budget_=sdk2_budget_; if(nested_>=32)throw Error("Game event recursion exceeds its bound."); ++nested_;struct Scope{unsigned& count;~Scope(){--count;}}scope{nested_}; const auto outgoing=owned_preview_arguments(ram_,online_,args); + execution::PhaseScope work_phase(event==DKR_CODE_EVENT_FRAME||event==DKR_CODE_EVENT_FRAME_END?execution::Phase::Simulation:execution::event_phase(Events[event])); world_.event("*",Events[event],args,budget_,outgoing?&*outgoing:nullptr); + if(event==DKR_CODE_EVENT_FRAME_END)sdk2_inspector(); } int GameWorld::dispatch_event(unsigned event,std::uint8_t* ram,recomp_context* context)noexcept { try { @@ -354,7 +409,7 @@ int GameWorld::indirect(std::uint32_t address,std::uint8_t* ram,recomp_context* std::lock_guard lock(mutex_); if(!context||ram!=ram_.data()||!error_.empty()||!linked().function_owners.contains(address)) throw Error("Indirect game callback does not belong to the admitted code world."); - if(nested_==0)budget_=Budget{}; + if(nested_==0&&!execution::active(&world_))budget_=sdk2_budget_; if(nested_>=32)throw Error("Indirect code callback recursion exceeds its bound."); ++nested_;struct Scope{unsigned& count;~Scope(){--count;}}scope{nested_}; auto registers=read(*context);const auto outgoing=owned_preview_arguments(ram_,online_,registers); @@ -370,13 +425,20 @@ int GameWorld::dispatch(bool leaving,std::uint32_t address,std::uint8_t* ram,rec const auto entry=std::lower_bound(table_.entries.begin(),table_.entries.end(),address, [](const auto& item,auto value){return item.addressaddress!=address)throw Error("Unknown code hook entry."); + sdk2_lifetime(entry->name?entry->name:"",leaving,read(*context)); + const auto previous_phase=sdk2_phase_; + const std::string_view function_name=entry->name?entry->name:""; + if(function_name.find("render")!=function_name.npos||function_name.starts_with("draw_")||function_name.starts_with("hud_"))sdk2_phase_=4; + struct Phase{unsigned& value;unsigned previous;~Phase(){value=previous;}} phase{sdk2_phase_,previous_phase}; + execution::PhaseScope work_phase(execution::function_phase(function_name)); const auto lifecycle=leaving?entry->after_event:entry->before_event; if(lifecycle)this->event(lifecycle,read(*context)); const auto named=named_events_.find(address); - if(!world_.has_hook(address)&&named==named_events_.end()){result=0;return 1;} - if(nested_==0)budget_=Budget{}; + if(!world_.has_hook(address)&&named==named_events_.end()&&!(sdk2_enabled()&&((leaving&&function_name=="menu_pause_loop")||(!leaving&&function_name=="update_AI_racer")))){result=0;return 1;} + if(nested_==0&&!execution::active(&world_))budget_=sdk2_budget_; if(nested_>=32)throw Error("Native/code hook recursion exceeds its bound."); ++nested_;struct Scope{unsigned& count;~Scope(){--count;}}scope{nested_}; + execution::Transaction work_transaction(&world_,budget_,"game-hooks"); auto registers=read(*context); const auto outgoing=owned_preview_arguments(ram_,online_,registers); const auto* native_arguments=outgoing?&*outgoing:nullptr; @@ -384,8 +446,8 @@ int GameWorld::dispatch(bool leaving,std::uint32_t address,std::uint8_t* ram,rec auto* previous=callback_;callback_=&callback; struct RestoreContext{CallbackContext*& slot;CallbackContext* previous;~RestoreContext(){slot=previous;}} restore_context{callback_,previous}; if(named!=named_events_.end())world_.event("*",leaving?named->second.second:named->second.first,registers,budget_,native_arguments); - if(leaving){world_.leave(address,registers,budget_,native_arguments);result=callback.repeat?1:0;} - else result=callback.skip||world_.enter(address,registers,budget_,native_arguments)?1:0; + if(leaving){world_.leave(address,registers,budget_,native_arguments);sdk2_native_return(function_name,registers);result=callback.repeat?1:0;} + else result=callback.skip||world_.enter(address,registers,budget_,native_arguments)||sdk2_freeze(function_name)?1:0; write(*context,registers); return 1; }catch(const std::exception& e){try{error_=e.what();}catch(...){}return -1;} diff --git a/runtime-recomp/src/game/mods/magic_code_game_world.hpp b/runtime-recomp/src/game/mods/magic_code_game_world.hpp index f8b19fb3..edc6ea65 100644 --- a/runtime-recomp/src/game/mods/magic_code_game_world.hpp +++ b/runtime-recomp/src/game/mods/magic_code_game_world.hpp @@ -5,6 +5,7 @@ #include "magic_code_trophy_catalog.hpp" #include "recomp.h" #include +#include "sdk2_bridge.hpp" namespace dkr::mods::code { using GuestFunction=void(*)(std::uint8_t*,recomp_context*); @@ -29,6 +30,16 @@ class GameWorld { // Called by the C-only private dispatcher, or the ordinary guest wrapper. // Errors return AFTER all C++ locks/leases have unwound. int dispatch(bool leaving,std::uint32_t address,std::uint8_t*,recomp_context*,std::uint64_t& result)noexcept; + unsigned sdk2_pulse(); + void sdk2_reload(); + int sdk2_dispatch_pulse()noexcept; + bool sdk2_frame_starts_at(std::uint32_t)const; + void sdk2_command(const dkr::sdk2::Command&); + void sdk2_development(bool value){sdk2_development_=value;} + void sdk2_frame_agreed(bool value){sdk2_frame_agreed_=value;} + void sdk2_current_course(std::function query){sdk2_current_course_=std::move(query);} + void sdk2_assets(const std::string& value){if(!value.empty())sdk2_assets_=parse_document(View(reinterpret_cast(value.data()),value.size()),2*MiB);} + bool sdk2_wants_assets()const{for(const auto& o:linked().owners)if(o.package->api==2&&o.package->modules.contains("catalogues2"))return true;return false;} const std::string& error()const{return error_;} Bytes checkpoint()const; // Quiesced native-to-owned handoff only, while the supplied RAM is alive. @@ -43,6 +54,40 @@ class GameWorld { std::size_t required_ram()const{return 16*MiB+linked().resident_bytes;} std::size_t checkpoint_limit()const; private: + bool sdk2_enabled()const; + void sdk2_boundary(unsigned); + void sdk2_inspector(); + void sdk2_lifetime(std::string_view,bool,const Registers&); + void sdk2_emit(Memory&,std::uint32_t,unsigned,unsigned,unsigned,unsigned,unsigned,unsigned,unsigned); + bool sdk2_service(std::string_view,std::size_t,Memory&,Registers&,Budget&); + bool sdk2_operation_available(unsigned)const; + int sdk2_operation(unsigned,std::size_t,Memory&,const std::array&,std::array&,Budget&); + int sdk2_cast(Memory&,std::uint32_t,std::uint32_t,Budget&); + int sdk2_track_catalogue(Memory&,std::string_view,unsigned,Json&,Budget&); + int sdk2_control(Memory&,std::size_t,unsigned,unsigned); + int sdk2_tune(Memory&,std::size_t,unsigned); + int sdk2_setting_apply(Memory&,std::size_t,unsigned,unsigned,std::uint32_t,Budget&); + void sdk2_vehicle(std::string_view,bool,const Registers&); + void sdk2_apply_overrides(std::size_t,unsigned,bool); + void sdk2_shading(std::string_view,bool,const Registers&); + bool sdk2_freeze(std::string_view)const; + void sdk2_native_return(std::string_view,Registers&); + int sdk2_document(std::size_t,Memory&,std::string_view,Registers&,Budget&); + bool sdk2_document_command(const dkr::sdk2::Command&,std::size_t,Memory&); + std::string sdk2_course_identity()const; + unsigned sdk2_phase_=1; + bool sdk2_command_=false; + bool sdk2_development_=false; + bool sdk2_active_=false; + bool sdk2_pulsing_=false; + bool sdk2_frame_agreed_=false; + unsigned sdk2_inspector_serial_=0; + Budget sdk2_budget_{}; + std::map sdk2_modules_; + std::vector sdk2_courses_; + Json sdk2_inventory_=Json::object(),sdk2_cups_=Json::array(); + Json sdk2_assets_=Json::object(); + std::function sdk2_current_course_; static Registers read(const recomp_context&); static void write(recomp_context&,const Registers&); DispatchTable table_; diff --git a/runtime-recomp/src/game/mods/magic_code_library.cpp b/runtime-recomp/src/game/mods/magic_code_library.cpp index 1a2ed112..8fff4881 100644 --- a/runtime-recomp/src/game/mods/magic_code_library.cpp +++ b/runtime-recomp/src/game/mods/magic_code_library.cpp @@ -110,7 +110,9 @@ Json catalog(const std::filesystem::path& root) { if(doc.size()!=3||doc.value("schema",0)!=2||!doc.contains("items")||!doc["items"].is_object()||doc["items"].size()>LibraryLimit|| !doc.contains("selected")||!doc["selected"].is_object()||doc["selected"].size()>LibraryLimit)bad("invalid code library catalogue"); for(const auto& [hash,item]:doc["items"].items()) { - if(!digest(hash)||!item.is_object()||item.size()!=7||!item.contains("id")||!item["id"].is_string()|| + if(!digest(hash)||!item.is_object()||(item.size()!=7&&item.size()!=8)|| + (item.size()==8&&(!item.contains("presentation")||!item["presentation"].is_boolean()))|| + (item.size()==7&&item.contains("presentation"))||!item.contains("id")||!item["id"].is_string()|| !valid_id(item["id"].get())||!item.contains("kept_online")||!item["kept_online"].is_boolean()|| !item.contains("deterministic")||!item["deterministic"].is_boolean())bad("invalid code library entry"); for(const auto* field:{"name","version","description"})if(!item.contains(field)||!item[field].is_string()||item[field].get_ref().size()>32768)bad("invalid code library metadata"); @@ -192,6 +194,7 @@ std::vector Library::scan(std::stop_token stop)const { cancelled(stop);LibraryItem entry;entry.digest=hash;entry.id=item["id"].get(); entry.name=item["name"].get();entry.version=item["version"].get();entry.description=item["description"].get(); entry.kept_online=item["kept_online"].get();entry.deterministic=item["deterministic"].get(); + entry.presentation=item.value("presentation",false); entry.compatibility=compatibility_issue(hash); entry.options=item["options"]; if(doc["selected"].contains(entry.id)) { @@ -219,7 +222,7 @@ std::string Library::import(View bytes,bool kept_online) { } const bool was_kept=doc["items"].contains(package->digest)&&doc["items"][package->digest].value("kept_online",false); doc["items"][package->digest]={{"id",package->id},{"name",package->name},{"version",package->version},{"description",package->description}, - {"deterministic",package->deterministic},{"kept_online",kept_online||was_kept},{"options",package->options}}; + {"deterministic",package->deterministic},{"presentation",package->modules.contains("presentation")},{"kept_online",kept_online||was_kept},{"options",package->options}}; // Network receipt NEVER selects, approves, enables or reconfigures an // offline edition. A manual import only selects an initially disabled one. if(!kept_online&&!doc["selected"].contains(package->id))doc["selected"][package->id]={{"digest",package->digest},{"enabled",false},{"approved",false},{"settings",configuration(*package,Json::object())},{"data",Json::object()}}; @@ -266,19 +269,22 @@ void Library::remove(std::string_view hash) { std::shared_ptr Library::payload(std::string_view hash)const { Lease lease(root_);return read_payload(root_,hash); } -FrozenSet Library::freeze(std::string_view revision,bool online,const std::map& modules,std::stop_token stop)const { +FrozenSet Library::freeze(std::string_view revision,bool online,const std::map& modules,std::stop_token stop,bool presentation)const { Lease lease(root_);const auto doc=catalog(root_);FrozenSet out;std::vector> packages; std::set enabled;std::size_t total=0; for(const auto& [id,profile]:doc["selected"].items())if(profile["enabled"].get()) { cancelled(stop);if(!profile["approved"].get())bad("unapproved code package was selected"); auto bytes=read_payload(root_,profile["digest"].get());auto p=inspect(*bytes,version_); if(p->id!=id)bad("package ID differs from its offline profile"); + // Derive the domain from authenticated package metadata, never the + // mutable catalogue hint. Local cameras cannot enter race/save hashes. + if(p->modules.contains("presentation")!=presentation)continue; std::size_t resource_bytes=bytes->size()+p->binary.size()*2;for(const auto& [name,r]:p->resources)resource_bytes+=r.size(); if(resource_bytes>ResourceLimit-total)bad("enabled package resources exceed the session budget"); total+=resource_bytes; enabled.insert(id);out.settings.emplace(id,configuration(*p,profile["settings"]));out.data.emplace(id,structured_data(profile["data"])); out.payloads.emplace(p->digest,std::move(bytes));packages.push_back(std::move(p)); } - out.ordered=resolve(packages,enabled,revision,online,modules);out.identity=session_identity(out.ordered,out.settings,revision,out.data);return out; + out.ordered=resolve(packages,enabled,revision,presentation?false:online,modules);out.identity=session_identity(out.ordered,out.settings,revision,out.data);return out; } } diff --git a/runtime-recomp/src/game/mods/magic_code_library.hpp b/runtime-recomp/src/game/mods/magic_code_library.hpp index 6aa36c9b..5eaceeeb 100644 --- a/runtime-recomp/src/game/mods/magic_code_library.hpp +++ b/runtime-recomp/src/game/mods/magic_code_library.hpp @@ -5,7 +5,7 @@ namespace dkr::mods::code { struct LibraryItem { std::string digest,id,name,version,description,compatibility; - bool kept_online=false,selected=false,enabled=false,approved=false,deterministic=false; + bool kept_online=false,selected=false,enabled=false,approved=false,deterministic=false,presentation=false; Json settings=Json::object(); Json options=Json::array(); Json data=Json::object(); @@ -32,7 +32,7 @@ class Library { void data(std::string_view digest,const Json& documents); void remove(std::string_view digest); FrozenSet freeze(std::string_view revision,bool online, - const std::map& modules,std::stop_token stop={})const; + const std::map& modules,std::stop_token stop={},bool presentation=false)const; std::shared_ptr payload(std::string_view digest)const; static void export_cups(const std::filesystem::path&,View); private: diff --git a/runtime-recomp/src/game/mods/magic_code_link.cpp b/runtime-recomp/src/game/mods/magic_code_link.cpp index 600dcf38..f4d80d44 100644 --- a/runtime-recomp/src/game/mods/magic_code_link.cpp +++ b/runtime-recomp/src/game/mods/magic_code_link.cpp @@ -2,6 +2,8 @@ #include #include +#include "magic_code_execution_policy.hpp" + namespace dkr::mods::code { namespace { [[noreturn]] void bad(const std::string& reason){throw Error("Custom Magic Code: "+reason);} @@ -20,6 +22,11 @@ std::shared_ptr link(const std::vector& settings,std::string_view revision,const LinkPolicy& policy, const std::map& data) { if(ordered.size()>128)bad("too many code packages enabled in one session"); + unsigned simulation_owners=0,tuning_owners=0; + for(const auto& p:ordered)if(p&&p->api==2) { + if(p->modules.contains("simulation2")&&++simulation_owners>1)bad("only one simulation control provider can own a session"); + if(p->modules.contains("racer-tuning2")&&++tuning_owners>1)bad("only one racer tuning provider can own a session"); + } std::set enabled; for(const auto& p:ordered)if(!p||!enabled.insert(p->id).second)bad("duplicate or missing code package"); const auto resolved=resolve(ordered,enabled,revision,policy.online,policy.modules); @@ -85,14 +92,15 @@ std::shared_ptr link(const std::vectorregions.push_back({at,end-at,true,false}); } - owner.heap_size=64*1024;owner.heap=allocate(owner.heap_size); + owner.heap_size=p.api==2?p.sdk2_heap:64*1024;owner.heap=allocate(owner.heap_size); out->regions.push_back({owner.heap,owner.heap_size,true,false}); constexpr unsigned StackBytes=32*1024;const auto stack=allocate(StackBytes); owner.stack=stack+StackBytes-80;out->regions.push_back({stack,StackBytes,true,false}); constexpr unsigned NativeStackBytes=64*1024;const auto native_stack=allocate(NativeStackBytes); owner.native_stack=native_stack+NativeStackBytes-80; out->regions.push_back({native_stack,NativeStackBytes,true,false}); - owner.services=allocate(4096);out->regions.push_back({owner.services,4096,true,false});owner.end=cursor; + const unsigned service_bytes=p.api==2?65536U:4096U; + owner.services=allocate(service_bytes);out->regions.push_back({owner.services,service_bytes,true,false});owner.end=cursor; for(const auto& symbol:symbols.import_symbols) { const auto dep=dependency(symbols,symbol.dependency_index); if(dep=="*"&&!policy.services.contains(symbol.base.name))bad("unavailable SDK import: "+symbol.base.name); @@ -154,6 +162,7 @@ std::shared_ptr link(const std::vector +#include "sdk2_schema.hpp" #include #include #include @@ -9,6 +11,8 @@ #include #include +#include "magic_code_execution_policy.hpp" + namespace dkr::mods::code { namespace { [[noreturn]] void bad(std::string why){throw Error("Custom Magic Code: "+why);} @@ -241,7 +245,10 @@ std::shared_ptr inspect(View bytes,std::string_view host_version) p->minimum_host=Version::parse(text(manifest,"minimum_recomp_version",64)); if(p->minimum_host>Version::parse(host_version))bad("package requires a newer DKR-R version"); if(manifest.contains("native_libraries")&&(!manifest["native_libraries"].is_array()||!manifest["native_libraries"].empty()))bad("native libraries are not supported"); - if(meta.value("schema",0)!=1||meta.value("api",0)!=ApiVersion)bad("unsupported DKR SDK version"); + if(!meta.contains("schema")||!meta["schema"].is_number_unsigned()||meta["schema"]!=1||!meta.contains("api")||!meta["api"].is_number_unsigned()||(meta["api"]!=1&&meta["api"]!=2))bad("unsupported DKR SDK version"); + p->api=meta.at("api").get(); + if(meta.contains("development")&&!meta["development"].is_boolean())bad("invalid development-session declaration"); + p->development=p->api==2&&meta.value("development",false); if(meta.value("activation",std::string("restart"))!="restart")bad("code changes require a new game session"); const auto mode=meta.value("online",std::string("offline-only")); if(mode!="offline-only"&&mode!="deterministic")bad("unsupported online execution mode"); @@ -256,8 +263,8 @@ std::shared_ptr inspect(View bytes,std::string_view host_version) if(meta.contains("requires")) { const auto& modules=meta["requires"];if(!modules.is_object()||modules.size()>32)bad("invalid SDK module requirements"); for(const auto& [name,version]:modules.items()) { - if(!valid_id(name)||!version.is_number_unsigned()||version.get()!=1)bad("unsupported SDK module version"); - p->modules.emplace(name,1); + if(!valid_id(name)||!version.is_number_unsigned()||version.get()<1||version.get()>p->api)bad("unsupported SDK module version"); + p->modules.emplace(name,version.get()); } } if(manifest.contains("dependencies")) { @@ -295,6 +302,40 @@ std::shared_ptr inspect(View bytes,std::string_view host_version) if(!schema.is_object()||!schema.contains("options")||!schema["options"].is_array()||schema["options"].size()>128)bad("invalid settings schema"); p->options=schema["options"]; } + if(p->api==2&&meta.contains("profile")) { + if(!meta["profile"].is_string())bad("SDK profile must be compact, standard or large"); + p->sdk2_profile=meta["profile"].get(); + if(p->sdk2_profile=="compact"){p->sdk2_heap=32768;p->sdk2_instructions=100000;p->sdk2_native_calls=32;} + else if(p->sdk2_profile=="large"){p->sdk2_heap=262144;p->sdk2_instructions=500000;p->sdk2_native_calls=256;} + else if(p->sdk2_profile!="standard")bad("unknown portable SDK profile"); + } + if(meta.contains("live_reload")) { + if(p->api!=2||!p->development||meta.at("live_reload")!="instruction-only"||!meta.contains("state_schema")|| + !meta.at("state_schema").is_number_unsigned()||meta.at("state_schema").get()<1||meta.at("state_schema").get()>UINT32_MAX) + bad("Live reload requires API 2 development, instruction-only and a nonzero state_schema"); + p->sdk2_reload=true;p->sdk2_state_schema=meta.at("state_schema").get(); + } + if(meta.contains("live_options")) { + if(p->api!=2||!p->modules.contains("live-settings2")||!meta.at("live_options").is_array()||meta.at("live_options").size()>128)bad("Live options require API 2/live-settings2 and a bounded list"); + for(const auto& id:meta.at("live_options")) { + if(!id.is_string()||!valid_id(id.get())||!p->sdk2_live_options.insert(id.get()).second)bad("Invalid or duplicate live option ID"); + const auto option=std::find_if(p->options.begin(),p->options.end(),[&](const auto& item){return item.at("id")==id;}); + if(option==p->options.end()||(option->at("type")!="Number"&&option->at("type")!="Enum"))bad("Only explicitly reviewed numeric/enum options can be live"); + } + } + sdk2_schema(*p); + if(p->api==2&&p->resources.contains("source-map")) { + const auto map=parse_document(p->resources.at("source-map"),1024*1024); + if(map.value("schema",0)!=2||!map.contains("functions")||!map["functions"].is_array()||map["functions"].size()>16384)bad("invalid SDK 2 source map"); + for(const auto& row:map["functions"]) { + if(!row.is_object()||!row.contains("name")||!row["name"].is_string()||row["name"].get_ref().size()>512|| + !row.contains("file")||!row["file"].is_string()||row["file"].get_ref().size()>512|| + !row.contains("line")||!row["line"].is_number_unsigned()||row["line"].get()>10000000)bad("invalid SDK source-map row"); + const auto file=row["file"].get(); + if(file.find("..")!=file.npos||file.find(':')!=file.npos||file.find('\\')!=file.npos||std::any_of(file.begin(),file.end(),[](unsigned char c){return c<32||c==127;}))bad("unsafe source-map filename"); + if(!file.empty()&&row["line"].get())p->debug_lines.emplace(row["name"].get(),file+":"+std::to_string(row["line"].get())); + } + } p->binary=archive.require("mod_binary.bin");const auto& symbols=archive.require("mod_syms.bin"); if(p->binary.empty()||p->binary.size()>MaxCode||symbols.size()>4*MiB)bad("code image exceeds its budget"); preflight_symbols(symbols,p->binary); @@ -385,7 +426,8 @@ Json structured_data(const Json& data) { } std::string session_identity(const std::vector>& packages, const std::map& settings,std::string_view revision,const std::map& data) { - Json value={{"api",ApiVersion},{"execution",ExecutionVersion},{"revision",revision},{"packages",Json::array()}}; + const bool sdk2=std::any_of(packages.begin(),packages.end(),[](const auto& p){return p->api==2;}); + Json value={{"work-policy",std::string(execution::Policy)},{"api",sdk2?2:ApiVersion},{"execution",sdk2?2:ExecutionVersion},{"revision",revision},{"packages",Json::array()}}; for(const auto& p:packages) { const auto found=settings.find(p->id);const auto options=configuration(*p,found==settings.end()?Json::object():found->second); const auto document=data.find(p->id); diff --git a/runtime-recomp/src/game/mods/magic_code_package.hpp b/runtime-recomp/src/game/mods/magic_code_package.hpp index 2c9ee4ad..18d6c594 100644 --- a/runtime-recomp/src/game/mods/magic_code_package.hpp +++ b/runtime-recomp/src/game/mods/magic_code_package.hpp @@ -25,6 +25,10 @@ struct Package { Version minimum_host; unsigned api=ApiVersion; bool deterministic=false; + bool development=false; + std::set sdk2_live_options; + bool sdk2_reload=false; + unsigned sdk2_state_schema=0; std::set revisions; std::map modules; std::vector dependencies; @@ -32,6 +36,9 @@ struct Package { std::vector tracks; std::map resources; Json options=Json::array(); + std::map debug_lines; + unsigned sdk2_heap=65536,sdk2_instructions=200000,sdk2_native_calls=128; + std::string sdk2_profile="standard"; Bytes binary; std::shared_ptr symbols; }; diff --git a/runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp b/runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp index 80f1a6a3..75764399 100644 --- a/runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp +++ b/runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp @@ -102,9 +102,28 @@ std::vector catalogue(const AssetBank& stock,const std::vector& le } std::sort(out.begin(),out.end(),[](const auto& a,const auto& b){return a.id()();}); + Json banks=Json::object();for(const auto [name,section]:std::array,6>{{{"models",29},{"textures-3d",2},{"textures-2d",4},{"sprites",12},{"object-templates",34},{"level-models",27}}}) + banks[name]={{"section",section},{"count",stock.record_count(section)},{"bank",stock.base_fingerprint()},{"scope","native-base"}}; + return {{"schema",2},{"banks",banks},{"characters",characters}}; +} +void verify_sdk2_inventory(const FrozenSet& code,const AssetBank& stock,const CharacterNamespace* names) { + Json characters=Json::array();if(names)for(const auto& item:names->characters)characters.push_back({{"id",item.id},{"name",item.name},{"donor",item.base_character}}); + const auto actual=sdk2_inventory(stock,std::move(characters)); + for(const auto& package:code.ordered)if(package->api==2&&package->modules.contains("catalogues2")) { + const auto& expected=code.data.at(package->id).at("_sdk2_catalogue").at("inventory"); + if(expected.at("schema")!=2||expected.at("banks")!=actual.at("banks")||expected.at("characters").size()!=actual.at("characters").size())throw Error("SDK resource inventory differs from admitted native assets."); + for(unsigned i=0;i installed_catalogue(const std::filesystem::path& rom,const std::filesystem::path& legacy, - const std::vector& paths,std::stop_token stop,bool canonical_order) { + const std::vector& paths,std::stop_token stop,bool canonical_order,Json* inventory) { auto bytes=read_file(rom,MaxImage);canonicalize_rom(bytes);const auto stock=AssetBank::stock(std::move(bytes)); + if(inventory) { + Json characters=Json::array();for(const auto& item:TrackCatalog::selected_items(legacy,stock->revision(),TrackCatalog::Kind::Character))characters.push_back({{"id",item.id},{"name",item.name},{"donor",item.base_character}}); + *inventory=sdk2_inventory(*stock,std::move(characters)); + } if(stop.stop_requested())throw Error("Cup catalogue cancelled."); std::vector roots;std::map defaults; if(TrackCatalog::has_enabled(legacy))for(const auto& t:TrackCatalog::load_enabled(legacy,stock)) { @@ -176,6 +195,12 @@ std::vector menu_entries(const FrozenSet& code) { if(entries.size()>32)throw Error("Custom trophy menu exceeds 32 cups.");return entries; } void verify_configuration(const FrozenSet& code,const std::vector& courses) { + Json catalogue=Json::array();for(const auto& course:courses)catalogue.push_back(course_document(course)); + for(const auto& p:code.ordered)if(p->api==2&&p->modules.contains("catalogues2")) { + const auto& data=code.data.at(p->id); + if(!data.contains("_sdk2_catalogue")||data.at("_sdk2_catalogue").at("schema")!=2||data.at("_sdk2_catalogue").at("courses")!=catalogue) + throw Error(p->name+": frozen SDK 2 resource catalogue differs from the admitted game assets."); + } for(const auto& package:code.ordered)for(const auto& required:package->tracks) if(std::none_of(courses.begin(),courses.end(),[&](const auto& course){return course.id==required;})) throw Error(package->name+" requires an enabled course: "+required); @@ -184,7 +209,13 @@ void verify_configuration(const FrozenSet& code,const std::vector& cours for(const auto& cup:all(data))(void)verify(encode(cup),courses); } } -void rebind(FrozenSet& code,const std::vector& courses,std::string_view revision) { +void rebind(FrozenSet& code,const std::vector& courses,std::string_view revision,const Json& inventory) { + Json catalogue=Json::array();for(const auto& course:courses)catalogue.push_back(course_document(course)); + for(const auto& p:code.ordered)if(p->api==2&&p->modules.contains("catalogues2")) { + auto& data=code.data[p->id];if(data.is_null())data=Json::object(); + data["_sdk2_catalogue"]={{"schema",2},{"courses",catalogue},{"inventory",inventory}}; + (void)structured_data(data); + } for(const auto& package:code.ordered)for(const auto& required:package->tracks) if(std::none_of(courses.begin(),courses.end(),[&](const auto& course){return course.id==required;})) throw Error(package->name+" requires an enabled course: "+required); diff --git a/runtime-recomp/src/game/mods/magic_code_trophy_catalog.hpp b/runtime-recomp/src/game/mods/magic_code_trophy_catalog.hpp index 05ea0efd..40b4ccc0 100644 --- a/runtime-recomp/src/game/mods/magic_code_trophy_catalog.hpp +++ b/runtime-recomp/src/game/mods/magic_code_trophy_catalog.hpp @@ -8,7 +8,7 @@ namespace dkr::mods::code::trophy { std::vector catalogue(const AssetBank&,const std::vector&,const dkr::runtime::custom_tracks::PreparedTracks&, const std::map& legacy_defaults={}); std::vector installed_catalogue(const std::filesystem::path&,const std::filesystem::path&, - const std::vector&,std::stop_token,bool canonical_order=true); + const std::vector&,std::stop_token,bool canonical_order=true,Json* sdk2_inventory=nullptr); Json course_document(const Course&); std::vector read_catalogue(const Json&); Json publication(const std::vector&,std::string_view,const std::vector&,std::uint64_t seed); @@ -17,7 +17,9 @@ std::vector all(const Json&); std::string menu_id(std::string_view package,std::string_view cup); std::vector menu_entries(const FrozenSet&); void verify_configuration(const FrozenSet&,const std::vector&); -void rebind(FrozenSet&,const std::vector&,std::string_view revision); +Json sdk2_inventory(const AssetBank&,Json characters); +void verify_sdk2_inventory(const FrozenSet&,const AssetBank&,const CharacterNamespace*); +void rebind(FrozenSet&,const std::vector&,std::string_view revision,const Json& inventory=Json::object()); // Data-only exchange format: no executable code, assets, paths or save state. struct SharedCups {std::vector cups;std::vector tracks;}; inline constexpr std::size_t MaxSharedCups=256*1024; diff --git a/runtime-recomp/src/game/mods/magic_code_vm.cpp b/runtime-recomp/src/game/mods/magic_code_vm.cpp index e6fc30ec..dc928ecb 100644 --- a/runtime-recomp/src/game/mods/magic_code_vm.cpp +++ b/runtime-recomp/src/game/mods/magic_code_vm.cpp @@ -8,6 +8,8 @@ #include #include +#include "magic_code_execution_policy.hpp" + namespace dkr::mods::code { namespace { [[noreturn]] void fault(const char* message){throw Error(std::string("Custom Magic Code: ")+message);} @@ -200,7 +202,7 @@ void execute(Memory& memory,Registers& c,std::uint32_t entry,Budget& budget,cons c.integer[0]=0;std::uint32_t pc=entry;std::optional pending; try {while(pc!=Done) { if(!budget.instructions)fault("instruction budget exceeded"); - --budget.instructions; + --budget.instructions;execution::instruction(); const auto instruction=std::uint32_t(memory.load(pc,4,true)); const unsigned op=instruction>>26,rs=(instruction>>21)&31,rt=(instruction>>16)&31, rd=(instruction>>11)&31,sa=(instruction>>6)&31,fn=instruction&63; @@ -329,8 +331,6 @@ void execute(Memory& memory,Registers& c,std::uint32_t entry,Budget& budget,cons if(prior) { pc=*prior; if(pc!=Done&&!memory.executable(pc)) { - if(!budget.native_calls)fault("native call budget exceeded"); - --budget.native_calls; if(!gateway)fault("unlinked function call"); gateway(pc,c,budget);pc=std::uint32_t(c.integer[31]);c.integer[0]=0; if(pc==Done)break; diff --git a/runtime-recomp/src/game/mods/magic_code_world.cpp b/runtime-recomp/src/game/mods/magic_code_world.cpp index 3e812e27..e8d47984 100644 --- a/runtime-recomp/src/game/mods/magic_code_world.cpp +++ b/runtime-recomp/src/game/mods/magic_code_world.cpp @@ -4,6 +4,9 @@ #include #include #include +#include + +#include "magic_code_execution_policy.hpp" namespace dkr::mods::code { namespace { @@ -46,7 +49,7 @@ World::World(std::shared_ptr linked,std::uint64_t seed,Native n for(std::size_t i=0;iowners.size();++i) { auto v=seed+=0x9e3779b97f4a7c15ULL;v=(v^(v>>30))*0xbf58476d1ce4e5b9ULL; v=(v^(v>>27))*0x94d049bb133111ebULL;v^=v>>31; - state_.push_back({v?v:1,0,{{0,linked_->owners[i].heap_size,false}}});calls_.push_back(0); + state_.push_back({v?v:1,0,{{0,linked_->owners[i].heap_size,false}}});calls_.push_back(0);metrics_.emplace_back(); } } LinkPolicy World::core_policy() { @@ -71,11 +74,37 @@ void World::attach(std::span ram,bool initial) { if(initial)install(*linked_,ram); ram_=ram;bus_=std::move(bus); } +std::unique_ptr World::prepare_rebind(std::shared_ptr image,int migration)const { + if(!idle()||ram_.empty()||!image||image->resident_bytes!=linked_->resident_bytes)bad("reload needs a retired equal-size world"); + auto regions=image->regions; + if(migration>=0)for(auto& r:regions)if(r.writable) { + const auto& owner=image->owners.at(unsigned(migration));r.writable=r.address>=owner.begin&&r.address+std::uint64_t(r.size)<=owner.end; + } + regions.push_back({0x80000000U,8*unsigned(MiB),migration<0,false}); + auto result=std::make_unique();result->linked=std::move(image);result->migration=migration; + result->bus=std::make_unique(ram_,std::move(regions));return result; +} +void World::exchange(Rebind& value)noexcept { + linked_.swap(value.linked);bus_.swap(value.bus);std::swap(migration_owner_,value.migration); + for(const auto& r:linked_->regions)if(r.executable) { + const auto image=std::find_if(linked_->sections.begin(),linked_->sections.end(),[&](const auto& s){return r.address>=s.address&&r.address-s.addressword_swapped.data()+r.address-image->address,r.size); + } +} +void World::owner_event(unsigned owner,std::string_view name,Budget& budget,const Registers& args) { + execution::PhaseScope phase_scope(execution::event_phase(name)); + execution::Transaction transaction(this,budget,name); + const auto found=linked_->events.find("*:"+std::string(name));if(found==linked_->events.end())return; + for(auto address:found->second)if(linked_->function_owners.at(address)==owner){auto c=args;call(address,c,budget);} +} void World::detach() { if(active_)bad("cannot retire RDRAM during a code callback"); bus_.reset();ram_={}; } void World::initialize(Budget& budget) { + execution::PhaseScope phase_scope(execution::Phase::Loading); + execution::Transaction transaction(this,budget,"on_boot"); if(initialized_||active_||ram_.empty())bad("code world initialization requires a fresh retired session"); initialized_=true; event("*","on_boot",Registers{},budget); @@ -84,6 +113,22 @@ void World::call(std::uint32_t function,Registers& c,Budget& budget,const Argume const auto found=linked_->function_owners.find(function); if(found==linked_->function_owners.end()||ram_.empty())bad("unlinked code callback"); const auto owner=found->second;auto& depth=calls_.at(owner); + const auto& package=*linked_->owners[owner].package; + execution::Transaction transaction(this,budget,"callback"); + auto& work_budget=transaction.budget(); + execution::OwnerScope execution_owner(owner,package.id, + package.api==2?package.sdk2_instructions:200000, + package.api==2?package.sdk2_native_calls:128); + const auto before=execution_owner.work().counts; + const auto started=std::chrono::steady_clock::now(); + struct Costs { + Metric& metric;const execution::OwnerWork& work;execution::Counts before; + bool outer;std::chrono::steady_clock::time_point started; + ~Costs(){++metric.callbacks;if(!outer)return; + metric.instructions+=work.counts.instructions-before.instructions; + metric.native_calls+=work.counts.native-before.native; + metric.nanoseconds+=std::chrono::duration_cast(std::chrono::steady_clock::now()-started).count();} + } costs{metrics_[owner],execution_owner.work(),before,depth==0,started}; if(depth>=8)bad("package callback stack exhausted"); try { const auto saved_sp=c.integer[29];const auto saved_fcsr=c.fcsr; @@ -100,34 +145,53 @@ void World::call(std::uint32_t function,Registers& c,Budget& budget,const Argume World& w;unsigned& depth;Registers& c;std::uint64_t sp;std::uint32_t fcsr;std::size_t owner; ~Guard(){w.state_[owner].fcsr=c.fcsr;c.integer[29]=sp;c.fcsr=fcsr;--depth;--w.active_;} }guard{*this,depth,c,saved_sp,saved_fcsr,owner}; - execute(bus,c,function,budget,[&](auto target,auto& registers,auto& fuel){gateway(target,registers,fuel);}); + execute(bus,c,function,work_budget,[&](auto target,auto& registers,auto& fuel){gateway(target,registers,fuel);}); }catch(const Error& e) { char context[160];std::snprintf(context,sizeof(context)," [package=%s callback=0x%08X]", linked_->owners[owner].package->id.c_str(),function); - throw Error(std::string(e.what())+context); + std::string location; + const auto& package=*linked_->owners[owner].package; + if(package.api==2&&!package.debug_lines.empty()) { + const auto& functions=linked_->owners[owner].functions;const auto found=std::find(functions.begin(),functions.end(),function); + if(found!=functions.end()) { + const auto& name=package.symbols->functions[std::size_t(found-functions.begin())].name; + if(const auto line=package.debug_lines.find(name);line!=package.debug_lines.end())location=" [callback-source="+line->second+"]"; + } + } + throw Error(std::string(e.what())+context+location+execution::diagnostic()); } } void World::event(std::string_view owner,std::string_view name,const Registers& arguments,Budget& budget,const ArgumentStack* native_arguments) { + execution::PhaseScope phase_scope(execution::event_phase(name)); + execution::Transaction transaction(this,budget,name); const auto found=linked_->events.find(std::string(owner)+":"+std::string(name)); if(found!=linked_->events.end())for(auto function:found->second){auto c=arguments;call(function,c,budget,native_arguments);} } bool World::has_hook(std::uint32_t function)const{return linked_->hooks.contains(function);} bool World::enter(std::uint32_t function,Registers& c,Budget& budget,const ArgumentStack* native_arguments) { + execution::Transaction transaction(this,budget,"entry-hooks"); const auto found=linked_->hooks.find(function);if(found==linked_->hooks.end())return false; for(auto hook:found->second.entry){auto context=c;call(hook,context,budget,native_arguments);} if(found->second.replacement){call(*found->second.replacement,c,budget,native_arguments);return true;}return false; } void World::leave(std::uint32_t function,const Registers& c,Budget& budget,const ArgumentStack* native_arguments) { + execution::Transaction transaction(this,budget,"exit-hooks"); const auto found=linked_->hooks.find(function);if(found==linked_->hooks.end())return; for(auto hook:found->second.exit){auto context=c;call(hook,context,budget,native_arguments);} } void World::gateway(std::uint32_t address,Registers& c,Budget& budget) { + if(migration_owner_>=0) { + const auto found=linked_->imports.find(address); + if(found==linked_->imports.end()||found->second.owner!=unsigned(migration_owner_)||found->second.event||found->second.dependency!="*"|| + !core_policy().services.contains(found->second.name))bad("reload migration permits only this owner's core services"); + } const auto found=linked_->imports.find(address); if(found==linked_->imports.end()) { if(!linked_->native_functions.contains(address)||!native_)bad("unlinked or protected base-game call"); native_(address,c,budget);return; } const auto& import=found->second;const auto& owner=linked_->owners[import.owner]; + execution::gateway(import.dependency=="*"&&!import.event); if(import.event){event(import.dependency,import.name,c,budget);return;} if(import.dependency=="*") { auto& bus=memory();static const auto policy=core_policy(); @@ -147,7 +211,7 @@ void World::gateway(std::uint32_t address,Registers& c,Budget& budget) { void World::core(std::string_view name,std::size_t index,Memory& bus,Registers& c,Budget&) { const auto& owner=linked_->owners[index];auto& state=state_[index]; const auto a=std::uint32_t(c.integer[4]),b=std::uint32_t(c.integer[5]),d=std::uint32_t(c.integer[6]); - if(name=="dkr_sdk_version"){c.integer[2]=ApiVersion;return;} + if(name=="dkr_sdk_version"){c.integer[2]=owner.package->api;return;} if(name=="dkr_random_u32") { auto x=state.random;x^=x>>12;x^=x<<25;x^=x>>27;state.random=x; c.integer[2]=std::uint32_t((x*0x2545f4914f6cdd1dULL)>>32);return; @@ -179,6 +243,15 @@ void World::core(std::string_view name,std::size_t index,Memory& bus,Registers& const auto& value=owner.settings[key]; if(name=="dkr_setting_number") { if(!value.is_number())bad("setting is not numeric"); + if(owner.package->api==2) { + const auto& options=owner.package->options; + const auto found=std::find_if(options.begin(),options.end(),[&](const auto& option){return option.at("id")==key;}); + if(found!=options.end()) { + const auto at=owner.services+28672+unsigned(found-options.begin())*8; + const auto tag=bus.load(at,4);const auto raw=std::uint32_t(bus.load(at+4,4)); + if(tag==1||tag==2){const double live=tag==1?double(std::bit_cast(raw)):double(raw);c.floating[0]=std::bit_cast(live);return;} + } + } c.floating[0]=std::bit_cast(value.get());return; } if(!value.is_string())bad("setting is not text"); @@ -217,7 +290,13 @@ std::vector World::take_effects(){ Bytes World::checkpoint()const { verify_immutable(); if(active_||ram_.empty())bad("checkpoint requires a retired code boundary"); - Writer w;w.word(ExecutionVersion);w.bytes.insert(w.bytes.end(),linked_->identity.begin(),linked_->identity.end()); + // Exact canonical size; preserve every field and zero-padding decision. + // One allocation instead of repeatedly growing the serialized image. + std::size_t serialized=80; + for(const auto& s:state_)serialized+=16+s.blocks.size()*12; + for(const auto& effect:effects_)serialized+=8+effect.text.size(); + for(const auto& region:linked_->regions)if(region.writable)serialized+=8+region.size; + Writer w;w.bytes.reserve(serialized);w.word(ExecutionVersion);w.bytes.insert(w.bytes.end(),linked_->identity.begin(),linked_->identity.end()); w.word(initialized_?1U:0U);w.word(std::uint32_t(state_.size())); for(const auto& s:state_) { w.wide(s.random);w.word(s.fcsr);w.word(std::uint32_t(s.blocks.size())); diff --git a/runtime-recomp/src/game/mods/magic_code_world.hpp b/runtime-recomp/src/game/mods/magic_code_world.hpp index 8cc401f6..c9605391 100644 --- a/runtime-recomp/src/game/mods/magic_code_world.hpp +++ b/runtime-recomp/src/game/mods/magic_code_world.hpp @@ -26,6 +26,13 @@ class World { std::shared_ptr stage(View)const; void commit(std::shared_ptr); const LinkedSet& linked()const{return *linked_;} + struct Metric {std::uint64_t callbacks=0,instructions=0,native_calls=0,nanoseconds=0;}; + const std::vector& local_metrics()const{return metrics_;} + struct Rebind {std::shared_ptr linked;std::unique_ptr bus;int migration=-1;}; + std::shared_ptr image()const{return linked_;} + std::unique_ptr prepare_rebind(std::shared_ptr,int migration=-1)const; + void exchange(Rebind&)noexcept; + void owner_event(unsigned,std::string_view,Budget&,const Registers& args={}); bool idle()const{return active_==0;} private: struct Block {std::uint32_t offset=0,size=0;bool used=false;}; @@ -33,12 +40,14 @@ class World { std::shared_ptr linked_; std::vector state_; std::vector calls_; + std::vector metrics_; std::span ram_; std::unique_ptr bus_; Native native_; Service service_; std::vector effects_; unsigned active_=0; + int migration_owner_=-1; bool initialized_=false; void gateway(std::uint32_t,Registers&,Budget&); void core(std::string_view,std::size_t,Memory&,Registers&,Budget&); diff --git a/runtime-recomp/src/game/mods/online_mod_manifest.cpp b/runtime-recomp/src/game/mods/online_mod_manifest.cpp index 8cf50e22..3640416a 100644 --- a/runtime-recomp/src/game/mods/online_mod_manifest.cpp +++ b/runtime-recomp/src/game/mods/online_mod_manifest.cpp @@ -1,3 +1,4 @@ +#include "vehicle_protocol.hpp" #include "online_mod_manifest.hpp" #include "legacy_character_artifact.hpp" #include "legacy_track_artifact.hpp" @@ -11,10 +12,12 @@ namespace dkr::mods::online { namespace { using nlohmann::json; const char* kind(Kind value) { + if(value==CarContent)return "car"; switch(value) {case Kind::Track:return "track";case Kind::Character:return "character";case Kind::TrackLab:return "tracklab";} throw Error("Invalid online mod kind."); } const char* payload_kind(PayloadKind value) { + if(value==CarPayload)return "car"; switch(value) {case PayloadKind::Xdelta:return "xdelta";case PayloadKind::TrackLab:return "tracklab";case PayloadKind::Code:return "code";} throw Error("Invalid online mod payload kind."); } @@ -56,7 +59,7 @@ Manifest canonical(Manifest value) { std::sort(value.content.begin(),value.content.end(),[](const auto& a,const auto& b){return std::tie(a.kind,a.id) payloads;std::uint64_t bytes=0; for(const auto& p:value.payloads) { - const auto limit=p.kind==PayloadKind::Xdelta?MaxPatch:p.kind==PayloadKind::Code?32*MiB:MaxStaged; + const auto limit=p.kind==PayloadKind::Xdelta?MaxPatch:(p.kind==PayloadKind::Code||p.kind==CarPayload)?32*MiB:MaxStaged; payload_kind(p.kind); if(!valid_digest(p.digest) || !p.size || p.size>limit || p.size>MaxTransferBytes-bytes || (!p.source_label.empty() && (!label(p.source_label) || p.source_label.size()>120)) || @@ -66,11 +69,20 @@ Manifest canonical(Manifest value) { throw Error("Invalid, duplicate or oversized online mod payload."); bytes+=p.size; } - std::set ids,used;std::size_t tracks=0,characters=0; + std::set ids,used;std::size_t tracks=0,characters=0,cars=0; for(const auto& c:value.content) { kind(c.kind); if(!valid_digest(c.id) || !label(c.name) || !valid_digest(c.artifact) || !valid_digest(c.bank) || !ids.insert(c.id).second || !payloads.contains(c.payload))throw Error("Invalid online mod content identity."); + if(c.kind==CarContent) { + const auto p=std::find_if(value.payloads.begin(),value.payloads.end(),[&](const auto& p){return p.digest==c.payload;}); + if(c.adapter!=CarAdapterVersion||c.artifact!=c.payload||c.bank!=c.payload||c.id!=c.payload|| + !c.behaviour.empty()||c.behaviour_abi||payloads.at(c.payload)!=CarPayload||p==value.payloads.end()|| + p->source_label.empty()||p->source_label.find_first_of("/\\:")!=std::string::npos|| + p->source_label.size()<9||p->source_label.substr(p->source_label.size()-8)!=".dkrkart") + throw Error("This build cannot safely prepare the required custom car."); + ++cars;used.insert(c.payload);continue; + } const auto expected=c.kind==Kind::Character?CharacterAdapterVersion:c.kind==Kind::Track?TrackAdapterVersion:1U; if(c.adapter!=expected || payloads.at(c.payload)!=(c.kind==Kind::TrackLab?PayloadKind::TrackLab:PayloadKind::Xdelta)) throw Error("This build cannot safely prepare the required online mod adapter."); @@ -87,7 +99,7 @@ Manifest canonical(Manifest value) { throw Error("Invalid or duplicate online code selection."); canonical_object(code.settings,512*1024);canonical_object(code.data,65536);used.insert(code.payload); } - if(tracks>MaxTracks || characters>MaxCharacters || used.size()!=payloads.size()) + if(tracks>MaxTracks || characters>MaxCharacters || cars>16 || used.size()!=payloads.size()) throw Error("Online mod selection exceeds the supported budget or includes unused payloads."); return value; } @@ -120,15 +132,15 @@ Manifest decode(View bytes) { for(const auto& p:payloads) { keys(p,{"kind","sha256","source_revision","size","source_label"}); const auto tag=p.at("kind").get(); - if(tag!="xdelta" && tag!="tracklab"&&tag!="code")throw Error("Unknown online mod payload."); + if(tag!="xdelta" && tag!="tracklab"&&tag!="code"&&tag!="car")throw Error("Unknown online mod payload."); if(!p.at("size").is_number_unsigned())throw Error("Invalid online mod payload size."); - out.payloads.push_back({tag=="xdelta"?PayloadKind::Xdelta:tag=="code"?PayloadKind::Code:PayloadKind::TrackLab,p.at("sha256").get(),p.at("source_revision").get(),p.at("size").get(),p.at("source_label").get()}); + out.payloads.push_back({tag=="car"?CarPayload:tag=="xdelta"?PayloadKind::Xdelta:tag=="code"?PayloadKind::Code:PayloadKind::TrackLab,p.at("sha256").get(),p.at("source_revision").get(),p.at("size").get(),p.at("source_label").get()}); } for(const auto& c:contents) { keys(c,{"kind","id","name","payload","artifact","bank","adapter","behaviour","behaviour_abi"}); const auto tag=c.at("kind").get(); - if(tag!="track" && tag!="character" && tag!="tracklab")throw Error("Unknown online mod content."); - out.content.push_back({tag=="track"?Kind::Track:tag=="character"?Kind::Character:Kind::TrackLab, + if(tag!="track" && tag!="character" && tag!="tracklab" && tag!="car")throw Error("Unknown online mod content."); + out.content.push_back({tag=="car"?CarContent:tag=="track"?Kind::Track:tag=="character"?Kind::Character:Kind::TrackLab, c.at("id").get(),c.at("name").get(),c.at("payload").get(), c.at("artifact").get(),c.at("bank").get(),number(c.at("adapter")), c.at("behaviour").get(),number(c.at("behaviour_abi"))}); diff --git a/runtime-recomp/src/game/mods/online_mod_prepare.cpp b/runtime-recomp/src/game/mods/online_mod_prepare.cpp index 89bd05ab..7bb5c2b3 100644 --- a/runtime-recomp/src/game/mods/online_mod_prepare.cpp +++ b/runtime-recomp/src/game/mods/online_mod_prepare.cpp @@ -112,16 +112,25 @@ void prepare_library(const Manifest& input,const std::filesystem::path& input_pa for(const bool enabled:{false,true}) { if(!(enabled?behaviour_enabled:asset_only))continue; report(kind==Kind::Track?"Preparing exact custom course selection":"Preparing exact custom character selection"); - const auto prepared=job/(std::string(kind==Kind::Track?"tracks":"characters")+(enabled?"-behaviour":"-assets")); + // Old host selections retain v2 IDs/save associations. Rebuild + // that exact edition only when requested, never accept a mismatch. + for(const bool previous_portraits:{false,true}) { + if(previous_portraits && kind!=Kind::Character)continue; + bool pending=false; + for(const auto& content:manifest.content) + if(content.payload==payload.digest && content.kind==kind && (content.behaviour_abi!=0)==enabled && + !found.contains({kind,content.id}))pending=true; + if(!pending)continue; + const auto prepared=job/(std::string(previous_portraits?"previous-portraits-":"")+std::string(kind==Kind::Track?"tracks":"characters")+(enabled?"-behaviour":"-assets")); if(kind==Kind::Track)prepare_imported_tracks(review,owned_roms,prepared,progress,&preparation,enabled); - else prepare_imported_characters(review,owned_roms,prepared,progress,&preparation,enabled); + else prepare_imported_characters(review,owned_roms,prepared,progress,&preparation,enabled,previous_portraits); auto receipt=read(prepared/"prepared.json"); const char* entries=kind==Kind::Track?"tracks":"characters"; auto selected=json::array(); for(const auto& item:receipt.at(entries)) { if(kind==Kind::Track && item.at("revision")!=manifest.revision)continue; const auto key=std::make_pair(kind,item.at("id").get());const auto entry=expected.find(key); - if(entry==expected.end() || entry->second.payload!=payload.digest)continue; + if(entry==expected.end() || entry->second.payload!=payload.digest || found.contains(key))continue; const auto& wanted=entry->second; if((wanted.behaviour_abi!=0)!=enabled)continue; if(item.at("patch")!=wanted.payload || item.at("artifact")!=wanted.artifact || item.at("name")!=wanted.name || @@ -132,6 +141,9 @@ void prepare_library(const Manifest& input,const std::filesystem::path& input_pa } // Do not expose extra roots or duplicate asset-only editions from // the other policy as members of the frozen session collection. + // A corrected pass may have no roots matching an old host ID. + // Leave that scratch unpublished; empty groups are not catalogues. + if(selected.empty())continue; receipt[entries]=std::move(selected); const auto original_receipt=prepared/"prepared.json"; const auto retained_receipt=prepared/"prepared-all.json"; @@ -140,6 +152,7 @@ void prepare_library(const Manifest& input,const std::filesystem::path& input_pa const auto hash=sha256(read_file(prepared/"prepared.json",MiB)); std::filesystem::rename(prepared,root/(kind==Kind::Track?"prepared":"prepared-characters")/hash); } + } } storage_budget(destination); } diff --git a/runtime-recomp/src/game/mods/online_mod_profile.cpp b/runtime-recomp/src/game/mods/online_mod_profile.cpp index 368da43b..a9188397 100644 --- a/runtime-recomp/src/game/mods/online_mod_profile.cpp +++ b/runtime-recomp/src/game/mods/online_mod_profile.cpp @@ -1,3 +1,6 @@ +#include "../offline_keep.hpp" +#include "../cache_repair.hpp" +#include "../vehicle_online.hpp" #include "online_mod_profile.hpp" #include "legacy_mod_process.hpp" #include "legacy_track_catalog.hpp" @@ -61,11 +64,12 @@ Bundle export_host(const std::filesystem::path& library,const std::vectormodules.contains("trophies")||!p->tracks.empty();})) { + if(std::any_of(frozen.ordered.begin(),frozen.ordered.end(),[](const auto& p){return (p->api==2&&p->modules.contains("catalogues2"))||p->modules.contains("trophies")||!p->tracks.empty();})) { + code::Json sdk2_inventory; const auto courses=with_matching_game_pak(roms,bundle.manifest.revision,stop, [](const auto& path){return dkr::runtime::rom::inspect(path);}, - [&](const auto& path){return code::trophy::installed_catalogue(path,library,labs,stop);}); - code::trophy::rebind(frozen,courses,bundle.manifest.revision); + [&](const auto& path){return code::trophy::installed_catalogue(path,library,labs,stop,true,&sdk2_inventory);}); + code::trophy::rebind(frozen,courses,bundle.manifest.revision,sdk2_inventory); } for(const auto& package:frozen.ordered) { const auto bytes=frozen.payloads.at(package->digest); @@ -77,6 +81,15 @@ Bundle export_host(const std::filesystem::path& library,const std::vectorsize(); } } + auto cars=vehicles::Library(library.parent_path()/"vehicles").freeze(stop); + for(const auto& car:cars.packages) { + cancelled(stop);if(car->source.size()>MaxTransferBytes-total)throw Error("Custom cars exceed the online transfer budget."); + auto label=car->source_name;label.replace(label.size()-8,8,".dkrkart"); + bundle.manifest.payloads.push_back({CarPayload,car->digest,{},car->source.size(),label}); + bundle.manifest.content.push_back({CarContent,car->digest,car->name,car->digest,car->digest,car->digest,CarAdapterVersion}); + if(!bundle.payloads.emplace(car->digest,std::make_shared(car->source)).second)throw Error("A custom car payload is duplicated."); + total+=car->source.size(); + } bundle.manifest=canonical(std::move(bundle.manifest));return bundle; } ProfileSources profile_sources(const Profile& input_profile) { @@ -116,7 +129,7 @@ ProfileSources profile_sources(const Profile& input_profile) { sources.courses.push_back(std::move(path)); } if(refs.contains("code")) { - if(!refs["code"].is_object()||refs["code"].size()>128)throw Error("Invalid online code source index."); + if(!refs["code"].is_object()||refs["code"].size()>144)throw Error("Invalid online code source index."); for(const auto& [hash,ref]:refs["code"].items()) { const auto text=ref.get();const auto path=utf8_path(text); if(!valid_digest(hash)||text.size()>32768||!path.is_absolute())throw Error("Invalid online code source path."); @@ -167,8 +180,15 @@ ProfileSources profile_sources(const Profile& input_profile) { sha256(pack_track_lab(path))!=wanted->second.payload || !found.insert(key).second) throw Error("Online Track Lab course differs from its host manifest."); } + // The existing bounded portable-blob map holds both executable code and + // data-only cars. Only Manifest::code can ever grant code execution. + for(const auto& c:manifest.content)if(c.kind==CarContent) { + const auto at=sources.code.find(c.payload);if(at==sources.code.end())throw Error("A required custom car is missing."); + check_storage(at->second,false);verified_car(manifest,c,read_file(at->second,vehicles::MaxPackageBytes)); + found.insert(std::make_pair(CarContent,c.id)); + } if(found.size()!=expected.size())throw Error("Online profile is missing required host content."); - if(sources.code.size()!=manifest.code.size())throw Error("Online profile is missing required executable code."); + if(sources.code.size()!=manifest.code.size()+car_count(manifest))throw Error("Online profile is missing required executable code."); for(const auto& code:manifest.code) { const auto at=sources.code.find(code.payload);if(at==sources.code.end())throw Error("Online code payload is missing."); check_storage(at->second,false);const auto bytes=read_file(at->second,32*MiB); @@ -241,45 +261,19 @@ void retain_profile(const Profile& input_profile,const std::filesystem::path& li cancelled(stop);codes.import(read_file(sources.code.at(package.payload),code::MaxPackage),true); } } + vehicles::Library cars(library.parent_path()/"vehicles"); + for(const auto& c:profile.manifest.content)if(c.kind==CarContent) { + cancelled(stop);const auto bytes=read_file(sources.code.at(c.payload),vehicles::MaxPackageBytes); + const auto package=verified_car(profile.manifest,c,bytes); + cars.import({package->source_name,bytes,vehicles::Type::Car},false); + } if(profile.manifest.content.empty())return; const auto root=private_storage_path(library),tracks=private_storage_path(track_library); ensure_directory(root);ensure_directory(tracks); for(const auto* folder:{"reviews","prepared","prepared-characters","online-keeps"})ensure_directory(root/folder); - std::set libraries; - std::map,std::filesystem::path> trees; - for(const auto* selected:{&sources.tracks,&sources.characters})for(const auto& source:*selected) { - libraries.insert(source.library); - const auto folder=selected==&sources.tracks?"prepared":"prepared-characters"; - for(const auto& [type,id]:{std::make_pair(std::string(folder),source.item.group),std::make_pair(std::string("reviews"),source.item.review)}) { - if(!valid_digest(id))throw Error("Invalid retained source identity."); - const auto path=source.library/type/id; - const auto [at,inserted]=trees.emplace(std::make_pair(type,id),path); - if(!inserted && at->second!=path && !same_tree(at->second,path,stop))throw Error("Conflicting verified source groups."); - } - } - for(const auto& library: libraries)if(library!=root)TrackCatalog::validate_retained_merge(root,library); + retain_legacy_sources(sources,root,stop); struct Copy {std::filesystem::path source,target,staged;};std::vector copies; std::uint64_t bytes=0;unsigned files=0; - for(const auto& [identity,source]:trees) { - const auto& [folder,name]=identity; - cancelled(stop); - if(!valid_digest(name))throw Error("Invalid verified offline storage identity."); - if(folder=="reviews" && sha256(read_file(source/"review.json",8*MiB))!=name) - throw Error("The retained source review no longer matches its identity."); - const auto target=root/folder/name; - // Already installed assets are the source itself: no copy or - // expensive recursive comparison of an entire multi-mod pack. - if(source==target)continue; - if(std::filesystem::exists(target)) { - if(!same_tree(source,target,stop))throw Error("A kept mod conflicts with existing offline files. They were not replaced."); - continue; - } - copies.push_back({source,target,{}}); - for(const auto& [path,type]:retained_tree(source,stop)) { - if(++files>32768 || type.second>std::uint64_t(1024*MiB)-bytes)throw Error("Offline mod copy exceeds its storage budget."); - bytes+=type.second; - } - } // Track Lab identity, not folder name, owns a slot. Do not overwrite a // user's authored/installed variant that happens to have the same id. std::map existing; @@ -331,7 +325,7 @@ void retain_profile(const Profile& input_profile,const std::filesystem::path& li if(error && (!std::filesystem::exists(copy.target) || !same_tree(copy.staged,copy.target,stop))) throw Error("Could not publish a complete offline copy. Existing mods and selections were not replaced."); } - std::fprintf(stderr,"[online-mods][keep] unique-trees=%zu copied-trees=%zu copied-files=%u bytes=%llu\n",trees.size(),copies.size(),files,static_cast(bytes)); + std::fprintf(stderr,"[online-mods][keep] unique-trees=%zu copied-trees=%zu copied-files=%u bytes=%llu\n",std::size_t(0),copies.size(),files,static_cast(bytes)); // No host catalogues, enablement lists, visibility, ROMs or saves copied. } void verify_online_worker(const std::filesystem::path& worker,const std::filesystem::path& online_root,std::stop_token stop) { @@ -356,21 +350,31 @@ void cache_installed_payloads(const Manifest& input,const std::filesystem::path& const std::filesystem::path& code_root,std::string_view version) { const auto manifest=canonical(input); const auto payloads=private_storage_path(input_payloads);check_storage(payloads,true); + const auto available=[&](const auto& payload){return dkr::lobby_stability::verify_or_repair_download(payloads.parent_path(),"payloads",payload,stop);}; if(!code_root.empty()&&std::filesystem::exists(code_root/"packages")) { code::Library library(code_root,std::string(version)); - for(const auto& payload:manifest.payloads)if(payload.kind==PayloadKind::Code&&!cached_payload(payloads,payload)) { + for(const auto& payload:manifest.payloads)if(payload.kind==PayloadKind::Code&&!available(payload)) { cancelled(stop); try { const auto bytes=library.payload(payload.digest);if(bytes->size()!=payload.size)continue; - try{write_new_file(payloads/payload.digest,*bytes);}catch(...){if(!cached_payload(payloads,payload))throw;} + try{write_new_file(payloads/payload.digest,*bytes);}catch(...){if(!available(payload))throw;} }catch(const std::exception&){cancelled(stop);} // optional reuse never waives SHA/package validation } } + if(!input_library.empty())for(const auto& p:manifest.payloads)if(p.kind==CarPayload&&!available(p)) { + cancelled(stop); + try { + const auto file=input_library.parent_path()/"vehicles"/"packages"/(p.digest+".dkrkart"); + check_storage(file,false);const auto bytes=read_file(file,vehicles::MaxPackageBytes); + if(bytes.size()!=p.size||sha256(bytes)!=p.digest)continue; + try{write_new_file(payloads/p.digest,bytes);}catch(...){if(!available(p))throw;} + }catch(const std::exception&){cancelled(stop);} + } // Authored Track Lab packages also have a canonical wire representation. // Local enablement sidecars are excluded by the existing packer. if(!track_library.empty() && std::filesystem::exists(track_library)) { std::map courses; - for(const auto& p:manifest.payloads)if(p.kind==PayloadKind::TrackLab && !cached_payload(payloads,p))courses.emplace(p.digest,p); + for(const auto& p:manifest.payloads)if(p.kind==PayloadKind::TrackLab && !available(p))courses.emplace(p.digest,p); const auto tracks=private_storage_path(track_library);check_storage(tracks,true);unsigned examined=0; for(const auto& entry:std::filesystem::directory_iterator(tracks)) { cancelled(stop);if(courses.empty())break; @@ -379,7 +383,7 @@ void cache_installed_payloads(const Manifest& input,const std::filesystem::path& try { const auto bytes=pack_track_lab(entry.path(),stop);const auto digest=sha256(bytes); const auto found=courses.find(digest);if(found==courses.end() || bytes.size()!=found->second.size)continue; - try{write_new_file(payloads/digest,bytes);}catch(...){if(!cached_payload(payloads,found->second))throw;} + try{write_new_file(payloads/digest,bytes);}catch(...){if(!available(found->second))throw;} courses.erase(found); }catch(const std::exception&){cancelled(stop);} } @@ -389,7 +393,7 @@ void cache_installed_payloads(const Manifest& input,const std::filesystem::path& if(!std::filesystem::exists(library/"reviews"))return; check_storage(library,true);check_storage(library/"reviews",true);check_storage(payloads,true); std::map missing; - for(const auto& payload:manifest.payloads)if(payload.kind==PayloadKind::Xdelta && !cached_payload(payloads,payload))missing.emplace(payload.digest,payload); + for(const auto& payload:manifest.payloads)if(payload.kind==PayloadKind::Xdelta && !available(payload))missing.emplace(payload.digest,payload); unsigned examined=0; for(const auto& entry:std::filesystem::directory_iterator(library/"reviews")) { cancelled(stop);if(missing.empty())break; @@ -407,7 +411,7 @@ void cache_installed_payloads(const Manifest& input,const std::filesystem::path& const auto patch=entry.path()/"patches"/(found->first+".xdelta");check_storage(patch,false); const auto bytes=read_file(patch,static_cast(found->second.size)); if(bytes.size()!=found->second.size || sha256(bytes)!=found->first)continue; - try{write_new_file(payloads/found->first,bytes);}catch(...){if(!cached_payload(payloads,found->second))throw;} + try{write_new_file(payloads/found->first,bytes);}catch(...){if(!available(found->second))throw;} missing.erase(found); } }catch(const std::exception&) { @@ -464,26 +468,22 @@ std::shared_ptr prepare_profile(const Manifest& input,const std:: const auto payloads=private_storage_path(input_payloads); if(needs_worker)check_storage(worker,false);check_storage(payloads,true);cancelled(stop); check_storage(root,true);child(root/"source-profiles");child(root/"prepared-payloads");child(root/"jobs"); - if(needs_worker)verify_online_worker(worker,root,stop); + bool worker_verified=false; + const auto ensure_worker=[&]{if(!worker_verified){verify_online_worker(worker,root,stop);worker_verified=true;}}; out->directory=root/"source-profiles"/out->digest; if(std::filesystem::exists(out->directory)) { - if(!installed_library.empty() || !installed_track_library.empty() || !selected_courses.empty()) { - // Prefer the current installed sources on rejoin, particularly - // after a first download was kept offline. An older cache index - // still references the online staging copy; returning it would - // force recursive comparisons of those same packs on every keep. - // Preserve the old receipt and publish only a new tiny index. - out->directory=root/"source-profiles"/nonce(); - } else { if(progress)progress("Revalidating the cached online mod profile"); - try{validate_profile(*out);return out;} + try{validate_profile(*out);cancelled(stop);std::fprintf(stderr,"[online-mods][profile] verified-index-reused=1 manifest=%s\n",out->digest.c_str());return out;} catch(const std::exception&) { cancelled(stop); - // A user may remove/reimport a group after the last lobby. Leave - // the old index untouched and build a new fully verified one. + // Preserve the stale index for QA and rebuild its named receipt + // atomically, so future rejoins reuse the repaired profile too. if(progress)progress("Refreshing stale online asset references"); - out->directory=root/"source-profiles"/nonce(); - } + check_storage(out->directory,true); + if(std::filesystem::canonical(out->directory).parent_path()!=std::filesystem::canonical(root/"source-profiles")||out->directory.filename()!=utf8_path(out->digest)) + throw Error("Cannot safely refresh the stale online profile index."); + child(root/"quarantined-profiles"); + std::filesystem::rename(out->directory,root/"quarantined-profiles"/(out->digest+"-"+nonce())); } } std::filesystem::path installed; @@ -537,6 +537,14 @@ std::shared_ptr prepare_profile(const Manifest& input,const std:: std::set batch_attempted; for(const auto& payload:out->manifest.payloads) { cancelled(stop); + if(payload.kind==CarPayload) { + if(!cached_payload(payloads,payload))throw Error("A custom car failed its SHA/size check."); + const auto wanted=std::find_if(out->manifest.content.begin(),out->manifest.content.end(),[&](const auto& c){return c.kind==CarContent&&c.payload==payload.digest;}); + if(wanted==out->manifest.content.end())throw Error("A custom car has no selected host identity."); + verified_car(out->manifest,*wanted,read_file(payloads/payload.digest,vehicles::MaxPackageBytes)); + sources.code.emplace(payload.digest,payloads/payload.digest);++index; + if(progress)progress("Verifying custom cars");continue; + } if(payload.kind==PayloadKind::Code) { if(!cached_payload(payloads,payload))throw Error("A required executable code package failed its SHA/size check."); sources.code.emplace(payload.digest,payloads/payload.digest);++index; @@ -554,7 +562,9 @@ std::shared_ptr prepare_profile(const Manifest& input,const std:: const Profile reusable{subset,identity(subset),root/"prepared-payloads"/identity(subset)}; auto source=reusable.directory; const auto started=std::chrono::steady_clock::now(); - const auto prefix="Mod "+std::to_string(++index)+"/"+std::to_string(out->manifest.payloads.size())+": "; + const auto named=std::find_if(out->manifest.content.begin(),out->manifest.content.end(),[&](const auto& c){return c.payload==payload.digest;}); + const auto prefix="Mod "+std::to_string(++index)+"/"+std::to_string(out->manifest.payloads.size())+": "+(named==out->manifest.content.end()?payload.digest.substr(0,12):named->name)+" - "; + std::fprintf(stderr,"[online-mods][package] payload=%s item=%u/%zu begin\n",payload.digest.c_str(),index,out->manifest.payloads.size()); std::string method="cache"; const bool reused=reuse_installed(subset,installed,installed_tracks,installed_characters,installed_courses,sources); std::optional cached; @@ -563,7 +573,6 @@ std::shared_ptr prepare_profile(const Manifest& input,const std:: try {cached=profile_sources(reusable);} catch(const std::exception&) { cancelled(stop); - if(payload.kind!=PayloadKind::TrackLab)throw; // Rebuild only this native payload from its verified download. // Preserve the old tree for recovery, never touch offline mods. check_storage(source,true); @@ -573,7 +582,7 @@ std::shared_ptr prepare_profile(const Manifest& input,const std:: child(root/"quarantined-payloads"); std::filesystem::rename(source,root/"quarantined-payloads"/(reusable.digest+"-"+nonce())); std::fprintf(stderr,"[online-mods][cache-repair] payload=%s preserved-old-tree=1\n",payload.digest.c_str()); - if(progress)progress(prefix+"Rebuilding incomplete course cache from verified files"); + if(progress)progress(prefix+"Rebuilding this damaged cache from verified files"); } } if(reused) { @@ -585,6 +594,7 @@ std::shared_ptr prepare_profile(const Manifest& input,const std:: append_sources(sources,*cached); } else { method=payload.kind==PayloadKind::TrackLab?"native-assets":"worker"; + if(payload.kind==PayloadKind::Xdelta)ensure_worker(); const auto reserve=payload.kind==PayloadKind::TrackLab?payload.size+64ULL*MiB:1280ULL*MiB; if(std::filesystem::space(root).available RuntimeResources::prepare(std::shared_pt cancelled(stop);const auto sources=profile_sources(*profile);check_storage(rom,false); auto out=std::shared_ptr(new RuntimeResources);out->profile_=std::move(profile); auto bytes=read_file(rom,MaxImage);canonicalize_rom(bytes); - const auto key=out->profile_->digest+":"+sha256(bytes); + const auto key=out->profile_->digest+":"+sha256(bytes)+":selected-music-v1"; { std::lock_guard lock(resource_cache_mutex); if(auto reusable=live_resource_cache.lock();reusable && resource_cache_key==key) { @@ -138,9 +141,8 @@ std::shared_ptr RuntimeResources::prepare(std::shared_pt out->authored_=std::make_shared( dkr::runtime::custom_tracks::prepare_tracks(std::move(selected),tables)); (void)course_display_budgets(out->authored_.get()); - auto music=std::make_shared();std::size_t music_budget=128*MiB; - std::map> decoded; - std::atomic cancel_decode{false};std::stop_callback cancel_music(stop,[&]{cancel_decode=true;}); + auto music=std::make_shared();std::size_t music_encoded_bytes=0; + std::map decoded; std::string music_identity; for(const auto& course:out->authored_->courses) { const auto track=std::ranges::find_if(out->authored_->tracks,[&](const auto& t){return t.id==course.id;}); @@ -148,39 +150,44 @@ std::shared_ptr RuntimeResources::prepare(std::shared_pt if(!track->music)continue; MusicSong song;song.info=*track->music; if(song.info.kind==dkr::runtime::custom_tracks::MusicKind::Recording) { - // Sharing decoded samples must not let a second course claim the - // first recording's digest without verifying its own file bytes. - cancelled(stop);const auto input=read_file(song.info.file,dkr::runtime::custom_tracks::kMaxMusicBytes); + cancelled(stop);auto input=read_file(song.info.file,dkr::runtime::custom_tracks::kMaxMusicBytes); if(input.size()!=song.info.bytes || sha256(input)!=song.info.sha256) throw Error("Music for \""+track->name+"\" changed after package verification. Re-import that track."); auto found=decoded.find(song.info.sha256); - if(found!=decoded.end())song.pcm=found->second; - else { - auto pcm=std::make_shared();std::string error; - if(!dkr::runtime::custom_music::decode_bytes(input.data(),input.size(),song.info.codec,*pcm,error,&cancel_decode,std::min(64*MiB,music_budget))) - throw Error("Music for \""+track->name+"\" cannot be prepared: "+error); - music_budget-=pcm->samples.size()*2;song.pcm=pcm;decoded.emplace(song.info.sha256,pcm); - // Verify the decoded representation too: a platform whose - // decoder differs must not enter deterministic replay. - music_identity+="\n"+song.info.sha256+":"+sha256_pcm_le(pcm->samples); + if(found==decoded.end()) { + if(input.size()>128*MiB-music_encoded_bytes) + throw Error("Encoded custom music exceeds the 128 MiB session limit."); + const auto encoded_size=input.size();resident_music::Prepared prepared; + try{prepared=resident_music::prepare(std::move(input),song.info,stop);} + catch(const std::exception& e){throw Error("Music for \""+track->name+"\" cannot be prepared: "+e.what());} + music_encoded_bytes+=encoded_size; + music_identity+="\n"+song.info.sha256+":"+prepared.pcm_sha256; + found=decoded.emplace(song.info.sha256,std::move(prepared)).first; } - // A shared recording does not imply shared loop metadata. Check - // EVERY course, including deduplicated recordings, before mixing. - const auto& pcm=*song.pcm; - if(!pcm.frames || pcm.frames!=song.info.frames || pcm.sample_rate!=song.info.sample_rate || - pcm.samples.size()!=pcm.frames*2 || song.info.loop_start>=pcm.frames || - (song.info.loop_end && (song.info.loop_end>pcm.frames || song.info.loop_end<=song.info.loop_start))) + song.pcm=found->second.metadata;const auto& shape=*song.pcm; + if(!shape.frames || shape.frames!=song.info.frames || shape.sample_rate!=song.info.sample_rate || + song.info.loop_start>=shape.frames || + (song.info.loop_end && (song.info.loop_end>shape.frames || song.info.loop_end<=song.info.loop_start))) throw Error("Music for \""+track->name+"\" has invalid frame, sample-rate or loop settings. Re-export that track."); - std::fprintf(stderr,"[online-mods][music] course=%s frames=%llu rate=%u unique-recordings=%zu resident-bytes=%zu\n", - track->id.c_str(),static_cast(pcm.frames),pcm.sample_rate,decoded.size(),128*MiB-music_budget); + std::fprintf(stderr,"[online-mods][music] course=%s frames=%llu rate=%u unique-recordings=%zu encoded-bytes=%zu idle-decoded-bytes=0\n", + track->id.c_str(),static_cast(shape.frames),shape.sample_rate,decoded.size(),music_encoded_bytes); } music->emplace(course.level_id,std::move(song)); } out->music_=std::move(music); + for(unsigned i=0;iauthored_->additions[i].size();++j) out->additions_.emplace(AssetKey{Data[i],static_cast(out->base_counts_[i]+j)},out->authored_->additions[i][j]); // Fully prepare every route NOW, before proof/readiness. Scene switches // subsequently select immutable banks and never decode/hash/read a file. + std::vector> cars; + for(const auto& c:out->profile_->manifest.content)if(c.kind==CarContent) { + cancelled(stop);cars.push_back(verified_car(out->profile_->manifest,c,read_file(sources.code.at(c.payload),vehicles::MaxPackageBytes))); + } + if(!cars.empty()) { + auto resources=vehicles::prepare_resources(out->stock_,AssetBank::append_courses(boot,out->additions_),out->characters_,vehicles::freeze_packages(std::move(cars))); + vehicles::bind_resources(out,resources); + } auto prepared=out->new_session(); if(out->code_) { std::map defaults; @@ -189,15 +196,25 @@ std::shared_ptr RuntimeResources::prepare(std::shared_pt if(header.size()<0x50)throw Error("A custom course is missing its default vehicle header."); defaults.emplace(track.root.content_id,header[0x4d]); } + code::trophy::verify_sdk2_inventory(*out->code_,*out->stock_,out->characters_.get()); code::trophy::verify_configuration(*out->code_,code::trophy::catalogue(*out->stock_,prepared->tracks(),*out->authored_,defaults)); } out->prepared_world_=prepared; std::string identity="dkr-online-runtime-2\n"+out->profile_->digest+"\n"+prepared->boot_bank()->fingerprint()+music_identity; if(out->code_)identity+="\ncode:"+out->code_->identity; + if(const auto cars=vehicles::resources_for(out.get()))identity+="\ncars:"+cars->identity; for(const auto& track:out->tracks_)identity+="\n"+track.root.content_id+":"+track.bank->fingerprint(); out->fingerprint_=sha256(View(reinterpret_cast(identity.data()),identity.size())); cancelled(stop); - std::size_t cache_bytes=prepared->prepared_bytes()+128*MiB-music_budget; + std::size_t cache_bytes=prepared->prepared_bytes()+music_encoded_bytes; + if(const auto cars=vehicles::resources_for(out.get())) { + for(const auto& car:cars->library.packages) { + cache_bytes+=car->source.size(); + for(const auto& lod:car->lods)for(const auto& mesh:lod.meshes) + cache_bytes+=mesh.vertices.size()*sizeof(vehicles::Vec3)+mesh.uv.size()*sizeof(vehicles::Vec2)+mesh.faces.size()*sizeof(std::array); + for(const auto& t:car->textures)cache_bytes+=t.image.rgba.size(); + } + } if(out->code_)for(const auto& package:out->code_->ordered) { cache_bytes+=out->code_->payloads.at(package->digest)->size()+package->binary.size()*2; for(const auto& [id,value]:package->resources)cache_bytes+=value.size(); @@ -225,17 +242,21 @@ std::shared_ptr RuntimeResources::augment(std::shared_ptr RuntimeResources::new_session()const { - if(prepared_world_)return prepared_world_->instantiate(); + if(prepared_world_) { + auto copy=prepared_world_->instantiate();vehicles::inherit_resources(prepared_world_.get(),copy);return copy; + } + const auto cars=vehicles::resources_for(this); // Capture immutable additions/counts by value; the session may outlive the // caller without retaining a dangling RuntimeResources pointer. const auto additions=std::make_shared(additions_); const auto counts=base_counts_; auto result=std::make_shared(stock_,characters_,std::vector{}, - [additions,counts](std::shared_ptr bank) { + [additions,counts,cars](std::shared_ptr bank) { for(unsigned i=0;irecord_count(Data[i])!=counts[i]) throw Error("Online course changed the frozen Track Lab namespace."); - return AssetBank::append_courses(std::move(bank),*additions); + auto next=AssetBank::append_courses(std::move(bank),*additions);return cars?cars->apply(std::move(next)):next; }); + vehicles::bind_resources(result,cars); for(const auto& track:tracks_)result->admit(track); return result; } diff --git a/runtime-recomp/src/game/mods/online_mod_sync.cpp b/runtime-recomp/src/game/mods/online_mod_sync.cpp index ea1a5dee..d412db99 100644 --- a/runtime-recomp/src/game/mods/online_mod_sync.cpp +++ b/runtime-recomp/src/game/mods/online_mod_sync.cpp @@ -1,3 +1,5 @@ +#include "../diagnostics.hpp" +#include "../cache_repair.hpp" #include "online_mod_sync.hpp" #include "online_mod_cache.hpp" #include "online_mod_runtime.hpp" @@ -19,7 +21,7 @@ void folders(const SyncSettings& settings) { bool data_message(Operation op){return op==Operation::ManifestChunk || op==Operation::PayloadChunk;} Message control(Operation op,const Download& download){return {op,download.digest()};} } -ModSync::~ModSync(){cancel();if(thread_.joinable())thread_.join();} +ModSync::~ModSync(){cancel();if(thread_.joinable())thread_.join();dkr::lobby_stability::erase(this);} SyncView ModSync::snapshot()const{std::lock_guard lock(mutex_);return state_;} void ModSync::publish(SyncView view){ std::lock_guard lock(mutex_); @@ -35,14 +37,16 @@ void ModSync::publish(SyncView view){ std::fprintf(stderr,"[online-mods][worker-failure] stage=%s error=%s\n",clean(view.stage).c_str(),clean(view.error).c_str()); std::fflush(stderr); } + dkr::lobby_stability::stage(this,int(view.phase),view.stage,view.error); state_=std::move(view);changed_.notify_all(); } -void ModSync::progress(std::string_view stage){std::lock_guard lock(mutex_);state_.stage=std::string(stage);} +void ModSync::progress(std::string_view stage){std::lock_guard lock(mutex_);state_.stage=std::string(stage);dkr::lobby_stability::stage(this,int(state_.phase),stage);} bool ModSync::start(SyncPhase phase) { // The frontend starts a new job only after cancellation has finished. Do // not join a running child process from an ImGui action. if(running_.load())return false; if(thread_.joinable())thread_.join(); + dkr::lobby_stability::begin(this,phase==SyncPhase::HostPreparing?"host":"client"); {std::lock_guard lock(mutex_);state_={};state_.phase=phase; if(phase==SyncPhase::ReadingOffer)state_.stage="Waiting for host approval and any required mod offer"; decision_.reset();keep_offline_=true;}running_.store(true);return true; @@ -76,7 +80,11 @@ bool ModSync::prepare_host(SyncSettings settings,std::filesystem::path legacy,st ready.offer=identity(bundle->manifest); ready.total=transfer_size(bundle->manifest); if(has_mods(bundle->manifest)) { - cache_bundle(*bundle,settings.online_root/"payloads",stop); + try {cache_bundle(*bundle,settings.online_root/"payloads",stop);} + catch(const Error&) { + for(const auto& payload:bundle->manifest.payloads)dkr::lobby_stability::verify_or_repair_download(settings.online_root,"payloads",payload,stop); + cache_bundle(*bundle,settings.online_root/"payloads",stop); + } ready.profile=prepare_profile(bundle->manifest,settings.online_root/"payloads",settings.imported_roms, settings.online_root,settings.worker,stop,[&](auto stage){progress(stage);},legacy,settings.offline_tracks_root,labs); progress("Validating the host's playable mod resources");ready.runtime=prepare(ready.profile,stop); @@ -91,8 +99,8 @@ bool ModSync::prepare_host(SyncSettings settings,std::filesystem::path legacy,st host_loop(std::move(bundle),stop); }catch(const std::exception& error){if(!stop.stop_requested())publish({SyncPhase::Failed,{},error.what()});} catch(...){if(!stop.stop_requested())publish({SyncPhase::Failed,{},"Online mod preparation stopped safely."});} - if(stop.stop_requested())publish({});running_.store(false); - });}catch(const std::exception& error){running_.store(false);publish({SyncPhase::Failed,{},error.what()});return false;} + if(stop.stop_requested())publish({});running_.store(false);dkr::lobby_stability::retire(this); + });}catch(const std::exception& error){running_.store(false);dkr::lobby_stability::retire(this);publish({SyncPhase::Failed,{},error.what()});return false;} return true; } bool ModSync::watch_client(SyncSettings settings,RuntimePreparation prepare) { @@ -110,14 +118,14 @@ bool ModSync::watch_client(SyncSettings settings,RuntimePreparation prepare) { if(!link_.view().failed)link_.leave("Online mod synchronization stopped safely."); auto failure=snapshot();failure.phase=SyncPhase::Failed;failure.error="Online mod synchronization stopped safely.";publish(std::move(failure)); }} - if(stop.stop_requested())publish({});running_.store(false); - });}catch(const std::exception& error){running_.store(false);publish({SyncPhase::Failed,{},error.what()});return false;} + if(stop.stop_requested())publish({});running_.store(false);dkr::lobby_stability::retire(this); + });}catch(const std::exception& error){running_.store(false);dkr::lobby_stability::retire(this);publish({SyncPhase::Failed,{},error.what()});return false;} return true; } void ModSync::host_loop(std::shared_ptr bundle,std::stop_token stop) { - struct Peer {std::uint64_t request=0;Upload upload;PeerProgress progress;std::optional acknowledgement;Clock::time_point next{}; + struct Peer {std::uint64_t request=0;Upload upload;PeerProgress progress;std::optional acknowledgement;Clock::time_point next{},status_at{};std::uint64_t ticket=0; Peer(std::uint64_t id,std::shared_ptr b):request(id),upload(b),progress{id,0,transfer_size(b->manifest),ProgressPhase::Review,"Waiting for download consent"}{};}; - std::map peers;bool hosted=false; + std::map peers;bool hosted=false;std::uint64_t next_ticket=0; const auto digest=identity(bundle->manifest); while(!stop.stop_requested()) { const auto network=link_.view(); @@ -132,7 +140,8 @@ void ModSync::host_loop(std::shared_ptr bundle,std::stop_token sto if(!packet.request || !network.host || std::find(network.preparing_requests.begin(),network.preparing_requests.end(),packet.request)==network.preparing_requests.end())continue; try { auto [at,inserted]=peers.try_emplace(packet.sender,packet.request,bundle); - if(at->second.request!=packet.request || peers.size()>3)throw Error("Unexpected provisional mod route."); + if(at->second.request!=packet.request || peers.size()>dkr::lobby_stability::MaxPending)throw Error("The bounded mod preparation queue is full. Please retry shortly."); + if(inserted)std::fprintf(stderr,"[lobby-join][host] request=%llu manifest=%s event=route-created\n",static_cast(packet.request),digest.c_str()); const auto message=decode_message(packet.bytes); if(message.operation==Operation::Progress) { auto& peer=at->second; @@ -148,6 +157,7 @@ void ModSync::host_loop(std::shared_ptr bundle,std::stop_token sto continue; } at->second.upload.request(message,link_.chunk_budget()); + if(message.operation==Operation::Consent&&!at->second.ticket)at->second.ticket=++next_ticket; if(message.operation==Operation::Consent)at->second.acknowledgement=Message{Operation::ConsentAck,message.manifest}; if(message.operation==Operation::Preparing)at->second.acknowledgement=Message{Operation::PreparingAck,message.manifest}; if(message.operation==Operation::Cancel){link_.reject(packet.request,"Mod synchronization declined.");peers.erase(at);} @@ -155,18 +165,33 @@ void ModSync::host_loop(std::shared_ptr bundle,std::stop_token sto std::string error;if(!link_.admit(packet.request,message.manifest,message.payload,error))throw Error(error); peers.erase(at); } - }catch(const std::exception& error){link_.reject(packet.request,error.what());peers.erase(packet.sender);} + }catch(const std::exception& error){ + std::fprintf(stderr,"[lobby-join][host] request=%llu manifest=%s event=rejected error=%s\n",static_cast(packet.request),digest.c_str(),dkr::lobby_stability::printable(error.what()).c_str()); + link_.reject(packet.request,error.what());peers.erase(packet.sender); + } } // Small, fair bursts; the transport's existing bulk backpressure still // wins. Bulk admission never runs while a game is loading or playing. + std::vector candidates; + for(const auto& [id,peer]:peers)if(peer.upload.consented()&&!peer.upload.preparing())candidates.push_back({id,peer.ticket}); + const auto slots=dkr::lobby_stability::download_slots(std::move(candidates)); for(auto& [id,peer]:peers)if(Clock::now()>=peer.next) { if(peer.acknowledgement) { std::string error;if(send(id,*peer.acknowledgement,error))peer.acknowledgement.reset(); else if(!error.empty())link_.reject(peer.request,error); continue; } + const bool queued=peer.upload.consented()&&!peer.upload.preparing()&&std::find(slots.begin(),slots.end(),id)==slots.end(); + if(peer.upload.consented()&&!peer.upload.preparing()&&Clock::now()>=peer.status_at) { + const std::string label=queued?dkr::lobby_stability::Queued:dkr::lobby_stability::SlotReady; + Message status{Operation::Progress,digest,{},static_cast(peer.progress.received),static_cast(peer.progress.total),{static_cast(ProgressPhase::Downloading)}}; + status.bytes.insert(status.bytes.end(),label.begin(),label.end());std::string ignored; + if(send(id,status,ignored))peer.status_at=Clock::now()+std::chrono::milliseconds(500); + if(queued)peer.progress.stage=label; + } for(unsigned burst=0;burstoperation==Operation::PayloadChunk)break; std::string error; if(send(id,*message,error)){peer.upload.sent();peer.next=Clock::now()+std::chrono::milliseconds(2);} else { @@ -180,6 +205,7 @@ void ModSync::host_loop(std::shared_ptr bundle,std::stop_token sto } } void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& prepare,std::stop_token stop) { + dkr::lobby_stability::GapBudget gaps;dkr::lobby_stability::PhaseBudget deadlines; Download download;ChunkInbox inbox;std::unique_ptr file;std::shared_ptr profile; std::shared_ptr runtime;Clock::time_point retry{},consent_retry{},preparing_retry{}; std::string requested_payload;std::uint32_t requested_offset=0; @@ -219,6 +245,7 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& ? "The host connection ended during mod admission.":network.failure_reason); if(network.offer) { download.offer(network.offer->digest,network.offer->bytes);saw_offer=true; + dkr::lobby_stability::manifest(this,download.digest()); } if(network.lobby) { if(saw_offer && (!prepared || download.phase()!=TransferPhase::Verified))throw Error("Lobby admission preceded complete local mod preparation."); @@ -227,13 +254,35 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& SyncView ready;ready.phase=SyncPhase::Verified;ready.profile=profile;ready.runtime=runtime; ready.kept_offline=kept_offline;ready.keep_error=keep_error; if(saw_offer){ready.manifest=std::make_shared(download.manifest());ready.offer=download.digest();ready.received=ready.total=transfer_size(download.manifest());} - ready.stage="Host mods verified for this session";publish(std::move(ready));return; + ready.stage="Host mods verified for this session";publish(ready); + if(profile&&keep_requested&&!settings.offline_legacy_root.empty()&&!settings.offline_tracks_root.empty()) { + const auto began=Clock::now(); + try {retain_profile(*profile,settings.offline_legacy_root,settings.offline_tracks_root,stop,settings.offline_code_root,settings.host_version);ready.kept_offline=true;} + catch(const std::exception& error){if(stop.stop_requested())return;ready.keep_error=std::string(error.what()).substr(0,1024);} + catch(...){if(stop.stop_requested())return;ready.keep_error="Offline copies could not be saved. Your verified session mods are still available.";} + if(!stop.stop_requested())publish(std::move(ready)); + std::fprintf(stderr,"[online-mods][offline-keep] after-admission=1 elapsed-ms=%lld\n",static_cast(std::chrono::duration_cast(Clock::now()-began).count())); + } + return; } if(saw_offer && !network.joining)throw Error("The host connection ended during mod admission."); + const auto phase=download.phase(); + const auto limit=phase==TransferPhase::Consent?600:phase==TransferPhase::Payloads?1200:phase==TransferPhase::Preparing?900:phase==TransferPhase::Verified?45:120; + if(deadlines.expired(int(phase),std::chrono::seconds(limit),Clock::now()))throw Error("This mod admission phase exceeded its time budget. Retry to reuse verified files."); + dkr::lobby_stability::counters(this,download.received_bytes(),saw_offer&&phase!=TransferPhase::Manifest?transfer_size(download.manifest()):0,wire_bytes,cached_bytes,retries); SyncPacket packet;unsigned drained=0; while(drained++<32 && link_.receive(packet)) { const auto message=decode_message(packet.bytes); if(message.manifest!=download.digest())throw Error("Mod response belongs to a different host selection."); + if(message.operation==Operation::Progress) { + if(download.phase()!=TransferPhase::Payloads||message.total!=transfer_size(download.manifest())||message.bytes.empty()||message.bytes[0]!=static_cast(ProgressPhase::Downloading))continue; + const std::string label(message.bytes.begin()+1,message.bytes.end()); + if(label==dkr::lobby_stability::Queued||label==dkr::lobby_stability::SlotReady) { + gaps.queue(label==dkr::lobby_stability::Queued,Clock::now()); + progress(label==dkr::lobby_stability::Queued?label:"Downloading required host mods"); + } + continue; // A status never advances bytes, consent or readiness. + } if(message.operation==Operation::ConsentAck) { if(download.phase()!=TransferPhase::Payloads && download.phase()!=TransferPhase::Preparing && download.phase()!=TransferPhase::Verified) throw Error("Host acknowledged mods before client consent."); @@ -250,7 +299,7 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& const auto chunk=inbox.take(download.request(link_.chunk_budget()));if(!chunk)break; if(download.phase()==TransferPhase::Manifest) { const bool advanced=download.manifest_chunk(*chunk); - if(advanced)retry=Clock::now()+std::chrono::seconds(1); + if(advanced){retry=Clock::now()+std::chrono::seconds(1);gaps.progress(Clock::now());} if(advanced && download.phase()==TransferPhase::Consent) { SyncView review;review.phase=SyncPhase::Review;review.manifest=std::make_shared(download.manifest()); review.offer=download.digest(); @@ -258,7 +307,7 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& } } else if(download.accept_payload_chunk(*chunk)) { if(!file)throw Error("Online mod chunk preceded an authorized private download."); - file->append(chunk->bytes);download.persisted(chunk->bytes.size()); + file->append(chunk->bytes);download.persisted(chunk->bytes.size());gaps.progress(Clock::now()); retry=Clock::now()+std::chrono::seconds(1); {std::lock_guard lock(mutex_);state_.received=download.received_bytes();} if(file->size()==chunk->total){file->publish(settings.online_root/"payloads"/chunk->payload);file.reset();download.payload_verified();inbox.clear();retry={};data_requested=false;} @@ -296,7 +345,9 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& const auto& items=download.manifest().payloads; const auto found=std::find_if(items.begin(),items.end(),[&](const auto& p){return p.digest==request.payload;}); if(found==items.end())throw Error("Required payload disappeared from the frozen host manifest."); - if(cached_payload(settings.online_root/"payloads",*found)){cached_bytes+=found->size;download.cached(found->digest,found->size);continue;} + dkr::lobby_stability::package(this,found->digest,"required host payload"); + if(dkr::lobby_stability::verify_or_repair_download(settings.online_root,"payloads",*found,stop)){cached_bytes+=found->size;download.cached(found->digest,found->size);continue;} + dkr::lobby_stability::verify_or_repair_download(settings.online_root,"partials",*found,stop); file=std::make_unique(settings.online_root/"partials"/found->digest,*found); if(file->size()==found->size){file->publish(settings.online_root/"payloads"/found->digest);file.reset();download.cached(found->digest,found->size);continue;} if(file->size())download.resume_prefix(found->digest,file->size());retry={}; @@ -304,13 +355,15 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& if(download.phase()==TransferPhase::Manifest || download.phase()==TransferPhase::Payloads) { auto request=download.request(link_.chunk_budget()); const bool first=!data_requested || request.payload!=requested_payload; - const bool gap=!first && Clock::now()>=retry; + const bool gap=!first && gaps.due(Clock::now()); + if(gap&&gaps.exhausted())throw Error("No mod bytes arrived after five repair attempts. Retry to resume verified downloads."); const bool refill=!first && request.offset>=requested_offset && request.offset-requested_offset>=transfer_window_bytes(link_.chunk_budget())/2; if(first || gap || refill) { if(refill && !gap)request.operation=request.payload.empty()?Operation::ManifestCredit:Operation::PayloadCredit; if(send(0,request,error)){ - if(gap)++retries;else if(!first)++credits; + if(first)gaps.progress(Clock::now()); + if(gap){++retries;gaps.repaired(Clock::now());}else if(!first)++credits; retry=Clock::now()+std::chrono::seconds(1);data_requested=true;requested_payload=request.payload;requested_offset=request.offset; } else if(!error.empty())throw Error(error); @@ -337,12 +390,6 @@ void ModSync::client_loop(const SyncSettings& settings,const RuntimePreparation& preparation_progress("Validating playable mod resources for this online backend");runtime=prepare(profile,stop); if(!runtime)throw Error("The host's selected mods do not have a validated online runtime adapter."); const auto runtime_ready=Clock::now(); - if(keep_requested && !settings.offline_legacy_root.empty() && !settings.offline_tracks_root.empty()) { - preparation_progress("Keeping verified copies in Mods/Hacks for offline play"); - try {retain_profile(*profile,settings.offline_legacy_root,settings.offline_tracks_root,stop,settings.offline_code_root,settings.host_version);kept_offline=true;} - catch(const std::exception& error){require_running(stop);keep_error=std::string(error.what()).substr(0,1024);} - catch(...){require_running(stop);keep_error="Offline copies could not be saved. Your verified session mods are still available.";} - } std::fprintf(stderr,"[online-mods][client-ready] roots=%zu profile-ms=%lld runtime-ms=%lld keep-ms=%lld total-ms=%lld\n", download.manifest().content.size(), static_cast(std::chrono::duration_cast(profile_ready-preparation_began).count()), diff --git a/runtime-recomp/src/game/mods/sdk2_asset_inventory.inl b/runtime-recomp/src/game/mods/sdk2_asset_inventory.inl new file mode 100644 index 00000000..aebcfb3b --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_asset_inventory.inl @@ -0,0 +1,16 @@ +// Immutable metadata only, prepared once outside VM callbacks. No source paths, +// ROM bytes, textures or mutable launcher selection are exported to a code mod. +static std::string sdk2_asset_inventory(const std::shared_ptr& session) { + if(!session)return {};using Json=mods::code::Json;const auto& bank=*session->boot_bank();Json output={{"schema",2},{"banks",Json::object()},{"characters",Json::array()}}; + for(const auto [name,section]:std::array,6>{{{"models",29},{"textures-3d",2},{"textures-2d",4},{"sprites",12},{"object-templates",34},{"level-models",27}}}) + output["banks"][name]={{"section",section},{"count",bank.record_count(section)},{"bank",bank.fingerprint()},{"scope","admitted-boot-namespace"}}; + if(const auto names=session->characters())for(const auto& item:names->characters) { + Json character={{"id",item.id},{"name",item.name},{"donor",item.base_character},{"portrait",item.portrait},{"headers",item.headers},{"stage_header",item.stage_header},{"models",Json::array()}}; + for(auto header_id:item.headers) { + const auto header=bank.record(34,header_id);if(header.size()<0x78)throw mods::Error("SDK inventory found a truncated admitted character header."); + const auto first=mods::be32(header,0x10);const unsigned count=header[0x55];if(count>127||first>header.size()||count>(header.size()-first)/4)throw mods::Error("SDK inventory found an invalid character model list."); + Json models=Json::array();for(unsigned i=0;i2*mods::MiB)throw mods::Error("SDK asset catalogue exceeds its metadata limit.");return bytes; +} diff --git a/runtime-recomp/src/game/mods/sdk2_bridge.cpp b/runtime-recomp/src/game/mods/sdk2_bridge.cpp new file mode 100644 index 00000000..96628692 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_bridge.cpp @@ -0,0 +1,136 @@ +#include "sdk2_bridge.hpp" +#include +#include +#include +#include +namespace dkr::sdk2 { +namespace { +std::mutex mutex; +Snapshot current; +std::shared_ptr cached; +std::deque pending; +std::uint32_t serial=0; +Reload pending_reload; +struct ToolMessage {unsigned id=0,kind=0,target=0;Command command;}; +struct ToolPeer {unsigned sent=0,received=0;std::deque pending;}; +std::array tool_peers; +std::uint64_t tool_epoch=0;unsigned observed_serial=0; +std::array cameras; +std::map> debug_owners; +std::array,4> debug_views; +bool send_tool(unsigned peer,unsigned kind,unsigned target,Command command={}) { + auto& route=tool_peers[peer];if(route.pending.size()>=32||route.sent==UINT32_MAX)return false; + route.pending.push_back({++route.sent,kind,target,command});return true; +} +bool enqueue(const Panel& panel,Command command) { + if(pending.size()>=512||serial==UINT32_MAX)return false; + command.words[1]=++serial;pending.push_back(command);return true; +} +} +void begin(const std::vector>& packages,bool online) { + std::lock_guard guard(mutex);cached.reset();++current.generation;current.active=true;current.online=online; + current.host=!online;current.development=!online;current.panels.clear();current.history.clear();current.notice.clear();pending.clear(); + current.local_session=std::uint64_t(std::chrono::steady_clock::now().time_since_epoch().count()); + current.requests.clear();current.local=0;current.players=online?0:1;current.grants=0;tool_peers={};tool_epoch=0;observed_serial=0; + cameras={};debug_owners.clear();debug_views={};pending_reload={};hud_hidden=false; + for(unsigned i=0;i snapshot(){std::lock_guard guard(mutex);if(!cached)cached=std::make_shared(current);return cached;} +bool available(){std::lock_guard guard(mutex);return current.active&&!current.panels.empty();} +bool submit(unsigned owner,unsigned action,const std::array& arguments,std::string_view digest,std::uint64_t generation) { + std::lock_guard guard(mutex); + cached.reset(); + const auto panel=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==owner;}); + if(generation!=current.generation||panel==current.panels.end()||panel->digest!=digest||!panel->scene) { + current.notice="The tool target changed. Wait for the new scene snapshot and select it again.";return false; + } + if(!current.active||!current.development||panel==current.panels.end()||!panel->development||!action||pending.size()>=512) { + current.notice="This action needs an active development session and host authority.";return false; + } + if(serial==UINT32_MAX){current.notice="Command IDs exhausted. Restart the app.";return false;} + Command command;command.words[0]=owner+1;command.words[1]=++serial;command.words[2]=action; + std::copy_n(arguments.begin(),12,command.words.begin()+3);command.words[15]=panel->scene; + if(current.online&&!current.host) { + if(!tool_epoch||!send_tool(0,1,command.words[1],command)){current.notice="The request queue is busy. Try again after the host replies.";return false;} + current.notice="Requested from the host. Nothing was changed locally.";return true; + } + pending.push_back(command); + current.history.push_back(panel->name+": action "+std::to_string(action)+" queued (#"+std::to_string(serial)+")"); + if(current.history.size()>128)current.history.pop_front();current.notice="Command queued.";return true; +} +#include "sdk2_broker.inl" +#include "sdk2_camera.inl" +#include "sdk2_debug.inl" +Command take(bool online) { + std::lock_guard guard(mutex);if(!current.active||current.online!=online||!current.host||!current.development||pending.empty())return {}; + auto command=pending.front();pending.pop_front();return command; +} +void publish(unsigned owner,Json values,std::uint32_t scene,std::uint32_t tick) { + std::lock_guard guard(mutex); + const auto found=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==owner;}); + if(found==current.panels.end())return; + cached.reset(); + // Validate the presentation contract before publishing. A malformed mod + // value must return a VM status, not throw through a live ImGui stack. + validate_values(found->schema,values); + found->values=std::move(values);found->scene=scene;found->tick=tick; +} +void validate_values(const Json& schema,const Json& values) { + for(const auto& page:schema.at("pages"))for(const auto& field:page.at("fields")) { + if(field.contains("visible")) { + const auto flag=values.find(field.at("visible").get()); + if(flag!=values.end()&&!flag->is_boolean())throw mods::Error("SDK tools visibility value must be boolean."); + } + const auto value=values.find(field.at("id").get());if(value==values.end())continue; + const auto type=field.at("type").get(); + if(type=="table") { + if(!value->is_array()||value->size()>128)throw mods::Error("SDK tools table exceeds its row/type contract."); + for(const auto& row:*value)if(row.is_object()) { + for(const auto* caption:{"name","detail"})if(row.contains(caption)&&(!row[caption].is_string()||row[caption].get_ref().size()>2048))throw mods::Error("SDK table caption is invalid."); + for(const auto* handle:{"slot","generation"})if(row.contains(handle)&&(!row[handle].is_number_unsigned()||row[handle].get()>UINT32_MAX))throw mods::Error("SDK table handle is invalid."); + } + }else if(type!="text"&&type!="search"&&type!="button"&&type!="inspector"&&type!="diagnostics"&&type!="authority"&&type!="document"&&type!="console"&&type!="camera"&&type!="debug"&&type!="reload") { + if(!value->is_number()||!std::isfinite(value->get())||std::abs(value->get())>1e7)throw mods::Error("SDK tools numeric value is invalid."); + } + } +} +bool request_reload(Reload request,std::uint64_t generation) { + std::lock_guard guard(mutex);if(current.online||!current.development||generation!=current.generation||pending_reload.package||!request.package)return false; + const auto p=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& item){return item.owner==request.owner&&item.digest==request.digest;}); + if(p==current.panels.end()||!p->development)return false;pending_reload=std::move(request);return true; +} +Reload take_reload(){std::lock_guard guard(mutex);return std::exchange(pending_reload,Reload{});} +void notice(std::string value){std::lock_guard guard(mutex);cached.reset();current.notice=std::move(value);} +void inspector(unsigned owner,std::shared_ptr> entities) { + std::lock_guard guard(mutex);const auto found=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==owner;}); + if(found!=current.panels.end()){found->inspector=std::move(entities);cached.reset();} +} +void diagnostics(unsigned owner,Json values) { + std::lock_guard guard(mutex);const auto found=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==owner;}); + if(found!=current.panels.end()){found->diagnostics=std::move(values);cached.reset();} +} +void document(unsigned owner,std::string id,Json value) { + std::lock_guard guard(mutex);const auto found=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==owner;}); + if(found!=current.panels.end()){found->documents[std::move(id)]=std::move(value);cached.reset();} +} +bool upload(unsigned owner,unsigned document_index,const Json& value,std::string_view digest,std::uint64_t generation) { + const auto bytes=value.dump();if(bytes.empty()||bytes.size()>16384)return false; + std::lock_guard guard(mutex);const auto found=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==owner;}); + const unsigned count=unsigned((bytes.size()+39)/40)+2; + if(!current.active||!current.host||!current.development||generation!=current.generation||found==current.panels.end()||found->digest!=digest||!found->scene||pending.size()+count>512||serial>UINT32_MAX-count)return false; + std::uint32_t hash=2166136261U;for(unsigned char c:bytes){hash^=c;hash*=16777619U;} + const auto command=[&](unsigned action){Command c;c.words[0]=owner+1;c.words[1]=++serial;c.words[2]=action;c.words[15]=found->scene;return c;}; + auto begin=command(65534);begin.words[3]=unsigned(bytes.size());begin.words[4]=hash;begin.words[5]=document_index;pending.push_back(begin); + for(unsigned offset=0;offset(bytes[offset+i]))<<(24-(i%4)*8); + pending.push_back(chunk); + } + auto commit=command(65532);pending.push_back(commit);cached.reset();current.notice="Document queued for the ordered simulation boundary. It is not applied from the file picker.";return true; +} +} diff --git a/runtime-recomp/src/game/mods/sdk2_bridge.hpp b/runtime-recomp/src/game/mods/sdk2_bridge.hpp new file mode 100644 index 00000000..aad18f9b --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_bridge.hpp @@ -0,0 +1,79 @@ +#pragma once +#include "magic_code_package.hpp" +#include "sdk2_command.hpp" +#include +#include +#include +#include +#ifndef DKR_PRESENTATION_HOST +#define DKR_PRESENTATION_HOST +#endif +#include "../../../include/dkr/presentation.h" +namespace dkr::sdk2 { +using Json=mods::code::Json; +struct PropertyView {unsigned id=0,type=0,flags=0,bits=0;std::string name,unit;}; +struct EntityView {unsigned slot=0,generation=0,behaviour=0,object_id=0;bool racer=false;std::vector properties;unsigned address=0,header=0;}; +struct Panel { + unsigned owner=0; + std::string id,name,digest; + Json schema,values=Json::object(); + bool development=false; + std::uint32_t scene=0,tick=0; + std::shared_ptr> inspector; + Json diagnostics=Json::object(); + std::map documents; +}; +struct Snapshot { + std::uint64_t generation=0; + std::uint64_t local_session=0; // UI preset lifetime only, never a gameplay clock + bool active=false,online=false,host=false,development=false; + std::vector panels; + std::deque history; + std::string notice; + struct Request {unsigned peer=0,id=0;Command command;std::string package;}; + std::deque requests; + unsigned local=0,players=1,grants=0; +}; +// A local copied-data mailbox. It never stores a GameWorld or RDRAM pointer. +// Only the authored input owner consumes commands; UI is merely a producer. +void begin(const std::vector>&,bool online); +void end(); +void authority(bool host,bool development); +std::shared_ptr snapshot(); +bool available(); +bool submit(unsigned owner,unsigned action,const std::array& arguments,std::string_view digest,std::uint64_t generation); +Command take(bool online); +void publish(unsigned owner,Json,std::uint32_t scene,std::uint32_t tick); +void inspector(unsigned owner,std::shared_ptr>); +void diagnostics(unsigned owner,Json); +void network_epoch(std::uint64_t,unsigned local,unsigned players); +std::vector network_packet(unsigned peer); +bool network_receive(unsigned peer,std::span); +bool decide(unsigned peer,unsigned request,bool approve); +bool grant(unsigned peer,bool allow); +void observed(const Command&); +void document(unsigned owner,std::string id,Json); +void validate_values(const Json& schema,const Json& values); +struct Reload {unsigned owner=0;std::string digest;std::shared_ptr package;}; +bool request_reload(Reload,std::uint64_t generation); +Reload take_reload(); +bool upload(unsigned owner,unsigned document_index,const Json&,std::string_view digest,std::uint64_t generation); +struct LocalCamera { + bool enabled=false,follow=false,valid=false,cut=true; + unsigned owner=0,slot=0,generation=0,scene=0; + float position[3]{},yaw=0,pitch=0,fov=1.04719755F,speed=200,smoothing=8; +}; +LocalCamera camera_state(unsigned view); +bool camera_edit(unsigned view,const LocalCamera&); +std::optional camera_frame(const DkrViewSnapshot&,const DkrViewInput&,std::span); +struct DebugPrimitive {unsigned kind=0,rgba=0,view=0;float a[3]{},b[3]{};std::string label;}; +struct DebugLine {float x1=0,y1=0,x2=0,y2=0;unsigned rgba=0;std::string label;}; +void debug(unsigned owner,std::vector); +void debug_frame(const DkrViewSnapshot&,const std::optional&,unsigned view_count); +std::shared_ptr> debug_lines(); +bool debug_available(); +inline std::atomic debug_enabled{false}; +inline std::atomic hud_hidden{false}; // local presentation only +void notice(std::string); +inline std::atomic tools_open{false}; +} diff --git a/runtime-recomp/src/game/mods/sdk2_broker.inl b/runtime-recomp/src/game/mods/sdk2_broker.inl new file mode 100644 index 00000000..10663a55 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_broker.inl @@ -0,0 +1,73 @@ +// Included inside dkr::sdk2. Authenticated transport calls this on its owner; +// only copied UI requests enter here, never a game pointer or a local write. +void network_epoch(std::uint64_t epoch,unsigned local,unsigned players) { + std::lock_guard guard(mutex);if(!current.active||!current.online||!current.development||local>=players||players>4)return; + current.local=local;current.players=players; + if(tool_epoch!=epoch){tool_epoch=epoch;tool_peers={};current.requests.clear();current.grants=0;cached.reset();} +} +std::vector network_packet(unsigned peer) { + std::lock_guard guard(mutex); + if(!tool_epoch||!current.development||peer>=current.players||peer==current.local)return {}; + const auto& route=tool_peers[peer];const ToolMessage message=route.pending.empty()?ToolMessage{}:route.pending.front(); + std::vector out;out.reserve(96); + const auto word=[&](std::uint32_t v){for(unsigned b=0;b<4;++b)out.push_back(std::uint8_t(v>>(24-b*8)));}; + word(0x444b5432);word(1);word(unsigned(tool_epoch>>32));word(unsigned(tool_epoch));word(message.id);word(route.received);word(message.kind);word(message.target); + for(auto value:message.command.words)word(value);return out; +} +bool network_receive(unsigned peer,std::span bytes) { + std::lock_guard guard(mutex); + if(bytes.size()!=96||!tool_epoch||!current.active||!current.development||peer>=current.players||peer==current.local)return false; + const auto word=[&](unsigned offset){std::uint32_t value=0;for(unsigned b=0;b<4;++b)value=(value<<8)|bytes[offset+b];return value;}; + const auto epoch=(std::uint64_t(word(8))<<32)|word(12); + if(word(0)!=0x444b5432||word(4)!=1||epoch!=tool_epoch)return false; + auto& route=tool_peers[peer];const unsigned id=word(16),ack=word(20),kind=word(24),target=word(28); + if(ack>route.sent)return false; + while(!route.pending.empty()&&route.pending.front().id<=ack)route.pending.pop_front(); + if(!id)return kind==0; + if(id<=route.received)return true; + if(id!=route.received+1)return false; + Command command;for(unsigned i=0;i<16;++i)command.words[i]=word(32+i*4); + if(kind==1&¤t.host&&peer) { + if(!command.valid()||command.empty()||current.requests.size()>=32)return false; + const auto panel=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner+1==command.words[0];}); + if(panel==current.panels.end()||!panel->development||command.words[15]!=panel->scene)return false; + if((current.grants&(1U<=32||route.sent==UINT32_MAX))return false; + if((current.grants&(1U<name}); + }else if(kind==2&&!current.host&&peer==0) { + current.notice=command.words[0]?"The host approved your tool request.":"The host declined your tool request."; + }else if(kind==3&&!current.host&&peer==0) { + if(target>=current.players||command.words[0]>1)return false; + if(command.words[0])current.grants|=1U<128)current.history.pop_front();cached.reset();return true; +} +bool decide(unsigned peer,unsigned request,bool approve) { + std::lock_guard guard(mutex);if(!current.active||!current.host||!current.development||peer>=current.players)return false; + const auto found=std::find_if(current.requests.begin(),current.requests.end(),[&](const auto& r){return r.peer==peer&&r.id==request;}); + if(found==current.requests.end()||tool_peers[peer].pending.size()>=32)return false; + const auto panel=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner+1==found->command.words[0];}); + const bool valid=panel!=current.panels.end()&&panel->scene==found->command.words[15]; + if(approve&&(!valid||!enqueue(*panel,found->command))){current.notice="That request is stale or the command queue is full.";cached.reset();return false;} + if(!send_tool(peer,2,request,approve?found->command:Command{}))return false; + current.history.push_back("P"+std::to_string(peer+1)+" request #"+std::to_string(request)+(approve?" approved and queued.":" declined.")); + current.requests.erase(found);while(current.history.size()>128)current.history.pop_front();cached.reset();return true; +} +bool grant(unsigned peer,bool allow) { + std::lock_guard guard(mutex);if(!current.host||!current.development||peer==0||peer>=current.players)return false; + for(unsigned route=1;route=32)return false; + Command flag;flag.words[0]=allow?1:0; + for(unsigned route=1;route128)current.history.pop_front();cached.reset();return true; +} +void observed(const Command& command) { + if(command.empty())return;std::lock_guard guard(mutex);if(command.words[1]<=observed_serial)return; + observed_serial=command.words[1];if(command.words[2]>65532)return; + current.history.push_back("Shared action "+std::to_string(command.words[2])+" reached its ordered boundary (#"+std::to_string(command.words[1])+")."); + while(current.history.size()>128)current.history.pop_front();cached.reset(); +} diff --git a/runtime-recomp/src/game/mods/sdk2_camera.inl b/runtime-recomp/src/game/mods/sdk2_camera.inl new file mode 100644 index 00000000..c0cbf5dc --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_camera.inl @@ -0,0 +1,42 @@ +LocalCamera camera_state(unsigned view) { + std::lock_guard guard(mutex);return view<4?cameras[view]:LocalCamera{}; +} +bool camera_edit(unsigned view,const LocalCamera& value) { + std::lock_guard guard(mutex);if(!current.active||view>=4||!value.valid)return false; + if(!std::isfinite(value.yaw)||!std::isfinite(value.pitch)||!std::isfinite(value.fov)||value.fov<0.35F||value.fov>2.1F|| + !std::isfinite(value.speed)||value.speed<1||value.speed>2000||!std::isfinite(value.smoothing)||value.smoothing<0||value.smoothing>30)return false; + for(auto coordinate:value.position)if(!std::isfinite(coordinate)||std::abs(coordinate)>1e6F)return false; + cameras[view]=value;cameras[view].cut=true;return true; +} +std::optional camera_frame(const DkrViewSnapshot& state,const DkrViewInput& input,std::span retained) { + std::lock_guard guard(mutex);if(state.view>=4||!current.active)return {}; + auto& camera=cameras[state.view]; + if(camera.scene!=state.scene){camera={};camera.scene=state.scene;} + if(!(state.flags&DKR_VIEW_DRIVING)||(state.flags&DKR_VIEW_SCRIPTED)){camera.enabled=false;return {};} + if(!camera.valid){std::copy_n(state.original_position,3,camera.position);camera.yaw=state.original_yaw;camera.pitch=state.original_pitch;camera.fov=state.original_fov;camera.valid=true;} + if(!camera.enabled){std::copy_n(state.original_position,3,camera.position);camera.yaw=state.original_yaw;camera.pitch=state.original_pitch;camera.fov=state.original_fov;camera.cut=false;return {};} + if(camera.follow) { + const auto panel=std::find_if(current.panels.begin(),current.panels.end(),[&](const auto& p){return p.owner==camera.owner;}); + const EntityView* target=nullptr;if(panel!=current.panels.end()&&panel->inspector)for(const auto& entity:*panel->inspector) + if(entity.slot==camera.slot&&entity.generation==camera.generation)target=&entity; + const auto word=[&](unsigned address){std::uint32_t result=0;if(address<0x80000000U||address>0x807ffffcU||retained.size()<0x800000U)return result;std::memcpy(&result,retained.data()+address-0x80000000U,4);return result;}; + if(!target||word(target->address+0x40)!=target->header){camera.enabled=false;return {};} + float point[3];for(unsigned i=0;i<3;++i){const auto raw=word(target->address+12+i*4);std::memcpy(point+i,&raw,4);if(!std::isfinite(point[i])){camera.enabled=false;return {};}} + const float desired[]={point[0],point[1]+80.F,point[2]+200.F};const float factor=camera.cut||camera.smoothing<=0?1.F:1.F-std::exp(-camera.smoothing*state.dt); + for(unsigned i=0;i<3;++i)camera.position[i]+=(desired[i]-camera.position[i])*factor; + const float x=point[0]-camera.position[0],y=point[1]-camera.position[1],z=point[2]-camera.position[2]; + camera.yaw=std::atan2(-x,-z);camera.pitch=std::atan2(y,std::sqrt(x*x+z*z)); + }else if(!input.blocked) { + camera.yaw=std::remainder(camera.yaw-input.orbit_x*state.dt*2.F,6.2831853F);camera.pitch=std::clamp(camera.pitch-input.orbit_y*state.dt*2.F,-1.5F,1.5F); + const float movement=input.zoom*camera.speed*state.dt; + camera.position[0]-=std::sin(camera.yaw)*std::cos(camera.pitch)*movement; + camera.position[1]+=std::sin(camera.pitch)*movement;camera.position[2]-=std::cos(camera.yaw)*std::cos(camera.pitch)*movement; + } + // This is a local retained-image camera, not a gameplay camera pointer. + // Keep the existing private draw closure's bounded resource envelope. + float distance=0;for(unsigned i=0;i<3;++i){const float d=camera.position[i]-state.position[i];distance+=d*d;} + if(distance>1000000.F){camera.enabled=false;current.notice="Tool camera left the supported 1000-unit envelope. Normal camera restored.";cached.reset();return {};} + DkrViewPose pose{};pose.api=1;pose.size=sizeof(pose);pose.view=state.view;pose.mode=DKR_VIEW_CHASE;pose.racer_id=state.racer_id;pose.racer_generation=state.racer_generation; + std::copy_n(camera.position,3,pose.position);pose.yaw=camera.yaw;pose.pitch=camera.pitch;pose.vertical_fov=camera.fov;pose.near_plane=state.original_near;pose.far_plane=state.original_far; + pose.cut=camera.cut||(state.flags&DKR_VIEW_CUT);camera.cut=false;return pose; +} diff --git a/runtime-recomp/src/game/mods/sdk2_collision.inl b/runtime-recomp/src/game/mods/sdk2_collision.inl new file mode 100644 index 00000000..ede8ff91 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_collision.inl @@ -0,0 +1,123 @@ +namespace sdk2 { +struct Vec {float x=0,y=0,z=0; + Vec operator+(Vec b)const{return {x+b.x,y+b.y,z+b.z};} + Vec operator-(Vec b)const{return {x-b.x,y-b.y,z-b.z};} + Vec operator*(float k)const{return {x*k,y*k,z*k};} +}; +float dot(Vec a,Vec b){return a.x*b.x+a.y*b.y+a.z*b.z;} +Vec cross(Vec a,Vec b){return {a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};} +Vec normalized(Vec a){const auto length=std::sqrt(dot(a,a));return length>1e-10F?a*(1.F/length):Vec{0,1,0};} +Vec closest_triangle(Vec p,Vec a,Vec b,Vec c) { + const auto ab=b-a,ac=c-a,ap=p-a;const float d1=dot(ab,ap),d2=dot(ac,ap);if(d1<=0&&d2<=0)return a; + const auto bp=p-b;const float d3=dot(ab,bp),d4=dot(ac,bp);if(d3>=0&&d4<=d3)return b; + const float vc=d1*d4-d3*d2;if(vc<=0&&d1>=0&&d3<=0)return a+ab*(d1/(d1-d3)); + const auto cp=p-c;const float d5=dot(ab,cp),d6=dot(ac,cp);if(d6>=0&&d5<=d6)return c; + const float vb=d5*d2-d1*d6;if(vb<=0&&d2>=0&&d6<=0)return a+ac*(d2/(d2-d6)); + const float va=d3*d6-d5*d4;if(va<=0&&d4-d3>=0&&d5-d6>=0)return b+(c-b)*((d4-d3)/((d4-d3)+(d5-d6))); + const float sum=va+vb+vc;if(std::abs(sum)<1e-12F)return a; + return a+ab*(vb/sum)+ac*(vc/sum); +} +bool inside(Vec p,Vec a,Vec b,Vec c,Vec n) { + return dot(cross(b-a,p-a),n)>=-1e-4F&&dot(cross(c-b,p-b),n)>=-1e-4F&&dot(cross(a-c,p-c),n)>=-1e-4F; +} +struct CastHit {float time=2;Vec point,normal;unsigned segment=0,triangle=0,texture=255;}; +void offer(CastHit& hit,float t,Vec point,Vec normal) { + if(std::isfinite(t)&&t>=0&&t<=1&&t1e-12F&&aa>1e-12F&&discriminant>=0) { + const float t=(-bb-std::sqrt(discriminant))/aa,y=em+t*ed; + if(y>=0&&y<=ee){const auto p=a+e*(y/ee);offer(hit,t,p,origin+direction*t-p);} + } + sphere(hit,origin,direction,a,radius);sphere(hit,origin,direction,b,radius); +} +void triangle(CastHit& hit,Vec origin,Vec direction,float radius,Vec a,Vec b,Vec c) { + const auto normal=cross(b-a,c-a);const float length=std::sqrt(dot(normal,normal));if(length<1e-8F)return; + const auto n=normal*(1.F/length);const float velocity=dot(direction,n),distance=dot(origin-a,n); + if(radius>0) { + const auto initial=closest_triangle(origin,a,b,c),offset=origin-initial; + if(dot(offset,offset)<=radius*radius){offer(hit,0,initial,dot(offset,offset)>1e-12F?offset:n);return;} + } + if(std::abs(velocity)>1e-9F)for(unsigned side=0;side<(radius>0?2U:1U);++side) { + const float signed_radius=side?-radius:radius,t=(signed_radius-distance)/velocity; + const auto centre=origin+direction*t,point=centre-n*signed_radius; + if(t>=0&&t<=1&&inside(point,a,b,c,normal))offer(hit,t,point,radius>0?centre-point:(velocity>0?n*-1.F:n)); + } + if(radius>0){edge(hit,origin,direction,a,b,radius);edge(hit,origin,direction,b,c,radius);edge(hit,origin,direction,c,a,radius);} +} +bool box(Vec origin,Vec direction,const float* low,const float* high,float radius) { + float enter=0,leave=1;const float o[]={origin.x,origin.y,origin.z},d[]={direction.x,direction.y,direction.z}; + for(unsigned i=0;i<3;++i) { + if(std::abs(d[i])<1e-9F){if(o[i]high[i]+radius)return false;} + else {float a=(low[i]-radius-o[i])/d[i],b=(high[i]+radius-o[i])/d[i];if(a>b)std::swap(a,b);enter=std::max(enter,a);leave=std::min(leave,b);if(enter>leave)return false;} + }return true; +} +} +int GameWorld::sdk2_cast(Memory& bus,std::uint32_t query,std::uint32_t output,Budget& budget) { + const unsigned flags=unsigned(bus.load(query+8,4)); + if(bus.load(query,4)<40||bus.load(output,4)<48||(flags&~3U))return -1; + const auto real=[&](unsigned at){return std::bit_cast(std::uint32_t(bus.load(at,4)));}; + const auto segment=std::uint32_t(bus.load(query+4,4));const float radius=real(query+12); + sdk2::Vec from{real(query+16),real(query+20),real(query+24)},to{real(query+28),real(query+32),real(query+36)}; + for(float value:{radius,from.x,from.y,from.z,to.x,to.y,to.z})if(!std::isfinite(value)||std::abs(value)>1e6F)return -1; + if(radius<0||radius>512)return -1; + bus.fill(output,48);bus.store(output,4,48); + const auto model=std::uint32_t(bus.load(symbol("gCurrentLevelModel"),4));if(!sdk2::guest(model,0x4c))return 1; + const auto count=unsigned(bus.load(model+0x1a,2)),segments=std::uint32_t(bus.load(model+4,4)),boxes=std::uint32_t(bus.load(model+8,4)); + if(count>1024||!sdk2::guest(segments,count*0x44)||!sdk2::guest(boxes,count*12))return -1; + if(segment!=UINT32_MAX&&segment>=count)return -1; + sdk2::CastHit best;const auto direction=to-from; + for(unsigned s=0;s0?120U:48U); + if(nt>8192||cost>budget.instructions)return -6;budget.instructions-=cost; + if(!sdk2::guest(vertices,nv*10)||!sdk2::guest(triangles,nt*16)||!sdk2::guest(batches,(nb+1)*12))return -1; + for(unsigned batch=0;batchend||end>nt)return -1; + for(unsigned triangle=begin;triangle=nv)return -1; + const auto at=vertices+index*10;v[i]={float(std::int16_t(bus.load(at,2))),float(std::int16_t(bus.load(at+2,2))),float(std::int16_t(bus.load(at+4,2)))}; + } + const float previous=best.time;sdk2::triangle(best,from,direction,radius,v[0],v[1],v[2]); + if(best.time512||!sdk2::guest(objects,n*4))return -1; + if(budget.instructions1)return 1; + bus.store(output+4,4,1);bus.store(output+8,4,std::bit_cast(best.time)); + const float values[]={best.point.x,best.point.y,best.point.z,best.normal.x,best.normal.y,best.normal.z}; + for(unsigned i=0;i<6;++i)bus.store(output+12+i*4,4,std::bit_cast(values[i])); + bus.store(output+36,4,best.segment);bus.store(output+40,4,best.triangle);bus.store(output+44,4,best.texture);return 0; +} diff --git a/runtime-recomp/src/game/mods/sdk2_command.hpp b/runtime-recomp/src/game/mods/sdk2_command.hpp new file mode 100644 index 00000000..b0cc3621 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_command.hpp @@ -0,0 +1,17 @@ +#pragma once +#include +#include +namespace dkr::sdk2 { +// Explicit fixed-width words. The transport codec writes these little-endian; +// the guest adapter writes the same words through its big-endian Memory bus. +// word 0: canonical package index + 1; 1: request ID; 2: mod action ID; +// 3..14: mod-defined deterministic arguments; 15: expected scene generation. +// Zero is the only empty command. +struct Command { + std::array words{}; + bool empty()const {return words==std::array{};} + bool valid()const {return empty()||(words[0]>=1&&words[0]<=128&&words[1]&&words[2]&&words[15]);} + bool operator==(const Command&)const=default; +}; +static_assert(sizeof(Command)==64); +} diff --git a/runtime-recomp/src/game/mods/sdk2_debug.inl b/runtime-recomp/src/game/mods/sdk2_debug.inl new file mode 100644 index 00000000..2c2247b8 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_debug.inl @@ -0,0 +1,46 @@ +void debug(unsigned owner,std::vector primitives) { + std::lock_guard guard(mutex);if(primitives.empty())debug_owners.erase(owner);else debug_owners[owner]=std::move(primitives); + if(debug_owners.empty())debug_views={}; +} +bool debug_available(){std::lock_guard guard(mutex);return debug_enabled.load()&¤t.active&&!debug_owners.empty();} +std::shared_ptr> debug_lines() { + std::lock_guard guard(mutex);auto result=std::make_shared>(); + if(current.active)for(const auto& view:debug_views)result->insert(result->end(),view.begin(),view.end());return result; +} +void debug_frame(const DkrViewSnapshot& snapshot,const std::optional& pose,unsigned view_count) { + std::lock_guard guard(mutex);if(snapshot.view>=4)return;auto& lines=debug_views[snapshot.view];lines.clear(); + if(debug_owners.empty()||!(snapshot.flags&DKR_VIEW_DRIVING)||(snapshot.flags&DKR_VIEW_SCRIPTED))return; + const auto* eye=pose?pose->position:snapshot.original_position; + const float yaw=pose?pose->yaw:snapshot.original_yaw,pitch=pose?pose->pitch:snapshot.original_pitch; + const float fov=pose?pose->vertical_fov:snapshot.original_fov; + const float cy=std::cos(yaw),sy=std::sin(yaw),cp=std::cos(pitch),sp=std::sin(pitch),f=1.F/std::tan(fov*.5F); + float left=0,top=0,width=1,height=1; + if(view_count==2){top=snapshot.view*.5F;height=.5F;} + else if(view_count>2){left=(snapshot.view&1)*.5F;top=(snapshot.view>>1)*.5F;width=height=.5F;} + const auto project=[&](const float* point,float& x,float& y) { + const float dx=point[0]-eye[0],dy=point[1]-eye[1],dz=point[2]-eye[2]; + const float right=dx*cy-dz*sy,forward=-dx*sy-dz*cy,depth=forward*cp+dy*sp,up=dy*cp-forward*sp; + if(depth<1.F)return false; + const float nx=right*f/(depth*snapshot.aspect),ny=up*f/depth; + if(!std::isfinite(nx)||!std::isfinite(ny)||std::abs(nx)>16||std::abs(ny)>16)return false; + x=left+(nx*.5F+.5F)*width;y=top+(.5F-ny*.5F)*height;return true; + }; + const auto line=[&](const DebugPrimitive& primitive,const float* a,const float* b,std::string label={}) { + if(lines.size()>=768)return;DebugLine value;value.rgba=primitive.rgba;value.label=std::move(label); + if(project(a,value.x1,value.y1)&&project(b,value.x2,value.y2)) { + // Each primitive stays clipped to its actual local viewport. + const float dx=value.x2-value.x1,dy=value.y2-value.y1;float first=0,last=1; + const auto clip=[&](float p,float q){if(std::abs(p)<1e-9F)return q>=0;const float r=q/p;if(p<0){if(r>last)return false;first=std::max(first,r);}else{if(r documents(const Package& package) { + std::vector out;if(!package.resources.contains("tools-ui"))return out; + const auto ui=parse_document(package.resources.at("tools-ui"),256*1024); + for(const auto& page:ui.at("pages"))for(const auto& field:page.at("fields"))if(field.at("type")=="document")out.push_back(field); + return out; +} +} +std::string GameWorld::sdk2_course_identity()const { + if(sdk2_current_course_){const auto id=sdk2_current_course_();if(!id.empty())return "legacy:"+id;} + if(!native_bus_)return {}; + const auto level=unsigned(native_bus_->load(symbol("gMapId"),4)); + for(const auto& course:sdk2_courses_)if(course.carrier==level&&!course.id.starts_with("legacy:"))return course.id; + return linked().revision+":level:"+std::to_string(level); +} +int GameWorld::sdk2_document(std::size_t index,Memory& bus,std::string_view name,Registers& c,Budget& budget) { + const auto& owner=linked().owners[index];if(!owner.package->modules.contains("documents2"))return -5; + const auto a=std::uint32_t(c.integer[4]),b=std::uint32_t(c.integer[5]),d=std::uint32_t(c.integer[6]);const auto key=guest_text(bus,a); + const auto fields=sdk2::documents(*owner.package); + const auto field=std::find_if(fields.begin(),fields.end(),[&](const auto& item){return item.at("document")==key;}); + if(field==fields.end())return -5; + if(name=="dkr2_document_publish") { + if(d>12000||d>budget.instructions)return -6;Bytes payload(d);bus.read(b,payload);budget.instructions-=d; + try { + auto value=parse_document(payload,12000);Json wrapper={{"schema",2},{"package",owner.package->id},{"digest",owner.package->digest}, + {"revision",linked().revision},{"course",sdk2_course_identity()},{"kind",key},{"payload",std::move(value)}}; + if(wrapper.dump().size()>16384)return -6;if(!online_||sdk2_frame_agreed_)dkr::sdk2::document(unsigned(index),key,std::move(wrapper));return 0; + }catch(const std::exception&){return -1;} + } + const auto at=owner.services+sdk2::Document; + if(!bus.load(at+20,4)||bus.load(at+16,4)!=unsigned(field-fields.begin()))return 1; + const auto bytes=unsigned(bus.load(at,4));const auto capacity=std::uint32_t(c.integer[7]); + if(name=="dkr2_document_read") { + if(b>bytes||capacity>bytes-b||capacity>4096)return -1; + std::array copy{};bus.read(owner.services+sdk2::DocumentData+b,std::span(copy).first(capacity));bus.write(d,std::span(copy).first(capacity));return int(capacity); + } + if(name=="dkr2_document_size")return int(bytes); + if(name=="dkr2_document_accept") { + if(sdk2_phase_!=2)return -3;bus.store(at+20,4,0);return 0; + } + return -5; +} +bool GameWorld::sdk2_document_command(const dkr::sdk2::Command& command,std::size_t index,Memory& bus) { + const auto action=command.words[2];if(action<65532)return false; + const auto& owner=linked().owners[index];const auto at=owner.services+sdk2::Document; + const auto reject=[&](const char* why){bus.fill(at,32);dkr::sdk2::notice(why);}; + if(!owner.package->modules.contains("documents2")||command.words[15]!=bus.load(owner.services+sdk2::Header+4,4)){reject("Document import target changed. Import it again in the intended scene.");return true;} + if(action==65534) { + const auto fields=sdk2::documents(*owner.package); + if(command.words[3]<1||command.words[3]>sdk2::DocumentCapacity||command.words[5]>=fields.size()){reject("Document transfer exceeds its schema or byte budget.");return true;} + bus.fill(at,32);bus.store(at,4,command.words[3]);bus.store(at+4,4,command.words[4]);bus.store(at+16,4,command.words[5]);bus.store(at+24,4,1);return true; + } + if(!bus.load(at+24,4)){reject("No document upload is active.");return true;} + const unsigned length=unsigned(bus.load(at,4)),received=unsigned(bus.load(at+12,4)); + if(action==65533) { + const unsigned offset=command.words[3],bytes=command.words[4]; + if(bytes>40||offset!=received||offset>length||bytes>length-offset){reject("Document upload arrived out of order.");return true;} + for(unsigned i=0;i>(24-(i%4)*8))&255U); + bus.store(at+12,4,offset+bytes);return true; + } + if(received!=length){reject("Document transfer is incomplete.");return true;} + Bytes bytes(length);bus.read(owner.services+sdk2::DocumentData,bytes);std::uint32_t hash=2166136261U;for(auto c:bytes){hash^=c;hash*=16777619U;} + if(hash!=bus.load(at+4,4)){reject("Document checksum did not match.");return true;} + try { + const auto value=parse_document(bytes,sdk2::DocumentCapacity);const auto fields=sdk2::documents(*owner.package);const auto field=unsigned(bus.load(at+16,4)); + if(value.at("schema")!=2||value.at("package")!=owner.package->id||value.at("digest")!=owner.package->digest||value.at("revision")!=linked().revision|| + value.at("course")!=sdk2_course_identity()||value.at("kind")!=fields.at(field).at("document")||!value.contains("payload")){reject("This document belongs to another mod edition, course, revision or schema.");return true;} + const auto payload=value.at("payload").dump();bus.write(owner.services+sdk2::DocumentData,View(reinterpret_cast(payload.data()),payload.size())); + bus.store(at,4,payload.size());bus.store(at+20,4,1);bus.store(at+24,4,0); + auto invoke=command;invoke.words[2]=fields[field].at("import_action").get(); + bus.store(owner.services+20480,4,1);for(unsigned i=0;i<16;++i)bus.store(owner.services+20496+i*4,4,invoke.words[i]); + }catch(const std::exception&){reject("The document is not a valid SDK 2 scene patch.");} + return true; +} diff --git a/runtime-recomp/src/game/mods/sdk2_operations.inl b/runtime-recomp/src/game/mods/sdk2_operations.inl new file mode 100644 index 00000000..cc2ca43e --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_operations.inl @@ -0,0 +1,197 @@ +namespace sdk2 { +constexpr unsigned SpawnOwners=24576,SpawnEntries=32768,MaximumSpawns=32; +std::span operation_functions(unsigned operation) { + static const char* teleport[]={"move_object","get_checkpoint_count","get_checkpoint_node"}; + static const char* clone[]={"spawn_object"};static const char* remove[]={"free_object"}; + static const char* checkpoint[]={"move_object","checkpoint_update_all"}; + static const char* fog[]={"set_fog"};static const char* weather[]={"weather_set"}; + static const char* sound[]={"sound_play"};static const char* music[]={"music_play"}; + static const char* stop[]={"music_stop"};static const char* fade[]={"music_fade"}; + static const char* course[]={"level_properties_push","level_transition_begin"}; + static const char* menu[]={"menu_pause_init","menu_pause_loop"}; + switch(operation){case 1:return teleport;case 2:return clone;case 3:return remove;case 4:return checkpoint; + case 10:return fog;case 11:return weather;case 20:return sound;case 21:return music; + case 22:return stop;case 23:return fade;case 30:case 31:case 40:return course;case 32:return menu;default:return {};} +} +} +bool GameWorld::sdk2_operation_available(unsigned operation)const { + const auto names=sdk2::operation_functions(operation);if(names.empty())return false; + for(const auto* name:names)if(std::none_of(table_.entries.begin(),table_.entries.end(),[&](const auto& e){ + return e.name&&std::string_view(e.name)==name&&e.function&&(!e.protected_call||(e.curated_call&&operation>=20&&operation<=23)); + }))return false; + return true; +} +int GameWorld::sdk2_operation(unsigned operation,std::size_t index,Memory& bus,const std::array& args, + std::array& output,Budget& budget) { + const auto& owner=linked().owners.at(index); + if(!sdk2_operation_available(operation))return -5; + if(sdk2_phase_!=2)return -3; + if(owner.package->development&&!sdk2_development_)return -4; + // Allocating scene/object operations are permitted only in offline worlds + // or the confirmed host-command boundary in an opted-in development lobby. + if(online_&&!sdk2_command_&&(operation==2||operation==3||operation==4||operation>=30))return -3; + const auto native_call=[&](const char* name,std::initializer_list arguments,bool curated=false) { + const auto entry=std::find_if(table_.entries.begin(),table_.entries.end(),[&](const auto& e){return e.name&&std::string_view(e.name)==name;}); + if(entry==table_.entries.end())throw Error("SDK operation has no native dispatcher."); + Registers call;const auto stack=owner.services+61440;bus.fill(stack,80);call.integer[29]=std::int32_t(stack); + unsigned i=0;for(auto value:arguments){if(i<4)call.integer[4+i]=std::uint64_t(std::int64_t(std::int32_t(value)));else bus.store(stack+16+(i-4)*4,4,value);++i;} + native(entry->address,call,budget,curated);return std::uint32_t(call.integer[2]); + }; + const auto real=[&](unsigned i){return std::bit_cast(args[i]);}; + const auto float_at=[&](unsigned p){return std::bit_cast(std::uint32_t(bus.load(p,4)));}; + if(operation==1) { + const auto obj=sdk2::handle_address(bus,owner.services,args[0],args[1]);if(!obj)return -2; + const float x=real(2),y=real(3),z=real(4); + if(!std::isfinite(x)||!std::isfinite(y)||!std::isfinite(z)||std::abs(x)>1e6F||std::abs(y)>1e6F||std::abs(z)>1e6F)return -1; + const auto model=std::uint32_t(bus.load(symbol("gCurrentLevelModel"),4));if(!sdk2::guest(model,0x4c))return -3; + const auto count=unsigned(bus.load(model+0x1a,2)),boxes=std::uint32_t(bus.load(model+8,4)); + if(count>1024||!sdk2::guest(boxes,count*12))return -1; + const int xi=int(x),yi=int(y),zi=int(z);int closest=-1,distance=1000000; + for(unsigned i=0;ibox[0]&&xibox[2]&&zi>1));if(delta127)return -6; + if(checkpoints) { + const auto list=native_call("get_checkpoint_node",{0});if(!sdk2::guest(list,checkpoints*0x3c))return -1; + float best=1e30F;for(unsigned i=0;i(x-float_at(obj+0xc)),std::bit_cast(y-float_at(obj+0x10)),std::bit_cast(z-float_at(obj+0x14))}); + if(moved)return -1;bus.store(obj+0x2e,2,unsigned(closest)); + for(unsigned j=0;j<3;++j)bus.store(obj+0x1c+j*4,4,0); + if(racer) { + bus.store(racer+0x2c,4,0);bus.store(racer+0x30,4,0); + for(unsigned j=0;j<3;++j){const auto value=bus.load(obj+0xc+j*4,4);bus.store(racer+0x5c+j*4,4,value);bus.store(racer+0x68+j*4,4,value);} + if(checkpoints){bus.store(racer+0x192,1,checkpoint);bus.store(racer+0x190,2,unsigned(bus.load(racer+0x193,1))*checkpoints+checkpoint);bus.store(racer+0xa8,4,std::bit_cast(1.F));bus.store(racer+0x1c8,1,0);} + } + return 0; + } + if(operation==4) { + const auto obj=sdk2::handle_address(bus,owner.services,args[0],args[1]);if(!obj)return -2; + if(bus.load(obj+0x48,2)!=13)return -5; + for(unsigned i=2;i<5;++i)if(!std::isfinite(real(i))||std::abs(real(i))>32760)return -1; + for(unsigned i=5;i<8;++i)if(std::int32_t(args[i])<-32768||std::int32_t(args[i])>32767)return -1; + if(!std::isfinite(real(8))||real(8)<0.01F||real(8)>16.F)return -1; + const auto count=unsigned(bus.load(symbol("gNumberOfTotalCheckpoints"),4)); + const auto nodes=std::uint32_t(bus.load(symbol("gTrackCheckpoints"),4)); + if(count>127||!sdk2::guest(nodes,count*0x3c))return -1; + for(unsigned i=0;i=500)return -5; + const auto entry=std::uint32_t(bus.load(obj+0x3c,4));if(!sdk2::guest(entry,8))return -5; + const unsigned size=unsigned(bus.load(entry+1,1))&127U;if(size<12||!sdk2::guest(entry,size))return -1; + for(unsigned i=2;i<=4;++i)if(!std::isfinite(real(i))||real(i)<-32768||real(i)>32767)return -1; + unsigned slot=32;for(unsigned i=0;i<32;++i)if(!bus.load(owner.services+sdk2::SpawnOwners+i*16,4)){slot=i;break;}if(slot==32)return -6; + const auto copy=owner.services+sdk2::SpawnEntries+slot*128;std::array bytes{};bus.read(entry,std::span(bytes).first(size));bus.write(copy,std::span(bytes).first(size)); + for(unsigned i=0;i<3;++i)bus.store(copy+2+i*2,2,std::uint32_t(std::int32_t(real(i+2)))); + const auto spawned=native_call("spawn_object",{copy,1});if(!spawned)return -6;if(!sdk2::guest(spawned,0x80))throw Error("SDK native spawn returned invalid ownership."); + const auto record=owner.services+sdk2::SpawnOwners+slot*16;bus.store(record,4,spawned);bus.store(record+4,4,bus.load(spawned+0x40,4));bus.store(record+8,4,copy);bus.store(record+12,4,bus.load(owner.services+sdk2::Header+4,4)); + output[0]=slot+1;return 0; + } + if(operation==3) { + const auto obj=sdk2::handle_address(bus,owner.services,args[0],args[1]);if(!obj)return -2; + unsigned record=0;for(unsigned i=0;i<32;++i) { + const auto at=owner.services+sdk2::SpawnOwners+i*16; + if(bus.load(at,4)==obj&&bus.load(at+4,4)==bus.load(obj+0x40,4)&&bus.load(at+8,4)==bus.load(obj+0x3c,4)&&bus.load(at+12,4)==bus.load(owner.services+sdk2::Header+4,4)){record=at;break;} + } + if(!record||bus.load(obj+0x48,2)!=2)return -4; + if(bus.load(symbol("gFreeListCount"),4)>=32)return -6; + if(bus.load(record+12,4)&0x80000000U)return -7; + native_call("free_object",{obj});bus.store(record+12,4,bus.load(record+12,4)|0x80000000U);return 0; + } + if(operation==10) { + if(args[0]>3||args[1]>1018||args[2]>1023||args[2]<=args[1]+4||args[3]>255||args[4]>255||args[5]>255)return -1; + native_call("set_fog",{args[0],args[1],args[2],args[3],args[4],args[5]});return 0; + } + if(operation==11) { + const auto header=std::uint32_t(bus.load(symbol("gCurrentLevelHeader"),4));if(!sdk2::guest(header,0xa0))return -3; + // Only modify a scene's already initialized weather. Enabling a new + // weather type requires resource preflight and a scene reload. + if(std::int16_t(bus.load(header+0x90,2))<=0)return -8; + for(unsigned i=0;i<3;++i)if(std::int32_t(args[i])<-0x100000||std::int32_t(args[i])>0x100000)return -1; + if(args[3]>65536||args[4]>65536||args[5]<1||args[5]>3600)return -1; + native_call("weather_set",{args[0],args[1],args[2],args[3],args[4],args[5]});return 0; + } + if(operation>=20&&operation<=23) { + Registers call;call.integer[4]=operation-20;call.integer[5]=args[0];call.integer[29]=std::int32_t(owner.services+61440);bus.fill(owner.services+61440,80); + if(!service("dkr_audio_command",index,bus,call,budget))return -5; + return call.integer[2]?0:-1; + } + if(operation==32) { + if(!sdk2_development_)return -4; + if(args[0]>2||bus.load(symbol("gGameMode"),4)!=0||bus.load(symbol("gLevelLoadTimer"),2)||bus.load(symbol("gPostRaceViewPort"),1))return -3; + native_call("menu_pause_init",{});bus.store(symbol("gIsPaused"),1,1); + bus.fill(owner.services+23200,20);bus.store(owner.services+23220,4,args[0]==0?5:args[0]==1?12:4);return 0; + } + if(operation==40) { + if(!sdk2_development_||!owner.package->modules.contains("track-tools2"))return -4; + // Native result/save arrays have three slots. The existing extended-lap + // owner has its own complete timing path; do not compete with it. + for(unsigned other=0;othersystems.contains("race-laps"))return -7; + if(args[0]>3)return -1;const auto header=std::uint32_t(bus.load(symbol("gCurrentLevelHeader"),4)); + if(bus.load(symbol("gGameMode"),4)!=0||!sdk2::guest(header,0xc4)||bus.load(header+0x4c,1)!=0)return -3; + const auto count=unsigned(bus.load(symbol("gObjectCount"),4)),list=std::uint32_t(bus.load(symbol("gObjPtrList"),4)); + if(count>512||!sdk2::guest(list,count*4))return -3; + if(budget.instructions0||bus.load(racer+0x1d8,1))return -3;++racers;} + if(!racers)return -3;const auto state=owner.services+38976; + for(unsigned other=0;otherapi==2&&bus.load(linked().owners[other].services+38976,4))return -7; + const unsigned prior=unsigned(bus.load(header+0x4b,1));if(prior<1||prior>3)return -8; + if(!args[0]){if(!bus.load(state,4))return 0;if(bus.load(state+4,4)!=header)return -2;bus.store(header+0x4b,1,bus.load(state+8,4));bus.fill(state,16);return 0;} + if(!bus.load(state,4)){bus.store(state,4,1);bus.store(state+4,4,header);bus.store(state+8,4,prior);} + bus.store(header+0x4b,1,args[0]);return 0; + } + if(operation==30||operation==31) { + if(!sdk2_development_)return -4; + if(bus.load(symbol("gGameMode"),4)!=0||bus.load(symbol("gLevelLoadTimer"),2)||bus.load(symbol("gLevelPropertyStackPos"),2))return -3; + const trophy::Course* course=nullptr; + if(operation==31){if(sdk2_courses_.empty())return -5;if(args[0]>=sdk2_courses_.size())return -1;course=&sdk2_courses_[args[0]];} + unsigned level=operation==30?unsigned(bus.load(symbol("gPlayableMapId"),4)):course->carrier; + unsigned vehicle=operation==30?unsigned(bus.load(symbol("gLevelDefaultVehicleID"),4)):args[2]; + if(level>=bus.load(symbol("gNumberOfLevelHeaders"),4)||args[1]>15||vehicle>2)return -1; + // Other courses have not constructed their native entrance objects. + // Only their admitted default spawn is safe to request before reload. + if(args[1]!=0) { + if(operation!=30)return -8;const unsigned racers=unsigned(bus.load(symbol("gNumRacers"),4)); + const unsigned count=unsigned(bus.load(symbol("gObjectCount"),4));const auto objects=std::uint32_t(bus.load(symbol("gObjPtrList"),4)); + if(!racers||racers>8||count>512||!sdk2::guest(objects,count*4))return -3; + unsigned anchors=0;for(unsigned i=0;ivehicles&(1U<id.starts_with("legacy:")?course->id.substr(7):std::string{},level); + if(operation==30&&course_selector_&&sdk2_current_course_)course_selector_(sdk2_current_course_(),level); + native_call("level_properties_push",{level,args[1],vehicle,0});native_call("level_transition_begin",{0});return 0; + } + return -5; +} diff --git a/runtime-recomp/src/game/mods/sdk2_overrides.inl b/runtime-recomp/src/game/mods/sdk2_overrides.inl new file mode 100644 index 00000000..fe2dd999 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_overrides.inl @@ -0,0 +1,26 @@ +void GameWorld::sdk2_apply_overrides(std::size_t index,unsigned object,bool shading) { + if(!native_bus_)return;auto& bus=*native_bus_;const auto& owner=linked().owners[index]; + const unsigned count=unsigned(bus.load(owner.services+38400,4));if(count>32)throw Error("SDK persistent property count is invalid."); + for(unsigned i=0;i=300))continue; + const auto property=std::find_if(std::begin(sdk2::Properties),std::end(sdk2::Properties),[&](const auto& value){return value.id==id;}); + if(property==std::end(sdk2::Properties)||!property->writable)throw Error("SDK persistent property descriptor is invalid."); + const auto racer=bus.load(obj+0x48,2)==1?std::uint32_t(bus.load(obj+0x64,4)):0;const auto base=sdk2::property_base(bus,obj,racer,*property); + if(base)bus.store(base+property->offset,property->width,bus.load(row+12,4)); + } +} +void GameWorld::sdk2_shading(std::string_view name,bool leaving,const Registers& args) { + if(name!="light_update_shading"||!sdk2_active_||!native_bus_)return;auto& bus=*native_bus_; + for(unsigned index=0;indexapi!=2||!owner.package->modules.contains("overrides2"))continue; + const auto header=owner.services+38400;unsigned depth=unsigned(bus.load(header+4,4)); + if(!leaving) { + if(depth>=8)throw Error("SDK shading scopes exceeded their bound."); + bus.store(header+8+depth*4,4,args.integer[4]);bus.store(header+4,4,depth+1); + }else { + if(!depth)throw Error("SDK shading scope has no matching entry."); + const auto object=unsigned(bus.load(header+8+(depth-1)*4,4));bus.store(header+4,4,depth-1);sdk2_apply_overrides(index,object,true); + } + } +} diff --git a/runtime-recomp/src/game/mods/sdk2_properties.inl b/runtime-recomp/src/game/mods/sdk2_properties.inl new file mode 100644 index 00000000..83f11047 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_properties.inl @@ -0,0 +1,49 @@ +// Reviewed offsets from extern/dkr-decomp/include/structs.h. Both supported +// revisions use this layout. No anonymous/unknown fields are writable here. +// id, name, unit, offset, width, type, racer, writable, minimum, maximum +{1,"Position X","world",0x0c,4,1,false,false,-1e6,1e6}, +{2,"Position Y","world",0x10,4,1,false,false,-1e6,1e6}, +{3,"Position Z","world",0x14,4,1,false,false,-1e6,1e6}, +{4,"Pitch","angle16",0x02,2,2,false,true,-32768,32767}, +{5,"Yaw","angle16",0x00,2,2,false,true,-32768,32767}, +{6,"Roll","angle16",0x04,2,2,false,true,-32768,32767}, +{7,"Scale","ratio",0x08,4,1,false,true,0.01,16}, +{8,"Opacity","byte",0x39,1,3,false,true,0,255}, +{9,"Velocity X","world/tick",0x1c,4,1,false,true,-1000,1000}, +{10,"Velocity Y","world/tick",0x20,4,1,false,true,-1000,1000}, +{11,"Velocity Z","world/tick",0x24,4,1,false,true,-1000,1000}, +{12,"Behaviour","id",0x48,2,2,false,false,-32768,32767}, +{13,"Object ID","id",0x4a,2,3,false,false,0,65535}, +{14,"Segment","id",0x2e,2,2,false,false,-1,32767}, +{100,"Forward speed","world/tick",0x2c,4,1,true,true,-500,500}, +{101,"Lateral speed","world/tick",0x30,4,1,true,true,-500,500}, +{102,"Bananas","count",0x185,1,2,true,true,0,99}, +{103,"Item type","id",0x172,1,2,true,true,0,4}, +{104,"Item ammunition","count",0x173,1,2,true,true,0,10}, +{105,"Item level","level",0x174,1,2,true,true,0,2}, +{106,"Boost timer","ticks",0x1d3,1,2,true,true,0,120}, +{107,"Shield timer","ticks",0x18e,2,2,true,true,0,3600}, +{108,"AI skill","level",0x1cc,1,2,true,true,0,100}, +{109,"Throttle","ratio",0xb4,4,1,true,true,0,1}, +{110,"Brake","ratio",0xb8,4,1,true,true,0,1}, +{111,"Buoyancy","ratio",0xc0,4,1,true,true,-100,100}, +{112,"Player port","port",0x00,2,2,true,false,-1,3}, +{113,"Racer index","id",0x02,1,2,true,false,0,7}, +{114,"Character","id",0x03,1,2,true,false,0,127}, +{115,"Vehicle","id",0x1d6,1,2,true,false,0,127}, +{116,"Lap","index",0x193,1,2,true,false,0,99}, +{117,"Checkpoint","index",0x190,2,2,true,false,-32768,32767}, +{118,"Position","place",0x1ae,2,2,true,false,0,8}, +{119,"Finished","bool",0x1d8,1,2,true,false,0,1}, +{120,"Shield type","level",0x189,1,2,true,true,0,3}, +{200,"Ambient lighting","ratio",0x28,4,1,false,true,0,2}, +{201,"Diffuse lighting","ratio",0x2c,4,1,false,true,0,2}, +{202,"Primary light red","byte",0x04,1,3,false,true,0,255}, +{203,"Primary light green","byte",0x05,1,3,false,true,0,255}, +{204,"Primary light blue","byte",0x06,1,3,false,true,0,255}, +{205,"Primary light intensity","byte",0x07,1,3,false,true,0,255}, +{206,"Light direction X","fixed8192",0x08,2,2,false,true,-8192,8192}, +{207,"Light direction Y","fixed8192",0x0a,2,2,false,true,-8192,8192}, +{208,"Light direction Z","fixed8192",0x0c,2,2,false,true,-8192,8192}, +{300,"Trigger radius","world",0x10,4,2,false,true,1,32767}, +{301,"Trigger vehicle","id",0x14,1,3,false,true,0,2}, diff --git a/runtime-recomp/src/game/mods/sdk2_reload.inl b/runtime-recomp/src/game/mods/sdk2_reload.inl new file mode 100644 index 00000000..fbf67f07 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_reload.inl @@ -0,0 +1,40 @@ +void GameWorld::sdk2_reload() { + auto request=dkr::sdk2::take_reload();if(!request.package)return; + try { + if(online_||!sdk2_development_||!world_.idle()||nested_||native_depth_)throw Error("Reload needs an offline retired development session. Online: return to lobby and readmit the new package."); + const auto old=world_.image();const auto owner=request.owner; + if(owner>=old->owners.size())throw Error("Reload owner changed.");const auto& previous=*old->owners[owner].package;const auto& replacement=*request.package; + if(previous.digest!=request.digest||previous.id!=replacement.id||!previous.sdk2_reload||!replacement.sdk2_reload||previous.sdk2_state_schema!=replacement.sdk2_state_schema) + throw Error("Both editions must opt into instruction-only reload with the same package ID and state_schema. Relaunch this package instead."); + if(previous.api!=replacement.api||previous.options!=replacement.options||previous.modules!=replacement.modules||previous.systems!=replacement.systems|| + previous.sdk2_profile!=replacement.sdk2_profile||previous.sdk2_live_options!=replacement.sdk2_live_options||previous.deterministic!=replacement.deterministic||previous.revisions!=replacement.revisions||previous.tracks!=replacement.tracks) + throw Error("Capabilities, settings, resources or profile changed. Relaunch to prepare them safely."); + auto resources=previous.resources,new_resources=replacement.resources;resources.erase("source-map");new_resources.erase("source-map"); + if(resources!=new_resources)throw Error("Immutable resources changed. Relaunch rather than live-replacing assets or tools."); + std::vector> packages;std::map settings,data; + for(unsigned i=0;iowners.size();++i){const auto& o=old->owners[i];packages.push_back(i==owner?request.package:o.package);settings[o.package->id]=o.settings;data[o.package->id]=Json::object();for(const auto& [key,bytes]:o.data)data[o.package->id][key]=parse_document(bytes,4*MiB);} + auto next=link(packages,settings,old->revision,policy(table_,false),data); + if(next->resident_bytes!=old->resident_bytes||next->regions.size()!=old->regions.size()||next->sections.size()!=old->sections.size()|| + next->function_owners!=old->function_owners||next->events!=old->events||next->native_functions!=old->native_functions||next->imports.size()!=old->imports.size()||next->hooks.size()!=old->hooks.size())throw Error("Code layout changed. Relaunch; only equal-layout instruction changes are live-reloadable."); + for(unsigned i=0;iregions.size();++i){const auto& a=old->regions[i];const auto& b=next->regions[i];if(a.address!=b.address||a.size!=b.size||a.writable!=b.writable||a.executable!=b.executable)throw Error("Code region permissions/layout changed. Relaunch required.");} + for(const auto& [address,a]:old->imports){const auto& b=next->imports.at(address);if(a.owner!=b.owner||a.dependency!=b.dependency||a.name!=b.name||a.event!=b.event)throw Error("Import ABI changed. Relaunch required.");} + for(const auto& [address,a]:old->hooks){const auto& b=next->hooks.at(address);if(a.entry!=b.entry||a.exit!=b.exit||a.replacement!=b.replacement)throw Error("Hook ABI changed. Relaunch required.");} + for(unsigned i=0;iowners.size();++i){const auto& a=old->owners[i];const auto& b=next->owners[i];if(a.begin!=b.begin||a.end!=b.end||a.functions!=b.functions||a.heap_size!=b.heap_size||a.heap!=b.heap||a.stack!=b.stack||a.native_stack!=b.native_stack||a.services!=b.services)throw Error("Owner state layout changed. Relaunch required.");} + for(unsigned i=0;isections.size();++i){const auto& a=old->sections[i];const auto& b=next->sections[i];if(a.address!=b.address||a.bytes.size()!=b.bytes.size())throw Error("Linked section changed. Relaunch required."); + for(unsigned at=0;atregions.begin(),old->regions.end(),[&](const auto& v){return v.executable&&address>=v.address&&address-v.addressregions.end())throw Error("Initial data or constants changed. Only instructions may change during live reload.");}} + const auto& o=old->owners[owner];auto& bus=*native_bus_; + for(unsigned i=0;i<32;++i)if(bus.load(o.services+sdk2::SpawnOwners+i*16,4))throw Error("Remove tool-owned spawns before reloading, or relaunch."); + if(bus.load(o.services+38404,4)||bus.load(o.services+40024,4))throw Error("Finish the native scope/document transfer before reloading."); + // Preallocate rollback, restricted migration and final buses BEFORE + // invoking user code. Cleanup/migration may only touch this owner's + // rewindable VM state: no native calls, game RAM or other mod writes. + auto saved=world_.stage(world_.checkpoint());auto cleanup=world_.prepare_rebind(old,int(owner));auto migrate=world_.prepare_rebind(next,int(owner));auto normal=world_.prepare_rebind(next); + auto regions=next->regions;regions.push_back({0x80000000U,8*unsigned(MiB),true,false});auto native=std::make_unique(ram_,std::move(regions)); + world_.exchange(*cleanup); + try {Budget fuel=sdk2_budget_;world_.owner_event(owner,"on_cleanup",fuel);world_.exchange(*migrate);world_.owner_event(owner,"on_reload",fuel);} + catch(...){if(world_.image()!=old)world_.exchange(*migrate);world_.exchange(*cleanup);world_.commit(std::move(saved));throw;} + world_.exchange(*normal);native_bus_.swap(native); + std::vector> admitted;for(const auto& item:next->owners)admitted.push_back(item.package);dkr::sdk2::begin(admitted,false); + dkr::sdk2::notice("Code reloaded for this offline session. State retained; installed files and save route unchanged."); + }catch(const std::exception& e){dkr::sdk2::notice(std::string("Reload not applied: ")+e.what());} +} diff --git a/runtime-recomp/src/game/mods/sdk2_schema.hpp b/runtime-recomp/src/game/mods/sdk2_schema.hpp new file mode 100644 index 00000000..0f245d0a --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_schema.hpp @@ -0,0 +1,48 @@ +#pragma once +#include "magic_code_package.hpp" +namespace dkr::mods::code { +inline void sdk2_schema(const Package& package) { + const auto found=package.resources.find("tools-ui");if(found==package.resources.end())return; + if(package.api!=2)throw Error("Tools panels require SDK API 2."); + const auto ui=parse_document(found->second,256*1024); + if(!ui.is_object()||ui.value("schema",0)!=2||!ui.contains("pages")||!ui["pages"].is_array()||ui["pages"].empty()||ui["pages"].size()>16) + throw Error("SDK tools UI must contain 1..16 dedicated pages."); + unsigned fields=0;std::set page_ids; + for(const auto& page:ui["pages"]) { + if(!page.is_object()||!page.contains("id")||!page["id"].is_string()||!valid_id(page["id"].get())|| + !page_ids.insert(page["id"].get()).second||!page.contains("label")||!page["label"].is_string()|| + page["label"].get_ref().size()>80||!page.contains("fields")||!page["fields"].is_array()) + throw Error("SDK tools page has an invalid ID, label or fields list."); + std::set ids; + for(const auto& field:page["fields"]) { + if(++fields>256||!field.is_object()||!field.contains("id")||!field["id"].is_string()|| + !valid_id(field["id"].get())||!ids.insert(field["id"].get()).second|| + !field.contains("label")||!field["label"].is_string()||field["label"].get_ref().size()>160) + throw Error("SDK tools field has an invalid ID/label or exceeds the 256-field budget."); + const auto type=field.value("type",std::string{}); + const std::set types{"text","button","number","integer","toggle","enum","vector","colour","search","table","inspector","diagnostics","authority","document","console","camera","debug","reload"}; + if(type=="reload"&&!package.modules.contains("reload2"))throw Error("Live reload UI requires reload2."); + if(!types.contains(type))throw Error("Unknown SDK tools control: "+type); + if((type=="camera"||type=="debug")&&!package.modules.contains("presentation2"))throw Error("Local camera/debug tools require presentation2."); + if(type=="document"&&!package.modules.contains("documents2"))throw Error("Document tools require documents2."); + if(type=="document"&&(!field.contains("document")||!field["document"].is_string()||!valid_id(field["document"].get())|| + !field.contains("import_action")||!field["import_action"].is_number_unsigned()||field["import_action"].get()<1||field["import_action"].get()>=65532))throw Error("SDK document needs a scoped ID and import action below 65532."); + for(const auto* flag:{"readonly","confirm","select","racer_only"})if(field.contains(flag)&&!field[flag].is_boolean())throw Error("SDK tools flag must be boolean."); + if(field.contains("edit_action")&&(!field["edit_action"].is_number_unsigned()||field["edit_action"].get()<1||field["edit_action"].get()>65535))throw Error("SDK inspector action ID is invalid."); + for(const auto* caption:{"help","visible"})if(field.contains(caption)&&(!field[caption].is_string()||field[caption].get_ref().size()>512))throw Error("SDK tools caption must be bounded text."); + if(type=="button"&&(!field.contains("action")||!field["action"].is_number_unsigned()||field["action"].get()<1||field["action"].get()>=65532)) + throw Error("SDK tools button needs an action ID in 1..65535."); + if(field.contains("argument")&&(!field["argument"].is_number_unsigned()||field["argument"].get()>11)) + throw Error("SDK tools argument must be 0..11; the final word is scene ownership."); + if(type=="enum"&&(!field.contains("choices")||!field["choices"].is_array()||field["choices"].empty()||field["choices"].size()>128)) + throw Error("SDK enum needs 1..128 choices."); + if(type=="enum")for(const auto& choice:field["choices"])if(!choice.is_string()||choice.get_ref().size()>160)throw Error("SDK enum choice is invalid."); + for(const auto* range:{"min","max","default"})if(field.contains(range)&&(!field[range].is_number()||!std::isfinite(field[range].get()))) + throw Error("SDK tools numeric range must be finite."); + for(const auto* range:{"min","max","default"})if(field.contains(range)&&std::abs(field[range].get())>1e7)throw Error("SDK tools numeric range exceeds its portable profile."); + if(field.value("min",0.0)>field.value("max",100.0))throw Error("SDK tools numeric range is reversed."); + if(type=="vector"||type=="colour")if(field.value("argument",13U)>(type=="vector"?9U:8U))throw Error("SDK vector/colour arguments exceed the command record."); + } + } +} +} diff --git a/runtime-recomp/src/game/mods/sdk2_settings.inl b/runtime-recomp/src/game/mods/sdk2_settings.inl new file mode 100644 index 00000000..e166829e --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_settings.inl @@ -0,0 +1,19 @@ +int GameWorld::sdk2_setting_apply(Memory& bus,std::size_t caller,unsigned package,unsigned option,std::uint32_t value,Budget& budget) { + const auto& source=linked().owners[caller];if(!source.package->modules.contains("live-settings2"))return -5; + if(!source.package->development||!sdk2_development_)return -4; + if(sdk2_phase_!=2||(online_&&!sdk2_command_))return -3; + if(package>=linked().owners.size())return -1;const auto& target=linked().owners[package]; + if(option>=target.package->options.size())return -1; + const auto& schema=target.package->options[option];const auto key=schema.at("id").get(); + if(target.package->api!=2||!target.package->modules.contains("live-settings2")||!target.package->sdk2_live_options.contains(key))return -8; + const auto type=schema.at("type").get();unsigned tag=0,bits=0; + if(value) { + bits=std::uint32_t(bus.load(value,4)); + if(type=="Number"){const float real=std::bit_cast(bits);if(!std::isfinite(real)||real()||real>schema.at("max").get())return -1;tag=1;} + else if(type=="Enum"){if(bits>=schema.at("options").size())return -1;tag=2;}else return -8; + } + const auto at=target.services+28672+option*8;if(bus.load(at,4)==tag&&(!tag||bus.load(at+4,4)==bits))return 0; + bus.store(at+4,4,bits);bus.store(at,4,tag); + Registers args;args.integer[4]=option;args.integer[5]=bits;args.integer[6]=tag; + world_.owner_event(package,"on_live_setting",budget,args);return 0; +} diff --git a/runtime-recomp/src/game/mods/sdk2_simulation.inl b/runtime-recomp/src/game/mods/sdk2_simulation.inl new file mode 100644 index 00000000..84252172 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_simulation.inl @@ -0,0 +1,55 @@ +// Retired main-loop control lane. No ordinary mod frame runs while paused. +// Controls are rewindable; window, transport and presentation owners stay live. +void GameWorld::sdk2_native_return(std::string_view name,Registers& registers) { + if(name!="menu_pause_loop"||!sdk2_development_||!native_bus_)return; + for(const auto& owner:linked().owners)if(owner.package->api==2) { + const auto address=owner.services+23220;const auto choice=native_bus_->load(address,4); + if(choice){registers.integer[2]=choice;native_bus_->store(address,4,0);return;} + } +} +unsigned GameWorld::sdk2_pulse() { + std::lock_guard lock(mutex_); + if(!sdk2_active_||!sdk2_development_||!native_bus_)return 1; + if(!online_)sdk2_reload(); + auto& bus=*native_bus_; + if(!online_)sdk2_command(dkr::sdk2::take(false)); + sdk2_boundary(DKR_CODE_EVENT_SCENE_LOADED); // lifetimes only, not a frame + const auto old_phase=sdk2_phase_;sdk2_phase_=2;sdk2_pulsing_=true; + struct Restore {unsigned& phase;unsigned previous;bool& pulse;~Restore(){phase=previous;pulse=false;}} restore{sdk2_phase_,old_phase,sdk2_pulsing_}; + if(!execution::active(&world_))budget_=sdk2_budget_;world_.event("*","on_tool",Registers{},budget_);sdk2_inspector(); + for(const auto& owner:linked().owners)if(owner.package->api==2&&owner.package->modules.contains("simulation2")) { + const auto at=owner.services+23200; + if(bus.load(symbol("gGameMode"),4)!=0||bus.load(symbol("gLevelLoadTimer"),2)) { + bus.fill(at,20);return 1; + } + if(bus.load(at,4)) { + const auto steps=bus.load(at+4,4);if(!steps)return 0;bus.store(at+4,4,steps-1); + } + const unsigned rate=unsigned(bus.load(at+8,4));if(rate||online_)bus.store(symbol("sLogicUpdateRate"),4,rate?rate:2); + } + return 1; +} +int GameWorld::sdk2_dispatch_pulse()noexcept { + try{return int(sdk2_pulse());}catch(const std::exception& e){try{error_=e.what();}catch(...){}return -1;} + catch(...){try{error_="Custom Magic Code failed at the development control boundary.";}catch(...){}return -1;} +} +bool GameWorld::sdk2_frame_starts_at(std::uint32_t address)const { + if(!sdk2_active_||online_||!sdk2_development_)return false; + const auto found=std::lower_bound(table_.entries.begin(),table_.entries.end(),address,[](const auto& entry,auto value){return entry.addressaddress==address&&found->before_event==DKR_CODE_EVENT_FRAME; +} +int GameWorld::sdk2_control(Memory& bus,std::size_t index,unsigned operation,unsigned value) { + const auto& owner=linked().owners[index]; + if(!owner.package->modules.contains("simulation2"))return -5; + if(!sdk2_development_||!owner.package->development)return -4; + if(!sdk2_pulsing_||sdk2_phase_!=2)return -3; + if(operation&& (bus.load(symbol("gGameMode"),4)!=0||bus.load(symbol("gLevelLoadTimer"),2)))return -3; + const auto at=owner.services+23200; + switch(operation) { + case 0:bus.fill(at,20);bus.store(symbol("sLogicUpdateRate"),4,2);return 0; + case 1:bus.store(at,4,1);bus.store(at+4,4,0);bus.store(at+8,4,2);return 0; + case 2:if(!bus.load(at,4))return -3;bus.store(at+4,4,1);return 0; + case 3:if(value<1||value>4)return -1;bus.store(at+8,4,value);return 0; + default:return -1; + } +} diff --git a/runtime-recomp/src/game/mods/sdk2_tools_camera.inl b/runtime-recomp/src/game/mods/sdk2_tools_camera.inl new file mode 100644 index 00000000..ec7184a1 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tools_camera.inl @@ -0,0 +1,41 @@ +void DrawSdk2Camera(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft) { + bool hidden=dkr::sdk2::hud_hidden.load();if(ImGui::Checkbox("Hide race HUD on this screen",&hidden))dkr::sdk2::hud_hidden=hidden; + ImGui::TextWrapped("Local screenshot control. Resets when the scene changes; other peers and gameplay HUD state are unchanged."); + if(ImGui::Button("Hide tools for screenshot",{std::min(260.F,ImGui::GetContentRegionAvail().x),38})){dkr::sdk2::tools_open=false;g_overlay_visible=false;} + auto& settings=draft.numbers["local-view"];unsigned view=snapshot.online?snapshot.local:unsigned(std::clamp(int(settings[0]),0,3)); + if(!snapshot.online){int selected=int(view);if(ImGui::SliderInt("Local viewport",&selected,0,3))settings[0]=float(selected);view=unsigned(selected);} + auto camera=dkr::sdk2::camera_state(view); + ImGui::TextWrapped("LOCAL CAMERA - Temporarily takes this viewport from the normal camera or ModernCamera++. Shared racers and cameras are not edited. Returns automatically on a scene change."); + if(!camera.valid){ImGui::TextDisabled("Enter a supported race to acquire this local camera.");return;} + if(camera.enabled) { + if(ImGui::Button("Return to normal camera",{260,40})){camera.enabled=false;dkr::sdk2::camera_edit(view,camera);} + }else if(ImGui::Button("Take local camera control",{260,40}))ImGui::OpenPopup("Take camera ownership"); + if(ImGui::BeginPopupModal("Take camera ownership",nullptr,ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("Temporarily suspend the current provider for this local viewport? Release it to restore that provider."); + if(ImGui::Button("Take control",{160,40})){camera.enabled=true;camera.follow=false;dkr::sdk2::camera_edit(view,camera);ImGui::CloseCurrentPopup();} + if(ImGui::GetContentRegionAvail().x>=328)ImGui::SameLine();if(ImGui::Button("Cancel",{160,40}))ImGui::CloseCurrentPopup();ImGui::EndPopup(); + } + ImGui::TextWrapped("Right-stick/mouse look and the existing camera zoom actions move the free camera when the tools are closed. It stays within 1000 units of the local racer."); + ImGui::BeginDisabled(!camera.enabled); + bool changed=false;ImGui::SetNextItemWidth(std::max(100.F,ImGui::GetContentRegionAvail().x-58)); + changed|=ImGui::InputFloat3("##camera-position",camera.position,"%.2f");ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0})) { + char initial[128];std::snprintf(initial,sizeof(initial),"%.3f %.3f %.3f",camera.position[0],camera.position[1],camera.position[2]); + RequestCustomTextKeyboard("Local camera X Y Z",initial,128,[view](const std::string& text){ + auto value=dkr::sdk2::camera_state(view);std::istringstream stream(text);for(auto& coordinate:value.position)if(!(stream>>coordinate)||!std::isfinite(coordinate))return; + stream>>std::ws;if(stream.eof())dkr::sdk2::camera_edit(view,value); + }); + } + changed|=ImGui::SliderAngle("Yaw",&camera.yaw,-180,180);changed|=ImGui::SliderAngle("Pitch",&camera.pitch,-85,85); + changed|=ImGui::SliderAngle("Field of view",&camera.fov,20,120);changed|=ImGui::SliderFloat("Travel speed",&camera.speed,1,2000,"%.0f"); + changed|=ImGui::SliderFloat("Follow smoothing",&camera.smoothing,0,30,"%.1f"); + if(ImGui::Button("Follow selected entity",{260,40})) { + camera.follow=true;camera.enabled=true;camera.owner=panel.owner;camera.slot=draft.arguments[0];camera.generation=draft.arguments[1];changed=true; + } + if(ImGui::Button("Free / spectator camera",{260,40})){camera.follow=false;camera.enabled=true;changed=true;} + auto& mark=draft.numbers["camera-bookmark"]; + if(ImGui::Button("Mark local camera position",{260,40})){std::copy_n(camera.position,3,mark.begin());mark[3]=float(camera.scene);} + ImGui::BeginDisabled(unsigned(mark[3])!=camera.scene); + if(ImGui::Button("Return to camera bookmark",{260,40})){std::copy_n(mark.begin(),3,camera.position);changed=true;} + ImGui::EndDisabled();if(changed)dkr::sdk2::camera_edit(view,camera);ImGui::EndDisabled(); +} diff --git a/runtime-recomp/src/game/mods/sdk2_tools_console.inl b/runtime-recomp/src/game/mods/sdk2_tools_console.inl new file mode 100644 index 00000000..642bfee3 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tools_console.inl @@ -0,0 +1,85 @@ +void DrawSdk2Console(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft,const std::string& id,const std::string& label) { + ImGui::TextWrapped("%s",label.c_str()); + ImGui::TextWrapped("Choose a registered action, or enter: action-ID [up to 12 words]. Integers use decimal or 0xHEX; floats use f:1.25. This is not a system shell."); + auto& buffer=draft.text[id];ImGui::SetNextItemWidth(std::max(80.F,ImGui::GetContentRegionAvail().x-58)); + ImGui::InputText("##command",buffer.data(),buffer.size());ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0}))RequestCustomTextKeyboard("SDK command",buffer.data(),buffer.size(),[digest=panel.digest,id](const std::string& text){ + auto& target=g_sdk2_drafts[digest].text[id];target.fill(0);std::copy_n(text.begin(),std::min(text.size(),target.size()-1),target.begin()); + }); + const auto commands=[&]{std::map values;for(const auto& page:panel.schema.at("pages"))for(const auto& field:page.at("fields")) + if(field.at("type")=="button")values.emplace(field.at("action").get(),field);return values;}(); + if(ImGui::BeginCombo("Registered actions","Choose an action")) { + for(unsigned group=0;group<2;++group)for(const auto& [action,field]:commands) { + const bool pinned=draft.favourites.contains(action);if(pinned!=(group==0))continue; + const auto caption=(pinned?std::string("[Pinned] "):std::string{})+std::to_string(action)+" - "+field.at("label").get(); + if(ImGui::Selectable(caption.c_str())){const auto value=std::to_string(action);buffer.fill(0);std::copy(value.begin(),value.end(),buffer.begin());} + }ImGui::EndCombo(); + } + unsigned chosen=0;{std::istringstream selected(buffer.data());selected>>chosen;} + if(commands.contains(chosen)) { + const auto caption=commands.at(chosen).at("label").get();ImGui::TextWrapped("%s",caption.c_str()); + bool pinned=draft.favourites.contains(chosen);if(ImGui::Checkbox("Pin command to favourites",&pinned)){if(pinned&&draft.favourites.size()<128)draft.favourites.insert(chosen);else draft.favourites.erase(chosen);} + auto values=draft.arguments;std::map hints; + for(const auto& page:panel.schema.at("pages")) { + bool contains=false;for(const auto& field:page.at("fields"))if(field.at("type")=="button"&&field.at("action")==chosen)contains=true; + if(!contains)continue; + for(const auto& field:page.at("fields")) { + const auto type=field.at("type").get();if(type=="button"&&field.at("action")==chosen)break; + if(type=="inspector"){hints[0]="Selected entity slot";hints[1]="Selected lifetime generation";continue;} + if(!field.contains("argument")||type=="search")continue; + const unsigned argument=field.at("argument").get(),count=type=="vector"?3:type=="colour"?4:1; + const auto key=field.at("id").get();const auto found=draft.numbers.find(key); + for(unsigned i=0;isecond[i]; + values[argument+i]=type=="integer"||type=="enum"||type=="toggle"?std::uint32_t(std::int32_t(value)):std::bit_cast(value); + hints[argument+i]=field.at("label").get()+(count>1?" component "+std::to_string(i+1):std::string{});} + }break; + } + for(const auto& [word,label]:hints)ImGui::TextWrapped("Word %u: %s",word,label.c_str()); + if(ImGui::Button("Complete from current tool fields",{std::min(300.F,ImGui::GetContentRegionAvail().x),38})) { + std::string line=std::to_string(chosen);for(unsigned i=0;i<12;++i){char word[16];std::snprintf(word,sizeof(word)," 0x%08X",values[i]);line+=word;} + buffer.fill(0);std::copy_n(line.begin(),std::min(line.size(),buffer.size()-1),buffer.begin()); + } + } + const bool locked=!snapshot.development||!panel.development;ImGui::BeginDisabled(locked); + if(ImGui::Button("Review / Run",{180,40})) { + try { + std::istringstream stream(buffer.data());unsigned action=0;if(!(stream>>action)||!commands.contains(action))throw std::runtime_error("Choose a registered action ID."); + auto arguments=draft.arguments;std::string token;unsigned word=0; + while(stream>>token) { + if(word>=12)throw std::runtime_error("A command has at most 12 argument words."); + if(token.starts_with("f:")) { + char* end=nullptr;const float value=std::strtof(token.c_str()+2,&end);if(end==token.c_str()+2||*end||!std::isfinite(value))throw std::runtime_error("Enter a finite float after f:."); + union {float real;std::uint32_t bits;} bits{value};arguments[word++]=bits.bits; + }else { + std::int64_t value=0;const bool hex=token.starts_with("0x");const auto begin=token.data()+(hex?2:0);const auto parsed=std::from_chars(begin,token.data()+token.size(),value,hex?16:10); + if(parsed.ec!=std::errc{}||parsed.ptr!=token.data()+token.size()||valueUINT32_MAX)throw std::runtime_error("Enter a 32-bit integer, hex word or f:float."); + arguments[word++]=std::uint32_t(value); + } + } + draft.arguments=arguments;draft.numbers[id+"-pending"][0]=float(action); + if(commands.at(action).value("confirm",false))ImGui::OpenPopup("Confirm console action"); + else if(Sdk2Submit(panel,snapshot,draft,action,arguments)) { + draft.command_history.emplace_back(buffer.data());if(draft.command_history.size()>64)draft.command_history.pop_front(); + } + }catch(const std::exception& e){dkr::sdk2::notice(e.what());} + } + if(ImGui::BeginPopupModal("Confirm console action",nullptr,ImGuiWindowFlags_AlwaysAutoResize)) { + const unsigned action=unsigned(draft.numbers[id+"-pending"][0]);ImGui::TextWrapped("Run %s?",commands.at(action).at("label").get_ref().c_str()); + if(ImGui::Button("Confirm",{140,40})){Sdk2Submit(panel,snapshot,draft,action,draft.arguments);ImGui::CloseCurrentPopup();} + if(ImGui::GetContentRegionAvail().x>=288)ImGui::SameLine();if(ImGui::Button("Cancel",{140,40}))ImGui::CloseCurrentPopup();ImGui::EndPopup(); + } + if(ImGui::Button(draft.recording?"Stop recording":"Record command macro",{230,40})) { + draft.recording=!draft.recording;if(draft.recording){draft.macro.clear();draft.macro_scene=panel.scene;} + } + ImGui::BeginDisabled(draft.recording||draft.macro.empty()||draft.macro_scene!=panel.scene); + if(ImGui::Button("Review macro playback",{230,40}))ImGui::OpenPopup("Play recorded commands"); + if(ImGui::BeginPopupModal("Play recorded commands",nullptr,ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("Queue %u recorded commands for this scene? Every edit still uses the normal permission and lifetime checks.",unsigned(draft.macro.size())); + if(ImGui::Button("Play",{140,40})) { + for(const auto& [action,arguments]:draft.macro)if(!Sdk2Submit(panel,snapshot,draft,action,arguments))break;ImGui::CloseCurrentPopup(); + } + if(ImGui::GetContentRegionAvail().x>=288)ImGui::SameLine();if(ImGui::Button("Cancel",{140,40}))ImGui::CloseCurrentPopup();ImGui::EndPopup(); + }ImGui::EndDisabled();ImGui::EndDisabled(); + ImGui::TextDisabled("Macro: %u commands. Entity handles are valid only in their original scene.",unsigned(draft.macro.size())); + for(const auto& line:draft.command_history)if(ImGui::Selectable(line.c_str())){buffer.fill(0);std::copy_n(line.begin(),std::min(line.size(),buffer.size()-1),buffer.begin());} +} diff --git a/runtime-recomp/src/game/mods/sdk2_tools_documents.inl b/runtime-recomp/src/game/mods/sdk2_tools_documents.inl new file mode 100644 index 00000000..b6aa0b12 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tools_documents.inl @@ -0,0 +1,67 @@ +void DrawSdk2Document(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft,const dkr::mods::code::Json& field) { + using Json=dkr::mods::code::Json; + const auto key=field.at("document").get();const auto label=field.at("label").get(); + ImGui::TextWrapped("%s",label.c_str()); + auto [entry,inserted]=draft.text.try_emplace(field.at("id").get()+"-path",std::array{});auto& path=entry->second; + if(inserted){const auto initial=(g_config_directory/"development"/(panel.id+"-"+key+".dkrdev")).u8string();const std::string value(reinterpret_cast(initial.data()),initial.size());std::copy_n(value.begin(),std::min(value.size(),path.size()-1),path.begin());} + ImGui::TextWrapped("File location (.dkrdev). Exports never overwrite an existing file.");ImGui::SetNextItemWidth(std::max(80.F,ImGui::GetContentRegionAvail().x-58)); + ImGui::InputText("##path",path.data(),path.size());ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0}))RequestCustomTextKeyboard("Development document file",path.data(),path.size(),[digest=panel.digest,id=entry->first](const std::string& text){ + auto& target=g_sdk2_drafts[digest].text[id];target.fill(0);std::copy_n(text.begin(),std::min(text.size(),target.size()-1),target.begin()); + }); + bool busy=draft.file_job.valid(); + if(busy&&draft.file_job.wait_for(std::chrono::seconds(0))==std::future_status::ready) { + try { + const auto result=draft.file_job.get(); + if(result.starts_with("{")) { + auto value=Json::parse(result);if(value.at("schema")!=2||value.at("package")!=panel.id||value.at("digest")!=panel.digest||value.at("kind")!=key)throw std::runtime_error("This document belongs to another tool or mod edition."); + draft.imported=std::move(value);dkr::sdk2::notice("Document read. Review it below before applying."); + }else dkr::sdk2::notice(result); + }catch(const std::exception& e){dkr::sdk2::notice(e.what());}busy=false; + } + const auto document=panel.documents.find(key);ImGui::BeginDisabled(busy||document==panel.documents.end()); + if(ImGui::Button("Export document",{210,40})) { + const auto selected=std::filesystem::u8path(path.data());const auto data=document->second.dump(2); + draft.file_job=std::async(std::launch::async,[selected,data]{ + if(selected.extension()!=".dkrdev")throw std::runtime_error("Use a .dkrdev filename."); + if(std::filesystem::exists(selected))throw std::runtime_error("That file already exists. Choose another name to keep the original."); + std::filesystem::create_directories(selected.parent_path()); + std::filesystem::path scratch; + for(unsigned i=0;i<16&&scratch.empty();++i) { + const auto candidate=selected.parent_path()/(".sdk2-export-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+"-"+std::to_string(i)); + if(std::filesystem::create_directory(candidate))scratch=candidate; + } + if(scratch.empty())throw std::runtime_error("Could not create temporary export storage."); + const auto temporary=scratch/"document.dkrdev"; + try { + std::ofstream stream(temporary,std::ios::binary);if(!stream)throw std::runtime_error("Could not create that document.");stream.write(data.data(),data.size());stream.close(); + if(!stream)throw std::runtime_error("The document could not be written completely."); + // No overwrite_existing: publishing must fail if another file + // appeared after the initial UI check. + std::filesystem::copy_file(temporary,selected,std::filesystem::copy_options::none); + std::filesystem::remove(temporary);std::filesystem::remove(scratch); + }catch(...){std::error_code ignored;std::filesystem::remove(temporary,ignored);std::filesystem::remove(scratch,ignored);throw;} + return std::string("Document exported. Source tracks and saves were not changed."); + }); + }ImGui::EndDisabled();ImGui::BeginDisabled(busy||!snapshot.host||!snapshot.development); + if(ImGui::Button("Read / Review import",{210,40})) { + const auto selected=std::filesystem::u8path(path.data()); + draft.file_job=std::async(std::launch::async,[selected]{ + if(selected.extension()!=".dkrdev"||!std::filesystem::is_regular_file(selected)||std::filesystem::file_size(selected)>32768)throw std::runtime_error("Choose a .dkrdev document smaller than 32 KiB."); + std::ifstream stream(selected,std::ios::binary);std::string bytes((std::istreambuf_iterator(stream)),{}); + if(!stream.eof()&&stream.fail())throw std::runtime_error("Could not read that document.");const auto value=Json::parse(bytes); + if(value.dump().size()>16384)throw std::runtime_error("The canonical document exceeds 16 KiB.");return value.dump(); + }); + } + if(draft.imported&&draft.imported->at("kind")==key) { + ImGui::TextWrapped("Preview: %s",draft.imported->dump(2).c_str()); + if(ImGui::Button("Queue reviewed import",{210,40})) { + unsigned index=0;bool found=false;for(const auto& page:panel.schema.at("pages"))for(const auto& item:page.at("fields"))if(item.at("type")=="document") { + if(!found&&item.at("document")==key)found=true;else if(!found)++index; + } + if(found&&dkr::sdk2::upload(panel.owner,index,*draft.imported,panel.digest,snapshot.generation))draft.imported.reset(); + else dkr::sdk2::notice("The import target changed or the upload queue is busy. No new document was queued."); + } + if(ImGui::Button("Discard preview",{210,40}))draft.imported.reset(); + }ImGui::EndDisabled();if(busy)ImGui::TextDisabled("Transferring the selected local file..."); +} diff --git a/runtime-recomp/src/game/mods/sdk2_tools_reload.inl b/runtime-recomp/src/game/mods/sdk2_tools_reload.inl new file mode 100644 index 00000000..33c6c57c --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tools_reload.inl @@ -0,0 +1,24 @@ +void DrawSdk2Reload(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft,const std::string& id) { + if(snapshot.online){ImGui::TextWrapped("Online code changes need everyone to return to the lobby and readmit the same new mod edition. Live replacement is offline only.");return;} + ImGui::TextWrapped("Instruction-only offline reload. Both editions must opt in; state layout, imports, hooks, resources and initial data must match. Everything else needs a relaunch."); + auto& path=draft.text[id+"-path"];ImGui::SetNextItemWidth(std::max(80.F,ImGui::GetContentRegionAvail().x-58));ImGui::InputText("##reload-path",path.data(),path.size());ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0}))RequestCustomTextKeyboard("New .nrm code package",path.data(),path.size(),[digest=panel.digest,id](const std::string& value){auto& to=g_sdk2_drafts[digest].text[id+"-path"];to.fill(0);std::copy_n(value.begin(),std::min(value.size(),to.size()-1),to.begin());}); + bool busy=draft.reload_job.valid();if(busy&&draft.reload_job.wait_for(std::chrono::seconds(0))==std::future_status::ready) { + try{draft.reload_package=draft.reload_job.get();dkr::sdk2::notice("New edition inspected. Review its identity before running new code.");}catch(const std::exception& e){dkr::sdk2::notice(e.what());}busy=false; + } + ImGui::BeginDisabled(busy||!snapshot.development); + if(ImGui::Button("Read / Review new code",{240,40})) { + const auto selected=std::filesystem::u8path(path.data());draft.reload_package.reset(); + draft.reload_job=std::async(std::launch::async,[selected]{ + using namespace dkr::mods;const auto size=std::filesystem::file_size(selected); + if(selected.extension()!=".nrm"||!std::filesystem::is_regular_file(selected)||size>code::MaxPackage)throw Error("Choose a bounded .nrm package."); + Bytes bytes(std::size_t(size),0);std::ifstream input(selected,std::ios::binary);input.read(reinterpret_cast(bytes.data()),std::streamsize(bytes.size())); + if(!input||input.peek()!=std::char_traits::eof())throw Error("Package changed or could not be read completely.");return code::inspect(bytes,DKR_RELEASE_VERSION); + }); + } + if(draft.reload_package){const auto& package=*draft.reload_package;ImGui::TextWrapped("%s / %s / %s",package.id.c_str(),package.version.c_str(),package.digest.c_str()); + ImGui::TextWrapped("This executes trusted mod code. Installed files are unchanged; migration is limited to private VM state. Native operations are forbidden during cleanup/reload."); + if(ImGui::Button("I trust this code - reload",{260,40})) { + if(dkr::sdk2::request_reload({panel.owner,panel.digest,draft.reload_package},snapshot.generation))dkr::sdk2::notice("Reload queued at the next retired offline boundary.");else dkr::sdk2::notice("The session changed or another reload is pending.");draft.reload_package.reset(); + }}ImGui::EndDisabled(); +} diff --git a/runtime-recomp/src/game/mods/sdk2_tools_ui.inl b/runtime-recomp/src/game/mods/sdk2_tools_ui.inl new file mode 100644 index 00000000..2b545aaa --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tools_ui.inl @@ -0,0 +1,297 @@ +// Generic mod-defined tools, rendered with the same existing paddock theme. +// No ImGui/process pointers enter the VM. Nothing here writes guest RAM. +struct Sdk2Watch { + int racer=-1;unsigned object_id=0,behaviour=0,property=0,scene=0,slot=0,generation=0; + float position[3]{};std::uint64_t session=0; +}; +struct Sdk2PanelDraft { + std::map> text; + std::map> numbers; + std::array arguments{}; + unsigned page=0; + bool initialized=false; + bool recording=false; + unsigned macro_scene=0; + std::vector>> macro; + std::deque command_history; + std::optional imported; + std::future file_job; + std::future> reload_job; + std::shared_ptr reload_package; + std::set favourites; + std::vector watches; + std::future preferences_job; + std::optional preferences_preview; +}; +std::map g_sdk2_drafts; +bool Sdk2ActionButton(const std::string& caption) { + const float available=ImGui::GetContentRegionAvail().x; + const float width=std::min(available,std::max(170.F,ImGui::CalcTextSize(caption.c_str()).x+40.F)); + std::istringstream words(caption);std::string word,line,wrapped;unsigned lines=1; + while(words>>word) { + const auto next=line.empty()?word:line+" "+word; + if(!line.empty()&&ImGui::CalcTextSize(next.c_str()).x>width-24.F){wrapped+=line+"\n";line=word;++lines;}else line=next; + } + wrapped+=line+"###action";return ImGui::Button(wrapped.c_str(),{width,std::max(42.F,ImGui::GetTextLineHeight()*lines+16.F)}); +} +bool Sdk2Submit(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft,unsigned action,const std::array& arguments) { + const bool accepted=dkr::sdk2::submit(panel.owner,action,arguments,panel.digest,snapshot.generation); + if(accepted&&draft.recording) { + if(draft.macro.size()<32){draft.macro.emplace_back(action,arguments);draft.macro_scene=panel.scene;} + else {draft.recording=false;dkr::sdk2::notice("Macro recording reached its 32-command limit.");} + }return accepted; +} +#include "mods/sdk2_tools_documents.inl" +#include "mods/sdk2_tools_console.inl" +#include "mods/sdk2_tools_camera.inl" +#include "mods/sdk2_tools_reload.inl" +#include "mods/sdk2_tools_workspace.inl" +void DrawSdk2Debug() { + if(!dkr::sdk2::debug_available())return;const auto lines=dkr::sdk2::debug_lines();const auto display=ImGui::GetIO().DisplaySize;auto* draw=ImGui::GetForegroundDrawList(); + for(const auto& line:*lines){const auto rgba=line.rgba;const auto colour=IM_COL32((rgba>>24)&255,(rgba>>16)&255,(rgba>>8)&255,rgba&255); + draw->AddLine({line.x1*display.x,line.y1*display.y},{line.x2*display.x,line.y2*display.y},colour,1.5F); + if(!line.label.empty())draw->AddText({line.x1*display.x,line.y1*display.y},colour,line.label.c_str());} +} +void DrawSdk2Tools() { + auto snapshot=dkr::sdk2::snapshot(); + bool open=true;const auto display=ImGui::GetIO().DisplaySize; + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign,{0.5F,0.5F}); + ImGui::SetNextWindowSize({std::max(320.F,display.x-48.F),std::max(240.F,display.y-48.F)},ImGuiCond_Appearing); + if(!ImGui::Begin("CUSTOM MAGIC CODE TOOLS",&open,ImGuiWindowFlags_NoCollapse|ImGuiWindowFlags_NoScrollbar)) { + ImGui::End();ImGui::PopStyleVar();if(!open){dkr::sdk2::tools_open=false;g_sdk2_key_capture=false;}return; + } + if(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)&&!ImGui::IsPopupOpen("",ImGuiPopupFlags_AnyPopupId))open=false; + if(!snapshot->active||snapshot->panels.empty())ImGui::TextWrapped("Enable a Custom Magic Code with tools, then start a game."); + else { + static unsigned selected=0;if(selected>=snapshot->panels.size())selected=0; + if(snapshot->panels.size()>1){ImGui::SetNextItemWidth(-1);if(ImGui::BeginCombo("##tools-package",snapshot->panels[selected].name.c_str())) { + for(unsigned i=0;ipanels.size();++i)if(ImGui::Selectable(snapshot->panels[i].name.c_str(),selected==i))selected=i;ImGui::EndCombo();}} + const auto& panel=snapshot->panels[selected];auto& draft=g_sdk2_drafts[panel.digest]; + const auto& pages=panel.schema.at("pages");draft.page=std::min(draft.page,unsigned(pages.size()-1)); + ImGui::TextUnformatted(panel.name.c_str());ImGui::SameLine(); + ImGui::TextDisabled("%s | %s | scene %u / tick %u",snapshot->online?"Online":"Offline", + snapshot->development?(snapshot->host?"Host tools":"Client requests"):"Read-only",panel.scene,panel.tick); + float line_width=0;const float available=ImGui::GetContentRegionAvail().x; + if(available<760.F) { + ImGui::SetNextItemWidth(-1); + if(ImGui::BeginCombo("##tools-screen",pages[draft.page].at("label").get_ref().c_str())) { + for(unsigned i=0;i().c_str(),draft.page==i))draft.page=i;ImGui::EndCombo(); + } + }else for(unsigned i=0;i();const float width=ImGui::CalcTextSize(label.c_str()).x+32; + if(line_width&&line_width+width+8(),label=field.at("label").get(),type=field.at("type").get(); + if(field.contains("visible")&&!panel.values.value(field.at("visible").get(),false))continue; + ImGui::PushID(id.c_str()); + const auto value=panel.values.find(id);const auto number=[&](float fallback){return value!=panel.values.end()&&value->is_number()?value->get():fallback;}; + const bool read_only=field.value("readonly",false); + const bool locked=read_only||!snapshot->development||!panel.development; + if(type=="text") { + ImGui::TextWrapped("%s",label.c_str()); + if(value!=panel.values.end()){const auto text=value->is_string()?value->get():value->dump();ImGui::TextWrapped("%s",text.c_str());} + }else if(type=="button") { + ImGui::BeginDisabled(locked); + if(Sdk2ActionButton(label)) { + if(field.value("confirm",false))ImGui::OpenPopup("Confirm action"); + else Sdk2Submit(panel,*snapshot,draft,field.at("action").get(),draft.arguments); + } + if(ImGui::BeginPopupModal("Confirm action",nullptr,ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("%s?",label.c_str()); + if(ImGui::Button("Confirm",{160,40})){Sdk2Submit(panel,*snapshot,draft,field.at("action").get(),draft.arguments);ImGui::CloseCurrentPopup();} + if(ImGui::GetContentRegionAvail().x>=328)ImGui::SameLine();if(ImGui::Button("Cancel",{160,40}))ImGui::CloseCurrentPopup();ImGui::EndPopup(); + } + ImGui::EndDisabled(); + }else if(type=="reload") { + DrawSdk2Reload(panel,*snapshot,draft,id); + }else if(type=="inspector") { + ImGui::TextUnformatted(label.c_str()); + const auto data=panel.inspector; + if(!data||data->empty())ImGui::TextDisabled("Waiting for an entity snapshot from the current scene."); + else { + auto& filter=draft.text[id];ImGui::SetNextItemWidth(std::max(100.F,ImGui::GetContentRegionAvail().x-58.F)); + ImGui::InputText("##filter",filter.data(),filter.size());ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0}))RequestCustomTextKeyboard("Find entity",filter.data(),filter.size(),[digest=panel.digest,id](const std::string& text){ + auto& target=g_sdk2_drafts[digest].text[id];target.fill(0);std::copy_n(text.begin(),std::min(text.size(),target.size()-1),target.begin()); + }); + std::string preview="Choose an entity";const dkr::sdk2::EntityView* selected_entity=nullptr; + for(const auto& entity:*data)if(entity.slot==draft.arguments[0]&&entity.generation==draft.arguments[1])selected_entity=&entity; + if(selected_entity)preview=std::string(selected_entity->racer?"Racer ":"Object ")+std::to_string(selected_entity->slot)+" / behaviour "+std::to_string(selected_entity->behaviour); + if(ImGui::BeginCombo("##entity",preview.c_str())) { + for(const auto& entity:*data) { + if(field.value("racer_only",false)&&!entity.racer)continue; + const auto text=std::string(entity.racer?"Racer ":"Object ")+std::to_string(entity.slot)+" | behaviour "+std::to_string(entity.behaviour)+" | ID "+std::to_string(entity.object_id); + auto haystack=text,needle=std::string(filter.data()); + for(auto& ch:haystack)ch=char(std::tolower(static_cast(ch)));for(auto& ch:needle)ch=char(std::tolower(static_cast(ch))); + if(!needle.empty()&&haystack.find(needle)==std::string::npos)continue; + if(ImGui::Selectable(text.c_str(),selected_entity==&entity)){draft.arguments[0]=entity.slot;draft.arguments[1]=entity.generation;selected_entity=&entity;} + }ImGui::EndCombo(); + } + if(selected_entity) { + auto& selection=draft.numbers[id+"-property"]; + if(ImGui::BeginTable("properties",3,ImGuiTableFlags_BordersInnerH|ImGuiTableFlags_SizingStretchProp)) { + for(const auto& property:selected_entity->properties) { + ImGui::TableNextRow();ImGui::TableNextColumn();ImGui::PushID(int(property.id)); + if(ImGui::Selectable(property.name.c_str(),unsigned(selection[0])==property.id,ImGuiSelectableFlags_SpanAllColumns)) { + selection[0]=float(property.id);union {std::uint32_t bits;float real;} value{property.bits}; + selection[1]=property.type==1?value.real:property.type==2?float(std::int32_t(property.bits)):float(property.bits); + } + ImGui::TableNextColumn();union {std::uint32_t bits;float real;} value{property.bits}; + if(property.type==1)ImGui::Text("%.3f",value.real);else if(property.type==2)ImGui::Text("%d",std::int32_t(property.bits));else ImGui::Text("%u",property.bits); + ImGui::TableNextColumn();ImGui::TextDisabled("%s",property.unit.c_str());ImGui::PopID(); + }ImGui::EndTable(); + } + const auto property=std::find_if(selected_entity->properties.begin(),selected_entity->properties.end(),[&](const auto& p){return p.id==unsigned(selection[0]);}); + if(property!=selected_entity->properties.end()&&ImGui::Button("Pin this field to Watches",{230,36}))Sdk2PinWatch(panel,*snapshot,draft,*selected_entity,property->id); + if(property!=selected_entity->properties.end()&&field.contains("edit_action")&&(property->flags&1)) { + ImGui::TextWrapped("Edit %s",property->name.c_str());ImGui::BeginDisabled(locked); + const bool compact=ImGui::GetContentRegionAvail().x<300.F; + ImGui::SetNextItemWidth(compact?ImGui::GetContentRegionAvail().x:ImGui::GetContentRegionAvail().x-218.F); + if(property->type==1)ImGui::InputFloat("##edit",&selection[1],0,0,"%.3f"); + else {int number=int(selection[1]);if(ImGui::InputInt("##edit",&number))selection[1]=float(number);} + if(!compact)ImGui::SameLine();if(ImGui::Button("Keys",{50,0})) { + char initial[64];std::snprintf(initial,sizeof(initial),"%.3f",selection[1]); + RequestCustomTextKeyboard(property->name.c_str(),initial,64,[digest=panel.digest,key=id+"-property"](const std::string& text){ + std::istringstream stream(text);float number=0;if(stream>>number&&number>=-1e7F&&number<=1e7F)g_sdk2_drafts[digest].numbers[key][1]=number; + }); + } + ImGui::SameLine();if(ImGui::Button("Apply property",{150,0})) { + auto arguments=draft.arguments;arguments[2]=property->id; + union {float real;std::uint32_t bits;} value{selection[1]}; + arguments[3]=property->type==1?value.bits:std::uint32_t(std::int32_t(selection[1])); + Sdk2Submit(panel,*snapshot,draft,field.at("edit_action").get(),arguments); + }ImGui::EndDisabled(); + } + } + } + }else if(type=="debug") { + bool enabled=dkr::sdk2::debug_enabled.load();if(ImGui::Checkbox(label.c_str(),&enabled))dkr::sdk2::debug_enabled=enabled; + ImGui::TextWrapped("Local overlay only. Does not change another player's view or the shared simulation."); + }else if(type=="camera") { + DrawSdk2Camera(panel,*snapshot,draft); + }else if(type=="console") { + DrawSdk2Console(panel,*snapshot,draft,id,label); + }else if(type=="document") { + DrawSdk2Document(panel,*snapshot,draft,field); + }else if(type=="authority") { + ImGui::TextWrapped("%s",label.c_str()); + if(!snapshot->online)ImGui::TextWrapped("Offline tools use this development session. Ordinary saves remain separate."); + else { + ImGui::TextWrapped("The host orders every shared change. Clients can inspect and request actions; approval never changes a client locally."); + if(snapshot->host)for(unsigned peer=1;peerplayers;++peer) { + ImGui::PushID(int(peer));bool enabled=(snapshot->grants&(1U<requests) { + ImGui::PushID(int(request.peer*65536+request.id)); + ImGui::TextWrapped("P%u: %s action %u (#%u)",request.peer+1,request.package.c_str(),request.command.words[2],request.id); + if(ImGui::Button("Approve",{140,38}))dkr::sdk2::decide(request.peer,request.id,true); + if(ImGui::GetContentRegionAvail().x>=288)ImGui::SameLine(); + if(ImGui::Button("Decline",{140,38}))dkr::sdk2::decide(request.peer,request.id,false);ImGui::PopID(); + } + if(snapshot->requests.empty())ImGui::TextDisabled("No pending requests."); + } + for(const auto& line:snapshot->history)ImGui::TextWrapped("%s",line.c_str()); + }else if(type=="diagnostics") { + ImGui::TextWrapped("%s",label.c_str()); + ImGui::TextWrapped("Local physical callback costs, including replay. These measurements never affect gameplay or shared RNG."); + if(panel.diagnostics.contains("packages"))for(const auto& cost:panel.diagnostics.at("packages")) { + ImGui::TextWrapped("%s | %s | %u KiB resident | %u KiB heap",cost.at("id").get_ref().c_str(),cost.at("profile").get_ref().c_str(), + cost.at("resident").get()/1024,cost.at("heap").get()/1024); + ImGui::TextWrapped("%llu callbacks | %llu instructions | %llu native calls | %.2f ms CPU",cost.at("callbacks").get(), + cost.at("instructions").get(),cost.at("native_calls").get(),cost.at("cpu_ms").get()); + } + }else if(type=="table") { + ImGui::TextUnformatted(label.c_str()); + if(value!=panel.values.end()&&value->is_array()&&!value->empty()) { + if(ImGui::BeginTable("values",2,ImGuiTableFlags_BordersInnerH|ImGuiTableFlags_SizingStretchProp)) { + unsigned row=0;for(const auto& item:*value) { + if(row++>=128)break;ImGui::TableNextRow();ImGui::TableNextColumn(); + const auto title=item.is_object()?item.value("name",std::string("Entry ")+std::to_string(row)):std::to_string(row); + ImGui::PushID(int(row)); + if(field.value("select",false)&&item.is_object()&&item.contains("slot")&&item.contains("generation")) { + const bool selected_entity=draft.arguments[0]==item.at("slot").get()&&draft.arguments[1]==item.at("generation").get(); + if(ImGui::Selectable(title.c_str(),selected_entity,ImGuiSelectableFlags_SpanAllColumns)) { + draft.arguments[0]=item.at("slot").get();draft.arguments[1]=item.at("generation").get(); + } + }else ImGui::TextWrapped("%s",title.c_str()); + ImGui::TableNextColumn();const auto details=item.is_object()?item.value("detail",std::string{}):item.dump();ImGui::TextWrapped("%s",details.c_str());ImGui::PopID(); + } + ImGui::EndTable(); + } + }else ImGui::TextDisabled("No entries in this scene."); + }else if(type=="search") { + auto& buffer=draft.text[id];ImGui::TextUnformatted(label.c_str());ImGui::SetNextItemWidth(-58); + ImGui::InputText("##text",buffer.data(),buffer.size());ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0}))RequestCustomTextKeyboard(label.c_str(),buffer.data(),buffer.size(),[digest=panel.digest,id](const std::string& text){ + auto& target=g_sdk2_drafts[digest].text[id];target.fill(0);std::copy_n(text.begin(),std::min(text.size(),target.size()-1),target.begin()); + }); + const auto argument=field.value("argument",13U);if(argument<12) { + const unsigned capacity=(12-argument)*4;for(unsigned i=0;i(buffer[i]):0)<{});auto& values=it->second; + if(inserted)values[0]=number(field.value("default",0.F)); + const auto argument=field.value("argument",13U); + ImGui::BeginDisabled(read_only);ImGui::TextUnformatted(label.c_str()); + const float keys=(type=="integer"||type=="vector"||type=="number")?58.F:0.F; + ImGui::SetNextItemWidth(std::max(40.F,std::min(560.F,ImGui::GetContentRegionAvail().x-keys))); + if(type=="toggle") {bool enabled=values[0]!=0;if(ImGui::Checkbox("##value",&enabled))values[0]=enabled?1.F:0.F;} + else if(type=="enum") { + const auto& choices=field.at("choices");int choice=std::clamp(int(values[0]),0,int(choices.size()-1)); + const auto preview=choices[choice].get();if(ImGui::BeginCombo("##value",preview.c_str())) { + for(unsigned i=0;i().c_str(),int(i)==choice))values[0]=float(i);ImGui::EndCombo(); + } + }else if(type=="integer") {int integer=int(values[0]);if(ImGui::InputInt("##value",&integer))values[0]=float(std::clamp(integer,field.value("min",0),field.value("max",100)));} + else if(type=="vector")ImGui::InputFloat3("##value",values.data(),"%.2f"); + else if(type=="colour")ImGui::ColorEdit4("##value",values.data(),ImGuiColorEditFlags_NoInputs); + else ImGui::SliderFloat("##value",values.data(),field.value("min",0.F),field.value("max",100.F),"%.3f"); + if(type=="integer"||type=="vector"||type=="number") { + ImGui::SameLine();if(ImGui::Button("Keys",{50,0})) { + char initial[128];const unsigned components=type=="vector"?3U:1U; + if(components==3)std::snprintf(initial,sizeof(initial),"%.4f %.4f %.4f",values[0],values[1],values[2]); + else std::snprintf(initial,sizeof(initial),"%.0f",values[0]); + RequestCustomTextKeyboard(label.c_str(),initial,128,[digest=panel.digest,id,components](const std::string& text){ + std::array numbers{};std::istringstream stream(text); + for(unsigned i=0;i>numbers[i])||!(numbers[i]>=-1e7F&&numbers[i]<=1e7F)){dkr::sdk2::notice("Enter finite numbers, separated by spaces.");return;} + stream>>std::ws;if(!stream.eof()){dkr::sdk2::notice("Enter only the requested numbers.");return;} + g_sdk2_drafts[digest].numbers[id]=numbers; + }); + } + } + ImGui::EndDisabled(); + if(argument<12)for(unsigned n=0;n<(type=="vector"?3U:type=="colour"?4U:1U)&&argument+n<12;++n) { + union {float real;std::uint32_t bits;} raw{values[n]}; + draft.arguments[argument+n]=type=="integer"||type=="enum"||type=="toggle"?std::uint32_t(std::int32_t(values[n])):raw.bits; + } + } + if(field.contains("help"))ImGui::TextWrapped("%s",field.at("help").get_ref().c_str()); + ImGui::Dummy({0,8});ImGui::PopID(); + } + ImGui::EndChild();ImGui::Separator(); + if(!snapshot->notice.empty())ImGui::TextWrapped("%s",snapshot->notice.c_str()); + ImGui::TextDisabled("Selected entity: %u / lifetime %u",draft.arguments[0],draft.arguments[1]); + if(ImGui::Button("Back / Close",{160,38}))open=false; + if(ImGui::GetContentRegionAvail().x>330)ImGui::SameLine(); + if(ImGui::Button("Workspace",{150,38}))ImGui::OpenPopup("Tools workspace"); + if(ImGui::GetContentRegionAvail().x>200)ImGui::SameLine(); + if(ImGui::Button("Shortcut settings",{200,38}))ImGui::OpenPopup("Tools shortcut settings"); + DrawSdk2Workspace(panel,*snapshot,draft); + if(ImGui::BeginPopupModal("Tools shortcut settings",nullptr,ImGuiWindowFlags_AlwaysAutoResize)) { + const std::string shortcut=g_sdk2_key_capture?"Press a key (Esc cancels)":std::string("Keyboard: ")+SDL_GetScancodeName(SDL_Scancode(g_sdk2_tools_key)); + if(ImGui::Button(shortcut.c_str(),{280,40}))g_sdk2_key_capture=true; + if(ImGui::Checkbox("Back/Select + Y controller shortcut",&g_sdk2_tools_chord))SaveSettings(); + if(ImGui::Button("Close",{160,38})){g_sdk2_key_capture=false;ImGui::CloseCurrentPopup();}ImGui::EndPopup(); + } + } + ImGui::End();ImGui::PopStyleVar();if(!open){dkr::sdk2::tools_open=false;g_sdk2_key_capture=false;} +} diff --git a/runtime-recomp/src/game/mods/sdk2_tools_workspace.inl b/runtime-recomp/src/game/mods/sdk2_tools_workspace.inl new file mode 100644 index 00000000..a3148faa --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tools_workspace.inl @@ -0,0 +1,100 @@ +// User-controlled local workspaces. Importing one changes UI drafts, never +// submits gameplay commands. Handles/macros retain strict session lifetimes. +using Sdk2Json=dkr::mods::code::Json; +std::map Sdk2Registered(const dkr::sdk2::Panel& panel) { + std::map result;for(const auto& page:panel.schema.at("pages"))for(const auto& field:page.at("fields")) + if(field.at("type")=="button")result.emplace(field.at("action").get(),field);return result; +} +void Sdk2PinWatch(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft,const dkr::sdk2::EntityView& entity,unsigned property) { + if(draft.watches.size()>=32){dkr::sdk2::notice("Watches reached the 32-field limit.");return;} + Sdk2Watch watch;watch.object_id=entity.object_id;watch.behaviour=entity.behaviour;watch.property=property;watch.scene=panel.scene;watch.slot=entity.slot;watch.generation=entity.generation;watch.session=snapshot.local_session; + for(const auto& value:entity.properties){if(value.id>=1&&value.id<=3)watch.position[value.id-1]=std::bit_cast(value.bits);if(entity.racer&&value.id==113)watch.racer=int(value.bits);} + for(const auto& previous:draft.watches)if(previous.slot==watch.slot&&previous.generation==watch.generation&&previous.property==property&&previous.scene==panel.scene){dkr::sdk2::notice("That field is already watched.");return;} + draft.watches.push_back(watch);dkr::sdk2::notice("Field added to the local workspace watch list."); +} +const dkr::sdk2::EntityView* Sdk2ResolveWatch(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2Watch& watch) { + if(!panel.inspector)return nullptr;const dkr::sdk2::EntityView* result=nullptr; + if(watch.session==snapshot.local_session&&watch.scene==panel.scene&&watch.slot) { + for(const auto& entity:*panel.inspector)if(entity.slot==watch.slot&&entity.generation==watch.generation)return &entity; + // A once-resolved lifetime may not bind to a replacement in this scene. + return nullptr; + } + for(const auto& entity:*panel.inspector) { + bool match=false;if(watch.racer>=0&&entity.racer)for(const auto& property:entity.properties)if(property.id==113&&int(property.bits)==watch.racer)match=true; + if(watch.racer<0&&!entity.racer&&entity.object_id==watch.object_id&&entity.behaviour==watch.behaviour) { + unsigned axes=0;for(const auto& property:entity.properties)if(property.id>=1&&property.id<=3&&std::abs(std::bit_cast(property.bits)-watch.position[property.id-1])<0.01F)++axes;match=axes==3; + } + if(match){if(result)return nullptr;result=&entity;} + } + if(result){watch.slot=result->slot;watch.generation=result->generation;watch.scene=panel.scene;watch.session=snapshot.local_session;}return result; +} +Sdk2Json Sdk2WorkspaceDocument(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,const Sdk2PanelDraft& draft) { + Sdk2Json value={{"schema",2},{"kind","tools-workspace"},{"package",panel.id},{"digest",panel.digest},{"scene",panel.scene},{"local_session",snapshot.local_session}, + {"page",draft.page},{"favourites",draft.favourites},{"numbers",draft.numbers},{"text",Sdk2Json::object()},{"watches",Sdk2Json::array()},{"macro",Sdk2Json::array()}}; + // Only declared search fields may be exported. Exclude file locations, + // document previews, reload paths, lobby metadata and native addresses. + for(const auto& page:panel.schema.at("pages"))for(const auto& field:page.at("fields"))if(field.at("type")=="search") { + const auto id=field.at("id").get();if(const auto at=draft.text.find(id);at!=draft.text.end())value["text"][id]=std::string(at->second.data()); + } + for(const auto& watch:draft.watches)value["watches"].push_back({{"racer",watch.racer},{"object_id",watch.object_id},{"behaviour",watch.behaviour},{"property",watch.property},{"position",watch.position}}); + for(const auto& [action,args]:draft.macro)value["macro"].push_back({{"action",action},{"arguments",args}}); + value["macro_scene"]=draft.macro_scene;return value; +} +void Sdk2ApplyWorkspace(const Sdk2Json& value,const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft) { + const auto reject=[](){throw std::runtime_error("That workspace has an invalid field, edition or portable limit.");}; + if(value.at("schema")!=2||value.at("kind")!="tools-workspace"||value.at("package")!=panel.id||value.at("digest")!=panel.digest)reject(); + auto favourites=value.at("favourites").get>();if(favourites.size()>128)reject();const auto actions=Sdk2Registered(panel);for(auto action:favourites)if(!actions.contains(action))reject(); + const auto& numbers=value.at("numbers");if(!numbers.is_object()||numbers.size()>256)reject();std::map> parsed_numbers; + for(const auto& [key,array]:numbers.items()){if(key.size()>160||!array.is_array()||array.size()!=4)reject();auto components=array.get>();for(auto component:components)if(!std::isfinite(component)||std::abs(component)>1e7F)reject();parsed_numbers.emplace(key,components);} + const auto& text=value.at("text");if(!text.is_object()||text.size()>256)reject();std::map> parsed_text; + for(const auto& [key,item]:text.items()) { + bool search=false;for(const auto& page:panel.schema.at("pages"))for(const auto& field:page.at("fields"))if(field.at("type")=="search"&&field.at("id")==key)search=true; + if(!search||!item.is_string()||item.get_ref().size()>256)reject();std::array buffer{};const auto source=item.get();std::copy(source.begin(),source.end(),buffer.begin());parsed_text.emplace(key,buffer); + } + const auto& watches=value.at("watches");if(!watches.is_array()||watches.size()>32)reject();std::vector parsed_watches; + for(const auto& row:watches){Sdk2Watch watch;watch.racer=row.at("racer").get();watch.object_id=row.at("object_id").get();watch.behaviour=row.at("behaviour").get();watch.property=row.at("property").get(); + if(watch.racer<-1||watch.racer>7||watch.object_id>65535||watch.behaviour>65535||watch.property<1||watch.property>65535)reject();const auto position=row.at("position").get>(); + for(unsigned i=0;i<3;++i){if(!std::isfinite(position[i])||std::abs(position[i])>1e6F)reject();watch.position[i]=position[i];}parsed_watches.push_back(watch);} + const auto& macro=value.at("macro");if(!macro.is_array()||macro.size()>32)reject();std::vector>> parsed_macro; + const bool same_scene=value.at("scene")==panel.scene&&value.at("local_session")==snapshot.local_session&&value.at("macro_scene")==panel.scene; + for(const auto& row:macro){const auto action=row.at("action").get();if(!actions.contains(action))reject();const auto args=row.at("arguments").get>();if(same_scene)parsed_macro.emplace_back(action,args);} + const unsigned page=value.at("page").get();if(page>=panel.schema.at("pages").size())reject(); + // Every field is checked before committing the local draft. No service, + // authoritative command, game edit or RNG call occurs here. + draft.favourites=std::move(favourites);draft.numbers=std::move(parsed_numbers);for(const auto& [key,buffer]:parsed_text)draft.text[key]=buffer; + draft.watches=std::move(parsed_watches);draft.macro=std::move(parsed_macro);draft.macro_scene=same_scene?panel.scene:0;draft.page=page;draft.recording=false; + dkr::sdk2::notice(same_scene?"Workspace loaded. No gameplay command was executed.":"Workspace loaded. Old-session macros were dropped; watches must resolve uniquely in this scene."); +} +void DrawSdk2Workspace(const dkr::sdk2::Panel& panel,const dkr::sdk2::Snapshot& snapshot,Sdk2PanelDraft& draft) { + ImGui::SetNextWindowSize({std::max(300.F,ImGui::GetIO().DisplaySize.x-80),std::max(240.F,ImGui::GetIO().DisplaySize.y-80)},ImGuiCond_Appearing); + if(!ImGui::BeginPopupModal("Tools workspace",nullptr,ImGuiWindowFlags_NoCollapse|ImGuiWindowFlags_NoScrollbar))return; + ImGui::TextWrapped("Local favourites, watch definitions and tool presets. Imports never run commands automatically."); + ImGui::BeginChild("workspace-page",{0,std::max(40.F,ImGui::GetContentRegionAvail().y-58)},false); + ImGui::TextUnformatted("WATCHES"); + for(unsigned i=0;iproperties)if(item.id==watch.property)property=&item; + if(property){if(property->type==1)ImGui::Text("%s: %.3f %s",property->name.c_str(),std::bit_cast(property->bits),property->unit.c_str());else ImGui::Text("%s: %d %s",property->name.c_str(),int(property->bits),property->unit.c_str());} + else ImGui::TextDisabled("Field %u: stale, absent or ambiguous target",watch.property); + if(ImGui::Button("Rebind to selected",{180,34})&&panel.inspector)for(const auto& selected:*panel.inspector)if(selected.slot==draft.arguments[0]&&selected.generation==draft.arguments[1]) { + const auto field=watch.property;draft.watches.erase(draft.watches.begin()+i);Sdk2PinWatch(panel,snapshot,draft,selected,field);break; + } + if(ImGui::GetContentRegionAvail().x>=298)ImGui::SameLine();const bool remove=ImGui::Button("Remove",{110,34});ImGui::PopID();if(remove)draft.watches.erase(draft.watches.begin()+i);else ++i; + } + if(draft.watches.empty())ImGui::TextDisabled("Select a property in an inspector and choose Pin this field to Watches."); + ImGui::Separator();ImGui::TextUnformatted("FAVOURITES");const auto actions=Sdk2Registered(panel); + for(const auto& [action,field]:actions){ImGui::PushID(int(action));bool pinned=draft.favourites.contains(action);const auto caption=field.at("label").get();if(ImGui::Checkbox(caption.c_str(),&pinned)){if(pinned&&draft.favourites.size()<128)draft.favourites.insert(action);else draft.favourites.erase(action);}ImGui::PopID();} + ImGui::Separator();ImGui::TextUnformatted("EXPORT / REVIEW WORKSPACE");auto [entry,inserted]=draft.text.try_emplace("workspace-file",std::array{});auto& path=entry->second; + if(inserted){const auto native=(g_config_directory/"development"/(panel.id+"-tools.dkrtools")).u8string();const std::string text(reinterpret_cast(native.data()),native.size());std::copy_n(text.begin(),std::min(text.size(),path.size()-1),path.begin());} + ImGui::SetNextItemWidth(std::max(80.F,ImGui::GetContentRegionAvail().x-58));ImGui::InputText("##workspace-file",path.data(),path.size());ImGui::SameLine(); + if(ImGui::Button("Keys",{50,0}))RequestCustomTextKeyboard("Workspace file (.dkrtools)",path.data(),path.size(),[digest=panel.digest](const std::string& text){auto& to=g_sdk2_drafts[digest].text["workspace-file"];to.fill(0);std::copy_n(text.begin(),std::min(text.size(),to.size()-1),to.begin());}); + bool busy=draft.preferences_job.valid();if(busy&&draft.preferences_job.wait_for(std::chrono::seconds(0))==std::future_status::ready){try{const auto value=draft.preferences_job.get();if(value.starts_with("{"))draft.preferences_preview=Sdk2Json::parse(value);else dkr::sdk2::notice(value);}catch(const std::exception& e){dkr::sdk2::notice(e.what());}busy=false;} + ImGui::BeginDisabled(busy); + if(ImGui::Button("Export new workspace",{240,38})){const auto selected=std::filesystem::u8path(path.data());const auto data=Sdk2WorkspaceDocument(panel,snapshot,draft).dump(2);draft.preferences_job=std::async(std::launch::async,[selected,data]{ + if(selected.extension()!=".dkrtools"||data.size()>32768)throw std::runtime_error("Choose a .dkrtools name; keep the workspace within 32 KiB.");std::filesystem::create_directories(selected.parent_path()); + dkr::mods::write_new_file(selected,dkr::mods::View(reinterpret_cast(data.data()),data.size()));return std::string("Workspace exported. Existing files were not overwritten.");});} + if(ImGui::Button("Read / Review workspace",{240,38})){const auto selected=std::filesystem::u8path(path.data());draft.preferences_job=std::async(std::launch::async,[selected]{ + if(selected.extension()!=".dkrtools")throw std::runtime_error("Choose a .dkrtools workspace.");const auto bytes=dkr::mods::read_file(selected,32768);return dkr::mods::code::parse_document(bytes,32768).dump();});} + ImGui::EndDisabled();if(draft.preferences_preview){ImGui::TextWrapped("Preview: %s",draft.preferences_preview->dump(2).c_str());if(ImGui::Button("Apply reviewed local preset",{260,38})){try{Sdk2ApplyWorkspace(*draft.preferences_preview,panel,snapshot,draft);draft.preferences_preview.reset();}catch(const std::exception& e){dkr::sdk2::notice(e.what());}}if(ImGui::Button("Discard preview",{180,38}))draft.preferences_preview.reset();} + ImGui::EndChild();ImGui::Separator();if(ImGui::Button("Back",{160,40})||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight))ImGui::CloseCurrentPopup();ImGui::EndPopup(); +} diff --git a/runtime-recomp/src/game/mods/sdk2_track_catalogue.inl b/runtime-recomp/src/game/mods/sdk2_track_catalogue.inl new file mode 100644 index 00000000..89fb6f10 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_track_catalogue.inl @@ -0,0 +1,64 @@ +// Read-only, bounded scene metadata. All indices refer to the currently loaded +// guest scene, never process addresses or a promise of live mesh replacement. +int GameWorld::sdk2_track_catalogue(Memory& bus,std::string_view list,unsigned index,Json& item,Budget& budget) { + if(budget.instructions<4096)return -6;budget.instructions-=4096; + const auto real=[&](unsigned p){return std::bit_cast(std::uint32_t(bus.load(p,4)));}; + const auto position=[&](unsigned object){return Json::array({real(object+12),real(object+16),real(object+20)});}; + if(list=="scene") { + if(index)return 1;const auto header=std::uint32_t(bus.load(symbol("gCurrentLevelHeader"),4)); + if(!sdk2::guest(header,0xc4))return -3; + item={{"map",bus.load(symbol("gPlayableMapId"),4)},{"race_type",bus.load(header+0x4c,1)},{"laps",bus.load(header+0x4b,1)}, + {"vehicle",bus.load(header+0x4d,1)},{"vehicles",bus.load(header+0x4e,1)}, + {"fog",Json::array()},{"course_height",real(header+8)},{"geometry_asset",bus.load(header+0x34,2)}, + {"policy","Read-only metadata. Structural water/material/mesh changes require a prepared asset and scene reload."}}; + for(unsigned i=0;i<5;++i)item["fog"].push_back(std::int16_t(bus.load(header+0x3a+i*2,2)));return 0; + } + if(list=="ai-nodes"||list=="spawn-points"||list=="triggers") { + const unsigned count=unsigned(bus.load(symbol("gObjectCount"),4));const auto objects=std::uint32_t(bus.load(symbol("gObjPtrList"),4)); + if(count>512||!sdk2::guest(objects,count*4))return -3;unsigned found=0; + for(unsigned i=0;i1024||!sdk2::guest(segments,count*0x44)||!sdk2::guest(boxes,count*12))return -3; + if(list=="segments") { + if(index>=count)return 1;const auto segment=segments+index*0x44;Json low=Json::array(),high=Json::array(); + for(unsigned n=0;n<3;++n){low.push_back(std::int16_t(bus.load(boxes+index*12+n*2,2)));high.push_back(std::int16_t(bus.load(boxes+index*12+6+n*2,2)));} + item={{"index",index},{"count",count},{"minimum",low},{"maximum",high},{"vertices",bus.load(segment+0x1c,2)},{"triangles",bus.load(segment+0x1e,2)},{"batches",bus.load(segment+0x20,2)}};return 0; + } + if(!list.starts_with("triangles/"))return -5; + unsigned selected=0;const auto number=list.substr(10);if(number.empty()||number.size()>4)return -1; + for(char ch:number){if(ch<'0'||ch>'9')return -1;selected=selected*10+unsigned(ch-'0');}if(selected>=count)return -1; + const auto segment=segments+selected*0x44;const unsigned nv=unsigned(bus.load(segment+0x1c,2)),nt=unsigned(bus.load(segment+0x1e,2)),nb=unsigned(bus.load(segment+0x20,2)); + const auto vertices=std::uint32_t(bus.load(segment,4)),triangles=std::uint32_t(bus.load(segment+4,4)),batches=std::uint32_t(bus.load(segment+12,4)); + if(nt>8192||nb>8192||!sdk2::guest(vertices,nv*10)||!sdk2::guest(triangles,nt*16)||!sdk2::guest(batches,(nb+1)*12))return -3; + if(index>=nt)return 1; + for(unsigned b=0;bend||end>nt)return -1; + if(index=end)continue;Json points=Json::array();sdk2::Vec v[3]; + for(unsigned n=0;n<3;++n){const unsigned vertex=unsigned(bus.load(at+2,2))+unsigned(bus.load(triangles+index*16+n+1,1));if(vertex>=nv)return -1; + const auto p=vertices+vertex*10;v[n]={float(std::int16_t(bus.load(p,2))),float(std::int16_t(bus.load(p+2,2))),float(std::int16_t(bus.load(p+4,2)))};points.push_back(Json::array({v[n].x,v[n].y,v[n].z}));} + const auto normal=sdk2::normalized(sdk2::cross(v[1]-v[0],v[2]-v[0])); + item={{"index",index},{"segment",selected},{"vertices",points},{"normal",Json::array({normal.x,normal.y,normal.z})},{"texture",bus.load(at,1)},{"batch",b}, + {"policy","Static render/collision geometry. Texture ID is not a universal physics surface ID; no live mesh writes."}};return 0; + }return 1; +} diff --git a/runtime-recomp/src/game/mods/sdk2_tuning.inl b/runtime-recomp/src/game/mods/sdk2_tuning.inl new file mode 100644 index 00000000..d8ff3f77 --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_tuning.inl @@ -0,0 +1,62 @@ +namespace sdk2 { +unsigned vehicle_tag(std::string_view name) { + constexpr const char* names[]={"func_8004F7F4","func_80046524","func_80049794","update_tricky","update_bluey","update_smokey","update_bubbler","update_wizpig","update_rocket"}; + for(unsigned i=0;iload(symbol("gGameMode"),4)!=0)return false; + for(const auto& owner:linked().owners)if(owner.package->api==2&&owner.package->modules.contains("racer-tuning2")&&native_bus_->load(owner.services+23228,4))return true; + return false; +} +int GameWorld::sdk2_tune(Memory& bus,std::size_t index,unsigned request) { + const auto& owner=linked().owners[index];if(!owner.package->modules.contains("racer-tuning2"))return -5; + if(sdk2_phase_!=2)return -3;if(owner.package->development&&!sdk2_development_)return -4; + if(bus.load(request,4)<32)return -1; + const unsigned slot=unsigned(bus.load(request+4,4)),generation=unsigned(bus.load(request+8,4)),mask=unsigned(bus.load(request+28,4));if(mask&~15U)return -1; + if(!slot&&!generation&&!mask){bus.fill(owner.services+36864,8*192);return 0;} + const auto object=sdk2::handle_address(bus,owner.services,slot,generation);if(!object)return -2; + if(bus.load(object+0x48,2)!=1)return -5;const auto racer=std::uint32_t(bus.load(object+0x64,4));if(!sdk2::guest(racer,0x224))return -2; + const unsigned id=unsigned(bus.load(racer+2,1));if(id>=8)return -1; + if((mask&4U)&&bus.load(racer+0x1d6,1)>2)return -5; + const auto at=owner.services+36864+id*192;if(bus.load(at+24,4))return -3; + for(unsigned i=0;i<4;++i)if(mask&(1U<(std::uint32_t(bus.load(request+12+i*4,4)));if(!std::isfinite(value)||value<(i==3?0.F:0.1F)||value>(i==3?100.F:4.F))return -1;} + bus.store(at,4,slot);bus.store(at+4,4,generation);for(unsigned i=0;i<4;++i)bus.store(at+8+i*4,4,(mask&(1U<api==2&&owner.package->modules.contains("racer-tuning2"))for(unsigned id=0;id<8;++id) { + const auto at=owner.services+36864+id*192;if(!bus.load(at+24,4)||bus.load(at+48,4)!=sdk2::vehicle_tag(name))continue; + const auto mask=unsigned(bus.load(at+44,4)); + if(mask&1U)bus.store(symbol("gCurrentRacerWeightStat"),4,bus.load(at+28,4));if(mask&2U)bus.store(symbol("gCurrentRacerHandlingStat"),4,bus.load(at+32,4));if(mask&4U)bus.store(symbol("gCurrentRacerMiscAssetPtr"),4,bus.load(at+36,4));bus.store(at+24,4,0); + const auto object=sdk2::handle_address(bus,owner.services,unsigned(bus.load(at,4)),unsigned(bus.load(at+4,4))); + if(!object||!(mask&8U))continue;const auto racer=std::uint32_t(bus.load(object+0x64,4));if(!sdk2::guest(racer,0x224))continue; + const auto real=[&](unsigned address){return std::bit_cast(std::uint32_t(bus.load(address,4)));}; + const float cap=real(at+20),speed=real(racer+0x2c);if(cap>0&&std::abs(speed)>cap) { + const float ratio=cap/std::abs(speed);bus.store(racer+0x2c,4,std::bit_cast(speed*ratio));for(unsigned axis:{0U,2U})bus.store(object+0x1c+axis*4,4,std::bit_cast(real(object+0x1c+axis*4)*ratio)); + } + }return; + } + const auto object=std::uint32_t(args.integer[6]);if(!sdk2::guest(object,0x68)||bus.load(object+0x48,2)!=1)return; + const auto racer=std::uint32_t(bus.load(object+0x64,4));if(!sdk2::guest(racer,0x224))return;const unsigned id=unsigned(bus.load(racer+2,1));if(id>=8)return; + for(const auto& owner:linked().owners)if(owner.package->api==2&&owner.package->modules.contains("racer-tuning2")) { + const auto at=owner.services+36864+id*192;const unsigned slot=unsigned(bus.load(at,4)),generation=unsigned(bus.load(at+4,4));if(sdk2::handle_address(bus,owner.services,slot,generation)!=object)continue; + const auto weight=symbol("gCurrentRacerWeightStat"),handling=symbol("gCurrentRacerHandlingStat"),curve=symbol("gCurrentRacerMiscAssetPtr");const auto mask=unsigned(bus.load(at+44,4)); + const auto real=[&](unsigned address){return std::bit_cast(std::uint32_t(bus.load(address,4)));}; + if(!leaving) { + if(bus.load(at+24,4))throw Error("SDK racer tuning re-entered before native restoration.");if(!mask)continue; + bus.store(at+28,4,bus.load(weight,4));bus.store(at+32,4,bus.load(handling,4));bus.store(at+36,4,bus.load(curve,4));bus.store(at+24,4,1);bus.store(at+48,4,sdk2::vehicle_tag(name)); + if(mask&1U)bus.store(weight,4,std::bit_cast(real(weight)*real(at+8))); + if(mask&2U)bus.store(handling,4,std::bit_cast(real(handling)*real(at+12))); + if(mask&4U) { + const auto source=std::uint32_t(bus.load(curve,4));if(!sdk2::guest(source,56))throw Error("Native acceleration curve is unavailable."); + for(unsigned n=0;n<14;++n){const auto value=real(source+n*4)*real(at+16);if(!std::isfinite(value))throw Error("Native acceleration curve is non-finite.");bus.store(at+64+n*4,4,std::bit_cast(value));}bus.store(curve,4,at+64); + } + } + } +} diff --git a/runtime-recomp/src/game/mods/sdk2_world.inl b/runtime-recomp/src/game/mods/sdk2_world.inl new file mode 100644 index 00000000..cfadc22e --- /dev/null +++ b/runtime-recomp/src/game/mods/sdk2_world.inl @@ -0,0 +1,482 @@ +// Included in the staged GameWorld translation unit, inside its namespace. +// Deterministic SDK state lives in owned writable service regions, so the +// existing mod checkpoint validation, handoff and replay include every byte. +namespace sdk2 { +constexpr unsigned Arena=4096, Header=4096, Handles=4352, Stride=24, Maximum=512; +constexpr unsigned EventHeader=20800,EventRecords=20864,RacerHistory=22000; +constexpr std::uint32_t Magic=0x53444b32; +struct Property {unsigned id;const char* name;const char* unit;unsigned offset,width,type; + bool racer,writable;float minimum,maximum;}; +constexpr Property Properties[]={ +#include "sdk2_properties.inl" +}; +bool guest(std::uint32_t p,unsigned n) {return p>=0x80000400U&&p<=0x80800000U&&n<=0x80800000U-p;} +std::uint32_t property_base(Memory& bus,std::uint32_t object,std::uint32_t racer,const Property& p) { + if(p.racer)return guest(racer,0x224)?racer:0; + if(p.id>=200&&p.id<300){const auto shade=std::uint32_t(bus.load(object+0x54,4));return guest(shade,0x30)?shade:0;} + if(p.id>=300&&p.id<400) { + const auto behaviour=unsigned(bus.load(object+0x48,2)); + // Object_Trigger is shared by mode-change (30), bonus (41), trigger + // (55). Doors/exits have separate state and are not interchangeable. + if(behaviour!=30&&behaviour!=41&&behaviour!=55)return 0; + const auto state=std::uint32_t(bus.load(object+0x64,4));return guest(state,0x18)?state:0; + } + return object; +} +void text(Memory& bus,std::uint32_t p,const char* value,unsigned capacity) { + bus.fill(p,capacity);const auto n=std::min(std::strlen(value),capacity-1); + bus.write(p,View(reinterpret_cast(value),n)); +} +std::uint32_t handle_address(Memory& bus,std::uint32_t services,unsigned slot,unsigned generation) { + if(slot<1||slot>Maximum||!generation)return 0; + const auto entry=services+Handles+(slot-1)*Stride; + if(bus.load(entry+4,4)!=generation||!bus.load(entry+20,4))return 0; + const auto p=std::uint32_t(bus.load(entry,4)); + if(!guest(p,0x80)||bus.load(p+0x40,4)!=bus.load(entry+8,4)||bus.load(p+0x48,2)!=bus.load(entry+12,4))return 0; + if(bus.load(entry+16,4)&&bus.load(p+0x64,4)!=bus.load(entry+16,4))return 0; + return p; +} +} +void GameWorld::sdk2_emit(Memory& bus,std::uint32_t services,unsigned type,unsigned slot,unsigned generation, + unsigned property,unsigned previous,unsigned value,unsigned auxiliary) { + const auto header=services+sdk2::EventHeader; + if(!(bus.load(header,4)&(1U<=32||count>32)throw Error("SDK event queue ownership is invalid."); + if(count==32){const auto lost=std::uint32_t(bus.load(header+12,4));bus.store(header+12,4,lost==UINT32_MAX?lost:lost+1);return;} + const auto at=services+sdk2::EventRecords+((begin+count)%32)*32; + const unsigned words[]={type,unsigned(bus.load(services+sdk2::Header+8,4)),slot,generation,property,previous,value,auxiliary}; + for(unsigned i=0;i<8;++i)bus.store(at+i*4,4,words[i]);bus.store(header+8,4,count+1); +} +bool GameWorld::sdk2_enabled()const { + return sdk2_active_; +} +void GameWorld::sdk2_lifetime(std::string_view function,bool leaving,const Registers& args) { + if(!sdk2_enabled()||!native_bus_)return; + sdk2_vehicle(function,leaving,args); + sdk2_shading(function,leaving,args); + auto& bus=*native_bus_; + if(function=="free_object"&&!leaving)for(const auto& owner:linked().owners)if(owner.package->api==2) { + for(unsigned i=0;iapi==2) { + const auto h=owner.services+sdk2::Header; + if(bus.load(h,4)!=sdk2::Magic)throw Error("SDK service ownership was modified outside its typed adapters."); + if(event==DKR_CODE_EVENT_SCENE_UNLOAD) { + dkr::sdk2::hud_hidden=false; + dkr::sdk2::debug(unsigned(&owner-linked().owners.data()),{}); + const auto generation=std::uint32_t(bus.load(h+4,4)); + if(generation>=0x7fffffffU)throw Error("SDK entity scene generation exhausted; restart the session."); + bus.store(h+4,4,generation+1); + for(unsigned i=0;imodules.contains("overrides2"))sdk2_apply_overrides(unsigned(&owner-linked().owners.data()),0,false); + const bool entities=owner.package->modules.contains("entities2")||owner.package->modules.contains("reflection2")||owner.package->modules.contains("transactions2")|| + owner.package->resources.contains("tools-ui")||bus.load(owner.services+sdk2::EventHeader,4)!=0; + if(!entities)continue; + const auto count=std::uint32_t(bus.load(symbol("gObjectCount"),4)); + const auto list=std::uint32_t(bus.load(symbol("gObjPtrList"),4)); + if(count>sdk2::Maximum||(!sdk2::guest(list,count*4)&&count))throw Error("SDK entity list exceeds its reviewed native bounds."); + std::array observed{}; + std::map existing; + std::set current; + for(unsigned i=0;isecond*sdk2::Stride; + if(bus.load(at+8,4)==header&&bus.load(at+12,4)==behaviour&&(behaviour!=1||bus.load(at+16,4)==bus.load(p+0x64,4)))slot=found->second; + else bus.store(at+20,4,0); + } + if(slot==sdk2::Maximum) { + for(unsigned i=0;iapi!=2||!owner.package->resources.contains("tools-ui")||(!sdk2_pulsing_&&(bus.load(owner.services+sdk2::Header+8,4)&7U)))continue; + auto values=std::make_shared>();values->reserve(512); + for(unsigned slot=1;slot<=512;++slot) { + const auto at=owner.services+sdk2::Handles+(slot-1)*sdk2::Stride; + const auto generation=unsigned(bus.load(at+4,4));const auto obj=sdk2::handle_address(bus,owner.services,slot,generation);if(!obj)continue; + const auto racer=std::uint32_t(bus.load(at+16,4)); + dkr::sdk2::EntityView value{slot,generation,unsigned(bus.load(at+12,4)),unsigned(bus.load(obj+0x4a,2)),racer!=0,{}}; + value.address=obj;value.header=unsigned(bus.load(obj+0x40,4)); + for(const auto& p:sdk2::Properties) { + const auto base=sdk2::property_base(bus,obj,racer,p);if(!base)continue; + auto raw=std::uint32_t(bus.load(base+p.offset,p.width)); + if(p.type==2&&p.width<4)raw=p.width==1?std::uint32_t(std::int32_t(std::int8_t(raw))):std::uint32_t(std::int32_t(std::int16_t(raw))); + value.properties.push_back({p.id,p.type,unsigned(p.writable)|(unsigned(p.racer)<<1),raw,p.name,p.unit}); + } + values->push_back(std::move(value)); + } + dkr::sdk2::inspector(index,std::move(values)); + const auto& metrics=world_.local_metrics();Json records=Json::array(); + for(unsigned package=0;packageid},{"profile",o.package->sdk2_profile},{"resident",o.end-o.begin},{"heap",o.heap_size}, + {"callbacks",cost.callbacks},{"instructions",cost.instructions},{"native_calls",cost.native_calls},{"cpu_ms",double(cost.nanoseconds)/1e6}}); + } + dkr::sdk2::diagnostics(index,{{"packages",std::move(records)},{"resident_total",linked().resident_bytes},{"last_fault",error_}}); + } +} +void GameWorld::sdk2_command(const dkr::sdk2::Command& command) { + std::lock_guard lock(mutex_); + if(!native_bus_) {if(!command.empty())throw Error("SDK command has no bound world.");return;} + if(!command.valid())throw Error("SDK command envelope is invalid."); + auto& bus=*native_bus_; + for(const auto& owner:linked().owners)if(owner.package->api==2)bus.store(owner.services+20480,4,0); + sdk2_command_=!command.empty(); + if(command.empty())return; + dkr::sdk2::observed(command); + if(!sdk2_development_||command.words[0]>linked().owners.size())throw Error("SDK command is not admitted in this development session."); + const auto& owner=linked().owners[command.words[0]-1]; + if(owner.package->api!=2||!owner.package->development)throw Error("SDK command owner has no development permission."); + if(sdk2_document_command(command,command.words[0]-1,bus))return; + bus.store(owner.services+20480,4,1); + for(unsigned i=0;i<16;++i)bus.store(owner.services+20496+i*4,4,command.words[i]); +} +bool GameWorld::sdk2_service(std::string_view name,std::size_t index,Memory& bus,Registers& c,Budget& budget) { + if(!name.starts_with("dkr2_"))return false; + const auto& owner=linked().owners.at(index); + const auto a=std::uint32_t(c.integer[4]),b=std::uint32_t(c.integer[5]),d=std::uint32_t(c.integer[6]); + auto result=[&](int status){c.integer[2]=std::uint64_t(std::int64_t(status));return true;}; + if(owner.package->api!=2)return result(-5); + if(name=="dkr2_setting_apply")return result(sdk2_setting_apply(bus,index,a,b,d,budget)); + if(name=="dkr2_freeze_cpus") { + if(!owner.package->modules.contains("racer-tuning2"))return result(-5); + if(sdk2_phase_!=2)return result(-3);if(owner.package->development&&!sdk2_development_)return result(-4); + if(a>1)return result(-1);bus.store(owner.services+23228,4,a);return result(0); + } + if(name=="dkr2_tune")return result(sdk2_tune(bus,index,a)); + if(name=="dkr2_debug") { + if(!owner.package->modules.contains("presentation2"))return result(-5); + if(b>64||b*88>budget.instructions)return result(-6);budget.instructions-=b*88; + std::vector lines;lines.reserve(b); + for(unsigned i=0;i3||line.view>3)return result(-1); + for(unsigned axis=0;axis<3;++axis){line.a[axis]=std::bit_cast(std::uint32_t(bus.load(at+16+axis*4,4)));line.b[axis]=std::bit_cast(std::uint32_t(bus.load(at+28+axis*4,4)));if(!std::isfinite(line.a[axis])||!std::isfinite(line.b[axis])||std::abs(line.a[axis])>1e6F||std::abs(line.b[axis])>1e6F)return result(-1);} + for(unsigned n=0;n<48;++n){const auto ch=char(bus.load(at+40+n,1));if(!ch)break;if(ch<32||ch>126)return result(-1);line.label+=ch;} + lines.push_back(std::move(line)); + }dkr::sdk2::debug(unsigned(index),std::move(lines));return result(0); + } + if(name.starts_with("dkr2_document_"))return result(sdk2_document(index,bus,name,c,budget)); + if(name=="dkr2_control")return result(sdk2_control(bus,index,a,b)); + if(name=="dkr2_route") { + if(bus.load(b,4)<64)return result(-1); + const auto count=unsigned(bus.load(symbol("gNumberOfTotalCheckpoints"),4)); + const auto main=unsigned(bus.load(symbol("gNumberOfMainCheckpoints"),4)); + const auto nodes=std::uint32_t(bus.load(symbol("gTrackCheckpoints"),4)); + if(count>127||main>count||!sdk2::guest(nodes,count*0x3c))return result(-3); + if(a>=count)return result(1);const auto node=nodes+a*0x3c,obj=std::uint32_t(bus.load(node+0x28,4));bus.fill(b,64); + bus.store(b,4,64);bus.store(b+4,4,a);bus.store(b+8,4,count);bus.store(b+12,4,main); + for(unsigned i=0;i<512;++i){const auto at=owner.services+sdk2::Handles+i*sdk2::Stride;if(bus.load(at+20,4)&&bus.load(at,4)==obj){bus.store(b+16,4,i+1);bus.store(b+20,4,bus.load(at+4,4));break;}} + for(unsigned i=0;i<3;++i){bus.store(b+24+i*4,4,bus.load(node+0x10+i*4,4));bus.store(b+36+i*4,4,bus.load(node+i*4,4));} + bus.store(b+48,4,bus.load(node+0x1c,4));bus.store(b+52,4,bus.load(node+0x20,4));bus.store(b+56,4,std::uint32_t(std::int32_t(std::int8_t(bus.load(node+0x3a,1)))));return result(0); + } + if(name=="dkr2_events") { + if(sdk2_phase_==4)return result(-3); + if(a&~0x800000feU)return result(-1); + bus.store(owner.services+sdk2::EventHeader,4,a);return result(0); + } + if(name=="dkr2_event_next") { + if(sdk2_phase_!=2)return result(-3); + const auto header=owner.services+sdk2::EventHeader; + if(const auto lost=bus.load(header+12,4)) { + bus.fill(a,32);bus.store(a,4,31);bus.store(a+24,4,lost);bus.store(header+12,4,0);return result(0); + } + const unsigned first=unsigned(bus.load(header+4,4)),count=unsigned(bus.load(header+8,4)); + if(first>=32||count>32)throw Error("SDK event queue is invalid."); + if(!count)return result(1); + std::array copy{};bus.read(owner.services+sdk2::EventRecords+first*32,copy);bus.write(a,copy); + bus.store(header+4,4,(first+1)%32);bus.store(header+8,4,count-1);return result(0); + } + if(name=="dkr2_cast")return result(sdk2_cast(bus,a,b,budget)); + if(name=="dkr2_catalogue") { + const auto list=guest_text(bus,a);const auto capacity=std::uint32_t(c.integer[7]);Json item; + if(list=="scene"||list=="segments"||list=="ai-nodes"||list=="spawn-points"||list=="triggers"||list.starts_with("triangles/")) { + if(!owner.package->modules.contains("track-tools2"))return result(-5); + const int status=sdk2_track_catalogue(bus,list,b,item,budget);if(status)return result(status); + }else if(list=="courses") { + if(b>=sdk2_courses_.size())return result(1);item=trophy::course_document(sdk2_courses_[b]); + }else if(list=="packages") { + if(b>=linked().owners.size())return result(1);const auto& p=*linked().owners[b].package; + item={{"id",p.id},{"name",p.name},{"version",p.version},{"digest",p.digest},{"api",p.api},{"development",p.development},{"modules",p.modules},{"systems",p.systems}}; + }else if(list=="cups") { + if(b>=sdk2_cups_.size())return result(1);item=sdk2_cups_[b];item["activation"]="Select in Track Select; cup flow remains owned by the admitted trophy adapter"; + }else if(list=="characters") { + constexpr const char* stock[]={"Krunch","Diddy Kong","Drumstick","Bumper","Banjo","Conker","Tiptup","T.T.","Pipsy","Timber"}; + if(b=sdk2_inventory_.at("characters").size())return result(1);item=sdk2_inventory_.at("characters")[b-std::size(stock)];item["scope"]="admitted-custom";item["activation"]="Scene/model reload required for character changes";} + }else if(list=="character-assets") { + if(!sdk2_assets_.contains("characters")||b>=sdk2_assets_.at("characters").size())return result(1);item=sdk2_assets_.at("characters")[b]; + }else if(list=="models"||list=="textures-3d"||list=="textures-2d"||list=="sprites"||list=="object-templates"||list=="level-models") { + const auto& catalogue=sdk2_assets_.contains("banks")?sdk2_assets_:sdk2_inventory_; + if(!catalogue.contains("banks")||!catalogue.at("banks").contains(list))return result(-5);const auto& bank=catalogue.at("banks").at(list); + if(b>=bank.at("count").get())return result(1);item=bank;item["index"]=b;item["id"]=bank.at("bank").get()+":"+list+":"+std::to_string(b); + item["activation"]="Read-only admitted base-bank resource; native lifecycle rules apply"; + }else if(list=="sounds"||list=="sequences") { + const bool music=list=="sequences";const auto count=unsigned(bus.load(symbol(music?"gSeqSoundCount":"gSoundCount"),4)); + if(b>=count||(music&&b>255))return result(1);item={{"id",list+":"+std::to_string(b)},{"index",b},{"count",count},{"scope","initialized-native-bank"}}; + }else if(list=="hooks") { + if(b>=linked().hooks.size())return result(1);auto it=linked().hooks.begin();std::advance(it,b); + item={{"address",it->first},{"entry",Json::array()},{"exit",Json::array()},{"replacement",nullptr}}; + const auto owner_of=[&](auto address){return linked().owners.at(linked().function_owners.at(address)).package->id;}; + for(auto f:it->second.entry)item["entry"].push_back(owner_of(f));for(auto f:it->second.exit)item["exit"].push_back(owner_of(f)); + if(it->second.replacement)item["replacement"]=owner_of(*it->second.replacement); + }else if(list=="callbacks") { + if(b>=linked().events.size())return result(1);auto it=linked().events.begin();std::advance(it,b);item={{"event",it->first},{"owners",Json::array()}}; + for(auto f:it->second)item["owners"].push_back(linked().owners.at(linked().function_owners.at(f)).package->id); + }else if(list=="settings") { + if(b>=owner.package->options.size())return result(1);item=owner.package->options[b]; + const auto at=owner.services+28672+b*8;const auto tag=bus.load(at,4);const auto bits=std::uint32_t(bus.load(at+4,4)); + item["live"]=tag==1?Json(std::bit_cast(bits)):tag==2?Json(bits):owner.settings.at(item.at("id").get()); + item["overridden"]=tag!=0; + }else if(list=="all-settings") { + unsigned cursor=0;bool found=false; + for(unsigned package=0;packageoptions.size();++option)if(cursor++==b) { + item=target.package->options[option];const auto key=item.at("id").get(); + item["package"]=target.package->id;item["package_index"]=package;item["option_index"]=option;item["api"]=target.package->api; + const auto at=target.services+28672+option*8;const auto tag=target.package->api==2?bus.load(at,4):0;const auto bits=tag?std::uint32_t(bus.load(at+4,4)):0; + item["live"]=tag==1?Json(std::bit_cast(bits)):tag==2?Json(bits):target.settings.at(key);item["overridden"]=tag!=0; + item["live_allowed"]=target.package->api==2&&target.package->modules.contains("live-settings2")&&target.package->sdk2_live_options.contains(key);found=true;break; + } + }if(!found)return result(1); + }else if(list=="audio") { + if(b>1)return result(1);const auto count=unsigned(bus.load(symbol(b?"gSeqSoundCount":"gSoundCount"),4)); + item={{"kind",b?"sequence":"sound"},{"first",0},{"count",count},{"maximum_id",count?count-1:0}}; + }else if(list=="native-operations") { + constexpr unsigned operations[]={1,2,3,4,10,11,20,21,22,23,30,31,32,40};if(b>=std::size(operations))return result(1); + item={{"operation",operations[b]},{"available",sdk2_operation_available(operations[b])}}; + }else if(list=="resources") { + if(b>=owner.package->resources.size())return result(1);auto it=owner.package->resources.begin();std::advance(it,b); + item={{"id",it->first},{"bytes",it->second.size()}}; + }else return result(-5); + const auto encoded=item.dump();if(encoded.size()>4095)return result(-6); + if(d&&capacity) { + if(capacity(encoded.c_str()),encoded.size()+1)); + } + return result(int(encoded.size()+1)); + } + if(name=="dkr2_setting_set"||name=="dkr2_setting_reset") { + if(sdk2_phase_!=2)return result(-3); + if(owner.package->development&&!sdk2_development_)return result(-4); + const auto key=guest_text(bus,a);unsigned option_index=0; + const auto found=std::find_if(owner.package->options.begin(),owner.package->options.end(),[&](const auto& option){return option.at("id")==key;}); + if(found==owner.package->options.end())return result(-5);option_index=unsigned(found-owner.package->options.begin()); + const auto at=owner.services+28672+option_index*8; + if(name=="dkr2_setting_reset"){bus.store(at,4,0);return result(0);} + const auto type=found->at("type").get();const auto raw=std::uint32_t(bus.load(b,4)); + if(type=="Number") { + const auto value=std::bit_cast(raw);if(!std::isfinite(value)||valueat("min").get()||value>found->at("max").get())return result(-1); + }else if(type=="Enum") {if(raw>=found->at("options").size())return result(-1);} + else return result(-8); + bus.store(at+4,4,raw);bus.store(at,4,type=="Number"?1:2);return result(0); + } + if(name=="dkr2_operation_available")return result(sdk2_operation_available(a)?1:0); + if(name=="dkr2_operation") { + std::array arguments{},output{}; + for(unsigned i=0;i<16;++i)arguments[i]=std::uint32_t(bus.load(b+i*4,4)); + if(d){if(downer.end||64>owner.end-d)return result(-1);bus.fill(d,64);} + const int status=sdk2_operation(a,index,bus,arguments,output,budget); + if(d)for(unsigned i=0;i<16;++i)bus.store(d+i*4,4,output[i]);return result(status); + } + if(name=="dkr2_ui_publish") { + if(!owner.package->resources.contains("tools-ui"))return result(-5); + if(b>16384||b>budget.instructions)return result(-6); + const auto tick=std::uint32_t(bus.load(owner.services+sdk2::Header+8,4)); + if((tick&7U)!=0&&!sdk2_pulsing_)return result(1); + Bytes bytes(b);bus.read(a,bytes);budget.instructions-=b; + try { + auto values=parse_document(bytes,16384); + if(!values.is_object()||values.size()>128)return result(-1); + dkr::sdk2::validate_values(parse_document(owner.package->resources.at("tools-ui"),256*1024),values); + if(!online_||sdk2_frame_agreed_)dkr::sdk2::publish(unsigned(index),std::move(values),std::uint32_t(bus.load(owner.services+sdk2::Header+4,4)),tick); + }catch(const std::exception&){return result(-1);} + return result(0); + } + if(name=="dkr2_action_next") { + if(sdk2_phase_!=2)return result(-3); + if(!sdk2_development_||!owner.package->development)return result(-4); + const auto at=owner.services+20480;if(!bus.load(at,4))return result(1); + std::array copy{};bus.read(at+16,copy);bus.write(a,copy);bus.store(at,4,0); + return result(bus.load(at+16+60,4)==bus.load(owner.services+sdk2::Header+4,4)?0:-2); + } + if(name=="dkr2_capability") { + const auto module=guest_text(bus,a); + const auto it=sdk2_modules_.find(module); + c.integer[2]=it==sdk2_modules_.end()?0:it->second;return true; + } + const auto header=owner.services+sdk2::Header; + if(name=="dkr2_session") { + if(bus.load(a,4)<48)return result(-1);bus.fill(a,48); + bus.store(a,4,48);bus.store(a+4,4,2);bus.store(a+8,4,linked().revision=="us.v77"?77:80);bus.store(a+12,4,online_); + bus.store(a+16,4,bus.load(header+4,4));bus.store(a+20,4,bus.load(header+8,4)); + bus.store(a+24,4,bus.load(symbol("gObjectCount"),4));bus.store(a+28,4,bus.load(symbol("gNumRacers"),4)); + bus.store(a+32,4,sdk2_development_);bus.store(a+36,4,sdk2_phase_); + bus.store(a+40,4,budget.instructions);bus.store(a+44,4,budget.native_calls);return result(0); + } + if(name=="dkr2_property") { + if(a>=std::size(sdk2::Properties))return result(1); + if(bus.load(b,4)<96)return result(-1);const auto& p=sdk2::Properties[a];bus.fill(b,96); + bus.store(b,4,96);bus.store(b+4,4,p.id);bus.store(b+8,4,p.type);bus.store(b+12,4,unsigned(p.writable)|(unsigned(p.racer)<<1)); + const auto value=[&](float f){return p.type==1?std::bit_cast(f):std::uint32_t(std::int32_t(f));}; + bus.store(b+16,4,value(p.minimum));bus.store(b+20,4,value(p.maximum));bus.store(b+24,4,p.writable?2:1); + sdk2::text(bus,b+32,p.name,48);sdk2::text(bus,b+80,p.unit,16);return result(0); + } + if(name=="dkr2_entities") { + const bool racers_only=(a&0x80000000U)!=0;const auto first=a&0x7fffffffU; + if(first>512||d>64)return result(-1);unsigned copied=0; + for(unsigned slot=first;slot<512&&copied32)return result(-6); + if(persistent&&!owner.package->modules.contains("overrides2"))return result(-5); + if(persistent&&bus.load(owner.services+38404,4))return result(-3); + if(write&&(sdk2_phase_!=2))return result(-3); + if(write&&owner.package->development&&!sdk2_development_)return result(-4); + struct Write {std::uint32_t address,value;unsigned width;};std::array edits{}; + const unsigned stride=write?16:20; + for(unsigned i=0;iwritable)return result(-5); + const auto raw=std::uint32_t(bus.load(at+12,4)); + const float value=p->type==1?std::bit_cast(raw):p->type==2?float(std::int32_t(raw)):float(raw); + if(!std::isfinite(value)||valueminimum||value>p->maximum)return result(-1); + for(unsigned j=0;joffset)return result(-7); + if(persistent)for(unsigned other=0;otherapi==2&&linked().owners[other].package->modules.contains("overrides2")) { + const auto services=linked().owners[other].services;const auto count=unsigned(bus.load(services+38400,4));if(count>32)throw Error("SDK override count is invalid."); + if(budget.instructionsracer?std::uint32_t(bus.load(target+0x64,4)):0,*p)==obj)return result(-7); + } + } + edits[i]={obj+p->offset,raw,p->width}; + }else { + if(!status) { + auto value=std::uint32_t(bus.load(obj+p->offset,p->width)); + if(p->type==2&&p->width<4)value=p->width==1?std::uint32_t(std::int32_t(std::int8_t(value))):std::uint32_t(std::int32_t(std::int16_t(value))); + bus.store(at+12,4,value); + } + bus.store(at+16,4,std::uint32_t(status)); + } + } + // All target lifetimes, values and duplicate fields were checked before + // the first write. No native calls/allocations occur during commit. + if(write)for(unsigned i=0;i records{};bus.read(a,std::span(records).first(b*16));bus.write(owner.services+38464,std::span(records).first(b*16));bus.store(owner.services+38400,4,b); + } + return result(0); + } + if(name=="dkr2_symbols") { + const auto symbols=linked().revision=="us.v77"?std::span(Symbols77):std::span(Symbols80); + if(a>=symbols.size())return result(1);if(bus.load(b,4)<112)return result(-1); + const auto& s=symbols[a];unsigned flags=s.function?1:0; + const auto entry=std::lower_bound(table_.entries.begin(),table_.entries.end(),s.address,[](const auto& e,auto address){return e.addressaddress==s.address) { + if(!entry->protected_call)flags|=2;if(!entry->protected_hook)flags|=4; + if(entry->protected_function||entry->protected_hook||entry->protected_call)flags|=8; + } + bus.fill(b,112);bus.store(b,4,112);bus.store(b+4,4,s.address);bus.store(b+8,4,s.size);bus.store(b+12,4,flags);sdk2::text(bus,b+16,s.name,96);return result(0); + } + if(name=="dkr2_memory_read") { + if(d>4096||!sdk2::guest(a,d))return result(-1); + std::array bytes{};bus.read(a,std::span(bytes).first(d));bus.write(b,std::span(bytes).first(d));return result(0); + } + return result(-5); +} diff --git a/runtime-recomp/src/game/native_resource_safety.hpp b/runtime-recomp/src/game/native_resource_safety.hpp new file mode 100644 index 00000000..1e4591d8 --- /dev/null +++ b/runtime-recomp/src/game/native_resource_safety.hpp @@ -0,0 +1,32 @@ +#pragma once +// The DKR-owned optional mip extension must validate the underlying native +// allocation, not just its C++ wrapper. No submodule/interface edits required. +#include "plume_vulkan.h" +#if defined(_WIN32) +#include "plume_d3d12.h" +#endif +#include +#include +namespace RT64::GeneratedMips { +inline bool resource_valid(const plume::RenderBuffer* value) { + if(!value)return false; +#if defined(_WIN32) + if(const auto* p=dynamic_cast(value))return p->d3d!=nullptr; +#endif + if(const auto* p=dynamic_cast(value))return p->vk!=VK_NULL_HANDLE; + return true; +} +inline bool resource_valid(const plume::RenderTexture* value) { + if(!value)return false; +#if defined(_WIN32) + if(const auto* p=dynamic_cast(value))return p->d3d!=nullptr; +#endif + if(const auto* p=dynamic_cast(value))return p->vk!=VK_NULL_HANDLE; + return true; +} +inline void upload_failure() { + static std::atomic notices{0}; + if(notices.fetch_add(1,std::memory_order_relaxed)<8) + std::fprintf(stderr,"[texture-packs][upload] native allocation unavailable; PNG replacement skipped safely; original texture retained\n"); +} +} diff --git a/runtime-recomp/src/game/netplay/direct_session.cpp b/runtime-recomp/src/game/netplay/direct_session.cpp index 4c66f1c9..f9f8d8c0 100644 --- a/runtime-recomp/src/game/netplay/direct_session.cpp +++ b/runtime-recomp/src/game/netplay/direct_session.cpp @@ -1,3 +1,13 @@ +#include "../policy.hpp" +#include "../owner_state.hpp" +#include "../departure.hpp" +#include "../start_gate.hpp" +#include "../rollback_check.hpp" +#include "../lobby_adapter.hpp" +#include "../rollback_policy.hpp" +#include "party_link.hpp" +#include "party_codec.hpp" +#include "monocypher.h" #include "direct_session.hpp" #include "experimental_runtime_admission.hpp" #include "experimental_latency.hpp" @@ -195,11 +205,14 @@ DirectSession::~DirectSession() { save_executor_.stop(); replay_writer_.stop(); transport_closer_.stop(); + dkr::runtime::party_start::forget(this); + dkr::runtime::spectators::forget_party_link(this); + dkr::online::native::forget(this); } void DirectSession::configure_manifest(const CompatibilityManifest& manifest) { std::scoped_lock lock(mutex_); - if (state_ == ConnectionState::Offline) manifest_ = manifest; + if (state_ == ConnectionState::Offline) {manifest_ = manifest;dkr::online::compatibility(manifest.release_version);} } void DirectSession::configure_session_save( @@ -358,7 +371,7 @@ bool DirectSession::host(std::uint16_t port, std::string advertised_host, std::string room_name, ConnectionMethod method, std::string player_name, const Rules& rules, std::string& error) { - if (const char* reason = experimental::runtime_admission_error(rules.synchronization, owned_backend_available())) { + if (const char* reason = dkr::runtime::netplay::rollback_policy::admission_error(rules.synchronization, owned_backend_available())) { error = reason; return false; } @@ -488,6 +501,13 @@ bool DirectSession::host(std::uint16_t port, std::string advertised_host, input_delay_ = rules.automatic_input_delay ? 2U : rules.manual_input_delay; room_view_ = lobby_.room(); state_ = ConnectionState::Hosting; + {auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->requested&&method_==ConnectionMethod::QuickJoin&&rules.synchronization==SynchronizationMode::ExperimentalRollback) { + party->enabled=party->host=party->admitted=true;party->local=party->authority=sender_id_;party->room_id=match_id_; + if(!party->party.create(sender_id_,local_name_,error))return false; + party->seed=session_save_;party->save_hash=manifest_.session_save_hash;party->save_generation=session_save_generation_; + party->ready[sender_id_]=false;party->maximum_racers=rules.maximum_players; + }} worker_wake_ = true; state_changed_.notify_all(); if (method_ == ConnectionMethod::QuickJoin) { @@ -511,6 +531,14 @@ bool DirectSession::join_friend_invite(std::string_view invite, std::string player_name, const secure::Key& admission, std::string& error) { + if(valid_quick_join_code(invite)){ + bool watch=false;{auto p=dkr::runtime::spectators::party_link(this);std::scoped_lock lock(p->mutex);watch=p->join_watch;} + if(dkr::online::begin_code_join(this,std::string(invite),player_name,admission,watch)){ + if(!dkr::online::view()->joining){error=dkr::online::view()->error;return false;} + error.clear();return true; + } + } + std::scoped_lock lock(mutex_); local_online_save_ready_ = false; local_online_save_acknowledged_ = false; @@ -631,7 +659,7 @@ bool DirectSession::join_friend_invite(std::string_view invite, host_public_ = quick_join ? secure::Key{} : host_public; invitation_capability_ = quick_join ? secure::Key{} : invitation_capability; client_friend_admission_ = admission; - client_key_pair_ = secure::generate_key_pair(); + client_key_pair_ = dkr::online::client_key(this); sender_id_ = random_u64(); local_name_ = std::move(player_name); local_slot_ = 0U; @@ -660,6 +688,36 @@ bool DirectSession::join_friend_invite(std::string_view invite, void DirectSession::disconnect(std::string_view reason) { std::scoped_lock lock(mutex_); +// disconnect already owns DirectSession::mutex_. No PartyLink lock is retained +// while pumping the authenticated receipt. The final wait is strictly bounded. +{ + namespace leaving=dkr::runtime::party_departure; + bool notify=false; + {auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + notify=party->enabled&&party->admitted&&!is_host_&&bool(host_address_)&&transport_->is_open();} + if(notify){ + leaving::begin(this,sender_id_); + const auto bytes=dkr::runtime::spectators::link_encode(dkr::runtime::spectators::LinkKind::Leave,leaving::notice(1,sender_id_)); + send_with_key(host_address_,key_,static_cast(dkr::runtime::spectators::PartyMessageType),bytes); + const auto deadline=std::chrono::steady_clock::now()+leaving::CloseGrace; + do{ + flush_outbound_locked();pump_locked(); + if(leaving::acknowledged(this))break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + }while(transport_->is_open()&&std::chrono::steady_clock::now()mutex); + if(party->enabled&&transport_->is_open()) { + const auto bytes=dkr::runtime::spectators::link_encode(dkr::runtime::spectators::LinkKind::Leave); + if(is_host_)for(const auto& p:party->peers)send_with_key(p.address,p.key,static_cast(dkr::runtime::spectators::PartyMessageType),bytes); + else if(host_address_)send_with_key(host_address_,key_,static_cast(dkr::runtime::spectators::PartyMessageType),bytes); + flush_outbound_locked(); + } + party->reset_connection();dkr::runtime::party_start::forget(this);} if (transport_->is_open() && state_ != ConnectionState::Offline) { // An empty disconnect used to erase the only useful evidence when a // guest cleared a locally failed session. The host then reported an @@ -908,7 +966,7 @@ void DirectSession::network_loop() { if (transport_->is_open()) pump_locked(); else if ((launch_descriptor_ && launch_descriptor_->synchronization == SynchronizationMode::ExperimentalRollback) || (owned_match_end_ && !owned_match_end_->resumed)) - fail_locked("The experimental connection closed. The game ended; return to Online to reconnect."); + fail_locked("The online connection closed. The game ended; return to Online to reconnect."); } catch (const std::exception& exception) { worker_fault = true; fail_locked(std::string("The online transport failed safely: ") + exception.what()); @@ -957,7 +1015,7 @@ void DirectSession::network_loop() { // the worker immediately; the bounded receive poll remains frequent // enough to service several times within one authored frame. const auto receive_poll = - state_ == ConnectionState::Running || + dkr::runtime::rollback_check::active(this) || state_ == ConnectionState::Running || state_ == ConnectionState::Loading || state_ == ConnectionState::Connecting || state_ == ConnectionState::AwaitingApproval @@ -994,6 +1052,7 @@ void DirectSession::network_loop() { bool DirectSession::set_ready(bool ready, std::string& error) { std::scoped_lock lock(mutex_); + if(dkr::runtime::spectators::party_enabled(this)){dkr::runtime::spectators::party_ready(this,ready);error.clear();return true;} if (owned_match_end_ && !owned_match_end_->resumed) { error = "Wait for every remaining racer to return to the lobby and verify the online save."; return false; @@ -1047,8 +1106,8 @@ bool DirectSession::approve_join_locked(std::uint64_t request_id, error = "Wait for the connection pre-flight check to finish."; return false; } - if (!is_host_ || (state_ != ConnectionState::Hosting && - state_ != ConnectionState::Lobby)) { + if (!is_host_ || ((state_ != ConnectionState::Hosting && + state_ != ConnectionState::Lobby)&&!dkr::runtime::spectators::party_enabled(this))) { error = "Only the host can approve racers while waiting in the lobby."; return false; } @@ -1057,6 +1116,18 @@ bool DirectSession::approve_join_locked(std::uint64_t request_id, error = "That join request has expired."; return false; } + if(auto owner=dkr::online::native::owner(this);owner->guarded){ + const auto* binding=dkr::online::native::binding(*owner,pending->sender_id); + auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock lock(party->mutex); + auto gate=dkr::runtime::party_start::gate(this); + const bool running_watch=binding&&dkr::online::contract::running_spectator(binding->watching,binding->allow_running&&dkr::online::view()->running_spectators,party->enabled,party->running,party->stopping,launch_countdown_active_||launch_stage_!=LaunchStage::Idle); + if(!binding||binding->client!=pending->client_public||binding->privacy!=owner->privacy|| + owner->blocked.contains(binding->account)||lobby_locked_||launch_countdown_active_||launch_stage_!=LaunchStage::Idle|| + gate->phase==dkr::runtime::party_start::Phase::Countdown||gate->phase==dkr::runtime::party_start::Phase::Committing|| + (party->launch&&!running_watch)||party->stopping||dkr::runtime::rollback_check::active(this)){ + error="This authenticated request is no longer valid for the waiting lobby.";return false; + } + } if (!manifest_.mod_manifest_hash.empty() && !pending->mods_verified) { if (!mod_manifest_bytes_) { error = "The host's immutable mod offer has not been prepared."; return false; @@ -1064,7 +1135,7 @@ bool DirectSession::approve_join_locked(std::uint64_t request_id, if (!pending->mods_approved) { const auto reserved=std::count_if(pending_joins_.begin(),pending_joins_.end(), [](const auto& p){return p.active && p.mods_approved;}); - if (occupied_players()+reserved>=room_view_.rules.maximum_players) { + if (!dkr::runtime::spectators::party_enabled(this)&&occupied_players()+reserved>=room_view_.rules.maximum_players) { error = "This lobby has no unreserved racer slot."; return false; } pending->mods_approved=true; @@ -1075,6 +1146,29 @@ bool DirectSession::approve_join_locked(std::uint64_t request_id, status_=pending->display_name+" is reviewing/preparing the host's mods."; error.clear(); return true; } +// Inside approve_join_locked, after provisional mod/content proof succeeds. +if(auto party=dkr::runtime::spectators::party_link(this);party->enabled) { + namespace watch=dkr::runtime::spectators; + std::scoped_lock party_lock(party->mutex); + const auto kind=party->pending_watch.find(pending->sender_id); + if(kind==party->pending_watch.end()){error="Use this build's Racer or Watch Race join option.";return false;} + if(party->launch&&!kind->second){error="The race is running. Join as a spectator, then request a racing slot.";return false;} + if(!save_sync_available()||session_save_.size()!=512){error="The host's online save is not ready.";return false;} + std::optional port; + if(!kind->second){for(unsigned i=0;i(4,room_view_.rules.maximum_players);++i)if(!party->party.snapshot().controls[i].member){port=std::uint8_t(i);break;} + if(!port){error="All four racing places are occupied. Join as a spectator instead.";return false;}} + if(!party->party.add(pending->sender_id,pending->display_name,port,error))return false; + party->peers.push_back({pending->sender_id,pending->address,pending->key,pending->next_sequence,std::chrono::steady_clock::now()}); + party->ready[pending->sender_id]=false; + if(!pending->mod_route_retained)transport_->retain_peer_route(pending->address); + const auto accepted=protocol::encode_hello_ack({true,0,"Joined the lobby",session_save_,{},session_save_generation_,manifest_.session_save_hash}); + const auto response=watch::link_encode(watch::LinkKind::Accepted,accepted); + send_with_key(pending->address,pending->key,static_cast(watch::PartyMessageType),response); + party->pending_watch.erase(pending->sender_id);party->advertised={}; + status_=pending->display_name+(port?" joined as a racer.":" joined to watch."); + *pending={};error.clear();return true; +} + const std::string peer_id = std::to_string(pending->sender_id); CompatibilityManifest synchronized_manifest = pending->manifest; const bool requires_save_sync = true; @@ -1191,9 +1285,11 @@ bool DirectSession::reject_join(std::uint64_t request_id, protocol::MessageType::HelloAck, protocol::encode_hello_ack({false, 0U, block_for_session ? "The host blocked this request for the session." - : "The host declined this join request."})); + : dkr::lobby_stability::decline_reason(this,request_id)})); if (block_for_session) { blocked_senders_.insert(pending->sender_id); + auto directory_owner=dkr::online::native::owner(this); + if(auto* binding=dkr::online::native::binding(*directory_owner,pending->sender_id))directory_owner->blocked.insert(binding->account); const std::string source_host = DatagramSocket::describe_host(pending->address); if (!source_host.empty()) blocked_sources_.insert(source_host); } @@ -1211,7 +1307,7 @@ bool DirectSession::set_lobby_locked(bool locked, std::string& error) { return false; } if (!is_host_ || (state_ != ConnectionState::Hosting && - state_ != ConnectionState::Lobby)) { + state_ != ConnectionState::Lobby && !dkr::runtime::spectators::party_enabled(this))) { error = "Only the host can lock the lobby before the race starts."; return false; } @@ -1307,11 +1403,12 @@ bool DirectSession::revoke_invitation(std::string& error) { bool DirectSession::request_start(std::string& error) { std::scoped_lock lock(mutex_); + if(dkr::runtime::spectators::party_enabled(this)){if(!is_host_){error="Only the host can start.";return false;}dkr::runtime::spectators::party_start(this);error.clear();return true;} if (owned_match_end_ && !owned_match_end_->resumed) { error = "Wait for every remaining racer to return to the lobby and verify the online save."; return false; } - if (const char* reason = experimental::runtime_admission_error(lobby_.room().rules.synchronization, owned_backend_available_)) { + if (const char* reason = dkr::runtime::netplay::rollback_policy::admission_error(lobby_.room().rules.synchronization, owned_backend_available_)) { error = reason; return false; } @@ -1391,62 +1488,14 @@ void DirectSession::begin_countdown_locked() { bool DirectSession::request_connection_test(std::string& error) { std::scoped_lock lock(mutex_); - if (pending_invitation_) { - error = "Wait for the code replacement to finish before testing."; - return false; - } - if (!is_host_ || (state_ != ConnectionState::Hosting && - state_ != ConnectionState::Lobby)) { - error = "Only Player 1 can run a connection pre-flight check from the lobby."; - return false; - } - if (connection_test_active_ || launch_stage_ != LaunchStage::Idle) { - error = "A synchronized lobby operation is already running."; - return false; - } - if (occupied_players() < 2U || occupied_players() > kSupportedOnlinePlayers) { - error = "At least two racers must be in the lobby before testing the session."; - return false; - } - constexpr std::array required_traffic{{ - TransportTrafficClass::Checkpoint, - TransportTrafficClass::Control, - TransportTrafficClass::Authoritative, - TransportTrafficClass::Realtime, - TransportTrafficClass::Replica}}; - for (const PeerRecord& peer : peers_) { - if (!peer.active) continue; - for (const TransportTrafficClass traffic : required_traffic) { - if (transport_->traffic_ready(peer.address, traffic)) continue; - error = "A racer's network lanes are still warming up. Try the test again in a moment."; - return false; - } - } - - reset_connection_test_locked(); - connection_test_results_ = {}; - connection_test_active_ = true; - connection_test_id_ = next_connection_test_id_++; - if (connection_test_id_ == 0U) connection_test_id_ = next_connection_test_id_++; - if (next_connection_test_id_ == 0U) next_connection_test_id_ = 1U; - const auto now = std::chrono::steady_clock::now(); - connection_test_started_ = now; - connection_test_measurement_end_ = now + std::chrono::seconds(6); - connection_test_drain_end_ = connection_test_measurement_end_ + - std::chrono::seconds(1); - connection_test_queue_failures_at_start_ = transport_queue_failures_; - for (std::uint32_t& sequence : connection_test_next_sequence_) sequence = 1U; - broadcast(protocol::MessageType::PreflightBegin, - protocol::encode_preflight_begin( - {connection_test_id_, 7000U})); - status_ = "Connection pre-flight is applying representative online traffic."; - worker_wake_ = true; - state_changed_.notify_all(); - error.clear(); - return true; +#include "../owner_context.inl" + const bool ok=dkr::runtime::rollback_check::request(this,rollback_context(),error); + if(ok){worker_wake_=true;state_changed_.notify_all();} + return ok; } void DirectSession::reset_connection_test_locked() { + dkr::runtime::rollback_check::forget(this); if (connection_test_id_) terminal_connection_test_id_ = connection_test_id_; connection_test_active_ = false; connection_test_draining_ = false; @@ -1468,183 +1517,9 @@ void DirectSession::reset_connection_test_locked() { connection_test_result_last_send_ = {}; } -void DirectSession::service_connection_test_locked( - std::chrono::steady_clock::time_point now) { - if (!connection_test_active_ || !is_host_) { - if (connection_test_active_ && !is_host_ && now >= connection_test_drain_end_) { - terminal_connection_test_id_ = connection_test_id_; - connection_test_active_ = false; - ++connection_test_result_generation_; - status_ = "Connection pre-flight was inconclusive: final results did not arrive in time. You can retry the test or change Ready."; - } - if (is_host_ && !connection_test_active_ && now < connection_test_result_retry_until_ && - now - connection_test_result_last_send_ >= std::chrono::milliseconds(500)) { - for (const auto& peer : peers_) { - if (!peer.active) continue; - for (const auto& result : connection_test_results_) { - if (!result.valid || (connection_test_result_acks_[peer.slot] & (1U << result.player_slot))) continue; - send_to(peer.address, protocol::MessageType::PreflightResult, - protocol::encode_preflight_result({connection_test_id_, result.player_slot, result.score, - result.p95_rtt_ms, result.jitter_ms, - static_cast(result.loss_percent * 10.0F), - static_cast(result.late_percent * 10.0F), result.queues_drained})); - } - } - connection_test_result_last_send_ = now; - } - return; - } - - constexpr std::array message_types{{ - protocol::MessageType::PreflightControlProbe, - protocol::MessageType::PreflightAuthorityProbe, - protocol::MessageType::PreflightRealtimeProbe, - protocol::MessageType::PreflightReplicaProbe, - protocol::MessageType::PreflightCheckpointProbe}}; - constexpr std::array intervals{{ - std::chrono::milliseconds(100), std::chrono::milliseconds(33), - std::chrono::milliseconds(33), std::chrono::milliseconds(200), - std::chrono::milliseconds(100)}}; - constexpr std::array padding_sizes{{ - 16U, 192U, 24U, 880U, 880U}}; - - if (now < connection_test_measurement_end_) { - for (std::size_t lane = 0U; lane < message_types.size(); ++lane) { - if (connection_test_last_send_[lane].time_since_epoch().count() != 0 && - now - connection_test_last_send_[lane] < intervals[lane]) { - continue; - } - std::uint32_t sequence = connection_test_next_sequence_[lane]++; - if (sequence == 0U) sequence = connection_test_next_sequence_[lane]++; - if (connection_test_next_sequence_[lane] == 0U) { - connection_test_next_sequence_[lane] = 1U; - } - protocol::PreflightProbePayload probe{ - connection_test_id_, sequence, steady_microseconds(), false, - std::vector(padding_sizes[lane], - static_cast(lane + 1U))}; - const auto payload = protocol::encode_preflight_probe(probe); - for (const PeerRecord& peer : peers_) { - if (!peer.active) continue; - if (send_to(peer.address, message_types[lane], payload)) { - ++connection_test_sent_[peer.slot][lane]; - } - } - connection_test_last_send_[lane] = now; - } - return; - } - - if (!connection_test_draining_) { - connection_test_draining_ = true; - status_ = "Connection pre-flight is draining and measuring its queues."; - } - if (now < connection_test_drain_end_) return; - - connection_test_results_ = {}; - connection_test_results_[local_slot_] = { - true, local_slot_, 10U, 0U, 0U, 0.0F, 0.0F, true}; - int overall_score = 10; - for (const PeerRecord& peer : peers_) { - if (!peer.active) continue; - std::vector all_rtt; - std::uint64_t total_sent = 0U; - std::uint64_t total_received = 0U; - std::uint64_t late_samples = 0U; - double jitter_sum = 0.0; - std::uint64_t jitter_samples = 0U; - for (std::size_t lane = 0U; lane < connection_test_rtt_samples_[peer.slot].size(); - ++lane) { - total_sent += connection_test_sent_[peer.slot][lane]; - total_received += connection_test_received_[peer.slot][lane]; - const auto& samples = connection_test_rtt_samples_[peer.slot][lane]; - all_rtt.insert(all_rtt.end(), samples.begin(), samples.end()); - for (std::size_t sample = 0U; sample < samples.size(); ++sample) { - if (samples[sample] > 100.0) ++late_samples; - if (sample != 0U) { - jitter_sum += std::abs(samples[sample] - samples[sample - 1U]); - ++jitter_samples; - } - } - } - std::sort(all_rtt.begin(), all_rtt.end()); - const double p95 = all_rtt.empty() - ? 65535.0 - : all_rtt[static_cast(std::ceil( - static_cast(all_rtt.size()) * 0.95)) - 1U]; - const double jitter = jitter_samples == 0U - ? 0.0 : jitter_sum / static_cast(jitter_samples); - const double loss = total_sent == 0U - ? 100.0 - : static_cast(total_sent - (std::min)(total_sent, total_received)) * - 100.0 / static_cast(total_sent); - const double late = all_rtt.empty() - ? 100.0 - : static_cast(late_samples) * 100.0 / - static_cast(all_rtt.size()); - const bool queues_drained = - critical_outbound_.empty() && repair_outbound_.empty() && - high_priority_outbound_.empty() && commit_outbound_.empty() && - authority_outbound_.empty() && normal_priority_outbound_.empty() && - bulk_outbound_.empty() && - transport_->buffered_bytes(TransportTrafficClass::Control) < 4096U && - transport_->buffered_bytes(TransportTrafficClass::Authoritative) < 4096U && - transport_->buffered_bytes(TransportTrafficClass::Checkpoint) < 4096U && - transport_->buffered_bytes(TransportTrafficClass::Realtime) < 4096U && - transport_->buffered_bytes(TransportTrafficClass::Replica) < 4096U && - transport_queue_failures_ == connection_test_queue_failures_at_start_; - - int score = 10; - if (p95 > 60.0) --score; - if (p95 > 100.0) score -= 2; - if (p95 > 180.0) score -= 2; - if (jitter > 8.0) --score; - if (jitter > 12.0) --score; - if (jitter > 30.0) --score; - if (loss > 0.2) --score; - if (loss > 0.5) --score; - if (loss > 2.0) --score; - if (late > 2.0) --score; - if (late > 8.0) --score; - if (!queues_drained) score -= 3; - score = std::clamp(score, 1, 10); - - overall_score = (std::min)(overall_score, score); - ConnectionTestResultView result{ - true, peer.slot, static_cast(score), - static_cast(std::clamp(p95, 0.0, 65535.0)), - static_cast(std::clamp(jitter, 0.0, 65535.0)), - static_cast(loss), static_cast(late), queues_drained}; - connection_test_results_[peer.slot] = result; - } - ++connection_test_result_generation_; - if (connection_test_result_generation_ == 0U) { - ++connection_test_result_generation_; - } - for (const ConnectionTestResultView& result : connection_test_results_) { - if (!result.valid) continue; - const auto payload = protocol::encode_preflight_result({ - connection_test_id_, result.player_slot, result.score, - result.p95_rtt_ms, result.jitter_ms, - static_cast(std::clamp( - std::lround(result.loss_percent * 10.0F), 0L, 1000L)), - static_cast(std::clamp( - std::lround(result.late_percent * 10.0F), 0L, 1000L)), - result.queues_drained}); - for (int copy = 0; copy < 3; ++copy) { - broadcast(protocol::MessageType::PreflightResult, payload); - } - } - connection_test_active_ = false; - connection_test_draining_ = false; - terminal_connection_test_id_ = connection_test_id_; - connection_test_result_retry_until_ = now + std::chrono::seconds(15); - status_ = overall_score >= 8 - ? "Connection pre-flight passed in the green band." - : overall_score >= 5 - ? "Connection pre-flight completed in the orange band." - : "Connection pre-flight found a red-band route."; - state_changed_.notify_all(); +void DirectSession::service_connection_test_locked(std::chrono::steady_clock::time_point) { + // Superseded by the isolated Rollback diagnostic serviced before the + // spectator-capable lobby's early return. Legacy scores are never run. } bool DirectSession::all_active_players_acknowledged_locked( @@ -2043,6 +1918,7 @@ void DirectSession::send_loaded_locked() { void DirectSession::fail_runtime_start(std::string reason) { std::scoped_lock lock(mutex_); + if(dkr::runtime::spectators::party_enabled(this)){dkr::runtime::spectators::party_stop(this,std::move(reason));return;} fail_locked(std::move(reason)); } @@ -3373,7 +3249,7 @@ bool DirectSession::receive_owned_packet_locked(std::uint8_t source, if(launch_hash!=launch_descriptor_hash(*launch_descriptor_))return true; const auto payload_size=packet.payload.size()-8U; if (owned_inbound_.size() >= 256 || owned_inbound_bytes_ + payload_size > 2U*1024U*1024U) { - fail_locked("The experimental packet consumer stopped servicing its bounded receive queue."); + fail_locked("The Rollback packet consumer stopped servicing its bounded receive queue."); return true; } const auto traffic = type == protocol::MessageType::ExperimentalInput ? TransportTrafficClass::Realtime @@ -4883,6 +4759,7 @@ SessionView DirectSession::view_locked() const { last_verified_frame_, last_authoritative_frame_, authoritative_corrections_, input_delay_, method_, match_id_, lobby_locked_}; + if(auto owner=dkr::online::native::owner(this);is_host_&&owner->guarded)result.room.visibility=owner->public_room?Visibility::Public:Visibility::Private; result.owned_match_ending = owned_match_end_ && !owned_match_end_->resumed; result.input_stalls = input_stalls_; result.longest_input_stall_ms = longest_input_stall_ms_; @@ -5069,6 +4946,17 @@ SessionView DirectSession::view_locked() const { result.network_jitter_ms = local.jitter_ms; result.network_loss_percent = local.packet_loss_percent; } + {auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->enabled){result.room=room_view_;result.room.players={};result.room.generation=party->party.snapshot().generation; + result.room.phase=party->launch&&!party->stopping?RoomPhase::Running:RoomPhase::Waiting; + for(const auto& m:party->party.snapshot().members)if(m.port){auto& p=result.room.players[*m.port]; + p.occupied=p.compatible=true;p.host=m.host;p.slot=*m.port;p.peer_id=std::to_string(m.id);p.display_name=m.name; + p.ready=party->ready.contains(m.id)&&party->ready.at(m.id);p.loaded=party->running;result.online_save_ready[*m.port]=true;} + if(!party->status.empty())result.status=party->status; + result.network_rtt_ms=party->rtt_ms; + if(is_host_)for(const auto& peer:party->peers)if(const auto* m=party->party.find(peer.id);m&&m->role==dkr::runtime::spectators::Role::Racer&&peer.rtt_count) + for(unsigned i=0;imutex); + if(auto* p=party->peer(address);p&&p->key==key)return p->sequence;} for (PendingRecord& pending : pending_joins_) { if (pending.active && pending.address == address && pending.key == key) { return pending.next_sequence; @@ -5768,7 +5659,11 @@ std::uint64_t& DirectSession::outbound_sequence( bool DirectSession::send_join_request() { const protocol::HelloPayload hello{local_name_, manifest_, client_friend_admission_}; - const auto payload = protocol::encode_hello(hello); + auto payload = protocol::encode_hello(hello); + {auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->requested){const std::array marker{'D','K','R','P','T','Y',1,std::uint8_t(party->join_watch)}; + payload.insert(payload.begin(),marker.begin(),marker.end());}} + secure::Key peer_key{}; const auto encrypted = secure::seal_join_request( payload, client_key_pair_.secret, client_key_pair_.public_key, @@ -5788,8 +5683,55 @@ void DirectSession::broadcast(protocol::MessageType type, } void DirectSession::pump_locked() { +#include "../owner_pump.inl" +#include "../owner_context.inl" +#include "../owner_send.inl" + +// This owner-only path is shared by authenticated leave/disconnect notices, +// current transport closure and the heartbeat watchdog. Spectators never own +// a racing port. A lost racer must stop the old control roster, not simulate on. +auto retire_party_member=[&](std::uint64_t id,const char* cause){ + namespace watch=dkr::runtime::spectators; + auto party=watch::party_link(this);std::scoped_lock party_lock(party->mutex); + if(!is_host_||!party->enabled||id==party->local)return false; + const auto* member=party->party.find(id);auto* peer=party->peer(id); + if(!member||!peer)return false; + const auto name=member->name;const auto address=peer->address; + const bool racer=member->role==watch::Role::Racer,live=racer&&bool(party->launch); + std::string reason; + if(!party->party.remove(id,reason)){ + party->stopping=party->stop_requested=true;party->running=false; + party->status="The disconnected member could not be retired safely: "+reason;return true; + } + party->ready.erase(id);party->pending_watch.erase(id); + std::erase_if(party->swap_requests,[&](const auto& item){return item.second==id;}); + if(party->requested_swap&&party->requested_swap->second==id)party->requested_swap.reset(); + if(party->approved_swap&&party->approved_swap->second==id)party->approved_swap.reset(); + for(auto& pending:party->outgoing)std::erase_if(pending,[&](const auto& item){ + if(item.target!=id)return false;party->outgoing_bytes-=item.bytes.size();return true; + }); + std::erase_if(party->incoming,[&](const auto& item){ + if(item.source!=id)return false;party->incoming_bytes-=item.bytes.size();return true; + }); + for(auto& item:party->peers)if(item.id==id)crypto_wipe(item.key.data(),item.key.size()); + std::erase_if(party->peers,[&](const auto& item){return item.id==id;}); + transport_->release_peer_route(address);party->advertised={}; + if(live){party->stopping=party->stop_requested=true;party->running=false; + party->status=name+" disconnected. Returning safely to the lobby.";} + else party->status=name+(racer?" left the lobby.":" stopped spectating."); + status_=party->status; + std::fprintf(stderr,"[lobby][departure] role=%s live=%u cause=%s; roster retired\n",racer?"racer":"spectator",unsigned(live),cause); + return true; +}; + PeerAddress departed{}; while(transport_->take_peer_departure(departed)) { + {auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->enabled){ + if(is_host_){if(const auto* member=party->peer(departed))if(retire_party_member(member->id,"transport closed"))continue;} + else if(departed==host_address_){fail_locked("The host disconnected. The lobby has closed.");return;} + }} + if(is_host_)for(auto& pending:pending_joins_)if(pending.active && pending.mod_route_retained && pending.address==departed) { clear_pending_mod_route_locked(pending);pending={}; } @@ -5878,9 +5820,11 @@ void DirectSession::pump_locked() { PeerRecord* peer = peer_by_sender(packet_sender); if (peer && peer->address==source) receive_key = &peer->key; else { - auto* pending=pending_by_sender(packet_sender); - if(!pending || !pending->mods_approved || !(pending->address==source))continue; - receive_key=&pending->key;provisional_mod_peer=true; + auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(auto* member=party->peer(packet_sender);member&&member->address==source)receive_key=&member->key; + else {auto* pending=pending_by_sender(packet_sender); + if(!pending || !pending->mods_approved || !(pending->address==source))continue; + receive_key=&pending->key;provisional_mod_peer=true;} } } else if (packet_sender != host_sender_id_) { continue; @@ -5904,10 +5848,257 @@ void DirectSession::pump_locked() { // per traffic lane while retaining one globally unique encryption // sequence/nonce on the sender. auto& receive_windows = received_sequences_[packet_sender]; - if (!receive_windows[receive_sequence_lane(packet.header.type)] + if(unsigned(packet.header.type)==dkr::runtime::spectators::PartyMessageType&&packet.header.frame>=5)continue; + const auto sequence_lane=unsigned(packet.header.type)==dkr::runtime::spectators::PartyMessageType?packet.header.frame:receive_sequence_lane(packet.header.type); + if (!receive_windows[sequence_lane] .accept(packet_sequence)) { continue; } + // Only the authenticated host's matching receipt is consumed during + // local teardown. Delayed launch/Room packets cannot re-arm the game. + if(dkr::runtime::party_departure::waiting(this)){ + dkr::runtime::spectators::LinkMessage reply; + if(packet_sender==host_sender_id_&&source==host_address_&& + unsigned(packet.header.type)==dkr::runtime::spectators::PartyMessageType&& + dkr::runtime::spectators::link_decode(packet.payload,reply)&& + reply.kind==dkr::runtime::spectators::LinkKind::Leave&& + dkr::runtime::party_departure::notice(reply.body,2,sender_id_)) + dkr::runtime::party_departure::acknowledge(this,sender_id_); + continue; + } + // The generic Disconnect handler only knows the old four-port peer + // table. An admitted party member has its own authenticated identity. + if(packet.header.type==protocol::MessageType::Disconnect){ + auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->enabled){ + if(is_host_)retire_party_member(packet_sender,"disconnect notice"); + else if(packet_sender==host_sender_id_&&source==host_address_){fail_locked("The host disconnected. The lobby has closed.");return;} + continue; + } + } + if(dkr::runtime::rollback_check::diagnostic_type(packet.header.type)) { + // Already decrypted, admitted and replay-window validated above. + bool approved=!is_host_&&packet_sender==host_sender_id_&&source==host_address_; + if(is_host_){ + const auto p=dkr::runtime::spectators::party_link(this);std::scoped_lock lock(p->mutex); + if(p->enabled){const auto* peer=p->peer(packet_sender);approved=peer&&peer->address==source;} + else{const auto* peer=peer_by_sender(packet_sender);approved=peer&&peer->active&&peer->address==source;} + } + if(approved)dkr::runtime::rollback_check::receive(this,rollback_context(),packet_sender,packet.header.type,packet.payload,rollback_send,std::chrono::steady_clock::now()); + continue; // Never enter legacy preflight or real game-input handlers. + } +// Inside DirectSession::pump_locked, after AEAD and replay-window validation. +if (unsigned(packet.header.type)==dkr::runtime::spectators::PartyMessageType) { + if(state_==ConnectionState::Failed||state_==ConnectionState::Offline)continue; + namespace watch=dkr::runtime::spectators; + auto party=watch::party_link(this);std::scoped_lock party_lock(party->mutex); + watch::LinkMessage message; + if(!watch::link_decode(packet.payload,message))continue; + if(is_host_) { + auto* member=party->peer(packet_sender); + if(!party->enabled||!member||member->address!=source)continue; + const auto now=std::chrono::steady_clock::now(); + const auto* identity=party->party.find(packet_sender); + const bool observer=identity&&identity->role==watch::Role::Spectator; + const std::size_t token_cap=observer?16*1024:128*1024,token_rate=observer?16:128; + member->received_tokens=std::min(member->received_tokens,token_cap); + if(member->rate_at.time_since_epoch().count()) { + const auto credit=std::chrono::duration_cast(now-member->rate_at).count(); + if(credit>0){member->received_tokens=std::min(token_cap,member->received_tokens+std::size_t(credit)*token_rate);member->rate_at=now;} + } else member->rate_at=now; + if(packet.payload.size()>member->received_tokens)continue; + member->received_tokens-=packet.payload.size();member->heard=now; + switch(message.kind) { + case watch::LinkKind::Runtime: + if(observer) { + // Watchers can acknowledge playback or request a seat, never + // supply racing input, membership, scene votes or host state. + if(message.body.size()<21||message.body.size()>128)break; + const auto kind=message.body[4]; + if(kind!=1&&kind!=4&&kind!=13&&kind!=15&&kind!=16&&kind!=17)break; + } + party->admit_incoming(packet_sender,message.body);break; + case watch::LinkKind::Heartbeat: { + std::uint64_t token=0;bool reply=false; + if(!watch::link_heartbeat(message.body,token,reply))break; + if(!reply)party->enqueue({packet_sender,watch::LinkKind::Heartbeat,TransportTrafficClass::Control,watch::link_heartbeat(token,true)}); + else if(token==member->ping_token&&member->ping_sent.time_since_epoch().count()) { + const auto ms=std::chrono::duration_cast(now-member->ping_sent).count(); + if(ms>=0&&ms<5000){member->rtts[member->rtt_index++%member->rtts.size()]=std::uint16_t(std::max(1,ms));member->rtt_count=std::min(member->rtt_count+1,unsigned(member->rtts.size()));} + member->ping_token=0; + }break; + } + case watch::LinkKind::Start: { + auto gate=dkr::runtime::party_start::gate(this); + const auto receipt=dkr::runtime::party_start::receipt(message.body,*gate); + if(!party->participant(packet_sender)||!gate->launch||gate->roster!=party->party.snapshot().generation)break; + if(receipt==1&&gate->phase==dkr::runtime::party_start::Phase::Countdown)gate->prepared.insert(packet_sender); + if(receipt==2&&gate->phase==dkr::runtime::party_start::Phase::Committing)gate->committed.insert(packet_sender); + break; + } + case watch::LinkKind::Ready: + if(!party->launch&&!party->stopping&&message.body.size()==1&&message.body[0]<=1&&party->participant(packet_sender)) + party->ready[packet_sender]=message.body[0]!=0; + break; + case watch::LinkKind::Stop: + if(party->participant(packet_sender)){party->stop_requested=true;party->status="A racer returned to the lobby.";} + break; + case watch::LinkKind::Returned: + if(party->launch&&party->participant(packet_sender)){ + party->stopping=party->stop_requested=true;party->running=false; + party->status=(identity?identity->name:std::string("A racer"))+" returned to the lobby. The match has ended."; + std::fprintf(stderr,"[lobby][departure] racer returned; match retirement requested\n"); + } + break; + case watch::LinkKind::Leave: { + const bool receipt=dkr::runtime::party_departure::notice(message.body,1,packet_sender); + if(!message.body.empty()&&!receipt)break; + if(receipt){ + const auto bytes=watch::link_encode(watch::LinkKind::Leave,dkr::runtime::party_departure::notice(2,packet_sender)); + send_with_key(member->address,member->key,static_cast(watch::PartyMessageType),bytes); + flush_outbound_locked(); + } + retire_party_member(packet_sender,"leave notice");break; + } + default:break; // No client can publish membership, a launch or a checkpoint as host. + } + } else { + if(packet_sender!=host_sender_id_||source!=host_address_)continue; + last_admission_response_=std::chrono::steady_clock::now(); + if(message.kind==watch::LinkKind::Accepted) { + protocol::HelloAckPayload accepted;std::string reason; + if(!party->requested||!protocol::decode_hello_ack(message.body,accepted,reason)||!accepted.accepted|| + accepted.synchronized_save.size()!=512|| + stable_hash(std::string_view(reinterpret_cast(accepted.synchronized_save.data()),512))!=accepted.online_save_hash|| + !accepted.online_save_generation||!accepted.online_save_hash)continue; + if(party->enabled&&party->admitted){ + if(accepted.online_save_generationlaunch&&!party->stopping){fail_locked("The host changed the lobby save during a running match.");continue;} + } + party->enabled=party->admitted=true;party->host=false;party->local=sender_id_;party->authority=host_sender_id_; + party->room_id=match_id_;party->seed=accepted.synchronized_save;party->save_generation=accepted.online_save_generation;party->save_hash=accepted.online_save_hash; + party->status="Joined the lobby";state_=ConnectionState::Lobby;local_slot_=255; + std::fprintf(stderr,"[lobby][admission] party accepted; awaiting authenticated roster and settings\n"); + local_online_save_ready_=local_online_save_acknowledged_=true; + session_save_generation_=accepted.online_save_generation;manifest_.session_save_hash=accepted.online_save_hash; + room_view_.manifest.session_save_hash=accepted.online_save_hash; + transport_->retain_peer_route(host_address_); + } else if(party->enabled&&party->admitted) { + if(message.kind==watch::LinkKind::Roster) { + watch::PartySnapshot roster;std::string reason; + if(watch::wire::roster(message.body,roster,reason)&&roster.host==host_sender_id_&& + std::any_of(roster.members.begin(),roster.members.end(),[&](const auto& m){return m.id==sender_id_;})&& + party->party.replace_verified(std::move(roster),reason)) { + const auto* local=party->party.find(sender_id_);local_slot_=local&&local->port?*local->port:255; + } + } else if(message.kind==watch::LinkKind::Room) { + protocol::LobbyStatePayload room;std::string reason; + if(protocol::decode_lobby_state(message.body,room,reason)&&room.generation==party->party.snapshot().generation&&room.generation>=room_view_.generation) { + const bool changed=room_view_.generation!=room.generation||room_view_.rules!=room.rules; + apply_lobby_payload(room); + // Party generation names membership, not Ready or automatic + // input-delay updates. Apply validated current metadata too. + room_view_.rules=room.rules;room_view_.phase=room.phase;input_delay_=room.input_delay_frames; + if(changed)std::fprintf(stderr,"[lobby][room] synchronized generation=%llu mode=Rollback delay=%u window=%u\n", + static_cast(room.generation),unsigned(input_delay_),unsigned(room.rules.rollback_window)); + for(const auto& m:party->party.snapshot().members)if(m.port)party->ready[m.id]=room.players[*m.port].ready; + } else if(reason==dkr::runtime::netplay::rollback_policy::unsupported_lobby) fail_locked(reason); + } else if(message.kind==watch::LinkKind::Launch) { +protocol::StartPayload start;std::string reason; +auto gate=dkr::runtime::party_start::gate(this); +using StartPhase=dkr::runtime::party_start::Phase; +if(!protocol::decode_start(message.body,start,reason)){ + party->status="The host sent an invalid starting grid. Please recreate the lobby."; + std::fprintf(stderr,"[lobby][start-reject] invalid descriptor: %s\n",reason.c_str()); +}else{ + const auto& launch=start.descriptor; + const auto& roster=party->party.snapshot(); + unsigned count=0;std::uint8_t mask=0; + for(const auto& m:roster.members)if(m.role==watch::Role::Racer&&m.port){++count;mask|=std::uint8_t(1U<<*m.port);} + const bool valid=launch.match_id==match_id_&&launch.synchronization==SynchronizationMode::ExperimentalRollback&& + launch.compatibility_hash==manifest_hash(room_view_.manifest)&&room_view_.generation==roster.generation&& + launch.player_count==count&&launch.occupied_mask==mask&&party->seed.size()==512&& + party->save_generation==session_save_generation_&&party->save_hash==manifest_.session_save_hash&& + start.descriptor_hash!=gate->retired; + if(!valid){ + // A roster or Room can be in flight. Do not arm a different grid or + // silently start; the host retries and its bounded gate names failure. + if(gate->hash!=start.descriptor_hash){ + gate->hash=start.descriptor_hash; + std::fprintf(stderr,"[lobby][start-wait] roster=%llu room=%llu racers=%u/%u manifest=%u seed=%u\n", + static_cast(roster.generation),static_cast(room_view_.generation),count,unsigned(launch.player_count), + unsigned(launch.compatibility_hash==manifest_hash(room_view_.manifest)),unsigned(party->seed.size()==512)); + } + party->status="Validating the host's current roster, settings and save before starting..."; + }else if(start.stage==0){ + if(!party->launch){ + if(!gate->launch||gate->hash!=start.descriptor_hash){ + gate->launch=launch;gate->hash=start.descriptor_hash;gate->roster=roster.generation; + gate->save_generation=session_save_generation_;gate->save_hash=manifest_.session_save_hash; + gate->phase=StartPhase::Countdown;gate->deadline=std::chrono::steady_clock::now()+std::chrono::seconds(35); + std::fprintf(stderr,"[lobby][start-prepare] starting grid verified; awaiting countdown and commit\n"); + } + party->enqueue({host_sender_id_,watch::LinkKind::Start,TransportTrafficClass::Control,dkr::runtime::party_start::receipt(1,*gate)}); + } + }else if(start.stage==1){ + const bool prepared=gate->launch&&*gate->launch==launch&&gate->hash==start.descriptor_hash&&gate->roster==roster.generation; + // A late spectator may join an already running match. It never blocks + // racers, never owns a port, and still validates the complete descriptor. + if(prepared||party->local_watching()){ + gate->launch=launch;gate->hash=start.descriptor_hash;gate->roster=roster.generation; + gate->save_generation=session_save_generation_;gate->save_hash=manifest_.session_save_hash;gate->phase=StartPhase::Released; + party->enqueue({host_sender_id_,watch::LinkKind::Start,TransportTrafficClass::Control,dkr::runtime::party_start::receipt(2,*gate)}); + if(!party->launch||*party->launch!=launch){ + party->launch=launch;party->running=party->stopping=party->returned=false; + launch_countdown_active_=false;launch_stage_=LaunchStage::Idle; + launch_descriptor_=launch;launch_requested_=true;state_=ConnectionState::Loading; + party->status=status_="Preparing the online game"; + std::fprintf(stderr,"[lobby][launch] accepted committed stage=1 racers=%u\n",unsigned(launch.player_count)); + } + } + } +} + + } else if(message.kind==watch::LinkKind::Runtime)party->admit_incoming(host_sender_id_,message.body); + else if(message.kind==watch::LinkKind::Heartbeat) { + std::uint64_t token=0;bool reply=false; + if(watch::link_heartbeat(message.body,token,reply)) { + if(!reply)party->enqueue({host_sender_id_,watch::LinkKind::Heartbeat,TransportTrafficClass::Control,watch::link_heartbeat(token,true)}); + else if(token==party->ping_token&&party->ping_sent.time_since_epoch().count()) { + const auto ms=std::chrono::duration_cast(std::chrono::steady_clock::now()-party->ping_sent).count(); + if(ms>=0&&ms<5000)party->rtt_ms=std::uint16_t(std::max(1,ms));party->ping_token=0; + } + } + } + else if(message.kind==watch::LinkKind::Start) { + auto gate=dkr::runtime::party_start::gate(this); + if(dkr::runtime::party_start::receipt(message.body,*gate)==3){ + gate->retired=gate->hash;gate->launch.reset();gate->phase=dkr::runtime::party_start::Phase::Cancelled; + reset_launch_transaction_locked();launch_descriptor_.reset();launch_requested_=false; + // A committed client may already be cold-booting. Its + // runtime sees stopping and retires; waiting clients stay. + party->stopping=bool(party->launch);party->launch.reset();party->running=false;party->ready.clear(); + party->status=status_="The host cancelled the synchronized start. Racers must ready again."; + state_=ConnectionState::Lobby;std::fprintf(stderr,"[lobby][start-cancel] host cancelled\n"); + } + } + else if(message.kind==watch::LinkKind::Stop) { + {auto gate=dkr::runtime::party_start::gate(this);gate->retired=gate->hash;gate->launch.reset();gate->phase=dkr::runtime::party_start::Phase::Cancelled;} + reset_launch_transaction_locked(); + party->stopping=true;party->running=false;party->status.assign(message.body.begin(),message.body.end()); + launch_descriptor_.reset();launch_requested_=false;state_=ConnectionState::Lobby;status_=party->status; + } else if(message.kind==watch::LinkKind::Leave) { + party->stopping=true;party->running=false;party->status="The host closed the lobby.";fail_locked(party->status); + } + } + } + continue; +} + handle_packet(source, packet_sender, packet); } flush_outbound_locked(); @@ -5915,6 +6106,243 @@ void DirectSession::pump_locked() { const auto now = std::chrono::steady_clock::now(); if(is_host_)for(auto& pending:pending_joins_)if(pending.active && pending.mods_approved && !pending.mods_verified && now-pending.last_acknowledgement>=std::chrono::seconds(1))send_mod_offer_locked(pending); + if(dkr::runtime::party_departure::waiting(this)){flush_outbound_locked();return;} + if(dkr::runtime::rollback_check::engaged(this)) + dkr::runtime::rollback_check::service(this,rollback_context(),rollback_send,std::chrono::steady_clock::now()); +// Inside pump_locked after ordinary receive/flush, before four-port policies. +{ + namespace watch=dkr::runtime::spectators; + auto party=watch::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->enabled) { + if(state_==ConnectionState::Failed||state_==ConnectionState::Offline)return; +#include "../remove_member.inl" + const auto now=std::chrono::steady_clock::now(); + auto queue=[&](watch::MemberId target,watch::LinkKind kind,std::vector body){ + return party->enqueue({target,kind,TransportTrafficClass::Control,std::move(body)}); + }; + auto stop=[&](std::string reason){ + party->running=false;party->stopping=true;party->ready.clear();party->status=std::move(reason); + launch_descriptor_.reset();launch_requested_=false;state_=is_host_?ConnectionState::Hosting:ConnectionState::Lobby; + reset_launch_transaction_locked();dkr::runtime::party_start::forget(this); + status_=party->status; + }; + if(is_host_) { + for(auto& peer:party->peers)if(!peer.ping_sent.time_since_epoch().count()||now-peer.ping_sent>=std::chrono::seconds(1)) { + peer.ping_token=std::uint64_t(std::chrono::duration_cast(now.time_since_epoch()).count());peer.ping_sent=now; + queue(peer.id,watch::LinkKind::Heartbeat,watch::link_heartbeat(peer.ping_token,false)); + } + if(!party->launch&&!launch_countdown_active_&&launch_stage_==LaunchStage::Idle&&room_view_.rules.automatic_input_delay) { + std::uint16_t slowest=0; + for(const auto& peer:party->peers)if(const auto* m=party->party.find(peer.id);m&&m->role==watch::Role::Racer&&peer.rtt_count) { + auto sorted=peer.rtts;std::sort(sorted.begin(),sorted.begin()+peer.rtt_count);slowest=std::max(slowest,sorted[peer.rtt_count-1]); + } + input_delay_=experimental::automatic_delay(std::max(100.,double(slowest)),room_view_.rules.rollback_window); + } + std::vector silent_members; + for(const auto& peer:party->peers) + if(now-peer.heard>=dkr::runtime::party_departure::SilenceLimit)silent_members.push_back(peer.id); + for(auto id:silent_members)retire_party_member(id,"authenticated traffic silent for 10 seconds"); + if(party->returned) { + if(party->seed.size()==512) { + session_save_=party->seed;manifest_.session_save_hash=stable_hash(std::string_view(reinterpret_cast(session_save_.data()),512)); + ++session_save_generation_;if(!session_save_generation_)++session_save_generation_; + party->save_generation=session_save_generation_;party->save_hash=manifest_.session_save_hash;room_view_.manifest=manifest_; + const auto refreshed=protocol::encode_hello_ack({true,0,"Back in the lobby",session_save_,{},session_save_generation_,manifest_.session_save_hash}); + queue(0,watch::LinkKind::Accepted,refreshed); + } + party->returned=false;party->launch.reset();party->stopping=false;party->ready.clear();party->advertised={}; + } + if(party->ready_requested&&!party->launch&&party->participant(sender_id_))party->ready[sender_id_]=party->ready_value; + party->ready_requested=false; +// Within the DirectSession and PartyLink owner locks. +auto gate=dkr::runtime::party_start::gate(this); +using StartPhase=dkr::runtime::party_start::Phase; +auto required_start=[&](const auto& acknowledgements){ + for(const auto& m:party->party.snapshot().members) + if((m.role==watch::Role::Racer||m.host)&&m.id!=sender_id_&&!acknowledgements.contains(m.id))return false; + return true; +}; +auto cancel_start=[&](std::string reason){ + if(gate->hash){queue(0,watch::LinkKind::Start,dkr::runtime::party_start::receipt(3,*gate));gate->retired=gate->hash;} + gate->phase=StartPhase::Cancelled;gate->launch.reset();gate->prepared.clear();gate->committed.clear(); + reset_launch_transaction_locked();party->launch.reset();launch_descriptor_.reset();launch_requested_=false; + party->running=party->stopping=false;state_=ConnectionState::Hosting; + party->ready.clear();party->advertised={};party->status=status_=std::move(reason); + std::fprintf(stderr,"[lobby][start-cancel] %s\n",status_.c_str()); +}; +if(party->start_requested){ + party->start_requested=false; + if(!party->launch&&!party->stopping&&gate->phase!=StartPhase::Countdown&&gate->phase!=StartPhase::Committing){ + std::string reason;bool all=true; + for(const auto& m:party->party.snapshot().members)if(m.role==watch::Role::Racer){ + if(!party->ready[m.id]){all=false;reason="Every racer must be ready before starting.";break;} + if(m.id!=sender_id_){ + const auto* peer=party->peer(m.id); + if(!peer||!peer->rtt_count||now-peer->heard>=std::chrono::seconds(5)){ + all=false;reason="Waiting for a completed two-way racer connection. Please try Start again in a moment.";break; + } + // An open lobby/control channel alone cannot deliver the + // initial rollback checkpoint. Prove every required route. + for(auto lane:{TransportTrafficClass::Control,TransportTrafficClass::Authoritative, + TransportTrafficClass::Realtime,TransportTrafficClass::Checkpoint}) + if(!transport_->traffic_ready(peer->address,lane)){all=false;reason="A racer's Rollback channels are still connecting. Please try Start again in a moment.";break;} + } + } + if(all&&session_save_.size()==512&&watch::prepare_racing_ports(party->party,reason)){ + unsigned count=0;for(const auto& m:party->party.snapshot().members)if(m.role==watch::Role::Racer)++count; + LaunchDescriptor launch;launch.match_id=match_id_;launch.lobby_generation=++party->launch_serial; + launch.compatibility_hash=manifest_hash(manifest_);launch.occupied_mask=std::uint8_t((1U<retired;*gate={};gate->retired=retired;gate->launch=launch;gate->hash=launch_descriptor_hash(launch); + gate->roster=party->party.snapshot().generation;gate->save_generation=session_save_generation_;gate->save_hash=manifest_.session_save_hash; + gate->rules=room_view_.rules;gate->phase=StartPhase::Countdown;gate->deadline=now+std::chrono::seconds(30); + gate->prepared.insert(sender_id_);gate->committed.insert(sender_id_); + reset_launch_transaction_locked();launch_countdown_active_=true;launch_stage_=LaunchStage::Countdown; + launch_countdown_deadline_=now+std::chrono::seconds(5);++launch_countdown_generation_; + if(!launch_countdown_generation_)++launch_countdown_generation_; + party->advertised={};party->status=status_="DKR-R Online starts in five seconds."; + std::fprintf(stderr,"[lobby][countdown] five seconds; racers=%u\n",count); + }else reason="The lobby settings cannot start this race."; + }else if(reason.empty())reason="The verified online save is not ready."; + if(!reason.empty())party->status=status_=reason; + } +} +if(gate->phase==StartPhase::Countdown||gate->phase==StartPhase::Committing){ + bool intact=gate->launch&&gate->roster==party->party.snapshot().generation&&gate->rules==room_view_.rules&& + gate->save_generation==session_save_generation_&&gate->save_hash==manifest_.session_save_hash&& + gate->launch->compatibility_hash==manifest_hash(manifest_); + for(const auto& m:party->party.snapshot().members)if(m.role==watch::Role::Racer&&!party->ready[m.id])intact=false; + if(!intact)cancel_start("The lobby changed. The five-second start was cancelled safely; racers must ready again."); + else if(party->stop_requested){party->stop_requested=false;cancel_start("The synchronized start was cancelled. Racers must ready again.");} + else if(now>=gate->deadline)cancel_start("A racer did not acknowledge the synchronized start in time. The lobby is still open; check the connection and try again."); + else{ + if(gate->phase==StartPhase::Countdown&&now>=launch_countdown_deadline_&&required_start(gate->prepared)){ + gate->phase=StartPhase::Committing;gate->deadline=now+std::chrono::seconds(15);gate->retry={}; + launch_countdown_active_=false;launch_stage_=LaunchStage::Committing; + party->status=status_="Countdown complete. Waiting for every racer to accept the same launch."; + } + if(gate->phase==StartPhase::Committing&&required_start(gate->committed)){ + gate->phase=StartPhase::Released;launch_stage_=LaunchStage::Idle;launch_countdown_active_=false; + party->launch=*gate->launch;party->seed=session_save_;party->running=party->stopping=party->returned=party->launch_sent=false; + launch_descriptor_=*gate->launch;launch_requested_=true;state_=ConnectionState::Loading;party->advertised={}; + const auto* me=party->party.find(sender_id_);local_slot_=me&&me->port?*me->port:255; + party->status=status_="Every racer accepted the launch. Preparing the synchronized game."; + std::fprintf(stderr,"[lobby][start-release] all required racers acknowledged; racers=%u\n",unsigned(gate->launch->player_count)); + }else if(now>=gate->retry){ + // Control is reliable; retries also make validation insensitive to + // roster/Room delivery order and duplicate messages idempotent. + queue(0,watch::LinkKind::Roster,watch::wire::roster(party->party.snapshot())); + const auto stage=std::uint8_t(gate->phase==StartPhase::Countdown?0:1); + queue(0,watch::LinkKind::Launch,protocol::encode_start({stage,*gate->launch,gate->hash})); + gate->retry=now+std::chrono::milliseconds(250); + if(now>=launch_countdown_deadline_&&gate->phase==StartPhase::Countdown) + party->status=status_="Countdown complete. Waiting for every racer to validate the starting grid."; + } + } +} + + if(party->stop_requested) { + party->stop_requested=false;auto reason=party->status.empty()?std::string("Returned to the lobby."):party->status; + if(reason.size()>240)reason.resize(240); + if(gate->phase==StartPhase::Countdown||gate->phase==StartPhase::Committing|| + (gate->phase==StartPhase::Released&&launch_requested_&&!party->running))cancel_start(std::move(reason)); + else {queue(0,watch::LinkKind::Stop,{reason.begin(),reason.end()});stop(std::move(reason));} + } + if(now-party->advertised>=std::chrono::milliseconds(500)) { + party->advertised=now; + auto room=lobby_payload();room.generation=party->party.snapshot().generation;room.players={};room.rules=room_view_.rules; + // The codec validates every slot, including vacant ports. + for(unsigned p=0;plaunch&&!party->stopping?RoomPhase::Running:RoomPhase::Waiting; + room.rules.synchronization=SynchronizationMode::ExperimentalRollback; + for(const auto& m:party->party.snapshot().members)if(m.port) { + auto& row=room.players[*m.port];row.occupied=true;row.slot=*m.port;row.display_name=m.name;row.ready=party->ready[m.id];row.loaded=party->running;row.route=Route::Direct; + } + queue(0,watch::LinkKind::Roster,watch::wire::roster(party->party.snapshot())); + const auto room_bytes=protocol::encode_lobby_state(room); + if(room_bytes.empty()) { + std::fprintf(stderr,"[lobby][wire-error] room serialization failed; no empty settings packet sent\n"); + stop("The lobby settings could not be published. Please recreate the lobby."); + party->launch.reset(); + } else queue(0,watch::LinkKind::Room,room_bytes); + if(party->launch&&!party->stopping){ + // StartPayload's committed stage is 1, never 2. + const auto bytes=protocol::encode_start({1,*party->launch,launch_descriptor_hash(*party->launch)}); + if(bytes.empty()) { + std::fprintf(stderr,"[lobby][wire-error] committed launch serialization failed\n"); + stop("The lobby start message could not be published. Please recreate the lobby.");party->launch.reset(); + } else { + queue(0,watch::LinkKind::Launch,bytes); + if(!party->launch_sent){party->launch_sent=true;std::fprintf(stderr,"[lobby][launch] committed stage=1 racers=%u\n",unsigned(party->launch->player_count));} + } + } + } + } else { + {auto gate=dkr::runtime::party_start::gate(this); + if(gate->phase==dkr::runtime::party_start::Phase::Countdown&&now>=gate->deadline){ + gate->retired=gate->hash;gate->launch.reset();gate->phase=dkr::runtime::party_start::Phase::Cancelled; + reset_launch_transaction_locked();party->status=status_="The host did not complete the synchronized start. Leave and rejoin the lobby."; + }} + if(party->admitted&&now-last_admission_response_>dkr::runtime::party_departure::SilenceLimit) { + party->stopping=true;party->running=false;fail_locked("The host stopped responding. The lobby has closed.");return; + } + if(party->ready_requested){ + if(party->party.find(party->local)&&room_view_.generation==party->party.snapshot().generation&& + room_view_.rules.synchronization==SynchronizationMode::ExperimentalRollback) { + queue(party->authority,watch::LinkKind::Ready,{std::uint8_t(party->ready_value)});party->ready_requested=false; + } else party->status="Waiting for the host's current lobby settings..."; + } + if(party->stop_requested){ + party->stop_requested=false; + const bool racer=party->participant(party->local); + queue(party->authority,racer?watch::LinkKind::Stop:watch::LinkKind::Leave,{}); + if(!racer)stop("Stopped watching."); + } + if(party->returned){queue(party->authority,watch::LinkKind::Returned,{});party->returned=false;party->launch.reset();party->stopping=false;} + if(now-party->heartbeat>=std::chrono::seconds(1)){ + party->ping_token=std::uint64_t(std::chrono::duration_cast(now.time_since_epoch()).count());party->ping_sent=party->heartbeat=now; + queue(party->authority,watch::LinkKind::Heartbeat,watch::link_heartbeat(party->ping_token,false)); + } + } + if(party->running&&launch_descriptor_)state_=ConnectionState::Running; + // Independent per-peer SCTP lanes and bounded round-robin bursts. + // Bulk never fills a racing input/control queue in DirectSession. + const auto elapsed=party->budget_at.time_since_epoch().count()?std::chrono::duration_cast(now-party->budget_at).count():0; + if(elapsed>0)party->bulk_tokens=std::min(256*1024,party->bulk_tokens+std::size_t(elapsed)*4096); + party->budget_at=now; + for(unsigned lane: {0U,2U,1U,3U,4U}) { + auto& pending=party->outgoing[lane];unsigned allowance=lane==4?8:16; + for(unsigned n=0;noutgoing_bytes-=item.bytes.size(); + auto payload=watch::link_encode(item.kind,item.bytes);bool blocked=false; + auto send=[&](const PeerAddress& destination,const secure::Key& key){ + if((lane==3||lane==4)&&payload.size()>party->bulk_tokens){blocked=true;return;} + if(!transport_->traffic_ready(destination,item.traffic)){blocked=true;return;} + auto& serial=outbound_sequence(destination,key);if(serial==UINT64_MAX){blocked=true;return;} + protocol::Datagram record{{static_cast(watch::PartyMessageType),match_id_,serial++,unsigned(item.traffic)},payload}; + auto plain=protocol::encode(record,transport_->maximum_plaintext_datagram_bytes()); + if(plain.empty()){blocked=true;return;} + auto encrypted=secure::seal(plain,key,sender_id_,record.header.sequence,match_id_);std::string ignored; + const auto sent=transport_->send_status(destination,encrypted,item.traffic,ignored); + if(sent!=DatagramSendStatus::Sent)blocked=true; + else if(lane==3||lane==4)party->bulk_tokens-=std::min(party->bulk_tokens,payload.size()); + }; + if(is_host_) { + if(!item.target) { + // Expand broadcasts once: a blocked recipient must not + // cause repeats or starvation on already sent routes. + for(const auto& peer:party->peers)party->enqueue({peer.id,item.kind,item.traffic,item.bytes}); + } else if(auto* peer=party->peer(item.target))send(peer->address,peer->key); + } else if(!item.target||item.target==host_sender_id_)send(host_address_,key_); + if(blocked)party->enqueue(std::move(item)); + } + } + expire_pending_joins();state_changed_.notify_all();return; + } +} + constexpr auto control_retry = std::chrono::milliseconds(250); service_owned_match_end_locked(now); // The outer runtime can apply lead backpressure before requesting input, @@ -6356,6 +6784,19 @@ void DirectSession::handle_join_request( } if (blocked_senders_.contains(packet_sender) || blocked_sources_.contains(DatagramSocket::describe_host(source))) return; + auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + bool party_join=false,watch_join=false; + const std::array marker{'D','K','R','P','T','Y',1}; + if(plain.size()>=8&&std::equal(marker.begin(),marker.end(),plain.begin())&&plain[7]<=1) { + party_join=true;watch_join=plain[7]!=0;plain.erase(plain.begin(),plain.begin()+8); + } + // The host owns this policy. A racer advertises support for parties, not + // permission to enable them. A non-spectator lobby uses normal admission. + if(party_join&&!party->enabled&&!watch_join)party_join=false; + if(party_join!=party->enabled || (party_join&&party->launch&&!watch_join)) { + send_with_key(source,peer_key,protocol::MessageType::HelloAck, + protocol::encode_hello_ack({false,0,party->enabled?"Join using Racer or Watch Race. A running race accepts spectators only.":"This host has disabled spectators. Join as a racer."}));return; + } protocol::HelloPayload hello{}; std::string error; if (!protocol::decode_hello(plain, hello, error)) { @@ -6364,6 +6805,14 @@ void DirectSession::handle_join_request( return; } ++join_packets_accepted_; + if(party_join) { + if(auto* existing=party->peer(packet_sender)) { + if(existing->address!=source||existing->key!=peer_key)return; + const auto body=protocol::encode_hello_ack({true,0,"Joined the lobby",session_save_,{},session_save_generation_,manifest_.session_save_hash}); + send_with_key(existing->address,existing->key,static_cast(dkr::runtime::spectators::PartyMessageType), + dkr::runtime::spectators::link_encode(dkr::runtime::spectators::LinkKind::Accepted,body));party->advertised={};return; + } + } if (PeerRecord* existing = peer_by_sender(packet_sender)) { if (!(existing->address == source)) return; send_with_key(existing->address, existing->key, @@ -6376,7 +6825,25 @@ void DirectSession::handle_join_request( broadcast_lobby(); return; } - if (lobby_locked_ || (state_ != ConnectionState::Lobby && state_ != ConnectionState::Hosting)) { + auto directory_owner=dkr::online::native::owner(this); + if(directory_owner->guarded){ + auto existing=dkr::online::native::binding(*directory_owner,packet_sender); + const bool repeated=existing&&existing->client==client_public&&existing->watching==watch_join&&existing->privacy==directory_owner->privacy&&!directory_owner->blocked.contains(existing->account); + if(!dkr::online::native::authorize(*directory_owner,hello.friend_admission,client_public,packet_sender,watch_join)){ + send_with_key(source,peer_key,protocol::MessageType::HelloAck,protocol::encode_hello_ack({false,0,"Sign in with Discord and join through DKR-R Online."}));return; + } + if(!repeated){auto* binding=dkr::online::native::binding(*directory_owner,packet_sender); + if(binding)dkr::online::respond(this,{{"type","consume"},{"id",binding->id}});} + auto gate=dkr::runtime::party_start::gate(this); + const auto* watch_binding=dkr::online::native::binding(*directory_owner,packet_sender); + const bool running_watch=watch_binding&&dkr::online::contract::running_spectator(watch_binding->watching,watch_binding->allow_running&&dkr::online::view()->running_spectators,party->enabled,party->running,party->stopping,launch_countdown_active_||launch_stage_!=LaunchStage::Idle); + if(lobby_locked_||launch_countdown_active_||launch_stage_!=LaunchStage::Idle|| + gate->phase==dkr::runtime::party_start::Phase::Countdown||gate->phase==dkr::runtime::party_start::Phase::Committing|| + (party->launch&&!running_watch)||party->stopping||dkr::runtime::rollback_check::active(this)){ + send_with_key(source,peer_key,protocol::MessageType::HelloAck,protocol::encode_hello_ack({false,0,"The lobby is changing state. Please join again when it is waiting."}));return; + } + } + if (lobby_locked_ || ((state_ != ConnectionState::Lobby && state_ != ConnectionState::Hosting)&&!party_join)) { send_with_key(source, peer_key, protocol::MessageType::HelloAck, protocol::encode_hello_ack({false, 0U, "The host is not accepting new racers right now."})); return; @@ -6412,10 +6879,17 @@ void DirectSession::handle_join_request( pending->display_name = std::move(hello.display_name); pending->manifest = std::move(hello.manifest); pending->last_seen = std::chrono::steady_clock::now(); + if(party_join){ + const auto role=party->pending_watch.find(packet_sender); + if(role!=party->pending_watch.end()&&role->second!=watch_join)return; + party->pending_watch[packet_sender]=watch_join; + } if(pending->mods_approved) { send_mod_offer_locked(*pending);return; } - if (consume_friend_admission_locked(hello.friend_admission)) { + const auto directory_binding=dkr::online::native::binding(*directory_owner,packet_sender); + if ((directory_owner->guarded&&directory_binding&&directory_binding->automatic)|| + (!directory_owner->guarded&&consume_friend_admission_locked(hello.friend_admission))) { const PeerAddress pending_address = pending->address; const secure::Key pending_key = pending->key; std::string admission_error; @@ -7179,6 +7653,7 @@ void DirectSession::handle_client_packet(const protocol::Datagram& packet) { } else if (packet.header.type == protocol::MessageType::LobbyState) { protocol::LobbyStatePayload payload{}; if (protocol::decode_lobby_state(packet.payload, payload, error)) apply_lobby_payload(payload); + else if (error == dkr::runtime::netplay::rollback_policy::unsupported_lobby) fail_locked(error); } else if (packet.header.type == protocol::MessageType::PreflightBegin) { protocol::PreflightBeginPayload begin{}; if (!protocol::decode_preflight_begin(packet.payload, begin, error) || @@ -8019,6 +8494,7 @@ protocol::LobbyStatePayload DirectSession::lobby_payload() const { payload.phase = room.phase; payload.room_name = room.name; payload.visibility = room.visibility; + if(auto owner=dkr::online::native::owner(this);is_host_&&owner->guarded)payload.visibility=owner->public_room?Visibility::Public:Visibility::Private; payload.rules = room.rules; payload.input_delay_frames = input_delay_; payload.countdown_active = launch_countdown_active_; @@ -8042,6 +8518,9 @@ protocol::LobbyStatePayload DirectSession::lobby_payload() const { void DirectSession::apply_lobby_payload(const protocol::LobbyStatePayload& payload) { if (retired_owned_generation_ && payload.generation <= retired_owned_generation_) return; if (payload.generation < room_view_.generation) return; + // Visibility already exists in the authenticated Room codec. It is + // metadata and must also update without changing party/launch generations. + room_view_.visibility=payload.visibility; const bool semantic_update = payload.generation > room_view_.generation; if (semantic_update) { room_view_.generation = payload.generation; @@ -8139,6 +8618,8 @@ void DirectSession::expire_pending_joins() { for (PendingRecord& pending : pending_joins_) { if (pending.active && (now - pending.last_seen > lifetime || (pending.mods_approved && now-pending.first_seen>std::chrono::minutes(20)))) { + if(pending.mods_approved)send_with_key(pending.address,pending.key,protocol::MessageType::HelloAck, + protocol::encode_hello_ack({false,0U,now-pending.last_seen>lifetime?"Mod preparation lost contact with this player. Retry to resume verified files.":"The mod admission time budget expired. Retry to reuse verified files."})); clear_pending_mod_route_locked(pending);pending = {}; } } @@ -9141,6 +9622,12 @@ void DirectSession::fail_locked(std::string message) { // a second failure transition. state_ = ConnectionState::Failed; status_ = message; + {auto party=dkr::runtime::spectators::party_link(this);std::scoped_lock party_lock(party->mutex); + if(party->enabled){party->stopping=true;party->running=false;party->status=message; + const auto body=dkr::runtime::spectators::link_encode(dkr::runtime::spectators::LinkKind::Leave); + if(is_host_)for(const auto& peer:party->peers)send_with_key(peer.address,peer.key,static_cast(dkr::runtime::spectators::PartyMessageType),body); + else if(host_address_)send_with_key(host_address_,key_,static_cast(dkr::runtime::spectators::PartyMessageType),body); + }} if (is_host_) broadcast(protocol::MessageType::Disconnect, failure_payload_); else if (host_address_) { send_to(host_address_, protocol::MessageType::Disconnect, @@ -9186,6 +9673,7 @@ LaunchDescriptor DirectSession::make_launch_descriptor() const { ? room.rules.rollback_window : 0U; descriptor.host_control = room.rules.host_control; + descriptor.development=room.rules.development; for (std::size_t slot = 0; slot < room.players.size(); ++slot) { if (!room.players[slot].occupied) continue; descriptor.occupied_mask |= static_cast(1U << slot); @@ -9197,7 +9685,7 @@ LaunchDescriptor DirectSession::make_launch_descriptor() const { bool DirectSession::validate_start_descriptor( const protocol::StartPayload& payload, std::string& error) const { const LaunchDescriptor& descriptor = payload.descriptor; - if (const char* reason = experimental::runtime_admission_error(descriptor.synchronization, owned_backend_available_)) { + if (const char* reason = dkr::runtime::netplay::rollback_policy::admission_error(descriptor.synchronization, owned_backend_available_)) { error = reason; return false; } diff --git a/runtime-recomp/src/game/netplay/experimental_confirmed_output.hpp b/runtime-recomp/src/game/netplay/experimental_confirmed_output.hpp index aeb48124..6448c06f 100644 --- a/runtime-recomp/src/game/netplay/experimental_confirmed_output.hpp +++ b/runtime-recomp/src/game/netplay/experimental_confirmed_output.hpp @@ -1,3 +1,4 @@ +#include "custom_records.hpp" #pragma once #include "experimental_pak.hpp" #include @@ -59,7 +60,7 @@ class ConfirmedAudioMailbox final { }; struct ConfirmedSaveSnapshot { - std::vector eeprom,paks; + std::vector eeprom,paks,records; }; // One write in flight plus one latest pending snapshot. Intermediate confirmed @@ -72,11 +73,12 @@ class ConfirmedSaveWriter final { ConfirmedSaveWriter(const ConfirmedSaveWriter&)=delete; ConfirmedSaveWriter& operator=(const ConfirmedSaveWriter&)=delete; ~ConfirmedSaveWriter() {finish();join();} - bool submit(std::span eeprom,std::span paks,std::string& error) { + bool submit(std::span eeprom,std::span paks,std::string& error,std::span records={}) { if(eeprom.size()!=512||paks.size()!=Paks::kImagesBytes) { error="Invalid confirmed online save snapshot size.";return false; } - ConfirmedSaveSnapshot copy{{eeprom.begin(),eeprom.end()},{paks.begin(),paks.end()}}; + if(!records.empty()&&!saves::records::valid(records)){error="Invalid confirmed custom records.";return false;} + ConfirmedSaveSnapshot copy{{eeprom.begin(),eeprom.end()},{paks.begin(),paks.end()},{records.begin(),records.end()}}; { std::scoped_lock lock(mutex_); if(!error_.empty()) {error=error_;return false;} diff --git a/runtime-recomp/src/game/netplay/experimental_draw_events.h b/runtime-recomp/src/game/netplay/experimental_draw_events.h index b0df58e2..09873f30 100644 --- a/runtime-recomp/src/game/netplay/experimental_draw_events.h +++ b/runtime-recomp/src/game/netplay/experimental_draw_events.h @@ -22,7 +22,7 @@ enum dkr_owned_draw_kind { DKR_OWNED_ONLINE_VIEW, DKR_OWNED_ONLINE_HUD, DKR_OWNED_ONLINE_SHARED, DKR_OWNED_ONLINE_DIVIDER_BEGIN, DKR_OWNED_ONLINE_DIVIDER_END, DKR_OWNED_ONLINE_RACER,DKR_OWNED_ONLINE_HUD_WIDGET_BEGIN,DKR_OWNED_ONLINE_HUD_WIDGET_END, - DKR_OWNED_LOCAL_MODEL,DKR_OWNED_LOCAL_OBJECT + DKR_OWNED_LOCAL_MODEL,DKR_OWNED_LOCAL_OBJECT,DKR_OWNED_CAMERA_WORLD_END }; typedef struct dkr_owned_draw_event { uint32_t kind,address,token; @@ -32,7 +32,7 @@ typedef struct dkr_owned_draw_event { } dkr_owned_draw_event; static inline int dkr_owned_draw_event_valid(const dkr_owned_draw_event* e) { - if(e->kindkind>DKR_OWNED_LOCAL_OBJECT || + if(e->kindkind>DKR_OWNED_CAMERA_WORLD_END || e->address<0x80000000U || e->address>0x807FFFD8U)return 0; const int quad=e->kind==DKR_OWNED_BACKGROUND_QUAD || e->kind==DKR_OWNED_SPLIT_SKY_QUAD || e->kind==DKR_OWNED_SPLIT_VOID_QUAD; @@ -85,6 +85,8 @@ static inline int dkr_owned_draw_event_valid(const dkr_owned_draw_event* e) { if(e->token>3||e->parameters[0]>3||e->parameters[1]>32|| e->parameters[2]>255||e->parameters[3]>1||e->parameters[4]>4)return 0; used=5;break; + case DKR_OWNED_CAMERA_WORLD_END: + if(e->token>3)return 0;break; case DKR_OWNED_ONLINE_HUD: if(e->token>3)return 0;break; case DKR_OWNED_ONLINE_SHARED:case DKR_OWNED_ONLINE_DIVIDER_BEGIN:case DKR_OWNED_ONLINE_DIVIDER_END: diff --git a/runtime-recomp/src/game/netplay/experimental_eeprom.hpp b/runtime-recomp/src/game/netplay/experimental_eeprom.hpp index 605d4738..84dfb76c 100644 --- a/runtime-recomp/src/game/netplay/experimental_eeprom.hpp +++ b/runtime-recomp/src/game/netplay/experimental_eeprom.hpp @@ -1,3 +1,4 @@ +#include #pragma once #include #include @@ -22,6 +23,17 @@ class Eeprom final { bool restore(std::span checkpoint); bool commit(std::uint64_t epoch,std::uint32_t frame,std::span journal); std::span confirmed_image() const { return confirmed_; } + // New confirmed-network admission. Ordinary rollback restore() below is + // unchanged and still cannot transplant an irreversible future cursor. + bool admit_confirmed(std::uint64_t epoch,std::uint32_t frame,std::span bytes) { + if(!epoch||frame==UINT32_MAX||bytes.size()!=kCheckpointBytes)return false; + std::uint64_t incoming_epoch=0;std::uint32_t incoming_frame=0; + for(unsigned i=0;i<8;++i)incoming_epoch|=std::uint64_t(bytes[i])<<(8*i); + for(unsigned i=0;i<4;++i)incoming_frame|=std::uint32_t(bytes[8+i])<<(8*i); + if(incoming_epoch!=epoch||incoming_frame!=frame)return false; + Eeprom staged;if(!staged.start(epoch,bytes.subspan(12)))return false; + staged.next_=staged.confirmed_next_=frame;*this=std::move(staged);return true; + } private: std::array working_{},confirmed_{}; std::vector journal_; diff --git a/runtime-recomp/src/game/netplay/experimental_lobby_admission.cpp b/runtime-recomp/src/game/netplay/experimental_lobby_admission.cpp index 3ae1d82f..8fa2bb28 100644 --- a/runtime-recomp/src/game/netplay/experimental_lobby_admission.cpp +++ b/runtime-recomp/src/game/netplay/experimental_lobby_admission.cpp @@ -1,3 +1,4 @@ +#include "../rollback_policy.hpp" #include "experimental_lobby_admission.hpp" #include "authoritative_state_codec.hpp" #include "monocypher.h" @@ -202,7 +203,7 @@ bool LobbyAdmission::service_admission(Clock::time_point now) { if(!transport_.receive(source,bytes,error)) {if(!error.empty())return fail(error);break;} if(source.size!=4||source.storage[0]!='D'||source.storage[1]!='K'||source.storage[2]!='X'||source.storage[3]>=players_||source.storage[3]==local_)return fail("Invalid admitted owned route."); if(bytes.size()>=4&&std::equal(prefix.begin(),prefix.begin()+4,bytes.begin())) { - if(bytes.size()<5||bytes[4]!=prefix[4])return fail("The racers are using different Experimental Rollback rebuilds. Install the same package on every machine."); + if(bytes.size()<5||bytes[4]!=prefix[4])return fail("The racers are using different Rollback rebuilds. Install the same package on every machine."); if(!packet(source.storage[3],bytes,now))return false; } else { diff --git a/runtime-recomp/src/game/netplay/experimental_magic_codes.hpp b/runtime-recomp/src/game/netplay/experimental_magic_codes.hpp index b85f0a90..a2fe2234 100644 --- a/runtime-recomp/src/game/netplay/experimental_magic_codes.hpp +++ b/runtime-recomp/src/game/netplay/experimental_magic_codes.hpp @@ -1,3 +1,4 @@ +#include #pragma once #include "../magic_code_runtime_policy.hpp" #include @@ -23,6 +24,13 @@ class MagicCodes final { bool restore(std::span checkpoint); bool commit(std::uint64_t epoch,std::uint32_t frame,std::span journal); std::uint32_t confirmed_actions() const {return confirmed_actions_;} + bool admit_confirmed(std::uint64_t epoch,std::uint32_t frame,std::uint32_t actions,std::span bytes) { + if(!epoch||frame==UINT32_MAX||bytes.size()!=kCheckpointBytes||(actions&~initial_.deferred_action_mask))return false; + MagicCodes staged;if(!staged.start(epoch,selection_,online_))return false; + staged.next_=frame; + if(!staged.restore(bytes)||(staged.working_.armed_action_mask&actions))return false; + staged.confirmed_next_=frame;staged.confirmed_actions_=actions;*this=std::move(staged);return true; + } private: bool valid(const magic_codes::MagicCodeSessionState& state) const; magic_codes::MagicCodeSessionState working_{},initial_{}; diff --git a/runtime-recomp/src/game/netplay/experimental_network.cpp b/runtime-recomp/src/game/netplay/experimental_network.cpp index a1136d2c..6382eeb2 100644 --- a/runtime-recomp/src/game/netplay/experimental_network.cpp +++ b/runtime-recomp/src/game/netplay/experimental_network.cpp @@ -1,5 +1,6 @@ #include "experimental_network.hpp" #include "monocypher.h" +#include "../mods/sdk2_bridge.hpp" #include #include @@ -93,13 +94,15 @@ void Network::receive(const PeerAddress& source,std::span by std::uint64_t sender=0,sequence=0; std::vector plain; if(!secure::open(bytes,keys_[peer],configuration_.match_id,sender,sequence,plain) || sender!=configuration_.peers[peer].sender_id || plain.size()<6 || - !std::equal(kPrefix.begin(),kPrefix.end(),plain.begin()) || plain[5]>1) { ++statistics_.rejected; return; } + !std::equal(kPrefix.begin(),kPrefix.end(),plain.begin()) || plain[5]>2) { ++statistics_.rejected; return; } if(!replay_[peer].accept(sequence)) { ++statistics_.duplicate; return; } const auto payload=std::span(plain).subspan(6); if(plain[5]==0) { const auto result=session_.receive_input(std::uint8_t(peer),payload); if(result==SessionInputResult::Rejected) ++statistics_.rejected; if(result==SessionInputResult::Conflict) fail("Conflicting authenticated experimental owner input."); + } else if(plain[5]==2) { + if(!dkr::sdk2::network_receive(peer,payload))++statistics_.rejected; } else { const auto result=session_.receive_control(std::uint8_t(peer),payload); if(result==EpochResult::Rejected) ++statistics_.rejected; @@ -127,8 +130,11 @@ bool Network::service(Clock::time_point now) { // Release/ACK repair is reconstructed through previous_control_for(). if(epoch_!=session_.epoch()) { pending_={}; epoch_=session_.epoch(); } const unsigned players=session_.player_count(),local=session_.local_owner(); + dkr::sdk2::network_epoch(session_.epoch(),local,players); for(unsigned peer=0;peer payload, TransportTrafficClass traffic, diff --git a/runtime-recomp/src/game/netplay/experimental_owner_inputs.cpp b/runtime-recomp/src/game/netplay/experimental_owner_inputs.cpp index d6d412bf..2c11daf6 100644 --- a/runtime-recomp/src/game/netplay/experimental_owner_inputs.cpp +++ b/runtime-recomp/src/game/netplay/experimental_owner_inputs.cpp @@ -5,7 +5,7 @@ namespace dkr::runtime::netplay::experimental { namespace { -constexpr unsigned kHeader = 52; +constexpr unsigned kHeader = 53; bool valid(const OwnerInputPacket& p) { if (!p.epoch || p.players < 2 || p.players > 4 || p.owner >= p.players || p.count > kOwnerInputBatch || (!p.count && p.first_frame) || std::uint64_t(p.first_frame) + p.count > UINT32_MAX) return false; @@ -16,6 +16,7 @@ bool valid(const OwnerInputPacket& p) { for(unsigned bit=1;bit<32;++bit)if((p.received_bits[owner]&(1U<=UINT32_MAX)return false; } + for(unsigned i=0;i& bytes, std::uint64_t value, unsigned width) { @@ -29,32 +30,40 @@ std::uint64_t get(std::span bytes, unsigned at, unsigned wid } std::vector encode_owner_inputs(const OwnerInputPacket& p) { if (!valid(p)) return {}; - std::vector result{'D','K','R','X',2,p.players,p.owner,p.count}; - result.reserve(kHeader + p.count * 4); + std::vector result{'D','K','R','X',3,p.players,p.owner,p.count}; + result.reserve(kOwnerInputMaximumBytes); put(result, p.epoch, 8); put(result, p.first_frame, 4); for (auto ack : p.received_next) put(result, ack, 4); for (auto bits : p.received_bits) put(result,bits,4); + std::uint8_t mask=0;for(unsigned i=0;i(p.inputs[i].stick_x)); result.push_back(std::bit_cast(p.inputs[i].stick_y)); + if(mask&(1U< decode_owner_inputs(std::span bytes) { if (bytes.size() < kHeader || bytes[0] != 'D' || bytes[1] != 'K' || bytes[2] != 'R' || - bytes[3] != 'X' || bytes[4] != 2 || bytes[7] > kOwnerInputBatch || - bytes.size() != kHeader + bytes[7] * 4U) return {}; + bytes[3] != 'X' || bytes[4] != 3 || bytes[7] > kOwnerInputBatch || + bytes.size()>kOwnerInputMaximumBytes || (bytes[52]>>bytes[7])) return {}; OwnerInputPacket p; p.players = bytes[5]; p.owner = bytes[6]; p.count = bytes[7]; p.epoch = get(bytes, 8, 8); p.first_frame = std::uint32_t(get(bytes, 16, 4)); for (unsigned i = 0; i < 4; ++i) p.received_next[i] = std::uint32_t(get(bytes, 20 + i * 4, 4)); for (unsigned i = 0; i < 4; ++i) p.received_bits[i] = std::uint32_t(get(bytes,36+i*4,4)); - for (unsigned i = 0; i < p.count; ++i) { - p.inputs[i] = {std::uint16_t(get(bytes, kHeader + i * 4, 2)), - std::bit_cast(bytes[kHeader + i * 4 + 2]), - std::bit_cast(bytes[kHeader + i * 4 + 3])}; + unsigned cursor=kHeader; + for(unsigned i=0;i(bytes[cursor+2]),std::bit_cast(bytes[cursor+3])};cursor+=4; + if(bytes[52]&(1U< OwnerInputHistory::actual(std::uint8_t owner, std::ui const auto& sample = samples_[owner][frame % kHistory]; return sample.valid && sample.frame == frame ? std::optional(sample.input) : std::nullopt; } +dkr::sdk2::Command OwnerInputHistory::command(std::uint8_t owner,std::uint32_t frame)const { + if(owner>=players_)return {};const auto& value=samples_[owner][frame%kHistory]; + return value.valid&&value.frame==frame?value.command:dkr::sdk2::Command{}; +} bool OwnerInputHistory::within_window(std::uint8_t owner, std::uint32_t frame) const { return frame != UINT32_MAX && std::uint64_t(frame) <= std::uint64_t(cursor_) + kFuture && (frame >= next_[owner] || next_[owner] - frame <= kHistory); @@ -95,8 +108,8 @@ bool OwnerInputHistory::may_replace(std::uint8_t owner, std::uint32_t frame) con } return true; } -void OwnerInputHistory::insert(std::uint8_t owner, std::uint32_t frame, PackedInput input) { - samples_[owner][frame % kHistory] = {frame, input, true}; +void OwnerInputHistory::insert(std::uint8_t owner, std::uint32_t frame, PackedInput input,dkr::sdk2::Command command) { + samples_[owner][frame % kHistory] = {frame, input, true,command}; newest_next_[owner]=(std::max)(newest_next_[owner],frame+1); while (actual(owner, next_[owner])) ++next_[owner]; // Receipt maps change only when actual input arrives, not on every hot @@ -107,15 +120,15 @@ void OwnerInputHistory::insert(std::uint8_t owner, std::uint32_t frame, PackedIn if(std::uint64_t(next_[owner])+bitcommand(local_,frame)==command) return OwnerInputResult::Duplicate; conflict_ = true; return OwnerInputResult::Conflict; } if (frame != next_[local_] || !within_window(local_, frame)) return OwnerInputResult::Rejected; if (!may_replace(local_, frame)) return OwnerInputResult::Backpressure; - insert(local_, frame, input); return OwnerInputResult::Accepted; + insert(local_, frame, input,command); return OwnerInputResult::Accepted; } OwnerInputResult OwnerInputHistory::receive_authenticated(std::uint8_t peer, const OwnerInputPacket& p, std::vector& received) { @@ -139,7 +152,7 @@ OwnerInputResult OwnerInputHistory::receive_authenticated(std::uint8_t peer, con for (unsigned i = 0; i < p.count; ++i) { const auto frame = p.first_frame + i; if (!within_window(p.owner, frame)) return OwnerInputResult::Rejected; - if (const auto prior = actual(p.owner, frame); prior && *prior != p.inputs[i]) { + if (const auto prior = actual(p.owner, frame); prior && (*prior != p.inputs[i]||command(p.owner,frame)!=p.commands[i])) { conflict_ = true; return OwnerInputResult::Conflict; } if (!may_replace(p.owner, frame)) return OwnerInputResult::Backpressure; @@ -150,7 +163,7 @@ OwnerInputResult OwnerInputHistory::receive_authenticated(std::uint8_t peer, con for (unsigned i = 0; i < p.count; ++i) { const auto frame = p.first_frame + i; if (!actual(p.owner, frame)) { - insert(p.owner, frame, p.inputs[i]); received.push_back({p.owner, frame, p.inputs[i]}); + insert(p.owner, frame, p.inputs[i],p.commands[i]); received.push_back({p.owner, frame, p.inputs[i],p.commands[i]}); } } for (unsigned owner = 0; owner < players_; ++owner) if (sent_next_[peer][owner]) { @@ -189,7 +202,7 @@ std::optional OwnerInputHistory::packet_for(std::uint8_t owner if (!sample) break; // Relay cannot repair bytes it has not received. if(send==OwnerInputSend::Repair && i && first+i-ack<32 && (ack_bits_[peer][owner]&(1U<<(first+i-ack))))break; - p.inputs[p.count++] = *sample; + p.commands[p.count]=command(owner,first+i);p.inputs[p.count++] = *sample; } if(p.count)sent_next_[peer][owner] = (std::max)(sent_next_[peer][owner], first + p.count); else p.first_frame=0; diff --git a/runtime-recomp/src/game/netplay/experimental_owner_inputs.hpp b/runtime-recomp/src/game/netplay/experimental_owner_inputs.hpp index b04c473a..0328fa44 100644 --- a/runtime-recomp/src/game/netplay/experimental_owner_inputs.hpp +++ b/runtime-recomp/src/game/netplay/experimental_owner_inputs.hpp @@ -1,5 +1,6 @@ #pragma once #include "netplay_types.hpp" +#include "../mods/sdk2_command.hpp" #include #include #include @@ -12,7 +13,7 @@ namespace dkr::runtime::netplay::experimental { // every published owner sample is final. Transport encryption/authentication // belongs to SecureChannel; this codec is its bounded plaintext payload only. inline constexpr unsigned kOwnerInputBatch = 8; -inline constexpr unsigned kOwnerInputMaximumBytes = 52+4*kOwnerInputBatch; +inline constexpr unsigned kOwnerInputMaximumBytes = 53+68*kOwnerInputBatch; struct OwnerInputPacket { std::uint64_t epoch = 0; std::uint32_t first_frame = 0; @@ -21,6 +22,7 @@ struct OwnerInputPacket { // predictions or simulation ACKs. Bit zero names the first missing frame. std::array received_bits{}; std::array inputs{}; + std::array commands{}; std::uint8_t players = 0; std::uint8_t owner = 0; std::uint8_t count = 0; @@ -31,7 +33,7 @@ std::optional decode_owner_inputs(std::span& newly_received); std::optional packet_for(std::uint8_t owner, std::uint8_t peer, OwnerInputSend send = OwnerInputSend::Live); std::optional actual(std::uint8_t owner, std::uint32_t frame) const; std::uint32_t received_next(std::uint8_t owner) const { return owner < players_ ? next_[owner] : 0; } + dkr::sdk2::Command command(std::uint8_t owner,std::uint32_t frame)const; bool failed() const { return conflict_; } private: static constexpr unsigned kHistory = 128; static constexpr unsigned kFuture = 48; - struct Sample { std::uint32_t frame = 0; PackedInput input{}; bool valid = false; }; + struct Sample { std::uint32_t frame = 0; PackedInput input{}; bool valid = false; dkr::sdk2::Command command{}; }; bool within_window(std::uint8_t owner, std::uint32_t frame) const; bool may_replace(std::uint8_t owner, std::uint32_t frame) const; - void insert(std::uint8_t owner, std::uint32_t frame, PackedInput input); + void insert(std::uint8_t owner, std::uint32_t frame, PackedInput input,dkr::sdk2::Command command={}); std::array, 4> samples_{}; std::array next_{}; std::array newest_next_{}; diff --git a/runtime-recomp/src/game/netplay/experimental_pak.hpp b/runtime-recomp/src/game/netplay/experimental_pak.hpp index f8a4632f..c84e889b 100644 --- a/runtime-recomp/src/game/netplay/experimental_pak.hpp +++ b/runtime-recomp/src/game/netplay/experimental_pak.hpp @@ -1,3 +1,4 @@ +#include #pragma once #include #include @@ -44,6 +45,17 @@ class Paks final { int erase(unsigned port,const Identity& identity); int write(unsigned port,unsigned index,std::size_t offset,std::span bytes); int reformat(unsigned port); + bool admit_confirmed(std::uint64_t epoch,std::uint32_t frame,std::span bytes) { + if(!epoch||frame==UINT32_MAX||bytes.size()!=kCheckpointBytes||bytes[0]!='D'||bytes[1]!='K'||bytes[2]!='P'||bytes[3]!='K'|| + bytes[4]!=1||bytes[5]!=mask_||bytes[6]||bytes[7])return false; + std::uint64_t incoming_epoch=0;std::uint32_t incoming_frame=0; + for(unsigned i=0;i<8;++i)incoming_epoch|=std::uint64_t(bytes[8+i])<<(8*i); + for(unsigned i=0;i<4;++i)incoming_frame|=std::uint32_t(bytes[16+i])<<(8*i); + if(incoming_epoch!=epoch||incoming_frame!=frame)return false; + for(unsigned i=20;i<32;++i)if(bytes[i])return false; + Paks staged;if(!staged.start(epoch,mask_,bytes.subspan(32)))return false; + staged.next_=staged.confirmed_next_=frame;*this=std::move(staged);return true; + } private: struct File { bool used=false;Identity identity{};std::vector data; }; struct Port { std::uint32_t generation=0;std::array files{}; }; diff --git a/runtime-recomp/src/game/netplay/experimental_pump.cpp b/runtime-recomp/src/game/netplay/experimental_pump.cpp index e2850198..2b20eeee 100644 --- a/runtime-recomp/src/game/netplay/experimental_pump.cpp +++ b/runtime-recomp/src/game/netplay/experimental_pump.cpp @@ -22,7 +22,7 @@ bool Pump::pulse(Network::Clock::time_point now,const std::function=next_sample_ && session_.needs_local_input(true)) { - if(session_.sample_local(sample(),true)!=OwnerInputResult::Accepted) return fail("Experimental owner input could not be published."); + if(session_.sample_local(sample(),true,commands_?commands_():dkr::sdk2::Command{})!=OwnerInputResult::Accepted) return fail("Experimental owner input could not be published."); ++view_.physical_samples; next_sample_=(std::max)(next_sample_+period,now-period); } diff --git a/runtime-recomp/src/game/netplay/experimental_pump.hpp b/runtime-recomp/src/game/netplay/experimental_pump.hpp index 7eac0b9c..7dcac5e7 100644 --- a/runtime-recomp/src/game/netplay/experimental_pump.hpp +++ b/runtime-recomp/src/game/netplay/experimental_pump.hpp @@ -31,6 +31,7 @@ class Pump final { // an automatic mid-match switch back to stable mode. bool pulse(Network::Clock::time_point now,const std::function& sample, bool allow_new_tick = true); + void command_source(std::function source){commands_=std::move(source);} const PumpView& view() const { return view_; } const std::string& error() const { return error_; } private: @@ -38,6 +39,7 @@ class Pump final { Session& session_; Network& network_; PumpView view_{}; + std::function commands_; std::string error_; Network::Clock::time_point last_{},next_tick_{},next_sample_{}; bool started_ = false; diff --git a/runtime-recomp/src/game/netplay/experimental_rollback.cpp b/runtime-recomp/src/game/netplay/experimental_rollback.cpp index 63cf8f25..4482c775 100644 --- a/runtime-recomp/src/game/netplay/experimental_rollback.cpp +++ b/runtime-recomp/src/game/netplay/experimental_rollback.cpp @@ -1,3 +1,5 @@ +#include "../rollback_policy.hpp" +#include "confirmed_observer.hpp" #include "experimental_rollback.hpp" #include "experimental_checkpoint_hash.hpp" #include "experimental_performance.hpp" @@ -36,7 +38,7 @@ bool Driver::start(Configuration configuration, std::string& error) { configuration.checkpoint_budget_bytes > 512U * 1024U * 1024U || (active_ && (configuration.epoch <= configuration_.epoch || statistics_.next_frame != statistics_.confirmed_frames))) { - error = "Experimental rollback requires a complete replay-safe simulation contract and a new scene epoch."; + error = "Rollback requires a complete replay-safe simulation contract and a new scene epoch."; return false; } try { @@ -48,7 +50,7 @@ bool Driver::start(Configuration configuration, std::string& error) { if (!simulation_.capture(scratch, error)) return false; if (!checkpoints->save(0U, scratch, checkpoint_hash(scratch)) || checkpoints->allocated_bytes() + scratch.capacity() > configuration.checkpoint_budget_bytes) { - error = "Experimental rollback checkpoint budget is insufficient."; + error = "Rollback checkpoint budget is insufficient."; return false; } configuration_ = configuration; @@ -69,7 +71,7 @@ bool Driver::start(Configuration configuration, std::string& error) { error.clear(); return true; } catch (const std::exception& exception) { - error = std::string("Experimental rollback admission failed: ") + exception.what(); + error = std::string("Rollback admission failed: ") + exception.what(); return false; } } @@ -95,9 +97,10 @@ Driver::Frame& Driver::entry(std::uint32_t frame) { } InputResult Driver::receive(std::uint64_t epoch, std::uint8_t owner, - std::uint32_t frame, PackedInput input) { + std::uint32_t frame, PackedInput input,dkr::sdk2::Command command) { if (!active()) return InputResult::Failed; if (epoch != configuration_.epoch) return InputResult::StaleEpoch; + if (!command.valid()||(owner!=0&&!command.empty()))return InputResult::OutOfRange; if (owner >= configuration_.players || static_cast(frame) > static_cast(statistics_.next_frame) + kFutureInputs || @@ -108,15 +111,16 @@ InputResult Driver::receive(std::uint64_t epoch, std::uint8_t owner, auto* existing = find(frame); const auto mask = static_cast(1U << owner); if (existing && (existing->actual_mask & mask)) { - if (existing->actual[owner] == input) return InputResult::Duplicate; + if (existing->actual[owner] == input && (owner!=0||existing->actual_command==command)) return InputResult::Duplicate; fail("An authenticated owner supplied conflicting final input for the same experimental frame."); return InputResult::Conflict; } if (frame < statistics_.confirmed_frames) return InputResult::OutOfRange; auto& value = entry(frame); value.actual[owner] = input; + if(owner==0)value.actual_command=command; value.actual_mask |= mask; - if (value.simulated && (value.dependencies&mask) && value.used[owner] != input) { + if (value.simulated && (((value.dependencies&mask)&&value.used[owner]!=input)||(owner==0&&value.used_command!=command))) { dirty_frame_ = (std::min)(dirty_frame_, frame); } return InputResult::Accepted; @@ -151,7 +155,7 @@ bool Driver::save(std::uint32_t frame) { checkpoints_->trim_spares(configuration_.checkpoint_budget_bytes-scratch_.capacity()); statistics_.checkpoint_bytes = checkpoints_->allocated_bytes() + scratch_.capacity(); if (statistics_.checkpoint_bytes > configuration_.checkpoint_budget_bytes) - return fail("Experimental rollback exceeded its bounded checkpoint budget."); + return fail("Rollback exceeded its bounded checkpoint budget."); return true; } @@ -191,12 +195,21 @@ bool Driver::confirm() { bool corrected=true; for(unsigned owner=0;ownerdependencies&(1U<used[owner]!=frame->actual[owner])corrected=false; + if(frame->used_command!=frame->actual_command)corrected=false; if(!corrected)break; std::string detail; if (!simulation_.commit(configuration_.epoch, frame->number, frame->output.effects, frame->output.scene_boundary, detail)) { return fail("Confirmed effect delivery failed (will not retry): " + detail); } + // An observer sees actual, committed inputs only. Observation is + // noexcept and opt-in; spectator failures never abort this commit. + dkr::runtime::spectators::observe_confirmed(configuration_.epoch,frame->number, + {{{frame->actual[0].buttons,frame->actual[0].stick_x,frame->actual[0].stick_y}, + {frame->actual[1].buttons,frame->actual[1].stick_x,frame->actual[1].stick_y}, + {frame->actual[2].buttons,frame->actual[2].stick_x,frame->actual[2].stick_y}, + {frame->actual[3].buttons,frame->actual[3].stick_x,frame->actual[3].stick_y}}}, + frame->actual_command.words,frame->output.scene_boundary); confirmed_inputs_ = frame->actual; ++statistics_.confirmed_frames; frame->output.effects.clear(); @@ -276,7 +289,7 @@ Step Driver::step(bool allow_advance) { const auto all = static_cast((1U << configuration_.players) - 1U); const bool agreed = frame.actual_mask == all && statistics_.next_frame == statistics_.confirmed_frames; - if ((simulation_.requires_confirmed_tick() || resource_frame_==frame.number) && !agreed) + if ((simulation_.requires_confirmed_tick() || resource_frame_==frame.number || !frame.actual_command.empty()) && !agreed) return Step::WaitingForConfirmedInput; std::string detail; const auto readiness = simulation_.prepare_tick(configuration_.epoch, frame.number, agreed, detail); @@ -286,13 +299,15 @@ Step Driver::step(bool allow_advance) { return Step::Failed; } frame.used = predict(frame); + frame.used_command=(frame.actual_mask&1)?frame.actual_command:dkr::sdk2::Command{}; frame.dependencies=simulation_.input_dependencies()&all; frame.output.effects.clear(); frame.output.scene_boundary = false; frame.output.confirmation_required = false; bool tick_ok; {performance::Scope timing(performance::Stage::Tick); - tick_ok=simulation_.tick(frame.number, frame.used, frame.output, detail);} + tick_ok=simulation_.development_command(configuration_.epoch,frame.number,frame.used_command,detail)&& + simulation_.tick(frame.number, frame.used, frame.output, detail);} if (!tick_ok) { // The adapter may already have modified its reversible world. // Recover this tick's starting state, then terminate this mode. @@ -337,7 +352,7 @@ Step Driver::step(bool allow_advance) { if (committed_boundary_) return Step::ConfirmedBoundary; return replay ? Step::Replayed : Step::Advanced; } catch (const std::exception& exception) { - fail(std::string("Experimental rollback stopped: ") + exception.what()); + fail(std::string("Rollback stopped: ") + exception.what()); return Step::Failed; } } diff --git a/runtime-recomp/src/game/netplay/experimental_rollback.hpp b/runtime-recomp/src/game/netplay/experimental_rollback.hpp index 1e5709a6..8d5aff3b 100644 --- a/runtime-recomp/src/game/netplay/experimental_rollback.hpp +++ b/runtime-recomp/src/game/netplay/experimental_rollback.hpp @@ -1,6 +1,7 @@ #pragma once #include "netplay_types.hpp" +#include "../mods/sdk2_command.hpp" #include "rollback_state_store.hpp" #include @@ -92,6 +93,9 @@ class Simulation { // the entire world unchanged; native stacks/OS queues are not checkpoints. virtual bool restore(std::span state, std::string& error) = 0; + virtual bool development_command(std::uint64_t,std::uint32_t,const dkr::sdk2::Command& command,std::string& error) { + if(command.empty())return true;error="This simulation has no development command adapter.";return false; + } virtual bool tick(std::uint32_t frame, const FrameInputs& inputs, TickOutput& output, std::string& error) = 0; virtual bool commit(std::uint64_t epoch, std::uint32_t frame, @@ -140,7 +144,7 @@ class Driver final { // stable-mode state, and no reinterpretation of its committed predictions. bool start(Configuration configuration, std::string& error); InputResult receive(std::uint64_t epoch, std::uint8_t owner, - std::uint32_t frame, PackedInput input); + std::uint32_t frame, PackedInput input,dkr::sdk2::Command command={}); // At most one simulated tick per call, including correction replay. The // caller controls a wall-clock/replay budget and can always service UI. // Confirmation and correction remain serviceable when the caller has no @@ -165,6 +169,7 @@ class Driver final { bool simulated = false; std::uint8_t actual_mask = 0U; std::uint8_t dependencies = 15U; + dkr::sdk2::Command actual_command{},used_command{}; FrameInputs actual{}; FrameInputs used{}; TickOutput output{}; diff --git a/runtime-recomp/src/game/netplay/experimental_runtime.cpp b/runtime-recomp/src/game/netplay/experimental_runtime.cpp index 9f4371cb..d4ca7c2f 100644 --- a/runtime-recomp/src/game/netplay/experimental_runtime.cpp +++ b/runtime-recomp/src/game/netplay/experimental_runtime.cpp @@ -1,4 +1,10 @@ +#include "../rollback_policy.hpp" +#include "party_runtime.hpp" +#include "party_camera.hpp" +#include "vehicle_binding.hpp" +#include "../achievements.hpp" #include "experimental_runtime.hpp" +#include "../mods/sdk2_bridge.hpp" #include "experimental_lobby_admission.hpp" #include "experimental_checkpoint_hash.hpp" #include "experimental_confirmed_output.hpp" @@ -115,6 +121,9 @@ class ObservedOwnedGame final:public OwnedGame { std::unique_ptr game_;OwnerActivity& activity_; public: ObservedOwnedGame(std::unique_ptr game,OwnerActivity& activity):game_(std::move(game)),activity_(activity) {} + bool development_command(std::uint64_t epoch,std::uint32_t frame,const dkr::sdk2::Command& command,std::string& error)override { + return game_->development_command(epoch,frame,command,error); + } SimulationContract contract() const override {return game_->contract();} bool requires_confirmed_tick() const override {return game_->requires_confirmed_tick();} std::uint8_t input_dependencies() const override {return game_->input_dependencies();} @@ -154,6 +163,7 @@ class ObservedOwnedGame final:public OwnedGame { } std::uint32_t confirmed_count() const override {return game_->confirmed_count();} std::span confirmed_save() const override {return game_->confirmed_save();} + std::span confirmed_records() const override {return game_->confirmed_records();} std::span confirmed_paks() const override {return game_->confirmed_paks();} bool install_initial_paks(std::uint64_t epoch,std::span images,std::string& error) override { return game_->install_initial_paks(epoch,images,error); @@ -234,13 +244,171 @@ std::uint32_t pack(PackedInput p) {return p.buttons|(std::uint32_t(std::uint8_t( PackedInput unpack(std::uint32_t p) {return {std::uint16_t(p),std::int8_t(p>>16),std::int8_t(p>>24)};} } bool runtime_available(rom::Revision revision) {return owned_adapter_available(revision);} -std::string_view runtime_engine_identity(rom::Revision revision) {return owned_adapter_identity(revision);} +std::string_view runtime_engine_identity(rom::Revision revision) { + auto identify=[](rom::Revision r){auto base=std::string(owned_adapter_identity(r));if(base.empty())return base; + base+="dcb1e8df777d341f2e96d4ee78cdc4337d32a6ddc86b4b60dbcc46fabfd31efc";base+="893bbedb02a3c87771e75befab107a2ce54641073742be69e97b995a9d0a3bbf";base+="2a82142dc00619b29b80f5f0b6485c3ffe8c0a33e89a7a6c48d0a6ac8eeb4a66";base+="c4d77b07ed3b7f9aabcf7ce1430780fc65a1ce086e62b66a0af672aac40eb5ac";const auto h=digest(std::span(reinterpret_cast(base.data()),base.size())); + std::string hex;constexpr char digits[]="0123456789abcdef";for(auto c:h){hex+=digits[c>>4];hex+=digits[c&15];}return hex;}; + static const auto v77=identify(rom::Revision::UsV77),v80=identify(rom::Revision::UsV80); + return revision==rom::Revision::UsV77?v77:revision==rom::Revision::UsV80?v80:std::string_view{}; +} RuntimeView runtime_view() {std::scoped_lock lock(view_mutex);return view;} void request_runtime_stop() {requested_stop=true;} +bool run_party_runtime(ultramodern::renderer::WindowHandle window,const std::filesystem::path& canonical_rom, + std::vector bootstrap,DirectSession& lobby,const LaunchDescriptor& launch, + unsigned timeout_seconds,std::string& error,std::shared_ptr mods, + std::vector mod_bootstrap,std::vector code_bootstrap) { + namespace party=dkr::runtime::spectators; + auto hub=party::party_link(&lobby);const auto initial=party::party_link_view(&lobby); + std::vector seed; + {std::scoped_lock lock(hub->mutex);seed=hub->seed;} + if(!initial.enabled||!initial.admitted||seed.size()!=512||bootstrap.empty()) {error="The party launch is missing its verified local bootstrap/save.";return false;} + if(initial.stopping){party::party_returned(&lobby,{});ui::report_online_game_end(initial.status,lobby.active());error.clear();return true;} + std::fprintf(stderr,"[party][boot] role=%s racers=%u seed=verified\n",initial.host?"host":initial.party.controls[0].member==initial.local?"racer-p1":"member",unsigned(launch.player_count)); + Shared shared;requested_stop=false;shared.confirmed_save=seed; + std::atomic local_port{4};std::atomic watching{true}; + ConfirmedSaveWriter save_writer([&,host=initial.host](const ConfirmedSaveSnapshot& s,std::string& e) { + // Only the authority stores the communal online bank. Observers never + // install host state into a personal save, records bank or controller Pak. + if(host&&!launch.development) { + if(!saves::commit_online_adventure(true,launch.match_id,s.eeprom,e)|| + !saves::commit_experimental_online_paks(true,launch.match_id,s.paks,e))return false; + if(!s.records.empty()) { + std::vector ignored;std::filesystem::path owner; + if(!saves::read_online_adventure(true,launch.match_id,ignored,owner,e)|| + !saves::records::persist(owner.parent_path()/"custom-records-v1.bin",s.records,e))return false; + } + } + std::scoped_lock lock(shared.mutex);shared.confirmed_save=s.eeprom;e.clear();return true; + }); + RuntimeWorkers workers{shared,save_writer}; + const auto saved_layout=online_view::requested_layout.load(); + platform::set_online_input_routing(true,launch.occupied_mask,255); + workers.render=std::thread([&] { + WorkerCompletion completed{shared.renderer_done}; + try { + std::vector parked(PresentationMailbox::kImageBytes); + RT64Renderer renderer(parked.data(),window,false,true); + if(!renderer.valid())throw std::runtime_error("The party renderer could not start."); + auto config=ultramodern::renderer::get_graphics_config();shared.renderer_ready=true; + std::uint64_t generation=0;unsigned previous_target=99;bool previous_watching=true;party::WatchCamera previous_camera=party::WatchCamera::AllSplits; + while(!shared.stop) { + renderer.reap_owned();auto next_config=ultramodern::renderer::get_graphics_config(); + if(config!=next_config){renderer.update_config(config,next_config);config=next_config;} + const auto v=party::party_link_view(&lobby);const auto p=local_port.load(); + const auto auto_target=unsigned(std::chrono::duration_cast(party::PartyRuntime::Clock::now().time_since_epoch()).count()/8)%launch.player_count; + const auto target=watching.load()?(v.camera==party::WatchCamera::Director?auto_target:v.target):p; + if(watching.load())online_view::requested_layout=v.camera==party::WatchCamera::AllSplits?online_view::Layout::SplitScreen:online_view::Layout::MyRacer; + else if(previous_watching)online_view::requested_layout=saved_layout; + previous_watching=watching.load(); + const auto custom_cars=dkr::mods::vehicles::render_resources(); + // The legacy private camera closure predates GPU-only custom + // car draws. Keep those cars/seat alignment intact by using + // the automatic full-screen racing camera for their director. + party::spectator_director=watching.load()&&v.camera==party::WatchCamera::Director&& + (!custom_cars||custom_cars->library.packages.empty()); + if(generation!=v.party.generation||target!=previous_target||previous_camera!=v.camera) { + online_view::Session session;session.players=launch.player_count;session.owner=std::min(target,unsigned(launch.player_count-1)); + for(const auto& m:v.party.members)if(m.port)session.names[*m.port]=m.name; + renderer.set_online_view_session(std::move(session));generation=v.party.generation;previous_target=target;previous_camera=v.camera; + } + bool presented=false; + if(renderer.owned_capacity_available())if(auto lease=shared.mailbox.take()){renderer.present_owned(lease,shared.mailbox);presented=true;} + if(!presented){renderer.repeat_owned();std::this_thread::sleep_for(std::chrono::milliseconds(1));} + } + }catch(const std::exception& e){shared.fail(e.what());}catch(...){shared.fail("Party presentation worker failed.");} + }); + workers.simulation=std::thread([&] { + WorkerCompletion completed{shared.simulation_done};std::unique_ptr world; + try { + auto bytes=read_rom(canonical_rom);std::string message;constexpr std::uint64_t epoch=91; + if(!shared.mailbox.begin_epoch(epoch))throw std::runtime_error("Party presentation epoch refused."); + const auto revision=lobby.view().room.manifest.revision==Revision::UsV80?rom::Revision::UsV80:rom::Revision::UsV77; + world=make_owned_game_for_revision(revision,bootstrap,bytes,epoch,shared.mailbox, + [&](ConfirmedAudio a,std::string& e){return shared.audio.push(a.rate,a.pcm,e);},message,false, + std::uint32_t(lobby.view().room.manifest.magic_codes_hash),seed, + OwnedBootOptions{true,launch.player_count,launch.host_control,enhancements::multiplayer_race_music_enabled(),mods, + std::move(mod_bootstrap),mods?mods->code():nullptr,std::move(code_bootstrap),launch.match_id,launch.development}); + if(!world)throw std::runtime_error(message); + std::fprintf(stderr,"[party][boot] owned world ready; bytes=%zu\n",world->contract().state_bytes); + if(initial.host) {std::vector paks;if(!saves::read_experimental_online_paks(true,launch.match_id,paks,message)||!world->install_initial_paks(epoch,paks,message))throw std::runtime_error(message);} + dkr::sdk2::authority(initial.host,launch.development); + world->set_confirmed_rumble_sink([&](unsigned p,bool enabled){if(!watching.load()&&p==local_port.load())shared.confirmed_motor.store(enabled?1:0);}); + party::PartyRuntime runtime(&lobby,*world,shared.mailbox,launch,[](auto b){return digest(b);}); + if(!runtime.begin(message))throw std::runtime_error(message); + std::fprintf(stderr,"[party][boot] checkpoint admission started\n"); + bool prior_watching=true;unsigned prior_port=4;auto next_save=Network::Clock::now()+std::chrono::seconds(5); + while(!shared.stop) { + OwnerActivity::Scope stage(shared.simulation_activity,OwnerStage::Pump); + const auto now=Network::Clock::now(); + if(!runtime.pulse(now,unpack(shared.input.load()),message)) { + if(!message.empty())throw std::runtime_error(message);shared.stop=true;break; + } + watching=runtime.watching();local_port=runtime.local_port(); + if(prior_watching!=watching.load()||prior_port!=local_port.load()) { + // A role transition starts fresh local achievement context; + // no observation from spectating can retroactively unlock. + dkr::runtime::achievements::begin(true,local_port.load(),bool(mods),launch.development||watching.load()); + prior_watching=watching.load();prior_port=local_port.load(); + } + const auto cursor=runtime.next(); + const auto stats=runtime.statistics(); + report({true,PumpWait::None,cursor.epoch,cursor.frame,runtime.confirmed(),stats.rollbacks,stats.replayed_frames,runtime.status(),cursor.epoch==0}); + if(now>=next_save) { + if(initial.host&&!save_writer.submit(world->confirmed_save(),world->confirmed_paks(),message,world->confirmed_records()))throw std::runtime_error(message); + next_save=now+std::chrono::seconds(5); + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }catch(const std::exception& e){std::fprintf(stderr,"[party][failure] %s\n",e.what());shared.fail(e.what());if(!initial.host&&party::party_watching(&lobby))lobby.disconnect("Stopped watching: "+std::string(e.what()));else party::party_stop(&lobby,e.what());} + catch(...){shared.fail("Party simulation worker failed.");if(!initial.host&&party::party_watching(&lobby))lobby.disconnect("Stopped watching.");else party::party_stop(&lobby,"Party simulation stopped.");} + if(world&&initial.host) {std::string message;if(!save_writer.submit(world->confirmed_save(),world->confirmed_paks(),message,world->confirmed_records()))shared.fail(message);} + }); + const auto start=Network::Clock::now();ConfirmedAudioMailbox::Block block; + std::array pcm{};unsigned routed=99; + std::uint16_t prior_buttons=0; + while(!shared.stop&&!shared.simulation_done) { + const auto port=local_port.load(); + if(port!=routed){for(int p=0;p<4;++p)platform::set_rumble(p,false);platform::set_online_input_routing(true,launch.occupied_mask,port<4?std::uint8_t(port):255);routed=port;} + const auto motor=shared.confirmed_motor.exchange(-1);if(motor>=0&&port<4&&!watching.load())platform::set_rumble(int(port),motor!=0); + platform::pump_window_events(nullptr);platform::poll_input(); + std::uint16_t buttons=0;float x=0,y=0;bool blocked=false;platform::get_local_online_input(&buttons,&x,&y,&blocked); + const auto pressed=std::uint16_t(buttons&~prior_buttons);prior_buttons=buttons; + if(watching.load()&&!blocked) { + const auto camera=party::party_link_view(&lobby); + if(pressed&0x0008)party::party_camera(&lobby,party::WatchCamera((unsigned(camera.camera)+1)%3),camera.target); + if(pressed&0x0300)party::party_camera(&lobby,party::WatchCamera::Follow, + (camera.target+((pressed&0x0100)?1:unsigned(launch.player_count-1)))%launch.player_count); + } + shared.input=pack(blocked||watching.load()?PackedInput{}:PackedInput{buttons,std::int8_t(std::clamp(x,-1.0F,1.0F)*80),std::int8_t(std::clamp(y,-1.0F,1.0F)*80)}); + for(unsigned i=0;i=std::chrono::seconds(timeout_seconds))) { + std::fprintf(stderr,"[party][exit] role=%s requested=%u lifecycle=%d\n",initial.host?"host":"client",unsigned(requested_stop.load()),int(lifecycle)); + if(lifecycle==ui::LifecycleRequest::Exit||lifecycle==ui::LifecycleRequest::Restart||requested_stop)lobby.disconnect(initial.host?"The host closed the lobby.":"Left the lobby."); + else if(watching.load()&&!initial.host)lobby.disconnect("Stopped watching."); + else party::party_stop(&lobby,"Returned to the lobby."); + shared.stop=true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + workers.retire(); + party::spectator_director=false;online_view::requested_layout=saved_layout; + {std::scoped_lock lock(shared.mutex);error=shared.failure;party::party_returned(&lobby,shared.confirmed_save);} + if(ui::lifecycle_request()!=ui::LifecycleRequest::Exit&&ui::lifecycle_request()!=ui::LifecycleRequest::Restart) + ui::report_online_game_end(error.empty()?party::party_link_view(&lobby).status:error,lobby.active()); + return error.empty(); +} + bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesystem::path& canonical_rom, std::vector bootstrap,DirectSession& lobby,const LaunchDescriptor& accepted_launch, unsigned timeout_seconds,std::string& error,bool scripted_check, std::shared_ptr mods,std::vector mod_bootstrap,std::vector code_bootstrap) { + if(dkr::runtime::spectators::party_enabled(&lobby))return run_party_runtime(window,canonical_rom,std::move(bootstrap),lobby,accepted_launch,timeout_seconds,error,std::move(mods),std::move(mod_bootstrap),std::move(code_bootstrap)); const std::optional launch{accepted_launch};const auto initial=lobby.runtime_view(); // A departure may retire the accepted descriptor during cold boot, or // between this caller and the worker startup. Validate against the caller's @@ -261,6 +429,7 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste // All peers use the same compact N64 owner roster even if a departed lobby // slot leaves a hole. The authenticated network routes retain their slots. LobbyRoute route(lobby,*launch);const auto local=route.owner(initial.local_slot); + dkr::runtime::achievements::begin(true,local,bool(mods),launch->development); if(local>=launch->player_count){error="The local racer is absent from the accepted roster.";return false;} std::vector seed;std::filesystem::path save_path; if(!saves::read_online_adventure(initial.host,launch->match_id,seed,save_path,error)|| @@ -272,6 +441,7 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste ConfirmedSaveWriter save_writer([&,host=initial.host,match=launch->match_id, last_save=seed,last_paks=std::vector{}](const ConfirmedSaveSnapshot& snapshot,std::string& message) mutable { OwnerActivity::Scope stage(shared.save_activity,OwnerStage::SaveWrite); + if(launch->development){message.clear();return true;} if(snapshot.eeprom!=last_save) { if(!saves::commit_online_adventure(host,match,snapshot.eeprom,message))return false; last_save=snapshot.eeprom; @@ -280,6 +450,11 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste if(!saves::commit_experimental_online_paks(host,match,snapshot.paks,message))return false; last_paks=snapshot.paks; } + if(!snapshot.records.empty()) { + std::vector ignored;std::filesystem::path owner; + if(!saves::read_online_adventure(host,match,ignored,owner,message)|| + !saves::records::persist(owner.parent_path()/"custom-records-v1.bin",snapshot.records,message))return false; + } std::scoped_lock lock(shared.mutex);shared.confirmed_save=snapshot.eeprom;return true; }); RuntimeWorkers workers{shared,save_writer}; @@ -330,9 +505,10 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste message,false,std::uint32_t(lobby.view().room.manifest.magic_codes_hash),seed, OwnedBootOptions{true,launch->player_count,launch->host_control, enhancements::multiplayer_race_music_enabled(),mods,std::move(mod_bootstrap), - mods?mods->code():nullptr,std::move(code_bootstrap),launch->match_id}); + mods?mods->code():nullptr,std::move(code_bootstrap),launch->match_id,launch->development}); } if(!world)throw std::runtime_error(message); + dkr::sdk2::authority(initial.host,launch->development); world=std::make_unique(std::move(world),shared.simulation_activity); if(initial.host) { std::vector pak_seed; @@ -414,6 +590,7 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste simulation_started=true; NetworkConfiguration config;if(!route.configuration(admission.incarnation(),config,message))throw std::runtime_error(message); Network network(session,admission);if(!network.start(config,message))throw std::runtime_error(message);Pump pump(session,network); + pump.command_source([local,development=launch->development]{return local==0&&development?dkr::sdk2::take(true):dkr::sdk2::Command{};}); std::uint64_t published_epoch=0;std::uint32_t published_frame=UINT32_MAX,published_replays=UINT32_MAX; auto last_save_at=seed; const auto initial_paks=world->confirmed_paks(); @@ -499,7 +676,7 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste const auto next_view=std::tuple{pump.view().wait,session.epoch(), stats.next_frame,stats.confirmed_frames,stats.rollbacks,stats.replayed_frames}; if(!reported_view||*reported_view!=next_view) { - report({true,pump.view().wait,session.epoch(),stats.next_frame,stats.confirmed_frames,stats.rollbacks,stats.replayed_frames,"Experimental rollback",false,session.missing_input_mask()}); + report({true,pump.view().wait,session.epoch(),stats.next_frame,stats.confirmed_frames,stats.rollbacks,stats.replayed_frames,"Rollback",false,session.missing_input_mask()}); reported_view=next_view; } if(now>=next_save) { @@ -507,8 +684,8 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste const auto confirmed=world->confirmed_save(); const auto paks=world->confirmed_paks(); if(!std::equal(confirmed.begin(),confirmed.end(),last_save_at.begin(),last_save_at.end())|| - !std::equal(paks.begin(),paks.end(),last_paks_at.begin(),last_paks_at.end())) { - if(!save_writer.submit(confirmed,paks,message))throw std::runtime_error(message); + !std::equal(paks.begin(),paks.end(),last_paks_at.begin(),last_paks_at.end())||!world->confirmed_records().empty()) { + if(!save_writer.submit(confirmed,paks,message,world->confirmed_records()))throw std::runtime_error(message); // These are enqueued, not yet durable. The writer // owns the persistence proof and final return fence. last_save_at.assign(confirmed.begin(),confirmed.end()); @@ -539,7 +716,7 @@ bool run_runtime(ultramodern::renderer::WindowHandle window,const std::filesyste if(world) try { OwnerActivity::Scope stage(shared.simulation_activity,OwnerStage::SaveSnapshot); std::string message; - if(!save_writer.submit(world->confirmed_save(),world->confirmed_paks(),message))shared.fail(message); + if(!save_writer.submit(world->confirmed_save(),world->confirmed_paks(),message,world->confirmed_records()))shared.fail(message); } catch(const std::exception& e){shared.fail(e.what());} catch(...){shared.fail("The confirmed online save could not be retained.");} }); diff --git a/runtime-recomp/src/game/netplay/experimental_session.cpp b/runtime-recomp/src/game/netplay/experimental_session.cpp index ecd5805d..71864683 100644 --- a/runtime-recomp/src/game/netplay/experimental_session.cpp +++ b/runtime-recomp/src/game/netplay/experimental_session.cpp @@ -1,3 +1,4 @@ +#include "../rollback_policy.hpp" #include "experimental_session.hpp" #include #include @@ -28,8 +29,8 @@ bool Session::service_inputs() { if(phase_==Phase::Running && !timeline_.service_inputs())return fail(timeline_.error()); return true; } -OwnerInputResult Session::sample_local(PackedInput input,bool live_lead) { - return needs_local_input(live_lead) ? timeline_.sample_local(input,live_lead) : OwnerInputResult::Rejected; +OwnerInputResult Session::sample_local(PackedInput input,bool live_lead,dkr::sdk2::Command command) { + return needs_local_input(live_lead) ? timeline_.sample_local(input,live_lead,command) : OwnerInputResult::Rejected; } SessionInputResult Session::receive_input(std::uint8_t peer, std::span bytes) { if (phase_==Phase::Inactive || phase_==Phase::Failed || peer>=configuration_.simulation.players || @@ -117,7 +118,7 @@ SessionStep Session::step(bool allow_advance) try { switch (world_.prepare_scene(intent_.scene_hash,baseline,detail)) { case PreparationStep::Pending:return SessionStep::PreparingScene; case PreparationStep::Failed: - fail("Experimental scene preparation failed: "+detail); return SessionStep::Failed; + fail("Online scene preparation failed: "+detail); return SessionStep::Failed; case PreparationStep::Ready: if (world_.contract().schema!=schema_) { fail("Experimental checkpoint schema changed during preparation."); return SessionStep::Failed; diff --git a/runtime-recomp/src/game/netplay/experimental_session.hpp b/runtime-recomp/src/game/netplay/experimental_session.hpp index 5c57c56d..7e0422a6 100644 --- a/runtime-recomp/src/game/netplay/experimental_session.hpp +++ b/runtime-recomp/src/game/netplay/experimental_session.hpp @@ -35,7 +35,7 @@ class Session final { explicit Session(SceneSimulation& world) : world_(world), timeline_(world) {} bool start(TimelineConfiguration configuration, std::string& error); bool needs_local_input(bool live_lead=false) const; - OwnerInputResult sample_local(PackedInput input,bool live_lead=false); + OwnerInputResult sample_local(PackedInput input,bool live_lead=false,dkr::sdk2::Command command={}); SessionInputResult receive_input(std::uint8_t peer, std::span bytes); bool service_inputs(); // Owner-only ledger admission, never a simulation tick. EpochResult receive_control(std::uint8_t peer, std::span bytes); diff --git a/runtime-recomp/src/game/netplay/experimental_timeline.cpp b/runtime-recomp/src/game/netplay/experimental_timeline.cpp index a262cea2..4c0065bc 100644 --- a/runtime-recomp/src/game/netplay/experimental_timeline.cpp +++ b/runtime-recomp/src/game/netplay/experimental_timeline.cpp @@ -33,22 +33,22 @@ bool Timeline::needs_local_input(bool live_lead) const { const auto limit=std::uint64_t(frontier_)+configuration_.input_delay+(live_lead ? 2 : 1); return inputs_.received_next(configuration_.local_owner) bytes) { if (!started_ || !error().empty()) return OwnerInputResult::Rejected; @@ -57,7 +57,7 @@ OwnerInputResult Timeline::receive_authenticated(std::uint8_t peer,std::span delivered; const auto result=inputs_.receive_authenticated(peer,*packet,delivered); if (result==OwnerInputResult::Conflict) error_="Conflicting immutable experimental owner input."; - for (const auto& actual:delivered) if (!stage(actual.owner,actual.frame,actual.input)) return OwnerInputResult::Rejected; + for (const auto& actual:delivered) if (!stage(actual.owner,actual.frame,actual.input,actual.command)) return OwnerInputResult::Rejected; return result; } std::optional Timeline::packet_for(std::uint8_t owner,std::uint8_t peer,OwnerInputSend lane) { @@ -73,7 +73,7 @@ bool Timeline::feed() { for (auto& pending:pending_[owner]) if (pending.valid) { if (pending.framestd::uint64_t(state.next_frame)+48) continue; - const auto result=driver_.receive(configuration_.simulation.epoch,owner,pending.frame,pending.input); + const auto result=driver_.receive(configuration_.simulation.epoch,owner,pending.frame,pending.input,pending.command); if (result!=InputResult::Accepted && result!=InputResult::Duplicate) { error_="Experimental driver rejected admitted owner input."; return false; } diff --git a/runtime-recomp/src/game/netplay/experimental_timeline.hpp b/runtime-recomp/src/game/netplay/experimental_timeline.hpp index e42a43a1..d46ae714 100644 --- a/runtime-recomp/src/game/netplay/experimental_timeline.hpp +++ b/runtime-recomp/src/game/netplay/experimental_timeline.hpp @@ -24,7 +24,7 @@ class Timeline final { // the presentation frontier does not rewind. The paced live clock may // publish a bounded lead, never overwrite a final sample on the wire. bool needs_local_input(bool live_lead=false) const; - OwnerInputResult sample_local(PackedInput input,bool live_lead=false); + OwnerInputResult sample_local(PackedInput input,bool live_lead=false,dkr::sdk2::Command command={}); OwnerInputResult receive_authenticated(std::uint8_t peer, std::span bytes); // Admit received immutable inputs into the driver's ledger BEFORE querying // whether to sample. Does not capture, restore, tick or commit the world. @@ -42,8 +42,8 @@ class Timeline final { std::uint8_t missing_input_mask() const {return driver_.missing_input_mask();} bool scene_boundary_pending() const { return driver_.scene_boundary_pending(); } private: - struct Pending { std::uint32_t frame = 0; PackedInput input{}; bool valid = false; }; - bool stage(std::uint8_t owner, std::uint32_t frame, PackedInput input); + struct Pending { std::uint32_t frame = 0; PackedInput input{}; bool valid = false; dkr::sdk2::Command command{}; }; + bool stage(std::uint8_t owner, std::uint32_t frame, PackedInput input,dkr::sdk2::Command command={}); bool feed(); Driver driver_; OwnerInputHistory inputs_; diff --git a/runtime-recomp/src/game/netplay/netplay_protocol.cpp b/runtime-recomp/src/game/netplay/netplay_protocol.cpp index 7ee34c40..53ba43ae 100644 --- a/runtime-recomp/src/game/netplay/netplay_protocol.cpp +++ b/runtime-recomp/src/game/netplay/netplay_protocol.cpp @@ -1,3 +1,4 @@ +#include "../rollback_policy.hpp" #include "netplay_protocol.hpp" #include "mods/online_mod_manifest.hpp" @@ -51,9 +52,8 @@ bool Take64(std::span bytes, std::size_t& cursor, } bool ValidType(std::uint8_t type) { - return type >= static_cast(MessageType::Hello) && - type <= - static_cast(MessageType::ModData); + return type==250 || (type >= static_cast(MessageType::Hello) && + type <= static_cast(MessageType::ModData)); } bool PutString(std::vector& out, std::string_view value, @@ -1063,6 +1063,7 @@ std::vector encode_lobby_state(const LobbyStatePayload& payload) { std::uint8_t rule_flags = payload.rules.automatic_input_delay ? 1U : 0U; rule_flags |= payload.rules.relay_only ? 2U : 0U; rule_flags |= payload.rules.record_replay ? 4U : 0U; + rule_flags |= payload.rules.development ? 8U : 0U; out.push_back(rule_flags); out.push_back(payload.rules.manual_input_delay); out.push_back(payload.rules.rollback_window); @@ -1122,7 +1123,7 @@ bool decode_lobby_state(std::span bytes, } if (visibility > static_cast(Visibility::Lan) || host_control > static_cast(HostControlPolicy::EveryAssignedPort) || - (rule_flags & ~7U) != 0U || + (rule_flags & ~15U) != 0U || !valid_synchronization_mode(static_cast(synchronization)) || payload.input_delay_frames > 9U || countdown_active > 1U || payload.countdown_remaining_ms > 5000U) { @@ -1134,9 +1135,14 @@ bool decode_lobby_state(std::span bytes, payload.rules.automatic_input_delay = (rule_flags & 1U) != 0U; payload.rules.relay_only = (rule_flags & 2U) != 0U; payload.rules.record_replay = (rule_flags & 4U) != 0U; + payload.rules.development=(rule_flags&8U)!=0U; payload.rules.synchronization = static_cast(synchronization); payload.countdown_active = countdown_active != 0U; + if (!dkr::runtime::netplay::rollback_policy::supported(payload.rules.synchronization)) { + error = dkr::runtime::netplay::rollback_policy::admission_error(payload.rules.synchronization); + return false; + } if (!valid_rules(payload.rules)) { error = "Lobby rules are outside the supported limits."; return false; @@ -1291,7 +1297,7 @@ std::vector encode_start(const StartPayload& payload) { return {}; } std::vector out; - out.reserve(39U); + out.reserve(40U); out.push_back(payload.stage); Put64(out, payload.descriptor.match_id); Put64(out, payload.descriptor.lobby_generation); @@ -1303,6 +1309,7 @@ std::vector encode_start(const StartPayload& payload) { out.push_back(static_cast( payload.descriptor.synchronization)); out.push_back(static_cast(payload.descriptor.host_control)); + out.push_back(payload.descriptor.development?1U:0U); Put64(out, payload.descriptor_hash); return out; } @@ -1310,7 +1317,7 @@ std::vector encode_start(const StartPayload& payload) { bool decode_start(std::span bytes, StartPayload& payload, std::string& error) { payload = {}; - if (bytes.size() != 39U || bytes[0] > 1U) { + if (bytes.size() != 40U || bytes[0] > 1U) { error = "Start descriptor is truncated."; return false; } @@ -1332,6 +1339,10 @@ bool decode_start(std::span bytes, StartPayload& payload, } payload.descriptor.synchronization = static_cast(bytes[cursor++]); + if (!dkr::runtime::netplay::rollback_policy::supported(payload.descriptor.synchronization)) { + error = dkr::runtime::netplay::rollback_policy::admission_error(payload.descriptor.synchronization); + return false; + } if (bytes[cursor] > static_cast( HostControlPolicy::EveryAssignedPort)) { error = "Start descriptor host policy is invalid."; @@ -1339,6 +1350,8 @@ bool decode_start(std::span bytes, StartPayload& payload, } payload.descriptor.host_control = static_cast(bytes[cursor++]); + if(bytes[cursor]>1){error="Invalid development-lobby flag.";return false;} + payload.descriptor.development=bytes[cursor++]!=0; if (!Take64(bytes, cursor, payload.descriptor_hash) || cursor != bytes.size() || !valid_launch_descriptor(payload.descriptor) || diff --git a/runtime-recomp/src/game/netplay/netplay_types.cpp b/runtime-recomp/src/game/netplay/netplay_types.cpp index 6443c7c0..4c879e8e 100644 --- a/runtime-recomp/src/game/netplay/netplay_types.cpp +++ b/runtime-recomp/src/game/netplay/netplay_types.cpp @@ -1,3 +1,4 @@ +#include "../rollback_policy.hpp" #include "netplay_types.hpp" #include @@ -8,7 +9,7 @@ namespace dkr::runtime::netplay { bool valid_launch_descriptor(const LaunchDescriptor& descriptor) { - if (descriptor.match_id == 0U || descriptor.lobby_generation == 0U || + if (!dkr::runtime::netplay::rollback_policy::supported(descriptor.synchronization) || descriptor.match_id == 0U || descriptor.lobby_generation == 0U || descriptor.compatibility_hash == 0U || descriptor.player_count < 2U || descriptor.player_count > kSupportedOnlinePlayers || descriptor.input_delay_frames > kMaximumInputDelayFrames || @@ -18,6 +19,7 @@ bool valid_launch_descriptor(const LaunchDescriptor& descriptor) { descriptor.rollback_window < 2U) || (descriptor.synchronization == SynchronizationMode::Lockstep && descriptor.rollback_window != 0U) || + (descriptor.development&&descriptor.synchronization!=SynchronizationMode::ExperimentalRollback) || descriptor.host_control > HostControlPolicy::EveryAssignedPort || (descriptor.occupied_mask & 1U) == 0U) { return false; @@ -44,6 +46,7 @@ std::uint64_t launch_descriptor_hash(const LaunchDescriptor& descriptor) { append(descriptor.rollback_window, 1U); append(static_cast(descriptor.synchronization), 1U); append(static_cast(descriptor.host_control), 1U); + append(descriptor.development,1U); return stable_hash(bytes); } namespace { @@ -88,7 +91,8 @@ bool valid_room_name(std::string_view value) { } bool valid_rules(const Rules& rules) { - return rules.maximum_players >= 2U && rules.maximum_players <= kSupportedOnlinePlayers && + if (!dkr::runtime::netplay::rollback_policy::supported(rules.synchronization)) return false; + return (!rules.development||rules.synchronization==SynchronizationMode::ExperimentalRollback)&&rules.maximum_players >= 2U && rules.maximum_players <= kSupportedOnlinePlayers && rules.manual_input_delay <= kMaximumInputDelayFrames && valid_synchronization_mode(rules.synchronization) && ((synchronization_has_prediction_window(rules.synchronization) && diff --git a/runtime-recomp/src/game/netplay/netplay_types.hpp b/runtime-recomp/src/game/netplay/netplay_types.hpp index 0eb746ae..efc8b9c6 100644 --- a/runtime-recomp/src/game/netplay/netplay_types.hpp +++ b/runtime-recomp/src/game/netplay/netplay_types.hpp @@ -44,7 +44,7 @@ namespace dkr::runtime::netplay { // An older peer must never interpret these tokens/checkpoints as its roster. // v50 checks the replay engine and prepared resources before lobby admission, // and carries named startup contract components instead of an opaque digest. -inline constexpr std::uint32_t kProtocolVersion = 50U; +inline constexpr std::uint32_t kProtocolVersion = 51U; inline constexpr std::uint8_t kMaximumInputDelayFrames = 9U; inline constexpr std::size_t kMaximumPlayers = 4U; // Regular races/minigames support 2-4 peers. Adventure remains two-player. @@ -143,6 +143,7 @@ struct Rules { SynchronizationMode synchronization = SynchronizationMode::Rollback; bool relay_only = false; bool record_replay = true; + bool development = false; bool operator==(const Rules&) const = default; }; @@ -186,6 +187,8 @@ struct LaunchDescriptor { HostControlPolicy host_control = HostControlPolicy::GuidedUntilCharacterSelect; + bool development = false; + constexpr bool occupied(std::size_t slot) const { return slot < kMaximumPlayers && (occupied_mask & static_cast(1U << slot)) != 0U; diff --git a/runtime-recomp/src/game/netplay/online_mod_admission.cpp b/runtime-recomp/src/game/netplay/online_mod_admission.cpp index b5d19c13..11ce6b5b 100644 --- a/runtime-recomp/src/game/netplay/online_mod_admission.cpp +++ b/runtime-recomp/src/game/netplay/online_mod_admission.cpp @@ -1,3 +1,4 @@ +#include "party_link.hpp" #include "direct_session.hpp" #include "mods/online_mod_manifest.hpp" #include @@ -42,7 +43,7 @@ bool DirectSession::send_mod_packet(std::uint64_t target,std::spanmods_approved && !pending->mods_verified){address=pending->address;key=&pending->key;} } else if(!is_host_ && joining(state_) && mod_offer_ && (target==0 || target==host_sender_id_)) {address=host_address_;key=&key_;} @@ -54,6 +55,7 @@ bool DirectSession::send_mod_packet(std::uint64_t target,std::spanmutex);party->pending_watch.erase(pending.sender_id);} if(pending.mod_route_retained && transport_)transport_->release_peer_route(pending.address); if(pending.active) { for(auto* queue:{&normal_priority_outbound_,&bulk_outbound_}) @@ -76,7 +78,7 @@ bool DirectSession::complete_local_mod_preparation(std::string_view digest,std:: } bool DirectSession::complete_mod_admission(std::uint64_t request,std::string_view digest,std::string_view resources,std::string& error) { std::scoped_lock lock(mutex_);auto* pending=pending_by_request(request); - if(!is_host_ || !waiting(state_) || !pending || !pending->mods_approved || manifest_.mod_manifest_hash.empty() || + if(!is_host_ || (!waiting(state_)&&!dkr::runtime::spectators::party_enabled(this)) || !pending || !pending->mods_approved || manifest_.mod_manifest_hash.empty() || digest!=manifest_.mod_manifest_hash) {error="The mod preparation proof does not belong to this approved request.";return false;} if(resources.empty() || !valid_mod_manifest_hash(resources) || resources!=manifest_.owned_resources_hash) { std::fprintf(stderr,"[online][compatibility] prepared-resources expected=%s received=%.*s\n", @@ -100,7 +102,7 @@ bool DirectSession::handle_mod_packet_locked(std::uint64_t sender,const protocol } if(!mod_offer_)return true; last_admission_response_=std::chrono::steady_clock::now(); - } else if(is_host_ && waiting(state_)) { + } else if(is_host_ && (waiting(state_)||dkr::runtime::spectators::party_enabled(this))) { const auto* pending=pending_by_sender(sender); if(type==protocol::MessageType::ModOffer || !pending || !pending->mods_approved || pending->mods_verified)return true; } else return true; diff --git a/runtime-recomp/src/game/netplay/online_mod_sync_link.hpp b/runtime-recomp/src/game/netplay/online_mod_sync_link.hpp index d0b7e5e4..7a29b293 100644 --- a/runtime-recomp/src/game/netplay/online_mod_sync_link.hpp +++ b/runtime-recomp/src/game/netplay/online_mod_sync_link.hpp @@ -1,3 +1,4 @@ +#include "../policy.hpp" #pragma once #include "direct_session.hpp" #include "../mods/online_mod_sync.hpp" @@ -28,7 +29,7 @@ class DirectModSyncLink final:public dkr::mods::online::SyncLink { bool send(std::uint64_t target,dkr::mods::View bytes,bool data,std::string& error)override{return session_.send_mod_packet(target,bytes,data,error);} bool verify_local(std::string_view digest,std::string_view resources,std::string& error)override{return session_.complete_local_mod_preparation(digest,resources,error);} bool admit(std::uint64_t request,std::string_view digest,std::string_view resources,std::string& error)override{return session_.complete_mod_admission(request,digest,resources,error);} - void reject(std::uint64_t request,std::string_view)override{std::string error;session_.reject_join(request,false,error);} + void reject(std::uint64_t request,std::string_view reason)override{dkr::lobby_stability::RejectionScope scope(&session_,request,reason);std::string error;session_.reject_join(request,false,error);} void leave(std::string_view reason)override{session_.disconnect(std::string(reason));} private: DirectSession& session_; diff --git a/runtime-recomp/src/game/netplay/quick_join_transport.cpp b/runtime-recomp/src/game/netplay/quick_join_transport.cpp index 1b748244..c81d75bd 100644 --- a/runtime-recomp/src/game/netplay/quick_join_transport.cpp +++ b/runtime-recomp/src/game/netplay/quick_join_transport.cpp @@ -225,7 +225,7 @@ class QuickJoinTransport final : public SessionTransport { std::scoped_lock lock(mutex_); for (const auto& [id, peer] : peers_) { if (peer->address == address && - (pinned_routes_.contains(peer->remote_id) || pinned_routes_.size() < 4U)) { + (pinned_routes_.contains(peer->remote_id) || pinned_routes_.size() < 16U)) { pinned_routes_[peer->remote_id] = {id, address}; return; } @@ -792,7 +792,7 @@ class QuickJoinTransport final : public SessionTransport { if(control&&peer->control_channel!=control)return; peer->departure_reported=true; std::erase_if(departed_peers_,[](const auto& p){const auto live=p.lock();return !live||!live->current.load();}); - if(departed_peers_.size()<4U)departed_peers_.push_back(peer); + if(departed_peers_.size()<16U)departed_peers_.push_back(peer); if(receive_signal_)receive_signal_->notify(); } @@ -841,7 +841,7 @@ class QuickJoinTransport final : public SessionTransport { std::scoped_lock lock(mutex_); if (peer->remote_id.size() > 128U || peer->connection_id.size() > 128U || retired_peers_.size() + pending_retirements_.load() >= 16U || - (peers_.size() >= 8U && std::none_of(peers_.begin(), peers_.end(), [&](const auto& entry) { return entry.second->remote_id == peer->remote_id; }))) return {}; + (peers_.size() >= 16U && std::none_of(peers_.begin(), peers_.end(), [&](const auto& entry) { return entry.second->remote_id == peer->remote_id; }))) return {}; auto existing = std::find_if( peers_.begin(), peers_.end(), [&](const auto& entry) { return entry.second && diff --git a/runtime-recomp/src/game/observer_music.hpp b/runtime-recomp/src/game/observer_music.hpp new file mode 100644 index 00000000..84615d99 --- /dev/null +++ b/runtime-recomp/src/game/observer_music.hpp @@ -0,0 +1,21 @@ +#pragma once +#include "resident_music.hpp" +#include + +namespace dkr::spectator_admission { +// Process-local loading permission, never checkpointed or sent over the wire. +// The only production caller is the already verified observer admission arm, +// while staging mod resources and BEFORE restoring any guest RAM or journals. +inline thread_local unsigned observer_music_depth = 0; +inline bool preparing_music() noexcept { return observer_music_depth != 0; } + +template +decltype(auto) prepare_verified_music(Stage&& stage) { + struct Scope { + dkr::mods::online::resident_music::PreparationScope loading; + Scope() noexcept { ++observer_music_depth; } + ~Scope() { --observer_music_depth; } + } scope; + return std::forward(stage)(); +} +} diff --git a/runtime-recomp/src/game/observer_playback.cpp b/runtime-recomp/src/game/observer_playback.cpp new file mode 100644 index 00000000..289952a3 --- /dev/null +++ b/runtime-recomp/src/game/observer_playback.cpp @@ -0,0 +1,105 @@ +#include "observer_playback.hpp" +#include +#include + +namespace dkr::runtime::spectators { +namespace {constexpr auto Tick=std::chrono::nanoseconds(1000000000/30);} +ObserverPlayback::ObserverPlayback(ObserverWorld& world,std::uint64_t match,HashState hash) + :world_(world),match_(match),hash_(std::move(hash)){} +bool ObserverPlayback::offer(Baseline b,std::string& error){ + if(view_.phase==PlaybackPhase::Stopped||!match_||!hash_||b.match!=match_||b.schema!=world_.schema()|| + !b.state||b.state->size()!=world_.state_bytes()||b.state->empty()||b.state->size()>ConfirmedFeed::MaximumState|| + !b.next.epoch||b.next.frame==UINT32_MAX||!b.control_generation||hash_(*b.state)!=b.digest){ + error="Spectator baseline does not match the verified game state.";return false; + } + if(view_.next.epoch&&(b.next.epochnext){ + if(b.digest!=pending_->digest||b.magic_actions!=pending_->magic_actions){error="Conflicting spectator baseline.";return false;} + error.clear();return true; + } + if(pending_&&(b.next.epochnext.epoch|| + (b.next.epoch==pending_->next.epoch&&b.next.framenext.frame))){error="Spectator baseline offer is older than the pending checkpoint.";return false;} + // Full integrity validation precedes any publication retirement or RAM + // mutation. Retire/admit themselves remain on the guest owner thread. + pending_=std::move(b);queue_.clear();recent_.clear();queued_bytes_=0;prepared_=scene_prepared_=false; + view_.received=pending_->next;view_.queued=0;view_.phase=PlaybackPhase::Retiring;view_.status="Loading the confirmed race"; + error.clear();return true; +} +FeedResult ObserverPlayback::append(std::span frames,std::string& error){ + if(frames.empty()||frames.size()>30||view_.phase==PlaybackPhase::WaitingForBaseline|| + view_.phase==PlaybackPhase::Failed||view_.phase==PlaybackPhase::Stopped){error="Spectator playback is not ready for input history.";return FeedResult::Invalid;} + // Validate the whole batch first; a malformed tail must not partly enter + // the queue or advance its acknowledged cursor. + Cursor expected=view_.received;std::size_t cost=0,accepted=0;bool boundary=!queue_.empty()&&queue_.back().scene_boundary; + for(const auto& f:frames){ + if(!f.cursor.epoch||f.cursor.frame==UINT32_MAX||f.command.size()>ConfirmedFeed::MaximumCommand){error="Invalid confirmed spectator frame.";return FeedResult::Invalid;} + if(f.cursor.epochMaximumQueued-queue_.size()||cost>ConfirmedFeed::MaximumTail-queued_bytes_){ + error="Spectator fell behind; a new checkpoint is required.";return FeedResult::Capacity; + } + for(const auto& f:frames)if(f.cursor.epoch==view_.received.epoch&&f.cursor.frame>=view_.received.frame){queue_.push_back(f);queued_bytes_+=48+f.command.size();} + view_.received=expected;view_.queued=queue_.size();error.clear();return accepted?FeedResult::Accepted:FeedResult::Duplicate; +} +Work ObserverPlayback::fail(std::string message,std::string& error){view_.phase=PlaybackPhase::Failed;view_.status=std::move(message);error=view_.status;return Work::Failed;} +Work ObserverPlayback::pulse(Clock::time_point now,std::string& error)try{ + if(clock_started_&&nownext,error); + if(ready==Work::Pending)return ready; + if(ready!=Work::Ready||!world_.admit(*pending_,error))return fail(error.empty()?"Spectator state admission failed.":error,error); + view_.next=pending_->next;pending_.reset();view_.phase=PlaybackPhase::CatchingUp;view_.status="Catching up";next_tick_=now;return Work::Ready; + } + if(view_.phase==PlaybackPhase::SceneBoundary){ + if(!scene_prepared_){ + const auto work=world_.prepare_scene({view_.next.epoch,view_.next.frame-1},error); + if(work==Work::Failed)return fail(error.empty()?"Spectator scene preparation failed.":error,error); + if(work==Work::Pending)return work; + scene_prepared_=true; + } + request_resync("Waiting for the next scene checkpoint");return Work::Ready; + } + if(queue_.empty()){ + view_.phase=PlaybackPhase::Watching;view_.status="Watching";next_tick_=now;return Work::Pending; + } + // Catch up without an unbounded inner loop. UI and incoming traffic get a + // turn between every tick. Near the head, keep a small confirmed delay. + const bool catchup=queue_.size()>8; + if(!catchup&&now120)recent_.pop_front();queue_.pop_front(); + ++view_.next.frame;++view_.simulated;view_.queued=queue_.size();prepared_=false; + view_.phase=boundary?PlaybackPhase::SceneBoundary:catchup?PlaybackPhase::CatchingUp:PlaybackPhase::Watching; + view_.status=boundary?"Changing scene":catchup?"Catching up":"Watching"; + // Rendering intermediate fast-forward ticks wastes GPU time and can fill + // immutable mailbox leases. Publish only near the confirmed live head. + if(!boundary&&!catchup&&!world_.publish(completed,error))return fail(error.empty()?"Spectator presentation failed.":error,error); + next_tick_=catchup?now:std::max(next_tick_+Tick,now-Tick);return Work::Ready; +}catch(const std::exception& e){return fail(std::string("Spectator playback stopped: ")+e.what(),error);} + catch(...){return fail("Spectator playback stopped unexpectedly.",error);} +void ObserverPlayback::request_resync(std::string reason){ + if(view_.phase==PlaybackPhase::Stopped)return; + pending_.reset();queue_.clear();recent_.clear();queued_bytes_=0;prepared_=scene_prepared_=false; + view_.queued=0;view_.phase=PlaybackPhase::WaitingForBaseline;view_.status=std::move(reason);++view_.resyncs; +} +void ObserverPlayback::stop(){pending_.reset();queue_.clear();recent_.clear();queued_bytes_=0;view_.queued=0;view_.phase=PlaybackPhase::Stopped;view_.status="Stopped watching";} +} diff --git a/runtime-recomp/src/game/observer_playback.hpp b/runtime-recomp/src/game/observer_playback.hpp new file mode 100644 index 00000000..7b902016 --- /dev/null +++ b/runtime-recomp/src/game/observer_playback.hpp @@ -0,0 +1,58 @@ +#pragma once +#include "checkpoint_transfer.hpp" +#include + +namespace dkr::runtime::spectators { +// No input sampling or file/device API is available through this boundary. +// A game adapter performs canonical guest operations on its simulation owner. +enum class Work {Pending,Ready,Failed}; +class ObserverWorld { +public: + virtual ~ObserverWorld()=default; + virtual std::uint64_t schema()const=0; + virtual std::size_t state_bytes()const=0; + virtual Work retire(Cursor next,std::string& error)=0; + virtual bool admit(const Baseline& baseline,std::string& error)=0; + virtual Work prepare(const ConfirmedFrame& frame,std::string& error)=0; + virtual bool tick_and_commit(const ConfirmedFrame& frame,std::string& error)=0; + virtual bool publish(Cursor completed,std::string& error)=0; + virtual Work prepare_scene(Cursor completed,std::string& error)=0; +}; +enum class PlaybackPhase {WaitingForBaseline,Retiring,CatchingUp,Watching,SceneBoundary,Failed,Stopped}; +struct PlaybackView { + PlaybackPhase phase=PlaybackPhase::WaitingForBaseline; + Cursor next{},received{}; + std::size_t queued=0; + std::uint64_t simulated=0,resyncs=0; + std::string status="Waiting for the host's confirmed game state"; +}; +// Confirmed-only observer. Every pulse does at most one guest tick or one +// bounded scene/preparation step. The host never waits on this object's pace. +// The playback owner calls all methods; transport queues immutable values. +class ObserverPlayback final { +public: + using Clock=std::chrono::steady_clock; + static constexpr std::size_t MaximumQueued=1800; + ObserverPlayback(ObserverWorld& world,std::uint64_t match,HashState hash); + bool offer(Baseline baseline,std::string& error); + FeedResult append(std::span frames,std::string& error); + Work pulse(Clock::time_point now,std::string& error); + void request_resync(std::string reason); + void stop(); + const PlaybackView& view()const{return view_;} + bool needs_baseline()const{return view_.phase==PlaybackPhase::WaitingForBaseline;} + bool caught_up(Cursor at)const{return view_.next==at&&queue_.empty()&&view_.phase==PlaybackPhase::Watching;} +private: + Work fail(std::string message,std::string& error); + ObserverWorld& world_; + std::uint64_t match_; + HashState hash_; + PlaybackView view_{}; + std::optional pending_; + std::deque queue_; + std::deque recent_; + std::size_t queued_bytes_=0; + Clock::time_point next_tick_{},last_poll_{}; + bool clock_started_=false,prepared_=false,scene_prepared_=false; +}; +} diff --git a/runtime-recomp/src/game/offline_keep.cpp b/runtime-recomp/src/game/offline_keep.cpp new file mode 100644 index 00000000..582b81f6 --- /dev/null +++ b/runtime-recomp/src/game/offline_keep.cpp @@ -0,0 +1,281 @@ +#include "cache_repair.hpp" +#include "vehicle_online.hpp" +#include "online_mod_profile.hpp" +#include "legacy_mod_process.hpp" +#include "legacy_track_catalog.hpp" +#include "online_mod_cache.hpp" +#include "online_mod_prepare.hpp" +#include "magic_code_game_world.hpp" +#include "online_game_pak.hpp" +#include "../custom_tracks.hpp" +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#endif + +namespace dkr::mods::online { +namespace { +using nlohmann::json; +std::string path_text(const std::filesystem::path& path){const auto text=path.generic_u8string();return {text.begin(),text.end()};} +void cancelled(std::stop_token stop){if(stop.stop_requested())throw Error("Online mod preparation cancelled. Offline mods and saves were not changed.");} +json read(const std::filesystem::path& path,std::size_t limit=MiB){check_storage(path,false);const auto bytes=read_file(path,limit);return json::parse(bytes.begin(),bytes.end(),[](int depth,json::parse_event_t,json&){if(depth>24)throw Error("Online metadata nesting exceeds its limit.");return true;});} +void write(const std::filesystem::path& path,const json& value){const auto data=value.dump();if(data.size()>MaxManifestBytes)throw Error("Online mod job metadata exceeds its budget.");write_new_file(path,View(reinterpret_cast(data.data()),data.size()));} +void child(const std::filesystem::path& path){check_storage(path.parent_path(),true);std::filesystem::create_directory(path);check_storage(path,true);} +std::string nonce(){std::random_device random;Bytes bytes(32);for(auto& b:bytes)b=static_cast(random());return sha256(bytes);} +// Only the nonce-named job exclusively reserved by this transaction is ours. +// Never delete a profile/cache/source path in cleanup. +struct Job { + std::filesystem::path jobs,path; + ~Job(){try{if(path.empty() || !valid_digest(path_text(path.filename())) || path.parent_path()!=jobs)return; + check_storage(jobs,true);check_storage(path,true); + if(std::filesystem::weakly_canonical(path).parent_path()!=std::filesystem::weakly_canonical(jobs))return; + for(const auto& entry:std::filesystem::recursive_directory_iterator(path))check_storage(entry.path(),entry.is_directory()); + std::filesystem::remove_all(path); + }catch(...){} } +}; +} +namespace { +void ensure_directory(const std::filesystem::path& path) { + if(std::filesystem::exists(path)){check_storage(path,true);return;} + ensure_directory(path.parent_path());child(path); +} +using Tree=std::map>; +// Ancestors were checked before traversal. Check each leaf before descending; +// do not issue dozens of redundant ancestor queries for every tiny asset. +void ordinary_leaf(const std::filesystem::path& path,bool directory) { + const auto status=std::filesystem::symlink_status(path); + if(std::filesystem::is_symlink(status) || (directory?!std::filesystem::is_directory(status):!std::filesystem::is_regular_file(status))) + throw Error("Online mod tree contains a link or unsupported file type."); +#ifdef _WIN32 + const auto attributes=GetFileAttributesW(path.c_str()); + if(attributes==INVALID_FILE_ATTRIBUTES || (attributes&FILE_ATTRIBUTE_REPARSE_POINT))throw Error("Online mod tree contains a reparse point."); +#endif +} +Tree retained_tree(const std::filesystem::path& root,std::stop_token stop={}) { + check_storage(root,true);Tree files;std::uint64_t bytes=0; + for(const auto& entry:std::filesystem::recursive_directory_iterator(root)) { + cancelled(stop);if(files.size()>=32768)throw Error("Offline mod copy exceeds its file-count budget."); + const bool directory=entry.is_directory();ordinary_leaf(entry.path(),directory); + const auto size=directory?0:entry.file_size(); + if(size>std::uint64_t(1024*MiB)-bytes)throw Error("Offline mod copy exceeds its storage budget."); + bytes+=size;files.emplace(entry.path().lexically_relative(root),std::make_pair(directory,size)); + } + return files; +} +bool same_tree(const std::filesystem::path& a,const std::filesystem::path& b,std::stop_token stop) { + const auto left=retained_tree(a,stop);if(left!=retained_tree(b,stop))return false; + std::array x{},y{}; + for(const auto& [name,type]:left)if(!type.first) { + cancelled(stop);std::ifstream first(private_storage_path(a/name),std::ios::binary),second(private_storage_path(b/name),std::ios::binary); + if(!first || !second)return false; + for(std::uint64_t at=0;at(std::min(x.size(),type.second-at)); + if(!first.read(x.data(),size) || !second.read(y.data(),size) || !std::equal(x.begin(),x.begin()+size,y.begin()))return false; + at+=size; + } + } + return true; +} +void copy_tree(const std::filesystem::path& source,const std::filesystem::path& target,std::stop_token stop) { + const auto entries=retained_tree(source,stop);child(target); + for(const auto& [name,type]:entries) { + cancelled(stop);const auto to=private_storage_path(target/name),from=private_storage_path(source/name); + if(type.first)ensure_directory(to); + else {ordinary_leaf(to.parent_path(),true);ordinary_leaf(from,false);std::filesystem::copy_file(from,to);} + } + if(!same_tree(source,target,stop))throw Error("A verified mod changed while being kept. No existing offline files were replaced."); +} +} + +namespace { +std::mutex offline_keep_mutex; +using KeepKey=std::tuple; +KeepKey keep_key(bool characters,const TrackCatalogItem& item){return {characters,item.id,item.revision};} +const char* keep_folder(bool characters){return characters?"prepared-characters":"prepared";} +const char* keep_entries(bool characters){return characters?"characters":"tracks";} +void verify_keep_asset(const CatalogSource& source,bool characters,std::stop_token stop) { + cancelled(stop);const auto& item=source.item; + for(const auto* id:{&item.group,&item.storage,&item.id,&item.artifact}) + if(!valid_digest(*id))throw Error("Invalid offline mod storage identity."); + const auto folder=source.library/keep_folder(characters)/item.group/item.storage; + check_storage(folder,true);const auto path=folder/(characters?"character.json":"track.json"); + check_storage(path,false);const auto raw=read_file(path,MiB); + if(sha256(raw)!=item.artifact)throw Error("An offline mod manifest needs repair: "+item.name); + const auto manifest=read(path); + if(manifest.at("id")!=item.id || manifest.at("revision")!=item.revision) + throw Error("An offline mod manifest has a conflicting identity: "+item.name); + std::map blobs; + const auto add=[&](std::string hash,std::size_t size) { + if(!valid_digest(hash)||size>MaxStaged)throw Error("Invalid retained mod blob descriptor."); + auto [at,inserted]=blobs.emplace(hash,size); + if(!inserted&&at->second!=size)throw Error("Conflicting retained mod blob sizes."); + }; + if(!manifest.at("records").is_array() || manifest.at("records").size()>4096) + throw Error("Retained mod record budget exceeded."); + for(const auto& record:manifest.at("records")) + add(record.at(characters?"hash":"digest").get(),record.at("size").get()); + if(characters) { + const auto& audio=manifest.at("audio"); + add(audio.at("control"),audio.at("control_size")); + add(audio.at("samples"),audio.at("sample_size")); + } + std::uint64_t total=0; + for(const auto& [hash,size]:blobs) { + cancelled(stop);if(size>std::uint64_t(MaxStaged)-total)throw Error("Retained mod asset budget exceeded."); + total+=size;const auto blob=folder/"blobs"/hash;check_storage(blob,false); + const auto bytes=read_file(blob,size); + if(bytes.size()!=size || sha256(bytes)!=hash)throw Error("An offline mod asset needs repair: "+item.name); + } +} +bool same_keep_content(const TrackCatalogItem& a,const TrackCatalogItem& b) { + // Names, receipt/artifact encoding and the source patch container are not + // content identities. Semantic and capability mismatches still fail closed. + return a.id==b.id&&a.revision==b.revision&&a.bank==b.bank&&a.carrier==b.carrier&& + a.vehicles==b.vehicles&&a.race_type==b.race_type&&a.base_character==b.base_character&& + a.behaviour_identity==b.behaviour_identity&&a.behaviour_abi==b.behaviour_abi; +} +struct KeepGroup {bool characters=false;std::filesystem::path source;json receipt; + std::map,TrackCatalogItem> items;}; +struct KeepCopy {std::filesystem::path source,target,staged;std::string receipt; + std::set storage;}; +} +void retain_legacy_sources(const ProfileSources& sources,const std::filesystem::path& library,std::stop_token stop) { + cancelled(stop);if(sources.tracks.empty()&&sources.characters.empty())return; + // Keep jobs from different lobbies serialize only their optional offline + // work; network admission and the immutable session profile never wait here. + std::lock_guard lease(offline_keep_mutex);cancelled(stop); + const auto root=private_storage_path(library);ensure_directory(root); + for(const auto* folder:{"reviews","prepared","prepared-characters","online-keeps"})ensure_directory(root/folder); + std::map installed; + std::array item_counts{},group_counts{}; + for(bool characters:{false,true}) { + const auto type=characters?TrackCatalog::Kind::Character:TrackCatalog::Kind::Track; + for(const auto* revision:{"us.v77","us.v80"})for(const auto& item:TrackCatalog::installed_items(root,revision,type)) + installed.emplace(keep_key(characters,item),CatalogSource{root,item}); + for(const auto& [key,source]:installed)if(std::get<0>(key)==characters)++item_counts[characters]; + for(const auto& entry:std::filesystem::directory_iterator(root/keep_folder(characters))) { + cancelled(stop);if(++group_counts[characters]>(characters?MaxInstalledCharacterVariants:1024U)) + throw Error("The offline mod catalogue is full."); + } + } + std::map,KeepGroup> groups; + std::set selected;unsigned reused=0; + for(bool characters:{false,true})for(const auto& source:characters?sources.characters:sources.tracks) { + cancelled(stop);const auto& item=source.item;const auto key=keep_key(characters,item); + if(!selected.insert(key).second)throw Error("The offline keep selection contains duplicate mod identities."); + // Always validate the selected source, even when an installed edition + // can satisfy the optional offline copy. No SHA checks are waived. + verify_keep_asset(source,characters,stop); + if(const auto at=installed.find(key);at!=installed.end()) { + if(!same_keep_content(at->second.item,item)) + throw Error("Downloaded content disagrees with an installed mod identity: "+item.name+". Existing files were not replaced."); + if(source.library!=root || source.item.group!=at->second.item.group) + verify_keep_asset(at->second,characters,stop); + ++reused;continue; + } + if(++item_counts[characters]>(characters?MaxInstalledCharacterVariants:1024U)) + throw Error("The offline mod library is full. The online session can still use its verified cache."); + if(!valid_digest(item.review))throw Error("A retained mod needs a verified source review."); + const auto path=source.library/keep_folder(characters)/item.group; + const auto identity=std::tuple{characters,source.library,item.group}; + auto [at,inserted]=groups.try_emplace(identity); + auto& group=at->second; + if(inserted) { + check_storage(path,true);check_storage(path/"prepared.json",false);const auto raw=read_file(path/"prepared.json",MiB); + if(sha256(raw)!=item.group)throw Error("A selected preparation receipt changed before retention."); + group={characters,path,read(path/"prepared.json"),{}}; + } + group.items.emplace(std::pair{item.id,item.revision},item); + } + std::vector copies;std::map reviews; + for(auto& [identity,group]:groups) { + cancelled(stop);const auto key=keep_entries(group.characters);auto receipt=group.receipt; + json entries=json::array();std::set storage; + for(const auto& entry:receipt.at(key)) { + const auto at=group.items.find({entry.at("id"),entry.at("revision")});if(at==group.items.end())continue; + const auto& item=at->second; + if(entry.at("artifact")!=item.artifact || entry.at("patch")!=item.patch || + entry.value("storage",item.id)!=item.storage || receipt.at("review")!=item.review) + throw Error("The selected mod differs from its preparation receipt."); + entries.push_back(entry);storage.insert(item.storage); + const auto review=group.source.parent_path().parent_path()/"reviews"/item.review; + const auto [prev,inserted]=reviews.emplace(item.review,review); + if(!inserted&&prev->second!=review&&!same_tree(prev->second,review,stop)) + throw Error("Conflicting verified source reviews."); + } + if(entries.size()!=group.items.size())throw Error("A selected mod is missing from its preparation receipt."); + // Retain ONLY selected missing entries. Copying the original whole + // pack would reintroduce already installed or unselected variants. + receipt[key]=entries;const auto encoded=receipt.dump(); + const auto name=sha256(View(reinterpret_cast(encoded.data()),encoded.size())); + if(++group_counts[group.characters]>(group.characters?MaxInstalledCharacterVariants:1024U)) + throw Error("Keeping this group would exceed the offline catalogue limit."); + copies.push_back({group.source,root/keep_folder(group.characters)/name,{},encoded,std::move(storage)}); + } + for(const auto& [hash,source]:reviews) { + check_storage(source,true);check_storage(source/"review.json",false); + if(sha256(read_file(source/"review.json",8*MiB))!=hash)throw Error("A retained source review changed."); + const auto target=root/"reviews"/hash; + if(std::filesystem::exists(target)) { + if(!same_tree(source,target,stop))throw Error("A retained source review conflicts with existing files."); + }else copies.push_back({source,target,{},{},{}}); + } + std::uint64_t bytes=0;std::size_t files=0; + const auto account=[&](std::uint64_t size) { + if(files>=32768 || size>std::uint64_t(1024*MiB)-bytes) + throw Error("Offline mod copy exceeds its storage budget."); + ++files;bytes+=size; + }; + const auto budget=[&](const std::filesystem::path& source) { + for(const auto& [name,type]:retained_tree(source,stop)) { + account(type.second); + } + }; + for(const auto& copy:copies) { + if(copy.receipt.empty())budget(copy.source); + else {account(copy.receipt.size());for(const auto& storage:copy.storage)budget(copy.source/storage);} + } + if(copies.empty()) { + std::fprintf(stderr,"[online-mods][keep-legacy] reused=%u new-groups=0 copied-bytes=0\n",reused);return; + } + if(std::filesystem::space(root).available(copy.receipt.data()),copy.receipt.size())); + for(const auto& storage:copy.storage)copy_tree(copy.source/storage,copy.staged/storage,stop); + if(sha256(read_file(copy.staged/"prepared.json",MiB))!=path_text(copy.target.filename())) + throw Error("The staged preparation receipt changed."); + } + } + // Reviews are published before their prepared groups. Every published + // directory is complete; interruption can leave only inactive full copies. + std::stable_sort(copies.begin(),copies.end(),[](const auto& a,const auto& b){return a.receipt.empty()>b.receipt.empty();}); + for(const auto& copy:copies) { + cancelled(stop);check_storage(copy.target.parent_path(),true); + if(std::filesystem::exists(copy.target)) { + check_storage(copy.target,true); + if(!same_tree(copy.staged,copy.target,stop))throw Error("Existing offline content differs from the staged copy. It was not replaced."); + continue; + } + std::error_code error;std::filesystem::rename(copy.staged,copy.target,error); + if(error) { + check_storage(copy.target,true); + if(!same_tree(copy.staged,copy.target,stop))throw Error("Could not publish a complete offline mod copy."); + } + } + std::fprintf(stderr,"[online-mods][keep-legacy] reused=%u new-groups=%zu copied-bytes=%llu\n",reused,groups.size(),static_cast(bytes)); +} + +} diff --git a/runtime-recomp/src/game/offline_keep.hpp b/runtime-recomp/src/game/offline_keep.hpp new file mode 100644 index 00000000..c10b711d --- /dev/null +++ b/runtime-recomp/src/game/offline_keep.hpp @@ -0,0 +1,6 @@ +#pragma once +#include "online_mod_profile.hpp" +namespace dkr::mods::online { +// Validated profile selections only. Does not change existing catalogue state. +void retain_legacy_sources(const ProfileSources&,const std::filesystem::path&,std::stop_token={}); +} diff --git a/runtime-recomp/src/game/online_directory.cpp b/runtime-recomp/src/game/online_directory.cpp new file mode 100644 index 00000000..91bc3d3f --- /dev/null +++ b/runtime-recomp/src/game/online_directory.cpp @@ -0,0 +1,283 @@ +#include "online_directory.hpp" +#include "directory_tls.hpp" +#include "monocypher.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::online { +namespace { +std::string string(const Json& j,const char* name,std::size_t limit=160) { + if(!j.is_object()||!j.contains(name)||!j[name].is_string())return {}; + auto s=j[name].get();return s.size()<=limit?s:std::string{}; +} +struct State { + std::mutex mutex,bridge_mutex; + std::condition_variable_any cv; + AccountView account; AccountRequest request; + View ui; + std::atomic> published{std::make_shared()}; + std::map> bridges; + struct JoinKey {secure::KeyPair pair;secure::Key admission;}; + std::map keys; + std::deque inbox,outbox; + std::map pending; + std::map join_requests; + std::filesystem::path root; + std::string instance=secure::encode_key(secure::generate_key()),compatible,joining_id; + std::size_t jitter=std::hash{}(instance); + secure::KeyPair join_key{}; + Json bootstrap; + bool configured=false,watch=false,stopping=false,retry_now=false; + std::shared_ptr socket; + std::atomic channel{0}; + std::uint64_t serial=0; + std::jthread worker; + void update(){published.store(std::make_shared(ui));} +}; +State& state(){static auto* p=new State;return *p;} +std::string next_id(State& s){return "r"+std::to_string(++s.serial);} +void retire_join(State&); +void close_socket(State& s) { + std::shared_ptr socket; + {std::lock_guard lock(s.mutex);s.ui.connection_generation=++s.channel;socket=std::move(s.socket);s.pending.clear();s.join_requests.clear();s.inbox.clear();retire_join(s);s.ui.connected=false;s.ui.count_available=false;s.ui.public_listed=false;s.ui.updating=false;s.ui.rooms.clear();s.update();} + if(socket){socket->resetCallbacks();socket->close();} +} +void enqueue(Json j) { + auto& s=state();std::lock_guard lock(s.mutex); + if(s.outbox.size()<64)s.outbox.push_back(std::move(j)); + else{s.ui.error="Too many online operations. Wait for the current request.";s.update();} + s.cv.notify_all(); +} +void send(State& s,Json j) { + std::shared_ptr socket; + {std::lock_guard lock(s.mutex);socket=s.socket;const auto id=next_id(s);j["request"]=id; + if(s.pending.size()>=256)throw std::runtime_error("Too many unanswered directory requests.");s.pending[id]=string(j,"type",32);} + {std::lock_guard lock(s.mutex);if(string(j,"type",32)=="join"||string(j,"type",32)=="resolve")s.join_requests[string(j,"request",80)]=string(j,"key",80);} + if(!socket||!socket->isOpen())throw std::runtime_error("The online directory disconnected."); + auto body=j.dump();if(body.size()>16384)throw std::runtime_error("The online message is too large."); + if(socket->bufferedAmount()>262144)throw std::runtime_error("The online connection is busy. Reconnecting."); + if(!socket->send(body))throw std::runtime_error("The online connection is busy. Reconnecting."); +} +void append_rooms(State& s,const Json& data,bool replace) { + if(!data.contains("rooms")||!data["rooms"].is_array()||data["rooms"].size()>50)return; + if(replace)s.ui.rooms.clear(); + for(const auto& j:data["rooms"]){ + Room r;r.id=string(j,"id",80);r.title=string(j,"title",80);r.host=string(j,"host",100); + r.compatibility=string(j,"compatibility",160);r.phase=string(j,"phase",24); + r.racers=j.value("racers",0U);r.max_racers=j.value("maxRacers",0U); + r.spectators=j.value("spectators",0U);r.max_spectators=j.value("maxSpectators",0U); + r.locked=j.value("locked",false);r.can_join=j.value("canJoin",false);r.can_spectate=j.value("canSpectate",false); + if(r.id.empty()||r.racers>4||r.max_racers>4||r.spectators>8||r.max_spectators>8)continue; + auto found=std::find_if(s.ui.rooms.begin(),s.ui.rooms.end(),[&](const auto& x){return x.id==r.id;}); + if(found!=s.ui.rooms.end())*found=std::move(r);else if(s.ui.rooms.size()<1000)s.ui.rooms.push_back(std::move(r)); + } + s.ui.online=data.value("online",std::uint64_t(0));s.ui.revision=data.value("revision",std::uint64_t(0)); + s.ui.count_available=data.contains("online"); +} +void retire_join(State& s) { + std::erase_if(s.outbox,[](const auto& message){const auto type=string(message,"type",32);return type=="join"||type=="resolve";}); + if(!s.joining_id.empty()&&s.outbox.size()<64)s.outbox.push_back({{"type","cancel"},{"id",s.joining_id}}); + s.joining_id.clear();s.bootstrap=nullptr;s.ui.joining=false; + crypto_wipe(s.join_key.secret.data(),s.join_key.secret.size());s.join_key={}; + {std::lock_guard lock(s.bridge_mutex);for(auto& [owner,key]:s.keys)crypto_wipe(key.pair.secret.data(),32);s.keys.clear();} +} +void run(std::stop_token stop) { + auto& s=state();unsigned generation=~0U,attempt=0; + auto retry=Clock::time_point{},heartbeat=Clock::time_point{},renew=Clock::time_point{}; + std::string host_epoch,host_id,last_state; + std::uint64_t host_revision=0; + auto changed_at=Clock::time_point{}; + while(!stop.stop_requested()) { + AccountView account;AccountRequest request;bool enabled; + {std::lock_guard lock(s.mutex);account=s.account;request=s.request;enabled=s.ui.enabled;if(s.retry_now){retry={};s.retry_now=false;}} + Account identity;if(account)identity=account(); + if(identity.generation!=generation){ + close_socket(s);generation=identity.generation;host_id.clear();host_epoch.clear();last_state.clear();host_revision=0;retry={}; + std::lock_guard lock(s.mutex);retire_join(s);s.inbox.clear();s.outbox.clear();s.ui.error.clear();s.update(); + } + {std::lock_guard lock(s.mutex);s.ui.signed_in=identity.signed_in;s.ui.account_name=identity.name;s.ui.account_generation=identity.generation;s.update();} + if(!identity.signed_in||!enabled||!request){ + close_socket(s); + {std::lock_guard lock(s.mutex);s.ui.status=identity.signed_in?"Disconnected from DKR-R Online.":"Sign in with Discord to find public lobbies.";s.update();} + }else try{ + const auto now=Clock::now();bool opened; + {std::lock_guard lock(s.mutex);opened=s.socket&&s.socket->isOpen();} + if(!opened&&now>=retry){ + close_socket(s); + auto capabilities=request("GET","/v1/online/capabilities",Json::object(),s.instance,stop); + if(!capabilities.value("enabled",false)||capabilities.value("protocol",0U)!=Protocol){ + std::lock_guard lock(s.mutex);s.ui.feature_enabled=false;s.ui.status="Public lobbies are not enabled on the service yet.";s.update();retry=now+std::chrono::milliseconds(30000+s.jitter%1000); + }else{ + auto ticket=request("POST","/v1/online/ticket",Json::object(),s.instance,stop); + const auto credential=string(ticket,"ticket",64); + if(credential.size()!=64)throw std::runtime_error("The directory returned an invalid connection ticket."); + rtc::WebSocket::Configuration config;config.protocols={"dkrr-online-v1","dkrr-ticket-"+credential}; + config.caCertificatePemFile=directory_ca_bundle(); + config.connectionTimeout=std::chrono::seconds(12);config.maxMessageSize=65536; + auto socket=std::make_shared(config); + const auto channel=++s.channel; + socket->onMessage([channel,weak=std::weak_ptr(socket)](rtc::message_variant message){ + if(auto* data=std::get_if(&message);data&&data->size()<=65536){ + try{auto value=Json::parse(*data);auto& s=state();bool overflow=false; + {std::lock_guard lock(s.mutex);if(channel==s.channel){ + if(s.inbox.size()<128){s.inbox.push_back(std::move(value));s.cv.notify_all();}else overflow=true;}} + if(overflow)if(auto socket=weak.lock())socket->close();} + catch(...){} + } + }); + socket->onClosed([channel]{auto& s=state();std::lock_guard lock(s.mutex);if(channel==s.channel){retire_join(s);s.ui.connected=false;s.ui.public_listed=false;s.ui.rooms.clear();s.update();s.cv.notify_all();}}); + socket->onError([channel](std::string){auto& s=state();std::lock_guard lock(s.mutex);if(channel==s.channel){s.ui.error="The directory connection failed. Retrying...";s.update();}}); + {std::lock_guard lock(s.mutex);s.socket=socket;s.ui.connection_generation=channel;s.ui.feature_enabled=true;s.ui.running_spectators=capabilities.value("runningSpectators",false);s.ui.status="Connecting to DKR-R Online...";s.update();} + socket->open("wss://dkr-mod-library-api.ross-macgregorbars.workers.dev/v1/online/connect"); + retry=now+std::chrono::seconds(15);heartbeat=now;renew=now+std::chrono::seconds(60); + host_id.clear();host_epoch.clear();last_state.clear();host_revision=0; + } + } + {std::lock_guard lock(s.mutex);opened=s.socket&&s.socket->isOpen();} + if(opened){ + {std::lock_guard lock(s.mutex);s.ui.connected=true;s.ui.status="Connected to DKR-R Online.";s.update();}attempt=0; + if(now>=renew){request("POST","/v1/online/session",Json::object(),s.instance,stop);renew=now+std::chrono::seconds(60);} + if(now>=heartbeat){send(s,{{"type","heartbeat"}});heartbeat=now+std::chrono::milliseconds(19000+(s.jitter+s.serial*73)%2000);} + std::deque inbox,outbox; + {std::lock_guard lock(s.mutex);inbox.swap(s.inbox);outbox.swap(s.outbox);} + for(auto& item:inbox){ + const auto type=string(item,"type",32); + if(type=="snapshot"){ + std::string cursor; + {std::lock_guard lock(s.mutex);append_rooms(s,item,true);cursor=string(item,"nextCursor",80);s.update();} + if(!cursor.empty())send(s,{{"type","list"},{"cursor",cursor}}); + }else if(type=="delta"){ + std::lock_guard lock(s.mutex); + if(item.value("revision",std::uint64_t(0))>=s.ui.revision){ + if(item.contains("upsert"))append_rooms(s,{{"rooms",item["upsert"]},{"online",item.value("online",std::uint64_t(0))},{"revision",item.value("revision",std::uint64_t(0))}},false); + if(item.contains("remove")&&item["remove"].is_array()&&item["remove"].size()<=50){ + for(const auto& id:item["remove"])if(id.is_string())std::erase_if(s.ui.rooms,[&](const auto& room){return room.id==id.get();}); + } + s.update(); + } + }else if(type=="result"){ + std::string operation,join_request; + {std::lock_guard lock(s.mutex);const auto id=string(item,"request",80);const auto found=s.pending.find(id);if(found==s.pending.end())continue;operation=found->second;s.pending.erase(found); + if(auto found=s.join_requests.find(id);found!=s.join_requests.end()){join_request=found->second;s.join_requests.erase(found);}} + if(!item.value("ok",false)){ + const auto error=item.value("error",Json::object()); + std::lock_guard lock(s.mutex);s.ui.error=string(error,"message",256); + if(s.ui.joining&&(operation=="join"||operation=="resolve")&&join_request==secure::encode_key(s.join_key.public_key))retire_join(s); + s.ui.updating=false;s.update(); + if(string(error,"code")=="revision"){ + const auto current=error.value("current",Json::object());last_state.clear();host_revision=current.value("revision",std::uint64_t(0));host_id=string(current,"id",80);host_epoch=string(current,"epoch",80); + } + }else if(item.contains("data")){ + const auto& data=item["data"]; + if(data.contains("rooms")){ + std::string cursor;{std::lock_guard lock(s.mutex);append_rooms(s,data,false);cursor=string(data,"nextCursor",80);s.update();} + if(!cursor.empty())send(s,{{"type","list"},{"cursor",cursor}}); + } + if(data.contains("online")){std::lock_guard lock(s.mutex);s.ui.online=data.value("online",std::uint64_t(0));s.ui.count_available=true;s.update();} + if(data.contains("visibility")){ + host_id=string(data,"id",80);host_epoch=string(data,"epoch",80);host_revision=data.value("revision",std::uint64_t(0)); + std::lock_guard lock(s.mutex);s.ui.public_listed=string(data,"visibility")=="public";s.ui.updating=false;s.update(); + } + if(string(data,"status")=="contacting_host"){ + std::lock_guard lock(s.mutex); + if(s.ui.joining&&join_request==secure::encode_key(s.join_key.public_key))s.joining_id=string(data,"id",80); + else if(s.outbox.size()<64)s.outbox.push_back({{"type","cancel"},{"id",string(data,"id",80)}}); + } + } + }else if(type=="intent"||type=="revoke"){ + std::lock_guard lock(s.bridge_mutex); + for(auto& [owner,bridge]:s.bridges){std::lock_guard own(bridge->mutex); + if(bridge->state.value("host",false)&&bridge->commands.size()<32)bridge->commands.push_back(item);} + }else if(type=="bootstrap"){ + std::lock_guard lock(s.mutex); + if(s.ui.joining&&string(item,"key",80)==secure::encode_key(s.join_key.public_key)&&string(item,"id",80)==s.joining_id)s.bootstrap=std::move(item); + }else if(type=="join_cancelled"){ + std::lock_guard lock(s.mutex);if(string(item,"id",80)==s.joining_id){s.ui.error=string(item,"message",256);retire_join(s);s.update();} + }else if(type=="join_consumed"){ + std::lock_guard lock(s.mutex);if(string(item,"id",80)==s.joining_id)s.joining_id.clear(); + } + } + for(auto& item:outbox)send(s,std::move(item)); + Json host; + std::vector responses; + {std::lock_guard lock(s.bridge_mutex); + for(auto& [owner,bridge]:s.bridges){std::lock_guard own(bridge->mutex); + if(bridge->state.value("host",false))host=bridge->state; + for(auto& response:bridge->responses)responses.push_back(std::move(response));bridge->responses.clear();}} + for(auto& response:responses)send(s,std::move(response)); + if(!host.is_null()){ + host.erase("host");const auto epoch=string(host,"epoch",80),serialized=host.dump(); + bool updating;{std::lock_guard lock(s.mutex);updating=std::any_of(s.pending.begin(),s.pending.end(),[](const auto& p){return p.second=="room";});} + if(!updating&&serialized!=last_state&&now>=changed_at){ + send(s,{{"type","room"},{"revision",epoch==host_epoch?host_revision:0},{"state",host}}); + last_state=serialized;changed_at=now+std::chrono::seconds(2); + std::lock_guard lock(s.mutex);s.ui.updating=true;s.update(); + } + }else if(!host_id.empty()){ + send(s,{{"type","close"}});host_id.clear();host_epoch.clear();last_state.clear();host_revision=0; + std::lock_guard lock(s.mutex);s.ui.public_listed=false;s.ui.updating=false;s.update(); + } + } + }catch(const std::exception&){ + close_socket(s);retry=Clock::now()+std::chrono::seconds(std::min(30U,1U<>prefs;s.ui.enabled=prefs.value("enabled",true);}catch(...){} + s.update();s.worker=std::jthread(run); +} +void shutdown_account(){auto& s=state();s.worker.request_stop();s.cv.notify_all();if(s.worker.joinable())s.worker.join();std::lock_guard lock(s.mutex);s.account={};s.request={};} +std::shared_ptr view(){return state().published.load();} +void connect(bool enabled){auto& s=state();std::lock_guard lock(s.mutex);s.ui.enabled=enabled;s.ui.error.clear();s.retry_now=enabled;if(!enabled)retire_join(s);s.update();s.cv.notify_all(); + try{std::ofstream file(s.root/"online-directory.json",std::ios::trunc);file< bridge(const void* owner){auto& s=state();std::lock_guard lock(s.bridge_mutex);auto& b=s.bridges[owner];if(!b)b=std::make_shared();return b;} +void forget(const void* owner){auto& s=state();std::lock_guard lock(s.bridge_mutex);s.bridges.erase(owner);auto key=s.keys.find(owner);if(key!=s.keys.end()){crypto_wipe(key->second.pair.secret.data(),32);s.keys.erase(key);}} +void publish(const void* owner,Json value){auto b=bridge(owner);std::lock_guard lock(b->mutex);b->state=std::move(value);} +std::vector commands(const void* owner){auto b=bridge(owner);std::lock_guard lock(b->mutex);std::vector result;result.swap(b->commands);return result;} +int hosting_mode(const void* owner){auto b=bridge(owner);std::lock_guard lock(b->mutex);return b->state.value("host",false)?2:b->state.value("legacyHost",false)?1:0;} +void respond(const void* owner,Json value){auto b=bridge(owner);std::lock_guard lock(b->mutex);if(b->responses.size()<64)b->responses.push_back(std::move(value));} +bool take_bootstrap(const void* owner,Bootstrap& out){ + auto& s=state();std::lock_guard lock(s.mutex);if(!s.bootstrap.is_object())return false; + const auto code=string(s.bootstrap,"code",5),admission=string(s.bootstrap,"admission",80); + if(code.size()!=5||!secure::decode_key(admission,out.admission)){retire_join(s);s.update();return false;} + out.code=code;out.watch=s.watch; + {std::lock_guard lock(s.bridge_mutex);s.keys[owner]={s.join_key,out.admission};} + crypto_wipe(s.join_key.secret.data(),32);s.join_key={};s.bootstrap=nullptr;s.ui.joining=false;s.update();return true; +} +secure::KeyPair client_key(const void* owner){auto& s=state();std::lock_guard lock(s.bridge_mutex);auto found=s.keys.find(owner); + if(found==s.keys.end())return secure::generate_key_pair();auto result=found->second.pair;crypto_wipe(found->second.pair.secret.data(),32);s.keys.erase(found);return result;} +bool bypass_code_resolution(const void* owner,const secure::Key& admission){auto& s=state();std::lock_guard lock(s.bridge_mutex);auto found=s.keys.find(owner);return found!=s.keys.end()&&found->second.admission==admission;} +bool begin_code_join(const void* owner,std::string code,std::string,const secure::Key& admission,bool watch){ + auto v=view();if(!v->feature_enabled||bypass_code_resolution(owner,admission))return false; + request_join({},std::move(code),watch,admission==secure::Key{}?std::string{}:secure::encode_key(admission));return true; +} +} // namespace dkr::online diff --git a/runtime-recomp/src/game/online_directory.hpp b/runtime-recomp/src/game/online_directory.hpp new file mode 100644 index 00000000..965c0cbe --- /dev/null +++ b/runtime-recomp/src/game/online_directory.hpp @@ -0,0 +1,70 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "netplay/secure_channel.hpp" + +namespace dkr::online { +using Json = nlohmann::json; +using Clock = std::chrono::steady_clock; +namespace secure = dkr::runtime::netplay::secure; +inline constexpr unsigned Protocol = 1; +inline constexpr const char* Api = "https://dkr-mod-library-api.ross-macgregorbars.workers.dev"; +struct Account { bool signed_in=false; unsigned generation=0; std::string name; }; +using AccountView = std::function; +using AccountRequest = std::function; +void bind_account(AccountView, AccountRequest); +void shutdown_account(); +void configure(const std::filesystem::path&); +struct Room { + std::string id,title,host,compatibility,phase; + unsigned racers=0,max_racers=0,spectators=0,max_spectators=0; + bool locked=false,can_join=false,can_spectate=false; +}; +struct View { + bool signed_in=false,connected=false,feature_enabled=false,enabled=true,joining=false; + bool public_desired=false,public_listed=false,updating=false,hidden=false; + bool running_spectators=false; + bool count_available=false; + std::uint64_t online=0,revision=0; + std::uint64_t connection_generation=0; + unsigned account_generation=0; + std::string account_name,status="Sign in with Discord to find public lobbies.",error; + std::vector rooms; +}; +std::shared_ptr view(); +void connect(bool enabled); +void visibility(bool make_public); +void hidden(bool value); +void request_join(std::string room, std::string code, bool watch, std::string friend_admission={}); +void cancel_join(); +struct Bootstrap { std::string code; secure::Key admission{}; bool watch=false; }; +bool take_bootstrap(const void* owner, Bootstrap&); + +// Native owner bridge: no socket callbacks acquire DirectSession or PartyLink. +struct Bridge { + std::mutex mutex; + Json state=Json::object(); + std::vector commands,responses; +}; +std::shared_ptr bridge(const void*); +void forget(const void*); +void publish(const void*, Json state); +std::vector commands(const void*); +int hosting_mode(const void*); // 0 preparing/inactive, 1 retained legacy room, 2 directory-backed. +void respond(const void*, Json response); +secure::KeyPair client_key(const void*); +bool bypass_code_resolution(const void*, const secure::Key&); +bool begin_code_join(const void*, std::string code, std::string name, const secure::Key& admission, bool watch); +std::string compatibility(); +void compatibility(std::string value); +} // namespace dkr::online diff --git a/runtime-recomp/src/game/online_view_rt64.cpp b/runtime-recomp/src/game/online_view_rt64.cpp index 542a271b..382cc374 100644 --- a/runtime-recomp/src/game/online_view_rt64.cpp +++ b/runtime-recomp/src/game/online_view_rt64.cpp @@ -57,7 +57,7 @@ void Pass::prepare(const Session& session,std::span if(!p.plan.eligible)return; for(unsigned i=0;i +#include + +namespace dkr::runtime::spectators { +namespace {thread_local std::unordered_map hooks;} +void register_owned_observer(const void* world,OwnedObserverHooks value){ + if(!world||!value.context||!value.retire||!value.admit||!value.magic_actions||!value.read_only||hooks.contains(world)) + throw std::runtime_error("Invalid or duplicate owned observer registration."); + hooks.emplace(world,value); +} +void unregister_owned_observer(const void* world){hooks.erase(world);} +Work retire_owned_observer(const void* world,Cursor next,std::string& error){ + const auto p=hooks.find(world);if(p==hooks.end()){error="Observer admission was called outside its owned simulation thread.";return Work::Failed;} + return p->second.retire(p->second.context,next,error); +} +bool admit_owned_observer(const void* world,const Baseline& baseline,std::string& error){ + const auto p=hooks.find(world);if(p==hooks.end()){error="Observer admission has no owned guest adapter.";return false;} + return p->second.admit(p->second.context,baseline,error); +} +std::uint32_t owned_magic_actions(const void* world){const auto p=hooks.find(world);return p==hooks.end()?0:p->second.magic_actions(p->second.context);} +void owned_observer_read_only(const void* world,bool enabled){const auto p=hooks.find(world);if(p!=hooks.end())p->second.read_only(p->second.context,enabled);} +} diff --git a/runtime-recomp/src/game/owned_observer.hpp b/runtime-recomp/src/game/owned_observer.hpp new file mode 100644 index 00000000..b49d8176 --- /dev/null +++ b/runtime-recomp/src/game/owned_observer.hpp @@ -0,0 +1,20 @@ +#pragma once +#include "observer_playback.hpp" + +namespace dkr::runtime::spectators { +struct OwnedObserverHooks { + void* context=nullptr; + Work (*retire)(void*,Cursor,std::string&)=nullptr; + bool (*admit)(void*,const Baseline&,std::string&)=nullptr; + std::uint32_t (*magic_actions)(void*)=nullptr; + void (*read_only)(void*,bool)=nullptr; +}; +// Simulation-owner thread-local registration. No raw pointer is sent over the +// network or put in guest checkpoints. Foreign-thread calls find no binding. +void register_owned_observer(const void* world,OwnedObserverHooks hooks); +void unregister_owned_observer(const void* world); +Work retire_owned_observer(const void* world,Cursor next,std::string& error); +bool admit_owned_observer(const void* world,const Baseline& baseline,std::string& error); +std::uint32_t owned_magic_actions(const void* world); +void owned_observer_read_only(const void* world,bool enabled); +} diff --git a/runtime-recomp/src/game/owned_vehicle_calls.inl b/runtime-recomp/src/game/owned_vehicle_calls.inl new file mode 100644 index 00000000..590f91cf --- /dev/null +++ b/runtime-recomp/src/game/owned_vehicle_calls.inl @@ -0,0 +1,16 @@ +// Included separately by each isolated revision. Internal linkage is essential: +// V77/V80 must never share a linker-folded wrapper for different guest symbols. +namespace { +namespace dkr_owned_vehicle_menu { +static void frame(std::uint8_t* ram,recomp_context* ctx) { + ctx->f_odd=ctx->mips3_float_mode?&ctx->f1.u32l:&ctx->f0.u32h; + const auto result=dkr_probe_run_native(::menu_element_render,ram,ctx); + if(!result.completed)throw dkr::mods::Error(result.blocked?result.blocked:"Owned vehicle frame callback failed."); +} +static void text_width(std::uint8_t* ram,recomp_context* ctx) { + ctx->f_odd=ctx->mips3_float_mode?&ctx->f1.u32l:&ctx->f0.u32h; + const auto result=dkr_probe_run_native(::get_text_width,ram,ctx); + if(!result.completed)throw dkr::mods::Error(result.blocked?result.blocked:"Owned vehicle text callback failed."); +} +} +} diff --git a/runtime-recomp/src/game/owned_vehicle_menu.hpp b/runtime-recomp/src/game/owned_vehicle_menu.hpp new file mode 100644 index 00000000..5719b810 --- /dev/null +++ b/runtime-recomp/src/game/owned_vehicle_menu.hpp @@ -0,0 +1,10 @@ +#pragma once +#include "game_payload.hpp" + +namespace dkr::runtime::legacy { +class ModWorld; +// Native entrypoints are bound only on the private simulation owner's stack. +// No host address enters guest arguments, checkpoints or network messages. +int draw_owned_vehicle_menu(ModWorld&,std::uint8_t*,recomp_context*, + RecompiledEntrypoint frame,RecompiledEntrypoint text_width) noexcept; +} diff --git a/runtime-recomp/src/game/owner_context.inl b/runtime-recomp/src/game/owner_context.inl new file mode 100644 index 00000000..069c8609 --- /dev/null +++ b/runtime-recomp/src/game/owner_context.inl @@ -0,0 +1,54 @@ +// Inside DirectSession, with mutex_ held. Credentials never enter the sidecar +// or its public snapshot. Lock order remains DirectSession -> Party -> check. +auto rollback_context=[&] { + namespace check=dkr::runtime::rollback_check; + namespace watch=dkr::runtime::spectators; + check::Context c;c.room=match_id_;c.local=sender_id_;c.host_id=is_host_?sender_id_:host_sender_id_;c.host=is_host_; + const auto& room=is_host_?lobby_.room():room_view_; + const auto& rules=room.rules;c.window=rules.rollback_window;c.automatic=rules.automatic_input_delay; + c.delay=c.automatic?input_delay_:rules.manual_input_delay; + c.idle=(state_==ConnectionState::Hosting||state_==ConnectionState::Lobby)&&!pending_invitation_&& + launch_stage_==LaunchStage::Idle&&!launch_countdown_active_&&rules.synchronization==SynchronizationMode::ExperimentalRollback; + auto party=watch::party_link(this);std::scoped_lock party_lock(party->mutex); + c.party=party->enabled; + if(c.party){ + const auto roster=party->party.snapshot();c.roster=roster.generation;c.local_watching=party->local_watching(); + c.idle=c.idle&&party->admitted&&!party->launch&&!party->running&&!party->stopping&&!party->start_requested&&!roster.handover; + for(const auto& m:roster.members){ + if(m.id==sender_id_||!m.connected||(!is_host_&&m.id!=host_sender_id_))continue; + const bool observer=m.role==watch::Role::Spectator; + const auto* peer=party->peer(m.id);const auto address=is_host_&&peer?peer->address:host_address_; + bool ready=bool(address)&&transport_->traffic_ready(address,TransportTrafficClass::Control); + ready=ready&&transport_->traffic_ready(address,observer?TransportTrafficClass::Replica:TransportTrafficClass::Control); + if(observer)ready=ready&&transport_->traffic_ready(address,TransportTrafficClass::Checkpoint); + c.peers.push_back({m.id,m.name,observer,ready}); + } + c.queued=party->outgoing_bytes; + }else if(is_host_){ + for(const auto& peer:peers_)if(peer.active){ + bool ready=true;for(auto lane:{TransportTrafficClass::Control,TransportTrafficClass::Realtime,TransportTrafficClass::Authoritative})ready=ready&&transport_->traffic_ready(peer.address,lane); + c.peers.push_back({peer.sender_id,room.players[peer.slot].display_name,false,ready}); + } + }else if(host_address_){ + std::string name="Host";for(const auto& player:room_view_.players)if(player.host)name=player.display_name; + c.peers.push_back({host_sender_id_,name,false,true}); + } + // Report identities are authenticated party IDs; ordinary four-port + // lobbies use generation-bound port tokens because their Room codec does + // not expose other clients' authentication IDs. No port is assigned here. + if(c.party){ + c.local_member=sender_id_;c.host_member=c.host_id; + for(const auto& m:party->party.snapshot().members)if(m.connected)c.members.push_back({m.id,m.name,m.role==watch::Role::Spectator,true,m.id}); + for(auto& p:c.peers)p.member=p.id; + }else{ + c.roster=room.generation;c.local_member=local_slot_<4?std::uint64_t(local_slot_)+1:0;c.host_member=1; + for(unsigned i=0;islot)+1;}else p.member=1;} + } + std::sort(c.members.begin(),c.members.end(),[](const auto& a,const auto& b){return a.idbuffered_bytes(lane); + return c; +}; diff --git a/runtime-recomp/src/game/owner_export.cpp b/runtime-recomp/src/game/owner_export.cpp new file mode 100644 index 00000000..1c2859bf --- /dev/null +++ b/runtime-recomp/src/game/owner_export.cpp @@ -0,0 +1,6 @@ +#include "owner_export.hpp" + +namespace dkr::runtime::spectators { +OwnerExport::OwnerExport(std::uint64_t match,bool host) + :stream_(host?acquire_export(match):nullptr),binding_(stream_.get()){} +} diff --git a/runtime-recomp/src/game/owner_export.hpp b/runtime-recomp/src/game/owner_export.hpp new file mode 100644 index 00000000..6acfbab2 --- /dev/null +++ b/runtime-recomp/src/game/owner_export.hpp @@ -0,0 +1,47 @@ +#pragma once +#include "confirmed_observer.hpp" +#include +#include + +namespace dkr::runtime::spectators { +// The only canonical-world operation is capture(), invoked on its owner only +// when the complete world frontier equals the committed frontier. Hashing is +// performed on a private immutable copy by one bounded background job. +class OwnerExport final { +public: + OwnerExport(std::uint64_t match,bool host); + templatevoid poll(std::uint64_t epoch,std::uint32_t next,std::uint32_t confirmed, + bool can_publish,World& world,Hash hash)noexcept{ + if(!stream_)return; + try{ + if(epoch_&&epoch!=epoch_)stream_->resync(); + epoch_=epoch; + if(work_.valid()&&work_.wait_for(std::chrono::seconds(0))==std::future_status::ready){ + auto completed=work_.get(); + if(completed.next.epoch==epoch)stream_->publish_baseline(completed.next,completed.schema,completed.control_generation,completed.state,completed.digest); + else stream_->resync(); + } + if(work_.valid()||!stream_->wants_baseline()||next!=confirmed||!can_publish)return; + const auto now=std::chrono::steady_clock::now(); + if(nowConfirmedFeed::MaximumState)return; + auto state=std::make_shared>(contract.state_bytes); + std::string error;if(!world.capture(*state,error)){stream_->resync();return;} + const Cursor cursor{epoch,next};stream_->begin_capture(cursor); + work_=std::async(std::launch::async,[state=std::shared_ptr>(std::move(state)),cursor,schema=contract.schema,hash]{ + Baseline b;b.next=cursor;b.schema=schema;b.control_generation=1;b.state=state;b.digest=hash(*state);return b; + }); + }catch(...){stream_->resync();} + } +private: + std::shared_ptr stream_; + ConfirmedExportBinding binding_; + std::future work_; + std::chrono::steady_clock::time_point retry_after_{}; + std::uint64_t epoch_=0; +}; +} diff --git a/runtime-recomp/src/game/owner_pump.inl b/runtime-recomp/src/game/owner_pump.inl new file mode 100644 index 00000000..02adc8f9 --- /dev/null +++ b/runtime-recomp/src/game/owner_pump.inl @@ -0,0 +1,125 @@ +// Inside DirectSession::pump_locked, before receiving packets. This is the only +// place directory intents are applied to native host state. +{ + namespace directory=dkr::online; + namespace watch=dkr::runtime::spectators; + auto owner=directory::native::owner(this); + const auto online=directory::view(); + const auto now=std::chrono::steady_clock::now(); + const bool hosting=is_host_&&method_==ConnectionMethod::QuickJoin&& + state_!=ConnectionState::Offline&&state_!=ConnectionState::Failed; + if(!hosting){ + if(!owner->epoch.empty())directory::native::reset(this); + directory::publish(this,directory::Json::object()); + }else{ + auto party=watch::party_link(this);std::scoped_lock party_lock(party->mutex); + const auto epoch=hex64(match_id_); + if(owner->epoch!=epoch){directory::native::reset(this);owner->epoch=epoch;owner->guarded=online->feature_enabled||online->public_desired;} + if(owner->channel!=online->connection_generation){ + owner->channel=online->connection_generation; + for(auto& a:owner->admissions)if(!a.consumed)crypto_wipe(a.capability.data(),32); + std::erase_if(owner->admissions,[](const auto& a){return !a.consumed;}); + } + const bool hidden=online->hidden; + const bool make_public=online->public_desired&&!hidden; + if(owner->public_room!=make_public){ + owner->public_room=make_public;++owner->privacy; + for(auto& a:owner->admissions)if(!a.consumed)crypto_wipe(a.capability.data(),32); + std::erase_if(owner->admissions,[](const auto& a){return !a.consumed;}); + } + auto gate=dkr::runtime::party_start::gate(this); + const bool starting=launch_countdown_active_||launch_stage_!=LaunchStage::Idle|| + gate->phase==dkr::runtime::party_start::Phase::Countdown||gate->phase==dkr::runtime::party_start::Phase::Committing; + const bool testing=connection_test_active_||dkr::runtime::rollback_check::active(this); + const bool returning=party->stopping||(owned_match_end_&&!owned_match_end_->resumed); + const bool live=party->running||state_==ConnectionState::Running; + const bool loading=state_==ConnectionState::Loading||bool(party->launch); + const std::string phase=returning?"returning":testing?"testing":starting?"starting":live?"racing":loading?"loading":"waiting"; + unsigned racers=0,spectators=0,pending_racers=0,pending_spectators=0; + std::uint64_t roster=room_view_.generation; + if(party->enabled){ + const auto snapshot=party->party.snapshot();roster=snapshot.generation; + for(const auto& member:snapshot.members)if(member.connected){if(member.role==watch::Role::Spectator)++spectators;else ++racers;} + }else racers=unsigned(occupied_players()); + for(const auto& p:pending_joins_)if(p.active){ + if(party->enabled&&party->pending_watch.contains(p.sender_id)&&party->pending_watch[p.sender_id])++pending_spectators; + else ++pending_racers; + } + const auto authorization=phase+":"+std::to_string(lobby_locked_)+":"+std::to_string(make_public)+":"+ + invite_+":"+secure::encode_key(host_key_pair_.public_key)+":"+std::to_string(room_view_.rules.maximum_players); + const auto policy=authorization+":"+std::to_string(roster)+":"+std::to_string(online->account_generation); + if(owner->policy!=policy){ + const bool same_scope=owner->authorization==authorization; + owner->authorization=authorization;owner->policy=policy;++owner->privacy; + for(auto& pending:pending_joins_)if(pending.active){ + auto* binding=directory::native::binding(*owner,pending.sender_id); + if(binding&&binding->privacy!=owner->privacy){ + // Host-approved immutable content preparation belongs to + // the authenticated native session, not a directory lease. + if(pending.mods_approved&&same_scope){binding->privacy=owner->privacy;continue;} + send_with_key(pending.address,pending.key,protocol::MessageType::HelloAck, + protocol::encode_hello_ack({false,0,"The host changed the lobby. Please request to join again."})); + party->pending_watch.erase(pending.sender_id);clear_pending_mod_route_locked(pending);pending={}; + } + } + for(auto& admission:owner->admissions)if(!admission.consumed)crypto_wipe(admission.capability.data(),32); + std::erase_if(owner->admissions,[](const auto& a){return !a.consumed;}); + } + std::erase_if(owner->admissions,[&](auto& a){ + auto* pending=pending_by_sender(a.sender); + const bool member=party->enabled?party->peer(a.sender)!=nullptr:peer_by_sender(a.sender)!=nullptr; + const bool expired=directory::contract::expired_admission(a.expires<=now,a.consumed,pending&&pending->mods_approved,member); + const bool finished=a.consumed&&!pending&&!member; + if(expired&&pending){ + send_with_key(pending->address,pending->key,protocol::MessageType::HelloAck, + protocol::encode_hello_ack({false,0,"The authenticated join request expired. Please request to join again."})); + party->pending_watch.erase(pending->sender_id);clear_pending_mod_route_locked(*pending);*pending={}; + } + if(expired||finished)crypto_wipe(a.capability.data(),32);return expired||finished; + }); + for(auto& command:directory::commands(this)){ + const auto type=command.value("type",std::string{}); + if(type=="revoke"){ + const auto id=command.value("id",std::string{}); + for(auto& a:owner->admissions)if(a.id==id&&!a.consumed)crypto_wipe(a.capability.data(),32); + std::erase_if(owner->admissions,[&](const auto& a){return a.id==id&&!a.consumed;});continue; + } + if(type!="intent"||!command.contains("join"))continue; + const auto& intent=command["join"]; + directory::native::Admission a; + a.id=intent.value("id",std::string{});a.account=intent.value("account",std::string{}); + a.epoch=intent.value("epoch",std::string{});a.watching=intent.value("role",std::string{})=="spectator"; + a.allow_running=intent.value("running",false)&&online->running_spectators; + a.privacy=owner->privacy;a.expires=now+std::chrono::seconds(30); + const bool running_watch=directory::contract::running_spectator(a.watching,a.allow_running,party->enabled,party->running,party->stopping,starting); + bool allowed=owner->guarded&&online->connected&&online->signed_in&&(phase=="waiting"||phase=="racing"&&running_watch)&&!lobby_locked_&& + (!intent.value("public",true)||owner->public_room)&& + a.epoch==owner->epoch&&!owner->blocked.contains(a.account)&&owner->admissions.size()<16&& + secure::decode_key(intent.value("key",std::string{}),a.client)&& + (a.watching?party->enabled&&spectators+pending_spectators<8:racers+pending_racersadmissions.push_back(a);} + directory::respond(this,{{"type","grant"},{"id",a.id},{"allowed",allowed}, + {"admission",allowed?secure::encode_key(a.capability):std::string{}}}); + } + if(owner->guarded&&now-owner->published>=std::chrono::milliseconds(250)){ + owner->published=now; + directory::publish(this,{{"host",true},{"title",room_view_.name},{"epoch",epoch}, + {"compatibility",manifest_.release_version},{"visibility",make_public?"public":"private"}, + {"phase",phase},{"racers",racers},{"maxRacers",unsigned(room_view_.rules.maximum_players)}, + {"spectators",spectators},{"maxSpectators",party->enabled?8:0}, + {"reservedRacers",pending_racers},{"reservedSpectators",pending_spectators}, + {"canSpectateRunning",party->enabled&&online->running_spectators}, + {"locked",lobby_locked_},{"hidden",online->hidden}, + {"roster",std::to_string(roster)},{"code",invite_},{"bootstrap",invite_}}); + }else if(!owner->guarded&&now-owner->published>=std::chrono::milliseconds(250)){ + owner->published=now;directory::publish(this,{{"legacyHost",true}}); + } + } +} diff --git a/runtime-recomp/src/game/owner_send.inl b/runtime-recomp/src/game/owner_send.inl new file mode 100644 index 00000000..6a952ba8 --- /dev/null +++ b/runtime-recomp/src/game/owner_send.inl @@ -0,0 +1,22 @@ +// Synthetic datagrams share the existing authenticated route and traffic +// classification, but never enter the game's reliable application queues. +auto rollback_send=[&](std::uint64_t target,protocol::MessageType type,std::span payload) { + namespace watch=dkr::runtime::spectators; + if(!transport_->is_open())return false; + PeerAddress address{};secure::Key peer_key{}; + auto party=watch::party_link(this);std::scoped_lock party_lock(party->mutex); + if(is_host_){ + if(party->enabled){auto* peer=party->peer(target);if(!peer)return false;address=peer->address;peer_key=peer->key;} + else{auto* peer=peer_by_sender(target);if(!peer)return false;address=peer->address;peer_key=peer->key;} + }else{if(target!=host_sender_id_)return false;address=host_address_;peer_key=key_;} + TransportTrafficClass lane=TransportTrafficClass::Control; + if(type==protocol::MessageType::PreflightRealtimeProbe)lane=TransportTrafficClass::Realtime; + else if(type==protocol::MessageType::PreflightAuthorityProbe)lane=TransportTrafficClass::Authoritative; + else if(type==protocol::MessageType::PreflightReplicaProbe)lane=TransportTrafficClass::Replica; + if(!transport_->traffic_ready(address,lane)||transport_->buffered_bytes(lane)>64*1024)return false; + auto& sequence=outbound_sequence(address,peer_key);if(sequence==UINT64_MAX)return false; + protocol::Datagram record{{type,match_id_,sequence++,0},{payload.begin(),payload.end()}}; + const auto plain=protocol::encode(record,transport_->maximum_plaintext_datagram_bytes());if(plain.empty())return false; + const auto bytes=secure::seal(plain,peer_key,sender_id_,record.header.sequence,match_id_); + std::string ignored;return transport_->send_status(address,bytes,lane,ignored)==DatagramSendStatus::Sent; +}; diff --git a/runtime-recomp/src/game/owner_state.hpp b/runtime-recomp/src/game/owner_state.hpp new file mode 100644 index 00000000..4e319970 --- /dev/null +++ b/runtime-recomp/src/game/owner_state.hpp @@ -0,0 +1,43 @@ +#pragma once +#include "online_directory.hpp" +#include "monocypher.h" +#include "admission_contract.hpp" +#include +#include + +// All Owner fields are accessed with the existing DirectSession mutex held. +// Only registry lookup uses its own lock; callbacks never touch Owner fields. +namespace dkr::online::native { +struct Admission : contract::Proof { + std::string id,account,epoch; + Clock::time_point expires{}; + bool automatic=false,allow_running=false; +}; +struct Owner { + std::string epoch,policy,authorization; + std::uint64_t privacy=1,channel=0; + bool guarded=false,public_room=false; + std::vector admissions; + std::set blocked; + Clock::time_point published{}; +}; +struct Registry {std::mutex mutex;std::map> owners;}; +inline Registry& registry(){static auto* p=new Registry;return *p;} +inline std::shared_ptr owner(const void* session){auto& r=registry();std::lock_guard lock(r.mutex);auto& p=r.owners[session];if(!p)p=std::make_shared();return p;} +inline void reset(const void* session){auto p=owner(session);for(auto& a:p->admissions)crypto_wipe(a.capability.data(),32);*p={};dkr::online::publish(session,Json::object());} +inline void forget(const void* session){auto& r=registry();std::lock_guard lock(r.mutex);auto p=r.owners.find(session); + if(p!=r.owners.end()){for(auto& a:p->second->admissions)crypto_wipe(a.capability.data(),32);r.owners.erase(p);}dkr::online::forget(session);} +inline Admission* binding(Owner& state,std::uint64_t sender){ + for(auto& admission:state.admissions)if(admission.sender==sender&&admission.consumed)return &admission;return nullptr; +} +inline bool authorize(Owner& owner,const secure::Key& capability,const secure::Key& key,std::uint64_t sender,bool watching){ + const auto now=Clock::now(); + for(auto& a:owner.admissions){ + if(!contract::permits(a,capability,key,owner.privacy,sender,watching,a.epoch==owner.epoch&&!owner.blocked.contains(a.account), + a.expires>now&&dkr::online::view()->connected))continue; + if(a.consumed)return a.sender==sender; + a.consumed=true;a.sender=sender;a.expires=now+std::chrono::minutes(3);return true; + } + return false; +} +} // namespace dkr::online::native diff --git a/runtime-recomp/src/game/package_compatibility.hpp b/runtime-recomp/src/game/package_compatibility.hpp new file mode 100644 index 00000000..b04f9f11 --- /dev/null +++ b/runtime-recomp/src/game/package_compatibility.hpp @@ -0,0 +1,214 @@ +#pragma once +#include "vehicle_package.hpp" +#include "magic_code_package.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::mods::vehicles::original_textures { +[[noreturn]] inline void fail(const std::string& why){throw Error("Custom vehicle: "+why);} + +inline void seating_contract(const Json& manifest,Package& car) { + if(!manifest.contains("seating_contract"))return; + const auto& c=manifest.at("seating_contract"); + if(!c.is_object() || c.value("id","")!="dkrkart.per-base-driver" || + !c.contains("revision") || !c["revision"].is_number_integer() || c["revision"]!=1) + fail("unsupported explicit seating contract revision."); + if(!car.per_driver())fail("the seating contract requires per-base-driver positions."); + const auto& coords=c.at("coordinate_system");const auto& units=c.at("units"); + if(coords.value("handedness","")!="right" || coords.value("up","")!="+Y" || coords.value("forward","")!="-Z" || + units.value("driver_vertices","")!="unscaled native DKR model units, before ObjectHeader scale" || + units.value("driver_anchors","")!="same unscaled native DKR model units" || + units.value("driver_seat_positions","")!="Studio placement units" || + !units.at("native_model_to_studio").is_number() || units.at("native_model_to_studio")!=1.0 || + units.at("object_header_scale_applied")!=false) + fail("unsupported seating coordinate units."); + const auto& driver=c.at("driver"); + if(driver.value("position_source","")!="driver_seat_positions[base_slot]" || + driver.value("anchor_source","")!="driver_anchors[base_slot]" || + driver.value("rotation_source","")!="settings.driver_yaw" || + driver.value("rotation_units","")!="degrees" || driver.value("rotation_axis","")!="+Y" || + driver.value("rotation_pivot","")!="driver_anchors[base_slot]")fail("unsupported explicit driver placement."); + for(const char* field:{"vehicle_scale_applies","vehicle_yaw_applies","vehicle_translation_applies", + "legacy_settings_driver_applies","legacy_forward_20_applies","legacy_diddy_correction_applies"}) + if(driver.at(field)!=false)fail("the seating contract requests an unsupported driver transform."); + const auto& table=c.at("anchor_table"); + if(table.value("definition","")!="kart-fixed-base-reference-v1" || table.value("read_values_from","")!="driver_anchors") + fail("unsupported explicit anchor table."); + const auto canonical=table.at("canonical_json").get(); + if(canonical.empty() || canonical.size()>8192)fail("invalid anchor table encoding."); + const View bytes(reinterpret_cast(canonical.data()),canonical.size()); + if(table.at("sha256")!=sha256(bytes) || code::parse_document(bytes,8192)!=manifest.at("driver_anchors")) + fail("the seating contract anchor table does not match its hash and values."); + // Preserve the existing Package ABI. For PerBaseDriverV1 only, an explicit + // authored anchor flag selects revisioned RAW native units. The old shared- + // seat calibrated-anchor flag remains distinct through car.per_driver(). + car.authored_anchors=true; +} + +inline void effect_contract(const Json& manifest) { + if(!manifest.contains("effect_anchors"))return; + const auto& effects=manifest.at("effect_anchors"); + if(!effects.contains("revision") || !effects["revision"].is_number_integer() || effects["revision"]!=1)fail("unsupported effect anchor revision."); + const auto& boost=effects.at("boost"); + if(!boost.at("enabled").is_boolean())fail("invalid boost anchor setting."); + if(boost["enabled"].get())fail("custom boost anchors are not supported yet; export with the native boost position."); + // Disabled means the native effect position, as declared by the exporter. +} + +inline constexpr unsigned Interior=30, MaxTiles=32, MaxMeshes=64, MaxVertices=8192, MaxTriangles=4096; +using Point=std::array; // source XYZ and pixel-space ST +struct Cell {unsigned tile=0;double low=0,high=0,origin=0;}; +struct Axis { + unsigned size;bool repeat; + bool split()const{return size>32;} + unsigned count()const{return split()?(size+Interior-1)/Interior:1;} + unsigned start(unsigned i)const{return split()?i*Interior:0;} + unsigned content(unsigned i)const{return split()?std::min(Interior,size-start(i)):size;} + unsigned gutter()const{return split()?1:0;} + unsigned stored(unsigned i)const{return content(i)+2*gutter();} + std::vector cells(double low,double high)const { + const double inf=std::numeric_limits::infinity(); + if(!split())return {{0,-inf,inf,0}}; + const auto intersects=[&](double a,double b){return low==high ? low>=a && lowa && low out; + if(!repeat) { + for(unsigned i=0;i256)fail("original texture repetition exceeds the preparation budget."); + for(int period=first;period<=last;++period)for(unsigned i=0;i clip(std::vector input,unsigned axis,double edge,bool greater) { + if(input.empty() || !std::isfinite(edge))return input; + std::vector out;out.reserve(input.size()+1); + auto a=input.back();auto in_a=greater?a[axis]>=edge:a[axis]<=edge; + for(const auto& b:input) { + const bool in_b=greater?b[axis]>=edge:b[axis]<=edge; + if(in_a!=in_b)out.push_back(intersection(a,b,axis,edge)); + if(in_b)out.push_back(b); + a=b;in_a=in_b; + } + out.erase(std::unique(out.begin(),out.end()),out.end()); + if(out.size()>1 && out.front()==out.back())out.pop_back(); + return out; +} +inline bool drawable(const Point& a,const Point& b,const Point& c) { + double norm=0; + for(unsigned i=0;i<3;++i){const auto j=(i+1)%3,k=(i+2)%3; + const auto cross=(b[j]-a[j])*(c[k]-a[k])-(b[k]-a[k])*(c[j]-a[j]);norm+=cross*cross;} + return norm>1e-24; +} +inline void prepare(Package& car) { + const auto& source_textures=car.textures;const auto source_count=source_textures.size(); + std::vector tiles;std::vector> mappings; + for(const auto& t:source_textures) { + const Axis sx{t.image.width,t.repeat_s},sy{t.image.height,t.repeat_t}; + if(!sx.size || !sy.size || t.image.rgba.size()!=std::size_t(sx.size)*sy.size*4)fail("invalid original-resolution image storage."); + if(sx.count()*sy.count()>MaxTiles-tiles.size())fail("original textures require more than 32 safe material tiles. Split or simplify the car materials."); + std::vector map; + for(unsigned y=0;y prepared; + for(unsigned level=0;level<6;++level) { + auto& out=prepared[level];unsigned vertices=0,visits=0; + for(const auto& source:car.lods[level].meshes) { + if(source.texture<0) {out.meshes.push_back(source);vertices+=unsigned(source.vertices.size());out.triangles+=unsigned(source.faces.size());continue;} + const auto original=unsigned(source.texture); + if(original>=source_textures.size())fail("invalid original-resolution material reference."); + const auto& t=source_textures[original];const Axis sx{t.image.width,t.repeat_s},sy{t.image.height,t.repeat_t}; + if(!sx.split()&&!sy.split()) {auto mesh=source;mesh.texture=int(mappings[original][0]);out.meshes.push_back(std::move(mesh));vertices+=unsigned(source.vertices.size());out.triangles+=unsigned(source.faces.size());continue;} + struct Group {Mesh mesh;std::map,unsigned> vertices;}; + std::map groups; + for(const auto& face:source.faces) { + std::vector polygon; + double min_s=std::numeric_limits::infinity(),min_t=min_s,max_s=-min_s,max_t=-min_s; + for(const auto index:face) { + const auto& p=source.vertices.at(index);const auto& uv=source.uv.at(index); + Point v{p.x,p.y,p.z,double(uv.x)*sx.size,double(uv.y)*sy.size};polygon.push_back(v); + min_s=std::min(min_s,v[3]);max_s=std::max(max_s,v[3]);min_t=std::min(min_t,v[4]);max_t=std::max(max_t,v[4]); + } + const auto xs=sx.cells(min_s,max_s),ys=sy.cells(min_t,max_t); + if(xs.size()*ys.size()>32768U-visits)fail("original texture clipping exceeds the preparation budget."); + visits+=unsigned(xs.size()*ys.size()); + for(const auto& cy:ys)for(const auto& cx:xs) { + auto cut=clip(clip(clip(clip(polygon,3,cx.low,true),3,cx.high,false),4,cy.low,true),4,cy.high,false); + if(cut.size()<3)continue; + const auto tile=mappings[original].at(cy.tile*sx.count()+cx.tile); + for(std::size_t i=1;i+1second; + if(created){group.mesh.id=source.id;group.mesh.name=source.name;group.mesh.colour=source.colour; + group.mesh.double_sided=source.double_sided;group.mesh.texture=int(tile);} + std::array triangle{};unsigned corner=0; + for(const auto* p:{&cut[0],&cut[i],&cut[i+1]}) { + const std::array value{float((*p)[0]),float((*p)[1]),float((*p)[2]), + float(((*p)[3]-cx.origin+sx.gutter())/tiles[tile].image.width), + float(((*p)[4]-cy.origin+sy.gutter())/tiles[tile].image.height)}; + std::array key{};for(unsigned n=0;n<5;++n)key[n]=std::bit_cast(value[n]==0?0.F:value[n]); + auto [v,added]=group.vertices.try_emplace(key,unsigned(group.mesh.vertices.size())); + if(added){group.mesh.vertices.push_back({value[0],value[1],value[2]});group.mesh.uv.push_back({value[3],value[4]}); + if(++vertices>MaxVertices)fail("original texture clipping exceeds 8192 vertices in one LOD.");} + triangle[corner++]=v->second; + } + if(triangle[0]==triangle[1] || triangle[1]==triangle[2] || triangle[2]==triangle[0])continue; + group.mesh.faces.push_back(triangle); + if(++out.triangles>MaxTriangles)fail("original texture clipping exceeds 4096 triangles in one LOD."); + } + } + } + if(out.meshes.size()+groups.size()>MaxMeshes)fail("original texture clipping exceeds 64 draw groups in one LOD."); + for(auto& [tile,group]:groups)if(!group.mesh.faces.empty())out.meshes.push_back(std::move(group.mesh)); + } + if(out.meshes.empty() || out.meshes.size()>MaxMeshes || vertices>MaxVertices || out.triangles>MaxTriangles) + fail("original-resolution geometry exceeds the safe draw budget."); + for(const auto& wheel:car.wheels)if(!wheel.empty() && std::none_of(out.meshes.begin(),out.meshes.end(),[&](const auto& m){return m.id==wheel;})) + fail("original texture preparation lost an assigned wheel."); + } + // Publish only after all six LODs fit. Authored pivots were computed before + // splitting; each piece keeps its wheel ID and shares the original pivot. + car.textures=std::move(tiles);car.lods=std::move(prepared); + static std::atomic reports{0}; + if(reports.fetch_add(1,std::memory_order_relaxed)<8) + std::fprintf(stderr,"[custom-vehicles][original-textures] source-materials=%zu safe-tiles=%zu lod0-triangles=%u; original RGBA8 pixels retained; 4 KiB limits unchanged\n",source_count,car.textures.size(),car.lods[0].triangles); +} +} diff --git a/runtime-recomp/src/game/party.cpp b/runtime-recomp/src/game/party.cpp new file mode 100644 index 00000000..8646775d --- /dev/null +++ b/runtime-recomp/src/game/party.cpp @@ -0,0 +1,171 @@ +#include "party.hpp" +#include +#include +#include + +namespace dkr::runtime::spectators { +namespace { +bool no(std::string& error,const char* text){error=text;return false;} +bool name_ok(const std::string& text){return !text.empty()&&text.size()<=24&&std::all_of(text.begin(),text.end(),[](unsigned char c){return c>=32&&c!=127;});} +bool before(Cursor a,Cursor b){return a.epoch& values,MemberId id){return std::find(values.begin(),values.end(),id)!=values.end();} +} +const Member* Party::find(MemberId id)const { + const auto it=std::find_if(state_.members.begin(),state_.members.end(),[&](const auto& m){return m.id==id;}); + return it==state_.members.end()?nullptr:&*it; +} +bool Party::bump(std::string& error){if(state_.generation==UINT64_MAX)return no(error,"Lobby membership generation exhausted.");++state_.generation;error.clear();return true;} +bool Party::create(MemberId id,std::string name,std::string& error) { + if(!id||!name_ok(name))return no(error,"Invalid lobby host identity."); + PartySnapshot next;next.host=id;next.generation=1; + next.members.push_back({id,std::move(name),Role::Racer,std::uint8_t(0),true,true}); + next.controls[0]={id,1,{}};state_=std::move(next);proposal_serial_=0;error.clear();return true; +} +bool Party::add(MemberId id,std::string name,std::optional port,std::string& error) { + if(!state_.host||!id||!name_ok(name)||find(id)||state_.members.size()>=MaximumMembers||state_.generation==UINT64_MAX) + return no(error,"Invalid, duplicate or excess lobby member."); + if(port&&(*port>=RacingPorts||state_.controls[*port].member))return no(error,"That racing controller port is occupied."); + const auto spectators=std::count_if(state_.members.begin(),state_.members.end(),[](const auto& m){return m.role==Role::Spectator;}); + if(!port&&spectators>=MaximumSpectators)return no(error,"The spectator places are full."); + if(port&&state_.handover)return no(error,"Finish the pending player swap before adding a racer."); + if(port&&state_.controls[*port].generation==UINT64_MAX)return no(error,"Controller ownership generation exhausted."); + // Any membership edit retires an uncommitted agreement. A newcomer must + // never leave an old roster-generation proposal waiting indefinitely. + state_.handover.reset(); + state_.members.push_back({id,std::move(name),port?Role::Racer:Role::Spectator,port,false,true}); + if(port){auto& lease=state_.controls[*port];lease.member=id;++lease.generation;lease.starts={};} + return bump(error); +} +bool Party::remove(MemberId id,std::string& error) { + const auto* member=find(id);if(!member)return no(error,"That lobby member is not connected."); + if(id==state_.host)return no(error,"The host closes the lobby; host migration is not implicit."); + if(state_.generation==UINT64_MAX)return no(error,"Lobby membership generation exhausted."); + if(member->port){auto& lease=state_.controls[*member->port];if(lease.generation==UINT64_MAX)return no(error,"Controller ownership generation exhausted.");lease.member=0;++lease.generation;} + state_.handover.reset(); + std::erase_if(state_.members,[&](const auto& m){return m.id==id;});return bump(error); +} +bool Party::waiting_role(MemberId actor,MemberId id,std::optional port,std::string& error) { + // Caller proves that no game launch exists. Live ownership ALWAYS uses + // the confirmed-boundary propose/acknowledge/transfer protocol instead. + const auto* current=find(id); + if(actor!=state_.host||!current||state_.handover||state_.generation==UINT64_MAX|| + (port&&(*port>=RacingPorts||(state_.controls[*port].member&&state_.controls[*port].member!=id))))return no(error,"That waiting-lobby role cannot be assigned."); + if(current->port==port){error.clear();return true;} + if(!port&&std::count_if(state_.members.begin(),state_.members.end(),[](const auto& m){return m.role==Role::Spectator;})>=MaximumSpectators)return no(error,"The spectator places are full."); + auto next=state_;auto it=std::find_if(next.members.begin(),next.members.end(),[&](const auto& m){return m.id==id;}); + if(it->port){auto& lease=next.controls[*it->port];if(lease.generation==UINT64_MAX)return no(error,"Controller generation exhausted.");lease={0,lease.generation+1,{}};} + it->port=port;it->role=port?Role::Racer:Role::Spectator; + if(port){auto& lease=next.controls[*port];if(lease.generation==UINT64_MAX)return no(error,"Controller generation exhausted.");lease={id,lease.generation+1,{91,0}};} + ++next.generation;return replace_verified(std::move(next),error); +} +bool Party::propose(MemberId actor,std::uint8_t port,MemberId incoming,Cursor boundary, + const std::array& digest,std::string& error) { + const auto* replacement=find(incoming); + if(actor!=state_.host||!actor||state_.handover||port>=RacingPorts||!boundary.epoch||boundary.frame==UINT32_MAX|| + !state_.controls[port].member||!replacement||replacement->role!=Role::Spectator||!replacement->connected|| + std::all_of(digest.begin(),digest.end(),[](auto b){return !b;})) + return no(error,"Only the host can arrange a swap into an occupied racing port."); + if(proposal_serial_==UINT64_MAX||state_.controls[port].generation==UINT64_MAX||state_.generation==UINT64_MAX) + return no(error,"Player-swap generation exhausted."); + if(!bump(error))return false; + Handover swap;swap.token=++proposal_serial_;swap.roster_generation=state_.generation;swap.port=port; + swap.outgoing=state_.controls[port].member;swap.incoming=incoming;swap.boundary=boundary;swap.world_digest=digest; + for(const auto& m:state_.members)if(m.connected&&(m.role==Role::Racer||m.id==state_.host||m.id==incoming))swap.required.push_back(m.id); + state_.handover=std::move(swap);error.clear();return true; +} +bool Party::acknowledge(MemberId id,std::uint64_t token,Cursor caught_up, + const std::array& digest,std::string& error) { + if(!state_.handover||state_.handover->token!=token||!contains(state_.handover->required,id)|| + caught_up!=state_.handover->boundary||digest!=state_.handover->world_digest||!find(id)||!find(id)->connected) + return no(error,"Player swap needs an exact synchronised boundary acknowledgement."); + auto& acks=state_.handover->acknowledged;if(!contains(acks,id))acks.push_back(id);error.clear();return true; +} +bool Party::transfer(MemberId actor,Cursor boundary,std::string& error) { + if(actor!=state_.host||!state_.handover)return no(error,"Only the host can commit the agreed player swap."); + const auto swap=*state_.handover; + if(swap.boundary!=boundary||swap.roster_generation!=state_.generation|| + state_.controls[swap.port].member!=swap.outgoing|| + std::any_of(swap.required.begin(),swap.required.end(),[&](auto id){return !contains(swap.acknowledged,id);})) + return no(error,"The replacement and racing peers have not all synchronised for this swap."); + if(state_.generation==UINT64_MAX||state_.controls[swap.port].generation==UINT64_MAX)return no(error,"Player-swap generation exhausted."); + for(auto& m:state_.members) { + if(m.id==swap.outgoing){m.role=Role::Spectator;m.port.reset();} + if(m.id==swap.incoming){m.role=Role::Racer;m.port=swap.port;} + } + auto& lease=state_.controls[swap.port];lease.member=swap.incoming;++lease.generation;lease.starts=boundary; + state_.handover.reset();return bump(error); +} +bool Party::cancel(MemberId actor,std::uint64_t token,std::string& error) { + if(!state_.handover||state_.handover->token!=token|| + (actor!=state_.host&&actor!=state_.handover->outgoing&&actor!=state_.handover->incoming)) + return no(error,"That player swap cannot be cancelled by this member."); + if(state_.generation==UINT64_MAX)return no(error,"Player-swap generation exhausted."); + state_.handover.reset();return bump(error); +} +bool Party::accepts(MemberId id,unsigned port,std::uint64_t generation,Cursor input)const { + if(!id||port>=RacingPorts||!input.epoch||input.frame==UINT32_MAX)return false; + // Stop admitting outgoing future samples at the agreed cutover. They + // cannot be placed in an immutable input ledger and revoked afterwards. + if(state_.handover&&state_.handover->port==port&&!before(input,state_.handover->boundary))return false; + const auto& lease=state_.controls[port];const auto* member=find(id); + return member&&member->connected&&member->role==Role::Racer&&member->port==port&& + lease.member==id&&lease.generation==generation&&!before(input,lease.starts); +} +bool Party::valid(const PartySnapshot& value,std::string& error) { + if(!value.host||!value.generation||value.members.empty()||value.members.size()>MaximumMembers)return no(error,"Invalid lobby membership snapshot."); + std::set ids;std::set ports;unsigned hosts=0,spectators=0; + for(const auto& m:value.members) { + if(!m.id||!ids.insert(m.id).second||!name_ok(m.name)||m.role>Role::Spectator||!m.connected)return no(error,"Invalid or duplicate lobby identity."); + if(m.host){if(m.id!=value.host)return no(error,"Lobby host identity changed.");++hosts;} + if(m.role==Role::Spectator){if(m.port)return no(error,"A spectator cannot have a controller port.");++spectators;} + else if(!m.port||*m.port>=RacingPorts||!ports.insert(*m.port).second|| + value.controls[*m.port].member!=m.id||!value.controls[*m.port].generation)return no(error,"Racing controller ownership is inconsistent."); + } + if(hosts!=1||spectators>MaximumSpectators)return no(error,"Invalid host or spectator count."); + for(unsigned p=0;p=RacingPorts||!h.boundary.epoch||h.boundary.frame==UINT32_MAX|| + h.outgoing!=value.controls[h.port].member||!ids.contains(h.incoming)||!ids.contains(h.outgoing)||h.incoming==h.outgoing|| + std::all_of(h.world_digest.begin(),h.world_digest.end(),[](auto b){return !b;})) + return no(error,"Invalid pending player swap."); + const auto incoming=std::find_if(value.members.begin(),value.members.end(),[&](const auto& m){return m.id==h.incoming;}); + if(incoming->role!=Role::Spectator||h.required.size()>MaximumMembers||h.acknowledged.size()>h.required.size())return no(error,"Invalid player swap participants."); + std::set required(h.required.begin(),h.required.end()),acks(h.acknowledged.begin(),h.acknowledged.end()); + std::set expected; + for(const auto& m:value.members)if(m.role==Role::Racer||m.id==value.host||m.id==h.incoming)expected.insert(m.id); + if(required!=expected||required.size()!=h.required.size()||acks.size()!=h.acknowledged.size()|| + std::any_of(acks.begin(),acks.end(),[&](auto id){return !required.contains(id);}))return no(error,"Player swap acknowledgement membership is invalid."); + } + error.clear();return true; +} +bool Party::replace_verified(PartySnapshot value,std::string& error) { + if(!valid(value,error))return false; + if(state_.host&&(value.host!=state_.host||value.generationtokentoken==proposal_serial_&& + (!state_.handover||state_.handover->token!=value.handover->token)) + return no(error,"A completed or cancelled player-swap proposal cannot be revived."); + if(state_.host)for(unsigned p=0;ptoken);return true; +} +} diff --git a/runtime-recomp/src/game/party.hpp b/runtime-recomp/src/game/party.hpp new file mode 100644 index 00000000..049e8ef7 --- /dev/null +++ b/runtime-recomp/src/game/party.hpp @@ -0,0 +1,72 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::spectators { +using MemberId=std::uint64_t; +inline constexpr unsigned RacingPorts=4,MaximumSpectators=8,MaximumMembers=RacingPorts+MaximumSpectators; +enum class Role:std::uint8_t {Racer,Spectator}; +struct Cursor { + std::uint64_t epoch=0; + std::uint32_t frame=0; + bool operator==(const Cursor&)const=default; +}; +struct Member { + MemberId id=0; + std::string name; + Role role=Role::Spectator; + std::optional port; + bool host=false,connected=false; + bool operator==(const Member&)const=default; +}; +struct ControlLease { + MemberId member=0; + std::uint64_t generation=0; + Cursor starts{}; + bool operator==(const ControlLease&)const=default; +}; +struct Handover { + std::uint64_t token=0,roster_generation=0; + std::uint8_t port=0; + MemberId outgoing=0,incoming=0; + Cursor boundary{}; + std::array world_digest{}; + std::vector required,acknowledged; + bool operator==(const Handover&)const=default; +}; +struct PartySnapshot { + MemberId host=0; + std::uint64_t generation=0; + std::array controls{}; + std::vector members; + std::optional handover; +}; +// Owner-thread policy. Synchronisation and authentication belong to its caller; +// packet-supplied host/port claims are never authority for these operations. +class Party final { +public: + bool create(MemberId host,std::string name,std::string& error); + bool add(MemberId authenticated,std::string name,std::optional port,std::string& error); + bool waiting_role(MemberId actor,MemberId member,std::optional port,std::string& error); + bool remove(MemberId authenticated,std::string& error); + bool propose(MemberId actor,std::uint8_t port,MemberId replacement,Cursor confirmed_boundary, + const std::array& world_digest,std::string& error); + bool acknowledge(MemberId authenticated,std::uint64_t token,Cursor caught_up, + const std::array& world_digest,std::string& error); + bool transfer(MemberId actor,Cursor confirmed_boundary,std::string& error); + bool cancel(MemberId actor,std::uint64_t token,std::string& error); + bool accepts(MemberId authenticated,unsigned port,std::uint64_t generation,Cursor input)const; + const Member* find(MemberId id)const; + const PartySnapshot& snapshot()const{return state_;} + bool replace_verified(PartySnapshot value,std::string& error); + static bool valid(const PartySnapshot& value,std::string& error); +private: + bool bump(std::string& error); + PartySnapshot state_; + std::uint64_t proposal_serial_=0; +}; +} diff --git a/runtime-recomp/src/game/party_camera.hpp b/runtime-recomp/src/game/party_camera.hpp new file mode 100644 index 00000000..60f3b5ed --- /dev/null +++ b/runtime-recomp/src/game/party_camera.hpp @@ -0,0 +1,6 @@ +#pragma once +#include +namespace dkr::runtime::spectators { +// Local presentation only; never included in a simulation checkpoint/rules. +inline std::atomic spectator_director{false}; +} diff --git a/runtime-recomp/src/game/party_codec.cpp b/runtime-recomp/src/game/party_codec.cpp new file mode 100644 index 00000000..90b94fa8 --- /dev/null +++ b/runtime-recomp/src/game/party_codec.cpp @@ -0,0 +1,91 @@ +#include "party_codec.hpp" +#include +#include + +namespace dkr::runtime::spectators::wire { +namespace { +struct Writer { + std::vector bytes; + void word(std::uint64_t v,unsigned count){if(count>8||bytes.size()+count>MaximumPacket)throw std::runtime_error("Spectator packet exceeds its wire bound.");for(unsigned i=0;i>(i*8)));} + void cursor(Cursor c){word(c.epoch,8);word(c.frame,4);} + void text(const std::string& value){if(value.size()>24)throw std::runtime_error("Spectator name is too long.");word(value.size(),1);bytes.insert(bytes.end(),value.begin(),value.end());} +}; +struct Reader { + std::span bytes;std::size_t at=0; + std::uint64_t word(unsigned count){if(count>8||count>bytes.size()-at)throw std::runtime_error("Spectator packet is truncated.");std::uint64_t v=0;for(unsigned i=0;i24||n>bytes.size()-at)throw std::runtime_error("Invalid spectator name.");std::string s(reinterpret_cast(bytes.data()+at),std::size_t(n));at+=std::size_t(n);return s;} + void end(){if(at!=bytes.size())throw std::runtime_error("Spectator packet has trailing data.");} +}; +templatebool checked(std::string& error,F f){try{f();error.clear();return true;}catch(const std::exception& e){error=e.what();return false;}catch(...){error="Invalid spectator message.";return false;}} +void input(Writer& w,PortInput p){w.word(p.buttons,2);w.word(std::uint8_t(p.x),1);w.word(std::uint8_t(p.y),1);} +PortInput input(Reader& r){PortInput p;p.buttons=std::uint16_t(r.word(2));p.x=std::int8_t(r.word(1));p.y=std::int8_t(r.word(1));return p;} +} +std::vector encode(const Packet& p) { + if(!p.match||!p.serial||p.kindKind::Reject||p.body.size()>MaximumPacket-22)throw std::runtime_error("Invalid spectator packet envelope."); + Writer w;for(auto c:{'D','K','S','P'})w.word(unsigned(c),1);w.word(Version,1);w.word(unsigned(p.kind),1);w.word(p.match,8);w.word(p.serial,8); + w.bytes.insert(w.bytes.end(),p.body.begin(),p.body.end());return w.bytes; +} +bool decode(std::span bytes,Packet& out,std::string& error) { + return checked(error,[&]{ + if(bytes.size()<22||bytes.size()>MaximumPacket)throw std::runtime_error("Invalid spectator envelope size."); + Reader r{bytes};for(auto c:{'D','K','S','P'})if(r.word(1)!=unsigned(c))throw std::runtime_error("Not a spectator message."); + if(r.word(1)!=Version)throw std::runtime_error("Spectator protocol versions differ."); + Packet p;p.kind=Kind(r.word(1));p.match=r.word(8);p.serial=r.word(8); + if(p.kindKind::Reject||!p.match||!p.serial)throw std::runtime_error("Invalid spectator envelope identity."); + p.body.assign(bytes.begin()+std::ptrdiff_t(r.at),bytes.end());out=std::move(p); + }); +} +std::vector roster(const PartySnapshot& p) { + std::string error;if(!Party::valid(p,error))throw std::runtime_error(error); + Writer w;w.word(p.host,8);w.word(p.generation,8);w.word(p.members.size(),1); + for(const auto& m:p.members){w.word(m.id,8);w.text(m.name);w.word(unsigned(m.role),1);w.word(m.port?*m.port:255,1);w.word(m.host?1:0,1);} + for(const auto& c:p.controls){w.word(c.member,8);w.word(c.generation,8);w.cursor(c.starts);} + w.word(p.handover?1:0,1); + if(p.handover){const auto& h=*p.handover;w.word(h.token,8);w.word(h.roster_generation,8);w.word(h.port,1);w.word(h.outgoing,8);w.word(h.incoming,8);w.cursor(h.boundary);for(auto b:h.world_digest)w.word(b,1); + w.word(h.required.size(),1);for(auto id:h.required)w.word(id,8);w.word(h.acknowledged.size(),1);for(auto id:h.acknowledged)w.word(id,8);} + return w.bytes; +} +bool roster(std::span bytes,PartySnapshot& out,std::string& error) { + return checked(error,[&]{ + if(bytes.size()>MaximumPacket)throw std::runtime_error("Spectator roster is too large."); + Reader r{bytes};PartySnapshot p;p.host=r.word(8);p.generation=r.word(8);const auto count=r.word(1); + if(!count||count>MaximumMembers)throw std::runtime_error("Too many spectator roster entries."); + for(unsigned i=0;i1)throw std::runtime_error("Invalid host flag.");m.host=host!=0;m.connected=true;p.members.push_back(std::move(m));} + for(auto& c:p.controls){c.member=r.word(8);c.generation=r.word(8);c.starts=r.cursor();} + const auto pending=r.word(1);if(pending>1)throw std::runtime_error("Invalid player swap flag."); + if(pending){Handover h;h.token=r.word(8);h.roster_generation=r.word(8);h.port=std::uint8_t(r.word(1));h.outgoing=r.word(8);h.incoming=r.word(8);h.boundary=r.cursor();for(auto& b:h.world_digest)b=std::uint8_t(r.word(1)); + const auto required=r.word(1);if(required>MaximumMembers)throw std::runtime_error("Too many swap acknowledgements.");for(unsigned i=0;irequired)throw std::runtime_error("Invalid swap acknowledgements.");for(unsigned i=0;i frames(std::span values) { + if(values.empty()||values.size()>30)throw std::runtime_error("Invalid confirmed spectator frame count."); + Writer w;w.word(values.size(),1);Cursor previous{};bool previous_boundary=false; + for(const auto& f:values){if(!f.cursor.epoch||f.cursor.frame==UINT32_MAX||f.command.size()>ConfirmedFeed::MaximumCommand|| + previous_boundary||(previous.epoch&&(f.cursor.epoch!=previous.epoch||f.cursor.frame!=previous.frame+1)))throw std::runtime_error("Confirmed spectator frames are not contiguous."); + w.cursor(f.cursor);for(auto p:f.inputs)input(w,p);w.word(f.scene_boundary?1:0,1);w.word(f.command.size(),2); + if(f.command.size()>MaximumPacket-w.bytes.size())throw std::runtime_error("Spectator frame packet is too large."); + w.bytes.insert(w.bytes.end(),f.command.begin(),f.command.end());previous=f.cursor;previous_boundary=f.scene_boundary;} + return w.bytes; +} +bool frames(std::span bytes,std::vector& out,std::string& error) { + return checked(error,[&]{ + if(bytes.size()>MaximumPacket)throw std::runtime_error("Spectator frame packet is too large."); + Reader r{bytes};const auto count=r.word(1);if(!count||count>30)throw std::runtime_error("Invalid spectator frame count.");std::vector result; + for(unsigned i=0;i1)throw std::runtime_error("Invalid scene-boundary flag.");f.scene_boundary=scene!=0; + const auto n=r.word(2);if(n>ConfirmedFeed::MaximumCommand||n>bytes.size()-r.at)throw std::runtime_error("Invalid spectator command size.");f.command.assign(bytes.begin()+std::ptrdiff_t(r.at),bytes.begin()+std::ptrdiff_t(r.at+n));r.at+=std::size_t(n); + if(!f.cursor.epoch||f.cursor.frame==UINT32_MAX||(!result.empty()&&(f.cursor.epoch!=result.back().cursor.epoch||f.cursor.frame!=result.back().cursor.frame+1||result.back().scene_boundary)))throw std::runtime_error("Non-contiguous spectator frame sequence."); + result.push_back(std::move(f));} + r.end();out=std::move(result); + }); +} +std::vector controller(const ControllerSample& p) { + if(!p.cursor.epoch||p.cursor.frame==UINT32_MAX||p.port>=RacingPorts||!p.lease)throw std::runtime_error("Invalid controller lease sample."); + Writer w;w.cursor(p.cursor);w.word(p.port,1);w.word(p.lease,8);input(w,p.input);return w.bytes; +} +bool controller(std::span bytes,ControllerSample& out,std::string& error) { + return checked(error,[&]{Reader r{bytes};ControllerSample p;p.cursor=r.cursor();p.port=std::uint8_t(r.word(1));p.lease=r.word(8);p.input=input(r);r.end();if(!p.cursor.epoch||p.cursor.frame==UINT32_MAX||p.port>=RacingPorts||!p.lease)throw std::runtime_error("Invalid controller sample identity.");out=p;}); +} +} diff --git a/runtime-recomp/src/game/party_codec.hpp b/runtime-recomp/src/game/party_codec.hpp new file mode 100644 index 00000000..2e059b61 --- /dev/null +++ b/runtime-recomp/src/game/party_codec.hpp @@ -0,0 +1,33 @@ +#pragma once +#include "party.hpp" +#include "confirmed_feed.hpp" +#include +#include + +namespace dkr::runtime::spectators::wire { +inline constexpr unsigned Version=2; +inline constexpr std::size_t MaximumPacket=12000,MaximumChunk=11264; +enum class Kind:std::uint8_t { + Roster=1,Frames=2,BaselineOffer=3,BaselineChunk=4,BaselineAck=5, + NeedBaseline=6,TakeOver=7,HandoverAck=8,Leave=9,Heartbeat=10,Input=11,Reject=12 +}; +struct Packet { + Kind kind=Kind::Heartbeat; + std::uint64_t match=0,serial=0; + std::vector body; +}; +struct ControllerSample { + Cursor cursor; + std::uint8_t port=0; + std::uint64_t lease=0; + PortInput input; +}; +std::vector encode(const Packet& value); +bool decode(std::span bytes,Packet& out,std::string& error); +std::vector roster(const PartySnapshot& value); +bool roster(std::span bytes,PartySnapshot& out,std::string& error); +std::vector frames(std::span values); +bool frames(std::span bytes,std::vector& out,std::string& error); +std::vector controller(const ControllerSample& value); +bool controller(std::span bytes,ControllerSample& out,std::string& error); +} diff --git a/runtime-recomp/src/game/party_link.cpp b/runtime-recomp/src/game/party_link.cpp new file mode 100644 index 00000000..39820825 --- /dev/null +++ b/runtime-recomp/src/game/party_link.cpp @@ -0,0 +1,101 @@ +#include "party_link.hpp" +#include "monocypher.h" +#include + +namespace dkr::runtime::spectators { +void party_request_swap(const void* s,unsigned port,MemberId incoming){auto h=party_link(s);std::scoped_lock lock(h->mutex);if(h->enabled&&h->running&&portrequested_swap={{port,incoming}};} +void party_approve_swap(const void* s,unsigned port,MemberId incoming){auto h=party_link(s);std::scoped_lock lock(h->mutex);if(h->host&&h->running&&portapproved_swap={{port,incoming}};} +void party_cancel_swap(const void* s){auto h=party_link(s);std::scoped_lock lock(h->mutex);h->cancel_swap=true;} +bool party_waiting_role(const void* s,MemberId id,bool racer,std::string& e) { + auto h=party_link(s);std::scoped_lock lock(h->mutex); + if(!h->enabled||!h->host||h->launch||h->running||h->stopping){e="Roles can only be assigned directly while waiting in the lobby.";return false;} + std::optional port; + if(racer){for(unsigned p=0;pmaximum_racers);++p)if(!h->party.snapshot().controls[p].member){port=std::uint8_t(p);break;}if(!port){e="All racing places are occupied.";return false;}} + if(!h->party.waiting_role(h->local,id,port,e))return false; + h->ready.clear();h->advertised={};return true; +} +namespace { +std::mutex bootstrap_mutex;std::vector bootstrap_bytes; +// DirectSession can be destroyed after translation-unit statics. The +// registry itself has process lifetime; normal session teardown still wipes +// keys and erases every retained PartyLink after the owner worker joins. +struct LinkRegistry {std::mutex mutex;std::unordered_map> links;}; +LinkRegistry& link_registry(){static auto* value=new LinkRegistry;return *value;} +constexpr std::size_t QueueBytes=4U*1024U*1024U,QueuePackets=2048; +} +void party_bootstrap_seed(std::span bytes){std::scoped_lock lock(bootstrap_mutex);bootstrap_bytes.assign(bytes.begin(),bytes.end());} +bool party_bootstrap_active(){std::scoped_lock lock(bootstrap_mutex);return bootstrap_bytes.size()==512;} +std::filesystem::path party_bootstrap_path(){return {};} +bool party_bootstrap_load(const std::filesystem::path&,std::vector& bytes){std::scoped_lock lock(bootstrap_mutex);if(bootstrap_bytes.size()!=512)return false;bytes=bootstrap_bytes;return true;} +bool party_bootstrap_write(const std::filesystem::path&,const std::vector& bytes){std::scoped_lock lock(bootstrap_mutex);return bootstrap_bytes.size()==512&&bytes==bootstrap_bytes;} +std::vector link_encode(LinkKind kind,std::span body){ + if(kindLinkKind::Leave||body.size()>12300)return {}; + std::vector out{'D','K','P','L',1,std::uint8_t(kind)};out.insert(out.end(),body.begin(),body.end());return out; +} +std::vector link_heartbeat(std::uint64_t token,bool reply){std::vector b{std::uint8_t(reply)};for(unsigned i=0;i<8;++i)b.push_back(std::uint8_t(token>>(i*8)));return b;} +bool link_heartbeat(std::span b,std::uint64_t& token,bool& reply){if(b.size()!=9||b[0]>1)return false;token=0;reply=b[0]!=0;for(unsigned i=0;i<8;++i)token|=std::uint64_t(b[i+1])<<(i*8);return token!=0;} +bool link_decode(std::span b,LinkMessage& out){ + if(b.size()<6||b.size()>12306||!std::equal(b.begin(),b.begin()+5,std::array{'D','K','P','L',1}.begin())|| + b[5]unsigned(LinkKind::Leave))return false; + LinkMessage next{LinkKind(b[5]),{b.begin()+6,b.end()}};out=std::move(next);return true; +} +LinkPeer* PartyLink::peer(MemberId id){auto p=std::find_if(peers.begin(),peers.end(),[&](const auto& v){return v.id==id;});return p==peers.end()?nullptr:&*p;} +LinkPeer* PartyLink::peer(const net::PeerAddress& a){auto p=std::find_if(peers.begin(),peers.end(),[&](const auto& v){return v.address==a;});return p==peers.end()?nullptr:&*p;} +bool PartyLink::local_watching()const{const auto* m=party.find(local);return admitted&&m&&m->role==Role::Spectator;} +bool PartyLink::participant(MemberId id)const{const auto* m=party.find(id);return m&&m->connected&&(m->host||m->role==Role::Racer);} +void PartyLink::reset_connection(){ + for(auto& p:peers)crypto_wipe(p.key.data(),p.key.size()); + enabled=host=admitted=running=stopping=returned=start_requested=ready_requested=launch_sent=stop_requested=false; + local=authority=room_id=0;party={};ready.clear();pending_watch.clear();peers.clear();seed.clear();save_generation=0;save_hash=0;launch.reset(); + incoming.clear();for(auto& q:outgoing)q.clear();incoming_bytes=outgoing_bytes=0;advertised=heartbeat=budget_at={};bulk_tokens=256*1024;status.clear(); + swap_requests.clear();requested_swap.reset();approved_swap.reset();cancel_swap=false; + ping_token=0;ping_sent={};rtt_ms=0; +} +bool PartyLink::enqueue(LinkOutgoing p){ + const auto lane=unsigned(p.traffic); + if(!enabled||lane>=outgoing.size()||p.bytes.size()>12300||p.bytes.size()>QueueBytes-outgoing_bytes)return false; + auto& q=outgoing[lane];if(q.size()>=QueuePackets)return false; + if(lane==3||lane==4) { + // Reserve racing input/control space independently of slow watchers. + if(outgoing_bytes+p.bytes.size()>QueueBytes-256*1024)return false; + std::size_t retained=0;for(const auto& item:q)if(item.target==p.target)retained+=item.bytes.size(); + if(retained+p.bytes.size()>256*1024)return false; + } + outgoing_bytes+=p.bytes.size();q.push_back(std::move(p));return true; +} +bool PartyLink::admit_incoming(MemberId sender,std::span bytes){ + if(!enabled||bytes.empty()||bytes.size()>12000||incoming.size()>=QueuePackets||bytes.size()>QueueBytes-incoming_bytes)return false; + if(host&&!party.find(sender))return false; + if(!host&&sender!=authority)return false; + incoming.push_back({sender,{bytes.begin(),bytes.end()}});incoming_bytes+=bytes.size();return true; +} +std::shared_ptr party_link(const void* session){auto& r=link_registry();std::scoped_lock lock(r.mutex);auto& result=r.links[session];if(!result)result=std::make_shared();return result;} +void forget_party_link(const void* session){auto& r=link_registry();std::scoped_lock lock(r.mutex);r.links.erase(session);} +void request_party_join(const void* session,bool spectate){auto p=party_link(session);std::scoped_lock lock(p->mutex);p->requested=true;p->join_watch=spectate;} +void request_party_host(const void* session,bool enabled){auto p=party_link(session);std::scoped_lock lock(p->mutex);p->requested=enabled;p->join_watch=false;} +LinkView party_link_view(const void* session){auto p=party_link(session);std::scoped_lock lock(p->mutex);return {p->enabled,p->host,p->admitted,p->running,p->stopping,p->local,p->party.snapshot(),p->ready,p->status,p->camera,p->target,p->swap_requests};} +bool party_enabled(const void* session){auto p=party_link(session);std::scoped_lock lock(p->mutex);return p->enabled;} +bool party_watching(const void* session){auto p=party_link(session);std::scoped_lock lock(p->mutex);return p->local_watching();} +bool party_send(const void* session,MemberId target,std::span bytes,net::TransportTrafficClass traffic){ + auto p=party_link(session);std::scoped_lock lock(p->mutex);return p->enqueue({target,LinkKind::Runtime,traffic,{bytes.begin(),bytes.end()}}); +} +bool party_receive(const void* session,LinkIncoming& out){auto p=party_link(session);std::scoped_lock lock(p->mutex);if(p->incoming.empty())return false; + out=std::move(p->incoming.front());p->incoming_bytes-=out.bytes.size();p->incoming.pop_front();return true;} +void party_ready(const void* session,bool ready){auto p=party_link(session);std::scoped_lock lock(p->mutex);p->ready_requested=true;p->ready_value=ready;} +void party_start(const void* session){auto p=party_link(session);std::scoped_lock lock(p->mutex);if(p->host)p->start_requested=true;} +void party_stop(const void* session,std::string reason){auto p=party_link(session);std::scoped_lock lock(p->mutex);p->stop_requested=true;p->status=std::move(reason);} +void party_returned(const void* session,std::span save){auto p=party_link(session);std::scoped_lock lock(p->mutex);if(!p->enabled)return;p->returned=true;if(p->host&&save.size()==512)p->seed.assign(save.begin(),save.end());} +void party_camera(const void* session,WatchCamera mode,unsigned target){auto p=party_link(session);std::scoped_lock lock(p->mutex);if(mode<=WatchCamera::Director)p->camera=mode;p->target=std::min(target,3U);} +bool prepare_racing_ports(Party& party,std::string& error){ + auto snapshot=party.snapshot();if(snapshot.handover||snapshot.generation==UINT64_MAX){error="Finish the pending player swap first.";return false;} + std::vector racers;for(auto& m:snapshot.members)if(m.role==Role::Racer)racers.push_back(&m); + if(racers.size()<2||racers.size()>4){error="A race needs two to four racers.";return false;} + std::sort(racers.begin(),racers.end(),[](auto a,auto b){return a->port.value_or(255)port.value_or(255);}); + std::uint64_t generation=0;for(const auto& c:snapshot.controls)generation=std::max(generation,c.generation); + if(generation==UINT64_MAX){error="Controller ownership generation exhausted.";return false;} + snapshot.controls={};++generation;++snapshot.generation; + for(auto& c:snapshot.controls)c.generation=generation; + for(unsigned i=0;iport=std::uint8_t(i);snapshot.controls[i]={racers[i]->id,generation,{91,0}};} + return party.replace_verified(std::move(snapshot),error); +} +} diff --git a/runtime-recomp/src/game/party_link.hpp b/runtime-recomp/src/game/party_link.hpp new file mode 100644 index 00000000..8dbb5cd7 --- /dev/null +++ b/runtime-recomp/src/game/party_link.hpp @@ -0,0 +1,119 @@ +#pragma once +#include "party.hpp" +#include "netplay/netplay_types.hpp" +#include "netplay/session_transport.hpp" +#include "netplay/secure_channel.hpp" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::spectators { +namespace net=dkr::runtime::netplay; +inline constexpr unsigned PartyMessageType=250; +enum class LinkKind:std::uint8_t {Accepted=1,Roster,Room,Launch,Runtime,Heartbeat,Ready,Start,Stop,Returned,Leave}; +struct LinkMessage {LinkKind kind=LinkKind::Heartbeat;std::vector body;}; +std::vector link_encode(LinkKind kind,std::span body={}); +bool link_decode(std::span bytes,LinkMessage& out); +std::vector link_heartbeat(std::uint64_t token,bool reply); +bool link_heartbeat(std::span bytes,std::uint64_t& token,bool& reply); +struct LinkPeer { + MemberId id=0; + net::PeerAddress address{}; + net::secure::Key key{}; + std::uint64_t sequence=1; + std::chrono::steady_clock::time_point heard{},rate_at{}; + std::size_t received_tokens=128*1024; + std::uint64_t ping_token=0; + std::chrono::steady_clock::time_point ping_sent{}; + std::array rtts{}; + unsigned rtt_count=0,rtt_index=0; +}; +struct LinkIncoming {MemberId source=0;std::vector bytes;}; +struct LinkOutgoing { + MemberId target=0; + LinkKind kind=LinkKind::Runtime; + net::TransportTrafficClass traffic=net::TransportTrafficClass::Control; + std::vector bytes; +}; +enum class WatchCamera:unsigned {AllSplits,Follow,Director}; +struct LinkView { + bool enabled=false,host=false,admitted=false,running=false,stopping=false; + MemberId local=0; + PartySnapshot party; + std::map ready; + std::string status; + WatchCamera camera=WatchCamera::Follow; + unsigned target=0; + std::vector> swaps; +}; +// DirectSession retains ownership of authentication and the WebRTC transport. +// Its member functions service this object while holding their existing lock; +// the additional recursive lock allows nested authenticated send/admit helpers. +// Public/UI functions below NEVER call DirectSession while holding this lock. +// No change to DirectSession's class layout or checked-out header is required. +struct PartyLink { + mutable std::recursive_mutex mutex; + bool requested=false,join_watch=false,enabled=false,host=false,admitted=false; + bool running=false,stopping=false,returned=false,start_requested=false,ready_requested=false,ready_value=false; + bool launch_sent=false,stop_requested=false; + MemberId local=0,authority=0; + std::uint64_t room_id=0,launch_serial=0; + Party party; + std::map ready; + std::unordered_map pending_watch; + std::vector peers; + std::vector seed; + std::uint32_t save_generation=0; + std::uint64_t save_hash=0; + std::optional launch; + std::deque incoming; + std::array,5> outgoing; + std::size_t incoming_bytes=0,outgoing_bytes=0; + std::chrono::steady_clock::time_point advertised{},heartbeat{},budget_at{}; + std::size_t bulk_tokens=256*1024; + WatchCamera camera=WatchCamera::Follow; + unsigned target=0; + std::string status; + unsigned maximum_racers=4; + std::vector> swap_requests; + std::optional> requested_swap,approved_swap; + bool cancel_swap=false; + std::uint64_t ping_token=0; + std::chrono::steady_clock::time_point ping_sent{}; + std::uint16_t rtt_ms=0; + LinkPeer* peer(MemberId id); + LinkPeer* peer(const net::PeerAddress& address); + bool local_watching()const; + bool participant(MemberId id)const; + void reset_connection(); + bool enqueue(LinkOutgoing packet); + bool admit_incoming(MemberId sender,std::span bytes); +}; +std::shared_ptr party_link(const void* session); +void forget_party_link(const void* session); +void request_party_join(const void* session,bool spectate); +void request_party_host(const void* session,bool enabled); +LinkView party_link_view(const void* session); +bool party_enabled(const void* session); +bool party_watching(const void* session); +bool party_send(const void* session,MemberId target,std::span bytes,net::TransportTrafficClass traffic); +bool party_receive(const void* session,LinkIncoming& out); +void party_ready(const void* session,bool ready); +void party_start(const void* session); +void party_stop(const void* session,std::string reason); +void party_returned(const void* session,std::span confirmed_save); +void party_camera(const void* session,WatchCamera mode,unsigned target); +bool prepare_racing_ports(Party& party,std::string& error); +void party_request_swap(const void* session,unsigned port,MemberId incoming); +void party_approve_swap(const void* session,unsigned port,MemberId incoming); +void party_cancel_swap(const void* session); +bool party_waiting_role(const void* session,MemberId member,bool racer,std::string& error); +void party_bootstrap_seed(std::span seed); +bool party_bootstrap_active(); +std::filesystem::path party_bootstrap_path(); +bool party_bootstrap_load(const std::filesystem::path&,std::vector& bytes); +bool party_bootstrap_write(const std::filesystem::path&,const std::vector& bytes); +} diff --git a/runtime-recomp/src/game/party_runtime.cpp b/runtime-recomp/src/game/party_runtime.cpp new file mode 100644 index 00000000..33b1f40d --- /dev/null +++ b/runtime-recomp/src/game/party_runtime.cpp @@ -0,0 +1,742 @@ +#include "admission_retry.hpp" +#include +#include +#include "rollback_policy.hpp" +#include "party_runtime.hpp" +#include "confirmed_observer.hpp" +#include "netplay/authoritative_state_codec.hpp" +#include "netplay/experimental_checkpoint_hash.hpp" +#include "mods/sdk2_bridge.hpp" +#include +#include +#include +#include +#include + +namespace dkr::runtime::spectators { +namespace ex=net::experimental; +namespace { +using Bytes=std::vector; +using Lane=net::TransportTrafficClass; +constexpr auto Tick=std::chrono::nanoseconds(1000000000/30); +enum class Msg:std::uint8_t {Hello=1,Offer,Chunk,Ready,Go,Input,Frames,Scene,Prepare,Prepared,Freeze,Frozen,Swap,Cancel,Need,OfferAck,FrameAck,Progress}; +struct Write { + Bytes b; + Write& n(std::uint64_t v,unsigned count=8){for(unsigned i=0;i>(8*i)));return *this;} + Write& at(Cursor c){return n(c.epoch).n(c.frame,4);} + Write& data(std::span v){b.insert(b.end(),v.begin(),v.end());return *this;} +}; +struct Read { + std::span b;std::size_t pos=0; + std::uint64_t n(unsigned count=8){if(count>8||count>b.size()-pos)throw std::runtime_error("Truncated party runtime message.");std::uint64_t v=0;for(unsigned i=0;i rest(){const auto tail=b.subspan(pos);pos=b.size();return tail;} + void end(){if(pos!=b.size())throw std::runtime_error("Trailing party runtime message data.");} +}; +net::PackedInput packed(PortInput p){return {p.buttons,p.x,p.y};} +PortInput portable(net::PackedInput p){return {p.buttons,p.stick_x,p.stick_y};} +dkr::sdk2::Command command(std::span bytes) { + dkr::sdk2::Command c;if(bytes.empty())return c; + if(bytes.size()!=64)throw std::runtime_error("Invalid confirmed development command."); + Read r{bytes};for(auto& w:c.words)w=std::uint32_t(r.n(4));return c; +} +Bytes command_bytes(const dkr::sdk2::Command& c){Write w;for(auto v:c.words)w.n(v,4);return w.b;} +// Rebase only the driver's relative history cursor. Guest clocks, sidecar +// cursors, SDK commands and irreversible effects keep their actual frame. +class OffsetWorld final:public ex::Simulation { + ex::OwnedGame& w_;std::uint32_t base_; +public: + OffsetWorld(ex::OwnedGame& w,std::uint32_t base):w_(w),base_(base){} + ex::SimulationContract contract()const override{return w_.contract();} + bool requires_confirmed_tick()const override{return w_.requires_confirmed_tick();} + std::uint8_t input_dependencies()const override{return w_.input_dependencies();} + ex::RestoreStep prepare_tick(std::uint64_t e,std::uint32_t f,bool agreed,std::string& s)override{return w_.prepare_tick(e,base_+f,agreed,s);} + bool capture(std::span b,std::string& s)override{return w_.capture(b,s);} + bool restore(std::span b,std::string& s)override{return w_.restore(b,s);} + ex::RestoreStep prepare_restore(std::uint64_t e,std::uint32_t f,std::string& s)override{return w_.prepare_restore(e,base_+f,s);} + bool development_command(std::uint64_t e,std::uint32_t f,const dkr::sdk2::Command& c,std::string& s)override{return w_.development_command(e,base_+f,c,s);} + bool tick(std::uint32_t f,const net::FrameInputs& i,ex::TickOutput& o,std::string& s)override{return w_.tick(base_+f,i,o,s);} + bool commit(std::uint64_t e,std::uint32_t f,std::span b,bool boundary,std::string& s)override{return w_.commit(e,base_+f,b,boundary,s);} +}; +class WatchingWorld final:public ObserverWorld { + ex::OwnedGame& w_;bool ready_=false; +public: + explicit WatchingWorld(ex::OwnedGame& w):w_(w){} + std::uint64_t schema()const override{return w_.contract().schema;} + std::size_t state_bytes()const override{return w_.contract().state_bytes;} + Work retire(Cursor c,std::string& e)override{return retire_owned_observer(&w_,c,e);} + bool admit(const Baseline& b,std::string& e)override{ready_=false;return admit_owned_observer(&w_,b,e);} + Work prepare(const ConfirmedFrame& f,std::string& e)override { + const auto step=w_.prepare_tick(f.cursor.epoch,f.cursor.frame,true,e); + return step==ex::RestoreStep::Ready?Work::Ready:step==ex::RestoreStep::Pending?Work::Pending:Work::Failed; + } + bool tick_and_commit(const ConfirmedFrame& f,std::string& e)override { + net::FrameInputs inputs{};for(unsigned i=0;i<4;++i)inputs[i]=packed(f.inputs[i]); + ex::TickOutput output; + if(!w_.development_command(f.cursor.epoch,f.cursor.frame,command(f.command),e)|| + !w_.tick(f.cursor.frame,inputs,output,e)||output.confirmation_required||output.scene_boundary!=f.scene_boundary|| + !w_.commit(f.cursor.epoch,f.cursor.frame,output.effects,output.scene_boundary,e))return false; + ready_=!output.scene_boundary;return true; + } + bool publish(Cursor c,std::string& e)override{return !ready_||w_.publish_current(c.epoch,c.frame,e);} + // Watchers do not vote or load ahead of the host's agreed new scene. A new + // host checkpoint admits the fully constructed scene transaction instead. + Work prepare_scene(Cursor,std::string&)override{return Work::Ready;} +}; +} +struct PartyRuntime::Impl { + const void* key;ex::OwnedGame& world;ex::PresentationMailbox& box;net::LaunchDescriptor launch;HashState hash; + std::shared_ptr hub;MemberId local=0,host=0;bool authority=false,started=false,go=false; + std::uint64_t match=0,controls=1,serial=0,transfer_serial=0,active_transfer=0;Party roster; + Cursor cursor{91,0};std::uint32_t base=0,authored=0;Clock::time_point next_tick{},progress{},hello_at{},phase_at{},proof_at{}; + std::unique_ptr offset;std::unique_ptr driver; + WatchingWorld watch_world;ObserverPlayback playback;ConfirmedFeed feed;ConfirmedFrameBinding binding; + dkr::spectator_admission::AdmissionRetry observer_retry; + std::uint64_t observer_render_epoch=0; + std::string text="Preparing party play",callback_error;std::optional held; + struct Encoded {Baseline raw,wire;}; + std::future encoding;std::future decoding;std::optional encode_pending; + struct Delivery {CheckpointSender sender;Digest raw_digest{};std::uint32_t actions=0;bool offered=false,accepted=false,admitted=false;Cursor sent{};Clock::time_point progress{},frames_at{};}; + std::map deliveries;std::set needing,prepared;std::map scene_votes,frozen; + std::map requested_at; + std::optional receiving;CheckpointReceiver receiver;std::optional encoded; + std::optional admitted_at;std::map,ConfirmedFrame> buffered; + std::map,std::pair> inputs; + std::map local_history; + enum class Phase {Admission,Running,SceneVote,ScenePrepare,SceneReady,Freeze,SwapReady}; + Phase phase=Phase::Admission;ex::BoundaryIntent boundary{};Digest phase_digest{}; + Clock::time_point admission_log{},ready_retry{},release_retry{},release_until{}; + Bytes release_packet;std::optional last_offer; + // A prepared scene or admitted observer checkpoint advances cursor before + // its Go arrives. Only an actually activated release is a duplicate. + std::optional> activated_release; + std::optional> swap;std::uint32_t freeze=UINT32_MAX; + std::uint64_t probe_id=0;Cursor probe_cursor{};std::map proofs; + Impl(const void* k,ex::OwnedGame& w,ex::PresentationMailbox& b,net::LaunchDescriptor l,HashState h) + :key(k),world(w),box(b),launch(l),hash(std::move(h)),hub(party_link(k)),watch_world(w),playback(watch_world,launch_descriptor_hash(l),hash), + binding([this](ConfirmedFrame f){committed(std::move(f));}),receiver(hash){} + bool server()const{return authority;} + const Member* member()const{return roster.find(local);} + bool watching()const{return member()&&member()->role==Role::Spectator;} + bool simulating()const{return server()||!watching();} + unsigned port()const{return member()&&member()->port?*member()->port:4;} + Cursor next()const{return driver?Cursor{cursor.epoch,base+driver->statistics().next_frame}:playback.view().next;} + std::uint32_t confirmed()const{return driver?base+driver->statistics().confirmed_frames:playback.view().next.frame;} + std::set required()const {std::set ids{host};for(const auto& m:roster.snapshot().members)if(m.role==Role::Racer)ids.insert(m.id);return ids;} + bool all(const std::set& ids)const {for(auto id:required())if(!ids.contains(id))return false;return true;} + templatebool all_votes(const Map& votes)const {for(auto id:required())if(!votes.contains(id))return false;return true;} + void send(MemberId target,Msg type,std::span body={},Lane lane=Lane::Control) { + Write w;w.n('D',1).n('P',1).n('R',1).n(2,1).n(unsigned(type),1).n(match).n(++serial).data(body); + if(!party_send(key,target,w.b,lane)) { + if(target&&roster.find(target)&&roster.find(target)->role==Role::Spectator&&lane==Lane::Checkpoint)return; + throw std::runtime_error("Party runtime transport queue is full."); + } + } + void racers(Msg type,std::span body={},Lane lane=Lane::Authoritative) { + for(auto id:required())if(id!=local)send(id,type,body,lane); + } + void update_hub() { + std::scoped_lock lock(hub->mutex);std::string e; + if(hub->stopping||!hub->enabled)return; // A retiring owner cannot republish its old racing roster. + const auto& latest=hub->party.snapshot(); + // Transport admission may have added another watcher since this + // owner last polled. Do not overwrite that newer membership snapshot. + if(latest.generation>=roster.snapshot().generation&&latest.controls==roster.snapshot().controls) { + if(!roster.replace_verified(latest,e))throw std::runtime_error(e); + } else if(!hub->party.replace_verified(roster.snapshot(),e))throw std::runtime_error(e); + hub->status=text; + } + Baseline capture(Cursor at) { + if(world.confirmed_count()!=at.frame)throw std::runtime_error("A speculative world cannot become a spectator baseline."); + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] capture frame=%u bytes=%zu\n",at.frame,world.contract().state_bytes); + auto bytes=std::make_shared(world.contract().state_bytes);std::string e; + if(!world.capture(*bytes,e))throw std::runtime_error("Could not capture confirmed party state: "+e); + return {at,match,world.contract().schema,controls,hash(*bytes),bytes,owned_magic_actions(&world)}; + } + void encode(Baseline b) { + held=b; + if(encoding.valid()){encode_pending=std::move(b);return;} + feed.clear();if(feed.baseline(b)!=FeedResult::Accepted)throw std::runtime_error("Confirmed party baseline was rejected."); + encoding=std::async(std::launch::async,[b=std::move(b),hasher=hash]()mutable { + auto bytes=std::make_shared(net::encode_authoritative_state_wire(*b.state)); + Baseline wire=b;wire.state=bytes;wire.digest=hasher(*bytes);return Encoded{std::move(b),std::move(wire)}; + }); + } + void committed(ConfirmedFrame f)noexcept { + try { + if(!server())return; + f.cursor.frame+=base; + if(feed.current()) { + const auto result=feed.append(f); + if(result!=FeedResult::Accepted&&result!=FeedResult::Duplicate){feed.clear();for(auto& [id,d]:deliveries){(void)d;needing.insert(id);}} + } + }catch(const std::exception& e){callback_error=e.what();}catch(...){callback_error="Confirmed spectator feed failed.";} + } + void reset_driver(Cursor at) { + driver.reset();offset.reset();cursor=at;base=at.frame;authored=at.frame;inputs.clear();local_history.clear(); + owned_observer_read_only(&world,watching()); + if(simulating()) { + offset=std::make_unique(world,base);driver=std::make_unique(*offset); + const auto bytes=world.contract().state_bytes; + const auto budget=std::max(128U*1024U*1024U,bytes*(std::size_t(launch.rollback_window)+2)); + std::string e;if(!driver->start({at.epoch,launch.player_count,launch.rollback_window,budget},e))throw std::runtime_error(e); + } + next_tick=Clock::now();progress=next_tick;proof_at=next_tick+std::chrono::seconds(5); + } + void ready(Baseline b) { + if(!server()) { + const auto restored=capture(b.next); + if(restored.digest!=b.digest||restored.magic_actions!=b.magic_actions) + throw std::runtime_error("The admitted party checkpoint did not recapture the exact confirmed state."); + } + held=b;admitted_at=b.next;cursor=b.next;controls=b.control_generation; + if(watching())std::fprintf(stderr,"[spectator][admission] ready epoch=%llu frame=%u\n", + static_cast(b.next.epoch),b.next.frame); + if(server()){prepared.insert(local);return;} + Write w;w.at(b.next).n(controls).data(b.digest).n(b.magic_actions,4);send(host,Msg::Ready,w.b);ready_retry=Clock::now()+std::chrono::milliseconds(500); + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] canonical admission verified; Ready sent\n"); + } + Bytes go_body(Cursor at) {Write w;const auto members=wire::roster(roster.snapshot());w.at(at).n(controls).data(members);return w.b;} + void release(Cursor at) { + const auto body=go_body(at);release_packet=body;release_retry=Clock::now()+std::chrono::milliseconds(500);release_until=Clock::now()+std::chrono::seconds(5); + for(const auto& m:roster.snapshot().members)if(m.id!=local)send(m.id,Msg::Go,body); + std::fprintf(stderr,"[party][release] confirmed start epoch=%llu frame=%u\n",static_cast(at.epoch),at.frame); + activate(at);update_hub(); + } + void activate(Cursor at) { + owned_observer_read_only(&world,watching()); + if(!simulating()){driver.reset();offset.reset();cursor=at;} + if(server())for(auto id:required())deliveries.erase(id); + if(simulating()) { + if(world.confirmed_count()!=at.frame)throw std::runtime_error("Party release arrived before exact confirmed admission."); + reset_driver(at); + } else if(held&&held->next==at) { + std::string e;if(!playback.offer(*held,e))throw std::runtime_error(e); + } else { + playback.request_resync("Loading the confirmed race");held.reset();admitted_at.reset(); + if(!server())send(host,Msg::Need); + } + activated_release=std::pair{at,controls}; + phase=Phase::Running;freeze=UINT32_MAX;swap.reset();frozen.clear();scene_votes.clear();prepared.clear();go=true; + text=watching()?"Watching the race":"Rollback"; + {std::scoped_lock lock(hub->mutex);hub->running=true;hub->swap_requests.clear();hub->status=text;} + } + bool begin(std::string& error) { + { + std::scoped_lock lock(hub->mutex); + if(!hub->enabled||!hub->admitted||!hub->launch||*hub->launch!=launch||!hash) {error="Party runtime has no authenticated launch.";return false;} + local=hub->local;host=hub->authority;authority=hub->host;match=launch_descriptor_hash(launch); + if(!roster.replace_verified(hub->party.snapshot(),error)||!roster.find(local))return false; + } + owned_observer_read_only(&world,watching());phase_at=progress=Clock::now();started=true; + std::fprintf(stderr,"[party][admission] begin authority=%u watching=%u\n",unsigned(server()),unsigned(watching())); + if(server()) {auto b=capture(cursor);ready(b);encode(b);for(const auto& m:roster.snapshot().members)if(m.id!=local)needing.insert(m.id);} + else send(host,Msg::Hello); + hello_at=Clock::now()+std::chrono::seconds(2);return true; + } + void start_delivery(MemberId id,const Baseline& wire) { + if(!roster.find(id)||id==local||!held||held->next!=wire.next||held->control_generation!=wire.control_generation)return; + auto& d=deliveries[id];d={};d.progress=Clock::now();d.sent=wire.next; + d.raw_digest=held->digest;d.actions=held->magic_actions; + if(!d.sender.begin(++transfer_serial,std::make_shared(wire)))throw std::runtime_error("Could not stage spectator checkpoint delivery."); + } + void transfer(Clock::time_point now) { + if(encoding.valid()&&encoding.wait_for(std::chrono::seconds(0))==std::future_status::ready) { + auto value=encoding.get();encoded=value.wire; + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] encoded bytes=%zu\n",encoded->state->size()); + if(encode_pending){auto pending=std::move(*encode_pending);encode_pending.reset();encode(std::move(pending));} + } + if(encoded&&held&&encoded->next==held->next&&encoded->control_generation==held->control_generation&&feed.covers(encoded->next)) {for(auto id:needing)start_delivery(id,*encoded);needing.clear();} + unsigned chunks=0; + for(auto& [id,d]:deliveries) { + if(!roster.find(id)||id==local)continue; + if(!d.offered||(!d.accepted&&now-d.progress>=std::chrono::seconds(1))){ + send(id,Msg::Offer,encode_offer(d.sender.offer()),Lane::Control);d.offered=true;d.progress=now; + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] offer bytes=%u\n",d.sender.offer().bytes); + } + if(d.accepted&&chunks<4)if(const auto chunk=d.sender.next_chunk()) { + const auto bytes=encode_chunk(*chunk);Write w;w.n('D',1).n('P',1).n('R',1).n(2,1).n(unsigned(Msg::Chunk),1).n(match).n(++serial).data(bytes); + if(party_send(key,id,w.b,Lane::Checkpoint)){d.sender.sent();d.progress=now;++chunks;} + } + if(d.sender.complete()&&d.admitted&&!required().contains(id)&&now-d.frames_at>=std::chrono::milliseconds(50)&&feed.covers(d.sent)) { + auto frames=feed.after(d.sent,8); + if(!frames.empty()) {Write w;w.n(d.sender.offer().transfer).data(wire::frames(frames)); + Write packet;packet.n('D',1).n('P',1).n('R',1).n(2,1).n(unsigned(Msg::Frames),1).n(match).n(++serial).data(w.b); + party_send(key,id,packet.b,Lane::Replica);d.frames_at=now;} + } else if(d.sender.complete()&&feed.current()&&!feed.covers(d.sent))needing.insert(id); + } + if(decoding.valid()&&decoding.wait_for(std::chrono::seconds(0))==std::future_status::ready) { + auto b=decoding.get();held=b; + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] decoded bytes=%zu\n",b.state->size()); + if(!simulating()) {std::string e;if(!playback.offer(b,e))throw std::runtime_error(e);} + } + if(!server()&&held&&simulating()&&!driver&&admitted_at!=held->next) { + std::string e; + if(phase==Phase::Admission&&!go&&held->next==Cursor{91,0}&&world.confirmed_count()==0&&held->magic_actions==0){ + // A fresh racer is a zero-frame owned launch, not a future + // observer restore. Use the existing canonical admission gate. + if(box.quiescent()){ + if(!world.admit_initial(*held->state,e))throw std::runtime_error("Initial racing checkpoint admission failed: "+e); + ready(*held); + } + }else{ + const auto work=retire_owned_observer(&world,held->next,e); + if(work==Work::Failed)throw std::runtime_error(e); + if(work==Work::Ready){if(!admit_owned_observer(&world,*held,e))throw std::runtime_error(e);ready(*held);} + } + } + if(receiver.active()&&receiver.expired(now)) { + std::fprintf(stderr,"[party][checkpoint] expired; received=%zu; requesting a fresh transfer\n",receiver.received_bytes()); + receiver.cancel();receiving.reset();send(host,Msg::Need); + } + } + void receive_input(MemberId source,Read& r) { + const auto generation=r.n();wire::ControllerSample sample;std::string e; + const auto count=r.n(1);if(count!=25||count>r.b.size()-r.pos)throw std::runtime_error("Invalid racing input width."); + if(!wire::controller(r.b.subspan(r.pos,std::size_t(count)),sample,e))throw std::runtime_error(e);r.pos+=std::size_t(count); + auto c=command(r.rest()); + if(generation!=controls||sample.cursor.epoch!=cursor.epoch||!driver||sample.cursor.frame=freeze)return; + if(server()) { + if(!roster.accepts(source,sample.port,sample.lease,sample.cursor)||!c.empty())return; + if(std::uint64_t(sample.cursor.frame)>std::uint64_t(next().frame)+48)return; + const auto id=std::pair{sample.cursor.frame,unsigned(sample.port)}; + const auto existing=inputs.find(id); + if(existing!=inputs.end()) { + if(existing->second.first!=sample.input)throw std::runtime_error("A racing member changed immutable input."); + return; + } + if(sample.port==0&&launch.development)c=dkr::sdk2::take(true); + inputs[id]={sample.input,c}; + Write body;body.n(controls).n(25,1).data(wire::controller(sample)).data(command_bytes(c)); + racers(Msg::Input,body.b,Lane::Control); + } else if(source!=host)return; + const auto result=driver->receive(cursor.epoch,sample.port,sample.cursor.frame-base,packed(sample.input),c); + if(result==ex::InputResult::Conflict||result==ex::InputResult::Failed)throw std::runtime_error(driver->error()); + if(server()&&result==ex::InputResult::OutOfRange)inputs.erase({sample.cursor.frame,unsigned(sample.port)}); + } + void message(MemberId source,std::span bytes,Clock::time_point now) { + Read r{bytes};if(r.n(1)!='D'||r.n(1)!='P'||r.n(1)!='R'||r.n(1)!=2)return; + const auto type=Msg(r.n(1));if(r.n()!=match)return;(void)r.n(); + const bool from_host=source==host; + if(!server()&&!from_host)return; + if(server()&&!roster.find(source))return; + switch(type) { + case Msg::Hello:case Msg::Need: + r.end();if(server()) { + if(type==Msg::Need&&required().contains(source)&&phase!=Phase::Admission)return; + auto& previous=requested_at[source]; + if(!previous.time_since_epoch().count()||now-previous>=std::chrono::seconds(2)) { + if(type==Msg::Need)if(auto d=deliveries.find(source);d!=deliveries.end()&&encoded&& + (encoded->next.epochsecond.sent.epoch||(encoded->next.epoch==d->second.sent.epoch&&encoded->next.framesecond.sent.frame)))encoded.reset(); + previous=now;needing.insert(source);text="Preparing a confirmed spectator checkpoint"; + } + }break; + case Msg::Offer: { + if(!from_host||server())return; + CheckpointOffer offer;std::string e; + if(!decode_offer(r.rest(),offer,e))throw std::runtime_error(e); + if(offer.bytes>world.contract().state_bytes+1024U*1024U||offer.match!=match||offer.schema!=world.contract().schema) + throw std::runtime_error("Initial checkpoint does not match the admitted game layout."); + // Retried offers must not reset a transfer already decoded or + // admitted at this cursor. Keep the acknowledgement idempotent. + if(offer.transfer==active_transfer&&(receiver.active()||held||decoding.valid())){ + if(!last_offer||offer!=*last_offer)throw std::runtime_error("Conflicting checkpoint offer retry."); + Write ack;ack.n(offer.transfer);send(host,Msg::OfferAck,ack.b);break; + } + if(!receiver.begin(offer,match,world.contract().schema,offer.bytes,now,e))throw std::runtime_error(e); + last_offer=offer;receiving=offer;active_transfer=offer.transfer;admitted_at.reset();buffered.clear(); + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] offer accepted bytes=%u\n",offer.bytes); + Write ack;ack.n(offer.transfer);send(host,Msg::OfferAck,ack.b);break; + } + case Msg::Chunk: { + if(!from_host||server()||!receiving||decoding.valid())return; + CheckpointChunk chunk;std::string e;if(!decode_chunk(r.rest(),chunk,e))throw std::runtime_error(e); + const bool first=receiver.received_bytes()==0; + const auto result=receiver.receive(chunk,now,e); + if(first&&result==CheckpointReceiver::Result::Accepted)std::fprintf(stderr,"[party][checkpoint] first chunk received\n"); + if(result==CheckpointReceiver::Result::Rejected)throw std::runtime_error(e); + if(result==CheckpointReceiver::Result::Complete) { + auto wire_state=receiver.take_verified(e);if(!wire_state)throw std::runtime_error(e); + receiving.reset(); + decoding=std::async(std::launch::async,[b=std::move(*wire_state),limit=world.contract().state_bytes,hasher=hash]()mutable { + auto decoded=std::make_shared();std::string failure; + if(!net::decode_authoritative_state_wire(*b.state,limit,*decoded,failure)||decoded->size()!=limit) + throw std::runtime_error("Spectator checkpoint decode/layout rejected: "+failure); + b.state=decoded;b.digest=hasher(*decoded);return b; + }); + }break; + } + case Msg::Ready: { + if(!server())return; + const auto at=r.at();const auto generation=r.n();const auto digest=r.hash();const auto actions=r.n(4);r.end(); + const auto delivery=deliveries.find(source); + if(delivery==deliveries.end()||at!=delivery->second.sender.offer().next||generation!=delivery->second.sender.offer().control_generation|| + digest!=delivery->second.raw_digest||actions!=delivery->second.actions)return; + delivery->second.admitted=true; + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] racer admission acknowledged\n"); + if(held&&at==held->next&&generation==held->control_generation&&digest==held->digest&&actions==held->magic_actions) { + prepared.insert(source);if(phase==Phase::SwapReady)frozen[source]=digest; + } + break; + } + case Msg::Go: { + if(!from_host||server())return; + const auto at=r.at();const auto generation=r.n();PartySnapshot state;std::string e; + if(at.epoch(at.epoch),at.frame,static_cast(generation)); + return; + } + if(activated_release&&at==activated_release->first&&generation<=activated_release->second) { + std::fprintf(stderr,"[party][release-receive] ignored=duplicate epoch=%llu frame=%u controls=%llu\n", + static_cast(at.epoch),at.frame,static_cast(generation)); + return; + } + if(!wire::roster(r.rest(),state,e)||state.host!=host)throw std::runtime_error(e); + if(!(state.generation(at.epoch),at.frame,static_cast(generation)); + break; + } + case Msg::Input:receive_input(source,r);break; + case Msg::Frames: { + if(!from_host||simulating())return; + const auto transfer=r.n();if(transfer!=active_transfer)return; + std::vector frames;std::string e;if(!wire::frames(r.rest(),frames,e))throw std::runtime_error(e); + for(auto& f:frames)if(buffered.size()second!=f)throw std::runtime_error("Conflicting confirmed observer history."); + buffered.insert_or_assign(id,std::move(f)); + } + break; + } + case Msg::Scene: { + if(!server()||!required().contains(source))return; + const auto at=r.at();const auto scene=r.n();const auto digest=r.hash();r.end(); + if(at.epoch!=cursor.epoch||at.frame=launch.player_count||!roster.find(incoming))return; + freeze=at.frame;swap={{p,incoming}};phase=Phase::Freeze;phase_at=now;frozen.clear();break; + } + case Msg::Frozen: { + if(!server()||!required().contains(source))return; + const auto at=r.at();const auto digest=r.hash();r.end(); + if(phase==Phase::Freeze&&at==Cursor{cursor.epoch,freeze})frozen[source]=digest;break; + } + case Msg::Swap: { + if(!server())return; + const auto p=unsigned(r.n(1));const auto incoming=r.n();r.end();const auto* sender=roster.find(source);const auto* replacement=roster.find(incoming); + if(p>=launch.player_count||!sender||!replacement||replacement->role!=Role::Spectator|| + (source!=incoming&&sender->port!=p))return; + std::scoped_lock lock(hub->mutex);const auto item=std::pair{p,incoming}; + if(hub->swap_requests.size()swap_requests.begin(),hub->swap_requests.end(),item)==hub->swap_requests.end())hub->swap_requests.push_back(item); + break; + } + case Msg::Cancel: + if(from_host&&!server()) {r.end();swap.reset();freeze=UINT32_MAX; + if(phase==Phase::Freeze||phase==Phase::SwapReady)phase=Phase::Running; + text="Player swap cancelled";} + break; + case Msg::OfferAck: { + const auto transfer=r.n();r.end(); + if(server())if(auto d=deliveries.find(source);d!=deliveries.end()&&d->second.sender.offer().transfer==transfer&&!d->second.accepted){ + d->second.accepted=true;d->second.progress=now; + if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] offer acknowledged\n"); + } + break; + } + case Msg::FrameAck: { + const auto transfer=r.n();const auto at=r.at();r.end(); + if(server())if(auto d=deliveries.find(source);d!=deliveries.end()&&d->second.sender.offer().transfer==transfer&&d->second.admitted&& + at.epoch==d->second.sent.epoch&&at.frame>=d->second.sent.frame&&feed.covers(at))d->second.sent=at; + break; + } + case Msg::Progress: { + const auto generation=r.n();const auto at=r.at();r.end(); + if(!server()||!driver||!required().contains(source)||generation!=controls||at.epoch!=cursor.epoch)return; + unsigned sent=0; + for(const auto& [id,value]:inputs)if(id.first>=at.frame&&id.first=launch.player_count)return; + wire::ControllerSample sample{{cursor.epoch,frame},std::uint8_t(port()),roster.snapshot().controls[port()].generation,portable(input)}; + local_history[frame]=sample; + Write body;body.n(controls).n(25,1).data(wire::controller(sample)).data(command_bytes({})); + if(server()){Read r{body.b};receive_input(local,r);} + else { + send(host,Msg::Input,body.b,Lane::Control); + if(port()!=0||!launch.development) { + const auto result=driver->receive(cursor.epoch,std::uint8_t(port()),frame-base,input); + if(result==ex::InputResult::Failed||result==ex::InputResult::Conflict)throw std::runtime_error(driver->error()); + } + } + } + Digest signature(const Baseline& b) {Write w;w.data(b.digest).n(b.magic_actions,4);return hash(w.b);} + void scene_step(Clock::time_point now) { + std::string e; + if(phase==Phase::SceneVote&&server()&&all_votes(scene_votes)) { + for(const auto& [id,d]:scene_votes){(void)id;if(d!=phase_digest)throw std::runtime_error("Racing peers disagreed on a confirmed scene boundary.");} + Write w;w.at(next()).n(boundary.scene_hash).data(phase_digest);racers(Msg::Prepare,w.b);phase=Phase::ScenePrepare; + } + if(phase==Phase::ScenePrepare) { + if(!box.quiescent())return; + std::uint64_t proof=0;const auto result=world.prepare_scene(boundary.scene_hash,proof,e); + if(result==ex::PreparationStep::Failed)throw std::runtime_error(e); + if(result==ex::PreparationStep::Pending)return; + ++cursor.epoch;cursor.frame=0;driver.reset();offset.reset();base=authored=0; + auto b=capture(cursor);phase_digest=signature(b);held=b;phase=Phase::SceneReady;phase_at=now; + std::fprintf(stderr,"[party][scene] prepared role=%s epoch=%llu frame=%u awaiting=%s\n", + server()?"host":"racer",static_cast(cursor.epoch),cursor.frame, + server()?"racer-proofs":"host-release"); + if(server()){frozen[local]=phase_digest;encode(b);for(auto& [id,d]:deliveries){(void)d;if(!required().contains(id))needing.insert(id);}} + else {Write w;w.at(cursor).data(phase_digest);send(host,Msg::Prepared,w.b);} + } + if(phase==Phase::SceneReady&&server()&&all_votes(frozen)) { + for(const auto& [id,d]:frozen){(void)id;if(d!=phase_digest)throw std::runtime_error("Racing peers constructed different scene baselines.");} + release(cursor); + } + } + void begin_swap(unsigned p,MemberId incoming,Clock::time_point now) { + const auto* target=roster.find(incoming); + if(!server()||phase!=Phase::Running||!driver||p>=launch.player_count||!target||target->role!=Role::Spectator)return; + // Input authoring is at most 48 ahead of the driver. Freeze beyond all + // authored input, then admit NO sample at/after that exact cutover. + const auto head=next().frame;if(head>UINT32_MAX-80)throw std::runtime_error("Party frame cursor exhausted."); + freeze=head+64;swap={{p,incoming}};phase=Phase::Freeze;phase_at=now;frozen.clear(); + Write w;w.at({cursor.epoch,freeze}).n(p,1).n(incoming);for(const auto& m:roster.snapshot().members)if(m.id!=local)send(m.id,Msg::Freeze,w.b); + text="Preparing player swap"; + } + void swap_step(Clock::time_point now) { + if(!swap)return; + if(phase==Phase::Freeze&&driver&&next().frame==freeze&&confirmed()==freeze&&!driver->correcting()&&!frozen.contains(local)) { + auto b=capture(next());held=b;const auto proof=signature(b);frozen[local]=proof; + if(!server()){Write w;w.at(b.next).data(proof);send(host,Msg::Frozen,w.b);} + } + if(phase==Phase::Freeze&&server()&&all_votes(frozen)) { + const auto proof=frozen.at(local);for(const auto& [id,d]:frozen){(void)id;if(d!=proof)throw std::runtime_error("Player swap stopped because peers disagree on the confirmed race.");} + std::string e; + {std::scoped_lock lock(hub->mutex); + if(!held||hub->party.snapshot().generation!=roster.snapshot().generation) {cancel_swap();return;} + if(!hub->party.propose(host,std::uint8_t(swap->first),swap->second,held->next,held->digest,e)) {cancel_swap();return;} + const auto token=hub->party.snapshot().handover->token; + for(auto id:required())if(!hub->party.acknowledge(id,token,held->next,held->digest,e))throw std::runtime_error(e); + if(!roster.replace_verified(hub->party.snapshot(),e))throw std::runtime_error(e); + } + phase=Phase::SwapReady;phase_at=now;prepared.clear(); + // Existing racers already proved this exact state. The incoming + // spectator must transactionally admit the same checkpoint first. + update_hub();needing.insert(swap->second);encode(*held); + } + if(phase==Phase::SwapReady&&server()&&prepared.contains(swap->second)) { + std::string e;const auto pending=*roster.snapshot().handover; + {std::scoped_lock lock(hub->mutex); + if(!hub->party.snapshot().handover||hub->party.snapshot().handover->token!=pending.token||hub->party.snapshot().generation!=roster.snapshot().generation) {cancel_swap();return;} + if(!hub->party.acknowledge(swap->second,pending.token,held->next,held->digest,e)||!hub->party.transfer(host,held->next,e)|| + !roster.replace_verified(hub->party.snapshot(),e))throw std::runtime_error(e); + } + ++controls;held->control_generation=controls;encoded.reset();feed.clear();encode(*held); + release(held->next); + } + } + void cancel_swap() { + if(!server()||!swap)return; + std::string ignored; + {std::scoped_lock lock(hub->mutex); + if(hub->party.snapshot().handover)hub->party.cancel(host,hub->party.snapshot().handover->token,ignored); + if(!roster.replace_verified(hub->party.snapshot(),ignored))throw std::runtime_error(ignored); + } + for(const auto& m:roster.snapshot().members)if(m.id!=local)send(m.id,Msg::Cancel); + swap.reset();freeze=UINT32_MAX;frozen.clear();phase=Phase::Running;text="Player swap cancelled";update_hub(); + } + bool pulse(Clock::time_point now,net::PackedInput input,std::string& error) { + if(!started){error="Party runtime is not started.";return false;} + PartySnapshot latest;std::optional> request,approval;bool cancel=false; + { + std::scoped_lock lock(hub->mutex); + if(hub->stopping||!hub->enabled){error.clear();return false;} + latest=hub->party.snapshot();request=std::exchange(hub->requested_swap,{});approval=std::exchange(hub->approved_swap,{});cancel=std::exchange(hub->cancel_swap,false); + } + // Transport admission may add/remove watchers, but cannot silently + // change the four racing leases. Only an agreed runtime Go can do so. + if(latest.generation>roster.snapshot().generation&&latest.controls==roster.snapshot().controls) { + const bool lost=swap&&roster.snapshot().handover&&!latest.handover; + if(!roster.replace_verified(std::move(latest),error))return false; + if(lost)cancel_swap(); + } + if(request) { + Write w;w.n(request->first,1).n(request->second); + if(server()){Write envelope;envelope.n('D',1).n('P',1).n('R',1).n(2,1).n(unsigned(Msg::Swap),1).n(match).n(++serial).data(w.b);message(local,envelope.b,now);} + else send(host,Msg::Swap,w.b); + } + if(approval)begin_swap(approval->first,approval->second,now); + if(cancel&&server())cancel_swap(); + LinkIncoming packet; + for(unsigned i=0;i<128&&party_receive(key,packet);++i) { + try {message(packet.source,packet.bytes,now);}catch(const std::exception& e) { + // An observer cannot stop required racing peers with malformed + // playback/seat requests. Required members still fail closed. + if(server()&&!required().contains(packet.source)){needing.erase(packet.source);deliveries.erase(packet.source);continue;} + throw std::runtime_error(e.what()); + } + } + if(phase==Phase::Admission&&now>=admission_log){ + admission_log=now+std::chrono::seconds(5); + std::fprintf(stderr,"[party][admission-progress] authority=%u prepared=%zu required=%zu receiving=%zu decoding=%u encoded=%u deliveries=%zu\n", + unsigned(server()),prepared.size(),required().size(),receiver.received_bytes(),unsigned(decoding.valid()),unsigned(bool(encoded)),deliveries.size()); + } + if(server()&&go&&now=release_retry&&!release_packet.empty()){ + for(const auto& m:roster.snapshot().members)if(m.id!=local)send(m.id,Msg::Go,release_packet); + release_retry=now+std::chrono::milliseconds(500); + } + if(!server()&&phase==Phase::Admission&&held&&admitted_at==held->next&&now>=ready_retry){ + Write w;w.at(held->next).n(held->control_generation).data(held->digest).n(held->magic_actions,4);send(host,Msg::Ready,w.b); + ready_retry=now+std::chrono::milliseconds(500); + } + if(!callback_error.empty())throw std::runtime_error(callback_error); + transfer(now); + std::erase_if(deliveries,[&](const auto& entry){return !roster.find(entry.first);}); + std::erase_if(requested_at,[&](const auto& entry){return !roster.find(entry.first);}); + if(server()&&phase==Phase::Admission&&all(prepared))release(cursor); + if(!server()&&phase==Phase::Admission&&now>=hello_at&&!held&&!receiving&&!decoding.valid()) {send(host,Msg::Hello);hello_at=now+std::chrono::seconds(2);} + if(!simulating()) { + if(!receiving&&!decoding.valid()) { + if(!buffered.empty()&&playback.view().phase!=PlaybackPhase::WaitingForBaseline) { + std::vector batch; + const auto first=playback.view().received;auto expected=first; + std::erase_if(buffered,[&](const auto& item){return item.firstsecond.scene_boundary;batch.push_back(std::move(f->second));buffered.erase(f);++expected.frame;if(boundary)break;} + if(!batch.empty()) {const auto result=playback.append(batch,error);if(result!=FeedResult::Accepted&&result!=FeedResult::Duplicate){playback.request_resync(error);buffered.clear();}} + Write ack;ack.n(active_transfer).at(playback.view().received);send(host,Msg::FrameAck,ack.b); + } + const auto simulated_before=playback.view().simulated; + const auto work=playback.pulse(now,error); + if(work==Work::Failed){ + const auto at=playback.view().received; + const bool retry=observer_retry.failed(at.epoch); + std::fprintf(stderr,"[spectator][admission] failed epoch=%llu frame=%u attempt=%u/%u reason=%.512s\n", + static_cast(at.epoch),at.frame,observer_retry.failures(), + dkr::spectator_admission::AdmissionRetry::MaximumFailures,error.c_str()); + if(!retry){error="Spectator playback could not recover after five failures: "+error;return false;} + playback.request_resync(error);held.reset();admitted_at.reset();send(host,Msg::Need);error.clear(); + } + if(playback.view().simulated!=simulated_before){ + observer_retry.progressed(); + const auto at=playback.view().next; + if(observer_render_epoch!=at.epoch){ + observer_render_epoch=at.epoch; + std::fprintf(stderr,"[spectator][playback] first-frame epoch=%llu frame=%u\n", + static_cast(at.epoch),at.frame-1); + } + } + if(held&&admitted_at!=held->next&&playback.view().next==held->next&&playback.view().phase!=PlaybackPhase::Retiring)ready(*held); + if(playback.needs_baseline()&&now>=hello_at){send(host,Msg::Need);hello_at=now+std::chrono::seconds(2);} + } + text=playback.view().status;return true; + } + if(phase==Phase::SceneVote||phase==Phase::ScenePrepare||phase==Phase::SceneReady) { + scene_step(now); + if(now-phase_at>std::chrono::seconds(120))throw std::runtime_error("Required party scene agreement timed out."); + return true; + } + if(!driver) { + if(now-phase_at>std::chrono::seconds(120))throw std::runtime_error("Required party admission did not complete within 120 seconds."); + return true; + } + const auto before=next();const auto was_confirmed=confirmed(); + if(!server()&&now>=proof_at) { + Write proof;proof.n(controls).at({cursor.epoch,confirmed()});send(host,Msg::Progress,proof.b); + // Bounded repair of immutable local samples which may have arrived + // beyond the host's short future window during a temporary hitch. + unsigned count=0; + for(const auto& [frame,sample]:local_history)if(frame>=confirmed()&&frame(launch.input_delay_frames,12); + if(port()next)); + const bool advance=now>=next_tick&&next().framestep(advance); + if(step==ex::Step::Failed)throw std::runtime_error(driver->error()); + if(step==ex::Step::Advanced)next_tick=std::max(next_tick+Tick,now-Tick*2); + if(step==ex::Step::ConfirmedBoundary) { + if(swap)cancel_swap(); + if(!world.confirmed_boundary(boundary,error))throw std::runtime_error(error); + auto b=capture(next());phase_digest=signature(b);phase=Phase::SceneVote;phase_at=now;frozen.clear(); + if(!box.retire_for_restore(cursor.epoch,next().frame-1))throw std::runtime_error("Scene presentation retirement refused."); + scene_votes[local]=phase_digest; + if(!server()){Write w;w.at(next()).n(boundary.scene_hash).data(phase_digest);send(host,Msg::Scene,w.b);} + } + if(align&&next().frame==confirmed()&&!driver->correcting()&&!driver->scene_boundary_pending())encode(capture(next())); + } + scene_step(now);swap_step(now); + if(swap&&now-phase_at>std::chrono::seconds(30))cancel_swap(); + if((phase==Phase::SceneVote||phase==Phase::ScenePrepare||phase==Phase::SceneReady)&&now-phase_at>std::chrono::seconds(120))throw std::runtime_error("Required party scene agreement timed out."); + if(driver) { + const auto after=next();const auto& stats=driver->statistics(); + if(after!=before||confirmed()!=was_confirmed)progress=now; + if(now-progress>std::chrono::seconds(90))throw std::runtime_error("Racing peers made no confirmed progress for 90 seconds."); + if(after.frame&&after!=before&&!driver->correcting()&&!driver->scene_boundary_pending()&& + (phase==Phase::Running||phase==Phase::Freeze)) { + if(!world.publish_current(after.epoch,after.frame-1,error))throw std::runtime_error(error); + } + const auto trim=confirmed()>96?confirmed()-96:0; + std::erase_if(inputs,[&](const auto& item){return item.first.firstmutex);hub->status=text;} + return true; + } +}; +PartyRuntime::PartyRuntime(const void* k,ex::OwnedGame& w,ex::PresentationMailbox& b,net::LaunchDescriptor l,HashState h) + :impl_(std::make_unique(k,w,b,l,std::move(h))){} +PartyRuntime::~PartyRuntime()=default; +bool PartyRuntime::begin(std::string& error)try{return impl_->begin(error);}catch(const std::exception& e){error=e.what();return false;} +bool PartyRuntime::pulse(Clock::time_point now,net::PackedInput input,std::string& error)try{return impl_->pulse(now,input,error);}catch(const std::exception& e){error=e.what();return false;} +bool PartyRuntime::watching()const{return impl_->watching();} +unsigned PartyRuntime::local_port()const{return impl_->port();} +Cursor PartyRuntime::next()const{return impl_->next();} +std::uint32_t PartyRuntime::confirmed()const{return impl_->confirmed();} +ex::Statistics PartyRuntime::statistics()const{return impl_->driver?impl_->driver->statistics():ex::Statistics{};} +std::string PartyRuntime::status()const{return impl_->text;} +} diff --git a/runtime-recomp/src/game/party_runtime.hpp b/runtime-recomp/src/game/party_runtime.hpp new file mode 100644 index 00000000..90f987e8 --- /dev/null +++ b/runtime-recomp/src/game/party_runtime.hpp @@ -0,0 +1,27 @@ +#pragma once +#include "party_link.hpp" +#include "owned_observer.hpp" +#include "owned_game.hpp" +#include + +namespace dkr::runtime::spectators { +// All gameplay calls and all protocol decisions live on the simulation owner. +// Transport/UI threads exchange only bounded messages and copied lobby views. +class PartyRuntime final { +public: + using Clock=std::chrono::steady_clock; + PartyRuntime(const void* link,net::experimental::OwnedGame& world, + net::experimental::PresentationMailbox& presentation,net::LaunchDescriptor launch,HashState hash); + ~PartyRuntime(); + bool begin(std::string& error); + bool pulse(Clock::time_point now,net::PackedInput input,std::string& error); + bool watching()const; + unsigned local_port()const; + Cursor next()const; + std::uint32_t confirmed()const; + net::experimental::Statistics statistics()const; + std::string status()const; +private: + struct Impl;std::unique_ptr impl_; +}; +} diff --git a/runtime-recomp/src/game/performance_observation.hpp b/runtime-recomp/src/game/performance_observation.hpp new file mode 100644 index 00000000..f9f4fc74 --- /dev/null +++ b/runtime-recomp/src/game/performance_observation.hpp @@ -0,0 +1,65 @@ +#pragma once +#include "performance_switches.hpp" +#include "performance_capture.hpp" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::surgical { +enum class Region : unsigned { Decode, Registry, LiquidWarmup, Count }; +struct Measurement { + std::uint64_t calls=0,total_us=0,maximum_us=0; + std::array buckets{}; +}; +inline constexpr std::array bounds_us{{ + 1,2,4,8,16,32,64,100,150,250,400,600,800,1000,1500,2000, + 3000,4000,5560,8334,12000,16667,25000,33334,50000,75000, + 100000,150000,250000,500000,1000000,UINT64_MAX}}; +struct Observations { + std::array values{}; + std::uint32_t scene=~0U; + unsigned tasks=0; +}; +inline thread_local Observations observations; +inline bool observing() noexcept {return profile() || performance_capture::active();} +inline void report() noexcept { + constexpr const char* names[]{"decode","water-registry","liquid-warmup"}; + for(unsigned i=0;i=rank)return bounds_us[bucket]; + return std::uint64_t(0); + }; + std::fprintf(stderr,"[perf][surgical] scene=%u region=%s calls=%llu wall-us=%llu p95-upper-us=%llu p99-upper-us=%llu max-us=%llu\n", + observations.scene,names[i],static_cast(m.calls), + static_cast(m.total_us),static_cast(percentile(95)), + static_cast(percentile(99)),static_cast(m.maximum_us)); + } + observations.values={};observations.tasks=0; +} +inline void context(std::uint32_t scene,std::uint32_t map,std::uint32_t menu,unsigned target,unsigned quality) noexcept { + if(!observing())return; + if(observations.scene!=scene) { + report();observations.scene=scene; + std::fprintf(stderr,"[perf][surgical-context] scene=%u map=%u menu=%u target=%u water-quality=%u; CPU wall times, not GPU or scanout\n",scene,map,menu,target,quality); + } + if(++observations.tasks>=240)report(); +} +class Scope { + Region region_;bool enabled_;std::chrono::steady_clock::time_point begin_{}; +public: + explicit Scope(Region value):region_(value),enabled_(observing()) {if(enabled_)begin_=std::chrono::steady_clock::now();} + ~Scope() { + if(!enabled_)return; + const auto us=std::uint64_t(std::max(0,std::chrono::duration_cast(std::chrono::steady_clock::now()-begin_).count())); + auto& m=observations.values[unsigned(region_)];++m.calls;m.total_us+=us;m.maximum_us=std::max(m.maximum_us,us); + unsigned bucket=0;while(us>bounds_us[bucket])++bucket;++m.buckets[bucket]; + } +}; +} diff --git a/runtime-recomp/src/game/performance_switches.hpp b/runtime-recomp/src/game/performance_switches.hpp new file mode 100644 index 00000000..32459294 --- /dev/null +++ b/runtime-recomp/src/game/performance_switches.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +namespace dkr::runtime::surgical { +inline bool option(const char* name,bool fallback=true) noexcept { + const char* value=std::getenv(name); + if(!value)return fallback; + if(value[0]=='0' && value[1]=='\0')return false; + if(value[0]=='1' && value[1]=='\0')return true; + return fallback; +} +inline bool flow_reuse() noexcept {static const bool value=option("DKR_PERF_FLOW_REUSE");return value;} +inline bool texture_index() noexcept {static const bool value=option("DKR_PERF_TEXTURE_INDEX");return value;} +inline bool mip_scratch() noexcept {static const bool value=option("DKR_PERF_MIP_SCRATCH");return value;} +inline bool monotonic_pacer() noexcept {static const bool value=option("DKR_PERF_MONOTONIC_PACER");return value;} +inline bool profile() noexcept {static const bool value=option("DKR_SURGICAL_PROFILE",false);return value;} +} diff --git a/runtime-recomp/src/game/policy.hpp b/runtime-recomp/src/game/policy.hpp new file mode 100644 index 00000000..d1a26ee6 --- /dev/null +++ b/runtime-recomp/src/game/policy.hpp @@ -0,0 +1,86 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace dkr::lobby_stability { +using Clock=std::chrono::steady_clock; +inline constexpr unsigned MaxPending=8, MaxDownloads=3, MaxRepairs=5; +inline constexpr const char* Queued="Waiting for a mod download slot"; +inline constexpr const char* SlotReady="Host download slot ready"; +// The bootstrap mutes presentation. For an audio submission only, let the +// unchanged SDK enqueue its normal SP worker instead of synchronously waiting +// for a completion acknowledgement on the guest scheduler's own thread. +inline thread_local unsigned bootstrap_audio_submissions=0; +class BootstrapAudioSubmission { + bool enabled_; +public: + explicit BootstrapAudioSubmission(bool enabled)noexcept:enabled_(enabled){if(enabled_)++bootstrap_audio_submissions;} + ~BootstrapAudioSubmission(){if(enabled_)--bootstrap_audio_submissions;} + BootstrapAudioSubmission(const BootstrapAudioSubmission&)=delete; + BootstrapAudioSubmission& operator=(const BootstrapAudioSubmission&)=delete; +}; +inline bool bootstrap_audio_worker_allowed()noexcept{return bootstrap_audio_submissions!=0;} +struct Candidate {std::uint64_t id,ticket;}; +inline std::vector download_slots(std::vector candidates) { + std::sort(candidates.begin(),candidates.end(),[](const auto& a,const auto& b){return a.ticket result; + for(const auto& c:candidates)if(result.size()=next_&&now>=queue_until_;} + bool exhausted()const{return repairs_>=MaxRepairs;} + unsigned count()const{return repairs_;} + void repaired(Clock::time_point now) { + ++repairs_;next_=now+std::chrono::seconds(std::min(30U,3U<limit; + } +}; +// HelloAck has a 160-byte text budget. Never turn a detailed rejection into +// an empty encoded packet, and never put paths or control characters on wire. +inline std::string rejection(std::string_view text) { + std::string out="Mod preparation failed: "; + for(char c:text) { + if(out.size()>=157)break; + if(c=='/'||c=='\\'||c==':')return "Mod preparation failed on the host. Ask the host to export the lobby log."; + out+=static_cast(c)>=32&&static_cast(c)<127?c:' '; + } + if(text.empty())out+="Please retry from the lobby."; + return out; +} +inline thread_local const void* rejection_session=nullptr; +inline thread_local std::uint64_t rejection_request=0; +inline thread_local std::string rejection_message; +class RejectionScope { + const void* previous_session_=rejection_session; + std::uint64_t previous_request_=rejection_request; + std::string previous_message_=std::move(rejection_message); +public: + RejectionScope(const void* session,std::uint64_t request,std::string_view message) { + rejection_session=session;rejection_request=request;rejection_message=rejection(message); + } + ~RejectionScope(){rejection_session=previous_session_;rejection_request=previous_request_;rejection_message=std::move(previous_message_);} +}; +inline std::string decline_reason(const void* session,std::uint64_t request) { + return session==rejection_session&&request==rejection_request&&!rejection_message.empty() + ?rejection_message:"The host declined this join request."; +} +} diff --git a/runtime-recomp/src/game/polish_symbols.hpp b/runtime-recomp/src/game/polish_symbols.hpp new file mode 100644 index 00000000..835c83bb --- /dev/null +++ b/runtime-recomp/src/game/polish_symbols.hpp @@ -0,0 +1,9 @@ +#pragma once +#include +#include +namespace dkr::runtime::presentation { +inline constexpr std::array,2> polish_symbols{{ + {0x800E3690U,0x800E36C8U,0x8012A7E4U,0x8012A7E0U,0x8012A7F0U,0x800E1DF0U,0x8011B0B8U,0x800E30D0U}, + {0x800E3C20U,0x800E3C58U,0x8012ADA4U,0x8012ADA0U,0x8012ADB0U,0x800E2370U,0x8011B638U,0x800E3660U} +}}; +} diff --git a/runtime-recomp/src/game/presentation_identity.cpp b/runtime-recomp/src/game/presentation_identity.cpp index a4c784eb..4618c83f 100644 --- a/runtime-recomp/src/game/presentation_identity.cpp +++ b/runtime-recomp/src/game/presentation_identity.cpp @@ -1,4 +1,7 @@ +#include "producer_snapshot.hpp" #include "presentation_identity.hpp" +#include "detail_contract.hpp" +#include #include "retained_metadata_table.hpp" #include "netplay/experimental_performance.hpp" #include "custom_music.hpp" @@ -127,6 +130,7 @@ struct MatrixBinding { }; struct SubmittedFrame { + std::vector details; std::uint32_t display_list_address = 0; std::uint32_t task_number = 0; std::uint32_t scene_generation = 0; @@ -168,6 +172,8 @@ dkr::runtime::presentation::PresentationDiagnosticBudget g_marker_rejections{}; std::array, 2> g_shadow_owner_motion_maps; +std::array,2> g_detail_maps; +thread_local std::vector g_active_detail; std::deque g_submitted_frames; bool g_submission_overflowed = false; std::atomic g_scene_generation{1U}; @@ -961,7 +967,7 @@ dkr::runtime::presentation::TaskIdentityScope::TaskIdentityScope( std::uint8_t* rdram_snapshot, std::uint32_t display_list_address) { g_active_task_presentation_time = 0.0; g_active_matrix_map.clear(); - g_active_marker_map.clear(); + g_active_marker_map.clear();g_active_detail.clear(); g_active_shadow_owner_motion.clear(); g_active_task_interpolation_allowed = false; g_active_task_scene_generation = 0U; @@ -1013,6 +1019,7 @@ dkr::runtime::presentation::TaskIdentityScope::TaskIdentityScope( std::next(matching)); g_active_matrix_map = std::move(frame.matrices); g_active_marker_map = std::move(frame.markers); + g_active_detail=std::move(frame.details); g_active_shadow_owner_motion = std::move(frame.shadow_owner_motion); g_active_task_interpolation_allowed = frame.interpolation_allowed; g_active_task_scene_generation = frame.scene_generation; @@ -1025,7 +1032,7 @@ dkr::runtime::presentation::TaskIdentityScope::TaskIdentityScope( dkr::runtime::presentation::TaskIdentityScope::~TaskIdentityScope() { g_active_matrix_map.clear(); - g_active_marker_map.clear(); + g_active_marker_map.clear();g_active_detail.clear(); g_active_shadow_owner_motion.clear(); g_active_task_interpolation_allowed = false; g_active_task_scene_generation = 0U; @@ -1043,7 +1050,7 @@ dkr::runtime::presentation::TaskIdentityScope::TaskIdentityScope( std::span shadows,std::uint32_t scene,bool allowed,double time) { dkr::runtime::netplay::experimental::performance::Scope identity_timing( dkr::runtime::netplay::experimental::performance::Stage::Identity); - g_active_matrix_map.clear();g_active_marker_map.clear();g_active_shadow_owner_motion.clear(); + g_active_matrix_map.clear();g_active_marker_map.clear();g_active_detail.clear();g_active_shadow_owner_motion.clear(); g_active_owned_task=true; g_active_task_presentation_time=std::isfinite(time) && time>=0 ? time : 0; if(markers.size()>8192 || matrices.size()>8192 || shadows.size()>8192) { @@ -1237,6 +1244,16 @@ bool dkr::runtime::presentation::record_presentation_marker( auto& integrity = g_marker_integrity[g_recording_buffer & 1U]; if (!integrity.valid) return false; auto& markers = g_marker_maps[g_recording_buffer & 1U][physical]; + const bool metadata_valid=ValidRange(command_address,8U)&& + valid_presentation_marker(kind,mode,variant)&& + (kind!=PresentationMarkerKind::HudWidget||hud_transform.valid()); + const bool empty_scope_kind=kind==PresentationMarkerKind::HudPass|| + kind==PresentationMarkerKind::HudWidget||kind==PresentationMarkerKind::HudRect|| + is_aspect_marker(kind); + if(metadata_valid&&empty_scope_kind&&mode==0&&!markers.empty()&& + markers.back().kind==kind&&markers.back().mode==1) { + markers.pop_back();return true; // empty-scope-compaction=v1 + } if (!integrity.accept(ValidRange(command_address, 8U) && valid_presentation_marker(kind, mode, variant) && (kind != PresentationMarkerKind::HudWidget || hud_transform.valid()), @@ -1274,6 +1291,16 @@ dkr::runtime::presentation::active_presentation_markers( return result; } +std::vector +dkr::runtime::presentation::camera_world_boundaries() { + std::vector out; + if(g_active_owned_task)return out; + for(const auto& [address,list]:g_active_marker_map)for(const auto& marker:list) + if(marker.kind==PresentationMarkerKind::CameraWorldBegin||marker.kind==PresentationMarkerKind::CameraWorldEnd) + out.push_back({address,marker}); + return out; +} + extern "C" void dkr_presentation_scene_begin(std::uint8_t*, recomp_context*) { std::scoped_lock lock(g_identity_mutex); std::uint32_t next = @@ -1281,6 +1308,7 @@ extern "C" void dkr_presentation_scene_begin(std::uint8_t*, recomp_context*) { if (next == 0U) { g_scene_generation.store(1U, std::memory_order_relaxed); } + for(auto& rows:g_detail_maps)rows.clear(); g_lifetimes.clear(); g_identity_owners.clear(); g_collided_identities.clear(); @@ -1304,7 +1332,7 @@ extern "C" void dkr_presentation_scene_begin(std::uint8_t*, recomp_context*) { g_presentation_ticks.store(0U, std::memory_order_relaxed); g_submitted_frames.clear(); g_active_matrix_map.clear(); - g_active_marker_map.clear(); + g_active_marker_map.clear();g_active_detail.clear(); g_active_shadow_owner_motion.clear(); for (auto& map : g_matrix_maps) { map.clear(); @@ -1352,7 +1380,7 @@ extern "C" void dkr_presentation_frame_begin(std::uint8_t* rdram, dkr::runtime::enhancements::presentation_profile()); std::scoped_lock lock(g_identity_mutex); g_matrix_maps[g_recording_buffer].clear(); - g_marker_maps[g_recording_buffer].clear(); + g_marker_maps[g_recording_buffer].clear();g_detail_maps[g_recording_buffer].clear(); g_marker_integrity[g_recording_buffer] = {}; g_shadow_owner_motion_maps[g_recording_buffer].clear(); } @@ -1510,10 +1538,12 @@ extern "C" void dkr_presentation_task_submitted(std::uint8_t* rdram, if (!dkr::runtime::netplay::external_side_effects_allowed()) { std::scoped_lock lock(g_identity_mutex); g_matrix_maps[g_recording_buffer & 1U].clear(); - g_marker_maps[g_recording_buffer & 1U].clear(); + g_marker_maps[g_recording_buffer & 1U].clear();g_detail_maps[g_recording_buffer & 1U].clear(); g_shadow_owner_motion_maps[g_recording_buffer & 1U].clear(); return; } + dkr::runtime::graphics_handoff::capture(rdram,static_cast(context->r4), + static_cast(context->r5)); if (!dkr::runtime::enhancements::modern_presentation_enabled()) { return; } @@ -1550,6 +1580,8 @@ extern "C" void dkr_presentation_task_submitted(std::uint8_t* rdram, kMaximumPendingFrames); return; } + frame.details=std::move(g_detail_maps[g_recording_buffer&1U]); + g_detail_maps[g_recording_buffer&1U].reserve(dkr::runtime::detail::Observations); frame.matrices = std::move(g_matrix_maps[g_recording_buffer & 1U]); g_matrix_maps[g_recording_buffer & 1U].reserve(1024U); frame.markers = std::move(g_marker_maps[g_recording_buffer & 1U]); @@ -1561,7 +1593,7 @@ extern "C" void dkr_presentation_task_submitted(std::uint8_t* rdram, // Use the existing no-sidecar fallback for this malformed task only. // Never publish a truncated begin/end stream, or retain the failure // into the next frame/scene. Valid tasks take the unchanged path. - frame.markers.clear(); + frame.markers.clear();frame.details.clear(); frame.matrices.clear(); frame.shadow_owner_motion.clear(); frame.interpolation_allowed = false; @@ -1672,3 +1704,5 @@ extern "C" void dkr_presentation_finish_camera_node( std::scoped_lock lock(g_identity_mutex); g_finish_camera_shots[slot].observe_node(owner, node); } + +#include "detail_observer.inl" diff --git a/runtime-recomp/src/game/presentation_identity.hpp b/runtime-recomp/src/game/presentation_identity.hpp index 8842d01e..8c093049 100644 --- a/runtime-recomp/src/game/presentation_identity.hpp +++ b/runtime-recomp/src/game/presentation_identity.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace dkr::runtime::presentation { @@ -2387,6 +2388,7 @@ struct PresentationMarkerList { std::size_t count = 0U; }; struct LocatedPresentationMarker {std::uint32_t address;PresentationMarker marker;}; +std::vector camera_world_boundaries(); struct OwnedMatrixBinding {std::uint32_t address;MatrixInterpolation interpolation;bool weak=false;}; struct OwnedShadowBinding {std::uint16_t token;RigidShadowOwnerPolicy policy;ShadowOwnerMotionSample motion;}; diff --git a/runtime-recomp/src/game/presentation_marker_policy.hpp b/runtime-recomp/src/game/presentation_marker_policy.hpp index c73a02c9..aee31a16 100644 --- a/runtime-recomp/src/game/presentation_marker_policy.hpp +++ b/runtime-recomp/src/game/presentation_marker_policy.hpp @@ -20,6 +20,7 @@ enum class PresentationMarkerKind : std::uint8_t { HudRect, TrackSelectUi, TrackSelectPreview, + CameraWorldBegin, CameraWorldEnd, }; // An overflowed nested begin and its end are one rejected pair. Never let @@ -60,6 +61,7 @@ constexpr bool valid_presentation_marker(PresentationMarkerKind kind, std::uint8_t mode, std::uint8_t variant) { if (variant > 31U) return false; + if(kind==PresentationMarkerKind::CameraWorldBegin||kind==PresentationMarkerKind::CameraWorldEnd)return mode==0&&variant==0; if (kind == PresentationMarkerKind::Geometry) return mode <= 9U; if (kind == PresentationMarkerKind::SplitViewport || kind == PresentationMarkerKind::PostraceFullViewport || diff --git a/runtime-recomp/src/game/presentation_pacer.hpp b/runtime-recomp/src/game/presentation_pacer.hpp new file mode 100644 index 00000000..175cb32f --- /dev/null +++ b/runtime-recomp/src/game/presentation_pacer.hpp @@ -0,0 +1,82 @@ +#pragma once +#include "performance_observation.hpp" +#include "presentation_wait.hpp" +#include +#include + +namespace dkr::runtime::surgical { +// Same calibrated short-sleep/spin algorithm as the accepted timer. Only the +// presentation deadline uses steady_clock; no guest, audio or network clock. +inline void precise_present_wait(std::chrono::steady_clock::time_point end) { + using Clock=std::chrono::steady_clock; + coarse_presentation_wait(end); + auto start=Clock::now(); + auto remaining=std::chrono::duration(end-start).count(); + if(remaining<=0)return; + constexpr double sleep_seconds=0.0001,upper=0.002; + static thread_local double estimate=sleep_seconds,mean=sleep_seconds,m2=0; + static thread_local std::uint64_t samples=1; + while(remaining>estimate) { + std::this_thread::sleep_for(std::chrono::nanoseconds(100000)); + const auto next=Clock::now(); + const double elapsed=std::chrono::duration(next-start).count(); + remaining-=elapsed; + if(elapsed producer_context{0}; +inline void publish_context(std::uint32_t scene,std::uint32_t map,std::uint32_t menu) noexcept { + if(observing())producer_context.store((std::uint64_t(scene)<<32)|((map&65535U)<<16)|(menu&65535U),std::memory_order_relaxed); +} +inline void observe_present(unsigned width,unsigned height,unsigned target) noexcept { + using Clock=std::chrono::steady_clock; + struct Window { + std::uint64_t context=0;unsigned width=0,height=0,target=0,count=0; + Clock::time_point previous{},stable{}; + std::array intervals{}; + }; + static thread_local Window window; + if(!observing()){window.previous={};window.count=0;return;} + const auto now=Clock::now();const auto context=producer_context.load(std::memory_order_relaxed); + if(context!=window.context || width!=window.width || height!=window.height || target!=window.target || window.stable==Clock::time_point{}) { + window={};window.context=context;window.width=width;window.height=height;window.target=target;window.stable=now;return; + } + // Exclude half a second around a scene/viewport/target transition. Context + // is the latest producer observation, not an exact GPU-frame scene tag. + if(now-window.stable(now-window.previous).count(); + window.previous=now; + if(window.count!=window.intervals.size())return; + auto values=window.intervals;std::sort(values.begin(),values.end()); + const double budget=target?1000.0/target:0; + const auto misses=[&](double limit){return std::count_if(values.begin(),values.end(),[&](double ms){return ms>limit;});}; + std::fprintf(stderr,"[perf][surgical-present] producer-scene=%u viewport=%ux%u target=%u n=300 ms(p50/p95/p99/max)=%.3f/%.3f/%.3f/%.3f over-1.5-budget=%zu over-2-budget=%zu; swapchain returns, not scanout\n", + unsigned(context>>32),width,height,target,values[149],values[284],values[296],values[299],std::size_t(misses(budget*1.5)),std::size_t(misses(budget*2))); + window.count=0; +} +} diff --git a/runtime-recomp/src/game/primary_heap.h b/runtime-recomp/src/game/primary_heap.h new file mode 100644 index 00000000..8dabb75c --- /dev/null +++ b/runtime-recomp/src/game/primary_heap.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#ifdef __cplusplus +#define DKR_HEAP_CONSTEXPR constexpr +#else +#define DKR_HEAP_CONSTEXPR +#endif + +// The all-player model residency policy applies to stock sessions too. Admit +// deterministic headroom in RAM already mapped by both runtime owners. Keep +// custom admissions, unknown pools and smaller maps. No free queue changes. +// The upper 128 KiB remain outside this expansion (including private stacks). +static inline DKR_HEAP_CONSTEXPR uint32_t dkr_primary_heap_end( + uint32_t admitted_end, size_t mapped_bytes) { + return admitted_end == 0x80400000U && mapped_bytes >= 0x800000U + ? 0x807E0000U : admitted_end; +} +#undef DKR_HEAP_CONSTEXPR diff --git a/runtime-recomp/src/game/producer_snapshot.cpp b/runtime-recomp/src/game/producer_snapshot.cpp new file mode 100644 index 00000000..2bd2af9d --- /dev/null +++ b/runtime-recomp/src/game/producer_snapshot.cpp @@ -0,0 +1,37 @@ +#include "producer_snapshot.hpp" +#include "performance_trace.hpp" +#include +#include +#include + +namespace dkr::runtime::graphics_handoff { +namespace {ProductionMailbox mailbox;std::atomic failures{0};} +bool capture(const std::uint8_t* ram,std::uint32_t begin,std::uint32_t end) noexcept { + try { + const bool ok=mailbox.capture(ram,begin,end,[](std::uint8_t* dst,const std::uint8_t* src,std::size_t bytes){ + performance_trace::Scope copy_cost(performance_trace::Region::SnapshotCopy,bytes); + std::memcpy(dst,src,bytes); + }); + static std::atomic_flag notice=ATOMIC_FLAG_INIT; + if(ok && !notice.test_and_set(std::memory_order_relaxed)) + std::fputs("[render][producer-snapshot] captured before scheduler enqueue; transferred without a second submission copy\n",stderr); + return ok; + } catch(const std::exception&) { + mailbox.invalidate(); + if(failures.fetch_add(1,std::memory_order_relaxed)<3U) + std::fputs("[render][producer-snapshot] capture unavailable; submission fallback retained\n",stderr); + return false; + } +} +Snapshot take(const std::uint8_t* ram,std::uint32_t root,std::uint32_t count){return mailbox.take(ram,root,count);} +Snapshot acquire(){return mailbox.acquire();} +void discard(const std::uint8_t* ram,std::uint32_t root,std::uint32_t count){auto unused=mailbox.take(ram,root,count);} +void reset() noexcept { + const auto m=mailbox.reset(); + if(m.captured || m.fallback) + std::fprintf(stderr,"[render][handoff-summary] captured=%llu transferred=%llu fallback=%llu mismatch=%llu overflow=%llu; original SP/DP completion retained\n", + static_cast(m.captured),static_cast(m.transferred), + static_cast(m.fallback),static_cast(m.mismatch), + static_cast(m.overflow)); +} +} diff --git a/runtime-recomp/src/game/producer_snapshot.hpp b/runtime-recomp/src/game/producer_snapshot.hpp new file mode 100644 index 00000000..3bf7d308 --- /dev/null +++ b/runtime-recomp/src/game/producer_snapshot.hpp @@ -0,0 +1,97 @@ +#pragma once +#include "graphics_snapshot_pool.hpp" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::graphics_handoff { +// One owner transfers the memory image across BOTH scheduler queues. Keys are +// checked in submission order; searching by a recycled display-list address +// would risk handing an older frame to a newer task. +template +class Mailbox { +public: + using Pool = GraphicsSnapshotPool; + using Snapshot = typename Pool::Snapshot; + struct Metrics {std::uint64_t captured=0, transferred=0, fallback=0, mismatch=0, overflow=0;}; + struct CopyBytes { + void operator()(std::uint8_t* dst,const std::uint8_t* src,std::size_t bytes) const {std::memcpy(dst,src,bytes);} + }; +private: + struct Entry {const void* owner=nullptr;std::uint32_t root=0, count=0;Snapshot image{};}; + Pool pool_; + std::mutex mutex_; + std::array pending_{}; + std::size_t first_=0, size_=0; + bool desynchronised_=false; + std::uint64_t era_=0; + Metrics metrics_{}; + void discard_locked() { + for(auto& entry:pending_)entry=Entry{}; + first_=size_=0; + } + static bool address(std::uint32_t raw, std::uint32_t& physical) { + const auto segment=raw&0xE0000000U; + if(segment!=0U && segment!=0x80000000U)return false; + physical=raw&0x1FFFFFFFU; + return physical<=Bytes && !(physical&7U); + } +public: + template + bool capture(const std::uint8_t* ram,std::uint32_t begin,std::uint32_t end,Copy copy={}) { + std::uint32_t root=0,finish=0; + if(!ram || !address(begin,root) || !address(end,finish) || finish<=root){invalidate();return false;} + std::uint64_t era=0; + { + std::lock_guard lock(mutex_); + if(desynchronised_)return false; + if(size_==Capacity){++metrics_.overflow;discard_locked();desynchronised_=true;return false;} + era=era_; + } + auto image=pool_.acquire(); + copy(image.get(),ram,Bytes); + std::lock_guard lock(mutex_); + if(desynchronised_ || era!=era_)return false; + if(size_==Capacity){++metrics_.overflow;discard_locked();desynchronised_=true;return false;} + auto& slot=pending_[(first_+size_)%Capacity]; + slot={ram,root,(finish-root)/8U,std::move(image)}; + ++size_;++metrics_.captured; + return true; + } + Snapshot take(const std::uint8_t* ram,std::uint32_t begin,std::uint32_t count) { + std::uint32_t root=0; + const bool valid=ram && address(begin,root) && root0 && count<=(Bytes-root)/8U; + std::lock_guard lock(mutex_); + if(size_ && !desynchronised_) { + auto& slot=pending_[first_]; + if(valid && slot.owner==ram && slot.root==root && slot.count==count) { + auto image=std::move(slot.image);slot=Entry{}; + first_=(first_+1U)%Capacity;--size_;++metrics_.transferred; + return image; + } + ++metrics_.mismatch; + desynchronised_=true; + } + // Never let an unmatched producer image survive and match a later + // reuse of the same root address. The caller retains the old safe + // submission-copy fallback for nonstandard tasks. + discard_locked();++metrics_.fallback; + return Snapshot{}; + } + Snapshot acquire(){return pool_.acquire();} + void invalidate(){std::lock_guard lock(mutex_);discard_locked();desynchronised_=true;} + Metrics metrics(){std::lock_guard lock(mutex_);return metrics_;} + Metrics reset(){std::lock_guard lock(mutex_);auto result=metrics_;++era_;discard_locked();metrics_={};desynchronised_=false;return result;} +}; + +using ProductionMailbox=Mailbox<0x800000>; +using Snapshot=ProductionMailbox::Snapshot; +bool capture(const std::uint8_t*,std::uint32_t,std::uint32_t) noexcept; +Snapshot take(const std::uint8_t*,std::uint32_t,std::uint32_t); +Snapshot acquire(); +void discard(const std::uint8_t*,std::uint32_t,std::uint32_t); +void reset() noexcept; +} diff --git a/runtime-recomp/src/game/record_store.inl b/runtime-recomp/src/game/record_store.inl new file mode 100644 index 00000000..03e96e6a --- /dev/null +++ b/runtime-recomp/src/game/record_store.inl @@ -0,0 +1,57 @@ +namespace dkr::runtime::saves::records { +namespace { +std::mutex record_mutex; +std::filesystem::path record_path; +Map record_seed; +Bytes record_pending; +std::future record_job; +std::string record_failure; +bool ReadRecords(const std::filesystem::path& path,Bytes& bytes) { + return ReadFileBounded(path,20+8192*76,bytes)&&valid(bytes); +} +} +bool persist(const std::filesystem::path& path,std::span bytes,std::string& error) { + Map incoming;if(!decode(bytes,incoming)){error="Custom records have an invalid format; previous records were kept.";return false;} + auto owner=storage::acquire(path,error);if(!owner)return false; + Map merged;Bytes previous; + if(std::filesystem::exists(path)) { + if(!ReadRecords(path,previous)||!decode(previous,merged)){error="Custom records need recovery; the existing image was retained.";return false;} + } + for(auto& [key,r]:incoming) { + auto& saved=merged[key];saved.key=r.key;saved.vehicle=r.vehicle; + if(r.lap&&(!saved.lap||r.lap8192){error="The custom-record store is full; export a copy before adding more records.";return false;} + return storage::write_atomic(path,encode(merged),ReadRecords,error); +} +bool begin(const std::filesystem::path& path,bool restore,std::string& error) { + if(!finish(error))return false; + std::scoped_lock lock(record_mutex);record_path=path;record_seed.clear();record_failure.clear(); + if(!path.empty()&&!storage::writable_path(path,error))return false; + if(restore&&std::filesystem::exists(path)) { + Bytes bytes;if(!ReadRecords(path,bytes)||!decode(bytes,record_seed)){error="Custom-course records need recovery. Their original file was retained.";return false;} + } + return true; +} +Bytes seed(){std::scoped_lock lock(record_mutex);return encode(record_seed);} +void submit(Bytes bytes) { + std::scoped_lock lock(record_mutex); + if(record_path.empty()||!record_failure.empty())return; + if(record_job.valid()&&record_job.wait_for(std::chrono::seconds(0))==std::future_status::ready)record_failure=record_job.get(); + if(!record_failure.empty())return; + record_pending=std::move(bytes); + if(!record_job.valid()) { + const auto path=record_path;auto next=std::move(record_pending);record_pending.clear(); + record_job=std::async(std::launch::async,[path,next=std::move(next)]{std::string error;persist(path,next,error);return error;}); + } +} +bool finish(std::string& error) { + std::scoped_lock lock(record_mutex); + if(record_job.valid())record_failure=record_job.get(); + if(record_failure.empty()&&!record_pending.empty()&&!record_path.empty())persist(record_path,record_pending,record_failure); + record_pending.clear();record_path.clear();record_seed.clear(); + if(!record_failure.empty()){error=record_failure;record_failure.clear();return false;} + return true; +} +} diff --git a/runtime-recomp/src/game/records_guest.inl b/runtime-recomp/src/game/records_guest.inl new file mode 100644 index 00000000..08d69738 --- /dev/null +++ b/runtime-recomp/src/game/records_guest.inl @@ -0,0 +1,68 @@ +// Owner-thread access to the SAME revision-mapped gSettingsPtr that the native +// get_settings routine returns. Never execute guest code from this observer: +// owned capture/initial installation occur outside an active guest call fence. +mods::Bytes course_records(std::uint8_t* rdram,const recomp_context&,bool restore) { + using namespace saves::records; + const auto courses=authored_courses();if(courses.empty())return {}; + const auto payload=guest_payload();if(!payload)return {}; + auto revision=payload->revision; + if(current_world().private_owner) { + const auto assets=current_world().session.load(); + if(!assets||!assets->boot_bank())throw mods::Error("Custom records lost their admitted asset owner."); + const auto id=assets->boot_bank()->revision(); + if(id!="us.v77"&&id!="us.v80")throw mods::Error("Custom records have an unsupported owned asset revision."); + revision=id=="us.v80"?rom::Revision::UsV80:rom::Revision::UsV77; + } + if(revision!=rom::Revision::UsV77&&revision!=rom::Revision::UsV80) + throw mods::Error("Custom records have no supported ROM revision."); + const auto valid=[](std::uint32_t p,unsigned bytes,unsigned alignment) { + return p>=0x80000000U&&p<0x80800000U&&bytes<=0x80800000U-p&&!(p&(alignment-1)); + }; + const auto word=[&](std::uint32_t p) { + if(!valid(p,4,4))throw mods::Error("Custom record pointer is outside owned RDRAM."); + std::uint32_t value;std::memcpy(&value,rdram+(p-0x80000000U),4);return value; + }; + const auto read_half=[&](std::uint32_t p) { + if(!valid(p,2,2))throw mods::Error("Custom record value is outside owned RDRAM."); + std::uint16_t value;std::memcpy(&value,rdram+((p-0x80000000U)^2U),2);return value; + }; + const auto write_half=[&](std::uint32_t p,std::uint16_t value) { + if(!restore||!valid(p,2,2))throw mods::Error("Custom record restoration crossed its owned span."); + std::memcpy(rdram+((p-0x80000000U)^2U),&value,2); + }; + if(!rdram)return {}; + const auto settings=word(revision==rom::Revision::UsV80?0x80123A90U:0x80123510U); + if(!valid(settings,0x48,4))return {}; + Map remembered; + if(restore&&!decode(seed(),remembered))throw mods::Error("Invalid custom-record launch seed."); + // Validate ALL destinations before writing any initial records. Capture + // only reads; original course slots 0..64 can never become destinations. + struct Row {std::string key;unsigned vehicle;std::array p;}; + std::vector rows;rows.reserve(courses.size()*3); + for(const auto& course:courses) { + if(course.level_id<65||course.level_id>=128)continue; + const auto key=mods::sha256(mods::View(reinterpret_cast(course.id.data()),course.id.size())); + for(unsigned vehicle=0;vehicle<3;++vehicle)if(course.vehicles&(1U<second;write_half(p[0],r.lap_name);write_half(p[1],r.lap); + write_half(p[2],r.course_name);write_half(p[3],r.course); + } + Record r{row.key,row.vehicle,read_half(p[1]),read_half(p[0]),read_half(p[3]),read_half(p[2])}; + if(r.lap||r.course)snapshot.emplace(std::pair{row.key,row.vehicle},r); + } + return encode(snapshot); +} diff --git a/runtime-recomp/src/game/remove_member.inl b/runtime-recomp/src/game/remove_member.inl new file mode 100644 index 00000000..326b33ea --- /dev/null +++ b/runtime-recomp/src/game/remove_member.inl @@ -0,0 +1,30 @@ +// Inside the existing DirectSession owner/party locks. UI requests carry a +// stable member and roster generation, never a potentially reassigned port. +{ + dkr::runtime::lobby_ui::Removal request; + if(dkr::runtime::lobby_ui::take_removal(this,request)) { + std::string reason; + const auto* selected=party->party.find(request.member); + auto* peer=party->peer(request.member); + if(!is_host_||request.room!=party->room_id||request.generation!=party->party.snapshot().generation|| + request.member==party->local||!selected||!peer||party->party.snapshot().handover) { + party->status="The lobby changed before removal. No player was removed."; + } else { + const std::string name=selected->name; + const bool stops_race=party->launch&&selected->role==watch::Role::Racer; + const auto address=peer->address;const auto key=peer->key; + if(party->party.remove(request.member,reason)) { + const auto leave=watch::link_encode(watch::LinkKind::Leave); + send_with_key(address,key,static_cast(watch::PartyMessageType),leave); + flush_outbound_locked(); + transport_->release_peer_route(address); + for(auto& item:party->peers)if(item.id==request.member)crypto_wipe(item.key.data(),item.key.size()); + std::erase_if(party->peers,[&](const auto& item){return item.id==request.member;}); + party->ready.erase(request.member);party->pending_watch.erase(request.member); + std::erase_if(party->swap_requests,[&](const auto& item){return item.second==request.member;}); + party->advertised={};party->status=name+" was removed from the lobby."; + if(stops_race){party->stop_requested=true;party->status="The host removed a racer. Returning safely to the lobby.";} + } else party->status=reason; + } + } +} diff --git a/runtime-recomp/src/game/render_heap_addresses.hpp b/runtime-recomp/src/game/render_heap_addresses.hpp new file mode 100644 index 00000000..c3e8daea --- /dev/null +++ b/runtime-recomp/src/game/render_heap_addresses.hpp @@ -0,0 +1,6 @@ +#pragma once +#include +namespace dkr::runtime::render_heap_addresses { +inline constexpr std::uint32_t Pool77=0x80123580U; +inline constexpr std::uint32_t Pool80=0x80123B00U; +} diff --git a/runtime-recomp/src/game/render_heap_support.cpp b/runtime-recomp/src/game/render_heap_support.cpp new file mode 100644 index 00000000..3ea18988 --- /dev/null +++ b/runtime-recomp/src/game/render_heap_support.cpp @@ -0,0 +1,64 @@ +#include "recomp.h" +#include "librecomp/helpers.hpp" +#include "revision_addresses.hpp" +#include "render_heap_addresses.hpp" +#include +#include +#include +#include + +namespace { +constexpr std::uint32_t RetailEnd=0x80400000U,ExpansionEnd=0x807E0000U; +constexpr unsigned SlotBytes=20; +bool valid(std::uint32_t at,std::uint32_t bytes) { + return at>=0x80000400U&&at%4==0&&bytes<=0x800000U&&at<=0x80800000U-bytes; +} +gpr guest(std::uint32_t at) { return static_cast(static_cast(at)); } +} + +// A code mod can legitimately add a second viewport after the outer level +// loader chose a one-player heap. Keep this scene-local admission inside the +// existing 8 MiB guest RAM. No host allocation, save routing or GPU completion. +extern "C" void dkr_render_heap_reserve(std::uint8_t* rdram,recomp_context*) { + using namespace dkr::runtime; + const bool v80=revision_addresses::CurrentHud==revision_addresses::kUsV80.CurrentHud; + const auto pool=v80?render_heap_addresses::Pool80:render_heap_addresses::Pool77; + const auto read=[&](std::uint32_t at){return std::uint32_t(MEM_W(0,guest(at)));}; + const auto write=[&](std::uint32_t at,std::uint32_t value){MEM_W(0,guest(at))=value;}; + const auto maximum=read(pool),used=read(pool+4),slots=read(pool+8); + if(!maximum||maximum>1600||!used||used>=maximum||!valid(slots,maximum*SlotBytes))return; + std::array seen{}; + const auto slot=[&](unsigned i){return slots+i*SlotBytes;}; + unsigned tail=0,visited=0; + std::uint32_t prior_end=0; + for(;;) { + if(tail>=maximum||seen[tail]||++visited>used)return; + seen[tail]=true; + const auto at=slot(tail),data=read(at),size=read(at+4); + if(!valid(data,size)||data(MEM_H(12,guest(at))); + if(next==-1)break; + if(next<0||unsigned(next)>=maximum)return; + tail=unsigned(next); + } + const auto end=prior_end; + if(visited!=used||end=RetailEnd+16)return; + const auto extra=ExpansionEnd-end; + if(MEM_H(8,guest(slot(tail)))==0) { + write(slot(tail)+4,read(slot(tail)+4)+extra); + } else { + if(used+1>=maximum)return; + const auto spare=static_cast(MEM_H(14,guest(slot(used)))); + if(spare<0||unsigned(spare)>=maximum||seen[unsigned(spare)])return; + const auto at=slot(unsigned(spare)); + write(at,end);write(at+4,extra); + MEM_H(8,guest(at))=0;MEM_H(10,guest(at))=static_cast(tail); + MEM_H(12,guest(at))=-1;write(at+16,0); + MEM_H(12,guest(slot(tail)))=spare;write(pool+4,used+1); + } + write(pool+12,read(pool+12)+extra); + static std::atomic reports{0}; + if(reports.fetch_add(1)<8) + std::fprintf(stderr,"[render][heap-headroom] added=%u bytes for mod-expanded viewports; existing 8 MiB guest RAM; 128 KiB upper guard retained\n",extra); +} diff --git a/runtime-recomp/src/game/render_provenance.hpp b/runtime-recomp/src/game/render_provenance.hpp new file mode 100644 index 00000000..1ec1add6 --- /dev/null +++ b/runtime-recomp/src/game/render_provenance.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::render_provenance { +struct View {std::span submitted,decode;std::uint32_t root=0;}; +inline View& current(){static thread_local View value;return value;} +class Scope { + View previous_; +public: + Scope(std::span submitted,std::span decode,std::uint32_t root) + :previous_(current()){current()={submitted,decode,root};} + ~Scope(){current()=previous_;} + Scope(const Scope&)=delete; +}; +enum class Result {Unavailable,SharedDecode,CommandChanged,PayloadChanged,Unchanged,PrivatePayload}; +inline bool valid(std::span bytes,std::uint64_t start,std::uint64_t count) { + return start<=bytes.size() && count<=bytes.size()-start; +} +inline Result inspect(View view,std::uint32_t command,std::uint32_t payload,std::uint32_t count) { + if(!valid(view.submitted,command,8) || !valid(view.decode,command,8))return Result::Unavailable; + if(view.submitted.data()==view.decode.data())return Result::SharedDecode; + if(std::memcmp(view.submitted.data()+command,view.decode.data()+command,8))return Result::CommandChanged; + if(count && !valid(view.submitted,payload,count))return Result::PrivatePayload; + if(count && !valid(view.decode,payload,count))return Result::Unavailable; + if(count && std::memcmp(view.submitted.data()+payload,view.decode.data()+payload,count))return Result::PayloadChanged; + return Result::Unchanged; +} +inline const char* name(Result result) { + switch(result) { + case Result::SharedDecode:return "shared-decode-no-independent-baseline"; + case Result::CommandChanged:return "command-changed-after-submission"; + case Result::PayloadChanged:return "payload-changed-after-submission"; + case Result::Unchanged:return "checked-bytes-match-submission"; + case Result::PrivatePayload:return "private-or-outside-submission-payload"; + default:return "baseline-unavailable"; + } +} +inline void report(std::uint64_t task,std::uint32_t command,std::uint32_t payload,std::uint32_t count, + std::uint32_t scene,std::uint32_t map,std::uint32_t menu) { + static std::atomic reports{0};if(reports.fetch_add(1,std::memory_order_relaxed)>=3)return; + const auto view=current();const auto result=inspect(view,command,payload,count); + std::fprintf(stderr,"[render][provenance] task=%llu root=%08X scene=%u map=%u menu=%u command=%08X payload=%08X bytes=%u origin=%s; guards retained; bounded report\n", + static_cast(task),view.root,scene,map,menu,command,payload,count,name(result)); + if(valid(view.submitted,payload,std::min(count,32U))) { + std::fprintf(stderr,"[render][submitted-payload]"); + for(unsigned i=0;i seed;if(!dkr::runtime::spectators::party_bootstrap_load({},seed)|| + dkr::runtime::netplay::stable_hash(std::string_view(seed.data(),seed.size()))!=requested_save_hash) { + error="The private party EEPROM does not match the authenticated launch.";return false; + } + error.clear();return true; + } return requested_memory && dkr::runtime::saves::activate_online_save_for_runtime( requested_host,requested_match,requested_save_hash,error); } diff --git a/runtime-recomp/src/game/report_ui.inl b/runtime-recomp/src/game/report_ui.inl new file mode 100644 index 00000000..38b40001 --- /dev/null +++ b/runtime-recomp/src/game/report_ui.inl @@ -0,0 +1,135 @@ +// Diagnostic presentation only. One body scrolls; the controller actions stay +// outside it. This does not alter any other lobby dialog or launcher section. +struct OlScoreboardState {std::uint64_t room=0,expanded=0,focus=0;unsigned revision=0;}; +OlScoreboardState g_ol_scoreboard; +unsigned OlScoreboardColour(const dkr::runtime::rollback_check::Assessment& a){return !a.rated()?kOlSoft:a.score>=8?kOlGo:a.score>=5?kOlAmber:kOlError;} +bool BeginOlScoreboard(const char* name){ + const ImVec2 display=ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowPos({display.x*.5F,display.y*.5F},ImGuiCond_Appearing,{.5F,.5F}); + ImGui::SetNextWindowSize({std::max(1.F,std::min(940.F,display.x-32.F)),std::max(140.F,std::min(800.F,display.y*.85F))},ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,display.x<600?ImVec2{16,16}:ImVec2{24,20}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,18.F);ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize,1.F); + ImGui::PushStyleColor(ImGuiCol_PopupBg,PaddockRgb(0x091D29));ImGui::PushStyleColor(ImGuiCol_Border,PaddockRgb(0x32657A)); + const bool visible=ImGui::BeginPopupModal(name,nullptr,ImGuiWindowFlags_NoTitleBar|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoScrollbar|ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleColor(2);ImGui::PopStyleVar(3);if(visible)g_paddock_modal_frame=ImGui::GetFrameCount();return visible; +} +void OlScoreboardDetails(const dkr::runtime::rollback_check::View& v,const dkr::runtime::rollback_check::Row& row,float width){ + namespace check=dkr::runtime::rollback_check;const auto a=check::assessment(v,row); + PaddockGap(8.F);OlParagraph(std::string(a.label)+" - "+a.reason,14.F,OlScoreboardColour(a),width); + if(row.host){OlParagraph("Shared race estimate: the weakest measured racer route, not a self-ping, host upload benchmark or PC performance score.",13.F,kOlSoft,width);return;} + OlParagraph("Measured round trip to the host. Not one-way latency or gameplay packet loss.",13.F,kOlSoft,width); + const auto metric=[&](const char* label,const std::string& value){ + if(ImGui::BeginTable(label,2,ImGuiTableFlags_SizingStretchProp,{width,0})){ + ImGui::TableSetupColumn("Label",ImGuiTableColumnFlags_WidthStretch,2.F);ImGui::TableSetupColumn("Value",ImGuiTableColumnFlags_WidthStretch,1.F); + ImGui::TableNextColumn();OlParagraph(label,13.F,kOlSoft,std::max(1.F,ImGui::GetContentRegionAvail().x)); + ImGui::TableNextColumn();OlParagraph(value,13.F,kOlCream,std::max(1.F,ImGui::GetContentRegionAvail().x));ImGui::EndTable(); + } + }; + metric("Typical round trip",row.replies?OlRollbackMs(row.median):"Not measured"); + metric("Slower round trips (p95)",row.replies?OlRollbackMs(row.p95):"Not measured"); + metric("Timing variation",row.replies?OlRollbackMs(row.jitter):"Not measured"); + metric("Probe replies",std::to_string(row.replies)+" / "+std::to_string(row.sent)); + if(row.repair_sent)metric("Repair-channel replies",std::to_string(row.repair_replies)+" / "+std::to_string(row.repair_sent)+"; p95 "+OlRollbackMs(row.repair_p95)); + if(row.sent>=row.replies)metric("Unanswered probes",std::to_string(row.sent-row.replies)+" after the drain period"); + if(row.blocked||row.errors)metric("Deferred / failed sends",std::to_string(row.blocked+row.errors)); + if(!row.spectator&&row.replies){ + const double headroom=check::arrival_budget(v)-(std::max(row.p95,row.repair_p95)+8.); + metric("Conservative timing margin",(headroom<0?"-":"+")+OlRollbackMs(std::abs(headroom))); + } + if(row.spectator)OlParagraph("Playback timing only. No controller port is used; checkpoint download and catch-up speed are not benchmarked.",13.F,kOlSoft,width); + OlParagraph("Policy v1: arrival timing 35%, probe reliability 35%, consistency 20%, latency 10%. Serious weaknesses cap the grade; incomplete or locally overloaded samples are not rated.",13.F,kOlSoft,width); +} +void DrawOlRollbackResults(const OnlineFrame& frame){ + namespace check=dkr::runtime::rollback_check;const auto& v=g_ol_rollback_check;auto& state=g_ol_scoreboard; + if(state.room!=v.room||state.revision!=v.revision){state={v.room,0,0,v.revision};} + const bool appearing=ImGui::IsWindowAppearing();if(appearing)state.expanded=state.focus=0; + if(!v.complete)state.expanded=state.focus=0; + const bool back=ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)&& + (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight,false)||ImGui::IsKeyPressed(ImGuiKey_Escape,false)); + const bool had_details=state.expanded!=0; + if(back&&had_details){state.focus=state.expanded;state.expanded=0;} + const float width=std::max(1.F,ImGui::GetContentRegionAvail().x); + OlParagraph("ROLLBACK CONNECTION CHECK",21.F,kOlAmber,width,true); + OlParagraph(v.complete?"Complete - host-measured results for this lobby":v.active?"Checking connections - scores appear when the test finishes":"Report no longer current",13.F,kOlSoft,width); + PaddockGap(10.F); + const bool stacked=width<400.F; + const float footer=stacked?140.F:92.F; + const float body_height=std::max(24.F,ImGui::GetContentRegionAvail().y-footer); + ImGui::BeginChild("##rollback-scoreboard-body",{width,body_height},false); + const float inner=std::max(1.F,ImGui::GetContentRegionAvail().x); + if(!v.complete){ + OlParagraph(v.status.empty()?"This report is no longer current. Run a fresh Rollback check from the lobby.":v.status,15.F,kOlAmber,inner); + if(v.active)OlParagraph("Checking... "+OlTestCountdown(frame)+". The host is measuring racer and spectator timing separately.",14.F,kOlSoft,inner); + }else{ + const auto overall=check::race_assessment(v); + const std::string score=overall.rated()?std::to_string(overall.score)+" / 10":"NOT RATED"; + const bool summary_columns=inner>=550.F&&ImGui::BeginTable("##scoreboard-summary",2,ImGuiTableFlags_SizingStretchProp,{inner,0}); + if(summary_columns){ImGui::TableSetupColumn("Score",ImGuiTableColumnFlags_WidthFixed,145.F);ImGui::TableSetupColumn("Experience",ImGuiTableColumnFlags_WidthStretch);ImGui::TableNextColumn();} + OlParagraph(score,32.F,OlScoreboardColour(overall),std::max(1.F,ImGui::GetContentRegionAvail().x),true); + if(summary_columns)ImGui::TableNextColumn(); + const float summary_width=std::max(1.F,ImGui::GetContentRegionAvail().x); + OlParagraph(std::string(overall.label)+(overall.rated()?" racing expected":""),18.F,kOlCream,summary_width,true); + OlParagraph(overall.reason,14.F,kOlSoft,summary_width); + if(summary_columns)ImGui::EndTable(); + PaddockGap(9.F); + OlParagraph("Rollback | Input delay: "+std::to_string(v.delay)+" frames "+(v.automatic?"(automatic at test start)":"(manual)")+" | Prediction: "+std::to_string(v.window)+" frames",13.F,kOlInk,inner); + OlParagraph("Configured input delay: about "+std::to_string(unsigned(std::round(v.delay*1000./30.)))+" ms at 30 Hz. More delay can cover lateness, but makes controls less immediate.",13.F,kOlSoft,inner); + bool first=true; + for(bool spectators:{false,true}){ + PaddockGap(15.F);OlParagraph(spectators?"SPECTATORS - PLAYBACK TIMING":"RACERS - CONNECTION TO HOST",15.F,kOlCream,inner,true); + unsigned count=0; + for(const auto& row:v.rows){ + if(row.spectator!=spectators)continue;++count;const auto a=check::assessment(v,row); + const float score_width=std::min(116.F,inner*.34F),text_width=std::max(1.F,inner-score_width-34.F); + const std::string name=row.name+(row.id==v.local?" (YOU)":""); + const std::string role=row.host?"HOST - SHARED RACE ESTIMATE":row.spectator?"SPECTATOR":"RACER"; + const std::string brief=row.host?"Based on measured racer routes, not a self-ping.":std::string(a.label)+" - "+a.reason; + const float name_h=OlParagraphHeight(name,16.F,text_width,true,1.25F); + const float role_h=OlParagraphHeight(role,12.F,text_width,false,1.25F); + const float reason_h=OlParagraphHeight(brief,13.F,text_width,false,1.3F); + const float height=std::max(76.F,name_h+role_h+reason_h+27.F); + ImGui::PushID(std::to_string(row.id).c_str()); + if(state.focus==row.id){ImGui::SetKeyboardFocusHere();state.focus=0;} + ImGui::PushStyleColor(ImGuiCol_Header,{0,0,0,0});ImGui::PushStyleColor(ImGuiCol_HeaderHovered,{0,0,0,0});ImGui::PushStyleColor(ImGuiCol_HeaderActive,{0,0,0,0}); + const bool pressed=ImGui::Selectable("##participant-details",state.expanded==row.id,ImGuiSelectableFlags_None,{inner,height}); + ImGui::PopStyleColor(3);const bool focused=ImGui::IsItemFocused(),hovered=ImGui::IsItemHovered(); + if(first&&appearing)ImGui::SetItemDefaultFocus();first=false; + if(pressed)state.expanded=state.expanded==row.id?0:row.id; + const auto at=ImGui::GetItemRectMin(),end=ImGui::GetItemRectMax();auto* draw=ImGui::GetWindowDrawList(); + PaddockFill(draw,at,end,PaddockRound(10.F),PaddockCol(hovered||state.expanded==row.id?0x153E50:0x0B2B3E)); + float y=at.y+10.F; + y+=OlParagraphAt(draw,{at.x+12.F,y},name,16.F,kOlCream,text_width,true,1.25F); + y+=OlParagraphAt(draw,{at.x+12.F,y+2.F},role,12.F,kOlSoft,text_width,false,1.25F)+3.F; + OlParagraphAt(draw,{at.x+12.F,y+2.F},brief,13.F,kOlSoft,text_width,false,1.3F); + const float sx=end.x-score_width-12.F; + const std::string grade=a.rated()?std::to_string(a.score)+" / 10":"-- / 10"; + const float grade_px=PaddockMeasure(OlRead(23.F,true,1.15F),grade)>score_width?19.F:23.F; + OlParagraphAt(draw,{sx,at.y+12.F},grade,grade_px,OlScoreboardColour(a),score_width,true,1.15F,true); + const float segment=(score_width-27.F)/10.F; + for(unsigned i=0;i<10;++i)draw->AddRectFilled({sx+i*(segment+3.F),at.y+44.F},{sx+i*(segment+3.F)+segment,at.y+49.F},PaddockCol(a.rated()&&i4096)OlParagraph("Local scheduling or queue pressure was observed. Retry when the lobby and connection are quiet.",13.F,kOlAmber,inner); + } + ImGui::EndChild();PaddockGap(8.F); + const bool can_test=OlCanTestConnection(frame)&&!v.active; + if(frame.view.host){const OlDisabled disabled(!can_test);if(OlButton(v.active?"Checking...":"Re-run test",OlTone::Plain,std::min(width,OlButtonWidth("Re-run test"))))OlRequestConnectionTest();} + else OlParagraph("The host can re-run this shared check.",13.F,kOlSoft,stacked?width:std::max(1.F,width-OlButtonWidth("Close")-12.F)); + if(!stacked)ImGui::SameLine(); + if(OlButton("Close",OlTone::Strong,std::min(width,OlButtonWidth("Close")))||(back&&!had_details))ImGui::CloseCurrentPopup(); + OlParagraph("D-pad: navigate A: details B: back / close",12.F,kOlSoft,width); +} diff --git a/runtime-recomp/src/game/resident_music.cpp b/runtime-recomp/src/game/resident_music.cpp new file mode 100644 index 00000000..a55ae775 --- /dev/null +++ b/runtime-recomp/src/game/resident_music.cpp @@ -0,0 +1,113 @@ +#include "observer_music.hpp" +#include "resident_music.hpp" +#include +#include +#include + +namespace dkr::mods::online::resident_music { +namespace { +using PCM=dkr::runtime::custom_music::DecodedMusic; +constexpr std::size_t MaxDecoded=64*MiB; +thread_local unsigned preparation_depth=0; +thread_local unsigned silent_preparation_depth=0; +struct Recording { + Bytes encoded; + dkr::runtime::custom_tracks::MusicCodec codec; + PCM descriptor; + std::string proof; + mutable std::mutex mutex; + mutable std::weak_ptr cached; +}; +std::mutex recordings_mutex; +std::map> recordings; + +std::shared_ptr recording(const PCM* p) { + std::lock_guard lock(recordings_mutex); + const auto at=recordings.find(p);return at==recordings.end()?nullptr:at->second.lock(); +} +std::shared_ptr acquire(const std::shared_ptr& r,bool allow_decode) { + std::lock_guard lock(r->mutex); + if(auto pcm=r->cached.lock())return pcm; + if(!allow_decode || !preparation_depth) + throw Error("Custom music was not prepared at a confirmed loading boundary."); + auto pcm=std::make_shared();std::string error; + pcm->samples.reserve(std::size_t(r->descriptor.frames)*2); + if(!dkr::runtime::custom_music::decode_bytes(r->encoded.data(),r->encoded.size(),r->codec,*pcm,error,nullptr,MaxDecoded)) + throw Error("Selected course music cannot be decoded: "+error); + if(pcm->frames!=r->descriptor.frames || pcm->sample_rate!=r->descriptor.sample_rate || + pcm->samples.size()!=pcm->frames*2 || sha256_pcm_le(pcm->samples)!=r->proof) + throw Error("Selected course music differs from its pre-match decoded proof."); + r->cached=pcm; + std::fprintf(stderr,"[online-mods][music-residency] selected-frames=%llu rate=%u decoded-bytes=%zu boundary-only=1\n", + static_cast(pcm->frames),pcm->sample_rate,pcm->samples.size()*sizeof(std::int16_t)); + return pcm; +} +} +PreviewScope::PreviewScope(bool enabled)noexcept:enabled_(enabled){if(enabled_)++silent_preparation_depth;} +PreviewScope::~PreviewScope(){if(enabled_)--silent_preparation_depth;} + +PreparationScope::PreparationScope(bool enabled,bool audible)noexcept:enabled_(enabled),silent_(enabled&&!audible){if(enabled_)++preparation_depth;if(silent_)++silent_preparation_depth;} +PreparationScope::~PreparationScope(){if(silent_)--silent_preparation_depth;if(enabled_)--preparation_depth;} + +Prepared prepare(Bytes encoded,const dkr::runtime::custom_tracks::MusicInfo& info,std::stop_token stop) { + if(!info.frames || info.frames>MaxDecoded/4 || info.sample_rate<8000 || info.sample_rate>192000 || + info.frames>std::uint64_t(info.sample_rate)*15*60) + throw Error("A recording exceeds the supported 15-minute or 64 MiB per-song limit."); + auto owner=std::make_shared();owner->encoded=std::move(encoded);owner->codec=info.codec; + PCM pcm;pcm.samples.reserve(std::size_t(info.frames)*2);std::string error; + std::atomic cancel{stop.stop_requested()};std::stop_callback cancellation(stop,[&]{cancel=true;}); + if(!dkr::runtime::custom_music::decode_bytes(owner->encoded.data(),owner->encoded.size(),owner->codec,pcm,error,&cancel,MaxDecoded)) + throw Error(error); + if(stop.stop_requested())throw Error("Online music preparation cancelled."); + if(pcm.frames!=info.frames || pcm.sample_rate!=info.sample_rate || pcm.samples.size()!=pcm.frames*2) + throw Error("The decoded frame count or sample rate differs from the track manifest."); + owner->descriptor.frames=pcm.frames;owner->descriptor.sample_rate=pcm.sample_rate; + owner->proof=sha256_pcm_le(pcm.samples); + std::shared_ptr metadata(owner,&owner->descriptor); + { + std::lock_guard lock(recordings_mutex); + for(auto it=recordings.begin();it!=recordings.end();)it=it->second.expired()?recordings.erase(it):std::next(it); + if(recordings.size()>=1024)throw Error("Too many live immutable music recordings."); + recordings[metadata.get()]=owner; + } + // pcm is released here. Only the encoded recording, shape and proof live + // until a confirmed course load. Identical recordings share this owner. + return {std::move(metadata),owner->proof}; +} +State::State(std::shared_ptr source,bool revision80):sources_(std::move(source)), + revision80_(revision80),active_(activate(UINT32_MAX,false)),native_(active_,revision80){} +std::shared_ptr State::activate(unsigned level,bool allow_decode)const { + auto next=std::make_shared();if(!sources_)return next; + // Copy ONLY the selected row. No mutation of a shared immutable library, + // and no accidental use of a metadata-only descriptor by the mixer. + const auto at=sources_->find(level);if(at==sources_->end())return next; + auto song=at->second; + if(song.pcm)if(const auto r=recording(song.pcm.get()))song.pcm=acquire(r,allow_decode || dkr::spectator_admission::preparing_music()); + if(song.pcm && song.pcm->samples.size()!=song.pcm->frames*2) + throw Error("Selected custom music has no resident samples."); + next->emplace(level,std::move(song));return next; +} +void State::load(CharacterMenuMemory& guest,unsigned level) { + // Track Select's silent background loader must not decode a whole song + // each time the cursor moves. Its checkpoint has no active custom song. + // The actual race continuation loads it before the next epoch is ready. + auto prepared=silent_preparation_depth?std::shared_ptr(std::make_shared()):activate(level,true); + native_.load(guest,level); // Restore any native sequence row first. + MusicState next(prepared,revision80_);next.load(guest,level); + active_=std::move(prepared);native_=std::move(next); +} +State State::stage_checkpoint(View bytes)const { + CheckpointReader reader(bytes);if(reader.u32()!=1)throw Error("Unsupported custom music checkpoint."); + const auto level=reader.u32(); + // Validate the complete music checkpoint against admitted metadata before + // spending decoder work. This reads no guest RAM and changes no state. + if(dkr::spectator_admission::preparing_music()) + (void)MusicState(sources_,revision80_).stage_checkpoint(bytes); + State next=*this; + next.active_=activate(level,false); // Weak-cache hit only; NEVER decode during rollback. + next.native_=MusicState(next.active_,revision80_).stage_checkpoint(bytes); + if(dkr::spectator_admission::preparing_music()) + std::fprintf(stderr,"[spectator][music] checkpoint-ready level=%u active=%u\n",level,unsigned(!next.active_->empty())); + return next; +} +} diff --git a/runtime-recomp/src/game/resident_music.hpp b/runtime-recomp/src/game/resident_music.hpp new file mode 100644 index 00000000..8a29f72a --- /dev/null +++ b/runtime-recomp/src/game/resident_music.hpp @@ -0,0 +1,60 @@ +#pragma once +#include "mods/online_mod_music.hpp" +#include + +namespace dkr::mods::online::resident_music { +// Prepared recordings use an aliasing metadata pointer: the owning object +// pins the verified encoded bytes, NOT a library-wide collection of PCM. +struct Prepared { + std::shared_ptr metadata; + std::string pcm_sha256; +}; +Prepared prepare(Bytes encoded,const dkr::runtime::custom_tracks::MusicInfo&,std::stop_token); + +// Only ordinary course loading or the confirmed owned scene continuation may +// open a decoder. Audio mixing and checkpoint replay never grant this scope. +class PreparationScope final { +public: + explicit PreparationScope(bool enabled=true,bool audible=true) noexcept; + ~PreparationScope(); + PreparationScope(const PreparationScope&)=delete; + PreparationScope& operator=(const PreparationScope&)=delete; +private: + bool enabled_,silent_; +}; + +// A preview can suppress music without granting permission to decode. +// Kept local to the course-prepare service so a failed guest continuation +// cannot leave thread-local suppression active after its native unwind. +class PreviewScope final { +public: + explicit PreviewScope(bool enabled) noexcept; + ~PreviewScope(); + PreviewScope(const PreviewScope&)=delete; + PreviewScope& operator=(const PreviewScope&)=delete; +private: + bool enabled_; +}; + +// Delegates the existing integer mixer/checkpoint ABI without changing any +// accepted MusicSong/MusicLibrary, RuntimeResources or ModWorld header layout. +class State { +public: + State(std::shared_ptr,bool revision80); + void load(CharacterMenuMemory&,unsigned level); + void loaded(CharacterMenuMemory& g,recomp_context& c){native_.loaded(g,c);} + void started(CharacterMenuMemory& g){native_.started(g);} + bool volume(CharacterMenuMemory& g,recomp_context& c){return native_.volume(g,c);} + void tick(CharacterMenuMemory& g){native_.tick(g);} + void mix_s16le(std::span b,unsigned r){native_.mix_s16le(b,r);} + void render(float* b,std::size_t n,unsigned r){native_.render(b,n,r);} + Bytes checkpoint()const{return native_.checkpoint();} + State stage_checkpoint(View)const; +private: + std::shared_ptr sources_; + bool revision80_; + std::shared_ptr active_; + MusicState native_; + std::shared_ptr activate(unsigned level,bool allow_decode)const; +}; +} diff --git a/runtime-recomp/src/game/reusable_flow_map.hpp b/runtime-recomp/src/game/reusable_flow_map.hpp new file mode 100644 index 00000000..d2889300 --- /dev/null +++ b/runtime-recomp/src/game/reusable_flow_map.hpp @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::surgical { +// Dense samples plus an open-addressed index. No sample survives clear(), but +// storage does. At most 16384 entries, <= 50% index occupancy. No node allocator +// activity once the scene's high-water mark has been reached. +template class ReusableFlowMap { + static_assert(std::is_same_v); + static constexpr std::size_t maximum=16384; + struct Entry {Key first;Value second;}; + std::vector entries_; + std::vector slots_; + static std::size_t bucket(Key key,std::size_t mask) noexcept { + key^=key>>16;key*=0x7feb352dU;key^=key>>15; + return std::size_t(key)&mask; + } + void index(std::vector& slots,std::size_t entry) const noexcept { + const auto mask=slots.size()-1; + auto at=bucket(entries_[entry].first,mask); + while(slots[at])at=(at+1)&mask; + slots[at]=std::uint32_t(entry+1); + } +public: + auto end() noexcept {return entries_.end();} + std::size_t size() const noexcept {return entries_.size();} + auto find(Key key) noexcept { + if(slots_.empty())return end(); + const auto mask=slots_.size()-1; + auto at=bucket(key,mask); + for(std::size_t probes=0;probes=maximum)throw std::length_error("flow sample storage bound exceeded"); + if(slots_.empty() || (entries_.size()+1)*2>slots_.size()) { + const auto capacity=slots_.empty()?256U:slots_.size()*2; + std::vector next(capacity,0); + for(std::size_t i=0;i +#include +#include +#include +#include +#include +#include +#include "assessment.hpp" + +namespace dkr::runtime::rollback_check { +namespace { +using Type=net::protocol::MessageType; +constexpr unsigned MaxPeers=11,MaxSamples=512; +constexpr std::size_t QueueLimit=64*1024; +constexpr auto Cadence=std::chrono::nanoseconds(1000000000/30); +constexpr auto ReplicaCadence=std::chrono::milliseconds(50); +constexpr auto RepairCadence=std::chrono::milliseconds(50); +enum Kind:std::uint8_t {Hello=1,HelloAck,Probe,Echo,Result,Receipt,Cancel,ReportRow}; +struct Pending {unsigned sequence=0,lane=0;std::uint64_t at=0;bool replied=false;}; +struct Measure { + Row row; + std::vector pending; + std::vector main,repair; + unsigned next=1; + bool receipted=false; unsigned report_next=0; + Clock::time_point main_due{},repair_due{},hello_due{},result_due{}; +}; +struct State { + std::mutex mutex; + Context context;View view; + std::map measures; + std::uint32_t test=0; + std::uint64_t nonce=0; + Clock::time_point started{},measure_at{},drain_at{},finish_at{},retry_until{},last_start{}; + unsigned client_packets=0;std::array,2> client_seen{}; + Clock::time_point client_hello{},receipt_at{},report_expected_at{}; + std::vector report_rows; + std::array,12> report_parts{}; + std::array received_rows{}; + unsigned report_cursor=0; +}; +struct CheckRegistry {std::mutex mutex;std::map> states;}; +CheckRegistry& check_registry(){static auto* value=new CheckRegistry;return *value;} +std::shared_ptr state(const void* key){ + auto& registry=check_registry();std::scoped_lock lock(registry.mutex); + if(auto it=registry.states.find(key);it!=registry.states.end())return it->second; + if(registry.states.size()>=8)registry.states.erase(registry.states.begin()); + auto next=std::make_shared();registry.states[key]=next;return next; +} +std::uint64_t micros(Clock::time_point now){return std::uint64_t(std::chrono::duration_cast(now.time_since_epoch()).count());} +void put(std::vector& b,unsigned at,std::uint64_t value,unsigned size){for(unsigned i=0;i>(8*i));} +std::uint64_t get(std::span b,unsigned at,unsigned size){std::uint64_t n=0;for(unsigned i=0;i wire(const State& s,Kind kind,unsigned sequence,std::uint64_t at,unsigned lane=0,const Measure* m=nullptr){ + // Reuse the existing authenticated preflight envelope, not game-input or + // Party Runtime messages. Old echo-only clients cannot manufacture HelloAck. + const unsigned size=kind==Probe||kind==Echo?128:96; + std::vector b(size,0); + b[0]='D';b[1]='K';b[2]='R';b[3]='C';b[4]=2;b[5]=kind;b[6]=s.context.party?1:0;b[7]=std::uint8_t(lane); + put(b,8,s.nonce,8);b[16]=std::uint8_t(s.view.window);b[17]=std::uint8_t(s.view.delay);b[18]=std::uint8_t(s.view.automatic); + if(kind==Hello){const auto finish=micros(s.finish_at);put(b,20,finish>at?std::min(10000,(finish-at)/1000):0,4);} + if(kind==Result&&m){ + const auto& r=m->row;b[19]=std::uint8_t(r.supported); + put(b,20,r.sent,2);put(b,22,r.replies,2);put(b,24,r.repair_sent,2);put(b,26,r.repair_replies,2); + put(b,28,std::uint32_t(std::ceil(r.median*1000)),4);put(b,32,std::uint32_t(std::ceil(r.p95*1000)),4); + put(b,36,std::uint32_t(std::ceil(r.jitter*1000)),4);put(b,40,std::uint32_t(std::ceil(r.repair_p95*1000)),4); + put(b,44,r.blocked,4);put(b,48,r.errors,4);put(b,52,s.view.scheduler_gaps,4); + put(b,56,s.view.peak_queued,4);put(b,60,s.view.final_queued,4); + } + return net::protocol::encode_preflight_probe({s.test,sequence,at,kind==HelloAck||kind==Echo||kind==Receipt,std::move(b)}); +} +void publish(State& s){ + s.view.rows.clear();for(const auto& p:s.context.members){ + if(p.id==s.context.host_member){Row r;r.id=p.id;r.name=p.name;r.spectator=p.spectator;r.host=true;s.view.rows.push_back(r);continue;} + for(const auto& [id,m]:s.measures)if(m.row.id==p.id){s.view.rows.push_back(m.row);break;} + } +} +double percentile(std::vector v,double p){if(v.empty())return 0;std::sort(v.begin(),v.end());return v[std::size_t(std::ceil(v.size()*p))-1];} +void calculate(Measure& m){ + m.row.median=percentile(m.main,.5);m.row.p95=percentile(m.main,.95);m.row.repair_p95=percentile(m.repair,.95); + double total=0;for(unsigned i=1;i1?total/double(m.main.size()-1):0; + // Host and clients grade identical wire-precision measurements. + for(auto* value:{&m.row.median,&m.row.p95,&m.row.jitter,&m.row.repair_p95})*value=std::ceil(*value*1000.)/1000.; +} +void cancel(State& s,std::string reason){s.view.active=false;s.view.complete=false;s.view.phase=Phase::Cancelled;s.view.status=std::move(reason);s.view.remaining_ms=0;s.retry_until={};} +void reset(State& s,const Context& c){s.context=c;s.view={};s.view.room=c.room;s.view.local=c.local_member;s.view.host_id=c.host_member;s.view.host=c.host;s.view.party=c.party;s.measures.clear();s.test=0;s.nonce=0;s.retry_until={};s.report_rows.clear();s.report_parts={};s.received_rows={};s.report_cursor=0;s.receipt_at=s.report_expected_at={};} +#include "scoreboard_wire.inl" +} +bool diagnostic_type(Type type){switch(type){ + case Type::PreflightBegin:case Type::PreflightResult:case Type::PreflightControlProbe:case Type::PreflightAuthorityProbe: + case Type::PreflightRealtimeProbe:case Type::PreflightReplicaProbe:case Type::PreflightCheckpointProbe:return true; + default:return false;}} +void forget(const void* key){auto& registry=check_registry();std::scoped_lock lock(registry.mutex);registry.states.erase(key);} +View snapshot(const void* key){auto s=state(key);std::scoped_lock lock(s->mutex);return s->view;} +bool active(const void* key){ + std::shared_ptr s;{auto& registry=check_registry();std::scoped_lock lock(registry.mutex);auto it=registry.states.find(key);if(it==registry.states.end())return false;s=it->second;} + std::scoped_lock lock(s->mutex);return s->view.active; +} +bool engaged(const void* key){ + std::shared_ptr s;{auto& registry=check_registry();std::scoped_lock lock(registry.mutex);auto it=registry.states.find(key);if(it==registry.states.end())return false;s=it->second;} + std::scoped_lock lock(s->mutex);return s->view.active||s->view.complete; +} +bool request(const void* key,const Context& c,std::string& error){ + auto p=state(key);auto& s=*p;std::scoped_lock lock(s.mutex);const auto now=Clock::now(); + if(!c.host||!c.idle){error="Only the host can check Rollback connections while waiting in the lobby.";return false;} + if(s.view.active){error="A Rollback connection check is already running.";return false;} + if(c.members.size()>12||!c.local_member||!c.host_member||!report_member(c,c.local_member)||!report_member(c,c.host_member)||c.members.size()!=c.peers.size()+1){error="Waiting for the complete lobby roster. Try again in a moment.";return false;} + if(c.peers.empty()||c.peers.size()>MaxPeers){error="At least one remote participant must join before checking Rollback.";return false;} + if(s.last_start.time_since_epoch().count()&&now-s.last_startQueueLimit){error="The connection is still transferring lobby resources. Wait for its queues to settle.";return false;} + const auto revision=s.view.revision;reset(s,c);s.view.revision=revision; + s.last_start=s.started=now;s.measure_at=now+std::chrono::seconds(2);s.drain_at=s.measure_at+std::chrono::seconds(6);s.finish_at=s.drain_at+std::chrono::seconds(2); + s.nonce=micros(now);s.test=std::uint32_t(s.nonce^(s.nonce>>32));if(!s.test)s.test=1; + s.view.active=true;s.view.phase=Phase::Negotiating;s.view.remaining_ms=10000;s.view.delay=c.delay;s.view.window=c.window;s.view.automatic=c.automatic; + s.view.status="Negotiating the Rollback diagnostic with every participant..."; + for(const auto& peer:c.peers){auto& m=s.measures[peer.id];m.row.id=peer.member;m.row.name=peer.name;m.row.spectator=peer.spectator;m.pending.reserve(MaxSamples);m.main.reserve(200);m.repair.reserve(125);} + publish(s);error.clear();return true; +} +void service(const void* key,const Context& c,const Send& send,Clock::time_point now){ + auto p=state(key);auto& s=*p;std::scoped_lock lock(s.mutex); + if(s.context.room!=c.room||s.context.local!=c.local){reset(s,c);return;} + if((s.view.active||s.view.complete)&&(!same(s.context,c)||!c.idle)){ + if(s.view.active&&c.host)for(const auto& peer:c.peers)send(peer.id,Type::PreflightControlProbe,wire(s,Cancel,1,micros(now))); + cancel(s,"The lobby, roles, settings or launch state changed. Run a fresh Rollback check.");return; + } + if(!s.view.active){ + if(c.host&&s.view.complete&&now(0,std::chrono::duration_cast(s.finish_at-now).count())); + if(!c.host){ + s.view.remaining_ms=unsigned(std::max(0,std::chrono::duration_cast(s.report_expected_at-now).count())); + if(now>=s.report_expected_at){s.view.phase=Phase::Draining;s.view.status="Receiving the complete connection scoreboard...";} + if(now>=s.finish_at)cancel(s,"The host's Rollback check expired without a final report. Retry from the lobby.");return;} + s.view.peak_queued=std::max(s.view.peak_queued,c.queued); + if(now=m.hello_due){send(id,Type::PreflightControlProbe,wire(s,Hello,1,micros(now)));m.hello_due=now+std::chrono::milliseconds(400);} + } else if(nowspectator))continue; + auto& due=lane?m.repair_due:m.main_due; + const auto interval=lane?std::chrono::duration_cast(RepairCadence):peer->spectator?std::chrono::duration_cast(ReplicaCadence):std::chrono::duration_cast(Cadence); + if(!due.time_since_epoch().count())due=s.measure_at; + if(now=Cadence*2)++s.view.scheduler_gaps; + due=now-due>=interval?now+interval:due+interval; + if(c.queued>QueueLimit){++m.row.blocked;continue;} + if(m.pending.size()>=MaxSamples)continue; + const unsigned sequence=m.next++;const auto at=micros(now); + if(send(id,traffic(c,*peer,lane),wire(s,Probe,sequence,at,lane))){m.pending.push_back({sequence,lane,at,false});if(lane)++m.row.repair_sent;else ++m.row.sent;} + else ++m.row.blocked; + } + } + } else if(now bytes,const Send& send,Clock::time_point now){ + if(!diagnostic_type(type)||!c.idle)return; + net::protocol::PreflightProbePayload packet;std::string ignored; + if(!net::protocol::decode_preflight_probe(bytes,packet,ignored))return; + const auto& b=packet.padding; + if((b.size()!=96&&b.size()!=128)||b[0]!='D'||b[1]!='K'||b[2]!='R'||b[3]!='C'||b[4]!=2||b[5]ReportRow||b[6]!=unsigned(c.party)||b[7]>1||!get(b,8,8))return; + const auto kind=Kind(b[5]); + if((kind==Probe||kind==Echo)!=(b.size()==128))return; + if(packet.echo!=(kind==HelloAck||kind==Echo||kind==Receipt))return; + if(kind!=Probe&&kind!=Echo&&type!=Type::PreflightControlProbe)return; + auto p=state(key);auto& s=*p;std::scoped_lock lock(s.mutex); + if(s.context.room!=c.room||s.context.local!=c.local)reset(s,c); + if(c.host){ + if(!same(s.context,c)||s.test!=packet.test_id||s.nonce!=get(b,8,8))return; + auto it=s.measures.find(sender);if(it==s.measures.end())return;auto& m=it->second; + if(kind==HelloAck&&s.view.active&&nowreplied||micros(now)at)return; + found->replied=true;const double ms=double(micros(now)-found->at)/1000.; + if(b[7]){m.repair.push_back(ms);++m.row.repair_replies;}else{m.main.push_back(ms);++m.row.replies;} + } + publish(s);return; + } + if(sender!=c.host_id)return; // Only the admitted authenticated host can ask. + if(kind==Hello){ + if(get(b,20,4)>10000||c.members.empty()||c.members.size()>12||!report_member(c,c.local_member)||!report_member(c,c.host_member))return; + if(b[16]!=c.window||b[18]!=unsigned(c.automatic)||(!c.automatic&&b[17]!=c.delay))return; + if(s.test!=packet.test_id||s.nonce!=get(b,8,8)){ + if(s.client_hello.time_since_epoch().count()&&now-s.client_hello=s.finish_at)return; + if(kind==Probe&&s.view.active){ + const bool watching=c.local_watching; + const Type expected=watching?Type::PreflightReplicaProbe:c.party?Type::PreflightControlProbe:b[7]?Type::PreflightAuthorityProbe:Type::PreflightRealtimeProbe; + if(type!=expected||(b[7]&&(watching||c.party))||packet.sequence>MaxSamples||s.client_seen[b[7]][packet.sequence]||++s.client_packets>MaxSamples+8)return; + s.client_seen[b[7]][packet.sequence]=true;send(sender,type,wire(s,Echo,packet.sequence,packet.sent_time_us,b[7])); + }else if(kind==ReportRow&&(s.view.active||s.view.complete)){ + receive_report(s,c,packet,send,now); + } + else if(kind==Cancel&&s.view.active)cancel(s,"The host cancelled the Rollback check because the lobby changed."); +} +} diff --git a/runtime-recomp/src/game/rollback_check.hpp b/runtime-recomp/src/game/rollback_check.hpp new file mode 100644 index 00000000..3979b472 --- /dev/null +++ b/runtime-recomp/src/game/rollback_check.hpp @@ -0,0 +1,48 @@ +#pragma once +#include "netplay/netplay_protocol.hpp" +#include "netplay/session_transport.hpp" +#include +#include +#include +#include + +// Diagnostic-only traffic. No controller samples, guest memory, renderer, save +// or rollback driver are exposed here. DirectSession owns authentication/send. +namespace dkr::runtime::rollback_check { +namespace net = netplay; +using Clock = std::chrono::steady_clock; +using Id = std::uint64_t; +struct Peer { Id id=0; std::string name; bool spectator=false,ready=false; Id member=0; }; +struct Context { + Id room=0,local=0,host_id=0,roster=0; + bool host=false,party=false,idle=false,automatic=false,local_watching=false; + unsigned delay=0,window=0; + std::size_t queued=0; + std::vector peers,members; Id local_member=0,host_member=0; +}; +enum class Phase { Idle, Negotiating, Sampling, Draining, Complete, Cancelled }; +struct Row { + Id id=0; std::string name; bool spectator=false,supported=false,host=false; + unsigned sent=0,replies=0,repair_sent=0,repair_replies=0; + double median=0,p95=0,jitter=0,repair_p95=0; + unsigned blocked=0,errors=0; +}; +struct View { + Id room=0,local=0,host_id=0; Phase phase=Phase::Idle; bool active=false,complete=false,host=false,party=false,automatic=false; + unsigned revision=0,remaining_ms=0,delay=0,window=0,scheduler_gaps=0; + std::size_t peak_queued=0,final_queued=0; + std::string status; + std::vector rows; +}; +// False means no packet was accepted by the transport; never retry a stale +// synthetic input or fill the game's critical/reliable application queues. +using Send = std::function)>; +bool request(const void*,const Context&,std::string& error); +void service(const void*,const Context&,const Send&,Clock::time_point); +void receive(const void*,const Context&,Id,net::protocol::MessageType,std::span,const Send&,Clock::time_point); +bool diagnostic_type(net::protocol::MessageType); +View snapshot(const void*); +bool active(const void*); +bool engaged(const void*); +void forget(const void*); +} diff --git a/runtime-recomp/src/game/rollback_policy.hpp b/runtime-recomp/src/game/rollback_policy.hpp new file mode 100644 index 00000000..4a4d944d --- /dev/null +++ b/runtime-recomp/src/game/rollback_policy.hpp @@ -0,0 +1,50 @@ +#pragma once +#include "netplay/netplay_types.hpp" + +namespace dkr::runtime::netplay::rollback_policy { +// Public Rollback is the existing owned replay engine, wire value 2. Do not +// reuse the legacy enum named Rollback (wire value 0, host prediction). +inline constexpr auto mode = SynchronizationMode::ExperimentalRollback; +inline constexpr int maximum_players = 4; +inline constexpr int prediction_window = 14; +inline constexpr int input_delay = 6; +inline constexpr bool automatic_delay = false; +inline constexpr bool record_replay = false; +inline constexpr bool development = false; +inline constexpr bool spectators = true; +inline constexpr const char* unsupported_lobby = + "This lobby uses a sync mode that is no longer supported. Ask the host to update DKR-R and create a Rollback lobby."; +static_assert(static_cast(mode) == 2); + +constexpr bool supported(SynchronizationMode value) { return value == mode; } +constexpr const char* name(SynchronizationMode value) { + return supported(value) ? "Rollback" : "Unsupported sync mode"; +} +constexpr const char* admission_error(SynchronizationMode value, + bool adapter_available = false) { + if (!supported(value)) + return unsupported_lobby; + if (!adapter_available) + return "Rollback is unavailable in this build. Install the complete DKR-R package on every player's machine."; + return nullptr; +} +constexpr Rules default_rules() { + Rules result; + result.host_control = HostControlPolicy::GuidedUntilCharacterSelect; + result.maximum_players = maximum_players; + result.synchronization = mode; + result.rollback_window = prediction_window; + result.automatic_input_delay = automatic_delay; + result.manual_input_delay = input_delay; + result.record_replay = record_replay; + result.development = development; + return result; +} +static_assert(default_rules().synchronization == mode); +static_assert(default_rules().host_control == HostControlPolicy::GuidedUntilCharacterSelect); +static_assert(default_rules().maximum_players == 4); +static_assert(default_rules().rollback_window == 14); +static_assert(default_rules().manual_input_delay == 6 && kMaximumInputDelayFrames >= 6); +static_assert(!default_rules().automatic_input_delay && !default_rules().record_replay); +static_assert(!default_rules().development); +} diff --git a/runtime-recomp/src/game/rt64_renderer.cpp b/runtime-recomp/src/game/rt64_renderer.cpp index a03a48f5..76953dea 100644 --- a/runtime-recomp/src/game/rt64_renderer.cpp +++ b/runtime-recomp/src/game/rt64_renderer.cpp @@ -1,4 +1,9 @@ +#include "render_provenance.hpp" +#include "vehicle_binding.hpp" #include "rt64_renderer.hpp" +#include "camera_rt64.hpp" +#include "revision_addresses.hpp" +#include "netplay/online_input_broker.hpp" #include "render_power_policy.hpp" #include "performance_trace.hpp" #include "water_performance.hpp" @@ -15,6 +20,7 @@ #include "game_registration.hpp" #include "presentation_identity.hpp" +#include "detail_contract.hpp" #include "renderer_snapshot.hpp" #include "local_scenery_rt64.hpp" #if defined(DKR_LOCAL_MODEL_DETAIL) @@ -35,6 +41,7 @@ #include "runtime_texture_packs.hpp" #include "startup_performance.hpp" #include "vi_presentation_policy.hpp" +#include "performance_switches.hpp" #include "runtime_platform.hpp" #include "runtime_ui.hpp" #if defined(_WIN32) @@ -583,6 +590,16 @@ dkr::runtime::RT64Renderer::RT64Renderer( // packs on its first presentation, even if the retired renderer had already // consumed this library generation. Do not change users' enabled settings. dkr::runtime::texture_packs::renderer_started(); + std::fprintf(stderr,"[perf][surgical-features] reusable-flow=%u texture-index=%u mip-scratch=%u monotonic-pacer=%u; quality, guest clocks and GPU fences preserved\n", + unsigned(surgical::flow_reuse()),unsigned(surgical::texture_index()),unsigned(surgical::mip_scratch()),unsigned(surgical::monotonic_pacer())); + if(application_->textureCache && application_->device) { + constexpr std::uint64_t MiB=1024ULL*1024; + const auto dedicated=application_->device->getDescription().dedicatedVideoMemory; + const auto budget=std::clamp(dedicated/2,512*MiB,3072*MiB); + application_->textureCache->setReplacementPoolMaxSize(budget); + std::fprintf(stderr,"[texture-packs][budget] effective=%llu MiB; scene and mip-upload headroom reserved\n", + static_cast(budget/MiB)); + } #if defined(DKR_ENHANCED_WATER) const bool water_ray_queries=application_->device && application_->device->getCapabilities().rayQuery; water::enhanced::hardware_ray_capability.store(water_ray_queries ? 1 : 0); @@ -718,11 +735,49 @@ void dkr::runtime::RT64Renderer::send_dl(const OSTask* task, // them. DKR relies on that during scene transitions and can free texture // allocations while the host graphics queue is still pending. Parse this // task from the submission-time snapshot, then restore live RDRAM for VI. - RendererSnapshotScope snapshot_scope(application_->core.RDRAM, - application_->state->RDRAM, - rdram_snapshot); dkr::runtime::presentation::TaskIdentityScope identity_scope( rdram_snapshot, task->t.data_ptr); + const bool detail_requested=enhancements::maximum_detail_enabled()&&!detail::ordinary_events().empty(); + std::uint8_t* decode_bytes=rdram_snapshot; + const bool camera_requested=bool(camera::activation()); + const auto car_resources=mods::vehicles::render_resources(); + const bool cars_requested=car_resources&&!car_resources->library.packages.empty(); + { // render repair: pristine submission, reusable private decode arena + if(camera_decoder_.size()!=DKR_CAMERA_KERNEL_BYTES)camera_decoder_.resize(DKR_CAMERA_KERNEL_BYTES); + std::memcpy(camera_decoder_.data(),rdram_snapshot,0x800000); + decode_bytes=camera_decoder_.data(); + } + RendererSnapshotScope snapshot_scope(application_->core.RDRAM, + application_->state->RDRAM, + decode_bytes); + render_provenance::Scope provenance({rdram_snapshot,0x800000}, + {decode_bytes,decode_bytes==rdram_snapshot?0x800000U:camera_decoder_.size()},task->t.data_ptr); + static std::atomic_flag provenance_notice=ATOMIC_FLAG_INIT; + if(!provenance_notice.test_and_set(std::memory_order_relaxed)) + std::fprintf(stderr,"[render][decode-isolation] pristine submission; reusable private workspace; 8 MiB copy per source task\n"); + + if(!camera_pass_)camera_pass_=std::make_unique(); + camera::FrameStamp camera_frame{}; + camera_frame.scene=presentation::task_scene_generation(); + camera_frame.time=float(presentation::task_presentation_time()); + camera_frame.frame=std::uint32_t(std::max(0.F,camera_frame.time)*30.F); + camera_frame.dt=1.F/30.F; + int camera_width=0,camera_height=0; + if(auto* w=static_cast(platform::sdl_window()))SDL_GetWindowSize(w,&camera_width,&camera_height); + if(application_->userConfig.aspectRatio==RT64::UserConfiguration::AspectRatio::Expand&&camera_width>0&&camera_height>0) + camera_frame.aspect=float(camera_width)/camera_height; + else if(application_->userConfig.aspectRatio==RT64::UserConfiguration::AspectRatio::Manual) + camera_frame.aspect=float(application_->userConfig.aspectTarget); + const auto camera_online=netplay::online_input_broker().view(); + camera_frame.online=camera_online.enabled; + if(camera_requested)camera_pass_->prepare({rdram_snapshot,0x800000},camera_decoder_,camera_frame,{}, + camera_online.local_slot,unsigned(platform::online_input_profile())); + // A submission snapshot is only 8 MiB. The existing private camera/car + // workspace supplies the disjoint 16-MiB decode arena; never write beyond + // the snapshot, live RDRAM, or the canonical guest's 8-MiB ownership. + static thread_local LocalModelRT64Pass ordinary_models; + ordinary_models.prepare(detail_requested?detail::ordinary_events():std::span{}, + detail_requested?std::span{camera_decoder_}:std::span{},presentation::task_scene_generation()); // DKR authors a new visual state at 30 Hz. Deriving that source cadence // from delayed VI history creates a positive feedback loop under load: // one late workload is misread as 20/15 Hz, RT64 schedules three or four @@ -743,16 +798,16 @@ void dkr::runtime::RT64Renderer::send_dl(const OSTask* task, #if defined(__ANDROID__) { android_frame_budget::Scope decodeTiming(android_frame_budget::Region::Decoder); - f3ddkr_.process(*application_, *task); + f3ddkr_.process(*application_, *task,nullptr,nullptr,detail_requested?&ordinary_models:nullptr,camera_requested?camera_pass_.get():nullptr); } android_frame_budget::report(); #else - f3ddkr_.process(*application_, *task); + f3ddkr_.process(*application_, *task,nullptr,nullptr,detail_requested?&ordinary_models:nullptr,camera_requested?camera_pass_.get():nullptr); #endif record_track_performance(rdram_snapshot, start); } else { track_capture_.count = 0; // A later opt-in capture must not span the idle gap. - f3ddkr_.process(*application_, *task); + f3ddkr_.process(*application_, *task,nullptr,nullptr,detail_requested?&ordinary_models:nullptr,camera_requested?camera_pass_.get():nullptr); } #if defined(__ANDROID__) } catch (const dkr::runtime::graphics_health::TaskAborted&) { @@ -1272,6 +1327,13 @@ void dkr::runtime::RT64Renderer::present_owned( const auto present_cursor=application_->presentQueue->writeCursor; {std::scoped_lock lock(presentation_mutex_); presentation::TaskIdentityScope identity{owned_markers,owned_matrices,owned_shadows,scene,modern,double(lease->descriptor().frame)/30.0}; + if(!camera_pass_)camera_pass_=std::make_unique(); + camera::FrameStamp camera_frame{};camera_frame.scene=scene; + camera_frame.epoch=std::uint32_t(lease->epoch());camera_frame.frame=d.frame; + camera_frame.time=float(d.frame)/30.F;camera_frame.dt=1.F/30.F; + camera_frame.aspect=cover*4.F/3.F;camera_frame.online=true; + camera_pass_->prepare(lease->bytes(),workspace->bytes(),camera_frame,lease->draw_events(), + online_view_session_.owner,unsigned(platform::online_input_profile()),owned_decode_scratch_->online.focused()); application_->state->setRefreshRate(30); {netplay::experimental::performance::Scope timing(netplay::experimental::performance::Stage::Process); #if defined(__ANDROID__) @@ -1281,8 +1343,10 @@ void dkr::runtime::RT64Renderer::present_owned( f3ddkr_.process(*application_,task,&owned_decode_scratch_->local_scenery,&owned_decode_scratch_->online #if defined(DKR_LOCAL_MODEL_DETAIL) ,&owned_decode_scratch_->local_models +#else + ,nullptr #endif - ); + ,camera_pass_.get()); #if defined(__ANDROID__) } catch (const graphics_health::TaskAborted&) { // DrainOnExit retains the immutable image, decode workspace and diff --git a/runtime-recomp/src/game/rt64_renderer.hpp b/runtime-recomp/src/game/rt64_renderer.hpp index 457cb2c4..1df7c4fc 100644 --- a/runtime-recomp/src/game/rt64_renderer.hpp +++ b/runtime-recomp/src/game/rt64_renderer.hpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace RT64 { struct Application; @@ -20,6 +21,7 @@ namespace dkr::runtime::netplay::experimental {class RenderSnapshot;class Presen namespace dkr::runtime { struct OwnedDecodeScratch; +namespace camera {class RenderPass;} class RT64Renderer final : public ultramodern::renderer::RendererContext { public: @@ -58,6 +60,8 @@ class RT64Renderer final : public ultramodern::renderer::RendererContext { std::mutex presentation_mutex_; std::unique_ptr application_; F3DDKRRT64Bridge f3ddkr_; + std::unique_ptr camera_pass_; + std::vector camera_decoder_; std::uint64_t present_count_ = 0; std::uint64_t interpolated_present_count_ = 0; presentation_counter::Snapshot completed_presentations_{}; diff --git a/runtime-recomp/src/game/runtime_camera_tuning_ui.inl b/runtime-recomp/src/game/runtime_camera_tuning_ui.inl index eb70786a..53655d77 100644 --- a/runtime-recomp/src/game/runtime_camera_tuning_ui.inl +++ b/runtime-recomp/src/game/runtime_camera_tuning_ui.inl @@ -1,483 +1,126 @@ -// Included by runtime_ui.cpp inside its UI namespace, before -// runtime_graphics_ui.inl. -// -// Camera tuning: the Bancada ModernCam inside the game. A floating window over -// the race (F7, or Graphics > Tune the Modern camera) that edits the Modern -// camera live and saves it to modern-camera.ini. It is mouse-only on purpose: -// the controller and keyboard keep driving while it is open. - -namespace camtune = dkr::runtime::modern_camera; - -struct CameraTuningGroup { - camtune::TuningGroup group; - const char* title; - const char* help; - bool open; -}; - -constexpr std::array kCameraTuningGroups{{ - {camtune::TuningGroup::Framing, "Framing", "Distance, height and zoom with speed.", true}, - {camtune::TuningGroup::Direction, "Direction", - "How closely the camera follows the racer through turns and drifts.", true}, - {camtune::TuningGroup::YawSpring, "Yaw spring", - "How smoothly the camera turns. Damping below 1 adds overshoot.", true}, - {camtune::TuningGroup::Follow, "Follow", "How elastic the position is.", false}, - {camtune::TuningGroup::Terrain, "Terrain", "Pitching with the track's slope.", false}, - {camtune::TuningGroup::LookAhead, "Look ahead", "Aiming where the racer is going.", false}, - {camtune::TuningGroup::Fov, "Field of view", "Sense of speed, added to the level's own FOV.", false}, - {camtune::TuningGroup::Roll, "Roll", "A slight tilt as the racer turns.", false}, - {camtune::TuningGroup::Orbit, "Manual orbit", - "Turn the camera with the right stick or mouse. Hold the right mouse button to keep the view; Escape frees the pointer.", false}, - {camtune::TuningGroup::Collision, "Walls", "When a wall comes between the camera and the racer.", false}, - {camtune::TuningGroup::Shake, "Shake", - "Shake from boosts, landings and impacts. Zero turns shake off.", true}, - {camtune::TuningGroup::Hits, "Hits", - "Lower values smooth the camera after collisions. 100% follows the impact immediately.", - true}, -}}; - -struct CameraTuningPanel { - std::atomic visible{false}; - // What modern-camera.ini holds (or the defaults while there is none). - camtune::Tuning saved = camtune::default_tuning(); - std::string status; - std::array lag{}; - std::size_t lag_next = 0U; -}; -CameraTuningPanel g_camera_tuning; - -std::filesystem::path CameraTuningPath() { - return g_config_directory / "modern-camera.ini"; -} - -bool SaveCameraTuning(const camtune::Tuning& tuning); - -void LoadCameraTuning() { - camtune::Tuning tuning = camtune::default_tuning(); - bool migrated = false; - std::ifstream input(CameraTuningPath(), std::ios::binary); - if (input) { - std::ostringstream text; - text << input.rdbuf(); - tuning = camtune::parse_tuning(text.str(), &migrated); - } - camtune::set_tuning(tuning); - g_camera_tuning.saved = tuning; - // An older file is brought up to date once, so the panel and the file - // agree on what is saved. - if (migrated && SaveCameraTuning(tuning)) { - g_camera_tuning.status = "Camera settings updated. Your custom values are kept."; - } -} - -bool SaveCameraTuning(const camtune::Tuning& tuning) { - std::error_code error; - std::filesystem::create_directories(g_config_directory, error); - const std::filesystem::path path = CameraTuningPath(); - const std::filesystem::path temporary = path.string() + ".tmp"; - { - std::ofstream output(temporary, std::ios::binary | std::ios::trunc); - if (!output) return false; - output << camtune::serialize_tuning(tuning); - if (!output) return false; - } - if (!ReplaceSettingsFile(temporary, path)) return false; - g_camera_tuning.saved = tuning; - return true; -} - -void ToggleCameraTuning() { - g_camera_tuning.visible.store(!g_camera_tuning.visible.load(std::memory_order_acquire), - std::memory_order_release); -} - -void OpenCameraTuning() { - g_camera_tuning.visible.store(true, std::memory_order_release); -} - -bool CameraTuningVisible() { - return g_camera_tuning.visible.load(std::memory_order_acquire); -} - -// ------------------------------------------------------------ mouse orbit - -// The mouse orbits the Modern camera of the keyboard player's port whenever -// that port races with it: the pointer is captured (relative mode) for as -// long as nothing else needs it, and let go for the menu, another window or -// this panel. With the panel open the pointer stays free for it, and holding -// the right button over the race orbits instead. Either way, holding the -// right button keeps the camera where it was put. -enum class CameraMouseMode : std::uint8_t { Off, Always, Held }; - -struct CameraMouseOrbit { - CameraMouseMode mode = CameraMouseMode::Off; - std::size_t port = 0U; - std::uint64_t racing_at = 0U; // SDL ticks when the port last raced with the Modern camera -}; -CameraMouseOrbit g_camera_mouse; - -// Loop and door cameras hand the race back to retail for a moment; keep the -// pointer captured through them instead of flashing it. -constexpr std::uint64_t kCameraMouseGraceMs = 1000U; - -bool ImGuiWantsMouse() { - std::scoped_lock guard(g_inspector_guard); - if (g_inspector == nullptr) return false; - std::scoped_lock frame_lock(g_inspector->frameMutex); - return ImGui::GetIO().WantCaptureMouse; -} - -void StopCameraMouseOrbit() { - if (g_camera_mouse.mode == CameraMouseMode::Off) return; - g_camera_mouse.mode = CameraMouseMode::Off; - camtune::set_mouse_orbit_held(g_camera_mouse.port, false); - SDL_SetRelativeMouseMode(SDL_FALSE); -} - -bool StartCameraMouseOrbit(CameraMouseMode mode, std::size_t port) { - if (SDL_SetRelativeMouseMode(SDL_TRUE) != 0) return false; - g_camera_mouse.mode = mode; - g_camera_mouse.port = port; - return true; -} - -// Something else needs the pointer: the menu, a key-binding capture, or -// another window. -bool CameraMousePointerNeeded() { - return g_overlay_visible.load(std::memory_order_acquire) || dkr::runtime::ui::input_capture_active() || - SDL_GetKeyboardFocus() == nullptr; -} - -// Once per event pump: capture the pointer when a race starts, let it go when -// something else needs it. +// Shared launcher/overlay theme and existing controller navigation/OSK. +namespace camplus=dkr::runtime::camera; +bool CameraTuningVisible(){return camplus::editor_open.load();} +void LoadCameraTuning(){} // Old Modern-camera INI no longer drives this editor. +void OpenCameraTuning(){camplus::editor_open.store(true);} +void ToggleCameraTuning(){camplus::editor_open.store(!CameraTuningVisible());} +bool g_camera_plus_mouse=false; +unsigned CameraMouseProfile(){const auto a=camplus::activation();return a&&a->online? + unsigned(dkr::runtime::platform::online_input_profile()):0U;} void UpdateCameraMouseOrbit() { -#if !defined(__ANDROID__) - const auto port = static_cast(dkr::runtime::input::keyboard_player()); - const std::uint64_t now = SDL_GetTicks64(); - const bool available = camtune::mouse_orbit_available(port); - if (available) g_camera_mouse.racing_at = now; - const bool racing = g_camera_mouse.racing_at != 0U && now - g_camera_mouse.racing_at < kCameraMouseGraceMs; - const bool pointer_needed = CameraMousePointerNeeded(); - switch (g_camera_mouse.mode) { - case CameraMouseMode::Always: - if (pointer_needed || CameraTuningVisible() || !racing || port != g_camera_mouse.port) { - StopCameraMouseOrbit(); - } - break; - case CameraMouseMode::Held: - if (pointer_needed || !racing) StopCameraMouseOrbit(); - break; - case CameraMouseMode::Off: - if (available && !pointer_needed && !CameraTuningVisible()) { - StartCameraMouseOrbit(CameraMouseMode::Always, port); - } - break; + if(g_camera_plus_mouse&&(CameraTuningVisible()||g_overlay_visible.load()||!camplus::owns_controls(CameraMouseProfile()))) { + SDL_SetRelativeMouseMode(SDL_FALSE);g_camera_plus_mouse=false; } -#endif } - -// True when the event belonged to the mouse orbit. bool HandleCameraMouseOrbit(const SDL_Event& event) { - switch (event.type) { - case SDL_MOUSEBUTTONDOWN: { - if (event.button.which == SDL_TOUCH_MOUSEID) return false; - if (g_camera_mouse.mode != CameraMouseMode::Off) { - if (event.button.button == SDL_BUTTON_RIGHT) camtune::set_mouse_orbit_held(g_camera_mouse.port, true); - return true; - } - // The panel is open, so the pointer is free: the right button over - // the race orbits while it is held. - if (event.button.button != SDL_BUTTON_RIGHT) return false; - const auto port = static_cast(dkr::runtime::input::keyboard_player()); - if (CameraMousePointerNeeded() || !camtune::mouse_orbit_available(port) || ImGuiWantsMouse() || - !StartCameraMouseOrbit(CameraMouseMode::Held, port)) { - return false; - } - camtune::set_mouse_orbit_held(port, true); - return true; + if(event.type==SDL_MOUSEBUTTONDOWN&&event.button.button==SDL_BUTTON_RIGHT&& + camplus::owns_controls(CameraMouseProfile())&&!g_overlay_visible.load()&&!CameraTuningVisible()) { + g_camera_plus_mouse=SDL_SetRelativeMouseMode(SDL_TRUE)==0;return g_camera_plus_mouse; } - case SDL_MOUSEBUTTONUP: - if (g_camera_mouse.mode == CameraMouseMode::Off || event.button.which == SDL_TOUCH_MOUSEID) return false; - if (event.button.button == SDL_BUTTON_RIGHT) { - camtune::set_mouse_orbit_held(g_camera_mouse.port, false); - if (g_camera_mouse.mode == CameraMouseMode::Held) StopCameraMouseOrbit(); - } - return true; - case SDL_MOUSEWHEEL: - return g_camera_mouse.mode != CameraMouseMode::Off && event.wheel.which != SDL_TOUCH_MOUSEID; - case SDL_MOUSEMOTION: - if (g_camera_mouse.mode == CameraMouseMode::Off || event.motion.which == SDL_TOUCH_MOUSEID) return false; - camtune::add_mouse_motion(g_camera_mouse.port, static_cast(event.motion.xrel), - static_cast(event.motion.yrel)); - return true; - case SDL_WINDOWEVENT: - if (event.window.event == SDL_WINDOWEVENT_FOCUS_LOST) StopCameraMouseOrbit(); - return false; - default: - return false; - } -} - -// One label + slider row of a two-column table. Gold labels differ from the -// DKR-R tuning; right-click puts a value back. -bool CameraTuningSlider(const camtune::TuningField& field, camtune::Tuning& tuning, - const camtune::Tuning& defaults) { - float& value = camtune::field_value(tuning, field); - const float base = camtune::field_value(defaults, field); - const float scale = field.degrees ? static_cast(180.0 / camtune::kPi) : 1.0F; - const bool differs = value != base; - ImGui::PushID(field.key); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (differs) ImGui::PushStyleColor(ImGuiCol_Text, kWarm); - ImGui::TextWrapped("%s", field.label); - if (differs) ImGui::PopStyleColor(); - ImGui::TableNextColumn(); - const std::string_view unit = field.unit; - const bool tight = unit.empty() || unit == "\xC2\xB0" || unit == "x"; - char format[32]; - std::snprintf(format, sizeof(format), "%%.%df%s%s", field.decimals, tight ? "" : " ", field.unit); - float shown = value * scale; - ImGui::SetNextItemWidth(-FLT_MIN); - bool changed = ImGui::SliderFloat("##value", &shown, field.minimum, field.maximum, format, - ImGuiSliderFlags_AlwaysClamp); - if (changed) value = shown / scale; - if (ImGui::IsItemHovered()) { - char shipped[32]; - std::snprintf(shipped, sizeof(shipped), format, base * scale); - ImGui::SetTooltip("%s\nDKR-R: %s. Right-click to reset, Ctrl+click to type.", field.key, shipped); + if(event.type==SDL_MOUSEBUTTONUP&&event.button.button==SDL_BUTTON_RIGHT&&g_camera_plus_mouse) { + SDL_SetRelativeMouseMode(SDL_FALSE);g_camera_plus_mouse=false;return true; } - if (differs && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - value = base; - changed = true; + if(event.type==SDL_MOUSEMOTION&&g_camera_plus_mouse) { + camplus::actions().mouse(CameraMouseProfile(),-float(event.motion.xrel),float(event.motion.yrel));return true; } - ImGui::PopID(); - return changed; + return false; } - -bool CameraTuningRows(camtune::TuningGroup group, camtune::Tuning& tuning, const camtune::Tuning& defaults) { - bool changed = false; - if (!ImGui::BeginTable("##rows", 2, ImGuiTableFlags_SizingStretchProp)) return false; - ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthStretch, 0.9F); - ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch, 1.1F); - for (const camtune::TuningField& field : camtune::kTuningFields) { - if (field.group == group) changed |= CameraTuningSlider(field, tuning, defaults); +void DrawCameraTuningPanel() { + const auto active=camplus::activation(); + static std::weak_ptr previous; + static unsigned profile=0,group=0; + static int preset=0; + static std::optional saving; + static std::uint64_t save_generation=0; + static std::string status; + if(previous.lock()!=active){previous=active;group=0;profile=active&&active->online? + unsigned(dkr::runtime::platform::online_input_profile()):0U;preset=0;saving.reset();status.clear();} + bool open=true; + const auto display=ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowSize({std::min(1120.F,display.x-32.F),std::min(760.F,display.y-32.F)},ImGuiCond_Appearing); + if(!ImGui::Begin("MODERN CAMERA++",&open,ImGuiWindowFlags_NoCollapse)){ + ImGui::End();if(!open)camplus::editor_open.store(false);return; } - ImGui::EndTable(); - return changed; -} - -void CameraTuningStatus(const camtune::Readout& live) { - namespace enh = dkr::runtime::enhancements; - if (!enh::modern_presentation_enabled()) { - ImGui::TextColored(kWarm, "Requires Modern graphics."); - } else if (!camtune::requested()) { - ImGui::TextColored(kWarm, "The Modern camera is off."); - } else if (live.live) { - ImGui::TextColored(kAccent, "Player %u camera. Changes apply while driving.", - static_cast(live.port + 1U)); + if(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)&&!ImGui::IsPopupOpen("",ImGuiPopupFlags_AnyPopupId))open=false; + if(!active||!active->editor) { + ImGui::TextWrapped("Import and enable ModernCamera++ in Mods/Hacks, then start a new game."); + const auto problem=camplus::diagnostic();if(!problem.empty())ImGui::TextWrapped("%s",problem.c_str()); } else { - ImGui::TextWrapped("Start an offline race to adjust the camera."); - } -} - -void CameraTuningReadout(CameraTuningPanel& panel, const camtune::Readout& live) { - panel.lag[panel.lag_next] = live.live ? std::clamp(live.lag, -45.0F, 45.0F) : 0.0F; - panel.lag_next = (panel.lag_next + 1U) % panel.lag.size(); - if (!live.live) return; - if (ImGui::BeginTable("##readout", 4, ImGuiTableFlags_SizingStretchSame)) { - const auto cell = [](const char* name, float value, int decimals, const char* unit) { - // Round first so a value that shows as zero never shows as "-0". - const float step = decimals > 0 ? 10.0F : 1.0F; - const float shown = std::round(value * step) / step + 0.0F; - ImGui::TableNextColumn(); - ImGui::TextDisabled("%s", name); - ImGui::Text("%.*f%s", decimals, shown, unit); - }; - cell("Speed", live.speed * 3.6F, 0, " km/h"); - cell("Camera vs nose", live.lag, 0, "\xC2\xB0"); - cell("Slip", live.slip, 0, "\xC2\xB0"); - cell("Roll", live.roll, 1, "\xC2\xB0"); - cell("FOV", live.fov, 1, "\xC2\xB0"); - cell("Boom", live.arm, 1, " m"); - cell("Clear of walls", live.clearance * 100.0F, 0, "%"); - ImGui::TableNextColumn(); - ImGui::TextDisabled("Shake"); - char shake[16]; - std::snprintf(shake, sizeof(shake), "%.2f", live.trauma * live.trauma); - ImGui::ProgressBar(live.trauma * live.trauma, {-FLT_MIN, 0.0F}, shake); - ImGui::EndTable(); - } - const auto chip = [](const char* name, bool on) { - ImGui::SameLine(); - if (on) ImGui::TextColored(kWarm, "%s", name); - else ImGui::TextDisabled("%s", name); - }; - ImGui::TextDisabled("State:"); - chip("Drift", live.drifting); - chip("Boost", live.boosting); - chip("Air", !live.grounded); - chip("Look back", live.look_back); - chip("Hit", live.steadying); - ImGui::SameLine(); - ImGui::TextDisabled("(%u seen)", static_cast(live.hits)); - ImGui::PlotLines("##lag", panel.lag.data(), static_cast(panel.lag.size()), - static_cast(panel.lag_next), "Camera vs nose, last 4 s (\xC2\xB1" "45\xC2\xB0)", - -45.0F, 45.0F, {-FLT_MIN, 56.0F}); -} - -void DrawCameraTuningPanel() { - CameraTuningPanel& panel = g_camera_tuning; - camtune::Tuning tuning = camtune::tuning(); - const camtune::Tuning defaults = camtune::default_tuning(); - const camtune::Readout live = camtune::readout(); - bool changed = false; - - ImFont* body = PaddockReading(13.0F).font; - ImFont* strong = PaddockReading(13.0F, true).font; - if (body != nullptr) ImGui::PushFont(body); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {14.0F, 12.0F}); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 10.0F); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0F); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {8.0F, 4.0F}); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {8.0F, 6.0F}); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 5.0F); - ImGui::PushStyleVar(ImGuiStyleVar_GrabMinSize, 12.0F); - ImGui::PushStyleColor(ImGuiCol_WindowBg, {0.025F, 0.08F, 0.12F, 0.94F}); - ImGui::PushStyleColor(ImGuiCol_Border, kWarm); - ImGui::PushStyleColor(ImGuiCol_TitleBg, {0.04F, 0.16F, 0.22F, 1.0F}); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, {0.06F, 0.26F, 0.33F, 1.0F}); - ImGui::PushStyleColor(ImGuiCol_TitleBgCollapsed, {0.04F, 0.16F, 0.22F, 0.9F}); - // A quieter grab, so the value it slides under stays readable. - ImGui::PushStyleColor(ImGuiCol_SliderGrab, {1.0F, 0.67F, 0.08F, 0.45F}); - const ImVec2 display = ImGui::GetIO().DisplaySize; - ImGui::SetNextWindowPos({display.x - 24.0F, 24.0F}, ImGuiCond_FirstUseEver, {1.0F, 0.0F}); - ImGui::SetNextWindowSize({480.0F, std::max(display.y * 0.8F, 320.0F)}, ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSizeConstraints({360.0F, 240.0F}, {std::max(display.x, 360.0F), std::max(display.y, 240.0F)}); - bool open = true; - if (ImGui::Begin("Modern camera tuning (F7)###dkr-camera-tuning", &open, ImGuiWindowFlags_NoNav)) { - CameraTuningStatus(live); - bool modern_camera = camtune::requested(); - if (ImGui::Checkbox("Modern camera", &modern_camera)) { - camtune::set_enabled(modern_camera); - SaveSettings(); - } - ImGui::SameLine(); - bool look_back = camtune::look_back_requested(); - if (ImGui::Checkbox("Look back", &look_back)) { - camtune::set_look_back_enabled(look_back); - SaveSettings(); - } - CameraTuningReadout(panel, live); - - ImGui::SeparatorText("Presets"); - int matched = -1; - for (std::size_t index = 0U; index < camtune::kTuningPresets.size(); ++index) { - const camtune::TuningPreset& preset = camtune::kTuningPresets[index]; - if (matched < 0 && camtune::same_tuning(tuning, preset.make())) matched = static_cast(index); - if (index > 0U) { - const float next = ImGui::GetItemRectMax().x + ImGui::GetStyle().ItemSpacing.x + - ImGui::CalcTextSize(preset.name).x + ImGui::GetStyle().FramePadding.x * 2.0F; - if (next < ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x) ImGui::SameLine(); - } - const bool selected = matched == static_cast(index); - if (selected) { - ImGui::PushStyleColor(ImGuiCol_Button, kWarm); - ImGui::PushStyleColor(ImGuiCol_Text, {0.02F, 0.08F, 0.12F, 1.0F}); - } - if (ImGui::Button(preset.name)) { - tuning = preset.make(); - changed = true; - } - if (selected) ImGui::PopStyleColor(2); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", preset.help); - } - ImGui::TextWrapped("%s", matched >= 0 - ? camtune::kTuningPresets[static_cast(matched)].help - : "Custom. Gold labels mark changed settings."); - ImGui::PushTextWrapPos(0.0F); - ImGui::TextDisabled("Motion sick? Start from Comfort: roll, shake, FOV changes and sway " - "are the usual causes."); - ImGui::PopTextWrapPos(); - - const bool unsaved = !camtune::same_tuning(tuning, panel.saved); - ImGui::BeginDisabled(!unsaved); - if (ImGui::Button("Save")) { - panel.status = SaveCameraTuning(tuning) - ? "Saved to modern-camera.ini." - : "Could not save modern-camera.ini."; - } - ImGui::SameLine(); - if (ImGui::Button("Revert to saved")) { - tuning = panel.saved; - changed = true; - panel.status.clear(); - } - ImGui::EndDisabled(); - ImGui::SameLine(); - if (ImGui::Button("Copy as C++")) { - ImGui::SetClipboardText(camtune::tuning_code(tuning).c_str()); - panel.status = "Copied the block for default_tuning() in modern_camera_dkr.hpp."; + auto& editor=*active->editor; + const auto library=g_code_library.view(); + if(saving&&!library->busy&&library->generation>save_generation) { + if(library->error.empty()) { + bool matched=false; + for(const auto& item:library->items)if(item.digest==active->frozen.ordered[0]->digest&&item.data==*saving)matched=true; + if(matched){editor.saved(*saving);status="Saved.";}else status="Settings were not saved. Please try again."; + } else status=library->error; + saving.reset(); } - if (unsaved) { - ImGui::TextColored(kWarm, "Unsaved changes: they last until you quit."); - } else if (!panel.status.empty()) { - ImGui::TextDisabled("%s", panel.status.c_str()); - } - bool step_log = camtune::step_log(); - if (ImGui::Checkbox("Record the camera to runtime.log", &step_log)) camtune::set_step_log(step_log); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Records camera movement in the log until turned off or DKR-R closes."); - } - ImGui::Spacing(); - - // NoNav here too: arrows and Space may be driving the racer. - ImGui::BeginChild("##camera-tuning-values", {0.0F, 0.0F}, ImGuiChildFlags_None, ImGuiWindowFlags_NoNav); - if (strong != nullptr) ImGui::PushFont(strong); - ImGui::SeparatorText("Enhancements"); - if (strong != nullptr) ImGui::PopFont(); - for (const camtune::TuningFeature& feature : camtune::kTuningFeatures) { - ImGui::PushID(feature.key); - bool on = tuning.params.*feature.member != 0; - const bool differs = on != (defaults.params.*feature.member != 0); - if (differs) ImGui::PushStyleColor(ImGuiCol_Text, kWarm); - if (ImGui::Checkbox(feature.label, &on)) { - tuning.params.*feature.member = on ? 1 : 0; - changed = true; + const float width=ImGui::GetContentRegionAvail().x; + std::vector players; + for(unsigned i=0;i<4;++i)players.emplace_back("Local player "+std::to_string(i+1)); + int selected=int(profile); + if(SettingsDropdown("##camera-owner","Profile",&selected,players,width))profile=unsigned(selected); + std::vector presets;for(const auto& value:editor.presets())presets.emplace_back(value.name); + if(!presets.empty()&&SettingsDropdown("##camera-preset","Preset",&preset,presets,width))editor.preset(profile,unsigned(preset)); + std::vector sections;for(const auto& value:editor.groups())sections.emplace_back(value.name); + int section=int(group); + if(SettingsDropdown("##camera-section","Section",§ion,sections,width))group=unsigned(section); + const float footer=180.F; + ImGui::BeginChild("camera-fields",{0,std::max(48.F,ImGui::GetContentRegionAvail().y-footer)},false); + if(group();std::vector labels; + for(const auto& text:field.choices)labels.emplace_back(text); + // A playtest must not expose a nonfunctional headset switch. + if(field.id=="mode"&&labels.size()==8) { + labels[6].disabled=true;labels[6].detail="Inspection output is not enabled in this playtest."; + labels[7].disabled=true;labels[7].detail="Headset scene output is not connected yet."; + } + if(field.id=="first_visibility"&&labels.size()==3) { + labels[2].disabled=true;labels[2].detail="Choose Show vehicle or Hide vehicle in this build."; + } + if(SettingsDropdown("##enum",field.name,&choice,labels,inner))editor.edit(profile,field.id,choice); + } else if(field.type=="Number") { + const double step=std::max(field.step,(field.maximum-field.minimum)/10000.); + const int count=std::max(1,int(std::ceil((field.maximum-field.minimum)/step))); + int position=std::clamp(int(std::lround((value.get()-field.minimum)/step)),0,count); + if(SettingsRange("##number",field.name,&position,0,count,inner,[&](int point){ + char number[40];std::snprintf(number,sizeof(number),"%.2f",std::min(field.maximum,field.minimum+point*step)); + return SettingsRangeValue{number,""}; + }))editor.edit(profile,field.id,std::min(field.maximum,field.minimum+position*step)); + } else { + SettingsLabel(field.name,inner); + const auto id=field.id;const auto owner=profile; + if(SettingsButton(value.get_ref().empty()?"Enter text":value.get_ref().c_str(),inner)) { + RequestCustomTextKeyboard(field.name.c_str(),value.get(),4097, + [weak=std::weak_ptr(active),id,owner](const std::string& text){ + if(auto a=weak.lock())a->editor->edit(owner,id,text); + }); + } } - if (differs) ImGui::PopStyleColor(); - ImGui::Indent(ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x); - ImGui::PushTextWrapPos(0.0F); - ImGui::TextDisabled("%s", feature.help); - ImGui::PopTextWrapPos(); - if (on) changed |= CameraTuningRows(feature.group, tuning, defaults); - ImGui::Unindent(ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x); - ImGui::PopID(); + PaddockGap(12.F);ImGui::PopID(); } - ImGui::Spacing(); - if (strong != nullptr) ImGui::PushFont(strong); - ImGui::SeparatorText("Parameters"); - if (strong != nullptr) ImGui::PopFont(); - for (const CameraTuningGroup& group : kCameraTuningGroups) { - ImGui::PushID(group.title); - if (ImGui::CollapsingHeader(group.title, group.open ? ImGuiTreeNodeFlags_DefaultOpen : 0)) { - ImGui::PushTextWrapPos(0.0F); - ImGui::TextDisabled("%s", group.help); - ImGui::PopTextWrapPos(); - changed |= CameraTuningRows(group.group, tuning, defaults); + ImGui::EndChild(); + const float gap=ImGui::GetStyle().ItemSpacing.x; + const float button=std::max(1.F,(width-gap*3)/4); + if(SettingsButton("Apply",button,!editor.pending())){editor.apply();status="Applied.";} + ImGui::SameLine();if(SettingsButton("Revert",button))editor.revert(); + ImGui::SameLine();if(SettingsButton("Reset",button))editor.reset(profile); + ImGui::SameLine();if(SettingsButton("Save",button,bool(saving)||library->busy)) { + editor.apply();auto document=editor.save_document(); + if(g_code_library.data(active->frozen.ordered[0]->digest,document)) { + saving=std::move(document);save_generation=library->generation;status="Saving..."; } - ImGui::PopID(); } - ImGui::EndChild(); + if(!editor.error().empty())ImGui::TextWrapped("%s",editor.error().c_str()); + else if(!status.empty())ImGui::TextUnformatted(status.c_str()); + else if(editor.pending()||editor.unsaved())ImGui::TextUnformatted("Unsaved changes"); } + if(SettingsButton("Close",120.F))open=false; ImGui::End(); - ImGui::PopStyleColor(6); - ImGui::PopStyleVar(7); - if (body != nullptr) ImGui::PopFont(); - - if (changed) { - camtune::set_tuning(tuning); - panel.status.clear(); - } - if (!open) panel.visible.store(false, std::memory_order_release); + if(!open)camplus::editor_open.store(false); } diff --git a/runtime-recomp/src/game/runtime_code_mods.cpp b/runtime-recomp/src/game/runtime_code_mods.cpp index d2d8a808..0fcc1999 100644 --- a/runtime-recomp/src/game/runtime_code_mods.cpp +++ b/runtime-recomp/src/game/runtime_code_mods.cpp @@ -1,9 +1,9 @@ +#include "mods/magic_code_dispatch.h" #include "runtime_code_mods.hpp" #include "runtime_legacy_mods.hpp" #include "atomic_snapshot.hpp" #include "mods/online_game_pak.hpp" #include "ultramodern/ultramodern.hpp" -#include "mods/magic_code_dispatch.h" #include #include @@ -53,6 +53,8 @@ void begin(std::shared_ptr code,rom::Revision revis next->world=std::make_shared(*next->frozen,mods::online::asset_revision(revision), mods::code::DispatchTable{{entries,count}},seed,online); next->world->course_selector([](const auto& id,unsigned carrier){legacy::request_scene(id,carrier);}); + next->world->sdk2_current_course([]{return legacy::current_course();}); + if(next->world->sdk2_wants_assets())next->world->sdk2_assets(legacy::asset_inventory()); legacy::set_trophy_menu(mods::code::trophy::menu_entries(*next->frozen)); next->world->trophy_selector([]{return legacy::selected_trophy();}); std::fprintf(stderr,"[custom-magic-codes][session] online=%u packages=%zu identity=%s\n", @@ -61,7 +63,7 @@ void begin(std::shared_ptr code,rom::Revision revis std::fprintf(stderr,"[custom-magic-codes][package] id=%s version=%s payload=%s\n",package->id.c_str(),package->version.c_str(),package->digest.c_str()); ordinary.store(std::move(next)); } -void reset(){ordinary.store(nullptr);std::lock_guard lock(error_mutex);last_error.clear();} +void reset(){dkr::sdk2::end();ordinary.store(nullptr);std::lock_guard lock(error_mutex);last_error.clear();} void bind_thread(){bound=ordinary.load();dkr_code_callbacks_enabled=bound?1:0;dkr_code_hook_mask=bound?bound->world->hook_mask():nullptr;} void unbind_thread(){dkr_code_callbacks_enabled=0;dkr_code_hook_mask=nullptr;bound.reset();} void boot(std::uint8_t* ram,std::size_t bytes) { @@ -80,6 +82,12 @@ mods::Bytes retire_bootstrap_checkpoint(std::span live_ram) { std::shared_ptr frozen(){const auto value=ordinary.load();return value?value->frozen:nullptr;} int dispatch(bool leaving,std::uint32_t address,std::uint8_t* ram,recomp_context* context) { if(!bound||!bound->world)stop("Code callback has no owner on this CPU thread."); + // The ordinary game worker yields through its existing scheduler at a + // retired main-loop boundary. UI/audio/VI workers are never blocked here. + if(!leaving&&bound->world->sdk2_frame_starts_at(address))for(;;) { + const int status=bound->world->sdk2_dispatch_pulse();if(status<0)stop(bound->world->error());if(status)break; + ultramodern::wait_for_external_message_timed(ram,16);ultramodern::check_running_queue(ram); + } std::uint64_t result=0; if(bound->world->dispatch(leaving,address,ram,context,result)<0)stop(bound->world->error()); if(leaving&&bound->world->frame_ends_at(address))for(const auto& effect:bound->world->effects()) diff --git a/runtime-recomp/src/game/runtime_code_mods_ui.inl b/runtime-recomp/src/game/runtime_code_mods_ui.inl index adbc5094..db2730a9 100644 --- a/runtime-recomp/src/game/runtime_code_mods_ui.inl +++ b/runtime-recomp/src/game/runtime_code_mods_ui.inl @@ -119,7 +119,7 @@ void DrawCustomMagicCodeLibrary(float requested_width,bool locked) { std::transform(query.begin(),query.end(),query.begin(),[](unsigned char c){return char(std::tolower(c));}); std::vector shown; for(const auto& item:snapshot->items){ - auto text=item.name+" "+item.id+" "+item.version; + auto text=dkr::store::library_display_name(item.digest, "magic", item.name)+" "+item.id+" "+item.version; std::transform(text.begin(),text.end(),text.begin(),[](unsigned char c){return char(std::tolower(c));}); if(!query.empty()&&text.find(query)==text.npos)continue; if((g_code_state_filter==1&&!item.enabled)||(g_code_state_filter==2&&item.enabled)|| @@ -154,8 +154,8 @@ void DrawCustomMagicCodeLibrary(float requested_width,bool locked) { std::vector cards;float height=0; for(std::size_t i=first;iitems.end())PaddockModalText("This code mod is no longer installed."); else { const auto& item=*found; - PaddockHeading(item.name,30,0xFFCD67,ImGui::GetContentRegionAvail().x); + PaddockHeading(dkr::store::library_display_name(item.digest, "magic", item.name),30,0xFFCD67,ImGui::GetContentRegionAvail().x); PaddockModalText("Version "+item.version); if(!item.description.empty())PaddockModalText(item.description); PaddockGap(10.0F); diff --git a/runtime-recomp/src/game/runtime_details_ui.inl b/runtime-recomp/src/game/runtime_details_ui.inl index 817c6bed..02136cc5 100644 --- a/runtime-recomp/src/game/runtime_details_ui.inl +++ b/runtime-recomp/src/game/runtime_details_ui.inl @@ -150,7 +150,7 @@ void DrawTexturePackLibrary(float width) { // The library and its filtered view change only with the library or a // filter, not every frame. struct LibraryCache { - std::uint64_t generation = 0U; + std::uint64_t generation = 0U, names_generation = 0U; std::string query; std::array filters{-1, -1, -1, -1, -1}; std::vector all; @@ -163,7 +163,9 @@ void DrawTexturePackLibrary(float width) { std::clamp(g_texture_pack_compatibility_filter, 0, 2), std::clamp(g_texture_pack_type_filter, 0, 4), std::clamp(g_texture_pack_visibility_filter, 0, 3)}; - if (cache.generation != generation) { + const auto names_generation = dkr::store::library_names_generation(); + if (cache.generation != generation || cache.names_generation != names_generation) { + cache.names_generation = names_generation; cache.all = texture_packs::snapshot(true); cache.generation = generation; cache.filters[0] = -1; @@ -179,7 +181,7 @@ void DrawTexturePackLibrary(float width) { (cache.shown.size() == 1U ? " PACK SHOWN" : " PACKS SHOWN"); PaddockText(PaddockReading(13.0F, true, 1.5F), PaddockRgb(kSetMuted), count, width); PaddockGap(16.0F); - const bool busy = TexturePackImportRunning() || DialogJobRunning(); + const bool busy = TexturePackImportRunning() || DialogJobRunning() || dkr::store::installations().view()->busy; DetailActionPair(width, width <= 520.0F, [&](float cell) { if (DetailRaceButton("IMPORT TEXTURE PACK", cell, 0x0A6EA1, busy)) ImportTexturePackWithDialog(); }, [&](float cell) { @@ -226,7 +228,7 @@ void DrawTexturePackLibrary(float width) { ImGui::SetCursorScreenPos({card.x + 16.0F, card.y + 16.0F}); bool enabled = pack.enabled; if (SettingsCheck("##enabled", pack.name, &enabled, column - 32.0F, {}, - pack.hidden || !pack.compatible)) { + busy || pack.hidden || !pack.compatible)) { texture_packs::set_enabled(pack.id, enabled); } ImGui::SetCursorScreenPos({card.x + 16.0F, card.y + 16.0F + name_height + 8.0F}); @@ -261,7 +263,7 @@ void DrawTexturePackLibrary(float width) { texture_packs::set_priority(pack.id, pack.priority + 1U, g_texture_pack_status); } ImGui::SetCursorScreenPos({card.x + 16.0F, card.y + card_height - 16.0F - 44.0F}); - if (DetailRaceButton("MANAGE...", column - 32.0F)) { + if (DetailRaceButton("MANAGE...", column - 32.0F, 0x0A6EA1, busy)) { g_texture_pack_manage_id = pack.id; g_texture_pack_manage_request = true; } @@ -400,7 +402,7 @@ void DrawAboutPatchNotes() { } constexpr std::array, 7> kNotes{{ {"QUICK START", "Skip opening logos and choose the start menu or character select. Portable mode keeps data beside DKR-R."}, - {"ONLINE", "Auto uses Experimental Rollback. Lobby mods can be downloaded from the host and kept for offline play. Online saves stay separate."}, + {"ONLINE", "Online play uses Rollback. Lobby mods can be downloaded from the host and kept for offline play. Online saves stay separate."}, {"CUSTOM MODS", "Browse custom characters with L / R, use custom tracks, compare duplicate mods and prepare saved imports again. Some custom features need additional support."}, {"GRAPHICS", "Enhanced water, reflections, waterfalls and lava, with separate lava lighting and quality controls. Widescreen track menus and custom-row backgrounds."}, {"TEXTURES AND CONTROLS", "Mix texture packs using priorities. Switch controller backends without closing the game. Adjust bindings, steering and gyro."}, @@ -426,6 +428,74 @@ void DrawAboutPatchNotes() { ImGui::EndPopup(); } +// Text-sized actions stay on one row. A narrow viewport scrolls horizontally +// rather than clipping labels or growing into several full-width rows. +void DrawSupportActionRow(float width) { + enum class Action { Export, Logs, Dumps, Reports, Performance, StartupLogs }; + struct Button { const char* label; const char* hint; Action action; }; + constexpr Button buttons[]{ +#if defined(__ANDROID__) + {"PERFORMANCE", "Record a performance report", Action::Performance}, + {"STARTUP LOGS", "Export Android startup logs", Action::StartupLogs}, +#endif + {"EXPORT SUMMARY", "Save a support summary", Action::Export}, + {"LOGS", "Open the logs folder", Action::Logs}, + {"CRASH DUMPS", "Open the crash dumps folder", Action::Dumps}, + {"REPORTS", "Open saved support reports", Action::Reports}, + }; + constexpr float gap = 8.0F; + constexpr float font = 17.0F; + float total = -gap; + for (const Button& button : buttons) total += PaddockRaceButtonWidth(button.label, font) + gap; + const float height = 44.0F + (total > width ? ImGui::GetStyle().ScrollbarSize + 2.0F : 0.0F); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {0.0F, 0.0F}); + ImGui::SetNextWindowContentSize({total, 44.0F}); + if (ImGui::BeginChild("##support-actions", {width, height}, false, + ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NavFlattened | + ImGuiWindowFlags_NoBackground)) { + RaceButtonLook look; + look.font_px = font; + look.hover_in = 0.12F; + for (std::size_t index = 0; index < std::size(buttons); ++index) { + const Button& button = buttons[index]; + if (index != 0U) ImGui::SameLine(0.0F, gap); + const bool pressed = PaddockRaceButton(button.label, + {PaddockRaceButtonWidth(button.label, font), 44.0F}, look); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", button.hint); + if (!pressed) continue; + switch (button.action) { + case Action::Export: { + std::filesystem::path output; + std::string error; + g_support_action_status = dkr::runtime::support::export_report(BuildSupportReport(), output, error) + ? "Support report saved." : error; + break; + } + case Action::Logs: + dkr::runtime::support::open_directory(dkr::runtime::support::log_directory(), g_support_action_status); + break; + case Action::Dumps: + dkr::runtime::support::open_directory(dkr::runtime::support::crash_dump_directory(), g_support_action_status); + break; + case Action::Reports: + dkr::runtime::support::open_directory(dkr::runtime::support::support_report_directory(), g_support_action_status); + break; +#if defined(__ANDROID__) + case Action::Performance: dkr::runtime::android::performance_tools(); break; + case Action::StartupLogs: dkr::runtime::android::export_diagnostics(); break; +#endif + default: break; + } + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); +#if defined(__ANDROID__) + PaddockGap(8.0F); + DetailText("Review logs before sharing; they may contain paths and session details.", width, kSetMuted, 0.0F); +#endif +} + void DrawAboutSupport(float width) { PollSupportSystemSummary(); AboutSectionTitle("Support summary", width); @@ -442,7 +512,7 @@ void DrawAboutSupport(float width) { {"HUD size", hud}, {"Enabled texture packs", std::to_string(EnabledTexturePackCount())}, {"Online synchronization", - dkr::runtime::netplay::synchronization_name(CurrentOnlineSynchronization())}, + dkr::runtime::netplay::rollback_policy::name(CurrentOnlineSynchronization())}, }; #if defined(__ANDROID__) facts.push_back({"Android build", dkr::runtime::android::build_identity()}); @@ -488,41 +558,7 @@ void DrawAboutSupport(float width) { g_support_action_status = dumps ? "Crash dumps are enabled." : "Crash dumps are disabled."; } PaddockGap(16.0F); - const bool stacked = inner <= 520.0F; -#if defined(__ANDROID__) - if (DetailRaceButton("RECORD PERFORMANCE REPORT", inner)) { - dkr::runtime::android::performance_tools(); - } - PaddockGap(12.0F); - if (DetailRaceButton("EXPORT ANDROID STARTUP LOGS", inner)) { - dkr::runtime::android::export_diagnostics(); - } - PaddockGap(8.0F); - DetailText("Review logs before sharing; they may contain paths and session details.", inner, kSetMuted, 0.0F); - PaddockGap(12.0F); -#endif - if (DetailRaceButton("EXPORT SUPPORT SUMMARY", inner)) { - std::filesystem::path output; - std::string error; - g_support_action_status = dkr::runtime::support::export_report(BuildSupportReport(), output, error) - ? "Support report saved." : error; - } - PaddockGap(12.0F); - DetailActionPair(inner, stacked, [&](float cell) { - if (DetailRaceButton("OPEN LOGS", cell)) { - dkr::runtime::support::open_directory(dkr::runtime::support::log_directory(), g_support_action_status); - } - }, [&](float cell) { - if (DetailRaceButton("OPEN CRASH DUMPS", cell)) { - dkr::runtime::support::open_directory(dkr::runtime::support::crash_dump_directory(), - g_support_action_status); - } - }); - PaddockGap(12.0F); - if (DetailRaceButton("OPEN SUPPORT REPORTS", inner)) { - dkr::runtime::support::open_directory(dkr::runtime::support::support_report_directory(), - g_support_action_status); - } + DrawSupportActionRow(inner); if (!g_support_action_status.empty()) { PaddockGap(16.0F); DrawFriendlyStatus(g_support_action_status, inner); @@ -580,6 +616,7 @@ void DrawAboutPage(float width, bool support) { width, kSetMuted, 0.0F); DrawUpdatesCard(width); + if (support) DrawAboutSupport(width); AboutSectionTitle("Credits", width); DetailCard(width, {20.0F, 18.0F}, [&](float inner) { DetailHeading("POOTERMAN - DKR-R ICON", inner, kSetWarm); @@ -622,7 +659,6 @@ void DrawAboutPage(float width, bool support) { inner); if (DetailRaceButton("JOIN THE DKR-R HDR DISCORD", inner)) SDL_OpenURL("https://discord.gg/AMWfXdBjNP"); }); - if (support) DrawAboutSupport(width); ImGui::PopStyleVar(); DrawAboutPatchNotes(); } diff --git a/runtime-recomp/src/game/runtime_enhancements.cpp b/runtime-recomp/src/game/runtime_enhancements.cpp index d749cd30..86f7622e 100644 --- a/runtime-recomp/src/game/runtime_enhancements.cpp +++ b/runtime-recomp/src/game/runtime_enhancements.cpp @@ -1,4 +1,6 @@ #include "runtime_enhancements.hpp" +#include "runtime_netplay.hpp" +#include "detail_contract.hpp" #include "character_select_animation_policy.hpp" #include "character_select_music_policy.hpp" @@ -404,6 +406,11 @@ extern "C" void dkr_character_select_animation_fraction(std::uint8_t*, extern "C" void dkr_apply_maximum_racer_detail(std::uint8_t* rdram, recomp_context* context) { + // Online geometry selection stays canonical, even when peers choose + // different quality. Its immutable render copy applies the local policy. + // One local machine can safely use the original fast selector for + // ALL viewports. Only online peers require independent render-only LODs. + if(dkr::runtime::netplay::session().runtime_view().active)return; if (!dkr::runtime::enhancements::maximum_detail_enabled()) { return; } @@ -414,7 +421,14 @@ extern "C" void dkr_apply_maximum_racer_detail(std::uint8_t* rdram, // the Object pointer. Preserve the ghost's deliberately distinct model. const std::uint16_t behaviour = MEM_HU(0x48, context->r16); if (behaviour != kTimeTrialGhostBehaviour) { + const auto authored_lod=static_cast(context->r8); context->r8 = context->r4; + // Bounded evidence of the native selector's actual first resident LOD. + // No extra guest reads, allocation, animation tick or model mutation. + const auto view=dkr::runtime::revision_addresses::selected_revision()==dkr::runtime::rom::Revision::UsV80?0x8011B634U:0x8011B0B4U; + dkr::runtime::detail::note({dkr::runtime::presentation::recording_scene_generation(), + static_cast(context->r16),static_cast(MEM_W(0,RdramAddress(view))), + authored_lod,static_cast(context->r8),0,true,"native highest resident; local-all-views-v2"}); } } diff --git a/runtime-recomp/src/game/runtime_graphics_ui.inl b/runtime-recomp/src/game/runtime_graphics_ui.inl index d230d4a9..69f196ce 100644 --- a/runtime-recomp/src/game/runtime_graphics_ui.inl +++ b/runtime-recomp/src/game/runtime_graphics_ui.inl @@ -492,30 +492,13 @@ void DrawGraphicsPage(float available_width, bool live) { SaveSettings(); } PaddockGap(16.0F); - namespace cam = dkr::runtime::modern_camera; - bool modern_camera = cam::requested(); - if (SettingsCheck("##modern-camera", "Modern camera", &modern_camera, inner, - "A smoother chase camera. The right stick turns the camera instead of sending C-buttons. Offline only.")) { - cam::set_enabled(modern_camera); - SaveSettings(); - } - PaddockGap(16.0F); - bool look_back = cam::look_back_requested(); - if (SettingsCheck("##look-back", "Look back", &look_back, inner, - "Hold Look back to see behind you. Change its binding in Controls. Offline only.")) { - cam::set_look_back_enabled(look_back); - SaveSettings(); - } - if (live) { - PaddockGap(12.0F); - if (SettingsButton("Adjust camera (F7)")) { - OpenCameraTuning(); - g_overlay_visible.store(false, std::memory_order_release); - } - PaddockGap(6.0F); - SettingsHelp("Adjust the camera while driving. Use the mouse.", inner); - } ImGui::EndDisabled(); + const auto camera_provider=dkr::runtime::camera::activation(); + SettingsHelp("ModernCamera++ is available as an optional mod in Mods/Hacks.",inner); + if(live&&camera_provider&&SettingsButton("Camera++ settings (F7)",inner)) { + OpenCameraTuning();g_overlay_visible.store(false,std::memory_order_release); + } + PaddockGap(16.0F); bool crt = g_crt_enabled; if (SettingsCheck("##crt", "Enable CRT filter", &crt, inner, @@ -548,7 +531,7 @@ void DrawGraphicsPage(float available_width, bool live) { ImGui::EndDisabled(); }; check("##max-detail", "Maximum vehicle detail", enh::maximum_detail_requested(), - enh::set_maximum_detail_enabled, "Use the most detailed racer models.", true + enh::set_maximum_detail_enabled, "Use the highest-detail characters and vehicles in every view, online and offline. Split-screen uses more graphics power.", true #if !defined(DKR_LOCAL_MODEL_DETAIL) , owned_camera_settings_deferred #endif diff --git a/runtime-recomp/src/game/runtime_hud_layout.cpp b/runtime-recomp/src/game/runtime_hud_layout.cpp index 7d014403..5a98d068 100644 --- a/runtime-recomp/src/game/runtime_hud_layout.cpp +++ b/runtime-recomp/src/game/runtime_hud_layout.cpp @@ -1,3 +1,4 @@ +#include "mods/legacy_memory_safety.h" #include "runtime_hud_layout.hpp" #include "atomic_snapshot.hpp" @@ -322,13 +323,24 @@ void EndHudPass(std::uint8_t* rdram, recomp_context* context, } // namespace -extern "C" void dkr_hud_asset_load_failed(std::uint8_t*, recomp_context* context) { +extern "C" void dkr_hud_asset_load_failed(std::uint8_t* rdram, recomp_context* context) { // One message per asset per guest thread: no retry loop or per-frame I/O. // Registers are observed at the hash-pinned native failed-load branch. static thread_local std::array reported{}; const auto sprite = static_cast(context->r8); if (sprite >= reported.size() || reported[sprite]) return; reported[sprite] = true; + const bool v80=dkr::runtime::revision_addresses::CurrentHud==dkr::runtime::revision_addresses::kUsV80.CurrentHud; + const auto read=[&](std::uint32_t address){return static_cast(MEM_W(0,RdramAddress(address)));}; + std::fprintf(stderr,"[hud][resource-context] sprite=%u layout=%u allocated-layout=%u dl=0x%08X tri=0x%08X; guest cursors and resource retries preserved\n", + sprite,read(v80?0x80121260U:0x80120CE0U),read(v80?0x80123A8CU:0x8012350CU), + read(v80?0x80121778U:0x801211F8U),read(v80?0x801217A8U:0x80121228U)); + + const dkr_memory_view view{rdram,0x800000U,v80?80U:77U}; + const auto heap=dkr_memory_measure(view); + std::fprintf(stderr,"[hud][heap] sprite=%u free=%u largest=%u slots=%u/%u queue=%u peak=%u valid=%d\n", + sprite,heap.free_bytes,heap.largest_free,heap.used_slots,heap.max_slots,heap.queue,heap.high_water,heap.valid); + std::fprintf(stderr, "[hud][asset-load-failed] sprite=%u element=0x%08X; native draw skipped, asset will retry on next draw\n", sprite, static_cast(context->r16)); } @@ -855,3 +867,10 @@ extern "C" void dkr_refresh_combined_accessories(std::uint8_t* rdram, dkr::runtime::platform::rumble_enabled(), retail, assigned, connected, rumble_capable); } + +extern "C" void dkr_render_heap_sync_notice(std::uint8_t*,recomp_context* context) { + static std::atomic reports{0}; + if(reports.fetch_add(1)>=16)return; + std::fprintf(stderr,"[render][heap-layout] before=%d requested=%d allocated=%d; post-mod-init-track=v1; before track generation\n", + int(context->r4),int(context->r5),int(context->r6)); +} diff --git a/runtime-recomp/src/game/runtime_input.cpp b/runtime-recomp/src/game/runtime_input.cpp index d3f80dde..948a3702 100644 --- a/runtime-recomp/src/game/runtime_input.cpp +++ b/runtime-recomp/src/game/runtime_input.cpp @@ -63,6 +63,16 @@ constexpr std::array(Action::Count)> kDefa {SDL_SCANCODE_L, kAxisSourceBase + SDL_CONTROLLER_AXIS_RIGHTX * 2 + 1}, {SDL_SCANCODE_O, SDL_CONTROLLER_BUTTON_RIGHTSTICK}, {dkr::runtime::input::kUnbound, kAxisSourceBase + SDL_CONTROLLER_AXIS_TRIGGERRIGHT * 2 + 1}, + {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTY*2}, + {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTY*2+1}, + {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTX*2}, + {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTX*2+1}, + {SDL_SCANCODE_R,SDL_CONTROLLER_BUTTON_LEFTSTICK}, + {SDL_SCANCODE_C,SDL_CONTROLLER_BUTTON_Y}, + {SDL_SCANCODE_PAGEUP,dkr::runtime::input::kUnbound}, + {SDL_SCANCODE_PAGEDOWN,dkr::runtime::input::kUnbound}, + {SDL_SCANCODE_F6,dkr::runtime::input::kUnbound}, + {SDL_SCANCODE_HOME,dkr::runtime::input::kUnbound}, }}; #else constexpr std::array(Action::Count)> kDefaults{}; @@ -147,6 +157,8 @@ constexpr std::array(Action::Count)> kIden "stick_up", "stick_down", "stick_left", "stick_right", "a", "b", "z", "start", "dpad_up", "dpad_down", "dpad_left", "dpad_right", "l", "r", "c_up", "c_down", "c_left", "c_right", "look_back", "accelerate", + "camera_up","camera_down","camera_left","camera_right","camera_recenter","camera_cycle", + "camera_zoom_in","camera_zoom_out","camera_first_person","camera_xr_recenter", }}; constexpr std::array(Action::Count)> kLabels{{ @@ -154,6 +166,8 @@ constexpr std::array(Action::Count)> kLabe "B button", "Z trigger", "Start", "D-pad up", "D-pad down", "D-pad left", "D-pad right", "L shoulder", "R shoulder", "C-up", "C-down", "C-left", "C-right", "Look back", "Accelerate", + "Camera up","Camera down","Camera left","Camera right","Centre camera","Cycle camera", + "Camera zoom in","Camera zoom out","First-person camera","Centre headset", }}; std::size_t Index(Action action) { @@ -1036,6 +1050,11 @@ dkr::runtime::input::State dkr::runtime::input::poll( state.camera_x = std::clamp(value(Action::CRight) - value(Action::CLeft), -1.0F, 1.0F); state.camera_y = std::clamp(value(Action::CUp) - value(Action::CDown), -1.0F, 1.0F); state.look_back = value(Action::LookBack) > 0.5F; + state.orbit_x=std::clamp(value(Action::OrbitLeft)-value(Action::OrbitRight),-1.F,1.F); + state.orbit_y=std::clamp(value(Action::OrbitDown)-value(Action::OrbitUp),-1.F,1.F); + const Action camera_keys[]={Action::LookBack,Action::CameraRecenter,Action::CameraCycle, + Action::CameraZoomIn,Action::CameraZoomOut,Action::CameraFirstPerson,Action::CameraXrRecenter}; + for(unsigned i=0;i<7;++i)if(value(camera_keys[i])>0.5F)state.camera_actions|=1U< (dkr::runtime::enhancements::modern_presentation_enabled() ? trigger_threshold() : 0.5F); state.stick_x = std::clamp(value(Action::StickRight) - value(Action::StickLeft), -1.0F, 1.0F); @@ -1178,6 +1197,11 @@ dkr::runtime::input::State dkr::runtime::input::poll_snapshot( state.camera_y = std::clamp(value(Action::CUp) - value(Action::CDown), -1.0F, 1.0F); state.look_back = value(Action::LookBack) > 0.5F; + state.orbit_x=std::clamp(value(Action::OrbitLeft)-value(Action::OrbitRight),-1.F,1.F); + state.orbit_y=std::clamp(value(Action::OrbitDown)-value(Action::OrbitUp),-1.F,1.F); + const Action camera_keys[]={Action::LookBack,Action::CameraRecenter,Action::CameraCycle, + Action::CameraZoomIn,Action::CameraZoomOut,Action::CameraFirstPerson,Action::CameraXrRecenter}; + for(unsigned i=0;i<7;++i)if(value(camera_keys[i])>0.5F)state.camera_actions|=1U< (dkr::runtime::enhancements::modern_presentation_enabled() ? trigger_threshold() : 0.5F); state.stick_x = std::clamp(value(Action::StickRight) - diff --git a/runtime-recomp/src/game/runtime_input.hpp b/runtime-recomp/src/game/runtime_input.hpp index de874ea8..4b60e13f 100644 --- a/runtime-recomp/src/game/runtime_input.hpp +++ b/runtime-recomp/src/game/runtime_input.hpp @@ -37,6 +37,9 @@ enum class Action : std::uint8_t { // Accelerate (presses A while the Modern camera drives the race). LookBack, Accelerate, + OrbitUp, OrbitDown, OrbitLeft, OrbitRight, + CameraRecenter, CameraCycle, CameraZoomIn, CameraZoomOut, + CameraFirstPerson, CameraXrRecenter, Count, }; @@ -71,6 +74,8 @@ struct State { float camera_y = 0.0F; bool look_back = false; bool accelerate = false; + float orbit_x=0,orbit_y=0; + std::uint32_t camera_actions=0; }; struct ShortcutBinding { diff --git a/runtime-recomp/src/game/runtime_legacy_mods.cpp b/runtime-recomp/src/game/runtime_legacy_mods.cpp index 3fba9ab1..3999a014 100644 --- a/runtime-recomp/src/game/runtime_legacy_mods.cpp +++ b/runtime-recomp/src/game/runtime_legacy_mods.cpp @@ -1,4 +1,15 @@ +#include "primary_heap.h" +#include "owned_vehicle_menu.hpp" +#include +#include +#include "resident_music.hpp" +#include "custom_records.hpp" #include "runtime_legacy_mods.hpp" +#include "achievements.hpp" +#include "vehicle_binding.hpp" +#include "vehicle_world.hpp" +#include +#include "mods/magic_code_package.hpp" #include "atomic_snapshot.hpp" #include "custom_tracks.hpp" #include "runtime_netplay.hpp" @@ -68,11 +79,12 @@ struct CharacterState { }; struct MusicBinding { std::mutex mutex; - mods::online::MusicState state; + mods::online::resident_music::State state; MusicBinding(std::shared_ptr songs,bool rev80):state(std::move(songs),rev80){} - explicit MusicBinding(mods::online::MusicState value):state(std::move(value)){} + explicit MusicBinding(mods::online::resident_music::State value):state(std::move(value)){} }; struct WorldState { + std::shared_ptr vehicles; AtomicSnapshot session; AtomicSnapshot launch; AtomicSnapshot menu; @@ -85,11 +97,15 @@ struct WorldState { std::shared_ptr authored; std::vector cups; bool private_owner=false,frozen_online=false,custom_ai=false; + bool records_restored=false; + mods::Bytes records_last; std::uint32_t pending_course_heap=0; }; WorldState ordinary_world; std::atomic ordinary_music_enabled{false}; thread_local WorldState* private_world=nullptr; +struct OwnedVehicleDrawCallbacks {RecompiledEntrypoint frame=nullptr,text_width=nullptr;}; +thread_local OwnedVehicleDrawCallbacks owned_vehicle_draw_callbacks; WorldState& current_world(){return private_world?*private_world:ordinary_world;} template WorldState::*Member>struct WorldSlot { std::shared_ptr load()const{return (current_world().*Member).load();} @@ -112,6 +128,7 @@ std::vector authored_courses() { return world.private_owner || world.frozen_online? (world.authored?world.authored->courses:std::vector{}):custom_tracks::track_select_entries(); } +#include "records_guest.inl" bool frozen_courses(){return current_world().private_owner || current_world().frozen_online;} // Retail Tracks mode only lets a player change vehicle on a course already // cleared in Adventure: func_80092188 tests courseFlags & RACE_CLEARED before @@ -224,9 +241,12 @@ void begin_session(std::shared_ptr value) { {std::lock_guard lock(error_mutex);last_error.clear();} launch.store(nullptr);characters.store(nullptr);menu.store(nullptr);session.store(std::move(value)); ordinary_world.frozen_online=false;ordinary_world.authored.reset();ordinary_world.custom_ai=false; - ordinary_world.pending_course_heap=0; + ordinary_world.pending_course_heap=0;ordinary_world.records_restored=false;ordinary_world.records_last.clear(); ordinary_world.cups.clear(); ordinary_music_enabled=false;ordinary_world.music.store(nullptr); + const auto assets=session.load();const auto cars=mods::vehicles::resources_for(assets.get()); + ordinary_world.vehicles=cars&&!cars->library.packages.empty()?std::make_shared(cars,assets->boot_bank()->revision()=="us.v80"):nullptr; + mods::vehicles::publish_render_resources(cars); } void begin_online(std::shared_ptr resources) { if(!resources)throw mods::Error("Online mod admission has no pinned game resources."); @@ -245,8 +265,8 @@ void begin_online(std::shared_ptr resource void begin_prepared(std::shared_ptr prepared) { begin_session(prepared?prepared->session:nullptr); if(!prepared || (!prepared->session&&!prepared->code))return; - if(prepared->save_subfolder.empty() || prepared->save_path.empty() || prepared->pak_directory.empty()) - throw mods::Error("Modded runtime admission requires isolated save paths."); + if(prepared->save_path.empty() || prepared->pak_directory.empty()) + throw mods::Error("Runtime admission requires a frozen personal save owner."); if(prepared->session&&!prepared->session->tracks().empty())begin_track_menu(true); if(prepared->session&&prepared->session->characters())begin_character_menu(); launch.store(std::move(prepared)); @@ -266,6 +286,7 @@ bool frozen_course_hook(const char* hook,std::uint8_t* ram,recomp_context* ctx) try { auto& world=current_world();const std::string_view name(hook); if(name=="prepare_memory") { + mods::online::resident_music::PreparationScope ordinary_load(!world.private_owner); world.pending_course_heap=mods::online::course_heap(world.authored.get(),static_cast(ctx->r4)); if(const auto music=world.music.load()) {std::lock_guard lock(music->mutex); mods::CharacterMenuFields fields;fields.fill(0x80000000U);mods::CharacterMenuMemory guest({ram,guest_memory_size()},fields); @@ -339,6 +360,9 @@ void set_trophy_menu(std::vector cups) { // are appended at retail initialization, so rebuild once at menu entry. state->catalog_ready=ordinary_world.frozen_online;ordinary_world.menu.store(std::move(state)); } +#include "mods/sdk2_asset_inventory.inl" +std::string asset_inventory(){return sdk2_asset_inventory(session.load());} +std::string current_course(){const auto active=session.load();return active?active->current_content():std::string{};} std::string selected_trophy() { const auto state=menu.load();if(!state)return {}; std::lock_guard lock(state->mutex);return state->adapter.selected_cup(); @@ -374,6 +398,18 @@ extern "C" int dkr_legacy_texture_animation(std::uint8_t* ram,recomp_context* ct } extern "C" void dkr_legacy_character_event(std::uint8_t* rdram,recomp_context* ctx,unsigned event,std::uint32_t roster_address) { using namespace dkr::runtime; + if(event>=200) { + try { + const auto cars=legacy::current_world().vehicles; + if(event==200)ctx->r2=cars?cars->render_begin({rdram,legacy::guest_memory_size()},roster_address):0; + else if(event==201){if(cars)cars->render_end({rdram,legacy::guest_memory_size()},roster_address,unsigned(ctx->r8));} + else if(event==202||event==203){if(cars)cars->wheel_scope({rdram,legacy::guest_memory_size()},roster_address,event==202);} + else if(event==204){if(cars)cars->advance_wheels({rdram,legacy::guest_memory_size()},roster_address,std::bit_cast(std::uint32_t(ctx->r6)));} + else throw dkr::mods::Error("Unknown custom vehicle render event."); + return; + }catch(const ultramodern::thread_terminated&){throw;} + catch(const std::exception& error){legacy::fail(error.what());} + } auto state=legacy::characters.load();if(!state)return; try { std::lock_guard lock(state->mutex); @@ -393,6 +429,14 @@ extern "C" void dkr_legacy_character_event(std::uint8_t* rdram,recomp_context* c } extern "C" void dkr_legacy_character_ai_event(std::uint8_t* rdram,recomp_context* ctx,unsigned event,dkr::mods::OriginalPiHandler random) { using namespace dkr::runtime; + // Cars have their own primary-racer lifecycle. Do not gate this on a + // character selector, the custom-AI preference, or an offline-only path. + try { + if(const auto cars=legacy::current_world().vehicles) + cars->race({rdram,legacy::guest_memory_size()},*ctx,event); + }catch(const ultramodern::thread_terminated&){throw;} + catch(const std::exception& error){legacy::fail(error.what());} + const auto state=legacy::characters.load(); if(!state || !state->selector || (legacy::frozen_courses()?!legacy::current_world().custom_ai:netplay::session().active()))return; try { @@ -532,10 +576,11 @@ extern "C" int dkr_legacy_character_menu(std::uint8_t* rdram,recomp_context* ctx catch(const std::exception& error){legacy::fail(error.what());} } extern "C" void dkr_legacy_heap_capacity(std::uint8_t*,recomp_context* ctx) { - const auto end=dkr::mods::legacy_heap_end(std::uint32_t(ctx->r15),dkr::runtime::legacy::guest_memory_size(), + const auto admitted=dkr::mods::legacy_heap_end(std::uint32_t(ctx->r15),dkr::runtime::legacy::guest_memory_size(), bool(dkr::runtime::legacy::session.load())); + const auto end=dkr_primary_heap_end(admitted,dkr::runtime::legacy::guest_memory_size()); if(end!=std::uint32_t(ctx->r15)) - std::fprintf(stderr,"[legacy][memory] offline custom-session pool uses existing 8 MiB RDRAM; stock/online pool unchanged\n"); + std::fprintf(stderr,"[memory][primary-pool] end=0x%08X mapped=%zu; deterministic residency headroom; deferred frees unchanged\n",end,dkr::runtime::legacy::guest_memory_size()); ctx->r15=static_cast(static_cast(end)); } extern "C" std::uint32_t dkr_legacy_course_component(std::uint8_t*,recomp_context*,unsigned site,std::uint32_t native_value) { @@ -735,7 +780,38 @@ extern "C" std::uint32_t dkr_legacy_character_cinematic_portrait(std::uint8_t*,r try {return state->presentation.cinematic_portrait(id);} catch(const std::exception& e){legacy::fail(e.what());} } +extern "C" void dkr_vehicle_menu_draw(std::uint8_t* rdram,recomp_context* ctx, + dkr::runtime::RecompiledEntrypoint frame,dkr::runtime::RecompiledEntrypoint text_width) { + using namespace dkr::runtime; + try { + const auto cars=legacy::current_world().vehicles;const auto calls=legacy::guest_payload(); + if(cars&&calls)cars->draw({rdram,legacy::guest_memory_size()},*ctx,*calls,frame,text_width); + }catch(const ultramodern::thread_terminated&){throw;} + catch(const std::exception& error){legacy::fail(error.what());} +} + extern "C" int dkr_legacy_track_menu(std::uint8_t* rdram,recomp_context* ctx,unsigned event,const std::uint32_t* fields,unsigned observed) { + if(event>=100) { + using namespace dkr::runtime; + try { + const auto cars=legacy::current_world().vehicles;if(!cars)return 0; + const auto calls=legacy::guest_payload();if(!calls)throw dkr::mods::Error("Custom vehicles have no admitted native menu callbacks."); + const auto result=cars->menu({rdram,legacy::guest_memory_size()},*ctx,event,*calls,[&](unsigned player) { + if(const auto state=legacy::characters.load()) { + std::lock_guard lock(state->mutex); + if(const auto entry=state->roster.entry(player))return entry->headers[0]; + } + dkr::mods::CharacterMenuFields addresses;addresses.fill(0x80000000U); + dkr::mods::CharacterMenuMemory g({rdram,legacy::guest_memory_size()},addresses); + const auto table=legacy::session.load()->boot_bank()->revision()=="us.v80"?0x80126990U:0x801263f0U; + constexpr unsigned headers[]{2,3,4,5,6,7,8,9,1,0};const auto base=g.read(table+player,1); + if(base>=10)throw dkr::mods::Error("Invalid selected native driver.");return headers[base]; + }); + return result?1:0; + }catch(const ultramodern::thread_terminated&){throw;} + catch(const std::exception& error){legacy::fail(error.what());} + } + using namespace dkr::runtime; // trackmenu_assets(TRACKMENU_TYPE_LOAD_LEVEL): Track Select confirmed a // course. That choice supersedes Track Lab's sticky load override before @@ -744,6 +820,16 @@ extern "C" int dkr_legacy_track_menu(std::uint8_t* rdram,recomp_context* ctx,uns custom_tracks::arm_track_override({}); auto state=legacy::menu.load(); try { + if(!legacy::private_world&&(event==0||event==1||event==15)) { + auto& owner=legacy::ordinary_world; + auto bytes=legacy::course_records(rdram,*ctx,!owner.records_restored); + if(!bytes.empty()) { + owner.records_restored=true; + if(!netplay::session().active()&&bytes!=owner.records_last) { + saves::records::submit(bytes);owner.records_last=std::move(bytes); + } + } + } // Frozen/owned worlds keep the reviewed retail vehicle gate and must // never consult the live process's transport session. if(!legacy::frozen_courses() && !netplay::session().active()) @@ -901,6 +987,7 @@ struct WorldRestore final:ModWorld::Restore { std::shared_ptr menu; std::shared_ptr characters; std::shared_ptr music; + std::shared_ptr vehicles; }; } ModWorld::ModWorld(std::shared_ptr assets,GamePayload calls, @@ -910,6 +997,10 @@ ModWorld::ModWorld(std::shared_ptr assets,GamePayload call if(!assets)throw mods::Error("An owned mod world needs admitted resources."); (void)mods::online::course_display_budgets(authored.get()); state_->session.store(assets);state_->private_owner=true;state_->calls=calls; + if(const auto cars=mods::vehicles::resources_for(assets.get());cars&&!cars->library.packages.empty()) { + state_->vehicles=std::make_shared(cars,assets->boot_bank()->revision()=="us.v80"); + mods::vehicles::publish_render_resources(cars); + } state_->animation=animation;state_->random=random;state_->authored=std::move(authored);state_->custom_ai=ai; state_->texture_animation=texture; state_->cups=std::move(cups); @@ -933,13 +1024,16 @@ void ModWorld::select_course(const std::string& id,unsigned carrier) { if(state_->authored&&std::ranges::any_of(state_->authored->courses,[&](const auto& c){return unsigned(c.level_id)==carrier;}))return; state_->session.load()->request(id,carrier); } +std::string ModWorld::asset_inventory()const {return sdk2_asset_inventory(state_->session.load());} +std::string ModWorld::current_course()const {return state_->session.load()->current_content();} std::string ModWorld::selected_trophy()const { const auto menu=state_->menu.load();if(!menu)return {}; std::lock_guard lock(menu->mutex);return menu->adapter.selected_cup(); } namespace { mods::Bytes capture_world(const WorldState& state) { - mods::CheckpointWriter out;out.u32(4);out.block(state.session.load()->checkpoint()); + mods::CheckpointWriter out;out.u32(5);out.block(state.session.load()->checkpoint()); + out.flag(bool(state.vehicles));if(state.vehicles)out.block(state.vehicles->checkpoint()); out.u32(state.pending_course_heap); const auto menu=state.menu.load();out.flag(bool(menu)); if(menu) {std::lock_guard lock(menu->mutex);out.block(menu->adapter.checkpoint());} @@ -963,9 +1057,11 @@ mods::Bytes online_bootstrap_checkpoint() { auto result=capture_world(ordinary_world);ordinary_music_enabled=false;return result; } std::unique_ptr ModWorld::stage_checkpoint(mods::View bytes)const { - mods::CheckpointReader in(bytes);if(in.u32()!=4)throw mods::Error("Unsupported owned mod world schema."); + mods::CheckpointReader in(bytes);if(in.u32()!=5)throw mods::Error("Unsupported owned mod world schema."); auto out=std::make_unique();out->authority=state_.get();out->mutation=state_->mutation; const auto assets=state_->session.load();out->session=assets->stage_checkpoint(in.block()); + if(in.flag()!=bool(state_->vehicles))throw mods::Error("Owned vehicle catalogue differs."); + if(state_->vehicles)out->vehicles=state_->vehicles->stage_checkpoint(in.block()); out->pending_course_heap=in.u32(); if(!mods::online::admitted_course_heap(state_->authored.get(),out->pending_course_heap)) throw mods::Error("Owned course checkpoint has an invalid collision heap."); @@ -997,6 +1093,7 @@ bool ModWorld::commit_checkpoint(std::unique_ptr value) { state_->mutation==UINT64_MAX || !state_->session.load()->commit_checkpoint(std::move(staged->session)))return false; state_->menu.store(std::move(staged->menu));state_->characters.store(std::move(staged->characters)); state_->pending_course_heap=staged->pending_course_heap; + state_->vehicles=std::move(staged->vehicles); state_->music.store(std::move(staged->music)); ++state_->mutation;return true; } @@ -1007,7 +1104,32 @@ int ModWorld::dispatch(const char* operation,std::uint8_t* ram,recomp_context* c throw mods::Error("Invalid owned mod callback boundary."); WorldScope scope(*state_);const std::string_view name(operation);result=0; const auto arity=[&](unsigned expected){if(count!=expected)throw mods::Error("Owned mod callback ABI differs.");}; - if(name=="dkr_legacy_asset_api"){arity(1);result=dkr_legacy_asset_api(ram,ctx,static_cast(args[0]));} + if(name=="dkr_owned_vehicle_menu_draw") { + arity(0); + const auto callbacks=owned_vehicle_draw_callbacks; + if(!state_->private_owner||!state_->vehicles||!callbacks.frame||!callbacks.text_width) + throw mods::Error("Owned vehicle menu has no private drawing binding."); + state_->vehicles->draw({ram,guest_memory_size()},*ctx,state_->calls,callbacks.frame,callbacks.text_width); + result=1; + static std::atomic reports{0}; + if(reports.fetch_add(1,std::memory_order_relaxed)<4) + std::fprintf(stderr,"[custom-vehicles][owned-menu] owned-vehicle-menu-v1: private picker, portraits, frame and text submitted\n"); + } + else if(name=="dkr_achievements_context") { + arity(1);const auto assets=session.load(); + result=owns_level(static_cast(args[0])) || + (assets&&!assets->current_content().empty()) || !selected_trophy().empty(); + } + else if(name=="dkr_shared_records_capture") { + arity(0);state_->records_last=course_records(ram,*ctx,false); + if(state_->records_last.empty())state_->records_last=saves::records::encode({}); + result=reinterpret_cast(&state_->records_last); + } + else if(name=="dkr_shared_records_install") { + arity(0);state_->records_last=course_records(ram,*ctx,true); + result=!state_->records_last.empty()||authored_courses().empty(); + } + else if(name=="dkr_legacy_asset_api"){arity(1);result=dkr_legacy_asset_api(ram,ctx,static_cast(args[0]));} else if(name=="dkr_legacy_scene_begin"){arity(0);dkr_legacy_scene_begin(ram,ctx);} else if(name=="dkr_owned_scene_admission") { arity(1);if(args[0]>127)throw mods::Error("Custom scene ID is outside its signed-byte range."); @@ -1015,7 +1137,10 @@ int ModWorld::dispatch(const char* operation,std::uint8_t* ram,recomp_context* c if(result)course_budget(ram,false); } else if(name=="dkr_owned_course_prepare") { - arity(0);frozen_course_hook("prepare_memory",ram,ctx); + arity(1); + if(args[0]>1)throw mods::Error("Invalid inline music preview policy."); + mods::online::resident_music::PreviewScope preview(args[0]!=0); + frozen_course_hook("prepare_memory",ram,ctx); } else if(name=="dkr_custom_music_sequence_loaded"){arity(0);frozen_music_hook("loaded",ram,ctx);} else if(name=="dkr_custom_music_sequence_started"){arity(0);frozen_music_hook("started",ram,ctx);} @@ -1079,6 +1204,20 @@ int ModWorld::dispatch(const char* operation,std::uint8_t* ram,recomp_context* c }catch(const std::exception& e){try{state_->error=e.what();}catch(...){}return -1;} catch(...){try{state_->error="Owned custom content stopped safely after an unexpected native fault.";}catch(...){}return -1;} } +int draw_owned_vehicle_menu(ModWorld& world,std::uint8_t* ram,recomp_context* ctx, + RecompiledEntrypoint frame,RecompiledEntrypoint text_width) noexcept { + if(!ram||!ctx||!frame||!text_width)return -1; + // Nested native callbacks retain the same world and restore their caller's + // entrypoints even when the checked guest trampoline rejects a callback. + struct Binding { + OwnedVehicleDrawCallbacks before=owned_vehicle_draw_callbacks; + Binding(RecompiledEntrypoint f,RecompiledEntrypoint w){owned_vehicle_draw_callbacks={f,w};} + ~Binding(){owned_vehicle_draw_callbacks=before;} + } binding(frame,text_width); + std::uint64_t result=0; + const auto status=world.dispatch("dkr_owned_vehicle_menu_draw",ram,ctx,nullptr,0,nullptr,0,result); + return status==1&&result==1?1:-1; +} void ModWorld::mix_music(std::uint8_t* ram,std::span pcm,unsigned rate) { const auto music=state_->music.load();if(!music)return; std::lock_guard lock(music->mutex);mods::CharacterMenuFields fields;fields.fill(0x80000000U); @@ -1086,3 +1225,32 @@ void ModWorld::mix_music(std::uint8_t* ram,std::span pcm,unsigned if(state_->mutation==UINT64_MAX)throw mods::Error("Custom music transaction exhausted.");++state_->mutation; } } + +namespace dkr::runtime::saves::records { +Bytes snapshot(legacy::ModWorld& world,std::uint8_t* ram) { + recomp_context call{};std::uint64_t result=0; + if(world.dispatch("dkr_shared_records_capture",ram,&call,nullptr,0,nullptr,0,result)!=1||!result) + throw std::runtime_error("Could not capture custom-course records from the owned guest: "+(world.error().empty()?std::string("missing records service result"):world.error())); + return *reinterpret_cast(std::uintptr_t(result)); +} +void install(legacy::ModWorld& world,std::uint8_t* ram) { + if(seed().size()==20)return; + recomp_context call{};std::uint64_t result=0; + if(world.dispatch("dkr_shared_records_install",ram,&call,nullptr,0,nullptr,0,result)!=1||!result) + throw std::runtime_error("Custom records could not be installed at the initial owned boundary; originals were retained: "+(world.error().empty()?std::string("record tables are not ready"):world.error())); +} +} + +extern "C" void dkr_achievements_observe(std::uint8_t* ram,unsigned revision) noexcept { + using namespace dkr::runtime; + if(achievements::online_session())return; + try { + auto sample=achievements::capture(ram,legacy::guest_memory_size(),revision); + const auto assets=legacy::session.load(); + sample.custom=sample.level>=65||legacy::owns_level(sample.level)|| + (assets&&!assets->current_content().empty())||!legacy::selected_trophy().empty(); + achievements::submit(sample); + }catch(...){std::fprintf(stderr,"[achievements] Read-only observer unavailable at this boundary\n");} +} + +// checked-record-observer-v2: revision-owned RAM access, no guest callback. diff --git a/runtime-recomp/src/game/runtime_legacy_mods.hpp b/runtime-recomp/src/game/runtime_legacy_mods.hpp index 0c820a1c..f554d169 100644 --- a/runtime-recomp/src/game/runtime_legacy_mods.hpp +++ b/runtime-recomp/src/game/runtime_legacy_mods.hpp @@ -31,6 +31,8 @@ std::shared_ptr prepared_launch(); void begin_track_menu(bool allow_races=false); void set_trophy_menu(std::vector); std::string selected_trophy(); +std::string current_course(); +std::string asset_inventory(); // Explicit roster seeding is reserved for development qualification. void begin_character_roster(const std::array& selected); void begin_character_menu(); @@ -65,6 +67,8 @@ class ModWorld { const std::string& error()const; void select_course(const std::string&,unsigned); std::string selected_trophy()const; + std::string current_course()const; + std::string asset_inventory()const; private: struct State; std::unique_ptr state_; diff --git a/runtime-recomp/src/game/runtime_mod_library_ui.inl b/runtime-recomp/src/game/runtime_mod_library_ui.inl index 52636fe3..4fa39009 100644 --- a/runtime-recomp/src/game/runtime_mod_library_ui.inl +++ b/runtime-recomp/src/game/runtime_mod_library_ui.inl @@ -114,6 +114,8 @@ struct LibraryEntry { }; struct LibraryCardView { + float preview_height=0; + std::function preview; bool code = false; bool native = false; std::string title; @@ -216,6 +218,7 @@ float MeasureLibraryCard(const LibraryCardView& view, float width) { height += 18.0F + (info_fits ? info.line : info.line + 8.0F + size.line) + 9.0F; if (!view.note.empty()) height += PaddockTextHeight(note, view.note, inner) + 18.0F; if (view.prepare) height += 42.0F; + if(view.preview)height+=view.preview_height+12.F; height += 2.0F + 12.0F + 42.0F + 16.0F + 2.0F; return std::ceil(height); } @@ -295,6 +298,9 @@ bool DrawLibraryCard(const LibraryCardView& view, ImVec2 origin, float width, y += PaddockTextAt(draw, note, {left, y}, inner, view.note, note_style) + 18.0F; } + if(view.preview) { + ImGui::SetCursorScreenPos({left,y});view.preview(inner,view.preview_height);y+=view.preview_height+12.F; + } bool details = false; if (view.prepare) { ImGui::SetCursorScreenPos({left, y}); @@ -314,8 +320,8 @@ bool DrawLibraryCard(const LibraryCardView& view, ImVec2 origin, float width, bool enabled = view.enabled; ImGui::BeginDisabled(!view.toggle_allowed); if (PaddockSwitch("##enabled", &enabled, "On", "Off")) { - if (view.code) { - if(code_toggle)code_toggle(enabled); + if (code_toggle) { + code_toggle(enabled); } else if (view.native) { if (dkr::runtime::custom_tracks::set_enabled(view.id, enabled, g_legacy_import_status)) g_legacy_import_status = enabled ? "Track on for the next game." : "Track off for the next game."; @@ -976,6 +982,7 @@ struct ModImportActions { std::function choose_track_zip; std::function choose_rom; std::function choose_code; + std::function choose_vehicle; }; void DrawModImportOption(const char* id, PaddockFormat format, const char* title, @@ -1077,6 +1084,15 @@ void DrawModImportChooser(const ModImportActions& actions) { ImGui::EndDisabled(); }); PaddockGap(18.0F); + DrawModImportOption("vehicle",PaddockFormat::Dkr,"Custom vehicles", + "Import a .dkrkart car or a vehicle ZIP. Choose your car from Track Select.",width,[&] { + ImGui::BeginDisabled(actions.locked); + if(PaddockButton("Choose car package")) { + ImGui::CloseCurrentPopup();if(actions.choose_vehicle)actions.choose_vehicle(); + } + ImGui::EndDisabled(); + }); + PaddockGap(18.0F); if (PaddockButton("Cancel##import-chooser", PaddockButtonKind::Link)) ImGui::CloseCurrentPopup(); ImGui::PopStyleVar(); ImGui::EndPopup(); @@ -1221,21 +1237,21 @@ bool PaddockImportButton(const char* label, float forced_width = 0.0F, bool upda } // Tracks / Characters (.mods-categories). Returns the selected index. -int DrawLibraryCategories(int current, std::size_t tracks, std::size_t characters,std::size_t codes,float available) { +int DrawLibraryCategories(int current, std::size_t tracks, std::size_t characters,std::size_t codes,std::size_t vehicles,float available) { const PaddockType type = PaddockSign(19.0F, 1.4F); const PaddockType count_type = PaddockReading(12.0F, true, 1.5F); - const std::array labels{{"Tracks", "Characters", "Custom Magic Codes"}}; - const std::array counts{{std::to_string(tracks), std::to_string(characters),std::to_string(codes)}}; - std::array widths{}; + const std::array labels{{"Tracks", "Characters", "Custom Magic Codes", "Vehicles"}}; + const std::array counts{{std::to_string(tracks), std::to_string(characters),std::to_string(codes),std::to_string(vehicles)}}; + std::array widths{}; for (std::size_t index = 0U; index < labels.size(); ++index) { const float pill = std::max(23.0F, PaddockMeasure(count_type, counts[index]) + 12.0F); widths[index] = std::ceil(2.0F + 26.0F + PaddockMeasure(type, labels[index]) + 9.0F + pill); } const float button_height = std::ceil(type.line + 14.0F + 2.0F); const ImVec2 origin = ImGui::GetCursorScreenPos(); - std::array positions{}; + std::array positions{}; float column=4.0F,row=4.0F,total=0; - for(unsigned i=0;i<3;++i) { + for(unsigned i=0;i<4;++i) { if(column>4.0F&&column+widths[i]+4.0F>available){column=4.0F;row+=button_height+4.0F;} positions[i]={origin.x+column,origin.y+row}; column+=widths[i]+4.0F;total=std::max(total,column); @@ -1249,7 +1265,7 @@ int DrawLibraryCategories(int current, std::size_t tracks, std::size_t character frame.ring_width = 1.0F; PaddockPanel(draw, origin, end, frame); int selected = current; - for (int index = 0; index < 3; ++index) { + for (int index = 0; index < 4; ++index) { ImGui::SetCursorScreenPos(positions[index]); const bool pressed_now = current == index; const PaddockPress press = PaddockBeginPress(labels[index], {widths[index], button_height}); diff --git a/runtime-recomp/src/game/runtime_netplay.cpp b/runtime-recomp/src/game/runtime_netplay.cpp index 94e4e8e7..7f5fe301 100644 --- a/runtime-recomp/src/game/runtime_netplay.cpp +++ b/runtime-recomp/src/game/runtime_netplay.cpp @@ -1,3 +1,4 @@ +#include "rollback_policy.hpp" #include "runtime_netplay.hpp" #if defined(DKR_REPLAY_QUALIFICATION) || defined(DKR_EXPERIMENTAL_RACE_TEST) #include "replay_probe_capture.hpp" @@ -2339,7 +2340,7 @@ int drive_authored_tick(std::uint8_t* rdram, recomp_context* context) { #endif const RuntimeSessionView view = g_session.runtime_view(); if (view.active && view.launch_descriptor) { - if (const char* reason = experimental::runtime_admission_error(view.launch_descriptor->synchronization)) { + if (const char* reason = dkr::runtime::netplay::rollback_policy::admission_error(view.launch_descriptor->synchronization)) { g_session.fail_runtime_start(reason); halt_failed_simulation(); return 1; diff --git a/runtime-recomp/src/game/runtime_online_mods_ui.inl b/runtime-recomp/src/game/runtime_online_mods_ui.inl index a9ee18d7..e81ab3b0 100644 --- a/runtime-recomp/src/game/runtime_online_mods_ui.inl +++ b/runtime-recomp/src/game/runtime_online_mods_ui.inl @@ -1,5 +1,7 @@ // Included after FinishOnlineLobby. Session selection is temporary: the // offline libraries and their enable/hide/order files are never written here. +std::string g_stability_retry_invite; +bool g_stability_retry_requested=false,g_stability_retry_watch=false; bool g_online_keep_downloads = true; bool g_online_approve_code=false; std::string g_online_code_offer; @@ -10,7 +12,7 @@ bool OnlineModSelectionLocked() { void ReportOnlinePreparationFailure(std::string_view error) { if(g_online_mod_notice.fail(error,dkr::runtime::rom::revision_name(g_online_mod_identity.revision), - dkr::runtime::netplay::synchronization_name(CurrentOnlineSynchronization()))) + dkr::runtime::netplay::rollback_policy::name(CurrentOnlineSynchronization()))) std::fprintf(stderr,"[online-mods][failure] revision=%s backend=%s stage=%s error=%s\n", g_online_mod_notice.revision.c_str(),g_online_mod_notice.backend.c_str(), g_online_mod_notice.stage.c_str(),g_online_mod_notice.error.c_str()); @@ -24,11 +26,11 @@ dkr::mods::online::RuntimePreparation OnlineModRuntimePreparation( // Only the host has authoritative rules at this stage. Clients still // link/validate the owned dispatcher here, verify identical resources, // and enforce the admitted launch descriptor in game_main. - if(!profile->manifest.code.empty()&&host_mode && + if((!profile->manifest.code.empty()||dkr::mods::online::car_count(profile->manifest))&&host_mode && *host_mode!=dkr::runtime::netplay::SynchronizationMode::ExperimentalRollback) - throw dkr::mods::Error("Custom Magic Codes need Auto or Experimental Rollback. Other online modes still support tracks and characters."); + throw dkr::mods::Error("Custom cars and Magic Codes require Rollback."); if(!profile->manifest.code.empty())std::fprintf(stderr,"[online-mods][code-admission] role=%s backend=%s packages=%zu\n", - host_mode?"host":"joining-client",host_mode?dkr::runtime::netplay::synchronization_name(*host_mode):"awaiting-admitted-rules", + host_mode?"host":"joining-client",host_mode?dkr::runtime::netplay::rollback_policy::name(*host_mode):"awaiting-admitted-rules", profile->manifest.code.size()); return dkr::mods::online::with_matching_game_pak(roms,profile->manifest.revision,stop, [](const auto& rom){return dkr::runtime::rom::inspect(rom);}, @@ -61,7 +63,7 @@ bool CreateOnlineLobby() { OnlineModSelectionLocked() || g_legacy_imports.snapshot().busy || g_code_library.view()->busy || g_mod_launch.snapshot().modal) { g_online_action_status="Finish the current operation and select a supported ROM before hosting.";return false; } - if(const char* reason=experimental::runtime_admission_error(CurrentOnlineSynchronization(),session().owned_backend_available())) { + if(const char* reason=dkr::runtime::netplay::rollback_policy::admission_error(CurrentOnlineSynchronization(),session().owned_backend_available())) { g_online_action_status=reason;ReportOnlinePreparationFailure(reason);return false; } OnlineHostRequest request; @@ -73,6 +75,7 @@ bool CreateOnlineLobby() { request.rules.automatic_input_delay=g_online_automatic_delay; request.rules.manual_input_delay=static_cast(g_online_manual_delay); request.rules.record_replay=g_online_record_replay; + request.rules.development=g_online_development&&request.rules.synchronization==SynchronizationMode::ExperimentalRollback; request.manifest=BuildNetplayManifest(g_online_mod_identity); request.save_mode=static_cast(g_online_save_seed_mode); request.room=g_online_room_name;request.name=g_online_player_name; @@ -89,7 +92,7 @@ bool CreateOnlineLobby() { std::fprintf(stderr,"[online-mods][host] revision=%s asset-revision=%s backend=%s preparation-start\n", dkr::runtime::rom::revision_id(g_online_mod_identity.revision).data(), dkr::mods::online::asset_revision(g_online_mod_identity.revision).data(), - synchronization_name(CurrentOnlineSynchronization())); + dkr::runtime::netplay::rollback_policy::name(CurrentOnlineSynchronization())); g_online_action_status="Preparing lobby mods..."; return true; } @@ -101,7 +104,15 @@ void PumpOnlineMods() { auto view=g_online_mod_sync->snapshot(); g_online_mod_notice.progress(view.stage); const auto network=session().view(); - if(view.phase==SyncPhase::Verified && view.profile && g_online_kept_profile!=view.profile->digest) { + if(g_stability_retry_requested&&!dkr::lobby_stability::snapshot(g_online_mod_sync.get()).running) { + g_stability_retry_requested=false;std::string error; + {auto p=dkr::runtime::spectators::party_link(&session());std::scoped_lock lock(p->mutex);p->join_watch=g_stability_retry_watch;} + if(session().join(g_stability_retry_invite,g_online_player_name,error)){g_online_mod_notice.clear();g_online_mod_watching=false;} + else g_online_action_status=error; + return; + } + if(view.phase==SyncPhase::Verified && view.profile && (view.kept_offline||!view.keep_error.empty()) && + network.state!=ConnectionState::Loading && network.state!=ConnectionState::Running && g_online_kept_profile!=view.profile->digest) { g_online_kept_profile=view.profile->digest; if(view.kept_offline) { g_legacy_imports.refresh();g_code_library.refresh();dkr::runtime::custom_tracks::reload(); @@ -138,6 +149,10 @@ void PumpOnlineMods() { return; } const bool joining=network.state==ConnectionState::Connecting || network.state==ConnectionState::AwaitingApproval; + if(joining&&!network.invite.empty()) { + g_stability_retry_invite=network.invite; + auto p=dkr::runtime::spectators::party_link(&session());std::scoped_lock lock(p->mutex);g_stability_retry_watch=p->join_watch; + } if(joining && !g_online_mod_watching) { auto settings=OnlineModSettings(); g_online_mod_notice.clear(); @@ -174,6 +189,17 @@ void DrawOnlineModModal() { DrawTechnicalDetails("ROM: " + g_online_mod_notice.revision + "\nOnline mode: " + g_online_mod_notice.backend + "\nStage: " + g_online_mod_notice.stage + "\n" + g_online_mod_notice.error); ImGui::TextWrapped("Offline saves and mod selections weren't changed."); + if(ImGui::Button("COPY JOIN DIAGNOSTICS",{ImGui::GetContentRegionAvail().x,36})) { + const auto details=std::string("DKR-R ")+DKR_RELEASE_VERSION+"\n"+dkr::lobby_stability::report(g_online_mod_sync.get())+ + "\nROM: "+g_online_mod_notice.revision+"\nBackend: "+g_online_mod_notice.backend+"\n"+g_online_mod_notice.error; + ImGui::SetClipboardText(details.c_str()); + } + if(!g_stability_retry_invite.empty()&&view.phase==SyncPhase::Failed&&!g_online_mod_host_request&&dkr::lobby_stability::snapshot(g_online_mod_sync.get()).role=="client") { + ImGui::TextWrapped("Retry reuses verified files and repairs damaged private cache entries. Review the host's current mods again."); + if(ImGui::Button("RETRY JOIN",{ImGui::GetContentRegionAvail().x,42})) { + g_online_mod_sync->cancel();g_stability_retry_requested=true;ImGui::CloseCurrentPopup(); + } + } if(ImGui::Button("DISMISS",{ImGui::GetContentRegionAvail().x,42})) { g_online_mod_notice.clear();g_online_mod_sync->cancel(); g_online_mod_host_request.reset();g_online_mod_watching=false; @@ -182,18 +208,27 @@ void DrawOnlineModModal() { ImGui::EndPopup();return; } ImGui::TextWrapped("%s",dkr::runtime::copy::progress(view.stage).c_str()); + if(busy) { + const auto d=dkr::lobby_stability::snapshot(g_online_mod_sync.get());const auto now=std::chrono::steady_clock::now(); + if(view.stage!=dkr::runtime::copy::progress(view.stage))ImGui::TextWrapped("%s",view.stage.c_str()); + ImGui::Text("Elapsed %.0fs | Stage %.0fs",std::chrono::duration(now-d.began).count(),std::chrono::duration(now-d.phase_began).count()); + if(view.phase==SyncPhase::Downloading&&view.total) { + ImGui::Text("%.1f / %.1f MiB | Downloaded %.1f MiB | Reused %.1f MiB",double(view.received)/dkr::mods::MiB,double(view.total)/dkr::mods::MiB,double(d.wire)/dkr::mods::MiB,double(d.cached)/dkr::mods::MiB); + ImGui::Text("Transfer rate %.2f MiB/s | Gap repairs %llu",d.bytes_per_second/dkr::mods::MiB,static_cast(d.retries)); + } + } if(review && view.manifest) { if(g_online_code_offer!=view.offer){g_online_code_offer=view.offer;g_online_approve_code=false;} ImGui::TextWrapped("Download these mods from the host to join?"); BeginPaddedChild("Required mods",{ImGui::GetContentRegionAvail().x,std::min(230.0F,40.0F+(view.manifest->content.size()+view.manifest->code.size())*28.0F)},true); for(const auto& mod:view.manifest->content) { - const char* kind=mod.kind==Kind::Character?"Character":mod.kind==Kind::TrackLab?"Track Lab":"Track"; + const char* kind=mod.kind==dkr::mods::online::CarContent?"Car":mod.kind==Kind::Character?"Character":mod.kind==Kind::TrackLab?"Track Lab":"Track"; ImGui::TextWrapped("%s: %s",kind,mod.name.c_str()); } for(const auto& code:view.manifest->code)ImGui::TextWrapped("Custom Magic Code: %s (%s)",code.name.c_str(),code.version.c_str()); ImGui::EndChild(); ImGui::Text("Required files: %.1f MiB (installed files are reused)",double(view.total)/dkr::mods::MiB); - ImGui::TextWrapped("New offline copies start disabled. Your existing mod selections and saves are kept."); + ImGui::TextWrapped("Offline copies are saved after joining and start disabled. Your current selections and saves are kept."); ImGui::Checkbox("Keep downloaded mods for offline play",&g_online_keep_downloads); if(!view.manifest->code.empty()) { ImGui::TextWrapped("This lobby runs executable mod code. It can change gameplay and game memory. Only join if you trust the host and mod authors."); diff --git a/runtime-recomp/src/game/runtime_online_ui.inl b/runtime-recomp/src/game/runtime_online_ui.inl index 84d97098..d5f9f7ef 100644 --- a/runtime-recomp/src/game/runtime_online_ui.inl +++ b/runtime-recomp/src/game/runtime_online_ui.inl @@ -894,6 +894,8 @@ struct OnlineFrame { bool has_space = false; // the host can admit one more racer }; +#include "lobby_context.inl" + // Content that slides in after a section or phase change (.is-entering). class OlEnter { public: @@ -947,8 +949,7 @@ const char* OlMenuOwnershipLabel(dkr::runtime::netplay::HostControlPolicy policy std::string OlRulesLine(const dkr::runtime::netplay::Rules& rules) { using dkr::runtime::netplay::SynchronizationMode; - std::string line = rules.synchronization == SynchronizationMode::ExperimentalRollback ? "Experimental Rollback" : - (rules.synchronization == SynchronizationMode::Rollback ? "Host prediction" : "Strict input sync"); + std::string line = dkr::runtime::netplay::rollback_policy::name(rules.synchronization); line += " \xC2\xB7 "; line += rules.automatic_input_delay ? std::string("Automatic input delay") : std::to_string(rules.manual_input_delay) + " frame delay"; @@ -1089,6 +1090,9 @@ void DrawOlFeature(float width, const char* title, const char* description, Cont }); } +extern float g_ol_page_width; +void DrawOlLobbyFilters(float); +void DrawOlSidebarReadiness(float, const OnlineFrame&); void DrawOlGuideCard(float width, const OnlineFrame& frame) { DrawOlFeature(width,"Your online view","Changes your screen only.",[&](float inner) { namespace ov=dkr::runtime::online_view; @@ -1108,9 +1112,13 @@ void DrawOlGuideCard(float width, const OnlineFrame& frame) { ov::requested_label_size=75U+unsigned(size)*25U;SaveSettings(); } PaddockGap(12.F); - OlParagraph("For Auto / Experimental Rollback races. Menus and shared scenes keep their usual view.",13.F,kOlSoft,inner); + OlParagraph("For Rollback races. Menus and shared scenes keep their usual view.",13.F,kOlSoft,inner); }); PaddockGap(20.F); + if (!frame.active && g_online_page.section == OlSection::Lobbies) { + DrawOlSidebarReadiness(width, frame); + PaddockGap(20.F); + } DrawOlFeature(width, "Online guide", "", [&](float inner) { PaddockGap(16.0F); OlParagraph(frame.active ? "Game settings stay fixed in this lobby." @@ -1123,6 +1131,7 @@ void DrawOlGuideCard(float width, const OnlineFrame& frame) { }); } +void DrawOlDirectoryAccount(float); void DrawOlProfileCard(float width, const OnlineFrame& frame, bool profile_section) { using namespace dkr::runtime::netplay; FriendService& service = friend_service(); @@ -1154,6 +1163,7 @@ void DrawOlProfileCard(float width, const OnlineFrame& frame, bool profile_secti ImGui::SetCursorScreenPos(at); ImGui::Dummy({inner, head}); } + DrawOlDirectoryAccount(inner); // Presence. PaddockGap(14.0F); { @@ -1224,6 +1234,9 @@ void DrawOlProfileCard(float width, const OnlineFrame& frame, bool profile_secti ImGui::Dummy({inner, height}); } PaddockGap(13.0F); + if (!profile_section && g_online_page.section == OlSection::Lobbies && g_ol_page_width > 1000.F) { + DrawOlLobbyFilters(inner); + } if (profile_section) { const OlInputResult result = OlInput("##online-display-name", "Display name", g_online_profile_name, sizeof(g_online_profile_name), inner); @@ -1296,7 +1309,8 @@ OlRoomPress DrawOlRoom(const OlRoom& room, float width) { if (OlButton(room.action, OlTone::Go)) pressed = OlRoomPress::Action; } if (room.secondary != nullptr) { - ImGui::SetCursorScreenPos({row.x + OlButtonWidth(room.action) + 8.0F, row.y}); + const bool wrap=OlButtonWidth(room.action)+8.F+OlButtonWidth(room.secondary)>inner; + ImGui::SetCursorScreenPos(wrap?ImVec2{row.x,row.y+48.F}:ImVec2{row.x+OlButtonWidth(room.action)+8.F,row.y}); if (OlButton(room.secondary)) pressed = OlRoomPress::Secondary; } } @@ -1305,7 +1319,9 @@ OlRoomPress DrawOlRoom(const OlRoom& room, float width) { return pressed; } +#include "directory_ui.inl" void DrawOlOpenLobbies(float width, const OnlineFrame& frame) { + if(!g_directory_friends_view){DrawOlPublicLobbies(width,frame);return;} using namespace dkr::runtime::netplay; const auto snapshot = friend_service().snapshot(); const bool can_join = frame.launcher && frame.rom_ready && !frame.active; @@ -1332,26 +1348,16 @@ void DrawOlOpenLobbies(float width, const OnlineFrame& frame) { if (!compatible) room.error = "This invitation was created by a different DKR-R network build."; if (delivered) { room.action = "Accept and join"; - room.action_disabled = !can_join || !compatible; + room.action_disabled = !compatible; room.secondary = "Decline"; } PaddockGap(16.0F); const OlRoomPress pressed = DrawOlRoom(room, inner); std::string error; if (pressed == OlRoomPress::Action) { - g_online_page.join_attempted = true; - g_online_page.join_error.clear(); - if (session().join_friend_invite(invite.lobby_code, g_online_player_name, - invite.admission, error)) { - g_joining_friend_invite = invite; - } else { - OlNotify(error); - } + RequestLobbyInvitation(invite.invite_id,invite.friend_identity); } else if (pressed == OlRoomPress::Secondary) { - FriendLobbyInviteView declined{}; - if (!friend_service().respond_lobby_invite(invite.invite_id, false, declined, error)) { - OlNotify(error); - } + DeclineLobbyInvitation(invite.invite_id,invite.friend_identity); } } for (const FriendView& racer : snapshot->friends) { @@ -1371,7 +1377,7 @@ void DrawOlOpenLobbies(float width, const OnlineFrame& frame) { std::string error; g_online_page.join_attempted = true; g_online_page.join_error.clear(); - if (!session().join(racer.lobby_code, g_online_player_name, error)) OlNotify(error); + ConfigurePartyJoin();if (!session().join(racer.lobby_code, g_online_player_name, error)) OlNotify(error); } } if (shown == 0U) { @@ -1495,7 +1501,7 @@ float OlHostNaturalHeight(float inner, bool previous_save) { PaddockTextHeight(OlSignType(board.title_px, 1.1F), "Start a lobby", inner, true) + 10.0F + OlParagraphHeight(OlHostInvitation(), 15.0F, text) + 14.0F + 10.0F + field + 10.0F + select + 10.0F + OlParagraphHeight(OlSaveSeedDescription(previous_save), 15.0F, text) + 14.0F + 10.0F + - 40.0F + 10.0F + 52.0F + board.pad_bottom + 2.0F; + DirectoryVisibilityHeight(inner) + 40.0F + 10.0F + 52.0F + board.pad_bottom + 2.0F; } std::string OlJoinError(const OnlineFrame& frame) { @@ -1521,7 +1527,7 @@ float OlJoinNaturalHeight(float inner, const std::string& error) { PaddockTextHeight(OlSignType(board.title_px, 1.1F), "Join a friend", inner, true) + 10.0F + OlParagraphHeight("Type the code your host sent you.", 15.0F, text) + 10.0F + 4.0F + tile + 10.0F + (error.empty() ? 0.0F : OlParagraphHeight(error, 14.0F, text, true) + 10.0F) + - OlJoinActionsHeight(inner) + 10.0F + 52.0F + board.pad_bottom + 2.0F; + OlJoinActionsHeight(inner) + 82.0F + 10.0F + 52.0F + board.pad_bottom + 2.0F; } void OlRequestJoin(const OnlineFrame& frame) { @@ -1534,6 +1540,7 @@ void OlRequestJoin(const OnlineFrame& frame) { g_online_page.join_error.clear(); g_online_page.join_attempted = true; std::string error; + ConfigurePartyJoin(); if (!dkr::runtime::netplay::session().join(code, g_online_player_name, error)) { g_online_page.join_error = error; } @@ -1572,11 +1579,14 @@ void DrawOlHostCard(ImVec2 at, float width, float height, const OnlineFrame& fra PaddockGap(10.0F); OlParagraph(OlSaveSeedDescription(previous_save), 15.0F, 0xD3E5EC, text); PaddockGap(14.0F + 10.0F); + DrawOlDirectoryVisibility(inner,false); if (OlButton("Host settings")) g_online_page.section = OlSection::Settings; ImGui::EndGroup(); ImGui::SetCursorScreenPos({x, at.y + height - 2.0F - board.pad_bottom - 52.0F}); if (OlCta("CREATE LOBBY", OlCtaColour::Green, inner, - !frame.launcher || !frame.rom_ready || frame.active || OnlineModSelectionLocked())) { + !frame.launcher || !frame.rom_ready || frame.active || OnlineModSelectionLocked() || + (dkr::online::view()->feature_enabled&&!dkr::online::view()->connected) || + (dkr::online::view()->public_desired&&!dkr::online::view()->connected))) { g_online_page.join_attempted = false; g_online_page.join_error.clear(); CreateOnlineLobby(); @@ -1601,6 +1611,11 @@ void DrawOlJoinCard(ImVec2 at, float width, float height, const OnlineFrame& fra PaddockRgb(0x031623)); PaddockGap(10.0F); OlParagraph("Type the code your host sent you.", 15.0F, 0xD3E5EC, text); + PaddockGap(8.F); + static const std::vector join_modes{"Racer","Watch Race"}; + int join_mode=g_online_join_watching?1:0; + if(OlSelect("party-join-role","Join as",&join_mode,join_modes,inner))g_online_join_watching=join_mode!=0; + PaddockGap(10.0F + 4.0F); // One real input laid over five readable tiles. { @@ -1945,90 +1960,50 @@ void DrawOlChecklist(float width, const OnlineFrame& frame) { box.End([&](ImDrawList* target, ImVec2 a, ImVec2 b) { PaintOlPanel(target, a, b, look); }); } +#include "lobby_sidebar.inl" + // ------------------------------------------------------------ host settings void DrawOlHostSettings(float width) { - using dkr::runtime::netplay::SynchronizationMode; DrawOlFeature(width, "Host settings", "Applies to the next lobby you create.", [&](float inner) { bool changed = false; + changed |= OlSwitchRow("##spectators", "Spectator-capable lobby", + "Allow up to eight spectators.", &g_online_allow_spectators, inner, false); static const std::vector ownership{ "Host until character select", "Host for shared menus", "All players"}; static const std::vector racers{"2", "3", "4"}; - static const std::vector modes{"Auto", "Host Prediction", "Strict Input Sync", -#if defined(DKR_EXPERIMENTAL_RACE_TEST) - "Experimental Rollback"}; -#else - "Experimental Rollback"}; -#endif - PaddockGap(16.0F); + PaddockGap(16.F); changed |= OlSelect("menu-ownership", "Who controls the menus?", &g_online_host_control, ownership, inner); - PaddockGap(16.0F); + PaddockGap(16.F); int racer_option = g_online_maximum_players - 2; if (OlSelect("maximum-racers", "Maximum players", &racer_option, racers, inner)) { g_online_maximum_players = racer_option + 2; changed = true; } - PaddockGap(16.0F); - OlParagraph("Adventure supports 2 players. Races and minigames support up to 4.", 15.0F, kOlSoft, - inner); - PaddockGap(16.0F); - int selected_mode = dkr::runtime::netplay::experimental::host_backend_choice( - g_online_synchronization, g_online_experimental_rollback, g_online_automatic_backend); - if (OlSelect("synchronization", "Online mode", &selected_mode, modes, inner)) { - changed |= dkr::runtime::netplay::experimental::select_host_backend( - selected_mode, g_online_synchronization, g_online_experimental_rollback, - g_online_automatic_backend); - } - PaddockGap(16.0F); - if (g_online_automatic_backend) { - OlParagraph("Uses Experimental Rollback.", - 14.0F, kOlSoft, inner); - PaddockGap(8.0F); - } - if (CurrentOnlineSynchronization() == SynchronizationMode::ExperimentalRollback) { -#if defined(DKR_EXPERIMENTAL_RACE_TEST) - changed |= OlRange("experimental-window", "Prediction window (frames)", &g_online_rollback_window, 2, 20, inner); - OlParagraph("Higher values allow more time for late inputs, but can increase replay work. Automatic input delay is a good starting point.", - 14.0F,kOlSoft,inner,true); -#else - OlParagraph("This online mode isn't available in this build.", 14.0F, kOlError, inner, true); - DrawTechnicalDetails(dkr::runtime::netplay::experimental::runtime_admission_error(SynchronizationMode::ExperimentalRollback)); -#endif - PaddockGap(8.0F); - if (OlButton("Use Host Prediction", OlTone::Plain, inner)) { - g_online_automatic_backend = false; - g_online_experimental_rollback = false; - changed = true; - } - } else if (CurrentOnlineSynchronization() == SynchronizationMode::Rollback) { - changed |= OlRange("rollback-window", "Prediction window (frames)", &g_online_rollback_window, 2, 20, inner); - OlParagraph("The host handles missing inputs. Response time depends on the connection.", - 14.0F, kOlSoft, inner); - } else { - OlParagraph("Waits for every player's input. Best for stable, low-latency connections.", - 14.0F, kOlError, inner, true); - } - PaddockGap(16.0F); + PaddockGap(16.F); + OlParagraph("Adventure supports 2 players. Races and minigames support up to 4.", 15.F, kOlSoft, inner); + PaddockGap(16.F); + OlParagraph("Online mode: Rollback", 15.F, kOlCream, inner); + PaddockGap(16.F); + changed |= OlRange("experimental-window", "Prediction window (frames)", &g_online_rollback_window, 2, 20, inner); + OlParagraph("How many frames Rollback can predict while waiting for late inputs.", 14.F, kOlSoft, inner, true); + PaddockGap(16.F); changed |= OlSwitchRow("##automatic-delay", "Automatic input delay", - "Adjusts input delay for the lobby's connection.", - &g_online_automatic_delay, inner, false); - PaddockGap(16.0F); - changed |= OlRange("input-delay", "Input delay (frames)", &g_online_manual_delay, 0, 9, inner, - g_online_automatic_delay); - if (!g_online_automatic_delay) { - PaddockGap(16.0F); - if (OlButton("Use automatic input delay", OlTone::Plain, inner)) { - g_online_automatic_delay = true; - changed = true; - } - } - PaddockGap(16.0F); - changed |= OlSwitchRow("##record-replay", "Record game replay", "Records game inputs for replay tools, not a video.", - &g_online_record_replay, inner, false); + "Adjusts input delay for the lobby's connection.", &g_online_automatic_delay, inner, false); + PaddockGap(16.F); + changed |= OlRange("input-delay", "Input delay (frames)", &g_online_manual_delay, 0, 9, inner, g_online_automatic_delay); + PaddockGap(16.F); + changed |= OlSwitchRow("##record-replay", "Record game replay", + "Records game inputs for replay tools, not a video.", &g_online_record_replay, inner, false); + PaddockGap(16.F); + changed |= OlSwitchRow("##development-lobby", "Development lobby", + "Allows host tool commands. All racers must agree. Progress is temporary; achievements are paused.", + &g_online_development, inner, false); if (changed) SaveSettings(); }); } + // ------------------------------------------------------------ online profile void DrawOlPrivacy(float width) { @@ -2039,7 +2014,7 @@ void DrawOlPrivacy(float width) { PaddockGap(8.0F); bool appear_offline = service.appear_offline(); if (OlSwitchRow("##appear-offline", "Appear offline", - "Friends see you as offline. You can still join with a code.", &appear_offline, inner, + "Friends see you as offline. You still count toward players online. Public hosting is disabled.", &appear_offline, inner, false)) { std::string error; if (!service.set_appear_offline(appear_offline, error)) OlNotify(error); @@ -2257,7 +2232,7 @@ void DrawOlFriends(float width, const OnlineFrame& frame) { const auto snapshot = service.snapshot(); const bool can_invite = frame.in_lobby && frame.view.host; const FriendLobbyAdvertisement lobby = FriendLobbyFromSession(frame.view); - const bool invite_space = can_invite && lobby.hosting && lobby.players < lobby.maximum_players && !frame.busy; + const bool invite_space = can_invite && (OlPartyActive()?frame.has_space:(lobby.hosting && lobby.players < lobby.maximum_players && !frame.busy)); std::map latest; for (const FriendLobbyInviteView& invite : snapshot->outgoing_lobby_invites) { if (!latest.contains(invite.friend_identity)) latest[invite.friend_identity] = invite; @@ -2600,115 +2575,7 @@ void DrawOlOverlays(float width) { // ------------------------------------------------------------ connection -void OlRequestConnectionTest() { - std::string error; - if (!dkr::runtime::netplay::session().request_connection_test(error)) OlNotify(error); -} - -bool OlCanTestConnection(const OnlineFrame& frame) { - using dkr::runtime::netplay::ConnectionState; - return frame.view.host && !frame.busy && - (frame.view.state == ConnectionState::Hosting || frame.view.state == ConnectionState::Lobby); -} - -bool OlHasTestResults(const OnlineFrame& frame) { - return std::any_of(frame.view.connection_test_results.begin(), frame.view.connection_test_results.end(), - [](const auto& result) { return result.valid; }); -} - -std::string OlTestCountdown(const OnlineFrame& frame) { - return std::to_string((frame.view.connection_test_remaining_ms + 999U) / 1000U) + " seconds"; -} - -void DrawOlConnection(float width, const OnlineFrame& frame) { - using namespace dkr::runtime::netplay; - const SessionView& view = frame.view; - const bool rollback = view.room.rules.synchronization == SynchronizationMode::Rollback; - const bool running = view.state == ConnectionState::Running; - DrawOlFeature(width, "Connection status", "", [&](float inner) { - PaddockGap(16.0F); - char loss[16]{}; - std::snprintf(loss, sizeof(loss), view.network_loss_percent == std::floor(view.network_loss_percent) - ? "%.0f%%" : "%.1f%%", - static_cast(view.network_loss_percent)); - const std::string measuring = "\xE2\x80\x94"; - const std::array, 6> metrics{{ - {"Online mode", synchronization_name(view.room.rules.synchronization)}, - {"Input delay", std::to_string(view.input_delay_frames) + - (view.input_delay_frames == 1U ? " frame" : " frames")}, - {"App RTT", view.network_rtt_ms > 0U ? std::to_string(view.network_rtt_ms) + " ms" : measuring}, - {"Jitter", view.network_rtt_ms > 0U ? std::to_string(view.network_jitter_ms) + " ms" : measuring}, - {"Expired probes", loss}, - {"Input stalls", std::to_string(view.input_stalls)}, - }}; - const int columns = OlNarrow(540.0F) ? 2 : 3; - const float cell = (inner - 12.0F * (columns - 1)) / columns; - const PaddockType caption = OlRead(12.5F, false, 1.5F); - const PaddockType value = OlRead(20.0F, true, 1.5F); - const float cell_height = 16.0F + caption.line + 8.0F + value.line + 16.0F; - const ImVec2 at = ImGui::GetCursorScreenPos(); - ImDrawList* draw = ImGui::GetWindowDrawList(); - for (std::size_t index = 0U; index < metrics.size(); ++index) { - const int column = static_cast(index) % columns; - const int row = static_cast(index) / columns; - const ImVec2 a{at.x + column * (cell + 12.0F), at.y + row * (cell_height + 12.0F)}; - PaddockFill(draw, a, {a.x + cell, a.y + cell_height}, PaddockRound(10.0F), PaddockCol(0x061D2C)); - PaddockTextStyle label; - label.colour = PaddockCol(kOlSoft); - PaddockTextAt(draw, caption, {a.x + 16.0F, a.y + 16.0F}, cell - 32.0F, metrics[index].first, label); - PaddockTextStyle number; - number.colour = PaddockCol(kOlInk); - PaddockTextAt(draw, value, {a.x + 16.0F, a.y + 16.0F + caption.line + 8.0F}, cell - 32.0F, - metrics[index].second, number); - } - const int rows = (static_cast(metrics.size()) + columns - 1) / columns; - ImGui::Dummy({inner, rows * cell_height + (rows - 1) * 12.0F}); - PaddockGap(16.0F); - std::string summary = "Waiting for the race to start."; - if (running) { - if (view.rollback_certified) { - summary = "Game in sync"; - } else { - summary = "Checking game sync..."; - } - } - OlParagraph(summary, 15.0F, kOlSoft, inner); - if (running && view.recovering) { - PaddockGap(16.0F); - OlParagraph("Restoring game sync...", - 14.0F, kOlAmber, inner, true); - } - if (!view.room.rules.automatic_input_delay && view.network_rtt_ms > 0U) { - const auto recommended = view.room.rules.synchronization==SynchronizationMode::ExperimentalRollback ? - dkr::runtime::netplay::experimental::automatic_delay(view.network_rtt_ms,view.room.rules.rollback_window): - host_authoritative_input_delay_frames( - view.network_rtt_ms, view.network_jitter_ms, view.network_loss_percent, - view.method == ConnectionMethod::Lan); - if (view.input_delay_frames < recommended) { - PaddockGap(16.0F); - OlParagraph("Input delay may be too low for this connection. Try Automatic next match.", - 14.0F, kOlAmber, inner, true); - } - } - if (view.room.rules.record_replay) { - PaddockGap(16.0F); - OlParagraph("Game input recording enabled.", 15.0F, kOlInk, inner); - } - if (view.host) { - PaddockGap(16.0F); - const OlDisabled disabled(!OlCanTestConnection(frame)); - if (OlButton("Test session connection", OlTone::Plain, inner)) OlRequestConnectionTest(); - } - if (view.connection_test_active) { - PaddockGap(16.0F); - OlParagraph("Testing connection... " + OlTestCountdown(frame), 15.0F, kOlInk, inner); - } - if (OlHasTestResults(frame)) { - PaddockGap(16.0F); - if (OlButton("View test results", OlTone::Plain, inner)) g_online_page.open_results = true; - } - }); -} +#include "connection_ui.inl" // ------------------------------------------------------------ lobby @@ -2803,7 +2670,7 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { using namespace dkr::runtime::netplay; const SessionView& view = frame.view; const int maximum = std::clamp(view.room.rules.maximum_players, 2, static_cast(view.room.players.size())); - const int columns = OlNarrow(760.0F) ? 1 : 2; + const int columns = width < 660.F ? 1 : 2; const float cell = (width - 14.0F * (columns - 1)) / columns; const ImVec2 origin = ImGui::GetCursorScreenPos(); ImDrawList* draw = ImGui::GetWindowDrawList(); @@ -2821,7 +2688,7 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { const bool you = slot == view.local_slot; std::vector items; const bool loaded = player.loaded; - const char* ready_text = loaded ? "Loaded" : player.ready ? "Ready" : "Not ready yet"; + const char* ready_text = OlPartyActive()&&OlPartyLive()?"Racing":loaded ? "Loaded" : player.ready ? "Ready" : "Not ready yet"; const PaddockType& ready_type = player.ready ? meta_bold : meta; items.push_back({13.0F + PaddockMeasure(ready_type, ready_text), [&, ready_text, player](ImVec2 at) { draw->AddCircleFilled({at.x + 3.5F, at.y + meta.line * 0.5F}, 3.5F, @@ -2830,7 +2697,7 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { PaddockCol(player.ready ? kOlGoText : kOlSoft), ready_text, ready_text + std::strlen(ready_text)); }}); - const char* save_text = view.online_save_ready[slot] ? "Save ready" + const char* save_text = OlPartyActive()?(g_ol_party.save_ready?"Session ready":"Syncing session..."):view.online_save_ready[slot] ? "Save ready" : "Syncing save..."; items.push_back({PaddockMeasure(meta, save_text), [&, save_text](ImVec2 at) { PaddockDrawRun(draw, meta, at, PaddockCol(kOlSoft), save_text, save_text + std::strlen(save_text)); @@ -2840,11 +2707,12 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { PaddockDrawRun(draw, meta, at, PaddockCol(kOlSoft), "This PC", "This PC" + 7); }}); } else { - const std::string ping = std::to_string(player.ping_ms) + " ms"; + const std::string ping = OlPartyActive()&&!player.ping_ms?"Measuring...":std::to_string(player.ping_ms) + " ms"; items.push_back({19.0F + PaddockMeasure(meta, ping), [&, player](ImVec2 at) { - DrawOlPing(draw, at, meta.line, player.ping_ms); + if(!OlPartyActive()||player.ping_ms)DrawOlPing(draw, at, meta.line, player.ping_ms); + else PaddockDrawRun(draw,meta,at,PaddockCol(kOlSoft),"Measuring...","Measuring..."+12); if (ImGui::IsWindowHovered() && - ImGui::IsMouseHoveringRect(at, {at.x + 19.0F + 60.0F, at.y + meta.line})) { + ImGui::IsMouseHoveringRect(at, {at.x + 19.0F + 60.0F, at.y + meta.line}) && !OlPartyActive()) { ImGui::SetTooltip("%s \xC2\xB7 %u ms round trip \xC2\xB7 jitter %u ms \xC2\xB7 %.1f%% expired probes", OnlineRouteName(player.route), static_cast(player.ping_ms), static_cast(player.jitter_ms), @@ -2869,7 +2737,7 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { for (int slot = 0; slot < maximum; ++slot) { const Player& player = view.room.players[static_cast(slot)]; if (!player.occupied) continue; - const bool removable = view.host && static_cast(slot) != view.local_slot; + const bool removable = OlPartyActive() || view.host && static_cast(slot) != view.local_slot; const float body = body_width(player, removable); const float body_height = strong.line + 3.0F + measure_meta(meta_items(static_cast(slot)), body); heights[static_cast(slot)] = std::max(76.0F, body_height + 20.0F); @@ -2921,7 +2789,7 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { } DrawOlAvatar(draw, {x, a.y + std::round((row_height - 38.0F) * 0.5F)}, 38.0F, player.display_name); x += 38.0F + 12.0F; - const bool removable = view.host && !you; + const bool removable = OlPartyActive() || view.host && !you; const float body = body_width(player, removable); const auto items = meta_items(slot); const float body_height = strong.line + 3.0F + measure_meta(items, body); @@ -2937,10 +2805,11 @@ void DrawOlGrid(float width, const OnlineFrame& frame) { if (removable) { ImGui::SetCursorScreenPos({b.x - 10.0F - 40.0F, a.y + std::round((row_height - 40.0F) * 0.5F)}); const std::string tooltip = "Remove " + player.display_name; - const OlDisabled disabled(frame.busy); - if (OlButton("\xC3\x97##remove", OlTone::Icon, 40.0F, tooltip.c_str())) { + const OlDisabled disabled(OlPartyActive()?OlPartyChanging(frame):frame.busy); + if (OlButton(OlPartyActive()?"...##player-options":"\xC3\x97##remove", OlTone::Icon, 40.0F, OlPartyActive()?"Player options":tooltip.c_str())) { std::string error; - if (!dkr::runtime::netplay::session().kick_player(static_cast(slot), error)) { + if(OlPartyActive())OlPartyOpenMember(g_ol_party.link.party.controls[slot].member); + else if (!dkr::runtime::netplay::session().kick_player(static_cast(slot), error)) { OlNotify(error); } } @@ -2970,18 +2839,22 @@ void DrawOlGate(float width, const OnlineFrame& frame) { for (const PendingJoinView& pending : view.pending_joins) { ImGui::PushID(static_cast(pending.request_id & 0x7FFFFFFFU)); const ImVec2 at = ImGui::GetCursorScreenPos(); - const std::string reason = pending.preparing_mods ? "Preparing required session mods" + const bool pending_space=OlPartyJoinSpace(frame,pending); + const auto watch=g_ol_party.pending_watch.find(pending.request_id); + const std::string reason = OlPartyActive()&&!pending.preparing_mods&&pending.compatible ? + (pending_space?(watch!=g_ol_party.pending_watch.end()&&watch->second?"Wants to watch - no controller port":"Wants a racing place"): + OlPartyLive()?"A running race accepts spectators only":"No space for this requested role") : pending.preparing_mods ? "Preparing required session mods" : !pending.compatible ? pending.compatibility - : frame.has_space ? "Used your lobby code" : "Lobby is full"; + : pending_space ? "Used your lobby code" : "Lobby is full"; struct Button { const char* label; OlTone tone; bool disabled; }; const std::array buttons{{ - {"Let in", OlTone::Go, pending.preparing_mods || !frame.has_space || !pending.compatible || view.lobby_locked}, - {"Decline", OlTone::Plain, frame.busy}, - {"Block for session", OlTone::Plain, frame.busy}, + {"Let in", OlTone::Go, pending.preparing_mods || !pending_space || !pending.compatible || view.lobby_locked}, + {"Decline", OlTone::Plain, OlPartyActive()?OlPartyChanging(frame):frame.busy}, + {"Block for session", OlTone::Plain, OlPartyActive()?OlPartyChanging(frame):frame.busy}, }}; float buttons_width = 0.0F; for (const Button& button : buttons) buttons_width += OlButtonWidth(button.label) + 10.0F; @@ -3038,6 +2911,12 @@ void DrawOlStartBar(float width, const OnlineFrame& frame) { using namespace dkr::runtime::netplay; const SessionView& view = frame.view; DirectSession& online = session(); + const auto room_key=stable_hash(view.room.room_id); + if(g_online_development_room!=room_key){g_online_development_room=room_key;g_online_development_consent=false;} + if(view.room.rules.development) { + OlParagraph("DEVELOPMENT LOBBY - The host can change gameplay with development tools. Progress is temporary.",14.0F,kOlError,width,true); + ImGui::Checkbox("I agree to development commands for this lobby",&g_online_development_consent); + } const int maximum = std::clamp(view.room.rules.maximum_players, 2, static_cast(view.room.players.size())); std::size_t total = 0U; std::size_t ready = 0U; @@ -3097,7 +2976,7 @@ void DrawOlStartBar(float width, const OnlineFrame& frame) { const bool pair = view.host && inner >= 190.0F * 2.0F + 10.0F; const float button = view.host && pair ? (inner - 10.0F) * 0.5F : inner; const bool can_ready = !frame.busy && view.local_online_save_ready && (local_ready || input_available) && - local != nullptr; + local != nullptr && (!view.room.rules.development||g_online_development_consent); if (OlCta(local_ready ? "NOT READY" : "READY TO RACE", local_ready ? OlCtaColour::Blue : OlCtaColour::Green, button, !can_ready)) { std::string error; @@ -3176,6 +3055,15 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { using namespace dkr::runtime::netplay; const SessionView& view = frame.view; const bool host = view.host; + const auto directory=dkr::online::view(); + const auto hosting=dkr::online::hosting_mode(&session()); + const char* visibility=!host?(view.room.visibility==Visibility::Public?"PUBLIC lobby":"PRIVATE lobby"): + hosting==1?"PRIVATE - recreate this lobby to enable public discovery.": + !directory->connected&&directory->feature_enabled?"Lobby listing unavailable - peer session continues.": + directory->updating?"Updating lobby visibility...":directory->public_listed?"PUBLIC - listed worldwide": + directory->public_desired?"PUBLIC - not currently listed":"PRIVATE - friends or lobby code"; + OlParagraph(visibility,14.F,kOlSoft,width); + if(host)DrawOlDirectoryVisibility(width,frame.busy||!directory->connected||hosting!=2); const bool launched = view.state == ConnectionState::Loading || view.state == ConnectionState::Running; const int maximum = std::clamp(view.room.rules.maximum_players, 2, static_cast(view.room.players.size())); std::size_t total = 0U; @@ -3186,6 +3074,7 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { ++total; if (player.host) host_name = player.display_name; } + if(OlPartyActive())host_name=OlPartyHostName(); OlParagraph("ONLINE SESSION SAVE - supplied by " + host_name, 16.0F, kOlCream, width); OlParagraph("Offline progress stays separate.", 15.0F, kOlSoft, width); @@ -3198,7 +3087,8 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { PaddockGap(8.0F); OlParagraph("LOBBY MODS: " + std::to_string(count(dkr::mods::online::Kind::Track) + count(dkr::mods::online::Kind::TrackLab)) + - " tracks, " + std::to_string(count(dkr::mods::online::Kind::Character)) + " characters.", + " tracks, " + std::to_string(count(dkr::mods::online::Kind::Character)) + " characters, " + std::to_string(count(static_cast(3))) + " vehicles, " + + std::to_string(mods.manifest?mods.manifest->code.size():0) + " Magic Codes.", 16.0F, kOlGoText, width); OlParagraph("Your offline mod selection returns when you leave. Modded online progress uses a separate save.", 15.0F, kOlSoft, width); @@ -3226,16 +3116,18 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { const bool side = !narrow && inner - plate_width - 20.0F >= 260.0F; const float title_width = side ? inner - plate_width - 20.0F : inner; ImGui::BeginGroup(); - ImGui::Dummy({title_width, DrawOlLive(draw, at, host ? "HOSTING" : "JOINED")}); + ImGui::Dummy({title_width, DrawOlLive(draw, at, OlPartyActive()?(OlPartyWatching()?"WATCHING":OlPartyLive()?"RACE IN PROGRESS":host?"HOSTING":"JOINED"):host?"HOSTING":"JOINED")}); PaddockGap(10.0F); const std::string title = host ? view.room.name : host_name + "\xE2\x80\x99s lobby"; PaddockText(OlSignType(narrow ? 30.0F : 38.0F, 1.1F), PaddockRgb(kOlCream), title, title_width, true, PaddockRgb(0x031623)); PaddockGap(4.0F); - const std::string line = host + std::string line = host ? std::to_string(total) + " of " + std::to_string(maximum) + " racers. " + (view.lobby_locked ? "Locked: nobody new can ask to join." : "Friends can join with the code.") : std::string("The host starts the race when everyone is ready."); + if(OlPartyActive())line=std::to_string(total)+" of "+std::to_string(maximum)+" racers. "+ + std::to_string(OlPartySpectators())+" of 8 spectators. "+(view.lobby_locked?"Lobby locked.":"Friends can join with the code."); OlParagraph(line, 15.0F, kOlSoft, title_width); ImGui::EndGroup(); const float title_height = ImGui::GetItemRectSize().y; @@ -3265,15 +3157,9 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { if (host) { PaddockGap(20.0F); const OlEnter enter(chunk++); - const ImVec2 row = ImGui::GetCursorScreenPos(); - { - const OlDisabled disabled(!OlCanTestConnection(frame)); - if (OlButton("Test session connection")) OlRequestConnectionTest(); - } - ImGui::SetCursorScreenPos({row.x + OlButtonWidth("Test session connection") + 8.0F, row.y}); - const OlDisabled disabled(!frame.has_space); - if (OlButton("Invite friends")) g_online_page.section = OlSection::Friends; + DrawOlLobbyToolbar(inner,frame); } + DrawOlPartySpectators(inner,frame); if (view.connection_test_active) { PaddockGap(20.0F); const OlEnter enter(chunk++); @@ -3292,7 +3178,8 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { if (OlButton("View test results")) g_online_page.open_results = true; } PaddockGap(20.0F); - if (launched) { + if(OlPartyActive()&&(launched||OlPartyWatching()||g_ol_party.link.stopping)){DrawOlPartyStart(inner,frame);} + else if (launched) { const OlEnter enter(chunk++); const bool running = view.state == ConnectionState::Running; OlPanelLook room; @@ -3326,7 +3213,7 @@ void DrawOlLobby(float width, const OnlineFrame& frame) { bool locked = view.lobby_locked; ImGui::BeginGroup(); if (OlSwitchRow("##lock-lobby", "Lock lobby", "Prevents new players joining.", - &locked, lock_width, false, frame.busy)) { + &locked, lock_width, false, OlPartyActive()?OlPartyChanging(frame):frame.busy)) { std::string error; if (!dkr::runtime::netplay::session().set_lobby_locked(locked, error)) OlNotify(error); } @@ -3522,6 +3409,8 @@ bool BeginOlDialog(const char* name) { return true; } +#include "report_ui.inl" + void DrawOlModals(const OnlineFrame& frame) { using namespace dkr::runtime::netplay; OnlinePageState& state = g_online_page; @@ -3529,7 +3418,7 @@ void DrawOlModals(const OnlineFrame& frame) { constexpr const char* kClose = "Close this lobby?"; constexpr const char* kBlock = "Block racer?"; constexpr const char* kRemove = "Remove friend?"; - constexpr const char* kResults = "Connection test results"; + constexpr const char* kResults = "Rollback connection report"; constexpr const char* kManage = "###online-manage-friend"; constexpr const char* kMatchEnd = "Online match ended"; if (std::exchange(state.open_match_end, false)) ImGui::OpenPopup(kMatchEnd); @@ -3620,41 +3509,8 @@ void DrawOlModals(const OnlineFrame& frame) { ImGui::EndPopup(); } - if (BeginOlDialog(kResults)) { - const SessionView& view = frame.view; - std::uint8_t overall = 10U; - bool any = false; - for (const auto& result : view.connection_test_results) { - if (!result.valid) continue; - overall = std::min(overall, result.score); - any = true; - } - const ImVec4 band = !any || overall <= 4U ? ImVec4{0.95F, 0.18F, 0.12F, 1.0F} - : overall <= 7U ? ImVec4{1.0F, 0.58F, 0.08F, 1.0F} - : ImVec4{0.12F, 0.88F, 0.42F, 1.0F}; - ImGui::PushStyleColor(ImGuiCol_Text, band); - ImGui::Text("Overall connection: %u / 10", static_cast(any ? overall : 0U)); - ImGui::PopStyleColor(); - ImGui::TextWrapped("%s", !any || overall <= 4U ? "Poor \xE2\x80\x94 expect an unstable online experience" - : overall <= 7U ? "Average \xE2\x80\x94 playable, but hitches may occur" - : "Good - fewer network interruptions expected"); - ImGui::Dummy({0.0F, 6.0F}); - for (std::size_t slot = 0U; slot < view.room.players.size(); ++slot) { - const auto& result = view.connection_test_results[slot]; - const Player& player = view.room.players[slot]; - if (!result.valid || !player.occupied) continue; - if (player.host) { - ImGui::TextWrapped("%s: %u / 10 \xC2\xB7 Local host", player.display_name.c_str(), - static_cast(result.score)); - } else { - ImGui::TextWrapped("%s: %u / 10 - Delay %u ms - Loss %.1f%%", - player.display_name.c_str(), static_cast(result.score), - static_cast(result.p95_rtt_ms), static_cast(result.loss_percent)); - } - } - ImGui::Dummy({0.0F, 6.0F}); - ImGui::TextWrapped("A 7-second connection check. Results don't measure game performance."); - if (DrawOlDialogActions({{"CLOSE"}}) == 0) ImGui::CloseCurrentPopup(); + if (BeginOlScoreboard(kResults)) { + DrawOlRollbackResults(frame); ImGui::EndPopup(); } } @@ -3670,9 +3526,9 @@ void DrawOnlineGuideModal() { ImGui::OpenPopup(kPopupName); } constexpr std::array paragraphs{{ - "Create a lobby and share its 5-character code, or enter a friend's code to join.", - "The host approves joining players. You can also join friends through Open Lobbies.", - "Everyone selects Ready, then the host starts the game. Auto uses Experimental Rollback.", + "Sign in with Discord to browse public lobbies or join with a code.", + "Choose Public or Private when hosting. The host approves joining players.", + "Every racer selects Ready, then the host starts. Spectators do not need a racing slot. Online play uses Rollback.", "Use the same ROM version. The host supplies online mods and a separate online save.", "Offline saves stay separate. Troubleshooting tools are available in About DKR-R.", }}; @@ -3721,6 +3577,12 @@ void DrawOlMain(float width, const OnlineFrame& frame) { switch (g_online_page.section) { case OlSection::Lobbies: { const OlEnter enter(0); + // On narrow windows, keep the filters before the list. The wide + // layout places the same controls beneath the account details. + if (g_ol_page_width <= 1000.F) { + DrawOlCard(width, OlPanelLook{}, [&](float inner) { DrawOlLobbyFilters(inner); }); + PaddockGap(20.F); + } DrawOlOpenLobbies(width, frame); break; } @@ -3786,17 +3648,20 @@ void DrawOlMain(float width, const OnlineFrame& frame) { break; } if (!frame.active && - (g_online_page.section == OlSection::Play || g_online_page.section == OlSection::Lobbies)) { + g_online_page.section == OlSection::Play) { PaddockGap(20.0F); DrawOlChecklist(width, frame); } } +#include "lobby_controls.inl" + void DrawOnlinePage(float available_width, bool launcher, bool rom_ready) { using namespace dkr::runtime::netplay; DirectSession& online = session(); PumpDirectSessionIfDue(); - const SessionView view = online.presentation_view(); + SessionView view = online.presentation_view(); + OlPartyProject(view); OnlinePageState& state = g_online_page; const int frame_count = ImGui::GetFrameCount(); const bool entered = state.context != ImGui::GetCurrentContext() || state.last_frame != frame_count - 1; @@ -3807,7 +3672,7 @@ void DrawOnlinePage(float available_width, bool launcher, bool rom_ready) { const bool joining = active && !view.host && (view.state == ConnectionState::Connecting || view.state == ConnectionState::AwaitingApproval); const bool in_lobby = active && !joining; - const bool busy = view.owned_match_ending || view.connection_test_active || view.launch_countdown_active || + const bool busy = (OlPartyActive()&&g_ol_party.link.stopping) || view.owned_match_ending || view.connection_test_active || view.launch_countdown_active || view.launch_stage != LaunchStage::Idle || view.state == ConnectionState::Loading || view.state == ConnectionState::Running; const int maximum = std::clamp(view.room.rules.maximum_players, 2, static_cast(view.room.players.size())); @@ -3815,10 +3680,12 @@ void DrawOnlinePage(float available_width, bool launcher, bool rom_ready) { for (const Player& player : view.room.players) { if (player.occupied) ++occupied; } - const bool has_space = in_lobby && view.host && !view.lobby_locked && !busy && occupied < maximum && + bool has_space = in_lobby && view.host && !view.lobby_locked && !busy && occupied < maximum && (view.state == ConnectionState::Hosting || view.state == ConnectionState::Lobby); + if(OlPartyActive())has_space=in_lobby&&view.host&&!view.lobby_locked&&!g_ol_party.link.stopping&& + !g_ol_rollback_check.active&&g_ol_party.link.party.members.size()connected&&directory->count_available?std::to_string(directory->online)+" players online":directory->signed_in?"Players online: unavailable":"Sign in with Discord to connect",14.F,kOlGoText,text_width); ImGui::EndGroup(); float bottom = ImGui::GetItemRectMax().y; if (rom_slot) { @@ -3969,6 +3839,7 @@ void DrawOnlinePage(float available_width, bool launcher, bool rom_ready) { if (indent > 0.0F) ImGui::Unindent(indent); DrawOlModals(frame); + DrawOlPartyDialogs(frame); DrawOnlineCodeKeyboard(); DrawFriendCodeKeyboard(); DrawFriendSearchKeyboard(); diff --git a/runtime-recomp/src/game/runtime_platform.cpp b/runtime-recomp/src/game/runtime_platform.cpp index 3cdfe709..5ca37cb1 100644 --- a/runtime-recomp/src/game/runtime_platform.cpp +++ b/runtime-recomp/src/game/runtime_platform.cpp @@ -1,10 +1,11 @@ +#include "invite_handoff.hpp" #include "runtime_platform.hpp" #include "application_layout.hpp" #include "audio_equalizer.hpp" #include "controller_snapshot.hpp" #include "controller_mapping_policy.hpp" #include "custom_music.hpp" -#include "modern_camera.hpp" +#include "camera_runtime.hpp" #include "netplay/online_input_broker.hpp" #include "online_input_policy.hpp" #include "runtime_input.hpp" @@ -2420,6 +2421,7 @@ void dkr::runtime::platform::set_audio_frequency(std::uint32_t frequency) { void dkr::runtime::platform::poll_input() { #if DKR_RUNTIME_HAS_RT64 if (g_input_backend_switch_in_progress.load(std::memory_order_acquire)) { + for(unsigned profile=0;profile<4;++profile)dkr::runtime::camera::actions().publish(profile,{}); ClearPublishedControllerInput(false); if (g_online_input_routing.load(std::memory_order_acquire)) { const std::size_t profile = std::min( @@ -2438,7 +2440,26 @@ void dkr::runtime::platform::poll_input() { SDL_GameControllerUpdate(); RefreshControllers(false); } - const bool blocked = dkr::runtime::ui::overlay_visible(); + // A confirm/back press used by the invitation must be released before + // it can become racing input. Read the already refreshed physical state; + // do not clear or mutate any canonical online controller ports. + if(dkr::runtime::invites::release_pending()) { + bool held=false;int count=0;const auto* keys=SDL_GetKeyboardState(&count); + for(int key=0;key16000; + } + } else for(auto* controller:g_controllers)if(controller) { + for(int button=0;button16000; + } + dkr::runtime::invites::release_if_clear(!held); + } + + const bool blocked = dkr::runtime::ui::overlay_visible() || dkr::runtime::camera::editor_open.load(); const bool window_focused = g_window != nullptr && (SDL_GetWindowFlags(g_window) & SDL_WINDOW_INPUT_FOCUS) != 0U; const bool online_routing = @@ -2488,9 +2509,25 @@ void dkr::runtime::platform::poll_input() { #endif } #endif - dkr::runtime::modern_camera::publish_input( - player, state.camera_x, state.camera_y, state.look_back, - online_routing); + dkr::runtime::camera::actions().publish(unsigned(player), + {state.camera_actions,state.orbit_x,state.orbit_y,0, + gameplay_blocked||!dkr::runtime::camera::owns_controls(unsigned(player)),true}); + if(dkr::runtime::camera::owns_controls(unsigned(player))) { + // Remove only C buttons whose binding is actually shared with a + // camera direction. Explicit independent C bindings remain usable. + using A=dkr::runtime::input::Action; + const A camera[]={A::OrbitUp,A::OrbitDown,A::OrbitLeft,A::OrbitRight}; + const A native[]={A::CUp,A::CDown,A::CLeft,A::CRight}; + const unsigned bits[]={8,4,2,1}; + for(unsigned c=0;c<4;++c)for(unsigned n=0;n<4;++n) { + const int cb=dkr::runtime::input::controller_binding(player,camera[c]); + const int nb=dkr::runtime::input::controller_binding(player,native[n]); + const int ck=dkr::runtime::input::keyboard_binding(player,camera[c]); + const int nk=dkr::runtime::input::keyboard_binding(player,native[n]); + if((cb>=0&&cb==nb)||(ck>=0&&ck==nk))state.buttons&=std::uint16_t(~bits[n]); + } + if(state.accelerate)state.buttons|=0x8000U; + } g_physical_buttons[player].store(state.buttons, std::memory_order_release); g_physical_stick_x[player].store(state.stick_x, std::memory_order_release); g_physical_stick_y[player].store(state.stick_y, std::memory_order_release); @@ -2507,12 +2544,6 @@ void dkr::runtime::platform::poll_input() { online_routing); if (publish_physical) { std::uint16_t buttons = state.buttons; - if (dkr::runtime::modern_camera::owns_controls(player)) { - // During a race the Modern camera owns the C-button bindings - // (they orbit it), and Accelerate presses A. - buttons &= static_cast(~0x000FU); - if (state.accelerate) buttons |= 0x8000U; - } g_buttons[player].store(buttons, std::memory_order_release); g_stick_x[player].store(state.stick_x, std::memory_order_release); g_stick_y[player].store(state.stick_y, std::memory_order_release); @@ -2532,7 +2563,7 @@ void dkr::runtime::platform::poll_input() { void dkr::runtime::platform::set_online_input_routing( bool enabled, std::uint8_t occupied_mask, std::uint8_t local_slot) { - dkr::runtime::modern_camera::set_online_routing(enabled); + g_online_occupied_mask.store(enabled ? occupied_mask : 0U, std::memory_order_release); g_online_local_slot.store( diff --git a/runtime-recomp/src/game/runtime_rice_texture_import.cpp b/runtime-recomp/src/game/runtime_rice_texture_import.cpp index 86b6611d..f4332433 100644 --- a/runtime-recomp/src/game/runtime_rice_texture_import.cpp +++ b/runtime-recomp/src/game/runtime_rice_texture_import.cpp @@ -1,4 +1,5 @@ #include "runtime_rice_texture_import.hpp" +#include #include "rice_texture_pack_policy.hpp" @@ -9,6 +10,7 @@ #include +#include #include #include #include @@ -104,16 +106,37 @@ bool WritePng(const std::filesystem::path& path, return true; } + +// Match decoded pixels, not filenames/counts (different packs often replace +// the same identities). This audit never writes or activates installed data. +bool ComparePng(const std::filesystem::path& path, const Image& expected) { + if (std::filesystem::is_symlink(path) || !std::filesystem::is_regular_file(path)) return false; + const auto size = std::filesystem::file_size(path); + if (!size || size > kMaximumSourceImageBytes) return false; + std::ifstream input(path, std::ios::binary); + std::vector bytes(static_cast(size)); + if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) return false; + int width = 0, height = 0, channels = 0; + if (!stbi_info_from_memory(bytes.data(), static_cast(bytes.size()), &width, &height, &channels) || + width != expected.width || height != expected.height) return false; + auto* pixels = stbi_load_from_memory(bytes.data(), static_cast(bytes.size()), &width, &height, &channels, 4); + if (!pixels) return false; + const bool same = width == expected.width && height == expected.height && + std::equal(expected.rgba.begin(), expected.rgba.end(), pixels); + stbi_image_free(pixels); + return same; +} + } // namespace namespace dkr::runtime::rice_texture { -bool convert_archive(RT64::FileSystem& archive, +static bool convert_or_compare_archive(RT64::FileSystem& archive, const std::filesystem::path& destination, const std::string& source_name, ImportResult& result, std::string& error, - const ProgressCallback& progress) { + const ProgressCallback& progress, bool compare_only) { result = {}; if (progress && !progress(0, 0, "Inspecting Rice texture identities")) { error = "Import cancelled."; @@ -175,7 +198,7 @@ bool convert_archive(RT64::FileSystem& archive, } std::error_code filesystem_error; - std::filesystem::create_directories(destination, filesystem_error); + if (!compare_only) std::filesystem::create_directories(destination, filesystem_error); if (filesystem_error) { error = "Could not create the managed Rice pack: " + filesystem_error.message(); return false; @@ -217,7 +240,9 @@ bool convert_archive(RT64::FileSystem& archive, } const std::string filename = "Diddy Kong Racing#" + identity + "_all.png"; - if (!WritePng(destination / filename, colour, error)) return false; + if (compare_only) { + if (!ComparePng(destination / filename, colour)) return false; + } else if (!WritePng(destination / filename, colour, error)) return false; textures.push_back({ {"path", ""}, {"hashes", { @@ -248,6 +273,22 @@ bool convert_archive(RT64::FileSystem& archive, {"shiftFilters", nlohmann::json::array()}, {"extraFiles", nlohmann::json::array()} }; + if (compare_only) { + const auto db_path = destination / "rt64.json"; + const auto report_path = destination / "dkr-r-rice-import.json"; + if (std::filesystem::is_symlink(db_path) || std::filesystem::is_symlink(report_path) || + !std::filesystem::is_regular_file(db_path) || !std::filesystem::is_regular_file(report_path) || + std::filesystem::file_size(db_path) > 32U * 1024U * 1024U || + std::filesystem::file_size(report_path) > 64U * 1024U) return false; + std::ifstream db_file(db_path), report_file(report_path); + const auto existing = nlohmann::json::parse(db_file); + const auto report = nlohmann::json::parse(report_file); + return existing == database && report.value("sourceImages", 0U) == result.source_images && + report.value("convertedIdentities", 0U) == identities.size() && + report.value("mergedRgbAlphaPairs", 0U) == result.merged_pairs && + report.value("opaqueRgbImages", 0U) == result.opaque_rgb && + report.value("nativeAllImages", 0U) == result.all_images; + } std::ofstream database_file(destination / "rt64.json", std::ios::trunc); if (!database_file) { error = "Could not create the native RT64 database for the Rice pack."; @@ -282,4 +323,19 @@ bool convert_archive(RT64::FileSystem& archive, return true; } + +bool convert_archive(RT64::FileSystem& archive, const std::filesystem::path& destination, + const std::string& source_name, ImportResult& result, std::string& error, + const ProgressCallback& progress) { + return convert_or_compare_archive(archive, destination, source_name, result, error, progress, false); +} +bool matches_converted_archive(RT64::FileSystem& archive, const std::filesystem::path& destination, + std::stop_token stop) { + try { + ImportResult result; + std::string error; + return convert_or_compare_archive(archive, destination, {}, result, error, + [&](std::size_t, std::size_t, const char*) { return !stop.stop_requested(); }, true); + } catch (...) { return false; } +} } // namespace dkr::runtime::rice_texture diff --git a/runtime-recomp/src/game/runtime_save_routing.cpp b/runtime-recomp/src/game/runtime_save_routing.cpp index bfdb760b..0e9409c1 100644 --- a/runtime-recomp/src/game/runtime_save_routing.cpp +++ b/runtime-recomp/src/game/runtime_save_routing.cpp @@ -17,7 +17,7 @@ dkr::runtime::saves::RuntimeOnlineSaveStatus g_runtime_save_status; struct SaveContext { std::filesystem::path path, paks; std::shared_ptr lease; - bool online = false, host = false, loaded = false, incomplete = false; + bool online = false, host = false, loaded = false, incomplete = false, development = false; std::uint64_t match = 0, hash = 0; std::string failure; }; @@ -108,7 +108,7 @@ dkr::runtime::saves::runtime_online_save_status() { bool dkr::runtime::saves::prepare_runtime_save_context(const std::filesystem::path& root, const std::filesystem::path& subfolder, const std::filesystem::path& paks, - bool online, bool host, std::uint64_t match, std::uint64_t hash, std::string& error) { + bool online, bool host, std::uint64_t match, std::uint64_t hash, std::string& error,bool development) { std::scoped_lock lock(g_runtime_save_mutex); if (g_context.lease) { error = "The previous save owner has not retired."; return false; } g_context = {}; @@ -127,6 +127,8 @@ bool dkr::runtime::saves::prepare_runtime_save_context(const std::filesystem::pa auto lease = storage::acquire(path, error); if (!lease) return false; g_context.path = path; g_context.paks = online ? path.parent_path() / "paks" : (paks.empty() ? storage::absolute(root) : storage::absolute(paks)); + g_context.development=development; + if(development)g_context.paks=storage::absolute(paks); g_context.online = online; g_context.host = host; g_context.match = match; g_context.hash = hash; g_context.lease = std::move(lease); std::fprintf(stderr, "[save][scope] online=%d host=%d lease=held immutable-route=1\n", int(online), int(host)); @@ -139,7 +141,16 @@ void dkr::runtime::saves::retire_runtime_save_context() { } bool dkr::runtime::saves::runtime_save_context_ready() { std::scoped_lock lock(g_runtime_save_mutex); - return g_context.lease && g_context.loaded && g_context.failure.empty(); + if(!g_context.lease||!g_context.loaded||!g_context.failure.empty())return false; + // Verify what init_saving actually loaded, not a second proposed route. + // On mismatch retain the scoped failure for the launcher's recovery UI. + // Never fall back to a user's ordinary save or release the existing lease. + if(storage::absolute(ultramodern::get_save_file_path())!=g_context.path) { + g_context.failure="The loaded EEPROM does not match its frozen launch save owner."; + std::fprintf(stderr,"[save][boot] loaded EEPROM differs from its frozen save owner\n"); + return false; + } + return true; } bool dkr::runtime::saves::runtime_save_writes_allowed() { std::scoped_lock lock(g_runtime_save_mutex); return g_context.lease && g_context.loaded; @@ -189,6 +200,10 @@ bool dkr::runtime::saves::commit_runtime_save(const std::filesystem::path& path, if (!g_context.incomplete) std::fprintf(stderr, "[save][commit] deferred incomplete EEPROM transaction\n"); g_context.incomplete = true; return false; } + // Online development starts from the authenticated seed, but never writes + // experimental progress into its normal durable profile. The owned writer + // follows the same policy and returns the original seed to the lobby. + if(g_context.development&&g_context.online){g_context.incomplete=false;return true;} std::string error; if (!storage::write_atomic(path, bytes, ValidRuntimeImage, error)) { g_context.failure = error; std::fprintf(stderr, "[save][commit] %s\n", error.c_str()); return false; diff --git a/runtime-recomp/src/game/runtime_save_routing.hpp b/runtime-recomp/src/game/runtime_save_routing.hpp index 8174e3e2..9663a2c3 100644 --- a/runtime-recomp/src/game/runtime_save_routing.hpp +++ b/runtime-recomp/src/game/runtime_save_routing.hpp @@ -23,7 +23,7 @@ RuntimeOnlineSaveStatus runtime_online_save_status(); bool prepare_runtime_save_context(const std::filesystem::path& root, const std::filesystem::path& offline_subfolder, const std::filesystem::path& offline_paks, bool online, bool host, - std::uint64_t match_id, std::uint64_t expected_hash, std::string& error); + std::uint64_t match_id, std::uint64_t expected_hash, std::string& error,bool development=false); void retire_runtime_save_context(); bool runtime_save_context_ready(); bool runtime_save_writes_allowed(); diff --git a/runtime-recomp/src/game/runtime_saves_ui.inl b/runtime-recomp/src/game/runtime_saves_ui.inl index 0cf6af9a..80ed485c 100644 --- a/runtime-recomp/src/game/runtime_saves_ui.inl +++ b/runtime-recomp/src/game/runtime_saves_ui.inl @@ -23,7 +23,7 @@ struct SavesPageState { std::optional summary; std::chrono::steady_clock::time_point summary_at{}; // The confirmation in flight. - enum class Ask { None, Repair, Reset, Restore, MaxOut, UnlockAll, Discard, Apply } ask = Ask::None; + enum class Ask { None, Repair, Reset, Restore, MaxOut, UnlockAll, Discard, Apply, Import, Bundle, Pak, RepairUse, Host, DevelopmentCopy } ask = Ask::None; std::filesystem::path restore; int slot = 0; }; @@ -453,16 +453,17 @@ void DrawSavesEditor(SavesPageState& state, float width, float panel, bool edita codec::SaveImage image{}; std::string error; if (load_adventure(image, error)) { - codec::normalise_editable_fields(image); + // Opening the editor must not silently normalise existing progress. g_save_builder_image = image; state.original = image; + g_shared_draft_revision=dkr::runtime::saves::shared::revision(); } else { g_save_manager_status = error; return; } } if (!state.original) state.original = *g_save_builder_image; - SavesNote("Edit a slot or shared unlocks. Changes stay in your draft until you apply them.", width); + SavesNote("Manual edits use Apply Changes. Completion and unlock buttons save when confirmed.", width); const int picked = SavesEditorTabs({"SLOT 1", "SLOT 2", "SLOT 3", "UNLOCKS", "RECORDS"}, state.tab, width); if (picked >= 0) state.tab = picked; PaddockGap(20.0F); @@ -518,551 +519,283 @@ void DrawSavesEditor(SavesPageState& state, float width, float panel, bool edita // ------------------------------------------------------------ overview -void DrawSavesOverview(SavesPageState& state, float width, float panel, const SaveManagerViewCache& view, - bool editable) { +void DrawSavesOverview(SavesPageState& state,float width,float panel,const SaveManagerViewCache& view,bool editable) { using namespace dkr::runtime::saves; - const auto& info = view.adventure; - const char* reason = !info.exists ? "Create or import a save first." : "Restore or repair the save first."; - PaddockText(PaddockReading(18.0F, true, 1.4F), PaddockRgb(kSetText), "Save file", width); - PaddockGap(13.0F); - SavesNote("Includes all three slots, unlocks and records.", width); - { - const float backup = PaddockRaceButtonWidth("BACK UP SAVE"); - const float export_width = PaddockRaceButtonWidth("EXPORT SAVE"); - const float import_width = PaddockRaceButtonWidth("IMPORT SAVE"); - if (SavesRaceButton("BACK UP SAVE", backup, SavesTone::Primary, !editable, reason)) { - std::filesystem::path created; - std::string error; - if (backup_adventure(created, error)) { - InvalidateSaveManagerViewCache(); - g_save_manager_status = "Backup created. You can restore it below."; - } else { - g_save_manager_status = error; - } - } - const bool one_line = backup + export_width + import_width + 20.0F <= width; - if (one_line) ImGui::SameLine(0.0F, 10.0F); else PaddockGap(10.0F); - if (SavesRaceButton("EXPORT SAVE", export_width, SavesTone::Plain, !editable, reason)) { - ExportAdventureWithDialog(); - } - if (one_line) ImGui::SameLine(0.0F, 10.0F); else PaddockGap(10.0F); - if (SavesRaceButton("IMPORT SAVE", import_width)) ImportAdventureWithDialog(); - PaddockGap(18.0F); - } - if (!editable) SavesNote(std::string(reason) + " Backup and export require a valid save.", width); - if (!info.exists) { - if (SettingsButton("Create fresh save")) state.ask = SavesPageState::Ask::Reset; - } else if (editable) { - // Adventure slots: who is racing in each and how far they got. - const auto now = std::chrono::steady_clock::now(); - if (!state.summary || now >= state.summary_at) { - codec::SaveImage image{}; - std::string error; - if (load_adventure(image, error)) state.summary = image; - state.summary_at = now + std::chrono::seconds(1); - } - if (state.summary) { - SavesSeparator("Adventure slots", width); - PaddockGap(10.0F); - const bool stacked = panel <= 540.0F; - const float column = stacked ? width : std::floor((width - 32.0F) / 3.0F); - const ImVec2 at = ImGui::GetCursorScreenPos(); - float bottom = at.y; - for (std::size_t index = 0; index < codec::kAdventureSlotCount; ++index) { - const auto& slot = state.summary->slots[index]; - const ImVec2 cell{stacked ? at.x : at.x + (column + 16.0F) * static_cast(index), - stacked ? bottom + (index > 0U ? 16.0F : 0.0F) : at.y}; - ImGui::SetCursorScreenPos({cell.x + 16.0F, cell.y}); - ImGui::BeginGroup(); - const float inner = column - 16.0F; - PaddockText(PaddockReading(18.0F, true, 1.4F), PaddockRgb(kSetText), - "Slot " + std::to_string(index + 1U), inner); - PaddockGap(6.0F); - const std::string name = codec::sanitise_name(slot.name); - const bool blank = name.find_first_not_of(' ') == std::string::npos; - PaddockText(PaddockReading(16.0F, false, 1.5F), PaddockRgb(kSetMuted), - blank ? std::string("No racer initials") : name, inner); - PaddockGap(8.0F); - PaddockText(PaddockReading(16.0F, true, 1.5F), PaddockRgb(kSetText), - std::to_string(slot.balloons[0]) + " / 47 balloons", inner); - PaddockGap(8.0F); - ImGui::PushID(static_cast(index)); - const std::string label = "Edit slot " + std::to_string(index + 1U); - if (SettingsButton(label.c_str(), inner)) { - state.section = 1; - state.tab = static_cast(index); - } - ImGui::PopID(); - ImGui::EndGroup(); - const float cell_bottom = ImGui::GetItemRectMax().y; - ImGui::GetWindowDrawList()->AddRectFilled({cell.x, cell.y}, {cell.x + 2.0F, cell_bottom}, - PaddockCol(kSetBorder)); - bottom = std::max(bottom, cell_bottom); - } - ImGui::SetCursorScreenPos({at.x, bottom}); - ImGui::Dummy({width, 0.0F}); - } + SavesNote("One personal save for normal play, with or without mods. Three Adventure slots, shared unlocks and records.",width); + const bool busy=SharedSaveBusy(); + const auto button=[&](const char* label,bool disabled=false,SavesTone tone=SavesTone::Plain){ + return SavesRaceButton(label,0,tone,busy||disabled); + }; + if(button("BACK UP SAVE",!editable,SavesTone::Primary))BeginSharedSaveJob([](std::string& error){std::filesystem::path created;return backup_adventure(created,error);},"Personal save backed up."); + PaddockGap(8); + if(button("EXPORT SAVE",!editable))ExportAdventureWithDialog(); + PaddockGap(8); + if(button("IMPORT SAVE"))ImportAdventureWithDialog(); + PaddockGap(14); + if(!view.adventure.exists) { + if(button("CREATE PERSONAL SAVE"))state.ask=SavesPageState::Ask::Reset; + return; } - - SavesSeparator("Backups", width); - SavesNote("A backup is created before replacing your save.", - width); - const auto& backups = view.adventure_backups; - if (backups.empty()) { - const ImVec2 at = ImGui::GetCursorScreenPos(); - ImGui::SetCursorScreenPos({at.x + 20.0F, at.y + 16.0F}); - ImGui::BeginGroup(); - PaddockText(PaddockReading(16.0F, true, 1.5F), PaddockRgb(kSetText), "No backups yet", width - 38.0F); - PaddockGap(13.0F); - PaddockText(PaddockReading(16.0F, false, 1.5F), PaddockRgb(kSetMuted), - "Use Back up save to keep a copy of your current progress here.", width - 38.0F); - ImGui::EndGroup(); - const float bottom = ImGui::GetItemRectMax().y + 16.0F; - ImGui::GetWindowDrawList()->AddRectFilled(at, {at.x + 2.0F, bottom}, PaddockCol(kSetBorder)); - ImGui::SetCursorScreenPos({at.x, bottom}); - ImGui::Dummy({width, 0.0F}); - } else { - const std::size_t shown = state.all_backups ? backups.size() : std::min(backups.size(), 6U); - for (std::size_t index = 0; index < shown; ++index) { - ImGui::PushID(static_cast(index)); - const ImVec2 at = ImGui::GetCursorScreenPos(); - const float restore_width = SettingsButtonWidth("Restore"); - ImGui::SetCursorScreenPos({at.x, at.y + 14.0F}); - ImGui::BeginGroup(); - PaddockText(PaddockReading(16.0F, false, 1.5F), PaddockRgb(kSetText), SavesBackupTime(backups[index]), - width - restore_width - 18.0F); - PaddockText(PaddockReading(16.0F, false, 1.5F), PaddockRgb(kSetMuted), - index == 0U ? "Latest backup" : "Earlier backup", width - restore_width - 18.0F); - ImGui::EndGroup(); - const float text_bottom = ImGui::GetItemRectMax().y; - const float row_height = std::max(text_bottom - (at.y + 14.0F), 44.0F); - ImGui::SetCursorScreenPos({at.x + width - restore_width, at.y + 14.0F + (row_height - 44.0F) * 0.5F}); - if (SettingsButton("Restore", restore_width)) { - state.ask = SavesPageState::Ask::Restore; - state.restore = backups[index]; + if(editable) { + if(!state.summary&&view.image)state.summary=view.image; + if(state.summary) { + SavesSeparator("Adventure slots",width); + for(unsigned i=0;i<3;++i) { + const auto& slot=state.summary->slots[i]; + SavesNote("Slot "+std::to_string(i+1)+" - "+(slot.name.empty()?"---":slot.name)+" - "+std::to_string(slot.balloons[0])+" / 47 balloons",width,18,kSetText); } - const float bottom = at.y + 14.0F + row_height + 14.0F; - ImGui::GetWindowDrawList()->AddRectFilled({at.x, bottom - 1.0F}, {at.x + width, bottom}, - PaddockCol(kSetBorder)); - ImGui::SetCursorScreenPos({at.x, bottom}); - ImGui::Dummy({width, 0.0F}); - ImGui::PopID(); - } - if (backups.size() > 6U) { - PaddockGap(12.0F); - const std::string label = state.all_backups ? std::string("Show recent backups") - : "Show all " + std::to_string(backups.size()) + " backups"; - if (SavesRaceButton(label.c_str())) state.all_backups = !state.all_backups; + const auto& settings=state.summary->settings; + SavesNote(std::string("Drumstick: ")+(settings.drumstick?"unlocked":"locked")+" | T.T.: "+ + (std::all_of(settings.tt_trials.begin(),settings.tt_trials.end(),[](bool x){return x;})?"unlocked":"locked")+ + " | Adventure Two: "+(settings.adventure_two?"unlocked":"locked"),width); } - } - - if (info.exists) { - // Start over (.saves-reset). - PaddockGap(28.0F); - const ImVec2 at = ImGui::GetCursorScreenPos(); - ImGui::GetWindowDrawList()->AddRectFilled(at, {at.x + width, at.y + 1.0F}, PaddockCol(kSetBorder)); - PaddockGap(1.0F + 20.0F); - const float reset_width = SettingsButtonWidth("Reset save"); - const ImVec2 row = ImGui::GetCursorScreenPos(); - ImGui::BeginGroup(); - PaddockText(PaddockReading(18.0F, true, 1.4F), PaddockRgb(kSetText), "Start over", width - reset_width - 20.0F); - PaddockGap(6.0F); - PaddockText(PaddockReading(16.0F, false, 1.45F), PaddockRgb(kSetMuted), - "Reset all three slots, unlocks and records. A backup is kept.", width - reset_width - 20.0F); - ImGui::EndGroup(); - const float text_bottom = ImGui::GetItemRectMax().y; - ImGui::SetCursorScreenPos({row.x + width - reset_width, row.y + (text_bottom - row.y - 44.0F) * 0.5F}); - if (SettingsButton("Reset save", reset_width)) state.ask = SavesPageState::Ask::Reset; - ImGui::SetCursorScreenPos({row.x, std::max(text_bottom, row.y + 44.0F)}); - ImGui::Dummy({width, 0.0F}); - } + if(button("MAX OUT ENTIRE SAVE",false,SavesTone::Primary)){state.slot=-1;state.ask=SavesPageState::Ask::MaxOut;} + PaddockGap(8); + if(button("UNLOCK ALL RACERS AND MODES"))state.ask=SavesPageState::Ask::UnlockAll; + SavesSeparator("Development tools",width); + SavesNote("Dev Menu uses a separate copy, not this personal save. New development copies start from your saved progress. You can refresh an existing copy below; its previous progress is kept.",width); + if(button("COPY PERSONAL SAVE TO DEVELOPMENT"))state.ask=SavesPageState::Ask::DevelopmentCopy; + } + SavesSeparator("Older progress and backups",width); + SavesNote("Older mod-set saves are kept. Choose one from Backups & Recovery if it contains the progress you want to keep. Nothing is merged or deleted automatically.",width); + if(button("OPEN BACKUPS & RECOVERY")){state.scope=3;state.ask=SavesPageState::Ask::None;} + PaddockGap(20); + if(button("RESET PERSONAL SAVE",false,SavesTone::Danger))state.ask=SavesPageState::Ask::Reset; + (void)panel; } + // ------------------------------------------------------------ transfer -void DrawSavesTransfer(float width, float panel, const SaveManagerViewCache& view) { - SavesSeparator("Move your offline saves", width, false); - SavesNote("Includes offline Adventure progress and Controller Paks. Online and modded saves are not included.", - width); - const bool stacked = panel <= 540.0F; - const float half = stacked ? width : std::floor((width - 10.0F) * 0.5F); - if (SavesRaceButton("EXPORT OFFLINE SAVE BUNDLE", half)) ExportSaveBundleWithDialog(); - if (!stacked) ImGui::SameLine(0.0F, 10.0F); else PaddockGap(10.0F); - if (SavesRaceButton("IMPORT OFFLINE SAVE BUNDLE", half)) ImportSaveBundleWithDialog(); - PaddockGap(18.0F); - SavesSeparator("Virtual Controller Paks", width, false); - SavesNote("Four virtual memory cards. Included in offline save bundles when available.", width); - const float column = stacked ? width : std::floor((width - 12.0F) * 0.5F); - const ImVec2 at = ImGui::GetCursorScreenPos(); - float row_top = at.y; - float row_bottom = at.y; - for (int channel = 0; channel < dkr::runtime::saves::kControllerPakCount; ++channel) { - const auto& pak = view.controller_paks[static_cast(channel)]; - const int slot = stacked ? 0 : channel % 2; - if (slot == 0 && channel > 0) row_top = row_bottom + 12.0F; - ImGui::SetCursorScreenPos({at.x + (column + 12.0F) * static_cast(slot), row_top}); - PaddockBox box(column, {19.0F, 19.0F}); - const float inner = box.Inner(); - PaddockText(PaddockSign(19.0F, 1.45F, 0.0F), PaddockRgb(kSetText), - "CONTROLLER " + std::to_string(channel + 1), inner); - PaddockGap(13.0F); - if (!pak.exists) { - PaddockText(PaddockReading(16.0F, false, 1.45F), PaddockRgb(kSetMuted), "Not created yet", inner); - } else { - PaddockText(PaddockSign(19.0F, 1.45F, 0.0F), PaddockRgb(pak.valid ? kSavesAccent : kSavesError), - pak.valid ? "PAK READY" : "RECOVERY NEEDED", inner); - } - PaddockGap(13.0F); - PaddockText(PaddockReading(14.0F, false, 1.4F), PaddockRgb(kSetMuted), PathUtf8(pak.path), inner); - const float height = box.End([](ImDrawList* draw, ImVec2 a, ImVec2 b) { - PaddockPanel(draw, a, b, {PaddockRound(16.0F), PaddockRgb(0x0B2E40, 245U), PaddockRgb(kSetBorder), 1.0F}); - }); - row_bottom = slot == 0 ? row_top + height : std::max(row_bottom, row_top + height); - } - ImGui::SetCursorScreenPos({at.x, row_bottom}); - ImGui::Dummy({width, 0.0F}); +// Complete manager actions and stored-save recovery. This replaces the old +// split-profile UI, but keeps the proven slot/record editor above unchanged. +void SharedPakDialog(int port,bool importing) { + if(SharedSaveBusy())return; + if(NFD_Init()!=NFD_OKAY){g_save_manager_status="The system file picker could not be opened.";return;} + nfdu8char_t* result=nullptr;const nfdfilteritem_t filters[]={{"DKR-R Controller Pak","mpk"}}; + const auto dialog=importing?NFD_OpenDialogU8(&result,filters,1,nullptr):NFD_SaveDialogU8(&result,filters,1,nullptr,"dkr-controller-pak.mpk"); + if(dialog==NFD_OKAY) { + auto path=std::filesystem::u8path(result);NFD_FreePathU8(result); + if(importing)g_shared_save_import={path,2,port}; + else BeginSharedSaveJob([path,port](std::string& error){return dkr::runtime::saves::export_controller_pak(port,path,error);},"Controller Pak exported."); + } else if(dialog==NFD_ERROR)g_save_manager_status=NFD_GetError(); + NFD_Quit(); +} +void SharedRawExportDialog(const std::filesystem::path& source) { + if(NFD_Init()!=NFD_OKAY)return; + nfdu8char_t* result=nullptr;const nfdfilteritem_t filters[]={{"Original recovery image","bin"}}; + if(NFD_SaveDialogU8(&result,filters,1,nullptr,"original-save-image.bin")==NFD_OKAY) { + auto dest=std::filesystem::u8path(result);NFD_FreePathU8(result); + BeginSharedSaveJob([source,dest](std::string& error){return dkr::runtime::saves::shared::export_raw(source,dest,error);},"Original image exported unchanged. It has not been repaired."); + } + NFD_Quit(); +} +void DrawSavesTransfer(float width,float panel,const SaveManagerViewCache& view) { + using namespace dkr::runtime::saves; + SavesNote("Move your personal Adventure, custom-course records and Controller Paks together. Older save bundles can still be imported.",width); + if(SavesRaceButton("EXPORT SAVE BUNDLE",0,SavesTone::Plain,SharedSaveBusy()))ExportSaveBundleWithDialog(); + PaddockGap(8); + if(SavesRaceButton("IMPORT SAVE BUNDLE",0,SavesTone::Plain,SharedSaveBusy()))ImportSaveBundleWithDialog(); + SavesSeparator("Controller Paks",width); + SavesNote("DKR-R virtual memory cards. Use DKR-R-format .mpk files, not an unrelated emulator format.",width); + for(int port=0;port<4;++port) { + ImGui::PushID(port);const auto& pak=view.controller_paks[port]; + SavesNote("Controller "+std::to_string(port+1)+": "+(!pak.exists?"not created":pak.valid?"ready":"needs recovery"),width,18,kSetText); + if(SavesRaceButton("BACK UP PAK",0,SavesTone::Plain,SharedSaveBusy()||!pak.valid)) + BeginSharedSaveJob([port](std::string& error){std::filesystem::path created;return backup_controller_pak(port,created,error);},"Controller Pak backed up."); + PaddockGap(8); + if(SavesRaceButton("EXPORT PAK",0,SavesTone::Plain,SharedSaveBusy()||!pak.valid))SharedPakDialog(port,false); + PaddockGap(8); + if(SavesRaceButton("IMPORT PAK",0,SavesTone::Plain,SharedSaveBusy()))SharedPakDialog(port,true); + PaddockGap(18);ImGui::PopID(); + } + (void)panel; } - -// ------------------------------------------------------------ confirmations - void DrawSavesConfirmations(SavesPageState& state) { using namespace dkr::runtime::saves; - using Ask = SavesPageState::Ask; - constexpr const char* kPopup = "##saves-confirm"; - static Ask shown = Ask::None; - if (state.ask != Ask::None && shown != state.ask) { - shown = state.ask; - ImGui::OpenPopup(kPopup); - } - const bool exists = CachedSaveManagerView().adventure.exists; - const bool dirty = SavesDraftDirty(); - std::string heading; - std::vector paragraphs; - const char* confirm = "OK"; - bool destructive = false; - switch (state.ask) { - case Ask::Repair: - heading = "Repair this save?"; - paragraphs = {"Repair this save? Your progress won't be edited.", - "The original will be backed up."}; - confirm = "BACK UP & REPAIR"; - break; - case Ask::Reset: - heading = exists ? "Start a fresh adventure?" : "Create an Adventure save?"; - paragraphs = {exists ? "Reset all three slots, unlocks and records? A backup will be created first." - : "Create an empty save with three adventure slots?", - dirty ? "Unsaved editor changes will be discarded." - : "You can edit the new save from Edit progress."}; - confirm = exists ? "BACK UP & RESET" : "CREATE SAVE"; - destructive = exists; - break; - case Ask::Restore: - heading = "Restore this backup?"; - paragraphs = {PathUtf8(state.restore), - "Your current save will be backed up before it is replaced.", - dirty ? "Unsaved editor changes will be discarded." - : "You can restore the replaced save from this list."}; - confirm = "RESTORE BACKUP"; - destructive = true; - break; - case Ask::MaxOut: - heading = "Max out this adventure?"; - paragraphs = {"Complete this Adventure slot?", - "Use Apply changes to save this edit."}; - confirm = "MAX OUT"; - break; - case Ask::UnlockAll: - heading = "Unlock all racers and modes?"; - paragraphs = {"Unlock Adventure Two, Drumstick and all T.T. victories?", - "Use Apply changes to save these unlocks."}; - confirm = "UNLOCK ALL"; - break; - case Ask::Discard: - heading = "Discard unsaved changes?"; - paragraphs = {"Discard edits to all three slots and shared unlocks?"}; - confirm = "DISCARD"; - destructive = true; - break; - case Ask::Apply: - heading = "Apply changes?"; - paragraphs = {"Apply these edits to your offline save? A backup will be created first.", - ""}; - confirm = "BACK UP & APPLY"; - break; - case Ask::None: - default: - break; - } - if (!BeginSettingsDialog(kPopup, heading)) { - if (shown != Ask::None && !ImGui::IsPopupOpen(kPopup)) { - shown = Ask::None; - state.ask = Ask::None; - } + using Ask=SavesPageState::Ask; + static Ask shown=Ask::None; + if(state.ask==Ask::None)return; + if(shown!=state.ask){shown=state.ask;ImGui::OpenPopup("##saves-confirm");} + std::string heading="Confirm save change",detail,confirm="CONFIRM"; + bool replace=true; + static std::filesystem::path preview_source;static std::string preview_text; + if(preview_source!=state.restore){preview_source=state.restore;preview_text=state.restore.empty()?std::string{}:shared::describe(state.restore);} + switch(state.ask) { + case Ask::Repair: heading="Repair personal save?";detail="Only checksums can be repaired. Damaged progress cannot be reconstructed. An unchanged original is preserved.";confirm="REPAIR & KEEP ORIGINAL";break; + case Ask::Reset: heading="Reset personal save?";detail="Reset all three slots, unlocks and records. Your current Adventure is backed up first.";confirm="BACK UP & RESET";break; + case Ask::Restore: heading="Use this as your personal save?";detail=preview_text;confirm="BACK UP & USE THIS SAVE";break; + case Ask::MaxOut: heading=state.slot<0?"Complete the entire save?":"Complete this Adventure?";detail=state.slot<0?"Complete all three slots and unlock Drumstick, T.T. and Adventure Two. Existing initials and personal records are kept.":"Complete this slot and unlock Drumstick, T.T. and Adventure Two. Other slots and personal records are kept.";confirm="BACK UP & SAVE COMPLETION";break; + case Ask::UnlockAll: heading="Unlock racers and modes?";detail="Unlock Drumstick, T.T. and Adventure Two. Your Adventure progress and personal best times are kept.";confirm="BACK UP & SAVE UNLOCKS";break; + case Ask::Discard: heading="Discard editor changes?";detail="Reload your current personal save. Nothing on disk will be replaced.";confirm="DISCARD DRAFT";replace=false;break; + case Ask::Apply: heading="Apply editor changes?";detail="Save the draft to your personal Adventure. A backup is created first.";confirm="BACK UP & APPLY";break; + case Ask::Import: heading="Import personal save?";detail=preview_text;confirm="BACK UP & IMPORT";break; + case Ask::Bundle: heading="Import save bundle?";detail="Replace the personal Adventure and Controller Paks included in this bundle. Originals are preserved in a recoverable transaction.";confirm="BACK UP & IMPORT BUNDLE";break; + case Ask::Pak: heading="Import Controller Pak?";detail="Replace Controller "+std::to_string(state.slot+1)+"'s Pak. An existing valid image is backed up first.";confirm="BACK UP & IMPORT PAK";break; + case Ask::RepairUse: heading="Repair and use a copy?";detail="Attempt checksum repair, then replace your personal save with that copy. Corrupt progress cannot be reconstructed. Both originals are retained.";confirm="REPAIR COPY & USE";break; + case Ask::Host: heading="Use this online Adventure?";detail=preview_text+". The current host Adventure is backed up. Personal saves are unchanged.";confirm="USE FOR HOSTING";break; + case Ask::DevelopmentCopy: heading="Refresh the next development save?";detail="Copy your saved personal progress into the next offline development session. The existing development save will be preserved before replacement. Your personal save will not be changed. Unapplied editor changes are not included.";confirm="QUEUE DEVELOPMENT COPY";replace=false;break; + default:break; + } + if(!BeginSettingsDialog("##saves-confirm",heading)) { + if(!ImGui::IsPopupOpen("##saves-confirm")){state.ask=shown=Ask::None;} return; } - paragraphs.insert(paragraphs.begin(), "Offline Adventure save. Online and modded saves aren't affected."); - const float inner = SettingsDialogInner(); - for (std::size_t index = 0; index < paragraphs.size(); ++index) { - if (index > 0U) PaddockGap(13.0F); - PaddockText(PaddockReading(16.0F, false, 1.45F), PaddockRgb(index == 0U ? kSetText : kSetMuted), - paragraphs[index], inner); - } - PaddockGap(24.0F); - const float half = std::floor((inner - 12.0F) * 0.5F); - bool close = false; - if (SavesRaceButton("CANCEL", half)) close = true; - ImGui::SameLine(0.0F, 12.0F); - if (SavesRaceButton(confirm, half, destructive ? SavesTone::Danger : SavesTone::Primary)) { - std::string error; - switch (state.ask) { - case Ask::Repair: { - bool changed = false; - std::filesystem::path backup; - if (repair_adventure_checksums(changed, backup, error)) { - InvalidateSaveManagerViewCache(); - SavesForgetDraft(); - g_save_manager_status = changed - ? "Save repaired. Original kept in Backups." - : "This save doesn't need repair."; - } else { - g_save_manager_status = error; - } - break; - } - case Ask::Reset: - if (reset_adventure(error)) { - InvalidateSaveManagerViewCache(); - SavesForgetDraft(); - g_save_manager_status = exists - ? "Fresh save created. Previous progress is available in Backups." - : "Fresh save created. Open Edit progress to customize it."; - } else { - g_save_manager_status = error; - } - break; - case Ask::Restore: - if (import_adventure(state.restore, error)) { - InvalidateSaveManagerViewCache(); - SavesForgetDraft(); - g_save_manager_status = "Backup restored. Any replaced save was backed up too."; - } else { - g_save_manager_status = error; - } - break; - case Ask::MaxOut: - if (g_save_builder_image) { - auto& slot = g_save_builder_image->slots[static_cast(state.slot)]; - std::fill(slot.course_status.begin(), slot.course_status.end(), 3U); - slot.taj_flags = 0x3F; - slot.trophies = 0x3FF; - slot.bosses = 0xFFF; - slot.balloons = {47, 8, 8, 8, 8, 8}; - slot.tt_amulet = 4; - slot.wizpig_amulet = 4; - slot.world_flags.fill(0xFFFF); - slot.keys = 0x1E; - } - break; - case Ask::UnlockAll: - if (g_save_builder_image) { - g_save_builder_image->settings.adventure_two = true; - g_save_builder_image->settings.drumstick = true; - g_save_builder_image->settings.tt_trials.fill(true); - } - break; - case Ask::Discard: - if (state.original) g_save_builder_image = state.original; - break; - case Ask::Apply: - if (g_save_builder_image && commit_adventure(*g_save_builder_image, error)) { - InvalidateSaveManagerViewCache(); - state.original = g_save_builder_image; - state.summary.reset(); - g_save_manager_status = "Changes saved. Previous progress kept in Backups."; - } else if (!error.empty()) { - g_save_manager_status = error; - } - break; - case Ask::None: - default: - break; - } - close = true; - } - if (close) { - ImGui::CloseCurrentPopup(); - state.ask = Ask::None; - shown = Ask::None; - } - EndSettingsDialog(); + const auto inner=SettingsDialogInner(); + SavesNote(detail,inner); + if(!state.restore.empty()&&(state.ask==Ask::Import||state.ask==Ask::Restore||state.ask==Ask::RepairUse||state.ask==Ask::Host))SavesNote(PathUtf8(state.restore),inner,14); + if(replace&&state.ask!=Ask::Host)SavesNote("Normal play and mods use this personal save. Online and development saves stay separate.",inner); + if(SavesDraftDirty()&&state.ask!=Ask::Apply&&state.ask!=Ask::MaxOut&&state.ask!=Ask::UnlockAll)SavesNote("Your unapplied editor draft will be discarded after this succeeds.",inner); + const float height=44;const bool stack=PaddockRaceButtonWidth(confirm.c_str())+PaddockRaceButtonWidth("CANCEL")+12>inner; + const float cell=stack?inner:(inner-12)*0.5f; + bool close=false; + if(SavesRaceButton("CANCEL",cell))close=true; + if(!stack)ImGui::SameLine(0,12);else PaddockGap(10); + if(SavesRaceButton(confirm.c_str(),cell,SavesTone::Primary,SharedSaveBusy())) { + const auto source=state.restore;const auto port=state.slot; + if(state.ask==Ask::Discard) {SavesForgetDraft();g_shared_draft_revision.clear();} + else if(state.ask==Ask::MaxOut||state.ask==Ask::UnlockAll||state.ask==Ask::Apply) { + codec::SaveImage image;std::string error;std::string expected=g_shared_draft_revision; + bool ready=false; + if(g_save_builder_image){image=*g_save_builder_image;ready=true;} + else {expected=shared::revision();ready=load_adventure(image,error);} + if(ready) { + if(state.ask==Ask::MaxOut)shared::complete(image,state.slot); + if(state.ask==Ask::UnlockAll)shared::unlock(image); + BeginSharedSaveJob([image=std::move(image),expected](std::string& error){return shared::apply(image,expected,error);},"Personal save updated and read-back verified. Existing Dev Menu copies are separate; use Copy Personal Save to Development to refresh one."); + }else g_save_manager_status=error; + } else if(state.ask==Ask::Reset)BeginSharedSaveJob([](std::string& error){return shared::reset(error);},"Personal save reset. Previous progress and custom records are kept in Recovery."); + else if(state.ask==Ask::Bundle)BeginSharedSaveJob([source](std::string& error){return import_bundle(source,error);},"Save bundle imported. Original images are kept in the transaction archive."); + else if(state.ask==Ask::Pak)BeginSharedSaveJob([source,port](std::string& error){return shared::prepare(g_config_directory,error)&&import_controller_pak(port,source,error);},"Controller Pak imported."); + else if(state.ask==Ask::Host)BeginSharedSaveJob([source](std::string& error){return shared::import_host(source,error);},"Online Adventure selected. Personal progress was not changed."); + else if(state.ask==Ask::DevelopmentCopy)BeginSharedSaveJob([](std::string& error){return shared::queue_development_copy(error);},"Personal save snapshot queued for the next offline development launch. Existing development progress will be preserved."); + else if(state.ask==Ask::Repair)BeginSharedSaveJob([](std::string& error){bool changed;std::filesystem::path original;return shared::prepare(g_config_directory,error)&&repair_adventure_checksums(changed,original,error);},"Checksum repair complete. Check your progress; the original was retained."); + else {const bool repair=state.ask==Ask::RepairUse;BeginSharedSaveJob([source,repair](std::string& error){return shared::adopt(source,repair,error);},"Personal save replaced. Both the selected original and previous progress were retained.");} + close=true; + } + if(close){ImGui::CloseCurrentPopup();state.ask=shown=Ask::None;state.restore.clear();} + EndSettingsDialog();(void)height; } -// ------------------------------------------------------------ page - -void DrawStoredSaves(SavesPageState& state, float width, bool locked) { +struct SharedSaveInventory { + std::vector records; + bool truncated=false; +}; +void DrawStoredSaves(SavesPageState& state,float width,bool locked) { using namespace dkr::runtime::saves; - if (!g_save_manager_status.empty()) DrawFriendlyStatus(g_save_manager_status, width, dkr::runtime::copy::Area::Saves); - static std::future> pending; - static std::vector records; - static bool loaded = false; - if ((!loaded || SavesRaceButton("REFRESH STORED SAVES")) && !pending.valid()) { - pending = std::async(std::launch::async, [] { return stored_adventures(); }); - loaded = true; - } - if (pending.valid()) { - if (pending.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { - try { records = pending.get(); } catch (...) { g_save_manager_status = "Stored-save scan failed; no files were changed."; } - } else SavesNote("Reading saves...", width); - } - SavesNote(state.scope == 1 ? "Online progress is separate from offline saves." - : state.scope == 2 ? "Each mod selection keeps separate progress." - : "Restore a surviving copy to recover progress. Recovery needs an existing save or backup.", width); - bool found = false; - for (const auto& record : records) { - if (record.scope != state.scope) continue; - found = true; ImGui::PushID(PathUtf8(record.info.path).c_str()); - PaddockBox box(width, {19.0F, 19.0F}); const float inner = box.Inner(); - SavesNote(record.label, inner, 16.0F, kSetText); - SavesNote(record.preview, inner); - SavesNote(PathUtf8(record.info.path), inner, 14.0F); - if (SavesRaceButton("COPY SAVE LOCATION")) SDL_SetClipboardText(PathUtf8(record.info.path).c_str()); - PaddockGap(8.0F); - if (SavesRaceButton("EXPORT STORED SAVE", 0, SavesTone::Plain, !record.info.valid)) ExportAdventureWithDialog(record.info.path, record.kind); - if (state.scope == 3 && record.kind == StoredSaveKind::Adventure && record.info.valid && !locked) { - PaddockGap(8.0F); - if (SavesRaceButton("RESTORE TO OFFLINE...", 0, SavesTone::Danger)) { - state.restore = record.info.path; state.ask = SavesPageState::Ask::Restore; - state.scope = 0; + static std::future pending; + static SharedSaveInventory inventory; + static std::size_t limit=256; + static std::uint64_t generation=0; + static auto refresh=std::chrono::steady_clock::time_point{}; + const auto now=std::chrono::steady_clock::now(); + const bool reload=generation!=g_shared_save_generation||now>=refresh; + if(!pending.valid()&&(reload||SavesRaceButton("REFRESH SAVES"))) { + generation=g_shared_save_generation;refresh=now+std::chrono::seconds(10); + pending=std::async(std::launch::async,[budget=limit]{SharedSaveInventory result;result.records=stored_adventures(budget,&result.truncated);return result;}); + } + if(pending.valid()&&pending.wait_for(std::chrono::seconds(0))==std::future_status::ready) { + try{ + inventory=pending.get(); + std::map,std::size_t> groups; + std::vector compact; + for(auto& record:inventory.records) { + const auto bucket=record.scope==0?0:record.scope==1?1:3; + const auto key=std::tuple{bucket,int(record.kind),record.content_hash}; + const auto [it,added]=groups.emplace(key,compact.size()); + if(added)compact.push_back(std::move(record));else ++compact[it->second].copies; + } + inventory.records=std::move(compact); + }catch(const std::exception& e){g_save_manager_status=e.what();} + } + if(pending.valid())SavesNote("Reading saved progress...",width); + SavesNote(state.scope==1?"Online Adventures are separate. The host can continue with different mods without switching saves.": + "Choose an older save to use as your personal Adventure. Original mod-set copies and backups are never removed automatically.",width); + bool found=false; + for(const auto& record:inventory.records) { + if(state.scope==1?(record.scope!=1):(record.scope==0||record.scope==1))continue; + found=true;ImGui::PushID(PathUtf8(record.info.path).c_str()); + PaddockBox box(width,{19,19});const auto inner=box.Inner(); + const auto label=record.kind==StoredSaveKind::CustomRecords?"Custom-course records": + record.kind==StoredSaveKind::ControllerPak?"Controller Pak backup":record.kind==StoredSaveKind::ExperimentalPaks?"Online Controller Paks": + state.scope==1?"Online Adventure":record.scope==2?"Older mod-set Adventure":"Adventure backup"; + SavesNote(label,inner,17,kSetText); + SavesNote(record.preview,inner);SavesNote(SavesBackupTime(record.info.path),inner,14); + if(record.copies>1)SavesNote(std::to_string(record.copies)+" identical copies grouped here. All originals are kept.",inner,14); + const bool adventure=record.kind==StoredSaveKind::Adventure; + const bool development=record.info.path.generic_string().find("/development/")!=std::string::npos; + if(development)SavesNote("Development image - contains tool-session progress, not ordinary play.",inner,14,kSetWarm); + if(adventure&&!locked) { + if(SavesRaceButton(record.info.valid?(state.scope==1?"USE FOR HOSTING":"USE AS PERSONAL SAVE"):"REPAIR & USE COPY",0,SavesTone::Primary,SharedSaveBusy()||(!record.info.valid&&record.info.size!=512))) { + state.restore=record.info.path;state.ask=!record.info.valid?SavesPageState::Ask::RepairUse:state.scope==1?SavesPageState::Ask::Host:SavesPageState::Ask::Restore; } + PaddockGap(8); } - box.End([](ImDrawList* draw, ImVec2 a, ImVec2 b) { - PaddockPanel(draw, a, b, {PaddockRound(16.0F), PaddockRgb(0x0B2E40, 245U), PaddockRgb(kSetBorder), 1.0F}); - }); - PaddockGap(14.0F); ImGui::PopID(); - } - if (!found && !pending.valid()) SavesNote("No saves found in this category.", width); -} - -void DrawSaveManagerPage(float available_width, bool live) { - SavesPageState& state = g_saves_page; - const int frame = ImGui::GetFrameCount(); - const bool entered = state.context != ImGui::GetCurrentContext() || state.last_frame != frame - 1; - if (entered) { - state.context = ImGui::GetCurrentContext(); - state.summary.reset(); - } - state.last_frame = frame; - const float width = available_width; - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {0.0F, 0.0F}); - DrawPageHeading("SAVE MANAGER"); - PaddockGap(13.0F); - PaddockText(PaddockReading(16.0F, false, 1.45F), PaddockRgb(kSetMuted), - "Back up, transfer and edit your saves.", width); - PaddockGap(22.0F); - const bool lobby = dkr::runtime::netplay::session().active(); - const int scope = SettingsSectionSigns({"OFFLINE", "ONLINE", "MODDED", "RECOVERY"}, state.scope, width); - if (scope >= 0) { state.scope = scope; state.ask = SavesPageState::Ask::None; } - PaddockGap(16.0F); - if (state.scope != 0) { - DrawStoredSaves(state, width, live || lobby); - ImGui::PopStyleVar(); return; - } - - if (live) { - PaddockBox box(width, {19.0F, 19.0F}); - const float inner = box.Inner(); - const auto owner = dkr::runtime::saves::runtime_save_selection(); - const auto route = dkr::runtime::saves::runtime_save_path(); - SavesNote(owner.online ? "ACTIVE: ONLINE SESSION SAVE" : route.parent_path().filename() == "saves" - ? "ACTIVE: YOUR OFFLINE ADVENTURE" : "ACTIVE: MOD-SET SAVE (OFFLINE)", inner, 16.0F, kSetText); - SavesNote(PathUtf8(route), inner, 14.0F); - PaddockText(PaddockSign(19.0F, 1.45F, 0.0F), PaddockRgb(kSetText), "SAVE EDITING UNAVAILABLE", inner); - PaddockGap(12.0F); - PaddockText(PaddockReading(16.0F, false, 1.45F), PaddockRgb(kSetMuted), - "Return to the launcher before importing, restoring or resetting saves.", - inner); - box.End([](ImDrawList* draw, ImVec2 a, ImVec2 b) { - PaddockPanel(draw, a, b, {PaddockRound(16.0F), PaddockRgb(0x0B2E40, 245U), PaddockRgb(kSetBorder), 1.0F}); - }); - ImGui::PopStyleVar(); - return; + if(SavesRaceButton(record.info.valid?"EXPORT COPY":"EXPORT ORIGINAL IMAGE",0,SavesTone::Plain,SharedSaveBusy())) { + if(record.info.valid)ExportAdventureWithDialog(record.info.path,record.kind);else SharedRawExportDialog(record.info.path); + } + PaddockGap(8); + if(SavesRaceButton("COPY LOCATION"))SDL_SetClipboardText(PathUtf8(record.info.path).c_str()); + box.End([](ImDrawList* draw,ImVec2 a,ImVec2 b){PaddockPanel(draw,a,b,{PaddockRound(16),PaddockRgb(0x0B2E40,245),PaddockRgb(kSetBorder),1});}); + PaddockGap(14);ImGui::PopID(); } - - if (!g_save_manager_status.empty()) { - DrawFriendlyStatus(g_save_manager_status, width, dkr::runtime::copy::Area::Saves); - PaddockGap(13.0F); + if(!found&&!pending.valid())SavesNote("No saved images found here.",width); + if(inventory.truncated) { + SavesNote("More saved images are available. The list is limited to keep scanning responsive.",width); + if(limit<50000&&SavesRaceButton("LOAD MORE SAVES")){limit=std::min(limit*4,50000);refresh={};} } - const SaveManagerViewCache& view = CachedSaveManagerView(); - const auto& info = view.adventure; - const bool editable = info.exists && info.valid && info.size == dkr::runtime::saves::codec::kImageSize; - if (!editable && g_save_builder_image) SavesForgetDraft(); +} - // The current save (.saves-card.saves-adventure). - { - std::string status = "Ready to use"; - std::string description = "Three adventure slots, shared unlocks and personal records."; - unsigned status_colour = kSavesAccent; - if (!info.exists) { - status = "No Adventure save yet"; - description = "Start an Adventure, import a save or create one below."; - status_colour = kSetText; - } else if (info.size != dkr::runtime::saves::codec::kImageSize) { - status = "Unsupported save format"; - description = "Try a supported save or restore a working backup."; - status_colour = kSavesError; - } else if (!info.valid) { - status = "Save needs repair"; - description = "Repair this save or restore a working backup."; - status_colour = kSavesError; - } - PaddockBox box(width, {19.0F, 19.0F}); - const float inner = box.Inner(); - const PaddockType badge = PaddockReading(15.0F, true, 1.4F); - const float badge_width = PaddockMeasure(badge, status); - const ImVec2 head = ImGui::GetCursorScreenPos(); - PaddockText(PaddockSign(19.0F, 1.2F, 0.0F), PaddockRgb(kSetText), "OFFLINE ADVENTURE", - inner - badge_width - 10.0F); - PaddockDrawRun(ImGui::GetWindowDrawList(), badge, {head.x + inner - badge_width, head.y}, - PaddockCol(status_colour), status.data(), status.data() + status.size()); - PaddockGap(12.0F); - PaddockText(PaddockReading(16.0F, false, 1.45F), PaddockRgb(kSetMuted), description, inner); - if (info.exists && info.size != dkr::runtime::saves::codec::kImageSize) - DrawTechnicalDetails("Save size: " + std::to_string(info.size) + " bytes. Expected 512 bytes.", "Format details"); - PaddockGap(13.0F); - if (ImGui::CollapsingHeader("Save location")) { - SavesNote(PathUtf8(info.path), inner, 14.0F); - if (SavesRaceButton("COPY LOCATION")) SDL_SetClipboardText(PathUtf8(info.path).c_str()); - } - if (info.exists && info.size == dkr::runtime::saves::codec::kImageSize && !info.valid) { - PaddockGap(13.0F); - if (SavesRaceButton("BACK UP & REPAIR SAVE", 0.0F, SavesTone::Primary, lobby)) { - state.ask = SavesPageState::Ask::Repair; +void DrawSaveManagerPage(float available_width,bool live) { + using namespace dkr::runtime::saves; + auto& state=g_saves_page;const auto width=available_width; + static std::uint64_t generation=0; + const auto frame=ImGui::GetFrameCount();const bool entered=state.context!=ImGui::GetCurrentContext()||state.last_frame!=frame-1; + if(entered||generation!=g_shared_save_generation) { + state.summary.reset();InvalidateSaveManagerViewCache();++g_shared_save_generation;generation=g_shared_save_generation; + // Preserve a dirty draft, but compare its revision at commit time. + // A clean draft can be safely reloaded after gameplay/return. + if(!SavesDraftDirty())SavesForgetDraft(); + } + state.context=ImGui::GetCurrentContext();state.last_frame=frame; + PumpSharedSaveJob(); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,{0,0}); + DrawPageHeading("SAVE MANAGER");PaddockGap(14); + if(!g_save_manager_status.empty())DrawFriendlyStatus(g_save_manager_status,width,dkr::runtime::copy::Area::Saves); + const bool lobby=dkr::runtime::netplay::session().active();const bool locked=live||lobby||SharedSaveBusy(); + const int current=state.scope==0?0:state.scope==1?2:1; + const int picked=SettingsSectionSigns({"YOUR SAVE","BACKUPS & RECOVERY","ONLINE SAVES"},current,width); + if(picked>=0){state.scope=picked==0?0:picked==2?1:3;state.ask=SavesPageState::Ask::None;state.restore.clear();} + PaddockGap(20); + if(live) { + SavesNote("Save editing is unavailable while the game is running. Return to the launcher first.",width); + SavesNote("Active save: "+PathUtf8(runtime_save_path()),width,14); + } else if(state.scope!=0)DrawStoredSaves(state,width,locked); + else { + const auto& view=CachedSaveManagerView();const auto& info=view.adventure; + if(!view.valid){SavesNote("Reading your personal save...",width);ImGui::PopStyleVar();return;} + const bool valid=info.exists&&info.valid; + SavesNote(valid?"YOUR PERSONAL ADVENTURE":info.exists?"YOUR SAVE NEEDS RECOVERY":"NO PERSONAL SAVE YET",width,19,valid?kSavesAccent:kSetWarm); + if(lobby)SavesNote("Leave the lobby before changing saves. You can export your personal save.",width); + if(SharedSaveBusy())SavesNote("Finishing the save operation. Launch is temporarily held until it is safe.",width); + if(!locked) { + if(!info.valid&&info.exists&&info.size==512) { + if(SavesRaceButton("BACK UP & REPAIR CHECKSUMS",0,SavesTone::Primary))state.ask=SavesPageState::Ask::Repair; + PaddockGap(12); } - } - box.End([](ImDrawList* draw, ImVec2 a, ImVec2 b) { - PaddockPanel(draw, a, b, {PaddockRound(16.0F), PaddockRgb(0x0B2E40, 245U), PaddockRgb(kSetBorder), 1.0F}); - }); - } - PaddockGap(24.0F); - if (lobby) { - SavesNote("Leave the lobby before changing offline saves.", width, 16.0F, kSetWarm); - SavesNote(view.preview, width); - if (SavesRaceButton("EXPORT OFFLINE COPY", 0, SavesTone::Plain, !editable)) ExportAdventureWithDialog(); - state.ask = SavesPageState::Ask::None; - ImGui::PopStyleVar(); return; - } - const std::string edit_label = std::string("EDIT PROGRESS") + (SavesDraftDirty() ? " *" : "") + "##edit"; - const int picked = SettingsSectionSigns({"OVERVIEW##overview", edit_label.c_str(), "TRANSFER & PAKS##transfer"}, - state.section, width); - if (picked >= 0) state.section = picked; - PaddockGap(24.0F); - if (state.section == 1) { - DrawSavesEditor(state, width, available_width, editable, info.exists); - } else if (state.section == 2) { - DrawSavesTransfer(width, available_width, view); - } else { - DrawSavesOverview(state, width, available_width, view, editable); + const auto section=SettingsSectionSigns({"OVERVIEW","EDIT PROGRESS","TRANSFER & PAKS"},state.section,width); + if(section>=0)state.section=section; + PaddockGap(18); + if(state.section==1)DrawSavesEditor(state,width,width,valid,info.exists); + else if(state.section==2)DrawSavesTransfer(width,width,view); + else DrawSavesOverview(state,width,width,view,valid); + } else if(!SharedSaveBusy()&&valid&&SavesRaceButton("EXPORT PERSONAL COPY"))ExportAdventureWithDialog(); + } + if(!g_shared_save_import.path.empty()&&!locked) { + state.restore=std::move(g_shared_save_import.path);state.slot=g_shared_save_import.port; + state.ask=g_shared_save_import.kind==1?SavesPageState::Ask::Bundle:g_shared_save_import.kind==2?SavesPageState::Ask::Pak:SavesPageState::Ask::Import; + g_shared_save_import={}; } ImGui::PopStyleVar(); - DrawSavesConfirmations(state); + if(!locked)DrawSavesConfirmations(state); } diff --git a/runtime-recomp/src/game/runtime_stubs.cpp b/runtime-recomp/src/game/runtime_stubs.cpp index 17a5c709..0a766ef8 100644 --- a/runtime-recomp/src/game/runtime_stubs.cpp +++ b/runtime-recomp/src/game/runtime_stubs.cpp @@ -1,3 +1,4 @@ +#include "diagnostics.hpp" #include "recomp.h" #include "runtime_enhancements.hpp" @@ -410,6 +411,9 @@ extern "C" void dkr_split_screen_viewport_fill( if (rdram == nullptr || context == nullptr) { return; } + AppendPresentationGroupCommand(rdram,context->r17,PresentationGroupMode::World, + std::uint16_t(MEM_W(0,RdramAddress(dkr::runtime::revision_addresses::ActiveCameraId))&3U), + 0U,PresentationMarkerKind::CameraWorldBegin); const float cover = ExpandedCoverScale(); const bool postrace = PostraceFullViewScope(rdram, cover); g_postrace_world_framed = postrace && @@ -488,6 +492,9 @@ extern "C" void dkr_split_screen_world_aspect_end( if (rdram == nullptr || context == nullptr) { return; } + AppendPresentationGroupCommand(rdram,context->r17,PresentationGroupMode::World, + std::uint16_t(MEM_W(0,RdramAddress(dkr::runtime::revision_addresses::ActiveCameraId))&3U), + 0U,PresentationMarkerKind::CameraWorldEnd); // Close in reverse order. Dedicated markers restore the exact renderer // state they captured, so they cannot consume an unrelated group scope. if (g_postrace_framed_scope_active) { @@ -598,7 +605,8 @@ extern "C" void dkr_track_select_lens_flare_tint_begin( const bool tracks_menu_active = MEM_W( 0, RdramAddress( dkr::runtime::revision_addresses::IsInTracksMenu)) != 0; - if (!dkr::runtime::enhancements::preserve_track_select_lens_flare_tint( + const bool postrace_lens=PostraceFullViewScope(rdram,ExpandedCoverScale()); + if (!postrace_lens && !dkr::runtime::enhancements::preserve_track_select_lens_flare_tint( dkr::runtime::enhancements::modern_presentation_enabled(), ExpandedCoverScale() > 1.0001F, tracks_menu_active, fullscreen_preview)) { @@ -607,7 +615,8 @@ extern "C" void dkr_track_select_lens_flare_tint_begin( g_track_select_lens_flare_scope_active = AppendPresentationGroupCommand( - rdram, context->r17, PresentationGroupMode::StaticAuto, 0U, + rdram, context->r17, PresentationGroupMode::StaticAuto, + postrace_lens&&!g_postrace_frame_gate.contracted_frame_submitted?1U:0U, 0U, PresentationMarkerKind::TrackSelectLensFlare); #else (void)rdram; @@ -666,7 +675,9 @@ extern "C" int dkr_scheduler_sp_event_valid(std::uint8_t* rdram, // valid retail task and must not reach that unchecked load. if (dkr::runtime::scheduler::is_valid_sp_task_pointer(task)) { using namespace dkr::runtime::host_tasks; - registry.consume(registry.lookup(task + 0x10U, Engine::Sp), Engine::Sp); + const auto token=registry.lookup(task+0x10U,Engine::Sp); + dkr::lobby_stability::bootstrap_event("SP-handler-consumed",scheduler,task,token.generation); + registry.consume(token,Engine::Sp); return 1; } } @@ -710,7 +721,9 @@ extern "C" int dkr_scheduler_dp_event_valid(std::uint8_t* rdram, static_cast(scheduler)))); if (dkr::runtime::scheduler::is_valid_dp_task_pointer(task)) { using namespace dkr::runtime::host_tasks; - registry.consume(registry.lookup(task + 0x10U, Engine::Dp), Engine::Dp); + const auto token=registry.lookup(task+0x10U,Engine::Dp); + dkr::lobby_stability::bootstrap_event("DP-handler-consumed",scheduler,task,token.generation); + registry.consume(token,Engine::Dp); return 1; } } @@ -743,6 +756,7 @@ extern "C" int dkr_scheduler_dp_event_valid(std::uint8_t* rdram, } extern "C" void dkr_scheduler_sp_handled(std::uint8_t*, recomp_context*) { + dkr::lobby_stability::bootstrap_event("SP-handler-acknowledging",0,0); // The host graphics/audio worker cannot safely publish a dependent DP edge // until DKR's scheduler has finished clearing and rescheduling the active // SP task. This hook is placed at the common exit of __scHandleRSP by the diff --git a/runtime-recomp/src/game/runtime_texture_packs.cpp b/runtime-recomp/src/game/runtime_texture_packs.cpp index 6c830014..c3ca3997 100644 --- a/runtime-recomp/src/game/runtime_texture_packs.cpp +++ b/runtime-recomp/src/game/runtime_texture_packs.cpp @@ -1,4 +1,6 @@ +#include "mod_store_install.hpp" #include "runtime_texture_packs.hpp" +#include "mod_store.hpp" #include "application_layout.hpp" #include "startup_performance.hpp" @@ -1010,11 +1012,20 @@ void refresh() { std::vector snapshot(bool include_hidden) { std::scoped_lock lock(g_mutex); - if (include_hidden) return g_packs; + const auto named = [](PackInfo pack) { + pack.name = dkr::store::texture_display_name(pack.path, pack.name); + return pack; + }; + if (include_hidden) { + auto result = g_packs; + for (auto& pack : result) pack = named(std::move(pack)); + return result; + } std::vector visible; visible.reserve(g_packs.size()); std::copy_if(g_packs.begin(), g_packs.end(), std::back_inserter(visible), [](const PackInfo& pack) { return !pack.hidden; }); + for (auto& pack : visible) pack = named(std::move(pack)); return visible; } @@ -1628,3 +1639,5 @@ std::string status() { } } // namespace dkr::runtime::texture_packs + +#include "store_texture_adapter.inl" diff --git a/runtime-recomp/src/game/runtime_ui.cpp b/runtime-recomp/src/game/runtime_ui.cpp index 3c8b48d9..d7f1de10 100644 --- a/runtime-recomp/src/game/runtime_ui.cpp +++ b/runtime-recomp/src/game/runtime_ui.cpp @@ -1,4 +1,22 @@ +#include "diagnostics.hpp" +#include "online_directory.hpp" +#include "linux_keyring.hpp" +#include "assessment.hpp" +#include "rollback_check.hpp" +#include "lobby_adapter.hpp" +#include "rollback_policy.hpp" +#include "invite_handoff.hpp" +#include +#include "party_link.hpp" #include "runtime_ui.hpp" +#include "achievements.hpp" +#include +#include +#include "texture_dump.hpp" +#include "shared_saves.hpp" +#include "mod_store.hpp" +#include "mod_store_install.hpp" +#include #include "launcher_copy_policy.hpp" #include "lobby_privacy_policy.hpp" #include "online_view_policy.hpp" @@ -40,12 +58,16 @@ #include "mods/legacy_mod_library.hpp" #include "mods/magic_code_library_jobs.hpp" #include "mods/legacy_mod_launch.hpp" +#include "vehicle_library.hpp" +#include "vehicle_protocol.hpp" #include "mods/legacy_mod_browser.hpp" #include "mods/online_mod_runtime.hpp" #include "netplay/online_mod_sync_link.hpp" #include "runtime_enhancements.hpp" #include "enhanced_water_settings.hpp" #include "modern_camera.hpp" +#include "camera_runtime.hpp" +#include "mods/sdk2_bridge.hpp" #include "runtime_hud_layout.hpp" #include "hud_layout_editor.hpp" #include "hud_basic_settings.hpp" @@ -95,6 +117,7 @@ #include "imgui/imgui_internal.h" #include "imgui/imgui_impl_sdl2_custom.h" #include "imgui/backends/imgui_impl_sdlrenderer2.h" +bool DkrReviewInputText(const char*, char*, size_t, const ImVec2&); #include "nfd.h" #include "ultramodern/config.hpp" #include "ultramodern/ultramodern.hpp" @@ -435,8 +458,12 @@ std::unique_ptr g_online_mod_sync; dkr::mods::online::SyncSettings g_online_mod_settings; std::filesystem::path g_online_mod_selected_rom; dkr::runtime::rom::Identity g_online_mod_identity; +bool g_online_allow_spectators=dkr::runtime::netplay::rollback_policy::spectators,g_online_join_watching=false; +void ConfigurePartyJoin(){dkr::runtime::spectators::request_party_join(&dkr::runtime::netplay::session(),g_online_join_watching);} struct OnlineHostRequest { - dkr::runtime::netplay::Rules rules; + bool spectators=g_online_allow_spectators; + + dkr::runtime::netplay::Rules rules=dkr::runtime::netplay::rollback_policy::default_rules(); dkr::runtime::netplay::CompatibilityManifest manifest; dkr::runtime::saves::OnlineSaveSeedMode save_mode; std::string room,name; @@ -634,6 +661,14 @@ std::map g_seen_friend_lobby_invites; std::optional g_joining_friend_invite; +bool InviteNoticeArrival(const dkr::runtime::netplay::FriendLobbyInviteView&); +void RequestLobbyInvitation(std::uint64_t,const std::string&); +void DeclineLobbyInvitation(std::uint64_t,const std::string&); +void PumpInviteLauncher(bool,bool); +bool InviteUiVisible(); +void DrawInviteUi(); +void DrawInviteInbox(float); + bool g_open_host_friend_invites = false; bool g_online_code_keyboard_pending = false; std::atomic g_online_code_keyboard_visible{false}; @@ -642,11 +677,11 @@ std::atomic g_online_code_keyboard_cancel_requested{false}; int g_online_countdown_panel_frame = -1; int g_online_host_control = static_cast( dkr::runtime::netplay::HostControlPolicy::GuidedUntilCharacterSelect); -int g_online_maximum_players = 2; +int g_online_maximum_players = dkr::runtime::netplay::rollback_policy::maximum_players; int g_online_synchronization = static_cast( dkr::runtime::netplay::SynchronizationMode::Rollback); -bool g_online_experimental_rollback = false; -bool g_online_automatic_backend = true; +bool g_online_experimental_rollback = true; +bool g_online_automatic_backend = false; #if defined(DKR_EXPERIMENTAL_RACE_TEST) std::filesystem::path g_online_experimental_rom; #if defined(DKR_EXPERIMENTAL_FULL_SCENES) @@ -654,15 +689,21 @@ bool g_online_experimental_adventure = false; #endif #endif dkr::runtime::netplay::SynchronizationMode CurrentOnlineSynchronization() { - return dkr::runtime::netplay::experimental::resolved_host_backend( - g_online_synchronization, g_online_experimental_rollback, g_online_automatic_backend); -} -int g_online_rollback_window = 10; -bool g_online_automatic_delay = true; -int g_online_manual_delay = 2; -bool g_online_record_replay = true; + return dkr::runtime::netplay::rollback_policy::mode; +} +int g_online_rollback_window = dkr::runtime::netplay::rollback_policy::prediction_window; +bool g_online_automatic_delay = dkr::runtime::netplay::rollback_policy::automatic_delay; +int g_online_manual_delay = dkr::runtime::netplay::rollback_policy::input_delay; +bool g_online_record_replay = dkr::runtime::netplay::rollback_policy::record_replay; +bool g_online_development=dkr::runtime::netplay::rollback_policy::development; +int g_sdk2_tools_key=SDL_SCANCODE_F2; +bool g_sdk2_key_capture=false,g_sdk2_tools_chord=true; +std::map g_sdk2_chord_held; +bool g_online_development_consent=false; +std::uint64_t g_online_development_room=0; int g_online_save_seed_mode = static_cast( - dkr::runtime::saves::OnlineSaveSeedMode::CopySinglePlayer); + dkr::runtime::saves::OnlineSaveSeedMode::ContinuePreviousSession); +bool g_shared_online_policy_loaded=false; int g_online_input_profile = 0; std::string g_online_action_status; std::string g_online_error_notification; @@ -696,9 +737,10 @@ constexpr int kPageOnlineMp = 5; constexpr int kPageModsHacks = 6; constexpr int kPageAbout = 7; constexpr int kPageTextures = 8; -constexpr int kMenuPageCount = 9; +constexpr int kPageAchievements = 9; +constexpr int kMenuPageCount = 10; // Stable page IDs, explicit visual order for both pointer and controller use. -constexpr std::array kSidebarOrder{0,5,6,1,2,3,4,8,7,9,10}; +constexpr std::array kSidebarOrder{0,5,6,1,2,3,4,8,9,7,10,11}; int StepSidebarSelection(int current,int direction) { const auto found=std::find(kSidebarOrder.begin(),kSidebarOrder.end(),current); const int index=found==kSidebarOrder.end()?0:static_cast(found-kSidebarOrder.begin()); @@ -749,53 +791,49 @@ struct SaveManagerViewCache { std::array controller_paks{}; std::vector adventure_backups; + std::optional image; std::string preview; }; SaveManagerViewCache g_save_manager_view_cache; +std::uint64_t g_save_cache_revision=0; void InvalidateSaveManagerViewCache() { g_save_manager_view_cache.valid = false; + ++g_save_cache_revision; } const SaveManagerViewCache& CachedSaveManagerView() { - const auto now = std::chrono::steady_clock::now(); - if (g_save_manager_view_cache.valid && - now < g_save_manager_view_cache.next_refresh) { - return g_save_manager_view_cache; - } - g_save_manager_view_cache.adventure = - dkr::runtime::saves::adventure_info(); - g_save_manager_view_cache.preview.clear(); - dkr::runtime::saves::codec::SaveImage summary; - std::string summary_error; - if (g_save_manager_view_cache.adventure.valid && dkr::runtime::saves::load_adventure(summary, summary_error)) { - for (std::size_t slot = 0; slot < summary.slots.size(); ++slot) { - if (slot) g_save_manager_view_cache.preview += " | "; - const auto& saved = summary.slots[slot]; - g_save_manager_view_cache.preview += "Slot " + std::to_string(slot + 1) + ": " + - (saved.name.empty() ? "Empty" : saved.name) + " (" + std::to_string(saved.balloons[0]) + " balloons)"; - } - } - for (int channel = 0; - channel < dkr::runtime::saves::kControllerPakCount; ++channel) { - g_save_manager_view_cache.controller_paks[ - static_cast(channel)] = - dkr::runtime::saves::controller_pak_info(channel); - } - g_save_manager_view_cache.adventure_backups = - dkr::runtime::saves::adventure_backups(); - g_save_manager_view_cache.next_refresh = - now + std::chrono::milliseconds{500}; - g_save_manager_view_cache.valid = true; + static std::future> pending; + const auto now=std::chrono::steady_clock::now(); + if(pending.valid()&&pending.wait_for(std::chrono::seconds(0))==std::future_status::ready) { + try {auto result=pending.get();if(result.first==g_save_cache_revision){g_save_manager_view_cache=std::move(result.second);}} + catch(const std::exception& e){g_save_manager_status=e.what();} + } + if(!pending.valid()&&(!g_save_manager_view_cache.valid||now>=g_save_manager_view_cache.next_refresh)) { + pending=std::async(std::launch::async,[generation=g_save_cache_revision]{ + SaveManagerViewCache view;view.adventure=dkr::runtime::saves::adventure_info(); + dkr::runtime::saves::codec::SaveImage image;std::string error; + if(view.adventure.valid&&dkr::runtime::saves::load_adventure(image,error)) { + view.image=image; + for(unsigned i=0;i<3;++i){if(i)view.preview+=" | ";const auto& s=image.slots[i];view.preview+="Slot "+std::to_string(i+1)+": "+s.name+" ("+std::to_string(s.balloons[0])+" balloons)";} + } + for(int i=0;i<4;++i)view.controller_paks[i]=dkr::runtime::saves::controller_pak_info(i); + view.adventure_backups=dkr::runtime::saves::adventure_backups(); + view.valid=true;view.next_refresh=std::chrono::steady_clock::now()+std::chrono::seconds(2); + return std::pair{generation,std::move(view)}; + }); + } return g_save_manager_view_cache; } + std::string g_magic_codes_status; bool g_online_compatibility_sync_requested = false; std::string g_controller_mapping_status; bool g_controller_mapping_popup_pending = false; bool g_controller_mapping_completion_saved = false; std::optional g_save_builder_image; +#include "shared_save_jobs.inl" bool HandleInputCaptureEvent(SDL_Event* event); @@ -1856,17 +1894,22 @@ void SaveSettings() { output << "online_label_size=" << dkr::runtime::online_view::requested_label_size.load() << '\n'; output << "online_room_name=" << g_online_room_name << '\n'; output << "online_host_control=" << g_online_host_control << '\n'; + output << "online_allow_spectators=" << (g_online_allow_spectators ? 1 : 0) << '\n'; output << "online_maximum_players=" << g_online_maximum_players << '\n'; - output << "online_synchronization=" << g_online_synchronization << '\n'; - output << "online_experimental_rollback=" << (g_online_experimental_rollback ? 1 : 0) << '\n'; - output << "online_automatic_backend=" << (g_online_automatic_backend ? 1 : 0) << '\n'; + output << "online_synchronization=" << 0 << '\n'; + output << "online_experimental_rollback=" << 1 << '\n'; + output << "online_automatic_backend=" << 0 << '\n'; #if defined(DKR_EXPERIMENTAL_FULL_SCENES) output << "online_experimental_adventure=" << (g_online_experimental_adventure ? 1 : 0) << '\n'; #endif output << "online_rollback_window=" << g_online_rollback_window << '\n'; output << "online_automatic_delay=" << (g_online_automatic_delay ? 1 : 0) << '\n'; output << "online_manual_delay=" << g_online_manual_delay << '\n'; + output << "sdk2_tools_key=" << g_sdk2_tools_key << '\n'; + output << "sdk2_tools_chord=" << (g_sdk2_tools_chord?1:0) << '\n'; + output << "online_development=" << (g_online_development?1:0) << '\n'; output << "online_record_replay=" << (g_online_record_replay ? 1 : 0) << '\n'; + output << "online_save_policy=2\n"; output << "online_save_seed_mode=" << g_online_save_seed_mode << '\n'; output << "online_input_profile=" << g_online_input_profile << '\n'; output << "friend_online_notifications=" @@ -2045,6 +2088,7 @@ void LoadSettings() { bool migrated = false; bool modern_scenery_settings_v2 = false; bool has_automatic_backend_setting = false; + bool has_rollback_optin_setting = false; bool has_legacy_backend_setting = false; std::array shortcut_keyboard{}; @@ -2088,6 +2132,9 @@ void LoadSettings() { g_online_hide_lobby_code = value == "1"; continue; } + if(key=="sdk2_tools_key"){int scancode=0;const auto parsed=std::from_chars(value.data(),value.data()+value.size(),scancode);if(parsed.ec==std::errc{}&&scancode>0&&scancode(std::strtoul(value.c_str(), nullptr, 10)); @@ -2204,7 +2252,12 @@ void LoadSettings() { } // Keep the opt-in independent of the legacy 0/1 setting (and of // the long compatibility parser below). + if (key == "online_allow_spectators") { + g_online_allow_spectators = number != 0; + continue; + } if (key == "online_experimental_rollback") { + has_rollback_optin_setting = true; g_online_experimental_rollback = number == 1; has_legacy_backend_setting = true; continue; @@ -2301,21 +2354,20 @@ void LoadSettings() { } else if (key == "modern_downsample") { g_modern_downsample = std::clamp(number, 1, 8); } else if (key == "online_host_control") { - g_online_host_control = std::clamp(number, 0, 2); + g_online_host_control = number >= 0 && number <= 2 ? number : 0; } else if (key == "online_maximum_players") { - g_online_maximum_players = std::clamp(number, 2, - static_cast(dkr::runtime::netplay::kSupportedOnlinePlayers)); + g_online_maximum_players = number >= 2 && number <= 4 ? number : dkr::runtime::netplay::rollback_policy::maximum_players; } else if (key == "online_synchronization") { g_online_synchronization = std::clamp(number, 0, 1); has_legacy_backend_setting = true; } else if (key == "online_rollback_window") { - g_online_rollback_window = std::clamp(number, 2, 20); + g_online_rollback_window = number >= 2 && number <= 20 ? number : dkr::runtime::netplay::rollback_policy::prediction_window; } else if (key == "online_automatic_delay") { - g_online_automatic_delay = number != 0; + g_online_automatic_delay = number == 0 || number == 1 ? number != 0 : dkr::runtime::netplay::rollback_policy::automatic_delay; } else if (key == "online_manual_delay") { - g_online_manual_delay = std::clamp(number, 0, 9); + g_online_manual_delay = number >= 0 && number <= 9 ? number : dkr::runtime::netplay::rollback_policy::input_delay; } else if (key == "online_record_replay") { - g_online_record_replay = number != 0; + g_online_record_replay = number == 0 || number == 1 ? number != 0 : dkr::runtime::netplay::rollback_policy::record_replay; } else if (key == "online_save_seed_mode") { g_online_save_seed_mode = std::clamp(number, 0, 2); } else if (key == "online_input_profile") { @@ -2557,8 +2609,15 @@ void LoadSettings() { // still owns an open handle. Migration may call SaveSettings below, so // release the read handle before attempting that replacement. input.close(); - g_online_automatic_backend = dkr::runtime::netplay::experimental::migrated_automatic_backend( - has_automatic_backend_setting, g_online_automatic_backend, has_legacy_backend_setting); + // Canonicalize legacy backend flags after reading the complete file. + // Retain valid delay/player/development preferences, not the removed modes. + const bool migrated_rollback_backend = !has_legacy_backend_setting || + !has_automatic_backend_setting || !has_rollback_optin_setting || g_online_synchronization != 0 || + !g_online_experimental_rollback || g_online_automatic_backend; + g_online_synchronization = 0; + g_online_experimental_rollback = true; + g_online_automatic_backend = false; + if (migrated_rollback_backend) SaveSettings(); for (std::size_t channel = 0; channel < kSoundChannelCount; ++channel) { SetSoundMuted(channel, (sound_muted & (1U << channel)) != 0U); } @@ -3083,7 +3142,7 @@ bool StartTexturePackImport( const std::filesystem::path& import_temp = {}) { { std::scoped_lock lock(g_texture_import_mutex); - if (g_texture_import_state.running) return false; + if (g_texture_import_state.running || dkr::store::installations().view()->busy) return false; } std::optional owner_copy; if (owner != nullptr) { @@ -3169,6 +3228,7 @@ std::function g_dialog_job_finish; std::jthread g_dialog_job_worker; bool DialogJobRunning() { + if(SharedSaveBusy())return true; std::scoped_lock lock(g_dialog_job_mutex); return g_dialog_job_running; } @@ -3269,7 +3329,7 @@ bool ImportTexturePackWithDialog() { } void ImportLegacyModWithDialog() { - if (g_legacy_imports.snapshot().busy) return; + if (g_legacy_imports.snapshot().busy || dkr::store::installations().view()->busy) return; if (NFD_Init() != NFD_OKAY) { g_legacy_import_status = "The system file picker could not be initialized."; return; @@ -3295,6 +3355,8 @@ void ImportLegacyModWithDialog() { g_legacy_imports.import_file(std::move(source), std::move(roms)); } +void ImportVehicleWithDialog(); +void ReleaseVehicleTextures(); void ImportMagicCodeWithDialog() { if(g_code_library.view()->busy)return; if(NFD_Init()!=NFD_OKAY){g_legacy_import_status="The file picker could not open.";return;} @@ -3309,33 +3371,16 @@ void ImportMagicCodeWithDialog() { } bool ImportAdventureWithDialog() { - if (NFD_Init() != NFD_OKAY) { - g_save_manager_status = "The system file picker could not be initialized."; - return false; - } - nfdu8char_t* result = nullptr; - const nfdfilteritem_t filters[] = {{"DKR Adventure save", "bin"}}; - const nfdresult_t dialog = NFD_OpenDialogU8(&result, filters, 1, nullptr); - bool imported = false; - if (dialog == NFD_OKAY) { - const auto source = std::filesystem::u8path(result); - NFD_FreePathU8(result); - std::string error; - imported = dkr::runtime::saves::import_adventure(source, error); - if (imported) { - InvalidateSaveManagerViewCache(); - g_save_builder_image.reset(); - } - g_save_manager_status = imported - ? "Adventure save imported. The previous save was backed up first." - : error; - } else if (dialog == NFD_ERROR) { - g_save_manager_status = NFD_GetError(); - } - NFD_Quit(); - return imported; + if(SharedSaveBusy())return false; + if(NFD_Init()!=NFD_OKAY){g_save_manager_status="The system file picker could not be opened.";return false;} + nfdu8char_t* result=nullptr;const nfdfilteritem_t filters[]={{"DKR Adventure save","bin,eep"}}; + const auto dialog=NFD_OpenDialogU8(&result,filters,1,nullptr); + if(dialog==NFD_OKAY){g_shared_save_import={std::filesystem::u8path(result),0,0};NFD_FreePathU8(result);} + else if(dialog==NFD_ERROR)g_save_manager_status=NFD_GetError(); + NFD_Quit();return dialog==NFD_OKAY; } + bool ExportAdventureWithDialog(const std::filesystem::path& source = {}, dkr::runtime::saves::StoredSaveKind kind = dkr::runtime::saves::StoredSaveKind::Adventure) { if (NFD_Init() != NFD_OKAY) { @@ -3356,10 +3401,12 @@ bool ExportAdventureWithDialog(const std::filesystem::path& source = {}, destination += pak ? ".mpk" : ".bin"; } std::string error; - exported = dkr::runtime::saves::export_stored_save(source.empty() - ? dkr::runtime::saves::adventure_info().path : source, destination, kind, error); + const auto selected=source.empty()?dkr::runtime::saves::adventure_info().path:source; + exported=BeginSharedSaveJob([selected,destination,kind](std::string& error){ + return dkr::runtime::saves::export_stored_save(selected,destination,kind,error); + },"Save exported."); g_save_manager_status = exported - ? "Save exported." + ? "Exporting save..." : error; } else if (dialog == NFD_ERROR) { g_save_manager_status = NFD_GetError(); @@ -3369,33 +3416,16 @@ bool ExportAdventureWithDialog(const std::filesystem::path& source = {}, } bool ImportSaveBundleWithDialog() { - if (NFD_Init() != NFD_OKAY) { - g_save_manager_status = "The system file picker could not be initialized."; - return false; - } - nfdchar_t* result = nullptr; - const nfdfilteritem_t filters[] = {{"DKR-R save bundle", "dkrsave"}}; - const nfdresult_t dialog = NFD_OpenDialogU8(&result, filters, 1, nullptr); - bool imported = false; - if (dialog == NFD_OKAY) { - const auto source = std::filesystem::u8path(result); - NFD_FreePathU8(result); - std::string error; - imported = dkr::runtime::saves::import_bundle(source, error); - if (imported) { - InvalidateSaveManagerViewCache(); - g_save_builder_image.reset(); - } - g_save_manager_status = imported - ? "Offline save bundle imported. Previous saves were backed up." - : error; - } else if (dialog == NFD_ERROR) { - g_save_manager_status = NFD_GetError(); - } - NFD_Quit(); - return imported; + if(SharedSaveBusy())return false; + if(NFD_Init()!=NFD_OKAY){g_save_manager_status="The system file picker could not be opened.";return false;} + nfdu8char_t* result=nullptr;const nfdfilteritem_t filters[]={{"DKR-R save bundle","dkrsave"}}; + const auto dialog=NFD_OpenDialogU8(&result,filters,1,nullptr); + if(dialog==NFD_OKAY){g_shared_save_import={std::filesystem::u8path(result),1,0};NFD_FreePathU8(result);} + else if(dialog==NFD_ERROR)g_save_manager_status=NFD_GetError(); + NFD_Quit();return dialog==NFD_OKAY; } + bool ExportSaveBundleWithDialog() { if (NFD_Init() != NFD_OKAY) { g_save_manager_status = "The system file picker could not be initialized."; @@ -3413,9 +3443,9 @@ bool ExportSaveBundleWithDialog() { destination += ".dkrsave"; } std::string error; - exported = dkr::runtime::saves::export_bundle(destination, error); + exported = BeginSharedSaveJob([destination](std::string& error){return dkr::runtime::saves::export_bundle(destination,error);},"Save bundle exported."); g_save_manager_status = exported - ? "Offline save bundle exported." + ? "Exporting save bundle..." : error; } else if (dialog == NFD_ERROR) { g_save_manager_status = NFD_GetError(); @@ -4499,7 +4529,7 @@ std::string BuildSupportReport() { << std::lround(dkr::runtime::hud::global_scale() * 100.0F) << "%\n" << "Enabled texture packs: " << EnabledTexturePackCount() << '\n' << "Online synchronization: " - << dkr::runtime::netplay::synchronization_name(CurrentOnlineSynchronization()) + << dkr::runtime::netplay::rollback_policy::name(CurrentOnlineSynchronization()) << '\n' << "Diagnostic logging: " << (dkr::runtime::support::diagnostic_logging_enabled() ? "On" @@ -4738,9 +4768,13 @@ bool PasteOnlineInviteFromClipboard() { bool FinishOnlineLobby(const OnlineHostRequest& request,std::string mod_digest,std::uint32_t manifest_bytes) { using namespace dkr::runtime::netplay; const auto& rules=request.rules; + if(request.spectators&&rules.synchronization!=SynchronizationMode::ExperimentalRollback) { + g_online_action_status="Choose Rollback for a spectator-capable lobby, or turn spectator support off in Host settings."; + return false; + } // Fail before creating/changing an online save directory. A new label // must never silently start the old implementation under an experiment. - if (const char* reason = experimental::runtime_admission_error(rules.synchronization,session().owned_backend_available())) { + if (const char* reason = dkr::runtime::netplay::rollback_policy::admission_error(rules.synchronization,session().owned_backend_available())) { g_online_action_status = reason; return false; } @@ -4769,6 +4803,7 @@ bool FinishOnlineLobby(const OnlineHostRequest& request,std::string mod_digest,s std::move(online_save), dkr::runtime::saves::install_synchronized_online_adventure, dkr::runtime::saves::read_online_adventure); + dkr::runtime::spectators::request_party_host(&session(),request.spectators&&rules.synchronization==SynchronizationMode::ExperimentalRollback); if (!session().host(0U, {}, request.room, ConnectionMethod::QuickJoin, request.name, rules, error)) { @@ -5423,7 +5458,7 @@ dkr::runtime::netplay::FriendLobbyAdvertisement FriendLobbyFromSession( advertisement.maximum_players = view.room.rules.maximum_players; advertisement.synchronization = view.room.rules.synchronization == SynchronizationMode::ExperimentalRollback - ? "Experimental rollback" + ? "Rollback" : (view.room.rules.synchronization == SynchronizationMode::Rollback ? "Rollback" : "Lockstep"); advertisement.compatibility = DKR_NETWORK_RELEASE_VERSION; for (const Player& player : view.room.players) { @@ -5462,7 +5497,22 @@ void UpdateFriendPresenceNotification() { } } +void PumpDirectoryJoin(){ + using namespace dkr::runtime::netplay; + auto& online=session(); + if(!online.active()&&!OnlineModSelectionLocked()&&!SharedSaveBusy()){ + dkr::online::Bootstrap bootstrap; + if(dkr::online::take_bootstrap(&online,bootstrap)){ + g_online_join_watching=bootstrap.watch;ConfigurePartyJoin(); + std::string error; + if(!online.join_friend_invite(bootstrap.code,g_online_player_name,bootstrap.admission,error)){dkr::online::cancel_join();g_online_action_status=error;} + } + } + const auto url=dkr::store::service().take_browser_url();if(!url.empty())SDL_OpenURL(url.c_str()); + dkr::online::hidden(friend_service().appear_offline()); +} void PumpFriendPresence() { + PumpDirectoryJoin(); // Presence has its own worker and does not need to be rebuilt at display // refresh rate. A 100 ms frontend cadence keeps notifications responsive // while avoiding lock/copy work on every launcher or overlay frame. @@ -5497,12 +5547,7 @@ void PumpFriendPresence() { g_seen_friend_lobby_invites.contains(invite.invite_id)) { continue; } - if (g_online_notifications.push( - dkr::runtime::ui_notifications::Kind::LobbyInvite, - "LOBBY INVITE", - invite.friend_display_name + - " invited you to race. Open DKR-R ONLINE - OPEN LOBBIES to accept.", - std::chrono::steady_clock::now())) + if (InviteNoticeArrival(invite)) g_seen_friend_lobby_invites.insert(invite.invite_id); } std::erase_if(g_seen_friend_lobby_invites, @@ -5735,7 +5780,7 @@ void DrawNetworkOverlay() { #if defined(DKR_EXPERIMENTAL_RACE_TEST) const auto owned = experimental::runtime_view(); if (owned.active) { - std::snprintf(buffer, sizeof(buffer), "ONLINE EXPERIMENTAL %u MS DELAY %u", + std::snprintf(buffer, sizeof(buffer), "ONLINE ROLLBACK %u MS DELAY %u", view.network_rtt_ms, view.input_delay_frames); fields.emplace_back(buffer); if (g_network_overlay_detail >= 1) { @@ -5752,7 +5797,7 @@ void DrawNetworkOverlay() { #endif std::snprintf(buffer, sizeof(buffer), "ONLINE %s %u MS", view.room.rules.synchronization == SynchronizationMode::ExperimentalRollback - ? "EXPERIMENTAL" + ? "ROLLBACK" : (view.room.rules.synchronization == SynchronizationMode::Rollback ? "ROLLBACK" : "LOCKSTEP"), view.network_rtt_ms); fields.emplace_back(buffer); @@ -6424,11 +6469,12 @@ void DrawOnlineWaitingNotification( } bool OnlineNotificationActive() { - return g_online_notifications.current(std::chrono::steady_clock::now()) != + return InviteUiVisible() || g_online_notifications.current(std::chrono::steady_clock::now()) != nullptr; } void DrawOnlineNotification() { + DrawInviteUi(); const auto now = std::chrono::steady_clock::now(); const auto* notification = g_online_notifications.current(now); if (notification == nullptr) return; @@ -6673,6 +6719,14 @@ bool DrawTexturePackManagementModal( #include "runtime_mod_library_ui.inl" void DrawToolIntro(std::string_view,float); #include "runtime_code_mods_ui.inl" +bool StoreVehiclesBusy(); +std::shared_ptr StoreVehiclesSnapshot(); +void StoreRefreshVehicles(); +dkr::store::Json StoreVehicleInventory(); +#include "mod_store_ui.inl" +#include "vehicle_ui.inl" +#include "vehicle_store_ui.inl" +#include "mods/sdk2_tools_ui.inl" // A Magic Code as a painted sign: its phrase on the header board, then what it // does and where it applies (.im-magic-cell in the launcher study). @@ -7449,6 +7503,7 @@ void DrawTrackLabSection(float width, bool locked, // MANAGE button and from a Track Lab "Manage" line. Both pages render it, and // it is only touched when it might be open. void DrawSharedTexturePackModal() { + if (dkr::store::installations().view()->busy) return; if (!g_texture_pack_manage_request && !ImGui::IsPopupOpen("Manage texture pack") && !ImGui::IsPopupOpen("Remove texture pack?")) { @@ -7496,7 +7551,7 @@ void DrawModHelpSection(float width, const dkr::mods::ModLibraryView& mods, bool "Turn tracks on in My mods to add them to Track Select.", }}; constexpr std::array safety{{ - "Legacy mod selections keep separate saves.", + "Normal mods share your personal save. Development tools stay separate.", "Hiding disables a mod. Removing it keeps saves and original source files.", }}; const auto list_height = [&] { @@ -7555,14 +7610,6 @@ void DrawModHelpSection(float width, const dkr::mods::ModLibraryView& mods, bool ImGui::Dummy({width, total}); PaddockGap(26.0F); } - { - PaddockEnter enter(3); - DrawPageDisclosure("versions", "ROM versions and compatibility", width, [](float inner) { - PaddockText(PaddockReading(13.0F, false, 1.55F), PaddockRgb(0xABC0CC), - "Legacy patches need the ROM version they were made for. Native tracks don't need patch preparation.", inner); - }); - PaddockGap(16.0F); - } { PaddockEnter enter(4); ImGui::BeginDisabled(library_locked); @@ -7667,13 +7714,13 @@ void DrawModHelpSection(float width, const dkr::mods::ModLibraryView& mods, bool void DrawModsLibrarySection(float width, const dkr::mods::ModLibraryView& mods, bool mods_locked, const std::vector& dkr_tracks) { - const bool locked = mods_locked || mods.busy; + const bool locked = mods_locked || mods.busy || g_vehicle_library.view()->busy; const std::size_t track_count = g_mod_browsers[0].all.size() + dkr_tracks.size(); const std::size_t character_count = g_mod_browsers[1].all.size(); { PaddockEnter enter(0); const ImVec2 at = ImGui::GetCursorScreenPos(); - const int chosen = DrawLibraryCategories(g_mods_page.category, track_count, character_count,g_code_library.view()->items.size(),width); + const int chosen = DrawLibraryCategories(g_mods_page.category, track_count, character_count,g_code_library.view()->items.size(),g_vehicle_library.view()->items.size(),width); float row = ImGui::GetItemRectSize().y; if (chosen != g_mods_page.category) { g_mods_page.category = chosen; @@ -7686,6 +7733,7 @@ void DrawModsLibrarySection(float width, const dkr::mods::ModLibraryView& mods, if (PaddockButton("Refresh library##library", PaddockButtonKind::Link)) { g_legacy_imports.refresh(); g_code_library.refresh(); + g_vehicle_library.job(); dkr::runtime::custom_tracks::reload(); } ImGui::EndDisabled(); @@ -7693,6 +7741,7 @@ void DrawModsLibrarySection(float width, const dkr::mods::ModLibraryView& mods, ImGui::Dummy({width, row}); PaddockGap(20.0F); } + if(g_mods_page.category==3){DrawVehicleLibrary(width,locked);return;} if(g_mods_page.category==2){DrawCustomMagicCodeLibrary(width,locked);return;} const bool characters = g_mods_page.category == 1; if (!characters) { @@ -7729,14 +7778,26 @@ void DrawModsLibrarySection(float width, const dkr::mods::ModLibraryView& mods, // Card caches follow the library snapshots; PLAY reads them too. void RefreshModBrowserCards(const dkr::mods::ModLibraryView& mods) { + static std::uint64_t names_generation = 0; + const auto current_names = dkr::store::library_names_generation(); + const bool names_changed = names_generation != current_names; + names_generation = current_names; for (const bool characters : {false, true}) { auto& browser = g_mod_browsers[characters ? 1 : 0]; const auto snapshot = characters ? mods.characters : mods.tracks; - if (browser.snapshot != snapshot) { + if (browser.snapshot != snapshot || names_changed) { browser.snapshot = snapshot; browser.all = snapshot != nullptr ? dkr::mods::browser::cards(*snapshot, characters) : std::vector{}; + for (auto& card : browser.all) { + const auto category = characters ? "characters" : "tracks"; + card.item.name = dkr::store::library_display_name(card.item.patch, category, card.item.name); + card.item.source_name = dkr::store::library_display_name(card.item.patch, category, card.item.source_name); + std::set sources; + for (const auto& source : card.sources) sources.insert(dkr::store::library_display_name(card.item.patch, category, source)); + card.sources = std::move(sources); + } browser.filter_key.clear(); } } @@ -7763,7 +7824,7 @@ void DrawModsHacks(float available_width, bool game_running = false) { const auto mods = g_legacy_imports.snapshot(); const bool lobby = dkr::runtime::netplay::session().active(); - const bool mods_locked = game_running || lobby || g_mod_launch.snapshot().modal || OnlineModSelectionLocked(); + const bool mods_locked = game_running || lobby || g_mod_launch.snapshot().modal || OnlineModSelectionLocked() || g_vehicle_library.view()->busy; const bool picking = DialogJobRunning(); const unsigned revision = g_mod_browser_revision; const std::vector tracks = tracks_ns::tracks(); @@ -7784,7 +7845,7 @@ void DrawModsHacks(float available_width, bool game_running = false) { DrawPageHeading("MODS / HACKS"); PaddockGap(9.0F); PaddockText(PaddockReading(14.0F, false, 1.55F), PaddockRgb(0xABC0CC), - "Your tracks, racers and race modifiers.", text_width); + "Your tracks, racers, vehicles and race modifiers.", text_width); ImGui::EndGroup(); const float title_height = ImGui::GetItemRectSize().y; const ImVec2 import_at = stacked @@ -7921,13 +7982,14 @@ void DrawModsHacks(float available_width, bool game_running = false) { PaddockGap(24.0F); { const ImVec2 at = ImGui::GetCursorScreenPos(); - constexpr std::array labels{{ + constexpr std::array labels{{ "My mods##section", "Magic Codes##section", "Track Lab##section", - "Help & imports##section"}}; + "Help & imports##section", "Mod Store##section"}}; const float tab_height = PaddockSectionTabHeight(); float x = at.x; float y = at.y; - for (int index = 0; index < 4; ++index) { + for (int index = 0; index < 5; ++index) { + if(index==4&&game_running)continue; const float tab = PaddockSectionTabWidth(labels[index]); if (x > at.x && x + tab > at.x + width) { x = at.x; @@ -7951,7 +8013,10 @@ void DrawModsHacks(float available_width, bool game_running = false) { if (g_mods_page.section >= 0) { PaddockGap(24.0F); const bool tools_locked = mods_locked; - if (g_mods_page.section == kModsSectionLibrary) { + if (game_running && g_mods_page.section == 4) g_mods_page.section = kModsSectionLibrary; + if (g_mods_page.section == 4) { + DrawModStore(width, mods_locked); + } else if (g_mods_page.section == kModsSectionLibrary) { DrawModsLibrarySection(width, mods, mods_locked, dkr_tracks); } else { constexpr std::array, 4> titles{{ @@ -7988,9 +8053,10 @@ void DrawModsHacks(float available_width, bool game_running = false) { [] { ImportLegacyModWithDialog(); }, [] { ImportTrackWithDialog(); }, [] { ImportTrackZipWithDialog(); }, - go_to_play, [] { ImportMagicCodeWithDialog(); }}); + go_to_play, [] { ImportMagicCodeWithDialog(); }, [] { ImportVehicleWithDialog(); }}); DrawDkrTrackDetails(dkr_tracks, revision != 0U, mods_locked || mods.busy || picking); DrawCustomMagicCodeDetails(mods_locked || picking); + DrawVehicleDetails(mods_locked || picking); if (!g_mods_page.uninstall_track_request.empty()) { const std::string id = std::exchange(g_mods_page.uninstall_track_request, {}); if (!mods_locked && !mods.busy && !picking) { @@ -8019,6 +8085,7 @@ bool SettingsDropdown(const char*, std::string_view, int*, void DrawPlayUpdateButton(float); #include "runtime_play_ui.inl" #include "runtime_online_ui.inl" +#include "invite_ui.inl" std::string FormatRecordTime(std::uint16_t frames) { if (frames == 0U) { @@ -8039,7 +8106,11 @@ std::string FormatRecordTime(std::uint16_t frames) { #include "runtime_camera_tuning_ui.inl" #include "runtime_graphics_ui.inl" #include "runtime_saves_ui.inl" +#define DrawTexturesPage DrawTexturesPageWithoutDump #include "runtime_details_ui.inl" +#undef DrawTexturesPage +#include "texture_dump_ui.inl" +#include "achievement_ui.inl" std::string ShortcutBindingName(CaptureDevice device, dkr::runtime::input::ShortcutBinding binding) { @@ -8305,6 +8376,11 @@ bool HandleInputCaptureEvent(SDL_Event* event) { } void DrawOverlayContent(float content_width) { + if(dkr::sdk2::available()) { + if(ImGui::Button("CUSTOM MAGIC CODE TOOLS",{std::min(content_width,320.F),42})) { + dkr::sdk2::tools_open=true;g_overlay_visible=false; + } + } if (g_overlay_page == kPagePlay) { DrawPageHeading("PLAY"); ImGui::PushStyleColor(ImGuiCol_Text, kMuted); @@ -8333,6 +8409,8 @@ void DrawOverlayContent(float content_width) { DrawModsHacks(content_width, true); } else if (g_overlay_page == kPageTextures) { DrawTexturesPage(content_width); + } else if (g_overlay_page == kPageAchievements) { + DrawAchievementsPage(content_width); } else if (g_overlay_page == kPageAbout) { DrawAboutPage(content_width, false); } @@ -8343,6 +8421,7 @@ void DrawOverlayContent(float content_width) { void dkr::runtime::ui::configure(const std::filesystem::path& config_directory) { g_config_directory = config_directory; + dkr::runtime::achievements::configure(config_directory); #if defined(__ANDROID__) || defined(DKR_IOS) dkr::runtime::mobile::configure(config_directory); #endif @@ -8384,6 +8463,7 @@ void dkr::runtime::ui::configure(const std::filesystem::path& config_directory) #endif g_legacy_imports.configure(config_directory / "mods" / "legacy", mod_worker); g_code_library.configure(config_directory / "mods" / "custom-magic-codes",DKR_RELEASE_VERSION); + g_vehicle_library.configure(config_directory / "mods" / "vehicles"); #if defined(_WIN32) constexpr const char* update_helper_name="DKR-R-Updater.exe"; #else @@ -8396,29 +8476,17 @@ void dkr::runtime::ui::configure(const std::filesystem::path& config_directory) g_online_mod_link=std::make_unique(dkr::runtime::netplay::session()); g_online_mod_sync=std::make_unique(*g_online_mod_link); dkr::runtime::hud::configure(config_directory); - const auto adventure = dkr::runtime::saves::adventure_info(); - if (adventure.exists && - adventure.size == dkr::runtime::saves::codec::kImageSize && - !adventure.valid) { - bool changed = false; - std::filesystem::path backup; - std::string error; - if (dkr::runtime::saves::repair_adventure_checksums( - changed, backup, error) && changed) { - g_save_manager_status = - "Save repaired. Original backed up at " + - PathUtf8(backup); - } else if (!error.empty()) { - g_save_manager_status = - "Couldn't repair the save: " + error; - } - } + // Checksum repair is explicit; never bless unknown payload damage at startup. dkr::runtime::magic_codes::configure(config_directory); dkr::runtime::texture_packs::configure(config_directory); dkr::runtime::netplay::session().configure_artifact_directory( config_directory / "netplay" / "replays"); RefreshCrtFilters(); LoadSettings(); + if(!g_shared_online_policy_loaded)g_online_save_seed_mode=int(dkr::runtime::saves::OnlineSaveSeedMode::ContinuePreviousSession); + dkr::store::installations().configure(g_config_directory, DKR_RELEASE_VERSION, mod_worker); + dkr::store::service().configure(g_config_directory, DKR_RELEASE_VERSION); + dkr::online::configure(g_config_directory); LoadCameraTuning(); dkr::runtime::netplay::friend_service().configure( config_directory, g_online_player_name); @@ -8576,6 +8644,8 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( ApplyStyle(); ImGui_ImplSDL2_InitForSDLRenderer(window, renderer); ImGui_ImplSDLRenderer2_Init(renderer); + dkr::store::service().gameplay(false); + g_mod_store_renderer = renderer; int launcher_display_refresh_rate = 60; const int launcher_display = SDL_GetWindowDisplayIndex(window); SDL_DisplayMode launcher_display_mode{}; @@ -8688,7 +8758,7 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( static std::future compatibility_scan; static dkr::runtime::netplay::SessionView scanned_offer; static std::filesystem::path scanned_selection; - if (g_online_compatibility_sync_requested && !compatibility_scan.valid()) { + if (!dkr::store::installations().view()->busy && dkr::store::installations().view()->ready && g_online_compatibility_sync_requested && !compatibility_scan.valid()) { g_online_compatibility_sync_requested = false; scanned_offer = dkr::runtime::netplay::session().view(); scanned_selection = selected_rom; @@ -8712,7 +8782,7 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( g_online_action_status = "Checking your ROM library..."; } } - if (compatibility_scan.valid() && + if (!dkr::store::installations().view()->busy && dkr::store::installations().view()->ready && compatibility_scan.valid() && compatibility_scan.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { MatchingRom matching_rom; try { matching_rom = compatibility_scan.get(); } catch (...) {} @@ -8797,6 +8867,7 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( const bool friend_context = g_joining_friend_invite && g_joining_friend_invite->lobby_code == invitation && g_joining_friend_invite->expires_unix > static_cast(std::time(nullptr)); + ConfigurePartyJoin(); const bool joined = friend_context ? online.join_friend_invite(invitation, g_online_player_name, g_joining_friend_invite->admission, sync_error) : online.join(invitation, g_online_player_name, sync_error); @@ -8887,10 +8958,12 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( g_online_mod_selected_rom=selected_rom; g_online_mod_identity=rom_ready?online_manifest_identity:dkr::runtime::rom::Identity{}; PumpOnlineMods(); + PumpInviteLauncher(rom_ready,online_manifest.has_value()); if (rom_ready && dkr::runtime::netplay::session().consume_launch_request()) { dkr::runtime::startup_performance::mark( "launcher-online-launch-requested"); + if(SharedSaveBusy())continue; result.start_game = true; result.rom_path = selected_rom; running = false; @@ -8939,6 +9012,14 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( } dkr::runtime::platform::update_fullscreen_cursor(&event); ImGui_ImplSDL2_ProcessEvent(&event); + if(dkr::runtime::invites::dialog()&&(event.type==SDL_KEYDOWN||event.type==SDL_KEYUP|| + event.type==SDL_TEXTINPUT||event.type==SDL_MOUSEMOTION||event.type==SDL_MOUSEBUTTONDOWN|| + event.type==SDL_MOUSEBUTTONUP||event.type==SDL_MOUSEWHEEL||event.type==SDL_CONTROLLERBUTTONDOWN|| + event.type==SDL_CONTROLLERBUTTONUP||event.type==SDL_CONTROLLERAXISMOTION)) { + if((event.type==SDL_KEYDOWN&&!event.key.repeat&&event.key.keysym.scancode==SDL_SCANCODE_ESCAPE)|| + (event.type==SDL_CONTROLLERBUTTONDOWN&&event.cbutton.button==SDL_CONTROLLER_BUTTON_B))dkr::runtime::invites::cancel_dialog(); + return; + } if (HandleInputCaptureEvent(&event)) { return; } @@ -9023,9 +9104,10 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( if (launch_requested) { launch_requested=false; if(!dkr::runtime::netplay::session().active() && + !dkr::store::installations().view()->busy && dkr::store::installations().view()->ready && !g_legacy_imports.snapshot().busy && !g_code_library.view()->busy && !g_mod_launch.snapshot().modal) { mod_launch_rom=selected_rom; - g_mod_launch.start(g_config_directory,mod_launch_rom,false); + if(!SharedSaveBusy())g_mod_launch.start(g_config_directory,mod_launch_rom,false); } } if(const auto prepared=g_mod_launch.snapshot();prepared.modal && prepared.prepared && !prepared.busy) { @@ -9035,6 +9117,7 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( g_mod_launch.dismiss();rom_status="Custom launch cancelled because an online lobby became active."; } else { dkr::runtime::startup_performance::mark("launcher-local-launch-requested"); + if(SharedSaveBusy())continue; result.start_game=true;result.rom_path=mod_launch_rom;result.mods=prepared.prepared; dkr::runtime::startup::prepare_launch(g_skip_intro, false, dkr::runtime::custom_tracks::auto_boot_enabled() && @@ -9157,6 +9240,8 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( nav_inner_width, sidebar_layout.button_height); LauncherSidebarButton("TEXTURES", kPageTextures, page, sidebar_selection, nav_inner_width, sidebar_layout.button_height); + LauncherSidebarButton("ACHIEVEMENTS", kPageAchievements, page, sidebar_selection, + nav_inner_width, sidebar_layout.button_height); LauncherSidebarButton("ABOUT DKR-R", kPageAbout, page, sidebar_selection, nav_inner_width, sidebar_layout.button_height); SidebarActionGap(nav_inner_width, sidebar_layout.action_section_gap); @@ -9237,6 +9322,8 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( DrawModsHacks(right_inner_width); } else if (page == kPageTextures) { DrawTexturesPage(right_inner_width); + } else if (page == kPageAchievements) { + DrawAchievementsPage(right_inner_width); } else { DrawAboutPage(right_inner_width, true); } @@ -9292,6 +9379,8 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( } ImGui::End(); PumpDialogJob(); + PumpSharedSaveJob(); + PumpModStoreLauncher(); DrawTexturePackImportModal(); DrawPaddockLegacyModImportModal(); DrawModLaunchModal(); @@ -9315,6 +9404,7 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( dkr::runtime::mobile::editor(); } #endif + DrawAchievementToast(); ImGui::Render(); const auto launcher_ui_build_finished = std::chrono::steady_clock::now(); @@ -9410,6 +9500,12 @@ dkr::runtime::ui::StartupResult dkr::runtime::ui::run_startup_screen( ImGui_ImplSDLRenderer2_Shutdown(); ImGui_ImplSDL2_Shutdown(); ImGui::DestroyContext(); + while(SharedSaveBusy()){PumpSharedSaveJob();SDL_PumpEvents();std::this_thread::sleep_for(std::chrono::milliseconds(2));} + dkr::store::service().gameplay(true); + dkr::store::installations().safe_to_write(false); + ReleaseAchievementAudio(); + ReleaseTextureDumpTextures(); + ReleaseModStoreTextures(); SDL_DestroyRenderer(renderer); const auto launcher_elapsed_microseconds = std::chrono::duration_cast( @@ -9513,7 +9609,7 @@ void dkr::runtime::ui::draw(RT64::Application& application) { const bool show_online_error = UpdateOnlineErrorNotification(); const bool show_online_failure_modal = g_online_failure_modal_requested || g_online_failure_modal_active; - const bool show_online_notification = OnlineNotificationActive(); + const bool show_online_notification = OnlineNotificationActive() || InviteUiVisible(); const bool show_network = g_network_overlay_enabled && dkr::runtime::netplay::session().presentation_active(); const bool show_controller_input = g_controller_input_overlay_enabled && @@ -9523,6 +9619,7 @@ void dkr::runtime::ui::draw(RT64::Application& application) { online_session_view.launch_countdown_active && online_session_view.launch_countdown_remaining_ms > 0U; const bool show_online_waiting = OnlineWaitingActive(online_session_view); + const bool show_achievement_toast=AchievementToastVisible(); const bool show_mipmap_loading = RT64::mipLoadingVisible(); const bool show_camera_tuning = CameraTuningVisible(); const bool keep_owned_ui = dkr::runtime::netplay::experimental::owned_runtime_active(); @@ -9530,11 +9627,11 @@ void dkr::runtime::ui::draw(RT64::Application& application) { #if defined(__ANDROID__) || defined(DKR_IOS) false && // Touch Menu remains available, even when all other overlays are off. #endif - !show_overlay && !show_fps && !show_crt && !show_online_error && + !show_achievement_toast && !show_overlay && !show_fps && !show_crt && !show_online_error && !show_online_failure_modal && !show_online_notification && !show_network && !show_controller_input && !show_online_countdown && - !show_online_waiting && !show_mipmap_loading && !show_camera_tuning && !keep_owned_ui) { + !show_online_waiting && !show_mipmap_loading && !show_camera_tuning && !keep_owned_ui && !dkr::sdk2::tools_open.load() && !dkr::sdk2::debug_available()) { if (application.presentQueue->inspector != nullptr) { detach(application); } @@ -9549,7 +9646,7 @@ void dkr::runtime::ui::draw(RT64::Application& application) { if (application.presentQueue->inspector == nullptr) { return; } - if (show_overlay) { + if (show_overlay || show_camera_tuning || dkr::runtime::invites::dialog()) { dkr::runtime::platform::update_ui_gamepad_navigation(); } RT64::Inspector* inspector = application.presentQueue->inspector.get(); @@ -9665,6 +9762,8 @@ void dkr::runtime::ui::draw(RT64::Application& application) { sidebar_layout.button_height); SidebarButton("TEXTURES", kPageTextures, sidebar_selection, nav_inner_width, sidebar_layout.button_height); + SidebarButton("ACHIEVEMENTS", kPageAchievements, sidebar_selection, nav_inner_width, + sidebar_layout.button_height); SidebarButton("ABOUT DKR-R", kPageAbout, sidebar_selection, nav_inner_width, sidebar_layout.button_height); SidebarActionGap(nav_inner_width, sidebar_layout.action_section_gap); @@ -9783,12 +9882,15 @@ void dkr::runtime::ui::draw(RT64::Application& application) { // or the game HUD. These dialogs render outside the content child. const PaddockLauncherTypographyScope modal_typography(show_overlay); PumpDialogJob(); + PumpSharedSaveJob(); DrawTexturePackImportModal(); DrawPaddockLegacyModImportModal(); DrawTextEntryKeyboard(); } // Over the race, not the menu: the panel is for tuning while driving. if (show_camera_tuning && !show_overlay) DrawCameraTuningPanel(); + DrawSdk2Debug(); + if(dkr::sdk2::tools_open.load())DrawSdk2Tools(); DrawFpsOverlay(application); DrawNetworkOverlay(); DrawControllerInputOverlay(); @@ -9804,10 +9906,13 @@ void dkr::runtime::ui::draw(RT64::Application& application) { dkr::runtime::mobile::draw_controls(show_overlay); dkr::runtime::mobile::editor(); #endif + DrawPinnedAchievement(); + DrawAchievementToast(); inspector->endFrame(); } void dkr::runtime::ui::update_camera_mouse() { + if(dkr::runtime::invites::capture()&&!g_overlay_visible.load())return; UpdateCameraMouseOrbit(); } @@ -9815,9 +9920,40 @@ bool dkr::runtime::ui::handle_runtime_event(SDL_Event* event) { if (event == nullptr) { return false; } + if(dkr::runtime::invites::dialog()) { + if((event->type==SDL_KEYDOWN&&!event->key.repeat&&event->key.keysym.scancode==SDL_SCANCODE_ESCAPE)|| + (event->type==SDL_CONTROLLERBUTTONDOWN&&event->cbutton.button==SDL_CONTROLLER_BUTTON_B)) { + dkr::runtime::invites::cancel_dialog();return true; + } + std::scoped_lock guard(g_inspector_guard);if(!g_inspector)return true; + std::scoped_lock frame_lock(g_inspector->frameMutex);g_inspector->handleSdlEvent(event);return true; + } + #if defined(__ANDROID__) || defined(DKR_IOS) if (dkr::runtime::mobile::event(*event, !overlay_visible())) return true; #endif + if(CameraTuningVisible()&&!g_online_code_keyboard_visible.load()&&event->type==SDL_KEYDOWN&&event->key.repeat==0&& + event->key.keysym.scancode==SDL_SCANCODE_ESCAPE) { + dkr::runtime::camera::editor_open.store(false);return true; + } + if(g_sdk2_key_capture&&event->type==SDL_KEYDOWN&&!event->key.repeat) { + if(event->key.keysym.scancode!=SDL_SCANCODE_ESCAPE)g_sdk2_tools_key=event->key.keysym.scancode; + g_sdk2_key_capture=false;SaveSettings();return true; + } + if(dkr::sdk2::available()&&g_sdk2_tools_chord&&(event->type==SDL_CONTROLLERBUTTONDOWN||event->type==SDL_CONTROLLERBUTTONUP)) { + const auto button=event->cbutton.button;const unsigned flag=button==SDL_CONTROLLER_BUTTON_BACK?1U:button==SDL_CONTROLLER_BUTTON_Y?2U:0U; + if(flag) { + auto& held=g_sdk2_chord_held[event->cbutton.which];const auto previous=held; + if(event->type==SDL_CONTROLLERBUTTONDOWN)held|=flag;else held&=~flag; + if(held==3&&previous!=3){dkr::sdk2::tools_open=!dkr::sdk2::tools_open.load();g_overlay_visible=false;return true;} + } + } + if(dkr::sdk2::available()&&event->type==SDL_KEYDOWN&&!event->key.repeat&&event->key.keysym.scancode==g_sdk2_tools_key) { + dkr::sdk2::tools_open=!dkr::sdk2::tools_open.load();return true; + } + if(dkr::sdk2::tools_open.load()&&g_text_entry_target==TextEntryTarget::None&&event->type==SDL_KEYDOWN&&!event->key.repeat&&event->key.keysym.scancode==SDL_SCANCODE_ESCAPE) { + dkr::sdk2::tools_open=false;return true; + } if (HandleInputCaptureEvent(event)) { return true; } @@ -9924,7 +10060,7 @@ bool dkr::runtime::ui::input_capture_active() { #if defined(__ANDROID__) || defined(DKR_IOS) dkr::runtime::mobile::editing() || #endif - dkr::runtime::hud::editor::active() || (g_capture_action != -1 && !g_capture_finished) || + dkr::runtime::invites::dialog() || dkr::runtime::hud::editor::active() || (g_capture_action != -1 && !g_capture_finished) || dkr::runtime::platform::controller_mapping_progress().capturing || g_online_code_keyboard_visible.load(std::memory_order_acquire); } @@ -9934,6 +10070,7 @@ void dkr::runtime::ui::toggle_overlay() { dkr::runtime::mobile::clear(); #endif if (dkr::runtime::hud::editor::active()) { dkr::runtime::hud::editor::request_back(); return; } + if(!g_overlay_visible.load(std::memory_order_acquire))dkr::runtime::invites::focus_visible(); const bool next = !g_overlay_visible.load(std::memory_order_acquire); g_overlay_page = 0; g_overlay_last_rendered_page.store(-1, std::memory_order_release); @@ -9944,7 +10081,7 @@ void dkr::runtime::ui::toggle_overlay() { } bool dkr::runtime::ui::overlay_visible() { - return g_overlay_visible.load(std::memory_order_acquire); + return dkr::runtime::invites::capture()||g_overlay_visible.load(std::memory_order_acquire)||dkr::sdk2::tools_open.load(); } dkr::runtime::ui::LifecycleRequest dkr::runtime::ui::lifecycle_request() { @@ -9978,6 +10115,7 @@ std::shared_ptr dkr::runtime::ui::onl } void dkr::runtime::ui::report_online_game_end(std::string message, bool lobby_retained) { + if(dkr::runtime::invites::return_pending())return; g_page_navigation_request = kPageOnlineMp; g_online_page.section = lobby_retained ? OlSection::Lobby : OlSection::Play; g_online_page.was_active = lobby_retained; diff --git a/runtime-recomp/src/game/save_manager.cpp b/runtime-recomp/src/game/save_manager.cpp index e9fefbd4..03bbbb95 100644 --- a/runtime-recomp/src/game/save_manager.cpp +++ b/runtime-recomp/src/game/save_manager.cpp @@ -1,3 +1,8 @@ +#include "shared_saves.hpp" +#include "custom_records.hpp" +#include "mods/legacy_mod_format.hpp" +#include +#include #include "save_manager.hpp" #include "netplay/experimental_pak.hpp" #include "dkr_save_codec.hpp" @@ -38,11 +43,12 @@ constexpr std::array kPakMagic{ 'D', 'K', 'R', 'M', 'P', 'K', '1', 0}; constexpr std::array kBundleMagic{ 'D', 'K', 'R', 'P', 'O', 'R', 'T', 'S', 'A', 'V', 'E', '1', 0, 0, 0, 0}; -constexpr std::uint32_t kBundleVersion = 1U; +constexpr std::uint32_t kBundleVersion = 2U; +constexpr std::uint32_t kBundleRecordsKind=3U; constexpr std::uint32_t kBundleAdventureKind = 1U; constexpr std::uint32_t kBundlePakKind = 2U; constexpr std::size_t kMaximumBundleSize = - 64U + kAdventureSaveSize + + 1024U*1024U + kAdventureSaveSize + dkr::runtime::saves::kControllerPakCount * (32U + kControllerPakSize); std::filesystem::path g_config_directory; std::vector g_recovery_roots; @@ -56,7 +62,7 @@ bool ValidModProfile(std::string_view profile) { } std::filesystem::path HostOnlineSubfolder(std::string_view profile) { auto folder=std::filesystem::path("online")/"host"; - if(!profile.empty())folder/="mod-profiles"/std::filesystem::path(profile); + (void)profile; // Mod manifests identify simulation, never durable progress. return folder; } @@ -177,6 +183,9 @@ bool ReadControllerPak(const std::filesystem::path& path, } bool ReadExperimentalOnlinePaks(const std::filesystem::path&, std::vector&); +bool ReadCustomRecords(const std::filesystem::path& path,std::vector& bytes) { + return ReadFileBounded(path,20+8192*76,bytes)&&dkr::runtime::saves::records::valid(bytes); +} bool ExportDestinationAllowed(const std::filesystem::path& path, std::string& error) { auto roots = g_recovery_roots; @@ -317,15 +326,15 @@ bool DecodeBundle(const std::filesystem::path& path, std::vector bytes; if (!ReadFileBounded(path, kMaximumBundleSize, bytes) || bytes.size() < 24U || !std::equal(kBundleMagic.begin(), kBundleMagic.end(), bytes.begin()) || - ReadLE32(bytes, 16U) != kBundleVersion) { + (ReadLE32(bytes, 16U) != 1U && ReadLE32(bytes, 16U) != kBundleVersion)) { return false; } const std::uint32_t count = ReadLE32(bytes, 20U); if (count == 0U || count > - 1U + static_cast(dkr::runtime::saves::kControllerPakCount)) { + 2U + static_cast(dkr::runtime::saves::kControllerPakCount)) { return false; } - std::array seen{}; + std::array seen{}; std::size_t cursor = 24U; entries.clear(); for (std::uint32_t index = 0; index < count; ++index) { @@ -345,7 +354,7 @@ bool DecodeBundle(const std::filesystem::path& path, cursor += size; std::size_t seen_index = 0U; if (entry.kind == kBundleAdventureKind) { - if (entry.channel != 0U || size != kAdventureSaveSize) { + if (entry.channel != 0U || size != kAdventureSaveSize || !dkr::runtime::saves::codec::validate(entry.bytes)) { return false; } } else if (entry.kind == kBundlePakKind) { @@ -354,6 +363,9 @@ bool DecodeBundle(const std::filesystem::path& path, return false; } seen_index = 1U + entry.channel; + } else if(entry.kind==kBundleRecordsKind) { + if(ReadLE32(bytes,16U)<2||entry.channel||!dkr::runtime::saves::records::valid(entry.bytes))return false; + seen_index=5; } else { return false; } @@ -397,7 +409,9 @@ void dkr::runtime::saves::set_online_save_protection(bool enabled) { storage::protect_offline(g_config_directory, enabled); } -std::vector dkr::runtime::saves::stored_adventures() { +std::vector dkr::runtime::saves::stored_adventures(std::size_t limit, bool* truncated) { + limit=std::clamp(limit,256,50000); + if(truncated)*truncated=false; std::filesystem::path root; std::vector roots; { std::scoped_lock lock(g_save_manager_mutex); root = g_config_directory; roots = g_recovery_roots; } @@ -416,7 +430,7 @@ std::vector dkr::runtime::saves::stored_ad // This guard only inspects metadata. It neither creates a lease nor writes. if (!storage::writable_path(path.parent_path() / ".inventory-check", checked) || !std::filesystem::is_regular_file(path, ec) || std::filesystem::is_symlink(path, ec) || - !seen.insert(storage::key(path)).second || result.size() >= 256) return; + !seen.insert(storage::key(path)).second || result.size() >= limit) return; #if defined(_WIN32) const auto attributes = GetFileAttributesW(path.c_str()); if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) return; @@ -424,8 +438,12 @@ std::vector dkr::runtime::saves::stored_ad StoredAdventure item; item.scope = scope; item.kind = kind; item.label = std::move(label); item.info.path = path; item.info.exists = true; item.info.size = std::filesystem::file_size(path, ec); std::vector bytes; + if(item.info.size<=1024*1024)ReadFileBounded(path,1024*1024,bytes); + if(dkr::runtime::saves::records::valid(bytes))kind=item.kind=StoredSaveKind::CustomRecords; + if(kind==StoredSaveKind::Adventure && item.info.size==32768)kind=item.kind=StoredSaveKind::ControllerPak; item.info.valid = !ec && (kind == StoredSaveKind::Adventure ? ReadAdventure(path, bytes) - : kind == StoredSaveKind::ControllerPak ? ReadControllerPak(path, bytes) : ReadExperimentalOnlinePaks(path, bytes)); + : kind == StoredSaveKind::ControllerPak ? ReadControllerPak(path, bytes) : kind==StoredSaveKind::CustomRecords?ReadCustomRecords(path,bytes):ReadExperimentalOnlinePaks(path, bytes)); + item.content_hash=bytes.empty()?path.generic_string():dkr::mods::sha256(bytes); if (item.info.valid && kind == StoredSaveKind::Adventure) { codec::SaveImage image; if (codec::decode(bytes, image)) for (std::size_t slot = 0; slot < image.slots.size(); ++slot) { @@ -435,7 +453,8 @@ std::vector dkr::runtime::saves::stored_ad (saved.name.empty() ? "Empty" : saved.name) + " (" + std::to_string(saved.balloons[0]) + " balloons)"; } - } else if (item.info.valid) item.preview = kind == StoredSaveKind::ControllerPak + } else if(item.info.valid&&kind==StoredSaveKind::CustomRecords)item.preview="Custom-course records. Included in personal save bundles; kept outside the retail EEPROM."; + else if (item.info.valid) item.preview = kind == StoredSaveKind::ControllerPak ? "Validated Controller Pak. Export a copy before any manual import." : "Validated four-port experimental online Pak image. Export only; not an offline single-Pak image."; else item.preview = "Invalid or incomplete image - original retained; do not overwrite it."; @@ -457,13 +476,13 @@ std::vector dkr::runtime::saves::stored_ad prefix + "Controller Pak backup " + std::to_string(channel + 1), StoredSaveKind::ControllerPak); } // The final location covers the previous relative-CWD experimental Pak bug. - for (const auto& directory : {candidate / "saves", candidate / "save-backups", candidate / "online", candidate / "save-history"}) { + for (const auto& directory : {candidate / "saves", candidate / "save-backups", candidate / "online", candidate / "save-history", candidate / "save-migration-v1/images", candidate / "save-recovery", candidate / "save-transactions"}) { std::string checked; if (!storage::writable_path(directory / ".inventory-check", checked)) continue; std::error_code ec; for (std::filesystem::recursive_directory_iterator it(directory, std::filesystem::directory_options::skip_permission_denied, ec), end; - !ec && it != end && visited++ < 2048 && result.size() < 256; it.increment(ec)) { + !ec && it != end && visited++ < limit*32 && result.size() < limit; it.increment(ec)) { if (it->is_symlink(ec)) { it.disable_recursion_pending(); continue; } #if defined(_WIN32) const auto attributes = GetFileAttributesW(it->path().c_str()); @@ -475,21 +494,24 @@ std::vector dkr::runtime::saves::stored_ad const auto relative = it->path().lexically_relative(candidate).generic_string(); const auto name = it->path().filename().string(); if (name.ends_with(".lock")) continue; - const bool primary = name == "dkr.us.v77.bin" || name == "experimental-controller-paks.bin" || + const bool primary = name == "custom-records-v1.bin" || name == "dkr.us.v77.bin" || name == "experimental-controller-paks.bin" || (name.starts_with("controller-pak-") && name.ends_with(".mpk")); const bool recovery = name.starts_with("dkr.us.v77.bin.") || name.starts_with("experimental-controller-paks.bin.") || (name.starts_with("controller-pak-") && name.find(".mpk.") != std::string::npos) || relative.starts_with("save-backups/") || relative.find("save-history/") != std::string::npos; - if (!primary && !recovery) continue; + const bool preserved=(relative.starts_with("save-migration-v1/images/")||relative.starts_with("save-recovery/"))&&name.ends_with(".bin"); + const bool transaction=relative.starts_with("save-transactions/")&&name.ends_with(".original"); + if (!primary && !recovery && !preserved && !transaction) continue; const int scope = recovery || !active ? 3 : relative.starts_with("saves/online/") ? 1 : relative.starts_with("saves/mods/") ? 2 : 3; - const auto kind = relative.find("experimental-controller-paks.bin") != std::string::npos ? StoredSaveKind::ExperimentalPaks + const auto kind = relative.find("custom-records-v1.bin")!=std::string::npos?StoredSaveKind::CustomRecords:relative.find("experimental-controller-paks.bin") != std::string::npos ? StoredSaveKind::ExperimentalPaks : relative.find("controller-pak-") != std::string::npos ? StoredSaveKind::ControllerPak : StoredSaveKind::Adventure; admit(it->path(), scope, prefix + relative, kind); } } } + if(truncated)*truncated=result.size()>=limit||visited>=limit*32; return result; } @@ -504,7 +526,7 @@ bool dkr::runtime::saves::export_stored_save(const std::filesystem::path& source if (!ExportDestinationAllowed(destination, error)) return false; std::vector bytes; const auto validator = kind == StoredSaveKind::Adventure ? ReadAdventure : - kind == StoredSaveKind::ControllerPak ? ReadControllerPak : ReadExperimentalOnlinePaks; + kind == StoredSaveKind::ControllerPak ? ReadControllerPak : kind==StoredSaveKind::CustomRecords?ReadCustomRecords:ReadExperimentalOnlinePaks; if (!validator(source, bytes)) { error = "This stored save is not checksum-valid; nothing was exported."; return false; } return WriteAtomic(destination, bytes, validator, error); } @@ -572,17 +594,8 @@ bool dkr::runtime::saves::import_adventure( const auto owner = storage::acquire(AdventurePath(), error); if (!owner) return false; std::vector bytes; if (!ReadAdventure(source, bytes)) { - std::vector unverified; - if (!ReadFileBounded(source, kAdventureSaveSize, unverified) || - unverified.size() != kAdventureSaveSize) { - error = "That file is not a 512-byte DKR Adventure save."; - return false; - } - if (!codec::repair_checksums(unverified, bytes, &error)) { - error = "That Adventure save could not be checksum-repaired: " + - error; - return false; - } + error="This save needs repair. Use Backups & Recovery to preserve and repair a copy first."; + return false; } std::error_code equivalent_error; if (std::filesystem::exists(AdventurePath(), equivalent_error)) { @@ -753,20 +766,27 @@ bool dkr::runtime::saves::prepare_host_online_adventure( if (mode == OnlineSaveSeedMode::Fresh) { bytes = codec::blank_bytes(); } else { - const std::filesystem::path source = - mode == OnlineSaveSeedMode::CopySinglePlayer - ? AdventurePath() : destination; + const auto source=mode==OnlineSaveSeedMode::CopySinglePlayer?AdventurePath():destination; std::vector stored; - if (!ReadAdventure(source, stored)) { - error = mode == OnlineSaveSeedMode::CopySinglePlayer - ? "No checksum-valid single-player Adventure save is available to copy." - : "No checksum-valid previous online save exists for this exact mod set. Choose a fresh save or copy your single-player save."; - return false; + if(ReadAdventure(source,stored))bytes=std::move(stored); + else { + std::error_code ec;const bool exists=std::filesystem::exists(source,ec); + if(mode!=OnlineSaveSeedMode::ContinuePreviousSession||exists||ec) { + error="That Adventure is missing or needs recovery. Choose a working save in Save Manager, copy your personal save, or start fresh."; + return false; + } + // First lobby only: seed from personal progress when available. + // Once a host Adventure exists, continuing never re-copies it. + if(ReadAdventure(AdventurePath(),stored))bytes=std::move(stored); + else { + std::error_code personal_error; + if(std::filesystem::exists(AdventurePath(),personal_error)||personal_error) { + error="Your personal Adventure needs recovery. Choose Start Fresh explicitly or repair it in Save Manager."; + return false; + } + bytes=codec::blank_bytes(); + } } - // The host explicitly chose an existing save. Preserve the complete - // checksum-valid EEPROM image byte-for-byte, including opaque retail - // data that the save editor does not interpret. - bytes = std::move(stored); } // Creating a lobby is not a commit point: registration/approval may fail. @@ -984,7 +1004,8 @@ bool dkr::runtime::saves::import_controller_pak( std::error_code exists_error; if (std::filesystem::exists(ControllerPakPath(channel), exists_error)) { std::filesystem::path backup; - if (!BackupPakUnlocked(channel, backup, error)) { + std::vector previous; + if (ReadControllerPak(ControllerPakPath(channel),previous) && !BackupPakUnlocked(channel, backup, error)) { return false; } } @@ -1007,6 +1028,11 @@ bool dkr::runtime::saves::export_bundle( static_cast(channel), image}); } } + const auto custom=g_config_directory/"saves/custom-records-v1.bin"; + if(std::filesystem::exists(custom)) { + if(!ReadCustomRecords(custom,image)){error="Custom records need recovery. Export their original image before transferring your save.";return false;} + entries.push_back({kBundleRecordsKind,0U,image}); + } if (entries.empty()) { error = "No valid Adventure save or Controller Pak is available to export."; return false; @@ -1024,85 +1050,10 @@ bool dkr::runtime::saves::export_bundle( return WriteAtomic(destination, bundle, ReadBundle, error); } -bool dkr::runtime::saves::import_bundle( - const std::filesystem::path& source, std::string& error) { - std::scoped_lock lock(g_save_manager_mutex); - std::vector> owners; - auto adventure_owner = storage::acquire(AdventurePath(), error); - if (!adventure_owner) return false; - owners.push_back(std::move(adventure_owner)); - for (int channel = 0; channel < kControllerPakCount; ++channel) { - auto owner = storage::acquire(ControllerPakPath(channel), error); - if (!owner) return false; - owners.push_back(std::move(owner)); - } - std::vector entries; - if (!DecodeBundle(source, entries)) { - error = "That file is not a valid DKR-R save bundle."; - return false; - } - - struct OriginalImage { - std::filesystem::path destination; - std::vector bytes; - ImageValidator validator = nullptr; - bool existed = false; - }; - std::vector originals; - originals.reserve(entries.size()); - for (const BundleEntry& entry : entries) { - OriginalImage original{}; - original.destination = entry.kind == kBundleAdventureKind - ? AdventurePath() - : ControllerPakPath(static_cast(entry.channel)); - original.validator = entry.kind == kBundleAdventureKind - ? ReadAdventure : ReadControllerPak; - original.existed = original.validator(original.destination, original.bytes); - if (std::filesystem::exists(original.destination) && !original.existed) { - error = "A destination save exists but is corrupt; move it aside before importing."; - return false; - } - originals.push_back(std::move(original)); - } +bool dkr::runtime::saves::import_bundle(const std::filesystem::path& source, std::string& error) { + return shared::apply_bundle(source,error); +} - std::size_t committed = 0U; - for (; committed < entries.size(); ++committed) { - const BundleEntry& entry = entries[committed]; - const OriginalImage& original = originals[committed]; - if (original.existed) { - std::filesystem::path backup; - const bool backed_up = entry.kind == kBundleAdventureKind - ? BackupUnlocked(backup, error) - : BackupPakUnlocked(static_cast(entry.channel), backup, error); - if (!backed_up) { - break; - } - } - if (!WriteAtomic(original.destination, entry.bytes, original.validator, error)) { - break; - } - } - if (committed == entries.size()) { - return true; - } +#include "shared_save_backend.inl" - // Roll back every earlier replacement from its in-memory original. A file - // that did not exist before the transaction is removed instead. - for (std::size_t index = 0; index < committed; ++index) { - const OriginalImage& original = originals[index]; - if (original.existed) { - std::string ignored; - WriteAtomic(original.destination, original.bytes, - original.validator, ignored); - } else { - std::error_code remove_error; - std::filesystem::remove(original.destination, remove_error); - } - } - if (error.empty()) { - error = "The save bundle transaction was rolled back safely."; - } else { - error += " The transaction was rolled back safely."; - } - return false; -} +#include "record_store.inl" diff --git a/runtime-recomp/src/game/save_manager.hpp b/runtime-recomp/src/game/save_manager.hpp index fd3f79ac..40da5433 100644 --- a/runtime-recomp/src/game/save_manager.hpp +++ b/runtime-recomp/src/game/save_manager.hpp @@ -31,15 +31,16 @@ void configure(const std::filesystem::path& config_directory); void configure_recovery_locations(std::vector roots); SaveInfo adventure_info(); void set_online_save_protection(bool enabled); -enum class StoredSaveKind { Adventure, ControllerPak, ExperimentalPaks }; +enum class StoredSaveKind { Adventure, ControllerPak, ExperimentalPaks, CustomRecords }; struct StoredAdventure { int scope = 0; // 0 offline, 1 online, 2 modded, 3 recovery candidates StoredSaveKind kind = StoredSaveKind::Adventure; SaveInfo info; - std::string label, preview; + std::string label, preview, content_hash; + unsigned copies=1; }; // Bounded read-only inventory; callers scan on a worker, never at frame rate. -std::vector stored_adventures(); +std::vector stored_adventures(std::size_t limit=256, bool* truncated=nullptr); bool export_stored_adventure(const std::filesystem::path& source, const std::filesystem::path& destination, std::string& error); bool export_stored_save(const std::filesystem::path& source, diff --git a/runtime-recomp/src/game/scene_load_work.hpp b/runtime-recomp/src/game/scene_load_work.hpp new file mode 100644 index 00000000..f0a1e763 --- /dev/null +++ b/runtime-recomp/src/game/scene_load_work.hpp @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace dkr_scene_work { +// Only the confirmed, GPU-drained scene transaction gets loader headroom. +// A modded race can construct eight unique racers, their resident model LODs, +// animations, attachment sprites and textures in ONE such transaction. The +// old menu-sized ceiling was shared by that entire load, not by each asset. +// These are finite work limits, never timeout/retry loops or unmetered calls. +inline constexpr std::uint64_t PreviewOperations=40000000ULL; +inline constexpr std::uint64_t ConfirmedOperations=256000000ULL; +inline constexpr std::uint64_t limit(bool background) noexcept { + return background?PreviewOperations:ConfirmedOperations; +} +static_assert(ConfirmedOperations>PreviewOperations&&ConfirmedOperations<1000000000ULL); +} diff --git a/runtime-recomp/src/game/scoreboard_wire.inl b/runtime-recomp/src/game/scoreboard_wire.inl new file mode 100644 index 00000000..7ea7012d --- /dev/null +++ b/runtime-recomp/src/game/scoreboard_wire.inl @@ -0,0 +1,69 @@ +// Included inside the diagnostic's anonymous namespace. A report contains at +// most 12 fixed-size rows. Names and roles come from the authenticated roster, +// never arbitrary report strings. No game input, guest state or save is exposed. +constexpr unsigned MaximumReportRows=12,ReportPacketsPerPulse=12; +const Peer* report_member(const Context& c,Id id){for(const auto& p:c.members)if(p.id==id)return &p;return nullptr;} +// calculate() has already rounded upwards once to wire microseconds. Do not +// ceil a second time: binary floating-point could otherwise add another us. +std::uint64_t bounded_us(double value){return std::uint64_t(std::clamp(std::round(value*1000.),0.,14000000.));} +std::vector report_wire(const State& s,unsigned index){ + auto packet=wire(s,ReportRow,index+1,0);net::protocol::PreflightProbePayload frame;std::string error; + if(!net::protocol::decode_preflight_probe(packet,frame,error))return {}; + auto& b=frame.padding;const auto& r=s.report_rows[index];b[19]=std::uint8_t(s.report_rows.size()); + put(b,20,r.id,8);b[28]=std::uint8_t(unsigned(r.supported)|(unsigned(r.host)<<1)|(unsigned(r.spectator)<<2));b[29]=ScoringVersion; + put(b,30,r.sent,2);put(b,32,r.replies,2);put(b,34,r.repair_sent,2);put(b,36,r.repair_replies,2); + put(b,40,bounded_us(r.median),4);put(b,44,bounded_us(r.p95),4);put(b,48,bounded_us(r.jitter),4);put(b,52,bounded_us(r.repair_p95),4); + put(b,56,r.blocked,2);put(b,58,r.errors,2);put(b,60,std::min(s.view.scheduler_gaps,65535U),2); + put(b,64,std::min(s.view.peak_queued,UINT32_MAX),4);put(b,68,std::min(s.view.final_queued,UINT32_MAX),4);put(b,72,s.context.roster,8); + return net::protocol::encode_preflight_probe(frame); +} +void freeze_report(State& s){ + s.report_rows=s.view.rows; + std::fprintf(stderr,"[rollback-check][scoreboard] policy=%u participants=%u; immutable host report\n",ScoringVersion,unsigned(s.report_rows.size())); +} +void deliver_report(State& s,const Send& send,Clock::time_point now){ + if(s.report_rows.empty()||s.report_rows.size()>MaximumReportRows||s.measures.empty())return; + std::vector ids;for(const auto& [id,m]:s.measures)ids.push_back(id); + for(unsigned n=0;nMaximumReportRows||count!=c.members.size()||index>=count|| + get(b,72,8)!=c.roster||b[16]!=s.view.window||b[17]!=s.view.delay||b[18]!=unsigned(s.view.automatic)||b[28]>7)return false; + const auto* member=report_member(c,get(b,20,8));if(!member)return false; + const bool host=(b[28]&2)!=0,spectator=(b[28]&4)!=0; + if(host!=(member->id==c.host_member)||spectator!=member->spectator)return false; + Row r;r.id=member->id;r.name=member->name;r.host=host;r.spectator=spectator;r.supported=(b[28]&1)!=0; + r.sent=unsigned(get(b,30,2));r.replies=unsigned(get(b,32,2));r.repair_sent=unsigned(get(b,34,2));r.repair_replies=unsigned(get(b,36,2)); + if(r.sent>MaxSamples||r.repair_sent>MaxSamples||r.replies>r.sent||r.repair_replies>r.repair_sent||get(b,56,2)>MaxSamples||get(b,58,2)>MaxSamples)return false; + for(unsigned offset:{40,44,48,52})if(get(b,offset,4)>14000000)return false; + r.median=get(b,40,4)/1000.;r.p95=get(b,44,4)/1000.;r.jitter=get(b,48,4)/1000.;r.repair_p95=get(b,52,4)/1000.; + if(r.p95=std::chrono::milliseconds(200)){ + send(c.host_id,Type::PreflightControlProbe,wire(s,Receipt,1,0));s.receipt_at=now; + } + return true; +} diff --git a/runtime-recomp/src/game/sdk_task_source.hpp b/runtime-recomp/src/game/sdk_task_source.hpp new file mode 100644 index 00000000..5835c11f --- /dev/null +++ b/runtime-recomp/src/game/sdk_task_source.hpp @@ -0,0 +1,2 @@ +#pragma once +#define DKR_STABILITY_ORIGINAL_SP "sp.cpp" diff --git a/runtime-recomp/src/game/shared_save_backend.inl b/runtime-recomp/src/game/shared_save_backend.inl new file mode 100644 index 00000000..8c25a2d2 --- /dev/null +++ b/runtime-recomp/src/game/shared_save_backend.inl @@ -0,0 +1,319 @@ +// Included at global scope by the staged save_manager.cpp. Existing codec, +// path guards, leases and durable replacement stay authoritative. +namespace dkr::runtime::saves::shared { +namespace { +using Json=nlohmann::json; +std::mutex operation_mutex; +std::filesystem::path data_root() { + std::scoped_lock lock(g_save_manager_mutex);return g_config_directory; +} +bool read_json(const std::filesystem::path& path,Json& doc) { + std::vector bytes; + if(!ReadFileBounded(path,16*1024*1024,bytes))return false; + try {doc=Json::parse(bytes.begin(),bytes.end());return doc.is_object();}catch(...){return false;} +} +bool write_json(const std::filesystem::path& path,const Json& doc,std::string& error) { + const auto text=doc.dump(2);return storage::write_atomic(path,{text.begin(),text.end()},nullptr,error); +} +std::filesystem::path transaction_destination(const Json& item) { + const auto kind=item.at("kind").get(); + if(kind==kBundleAdventureKind&&item.at("channel")==0)return data_root()/"saves/dkr.us.v77.bin"; + if(kind==kBundleRecordsKind&&item.at("channel")==0)return data_root()/"saves/custom-records-v1.bin"; + const auto channel=item.at("channel").get(); + if(kind==kBundlePakKind&&channel<4)return data_root()/("controller-pak-"+std::to_string(channel+1)+".mpk"); + throw std::runtime_error("The save transaction contains an unsupported destination."); +} +bool retire_transaction(const std::filesystem::path& dir,std::string& error) { + auto archive=data_root()/"save-transactions"/Timestamp(); + std::error_code ec;std::filesystem::create_directories(archive.parent_path(),ec); + if(!ec&&std::filesystem::exists(archive,ec))archive+=std::to_string(++storage::serial); + if(!storage::writable_path(archive/".check",error))return false; + if(!ec)std::filesystem::rename(dir,archive,ec); + if(ec){error="Save images are safe, but the transaction archive could not be finalised: "+ec.message();return false;} + return true; +} +bool recover_unlocked(std::string& error) { + const auto dir=data_root()/"pending-save-transaction"; + std::error_code ec;if(!std::filesystem::exists(dir,ec))return !ec; + Json journal; + if(!read_json(dir/"journal.json",journal)||journal.value("schema",0)!=1|| + !journal.contains("items")||!journal["items"].is_array()||journal["items"].size()>6) { + error="An interrupted save transfer needs recovery. Its files were retained; do not delete pending-save-transaction.";return false; + } + if(journal.value("state","")=="committed")return retire_transaction(dir,error); + std::vector> owners; + std::set seen; + try { + // Validate every original before rolling back even one destination. + for(std::size_t i=0;i()) { + std::vector bytes; + const auto size=item.at("bytes").get(); + if(size>1024*1024||!storage::read_exact(dir/(std::to_string(i)+".original"),size,bytes)|| + storage::content_id(bytes)!=item.at("hash").get()) + throw std::runtime_error("An original save transfer image is missing or damaged."); + } + } + for(std::size_t i=0;i()) { + std::vector bytes; + if(!storage::read_exact(dir/(std::to_string(i)+".original"),item.at("bytes").get(),bytes)|| + !storage::write_atomic(dest,bytes,nullptr,error))return false; + } else { + // An absent-before file is preserved in the recovery archive, + // never deleted without an original. Fixed typed paths only. + if(!storage::writable_path(dest,error))return false; + if(std::filesystem::exists(dest,ec)) { + const auto retained=dir/(std::to_string(i)+".uncommitted"); + if(std::filesystem::exists(retained,ec)){error="A save recovery image already exists; originals were retained.";return false;} + std::filesystem::rename(dest,retained,ec);if(ec){error=ec.message();return false;} + } + } + } + journal["state"]="rolled-back"; + if(!write_json(dir/"journal.json",journal,error))return false; + return retire_transaction(dir,error); + }catch(const std::exception& e){error=e.what();return false;} +} +} + +std::string revision() { + std::vector bytes; + return storage::read_exact(adventure_info().path,512,bytes)?storage::content_id(bytes):"missing"; +} +bool recover_transaction(std::string& error) { + std::scoped_lock lock(operation_mutex);return recover_unlocked(error); +} +bool prepare(const std::filesystem::path& root,std::string& error) { + std::scoped_lock lock(operation_mutex); + if(storage::absolute(root)!=storage::absolute(data_root())){error="Save preparation has the wrong data owner.";return false;} + auto personal_owner=storage::acquire(root/"saves/dkr.us.v77.bin",error);if(!personal_owner)return false; + if(!recover_unlocked(error))return false; + const auto dir=root/"save-migration-v1"; + if(!storage::writable_path(dir/"manifest.json",error))return false; + Json existing; + if(std::filesystem::exists(dir/"manifest.json")) { + if(!read_json(dir/"manifest.json",existing)||existing.value("schema",0)!=1){error="The saved migration inventory is unreadable; original progress has not been replaced.";return false;} + return true; + } + bool truncated=false;const auto records=stored_adventures(50000,&truncated); + if(truncated){error="There are too many save candidates for one migration. Export your saves before continuing.";return false;} + Json index={{"schema",1},{"policy","one-personal-save"},{"created",Timestamp()},{"entries",Json::array()}}; + for(const auto& record:records) { + if(record.info.size>1024*1024)continue; + std::vector bytes; + if(!storage::read_exact(record.info.path,record.info.size,bytes)){error="A save changed during the migration snapshot. No personal save was replaced.";return false;} + auto id=storage::content_id(bytes);auto image=dir/"images"/(id+".bin"); + std::string guard;if(!storage::writable_path(image,guard)){error=guard;return false;} + std::vector prior; + if(std::filesystem::exists(image)&&(!storage::read_exact(image,bytes.size(),prior)||prior!=bytes))image=dir/"images"/(id+"-"+std::to_string(++storage::serial)+".bin"); + if(!std::filesystem::exists(image)&&!storage::write_atomic(image,bytes,nullptr,error))return false; + const auto source_u8=record.info.path.generic_u8string(); + index["entries"].push_back({{"source",std::string(source_u8.begin(),source_u8.end())},{"image",image.filename().string()}, + {"bytes",bytes.size()},{"hash",id},{"scope",record.scope},{"kind",int(record.kind)},{"valid",record.info.valid},{"preview",record.preview}}); + } + if(!write_json(dir/"manifest.json",index,error))return false; + std::fprintf(stderr,"[save][migration] original-images=%zu retained; personal save unchanged\n",records.size()); + return true; +} +bool queue_development_copy(std::string& error) { + if(!prepare(data_root(),error))return false; + std::scoped_lock lock(operation_mutex); + const auto personal=data_root()/"saves/dkr.us.v77.bin"; + auto owner=storage::acquire(personal,error);if(!owner)return false; + std::vector bytes; + if(!ReadAdventure(personal,bytes)){error="Your personal save needs recovery before it can be copied to development.";return false;} + return storage::write_atomic(data_root()/"pending-development-seed.bin",bytes,ReadAdventure,error); +} +bool prepare_development(const std::filesystem::path& root,const std::filesystem::path& folder,std::string& error) { + if(!prepare(root,error))return false; + std::scoped_lock lock(operation_mutex); + // The caller passes the frozen code identity, never an imported file path. + std::vector parts; + for(const auto& part:folder)parts.push_back(part.string()); + if(parts.size()!=2||parts[0]!="development"||parts[1].size()!=24|| + parts[1].find_first_not_of("0123456789abcdef")!=std::string::npos) { + error="The offline development save has an invalid owner.";return false; + } + const auto target=root/"saves"/folder/"dkr.us.v77.bin"; + const auto pending=root/"pending-development-seed.bin"; + auto owner=storage::acquire(target,error);if(!owner)return false; + auto request_owner=storage::acquire(pending,error);if(!request_owner)return false; + std::error_code ec; + const bool refresh=std::filesystem::exists(pending,ec); + if(ec){error="Cannot inspect the pending development copy: "+ec.message();return false;} + const bool exists=std::filesystem::exists(target,ec); + if(ec){error="Cannot inspect the development save: "+ec.message();return false;} + std::vector bytes; + if(exists&&!refresh) { + if(!ReadAdventure(target,bytes)){error="Your development save needs recovery. It was not replaced; use the confirmed Copy Personal Save to Development action to start a new copy.";return false;} + std::fprintf(stderr,"[save][development] existing sandbox retained; personal progress unchanged\n"); + return true; + } + auto personal_owner=storage::acquire(root/"saves/dkr.us.v77.bin",error);if(!personal_owner)return false; + auto source=refresh?pending:root/"saves/dkr.us.v77.bin"; + const auto previous=std::filesystem::path(target.string()+".bak"); + if(!refresh&&std::filesystem::exists(previous,ec))source=previous; + if(ec){error=ec.message();return false;} + if(!ReadAdventure(source,bytes)) { + const bool source_exists=std::filesystem::exists(source,ec); + if(ec||source_exists||refresh||source==previous||std::filesystem::exists(source.string()+".bak",ec)) { + error="The development seed needs recovery. No original save was replaced.";return false; + } + if(ec){error=ec.message();return false;} + bytes=codec::blank_bytes(); // Only a genuinely new personal Adventure. + } + if(exists) { + std::vector original; + if(!ReadFileBounded(target,1024*1024,original)){error="The previous development save could not be preserved. No replacement was made.";return false;} + const auto archive=root/"save-recovery"/("development-original-"+storage::content_id(original)+".bin"); + if(!storage::write_atomic(archive,original,nullptr,error))return false; + } + if(!storage::write_atomic(target,bytes,ReadAdventure,error))return false; + std::vector verified; + if(!ReadAdventure(target,verified)||verified!=bytes){error="The development copy failed read-back verification. Original saves were kept.";return false;} + if(refresh) { + auto archive=root/"save-recovery"/("applied-development-seed-"+Timestamp()+"-"+std::to_string(++storage::serial)+".bin"); + if(!storage::writable_path(pending,error)||!storage::writable_path(archive,error))return false; + std::filesystem::create_directories(archive.parent_path(),ec); + if(!ec)std::filesystem::rename(pending,archive,ec); + if(ec){error="The development copy is safe, but its request could not be archived: "+ec.message();return false;} + } + std::fprintf(stderr,"[save][development] %s; personal EEPROM never used as a development write target\n",refresh?"confirmed personal copy installed":"new sandbox seeded from saved progress"); + return true; +} +void unlock(codec::SaveImage& image) { + image.settings.adventure_two=true;image.settings.drumstick=true;image.settings.tt_trials.fill(true); +} +void complete(codec::SaveImage& image,int selected) { + for(int i=0;i original; + if(!ReadAdventure(adventure_info().path,original)){error="The current personal image is not valid; use Recovery before editing.";return false;} + codec::SaveImage previous; + if(!codec::decode(original,previous,&error))return false; + auto proposed=codec::encode(image);const auto canonical=codec::encode(previous); + // The manager's record views are read-only. Preserve both retail record + // blocks byte-for-byte, including opaque/unused initials encodings. + std::copy(original.begin()+0x80,original.end(),proposed.begin()+0x80); + // An unchanged slot need not be canonicalised as a side effect of editing + // another one (notably an erased 0xff empty slot). + for(unsigned slot=0;slot<3;++slot)if(std::equal(proposed.begin()+slot*40,proposed.begin()+(slot+1)*40,canonical.begin()+slot*40)) + std::copy_n(original.begin()+slot*40,40,proposed.begin()+slot*40); + if(!codec::validate(proposed,&error))return false; + std::filesystem::path backup; + if(!backup_adventure(backup,error)||!storage::write_atomic(adventure_info().path,proposed,ReadAdventure,error))return false; + std::vector check; + if(!ReadAdventure(adventure_info().path,check)||check!=proposed){error="The saved edit failed read-back verification. Its backup was retained.";return false;} + std::fprintf(stderr,"[save][editor] personal EEPROM committed and read-back verified\n");error.clear();return true; +} +bool adopt(const std::filesystem::path& source,bool repair,std::string& error) { + if(!prepare(data_root(),error))return false; + std::vector bytes; + if(!ReadFileBounded(source,512,bytes)||bytes.size()!=512){error="Choose a 512-byte Adventure save.";return false;} + if(!codec::validate(bytes)) { + if(!repair){error="This image needs checksum repair. Choose Repair & use copy if you want to attempt recovery.";return false;} + std::vector fixed; + if(!codec::repair_checksums(bytes,fixed,&error))return false; + auto preserved=data_root()/"save-recovery"/("original-"+storage::content_id(bytes)+".bin"); + if(!storage::write_atomic(preserved,bytes,nullptr,error))return false; + bytes=std::move(fixed); + } + const auto staged=data_root()/"save-recovery"/("candidate-"+storage::content_id(bytes)+".bin"); + if(!storage::write_atomic(staged,bytes,ReadAdventure,error))return false; + return import_adventure(staged,error); +} +bool export_raw(const std::filesystem::path& source,const std::filesystem::path& destination,std::string& error) { + if(!ExportDestinationAllowed(destination,error))return false; + std::vector bytes; + if(!ReadFileBounded(source,1024*1024,bytes)){error="Could not read the original recovery image.";return false;} + return storage::write_atomic(destination,bytes,nullptr,error); +} +bool import_host(const std::filesystem::path& source,std::string& error) { + std::vector bytes; + if(!ReadAdventure(source,bytes)){error="Choose a valid Adventure image for hosting.";return false;} + if(!prepare(data_root(),error))return false; + return storage::write_atomic(data_root()/"saves/online/host/dkr.us.v77.bin",bytes,ReadAdventure,error); +} +std::string describe(const std::filesystem::path& path) { + std::vector bytes;codec::SaveImage image; + if(!ReadFileBounded(path,512,bytes)||!codec::decode(bytes,image))return "Not a checksum-valid Adventure image. Checksum repair cannot reconstruct damaged progress."; + std::string text; + for(unsigned i=0;i<3;++i) { + if(i)text+=" | ";const auto& s=image.slots[i]; + text+="Slot "+std::to_string(i+1)+": "+(s.name.empty()?"---":s.name)+" ("+std::to_string(s.balloons[0])+" balloons)"; + } + text+=" | Drumstick: "+std::string(image.settings.drumstick?"unlocked":"locked"); + text+=" | T.T.: "+std::string(std::all_of(image.settings.tt_trials.begin(),image.settings.tt_trials.end(),[](bool x){return x;})?"unlocked":"locked"); + return text; +} +bool apply_bundle(const std::filesystem::path& source,std::string& error) { + if(!prepare(data_root(),error))return false; + std::scoped_lock lock(operation_mutex); + std::vector entries; + if(!DecodeBundle(source,entries)){error="That file is not a valid DKR-R save bundle.";return false;} + const auto dir=data_root()/"pending-save-transaction"; + Json journal={{"schema",1},{"state","prepared"},{"items",Json::array()}}; + // A crash while preserving originals has an empty rollback set: no live + // destination is replaced until the complete prepared journal is durable. + if(!write_json(dir/"journal.json",journal,error))return false; + std::vector> owners; + for(std::size_t i=0;i original;std::error_code ec;const bool exists=std::filesystem::exists(dest,ec); + if(ec){error=ec.message();return false;} + if(exists&&!ReadFileBounded(dest,1024*1024,original)){error="Could not preserve a destination before import.";return false;} + if(exists&&!storage::write_atomic(dir/(std::to_string(i)+".original"),original,nullptr,error))return false; + item["existed"]=exists;item["bytes"]=original.size();item["hash"]=storage::content_id(original);journal["items"].push_back(std::move(item)); + } + if(!write_json(dir/"journal.json",journal,error))return false; + for(std::size_t i=0;i entries{{kBundleAdventureKind,0,codec::blank_bytes()}, + {kBundleRecordsKind,0,records::encode({})}}; + std::vector bytes(kBundleMagic.begin(),kBundleMagic.end()); + AppendLE32(bytes,kBundleVersion);AppendLE32(bytes,entries.size()); + for(const auto& entry:entries) { + AppendLE32(bytes,entry.kind);AppendLE32(bytes,entry.channel);AppendLE32(bytes,entry.bytes.size()); + AppendLE32(bytes,ImageChecksum(entry.bytes));bytes.insert(bytes.end(),entry.bytes.begin(),entry.bytes.end()); + } + const auto source=data_root()/"save-recovery"/("fresh-"+Timestamp()+".dkrsave"); + if(!storage::write_atomic(source,bytes,ReadBundle,error))return false; + return apply_bundle(source,error); +} +} diff --git a/runtime-recomp/src/game/shared_save_jobs.inl b/runtime-recomp/src/game/shared_save_jobs.inl new file mode 100644 index 00000000..1e7407f1 --- /dev/null +++ b/runtime-recomp/src/game/shared_save_jobs.inl @@ -0,0 +1,26 @@ +// UI-owned completion, worker-owned I/O. No ImGui calls or borrowed references +// cross the worker boundary. Launch is fenced until persistence finishes. +struct SharedSaveResult {bool ok=false;std::string message;}; +std::future g_shared_save_job; +std::string g_shared_draft_revision; +std::uint64_t g_shared_save_generation=1; +struct SharedSaveImport {std::filesystem::path path;int kind=0;int port=0;}; +SharedSaveImport g_shared_save_import; +bool SharedSaveBusy(){return g_shared_save_job.valid();} +bool BeginSharedSaveJob(std::function action,std::string success) { + if(SharedSaveBusy())return false; + g_save_manager_status="Saving..."; + g_shared_save_job=std::async(std::launch::async,[action=std::move(action),success=std::move(success)]{ + SharedSaveResult result; + try {std::string error;result.ok=action(error);result.message=result.ok?success:error;} + catch(const std::exception& e){result.message=e.what();} + catch(...){result.message="The save operation stopped. Original recovery images were retained.";} + return result; + }); + return true; +} +void PumpSharedSaveJob() { + if(!g_shared_save_job.valid()||g_shared_save_job.wait_for(std::chrono::seconds(0))!=std::future_status::ready)return; + const auto result=g_shared_save_job.get();g_save_manager_status=result.message; + if(result.ok){g_save_builder_image.reset();g_shared_draft_revision.clear();++g_shared_save_generation;InvalidateSaveManagerViewCache();} +} diff --git a/runtime-recomp/src/game/shared_saves.hpp b/runtime-recomp/src/game/shared_saves.hpp new file mode 100644 index 00000000..7fefa154 --- /dev/null +++ b/runtime-recomp/src/game/shared_saves.hpp @@ -0,0 +1,23 @@ +#pragma once +#include "save_manager.hpp" +#include + +namespace dkr::runtime::saves::shared { +// One personal EEPROM. Content/network identities never select this file. +bool prepare(const std::filesystem::path& root, std::string& error); +std::string revision(); +bool apply(const codec::SaveImage& image, std::string_view expected, std::string& error); +void complete(codec::SaveImage& image, int slot); +void unlock(codec::SaveImage& image); +bool adopt(const std::filesystem::path& source, bool repair, std::string& error); +bool export_raw(const std::filesystem::path& source, const std::filesystem::path& destination, std::string& error); +bool import_host(const std::filesystem::path& source, std::string& error); +bool apply_bundle(const std::filesystem::path& source, std::string& error); +bool reset(std::string& error); +bool recover_transaction(std::string& error); +// Development keeps its own write owner, but a new sandbox inherits personal +// progress. Refreshing an existing sandbox is a separately confirmed action. +bool prepare_development(const std::filesystem::path& root, const std::filesystem::path& folder, std::string& error); +bool queue_development_copy(std::string& error); +std::string describe(const std::filesystem::path& path); +} diff --git a/runtime-recomp/src/game/start_gate.hpp b/runtime-recomp/src/game/start_gate.hpp new file mode 100644 index 00000000..b8a3e3a0 --- /dev/null +++ b/runtime-recomp/src/game/start_gate.hpp @@ -0,0 +1,43 @@ +#pragma once +#include "party_link.hpp" +#include +#include +#include +#include +#include +#include + +// Authored through the Patch Pipeline. DirectSession's checked-out header and +// ABI stay unchanged. Only its owner accesses a Gate, under its existing mutex. +namespace dkr::runtime::party_start { +using Clock=std::chrono::steady_clock; +using Id=std::uint64_t; +enum class Phase {Idle,Countdown,Committing,Released,Cancelled}; +struct Gate { + Phase phase=Phase::Idle; + std::optional launch; + netplay::Rules rules{}; + std::uint64_t roster=0,save_hash=0,hash=0,retired=0; + std::uint32_t save_generation=0; + std::set prepared,committed; + Clock::time_point deadline{},retry{},progress{}; +}; +struct Registry {std::mutex mutex;std::map> entries;}; +inline Registry& registry(){static auto* value=new Registry;return *value;} +inline std::shared_ptr gate(const void* owner){auto& r=registry();std::scoped_lock lock(r.mutex); + auto& value=r.entries[owner];if(!value)value=std::make_shared();return value;} +inline void forget(const void* owner){auto& r=registry();std::scoped_lock lock(r.mutex);r.entries.erase(owner);} +// Receipt/cancel metadata is inside the authenticated party envelope. It binds +// the descriptor, current roster and verified EEPROM identity, never a slot claim. +inline std::vector receipt(unsigned stage,const Gate& g){ + if(stage<1||stage>3||!g.hash||!g.roster||!g.save_generation||!g.save_hash)return {}; + std::vector b{'D','K','S','T',1,std::uint8_t(stage)}; + auto put=[&](std::uint64_t v,unsigned n){for(unsigned i=0;i>(8*i)));}; + put(g.hash,8);put(g.roster,8);put(g.save_generation,4);put(g.save_hash,8);return b; +} +inline unsigned receipt(std::span b,const Gate& g){ + if(b.size()!=34||b[0]!='D'||b[1]!='K'||b[2]!='S'||b[3]!='T'||b[4]!=1||b[5]<1||b[5]>3)return 0; + auto get=[&](unsigned at,unsigned n){std::uint64_t v=0;for(unsigned i=0;i std::size_t(StoreLimit("my-reviews"))) + continue; + if (StoreRow(StoreText(review, "id", 160), item ? *item : StoreJson::object(), title, + std::to_string(StoreInt(review, "score")) + " / 5 | Version " + + StoreText(review, "version", 80), + "View review", width) && + item) { + StoreOpenMod(*item, true); + g_mod_store.detail_tab = 1; + g_mod_store.review_editing = true; + g_mod_store.page = 0; + } + } + if (!matching) + StoreNote("No reviews match your search. Open a mod to leave one.", width); + StoreMore("my-reviews", matching, width); + return; + } + { + StorePanel profile(std::min(width, 700.F)); + ImGui::BeginDisabled(StoreBusyAction(store)); + StoreField("profile-name", "Display name", profile.inner(), 60); + ImGui::BeginDisabled(g_mod_store.fields["profile-name"].empty() || + g_mod_store.fields["profile-name"] == StoreText(store.user, "displayName", 60)); + if (StoreButton("Save name", 0, true)) + StoreAction("account.profile", {{"displayName", g_mod_store.fields["profile-name"]}}); + ImGui::EndDisabled(); + ImGui::EndDisabled(); + StoreActionLine( + {{"My requests", + [] { + g_mod_store.page = 1; + g_mod_store.request_page = 1; + }}, + {"Privacy information", [] { SDL_OpenURL("https://positron-portal.com/dkr-r/privacy.html"); }}}, + profile.inner()); + } + { + StorePanel session(std::min(width, 700.F)); + StoreHeading("Session", session.inner(), 18); +#ifdef _WIN32 + StoreNote(store.remember ? "This PC remembers your sign-in." : "Signed in for this session.", session.inner()); +#else + StoreNote(store.remember?"Your desktop keyring remembers this sign-in.":"Signed in for this session.",session.inner()); +#endif + ImGui::BeginDisabled(StoreBusyAction(store)); + if (StoreButton("Sign out")) + StoreNavigate([] { dkr::store::service().logout(); }); + ImGui::EndDisabled(); + } + { + StorePanel danger(std::min(width, 700.F)); + StoreHeading("Delete library account", danger.inner(), 18); + StoreNote("Deletes your library account, not your installed mods or Discord account.", danger.inner()); + ImGui::BeginDisabled(StoreBusyAction(store)); + if (StoreButton("Delete library account")) { + g_mod_store.fields["account-delete"].clear(); + StoreOpenPopup("Delete your library account?"); + } + ImGui::EndDisabled(); + } +} diff --git a/runtime-recomp/src/game/store_page_shell.inl b/runtime-recomp/src/game/store_page_shell.inl new file mode 100644 index 00000000..72bc418f --- /dev/null +++ b/runtime-recomp/src/game/store_page_shell.inl @@ -0,0 +1,172 @@ +void DrawModStore(float width, bool locked) { + auto& service = dkr::store::service(); + if (!g_mod_store.opened) { + g_mod_store.opened = true; + service.refresh(); + } + auto store = service.view(); + StoreSyncIdentity(*store); + if (!store->writing && store->mutation != g_mod_store.mutation) { + g_mod_store.mutation = store->mutation; + if (g_mod_store.pending_editor_generation == g_mod_store.editor_generation) { + if (store->last_action == "catalogue.save" && store->last_result.is_string()) { + const auto id = store->last_result.get(); + const auto drafts = store->admin.value("drafts", StoreJson::array()); + if (const auto* saved = StoreFind(drafts, id)) { + const int step = g_mod_store.editor_step; + StoreLoadDraft(saved->contains("draft") ? (*saved)["draft"] : *saved); + g_mod_store.editor = id; + g_mod_store.last_saved = id; + g_mod_store.draft["revision"] = StoreInt(*saved, "revision"); + g_mod_store.editor_step = step; + g_mod_store.dirty = false; + } + } else if (store->last_action == "catalogue.update") + g_mod_store.dirty = false; + else if (store->last_action == "catalogue.publish" || store->last_action == "request.publish") { + g_mod_store.dirty = false; + g_mod_store.editing = false; + g_mod_store.request.clear(); + } + } + if (store->last_action == "request.submit") { + for (const char* key : {"request-title", "request-author", "request-source", "request-download", + "request-summary", "request-requirements", "request-tested", "request-screenshot"}) + g_mod_store.fields[key].clear(); + g_mod_store.request_credit = false; + if (g_mod_store.page == 1) + g_mod_store.request_page = 1; + } + } + if (auto url = service.take_browser_url(); !url.empty()) + SDL_OpenURL(url.c_str()); + StorePumpInstall(); + StoreStartQueuedInstall(locked || store->writing); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 6.F); + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, {10.F, 8.F}); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, {.5F, .5F}); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {10.F, 14.F}); + ImGui::PushStyleColor(ImGuiCol_FrameBg, PaddockCol(0x102F43)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, PaddockCol(0x1C4C66)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, PaddockCol(0x205774)); + ImGui::PushStyleColor(ImGuiCol_Border, PaddockCol(0x496579)); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, PaddockCol(0xFFC15A)); + const bool header = + width >= 700.F && ImGui::BeginTable("store-header", 2, ImGuiTableFlags_NoSavedSettings, {width, 0}); + if (header) { + ImGui::TableSetupColumn("Store", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Tools", ImGuiTableColumnFlags_WidthFixed, 300.F); + ImGui::TableNextColumn(); + } + StoreHeading("MOD STORE", ImGui::GetContentRegionAvail().x, 27); + StoreNote(store->user.is_null() ? "Community mods for DKR-R" + : "Signed in as " + StoreText(store->user, "displayName", 100), + ImGui::GetContentRegionAvail().x); + if (header) + ImGui::TableNextColumn(); + ImGui::BeginDisabled(store->loading); + StoreActionLine({{store->writing ? "Saving..." + : store->loading ? "Refreshing..." + : "Refresh", + [&] { + g_mod_store.message_page = std::to_string(g_mod_store.page); + service.refresh(); + }}, + {"Website", [] { SDL_OpenURL("https://positron-portal.com/dkr-r/index.html"); }}}, + ImGui::GetContentRegionAvail().x); + ImGui::EndDisabled(); + if (header) + ImGui::EndTable(); + if (store->cached && !store->loading) + StoreNote("Showing the saved library. Refresh to try connecting again.", width); + const auto active = std::count_if(store->downloads.begin(), store->downloads.end(), StoreTransferActive); + const auto manager = dkr::store::installations().view(); + const int current = g_mod_store.page == 0 ? 0 + : g_mod_store.page == 4 || g_mod_store.page == 5 || g_mod_store.page == 6 ? 1 + : g_mod_store.page == 2 ? 2 + : g_mod_store.page == 1 ? 3 + : g_mod_store.page == 3 ? 4 + : 5; + std::vector tabs{"Browse", "Installed", "Downloads (" + std::to_string(active) + ")", "Requests", + store->user.is_null() ? "Sign in" : "Account"}; + if (dkr::store::staff(store->user)) + tabs.push_back("Manage library"); + const int chosen = StoreTabLine(tabs, current, width, "store-pages"); + if (chosen != current) + StoreNavigate([chosen] { + const int pages[] = {0, 4, 2, 1, 3, 7}; + g_mod_store.page = pages[chosen]; + if (chosen != 5) + g_mod_store.picker_upload = false; + }); + if (g_mod_store.page == 5 || g_mod_store.page == 6) { + g_mod_store.installed_section = g_mod_store.page == 5 ? 1 : 2; + g_mod_store.page = 4; + } + if (g_mod_store.page == 4) + g_mod_store.installed_section = + StoreTabLine({"Installed mods", "Updates (" + std::to_string(manager->updates.size()) + ")", "Duplicates"}, + g_mod_store.installed_section, width, "installed-sections"); + if (g_mod_store.last_error != store->error || g_mod_store.last_status != store->status) { + if (!store->error.empty() && g_mod_store.message_page.empty()) + g_mod_store.message_page = std::to_string(g_mod_store.page); + g_mod_store.last_error = store->error; + g_mod_store.last_status = store->status; + } + if (!store->error.empty() && g_mod_store.message_page == std::to_string(g_mod_store.page)) + StoreMessage("Couldn't complete that action", store->error, width); + else if (!store->status.empty() && g_mod_store.message_page == std::to_string(g_mod_store.page) && + (store->status == "Your change was saved." || store->status.starts_with("Library account deleted"))) { + StoreNote(store->status, width, false); + if (StoreButton("Dismiss message")) + service.clear_messages(); + } + if (!manager->ready && !manager->busy) { + StoreNotice("Installation recovery needs attention", + manager->error.empty() ? "Finish recovery before changing mods or starting a game." + : manager->error, + width, true); + if (!manager->error.empty() && StoreButton("Copy recovery error")) + SDL_SetClipboardText(manager->error.c_str()); + if (StoreButton("Retry recovery", 0, true)) + dkr::store::installations().retry_recovery(); + } + if (manager->busy && g_mod_store.page != 2) { + StoreNote(manager->stage, width); + ImGui::ProgressBar(manager->progress, {width, 10.F}, ""); + if (StoreButton("View installation")) { + g_mod_store.page = 2; + g_mod_store.download_section = 0; + } + } + const auto attention = std::count_if(store->downloads.begin(), store->downloads.end(), [](const auto& job) { + return job.state == "Failed" || job.state == "Install failed"; + }); + if (attention && g_mod_store.page != 2) { + if (StoreButton(std::to_string(attention) + " download(s) need attention")) { + g_mod_store.page = 2; + g_mod_store.download_section = 1; + } + } + StoreBeginBodyNavigation(); + if (g_mod_store.page == 0) + StoreBrowser(width, *store); + else if (g_mod_store.page == 1) + StoreRequestForm(width, *store); + else if (g_mod_store.page == 2) + StoreDownloads(width, locked, *store); + else if (g_mod_store.page == 3) + StoreAccount(width, *store); + else if (g_mod_store.page == 4) { + if (g_mod_store.installed_section == 0) + StoreInstalledPage(width, *store, locked); + else if (g_mod_store.installed_section == 1) + StoreUpdatesPage(width, *store, locked); + else + StoreDuplicatesPage(width, *store, locked); + } else + StoreManagement(width, *store); + StoreEndBodyNavigation(); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(4); +} diff --git a/runtime-recomp/src/game/store_player_activity.inl b/runtime-recomp/src/game/store_player_activity.inl new file mode 100644 index 00000000..650b041c --- /dev/null +++ b/runtime-recomp/src/game/store_player_activity.inl @@ -0,0 +1,269 @@ +void StoreDownloads(float width, bool locked, const dkr::store::Snapshot& store) { + width = std::min(width, 1280.F); + StoreHeading("Downloads", width); + g_mod_store.download_section = StoreTabLine({"In progress", "Needs attention", "History"}, + g_mod_store.download_section, width, "download-sections"); + StoreField("download-search", "Find a download", std::min(width, 760.F), 200); + if (g_mod_store.download_section == 2) { + if (StoreButton("Clear completed history")) + dkr::store::service().clear_completed_history(); + StoreNote("Clears completed entries only. Installed mods and cached packages are kept.", width); + } + const auto manager = dkr::store::installations().view(); + std::size_t matching = 0; + for (auto it = store.downloads.rbegin(); it != store.downloads.rend(); ++it) { + const auto& job = *it; + const int section = + job.state == "Installed" ? 2 + : StoreTransferActive(job) || job.state == "Ready to install" || job.state == "Paused for gameplay" ? 0 + : 1; + if (section != g_mod_store.download_section || !StoreMatches(job.title, g_mod_store.fields["download-search"])) + continue; + if (++matching > std::size_t(StoreLimit("downloads-" + std::to_string(section)))) + continue; + ImGui::PushID(job.key.c_str()); + StorePanel card(width); + const float inner = card.inner(); + StoreHeading(job.title, inner, 18); + StoreBadge(job.state, inner, + section == 1 ? PaddockTone::Warning + : section == 2 ? PaddockTone::On + : PaddockTone::Neutral); + if (job.state == "Downloading" || job.state == "Verifying") { + if (job.total) + ImGui::ProgressBar(std::clamp(float(double(job.received) / double(job.total)), 0.F, 1.F), {inner, 12.F}, + ""); + StoreNote(StoreSize(job.received) + " / " + StoreSize(job.total), inner); + } else if (job.state == "Installing") { + if (g_mod_store.pending_key == job.key && manager->busy) { + StoreNote(manager->stage, inner); + ImGui::ProgressBar(manager->progress, {inner, 12.F}, ""); + } else + StoreNote("Waiting for the installer.", inner); + } + if (!job.error.empty()) { + StoreNote(job.error, inner, false); + if (StoreButton("Copy error")) + SDL_SetClipboardText(job.error.c_str()); + } + if (job.state == "Queued" || job.state == "Downloading" || job.state == "Preparing download") { + if (StoreButton("Pause")) + dkr::store::service().pause_download(job.key); + } else if (job.state == "Failed" || job.state == "Paused") { + if (StoreButton("Retry / resume", 0, true)) { + g_mod_store.install_after_download.insert(job.key); + dkr::store::service().resume_download(job.key); + } + } else if (job.state == "Ready to install" || job.state == "Install failed") { + ImGui::BeginDisabled(locked || manager->busy); + if (StoreButton(job.state == "Install failed" ? "Retry installation" : "Install", 0, true)) + StoreInstall(job, locked); + ImGui::EndDisabled(); + if (locked) + StoreNote("Leave the online lobby before installing.", inner); + } else if (job.state == "Installed") { + if (StoreButton("Manage installed mods")) { + g_mod_store.page = 4; + g_mod_store.installed_section = 0; + } + } + if (const auto* item = StoreFind(StoreItems(store), job.item)) { + if (StoreButton("View mod")) { + StoreOpenMod(*item, true); + g_mod_store.page = 0; + } + if (section == 1 && item->contains("releases")) + for (const auto& release : (*item)["releases"]) + if (StoreText(release, "id", 160) == job.release) { + const auto url = StoreText(release, "fileUrl", 2048); + if (dkr::store::trusted_url(url) && StoreButton("Open download in browser")) + SDL_OpenURL(url.c_str()); + break; + } + } + ImGui::PopID(); + } + if (!matching) { + StoreNotice("Nothing here yet", + g_mod_store.download_section == 0 ? "New downloads and installations appear here." + : g_mod_store.download_section == 1 ? "No paused or failed downloads match your search." + : "Completed installations appear here.", + width); + if (StoreButton("Browse mods", 0, true)) + g_mod_store.page = 0; + } + StoreMore("downloads-" + std::to_string(g_mod_store.download_section), matching, width); +} +void StoreRequestForm(float width, const dkr::store::Snapshot& store) { + StoreHeading("Requests", width); + if (store.user.is_null()) { + StoreNotice("Share a mod with the library", "Sign in with Discord to send a request or follow its progress.", + width); + if (StoreButton("Sign in with Discord", 0, true)) + dkr::store::service().login(); + return; + } + if (g_mod_store.request_picker) { + if (StoreBack("Back to request", [] { g_mod_store.request_picker = false; })) + return; + StoreHeading("Choose an existing listing", width, 20); + StoreField("request-listing-search", "Find a mod", std::min(width, 760.F), 200); + std::size_t matches = 0; + for (const auto& row : StoreItems(store)) { + if (StoreText(row, "category", 32) != kStoreCategoryIds[g_mod_store.request_category] || + !StoreMatches(StoreText(row, "title", 200), g_mod_store.fields["request-listing-search"])) + continue; + if (++matches > std::size_t(StoreLimit("request-listings"))) + continue; + if (StoreRow(StoreText(row, "id", 160), row, StoreText(row, "title", 200), StoreText(row, "author", 200), + "Choose", width)) { + g_mod_store.request_target = StoreText(row, "id", 160); + g_mod_store.request_picker = false; + } + } + if (!matches) + StoreNote("No listings match this category and search.", width); + StoreMore("request-listings", matches, width); + return; + } + g_mod_store.request_page = + StoreTabLine({"Request a mod", "My requests"}, g_mod_store.request_page, width, "request-pages"); + if (g_mod_store.request_page == 0) { + StoreFormLayout layout("request-layout", width, 760.F); + layout.sidebar([&](float side) { + StorePanel preview(side); + StoreHeading("Your submission", preview.inner(), 20); + if (!g_mod_store.fields["request-screenshot"].empty()) + StoreCover({{"image", g_mod_store.fields["request-screenshot"]}}, preview.inner()); + StoreHeading(g_mod_store.fields["request-title"].empty() ? "Mod title" + : g_mod_store.fields["request-title"], + preview.inner(), 18); + StoreNote(g_mod_store.fields["request-author"].empty() ? "Creator credit" + : "by " + g_mod_store.fields["request-author"], + preview.inner()); + StoreNote("Private request -> Staff review -> Library listing", preview.inner()); + }); + StorePanel form(layout.width); + const float inner = form.inner(); + ImGui::BeginDisabled(StoreBusyAction(store)); + PaddockSeparatorText("Mod details", inner); + g_mod_store.request_kind = + StoreCombo("I'm sharing", g_mod_store.request_kind, {"An existing mod", "A mod I created"}, inner); + StoreFieldPair("request-title", "Mod title", "request-author", "Original creator", inner); + const int category = + StoreCombo("Category", g_mod_store.request_category - 1, + {"Tracks", "Characters", "Vehicles", "Custom Magic Codes", "Texture packs"}, inner) + + 1; + if (category != g_mod_store.request_category) { + g_mod_store.request_category = category; + g_mod_store.request_target.clear(); + } + g_mod_store.request_relation = StoreCombo("Request type", g_mod_store.request_relation, + {"New library listing", "Update an existing listing"}, inner); + if (g_mod_store.request_relation) { + const auto* target = StoreFind(StoreItems(store), g_mod_store.request_target); + StoreNote(target ? StoreText(*target, "title", 200) : "Choose the listing to update.", inner, false); + if (StoreButton("Choose listing")) + g_mod_store.request_picker = true; + } + StoreField("request-summary", "Description and creator credits", inner, 4000, true); + PaddockSeparatorText("Links & requirements", inner); + StoreField("request-source", "Original source link (required)", inner, 1500); + StoreField("request-download", "Download link (optional)", inner, 1500); + StoreFieldPair("request-tested", "DKR-R version played", "request-screenshot", "Screenshot link (optional)", + inner, 80, 1500); + StoreField("request-requirements", "Requirements (optional)", inner, 2000, true); + StoreCheck("I have credited the original creator", g_mod_store.request_credit, inner); + const bool valid = !g_mod_store.fields["request-title"].empty() && + !g_mod_store.fields["request-author"].empty() && + !g_mod_store.fields["request-summary"].empty() && + g_mod_store.fields["request-source"].starts_with("https://") && g_mod_store.request_credit && + (!g_mod_store.request_relation || !g_mod_store.request_target.empty()); + if (!valid) + StoreNote( + "Add a title, creator, description and HTTPS source link. Confirm the creator credit before sending.", + inner); + ImGui::BeginDisabled(!valid); + if (StoreButton("Send for review", 0, true)) { + StoreJson input = {{"title", g_mod_store.fields["request-title"]}, + {"author", g_mod_store.fields["request-author"]}, + {"category", kStoreCategoryIds[g_mod_store.request_category]}, + {"sourceUrl", g_mod_store.fields["request-source"]}, + {"downloadUrl", g_mod_store.fields["request-download"]}, + {"summary", g_mod_store.fields["request-summary"]}, + {"requirements", g_mod_store.fields["request-requirements"]}, + {"testedVersion", g_mod_store.fields["request-tested"]}, + {"screenshotUrl", g_mod_store.fields["request-screenshot"]}, + {"kind", g_mod_store.request_kind ? "creator" : "suggestion"}, + {"relation", g_mod_store.request_relation ? "update" : "new"}}; + if (g_mod_store.request_relation) + input["targetId"] = g_mod_store.request_target; + StoreAction("request.submit", std::move(input)); + } + ImGui::EndDisabled(); + ImGui::EndDisabled(); + return; + } + const auto requests = store.personal.value("requests", StoreJson::array()); + if (!g_mod_store.my_request.empty()) { + if (StoreBack("Back to my requests", [] { g_mod_store.my_request.clear(); })) + return; + const auto* row = StoreFind(requests, g_mod_store.my_request); + if (!row) { + StoreNote("This request is no longer available.", width); + return; + } + const auto& request = *row; + StoreHeading(StoreText(request, "title", 200), width, 22); + StoreBadge(StoreText(request, "status", 50), width); + StoreNote(StoreText(request, "decisionMessage", 2400), width, false); + StoreNote(StoreText(request, "summary", 4000), width); + StoreHeading("Request history", width, 18); + if (request.contains("events")) + for (const auto& event : request["events"]) { + StorePanel entry(std::min(width, 960.F)); + StoreHeading(StoreText(event, "label", 200), entry.inner(), 17); + StoreNote(StoreText(event, "message", 3000), entry.inner()); + StoreNote(StoreDate(event, "createdAt"), entry.inner()); + } + if (StoreText(request, "status", 40) == "changes") { + const auto prefix = "resubmit-" + g_mod_store.my_request + "-"; + for (const char* key : {"sourceUrl", "downloadUrl", "screenshotUrl", "requirements", "testedVersion"}) + if (!g_mod_store.fields.contains(prefix + key)) + g_mod_store.fields[prefix + key] = StoreText(request, key, 2000); + StorePanel reply(std::min(width, 820.F)); + ImGui::BeginDisabled(StoreBusyAction(store)); + StoreField((prefix + "reply").c_str(), "Reply to the library team", reply.inner(), 2400, true); + StoreField((prefix + "sourceUrl").c_str(), "Updated source link", reply.inner(), 1500); + StoreField((prefix + "downloadUrl").c_str(), "Updated download link", reply.inner(), 1500); + StoreField((prefix + "requirements").c_str(), "Requirements", reply.inner(), 2000, true); + StoreFieldPair((prefix + "testedVersion").c_str(), "Version played", (prefix + "screenshotUrl").c_str(), + "Screenshot link", reply.inner(), 80, 1500); + if (StoreButton("Send updated request", 0, true)) + StoreAction("request.resubmit", {{"id", request["id"]}, + {"expectedRevision", StoreInt(request, "revision")}, + {"message", g_mod_store.fields[prefix + "reply"]}, + {"sourceUrl", g_mod_store.fields[prefix + "sourceUrl"]}, + {"downloadUrl", g_mod_store.fields[prefix + "downloadUrl"]}, + {"requirements", g_mod_store.fields[prefix + "requirements"]}, + {"testedVersion", g_mod_store.fields[prefix + "testedVersion"]}, + {"screenshotUrl", g_mod_store.fields[prefix + "screenshotUrl"]}}); + ImGui::EndDisabled(); + } + return; + } + StoreField("my-request-search", "Find a request", std::min(width, 760.F), 200); + std::size_t matching = 0; + for (const auto& row : requests) { + if (!StoreMatches(StoreText(row, "title", 200), g_mod_store.fields["my-request-search"])) + continue; + if (++matching > std::size_t(StoreLimit("my-requests"))) + continue; + if (StoreRow(StoreText(row, "id", 160), StoreJson::object(), StoreText(row, "title", 200), + StoreText(row, "status", 40) + " | " + StoreDate(row), "Open request", width)) + g_mod_store.my_request = StoreText(row, "id", 160); + } + if (!matching) + StoreNotice("No requests yet", "Use Request a mod to share something with the library team.", width); + StoreMore("my-requests", matching, width); +} diff --git a/runtime-recomp/src/game/store_player_details.inl b/runtime-recomp/src/game/store_player_details.inl new file mode 100644 index 00000000..3817ad5c --- /dev/null +++ b/runtime-recomp/src/game/store_player_details.inl @@ -0,0 +1,185 @@ +void StoreReviewForm(const StoreJson& row, float width, const dkr::store::Snapshot& store) { + const auto item = StoreText(row, "id", 160), prefix = "review-" + item; + if (store.user.is_null()) { + StoreNote("Sign in with Discord to leave a review.", width); + if (StoreButton("Sign in with Discord")) + dkr::store::service().login(); + return; + } + if (!g_mod_store.fields.contains(prefix + "-text")) { + if (StoreBusyAction(store) && !store.personal.contains("reviews")) { + StoreNote("Loading your review...", width); + return; + } + g_mod_store.fields[prefix + "-text"] = ""; + g_mod_store.fields[prefix + "-version"] = StoreText(row, "version", 80); + g_mod_store.fields[prefix + "-rating"] = "5"; + for (const auto& own : store.personal.value("reviews", StoreJson::array())) + if (StoreText(own, "itemId", 160) == item) { + g_mod_store.fields[prefix + "-text"] = StoreText(own, "text", 4000); + g_mod_store.fields[prefix + "-version"] = StoreText(own, "version", 80); + g_mod_store.fields[prefix + "-rating"] = std::to_string(std::clamp(StoreInt(own, "score", 5), 1, 5)); + } + } + StorePanel form(std::min(width, 820.F)); + const auto inner = form.inner(); + StoreHeading("Your review", inner, 20); + ImGui::PushID(prefix.c_str()); + ImGui::BeginDisabled(StoreBusyAction(store)); + auto& rating = g_mod_store.fields[prefix + "-rating"]; + StoreNote("Rating", inner); + const int selected = rating.empty() ? 4 : std::clamp(rating[0] - '1', 0, 4); + rating = std::to_string(StoreTabLine({"1 star", "2 stars", "3 stars", "4 stars", "5 stars"}, + selected, inner, "review-rating") + 1); + StoreField((prefix + "-text").c_str(), "Review", inner, 4000, true, true); + StoreField((prefix + "-version").c_str(), "Version played", std::min(inner, 340.F), 80, false, true); + if (StoreButton("Save review", 0, true)) { + StoreAction("review.save", {{"itemId", item}, + {"score", std::clamp(rating[0] - '0', 1, 5)}, + {"text", g_mod_store.fields[prefix + "-text"]}, + {"version", g_mod_store.fields[prefix + "-version"]}}); + g_mod_store.review_editing = false; + } + ImGui::EndDisabled(); + if (StoreButton("Keep for later")) + g_mod_store.review_editing = false; + ImGui::PopID(); +} + +void StoreDetails(const StoreJson& row, float width, const dkr::store::Snapshot& store, bool inspect) { + if (!inspect && StoreBack("Back to browse", [] { g_mod_store.detail = false; })) + return; + const auto id = StoreText(row, "id", 160); + const float outer = width; + const bool hero = + !inspect && width >= 840.F && ImGui::BeginTable("detail-hero", 2, ImGuiTableFlags_NoSavedSettings, {width, 0}); + if (hero) { + ImGui::TableSetupColumn("Art", ImGuiTableColumnFlags_WidthStretch, 1.2F); + ImGui::TableSetupColumn("Release", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextColumn(); + StoreCover(row, ImGui::GetContentRegionAvail().x, 220.F); + ImGui::TableNextColumn(); + } else + StoreCover(row, width, std::min(190.F, width * .45F)); + { + StorePanel summary(hero ? ImGui::GetContentRegionAvail().x : width); + const float inner = summary.inner(); + StoreHeading(StoreText(row, "title", 200), inner, 23); + StoreNote("by " + StoreText(row, "author", 200) + " | " + StoreCategory(row), inner); + StoreNote(StoreRating(row), inner); + StoreNote("Version " + StoreText(row, "version", 80) + " | " + StoreDate(row), inner); + StoreDownloadButton(row, StoreCurrentRelease(row), inner); + } + if (hero) + ImGui::EndTable(); + width = std::min(1120.F, outer); + g_mod_store.detail_tab = + StoreTabLine({"Overview", "Reviews", "Install help", "Releases"}, g_mod_store.detail_tab, width, "mod-details"); + if (g_mod_store.detail_tab == 0) { + const auto description = StoreText(row, "description", 16000); + StoreNote(description.empty() ? StoreText(row, "summary", 4000) : description, width, false); + const auto requirements = StoreText(row, "requirements", 2000); + if (!requirements.empty()) { + StoreHeading("Requirements", width, 18); + StoreNote(requirements, width); + } + if (row.contains("gallery") && row["gallery"].is_array() && !row["gallery"].empty()) { + StoreHeading("Screenshots", width, 18); + const int columns = std::max(1, std::min(4, int(width / 180.F))); + const float thumb = std::min(230.F, (width - (columns - 1) * 12.F) / columns); + if (ImGui::BeginTable("gallery-thumbnails", columns, ImGuiTableFlags_NoSavedSettings, {width, 0})) { + int shown = 0; + for (std::size_t i = 0; i < row["gallery"].size() && shown < columns; ++i) { + if (!row["gallery"][i].is_string()) + continue; + ++shown; + ImGui::TableNextColumn(); + ImGui::PushID(int(i)); + StoreCover({{"image", row["gallery"][i]}}, thumb, thumb * .56F); + if (StoreButton("View image", thumb)) { + g_mod_store.gallery = StoreJson::array(); + for (const auto& image : row["gallery"]) + if (image.is_string()) + g_mod_store.gallery.push_back(image); + g_mod_store.gallery_index = shown - 1; + g_mod_store.gallery_title = StoreText(row, "title", 200); + StoreOpenPopup("Mod gallery"); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + StoreNote(std::to_string(row["gallery"].size()) + " screenshots - open the gallery to see all.", width); + } + if (row.contains("related") && row["related"].is_array()) + for (const auto& related : row["related"]) + if (related.is_string()) + if (const auto* companion = StoreFind(StoreItems(store), related.get())) + if (StoreRow(StoreText(*companion, "id", 160), *companion, StoreText(*companion, "title", 200), + "Companion download - installed separately", "View mod", width)) + StoreOpenMod(*companion, !inspect); + } else if (g_mod_store.detail_tab == 1) { + StoreActionLine({{"Refresh reviews", [id] { dkr::store::service().reviews(id); }}, + {store.user.is_null() ? "Sign in to review" : "Write / edit review", + [&store] { + if (store.user.is_null()) + dkr::store::service().login(); + else + g_mod_store.review_editing = true; + }}}, + width); + if (g_mod_store.review_editing) { + StoreReviewForm(row, width, store); + return; + } + const auto reviews = store.reviews.contains(id) ? store.reviews[id] : StoreJson::array(); + if (reviews.empty()) + StoreNote("No reviews yet.", width); + int shown = 0; + for (const auto& review : reviews) { + if (++shown > StoreLimit("reviews-" + id)) + break; + ImGui::PushID(StoreText(review, "id", 160).c_str()); + StorePanel panel(width); + StoreHeading(StoreText(review, "displayName", 100) + " | " + std::to_string(StoreInt(review, "score")) + + " / 5", + panel.inner(), 18); + StoreNote(StoreText(review, "text", 4000), panel.inner(), false); + StoreNote(StoreDate(review, "createdAt") + " | Version " + StoreText(review, "version", 80), + panel.inner()); + if (!store.user.is_null() && StoreButton("Report")) { + g_mod_store.report = StoreText(review, "id", 160); + StoreOpenPopup("Report a review"); + } + ImGui::PopID(); + } + StoreMore("reviews-" + id, reviews.size(), width); + } else if (g_mod_store.detail_tab == 2) { + const auto install = StoreText(row, "install", 6000); + StoreNote(install.empty() ? "Choose Download & install. DKR-R handles the package for you." : install, width); + StoreNote("Code mods still need your permission. Patch imports may need your original ROM.", width); + } else if (row.contains("releases") && row["releases"].is_array()) { + int shown = 0; + const auto current = StoreText(StoreCurrentRelease(row), "id", 160); + for (const auto& release : row["releases"]) { + if (++shown > StoreLimit("releases-" + id)) + break; + ImGui::PushID(StoreText(release, "id", 160).c_str()); + StorePanel panel(width); + StoreHeading("Version " + StoreText(release, "version", 80), panel.inner(), 18); + StoreBadge(StoreText(release, "id", 160) == current ? "Current release" : "Older / alternate release", + panel.inner()); + StoreNote(StoreDate(release, "createdAt") + " | " + StoreText(release, "format", 40) + " | " + + StoreSize(release.value("size", std::uint64_t(0))), + panel.inner()); + if (StoreButton("Release information")) { + g_mod_store.release_details = release; + g_mod_store.release_title = StoreText(row, "title", 200); + StoreOpenPopup("Release information"); + } + StoreDownloadButton(row, release, std::min(panel.inner(), 300.F)); + ImGui::PopID(); + } + StoreMore("releases-" + id, row["releases"].size(), width); + } +} diff --git a/runtime-recomp/src/game/store_player_library.inl b/runtime-recomp/src/game/store_player_library.inl new file mode 100644 index 00000000..69a14900 --- /dev/null +++ b/runtime-recomp/src/game/store_player_library.inl @@ -0,0 +1,361 @@ +// Compact rows shared by player and staff pages. One scroll surface belongs to +// the launcher; selecting a row opens a dedicated, restorable detail screen. +bool StoreRow(const std::string& id, const StoreJson& item, const std::string& title, const std::string& meta, + const char* action, float width, bool selected = false, bool* choice = nullptr) { + ImGui::PushID(id.c_str()); + StorePanel panel(width, selected); + const float inner = panel.inner(); + bool pressed = false; + if (inner >= 530.F && ImGui::BeginTable("row", 3, ImGuiTableFlags_NoSavedSettings, {inner, 0})) { + ImGui::TableSetupColumn("Art", ImGuiTableColumnFlags_WidthFixed, 72.F); + ImGui::TableSetupColumn("Mod", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, + std::max(130.F, PaddockButtonWidth(action, PaddockButtonKind::Plain))); + ImGui::TableNextColumn(); + StoreCover(item, 64.F, 52.F); + ImGui::TableNextColumn(); + StoreHeading(title, ImGui::GetContentRegionAvail().x, 18.F); + StoreNote(meta, ImGui::GetContentRegionAvail().x); + ImGui::TableNextColumn(); + if (choice) + StoreCheck("Select", *choice, ImGui::GetContentRegionAvail().x); + pressed = StoreButton(action, ImGui::GetContentRegionAvail().x); + g_mod_store.last_row_action = ImGui::GetItemID(); + ImGui::EndTable(); + } else { + StoreHeading(title, inner, 18.F); + StoreNote(meta, inner); + if (choice) + StoreCheck("Select", *choice, inner); + pressed = StoreButton(action); + g_mod_store.last_row_action = ImGui::GetItemID(); + } + ImGui::PopID(); + return pressed; +} +const StoreJson* StoreBy(const StoreJson& rows, const char* field, const std::string& value) { + if (rows.is_array()) + for (const auto& row : rows) + if (StoreText(row, field, 1024) == value) + return &row; + return nullptr; +} +int StoreLocalCategory(const StoreJson& local) { + const auto kind = StoreText(local, "kind", 80); + return kind == "track" || kind == "legacy-track" ? 1 + : kind == "legacy-character" ? 2 + : kind == "code" ? 4 + : kind == "texture" ? 5 + : 3; +} +void StoreInstalledPage(float width, const dkr::store::Snapshot& store, bool locked) { + const auto view = dkr::store::installations().view(); + width = std::min(width, 1280.F); + if (!g_mod_store.installed_selected.empty()) { + const StoreJson* record = nullptr; + for (const auto& row : view->records) + if (StoreText(row, "instance", 32) == g_mod_store.installed_selected) { + record = &row; + break; + } + if (StoreBack("Back to installed mods", [] { g_mod_store.installed_selected.clear(); })) + return; + if (!record) { + StoreNotice("Installation changed", "Return to Installed to choose another mod.", width); + return; + } + const auto& row = *record; + const auto instance = StoreText(row, "instance", 32); + const auto* item = StoreFind(StoreItems(store), StoreText(row, "item", 160)); + StoreHeading(StoreText(row, "title", 200), width); + StoreBadge(StoreText(row, "state", 80) + " | " + StoreText(row, "version", 80), width); + if (row.value("anotherTrackCopyActive", false)) + StoreNotice("Another copy is active", + "This copy is installed separately. Your other copy has not been changed.", width); + for (const auto& warning : row.value("warnings", StoreJson::array())) + if (warning.is_string()) + StoreNotice("Needs attention", warning.get(), width, true); + ImGui::BeginDisabled(locked || view->busy); + StorePanel settings(std::min(width, 820.F)); + bool follow = row.value("follow", true); + if (StoreCheck("Notify me about updates", follow, settings.inner())) + dkr::store::installations().policy(instance, follow); + std::vector>> actions; + if (row.value("stored", false) && row.contains("file")) + actions.push_back({"Use this edition", [instance] { + dkr::store::installations().activate(instance, false, StoreOriginalRoms()); + }}); + if (item) + if (const auto* release = StoreInstalledRelease(*item, row)) { + const auto state = StoreText(row, "state", 80); + actions.push_back({state == "Missing" || state == "Needs repair" ? "Repair installation" : "Reinstall", + [item = *item, release = *release, instance] { + StoreQueueRelease(item, release, "reinstall", instance); + }}); + } + if (row.contains("previous") && row["previous"].contains("file")) + actions.push_back({"Restore previous version", [instance] { + dkr::store::installations().activate(instance, true, StoreOriginalRoms()); + }}); + StoreActionLine(actions, settings.inner()); + PaddockSeparatorText("Remove or unlink", settings.inner()); + StoreNote("Remove this copy, or keep its files and stop linking it to the Store.", settings.inner()); + if (StoreButton("Remove or unlink")) { + g_mod_store.remove_instance = instance; + g_mod_store.remove_title = StoreText(row, "title", 200); + g_mod_store.remove_requested = true; + } + ImGui::EndDisabled(); + return; + } + StoreHeading("Installed mods", width); + StoreField("installed-search", "Find a mod", std::min(width, 760.F), 200); + const bool filters = + width >= 800.F && ImGui::BeginTable("installed-filters", 3, ImGuiTableFlags_NoSavedSettings, {width, 0}); + if (filters) + ImGui::TableNextColumn(); + g_mod_store.installed_category = StoreCombo( + "Category", g_mod_store.installed_category, + {"All categories", "Tracks", "Characters", "Vehicles", "Custom Magic Codes", "Texture packs"}, + std::min(width, 340.F)); + if (filters) + ImGui::TableNextColumn(); + g_mod_store.installed_state = + StoreCombo("Show", g_mod_store.installed_state, + {"All installations", "Installed", "Saved editions", "Needs attention", "Not linked to Store"}, + std::min(width, 340.F)); + if (filters) + ImGui::TableNextColumn(); + g_mod_store.installed_sort = + StoreCombo("Sort", g_mod_store.installed_sort, {"Name A-Z", "Name Z-A", "Status"}, std::min(width, 340.F)); + if (filters) + ImGui::EndTable(); + const auto query = g_mod_store.fields["installed-search"]; + std::vector rows; + for (const auto& record : view->records) { + if (g_mod_store.installed_state == 4 || !StoreMatches(StoreText(record, "title", 200), query)) + continue; + const auto* item = StoreFind(StoreItems(store), StoreText(record, "item", 160)); + if (g_mod_store.installed_category && + (item ? StoreText(*item, "category", 32) : StoreText(record, "category", 32)) != + kStoreCategoryIds[g_mod_store.installed_category]) + continue; + const auto state = StoreText(record, "state", 80); + if (g_mod_store.installed_state == 1 && (state != "Installed" || record.value("stored", false))) + continue; + if (g_mod_store.installed_state == 2 && !record.value("stored", false)) + continue; + if (g_mod_store.installed_state == 3 && state != "Needs repair" && state != "Missing" && + record.value("warnings", StoreJson::array()).empty()) + continue; + rows.push_back(&record); + } + std::stable_sort(rows.begin(), rows.end(), [](auto a, auto b) { + if (g_mod_store.installed_sort == 2 && StoreText(*a, "state", 80) != StoreText(*b, "state", 80)) + return StoreText(*a, "state", 80) < StoreText(*b, "state", 80); + const auto left = StoreLower(StoreText(*a, "title", 200)), right = StoreLower(StoreText(*b, "title", 200)); + return g_mod_store.installed_sort == 1 ? left > right : left < right; + }); + int shown = 0; + for (const auto* record : rows) { + if (++shown > StoreLimit("installed")) + break; + const auto* item = StoreFind(StoreItems(store), StoreText(*record, "item", 160)); + if (StoreRow(StoreText(*record, "instance", 32), item ? *item : StoreJson::object(), + StoreText(*record, "title", 200), + StoreText(*record, "version", 80) + " | " + StoreText(*record, "state", 80), "Manage", width)) + g_mod_store.installed_selected = StoreText(*record, "instance", 32); + } + StoreMore("installed", rows.size(), width); + if (rows.empty() && g_mod_store.installed_state != 4) + StoreNote("No linked installations match these filters.", width); + if (g_mod_store.installed_state != 0 && g_mod_store.installed_state != 4) + return; + StoreHeading("Other installed mods", width, 20); + std::vector unlinked; + for (const auto& local : view->unlinked) + if (StoreMatches(StoreText(local, "name", 200), query) && + (!g_mod_store.installed_category || StoreLocalCategory(local) == g_mod_store.installed_category)) + unlinked.push_back(&local); + shown = 0; + for (const auto* local : unlinked) { + if (++shown > StoreLimit("unlinked")) + break; + ImGui::BeginDisabled(locked || view->busy); + if (StoreRow(StoreText(*local, "key", 512), StoreJson::object(), StoreText(*local, "name", 200), + "Installed locally | Not linked to Store updates", "Link to Store", width)) { + g_mod_store.link_key = StoreText(*local, "key", 512); + g_mod_store.link_requested = true; + } + ImGui::EndDisabled(); + } + StoreMore("unlinked", unlinked.size(), width); + if (unlinked.empty()) + StoreNote("No unlinked mods match these filters.", width); +} +void StoreUpdatesPage(float width, const dkr::store::Snapshot& store, bool locked) { + const auto view = dkr::store::installations().view(); + width = std::min(width, 1280.F); + StoreHeading("Mod updates", width); + if (!g_mod_store.update_detail.empty() && StoreBack("Back to updates", [] { g_mod_store.update_detail.clear(); })) + return; + ImGui::BeginDisabled(store.loading); + if (StoreButton(store.loading ? "Checking..." : "Check for updates")) + dkr::store::service().refresh(); + ImGui::EndDisabled(); + if (view->updates.empty()) { + StoreNotice(store.loading ? "Checking the library" : "You're up to date", + "Updates for linked mods appear here.", width); + return; + } + if (!g_mod_store.update_detail.empty() && + std::none_of(view->updates.begin(), view->updates.end(), + [](const auto& row) { return StoreText(row, "instance", 32) == g_mod_store.update_detail; })) { + StoreNotice("This update is no longer pending", "Return to Updates to review the current list.", width); + return; + } + std::set available; + for (const auto& update : view->updates) + if (!update.value("downgrade", false)) + available.insert(StoreText(update, "instance", 32)); + std::erase_if(g_mod_store.selected_updates, [&](const auto& id) { return !available.contains(id); }); + ImGui::BeginDisabled(locked || view->busy); + if (g_mod_store.update_detail.empty()) { + StoreActionLine({{"Select all", [available] { g_mod_store.selected_updates = available; }}, + {"Clear selection", [] { g_mod_store.selected_updates.clear(); }}}, + width); + const auto count = g_mod_store.selected_updates.size(); + ImGui::BeginDisabled(count == 0); + if (StoreButton("Update selected (" + std::to_string(count) + ")", 0, true)) + for (const auto& update : view->updates) + if (!update.value("downgrade", false) && + g_mod_store.selected_updates.contains(StoreText(update, "instance", 32))) + StoreQueueRelease(update["item"], update["release"], "update", StoreText(update, "instance", 32)); + ImGui::EndDisabled(); + if (StoreButton("Update all")) + for (const auto& update : view->updates) + if (!update.value("downgrade", false)) + StoreQueueRelease(update["item"], update["release"], "update", StoreText(update, "instance", 32)); + } + int shown = 0; + for (const auto& update : view->updates) { + if (++shown > StoreLimit("updates") && g_mod_store.update_detail.empty()) + break; + const auto instance = StoreText(update, "instance", 32); + if (g_mod_store.update_detail.empty()) { + bool selected = g_mod_store.selected_updates.contains(instance); + const bool downgrade = update.value("downgrade", false); + if (StoreRow(instance, update["item"], StoreText(update, "title", 200), + StoreText(update, "current", 80) + " -> " + StoreText(update["release"], "version", 80) + + " | " + + (downgrade ? "Older publisher release" + : StoreSize(update["release"].value("size", std::uint64_t(0)))), + "Review update", width, selected, downgrade ? nullptr : &selected)) + g_mod_store.update_detail = instance; + if (selected && !downgrade) + g_mod_store.selected_updates.insert(instance); + else + g_mod_store.selected_updates.erase(instance); + continue; + } + if (instance != g_mod_store.update_detail) + continue; + ImGui::PushID(instance.c_str()); + StorePanel panel(width); + const float inner = panel.inner(); + StoreHeading(StoreText(update, "title", 200), inner, 18); + const auto release = update["release"]; + StoreNote(StoreText(update, "current", 80) + " -> " + StoreText(release, "version", 80) + " | " + + StoreSize(release.value("size", std::uint64_t(0))), + inner); + const bool downgrade = update.value("downgrade", false); + if (!downgrade) { + bool selected = g_mod_store.selected_updates.contains(instance); + if (StoreCheck("Select update", selected, inner)) { + if (selected) + g_mod_store.selected_updates.insert(instance); + else + g_mod_store.selected_updates.erase(instance); + } + } else + StoreNote("Publisher selected an older release. Bulk updates will skip this mod.", inner, false); + if (update.value("codePermission", false)) + StoreNote("New code needs your approval before it can run.", inner); + StoreActionLine({{"Release notes", + [release, update] { + g_mod_store.release_details = release; + g_mod_store.release_title = StoreText(update, "title", 200); + StoreOpenPopup("Release information"); + }}, + {downgrade ? "Review older release" : "Update", + [update, instance, downgrade] { + if (downgrade) { + g_mod_store.release_details = update["release"]; + g_mod_store.update_selected = instance; + StoreOpenPopup("Use older mod release?"); + } else + StoreQueueRelease(update["item"], update["release"], "update", instance); + }}, + {"Skip release", + [instance, release] { + dkr::store::installations().policy(instance, true, StoreText(release, "id", 160)); + }}, + {"Keep my version", [instance] { dkr::store::installations().policy(instance, false); }}}, + inner); + ImGui::PopID(); + } + ImGui::EndDisabled(); + if (g_mod_store.update_detail.empty()) + StoreMore("updates", view->updates.size(), width); +} +void StoreDuplicatesPage(float width, const dkr::store::Snapshot&, bool locked) { + const auto view = dkr::store::installations().view(); + width = std::min(width, 1280.F); + StoreHeading("Duplicates", width); + StoreNote("Compare copies before removing one. Matching names do not prove matching content.", width); + StoreField("duplicates-search", "Find a duplicate", std::min(width, 760.F), 200); + std::size_t matching = 0; + for (const auto& group : view->duplicates) { + bool match = false; + for (const auto& row : group["entries"]) + if (StoreMatches(StoreText(row, "name", 200), g_mod_store.fields["duplicates-search"])) + match = true; + if (!match) + continue; + if (++matching > std::size_t(StoreLimit("duplicates"))) + continue; + ImGui::PushID(StoreText(group, "identity", 1024).c_str()); + StorePanel panel(width); + const float inner = panel.inner(); + StoreHeading(group.value("exact", false) ? "Matching content" : "Similar names - compare carefully", inner, 18); + for (const auto& entry : group["entries"]) { + ImGui::PushID(StoreText(entry, "key", 512).c_str()); + StoreHeading(StoreText(entry, "name", 200), inner, 17); + StoreNote(std::string(entry.value("enabled", false) ? "Enabled" : "Disabled") + " | " + + kStoreCategoryNames[StoreLocalCategory(entry)] + + (entry.contains("version") ? " | " + StoreText(entry, "version", 80) : ""), + inner); + const auto path = StoreText(entry, "path", 4096); + if (!path.empty()) { + StoreNote(path, inner); + if (StoreButton("Copy location")) + SDL_SetClipboardText(path.c_str()); + } + if (entry.contains("priority")) + StoreNote("Texture priority: " + std::to_string(entry.value("priority", 0)), inner); + ImGui::BeginDisabled(locked || view->busy); + if (StoreButton("Remove this copy")) { + g_mod_store.remove_instance = "local:" + StoreText(entry, "key", 512); + g_mod_store.remove_title = StoreText(entry, "name", 200) + "\n" + path; + g_mod_store.remove_requested = true; + } + ImGui::EndDisabled(); + PaddockGap(10.F); + ImGui::PopID(); + } + ImGui::PopID(); + } + if (!matching) + StoreNotice("No duplicates found", "No matching copies in the current library inventory.", width); + StoreMore("duplicates", matching, width); +} diff --git a/runtime-recomp/src/game/store_polish_controls.inl b/runtime-recomp/src/game/store_polish_controls.inl new file mode 100644 index 00000000..f77d7a94 --- /dev/null +++ b/runtime-recomp/src/game/store_polish_controls.inl @@ -0,0 +1,186 @@ +// UI-only helpers. The installer, ownership receipts and server authority remain +// the sole source of truth for writes; these helpers only arrange their controls. +bool StoreBeginManagerModal(const char*, float, float); +bool StoreModalBack(); +void StoreModalBody(const char*, float, std::initializer_list); +void StoreOpenPopup(const char* name) { + g_mod_store.popup_request = name; +} +void StoreConfirm(std::string title, std::string message, std::string label, std::function action) { + g_mod_store.confirm_title = std::move(title); + g_mod_store.confirm_message = std::move(message); + g_mod_store.confirm_label = std::move(label); + g_mod_store.confirm_identity = g_mod_store.identity; + g_mod_store.confirmation = std::move(action); + StoreOpenPopup("Confirm Store action"); +} +std::string StoreRoute() { + return std::to_string(g_mod_store.page) + ":" + std::to_string(g_mod_store.installed_section) + ":" + + std::to_string(g_mod_store.staff_tab) + ":" + std::to_string(g_mod_store.detail_tab) + ":" + + std::to_string(g_mod_store.detail) + ":" + std::to_string(g_mod_store.editing) + ":" + + std::to_string(g_mod_store.editor_step) + ":" + std::to_string(g_mod_store.preview) + ":" + + std::to_string(g_mod_store.related_open) + ":" + std::to_string(g_mod_store.request_page) + ":" + + std::to_string(g_mod_store.account_page) + ":" + g_mod_store.installed_selected + ":" + + std::to_string(g_mod_store.request_picker) + ":" + std::to_string(g_mod_store.review_editing) + ":" + + std::to_string(g_mod_store.download_section) + ":" + g_mod_store.update_detail + ":" + + g_mod_store.my_request + ":" + g_mod_store.staff_report + ":" + + (g_mod_store.detail ? g_mod_store.selected : "") + ":" + g_mod_store.request + ":" + + g_mod_store.upload_selection; +} +void StoreNavigate(std::function action) { + if (g_mod_store.editing && g_mod_store.dirty) { + g_mod_store.pending_navigation = std::move(action); + StoreOpenPopup("Leave unsaved listing?"); + } else + action(); +} +bool StoreBack(const char* label, const std::function& action, bool protect = false) { + const bool back = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::GetIO().WantTextInput && + (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight) || ImGui::IsKeyPressed(ImGuiKey_Escape)); + if (StoreButton(label) || back) { + if (protect) + StoreNavigate(action); + else + action(); + return true; + } + PaddockGap(8.F); + return false; +} +void StoreAction(const std::string& name, StoreJson input) { + g_mod_store.message_page = std::to_string(g_mod_store.page); + if (name.starts_with("catalogue.") || name == "request.publish") + g_mod_store.pending_editor_generation = g_mod_store.editor_generation; + dkr::store::service().action(name, std::move(input)); +} +void StoreSyncIdentity(const dkr::store::Snapshot& store) { + const auto identity = StoreText(store.user, "id", 160) + ":" + StoreText(store.user, "role", 32); + if (identity == g_mod_store.identity) + return; + const auto search = g_mod_store.fields["search"]; + g_mod_store.fields.clear(); + g_mod_store.fields["search"] = search; + g_mod_store.fields["profile-name"] = StoreText(store.user, "displayName", 60); + g_mod_store.identity = identity; + g_mod_store.request.clear(); + g_mod_store.editor.clear(); + g_mod_store.report.clear(); + g_mod_store.my_request.clear(); + g_mod_store.staff_report.clear(); + g_mod_store.confirmation = {}; + g_mod_store.pending_navigation = {}; + g_mod_store.popup_request.clear(); + g_mod_store.draft = StoreJson::object(); + g_mod_store.editing = false; + g_mod_store.dirty = false; + g_mod_store.previewed = false; + g_mod_store.related_open = false; + g_mod_store.picker_upload = false; + g_mod_store.request_picker = false; + g_mod_store.review_editing = false; + g_mod_store.request_credit = false; + g_mod_store.request_target.clear(); + g_mod_store.install_after_download.clear(); + g_mod_store.positions.clear(); + g_mod_store.nav_screen.clear(); + ++g_mod_store.editor_generation; + if (g_mod_store.page >= 7 && !dkr::store::staff(store.user)) + g_mod_store.page = 3; +} +bool StoreBusyAction(const dkr::store::Snapshot& store) { + return store.writing || store.loading; +} +void StoreFooter(const std::string& primary, bool disabled, const std::function& action, float width) { + const bool paired = PaddockButtonWidth(primary.c_str(), PaddockButtonKind::Primary) + + PaddockButtonWidth("Cancel", PaddockButtonKind::Plain) + 8.F <= + width; + ImGui::BeginDisabled(disabled); + if (StoreButton(primary, 0, true)) + action(); + ImGui::EndDisabled(); + if (paired) + ImGui::SameLine(0, 8.F); + if (StoreButton("Cancel") || StoreModalBack()) { + g_mod_store.confirmation = {}; + ImGui::CloseCurrentPopup(); + } + if (ImGui::IsWindowAppearing()) + ImGui::SetItemDefaultFocus(); +} +bool StoreMatches(const std::string& value, const std::string& query) { + const auto lower = [](std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + return text; + }; + return query.empty() || lower(value).find(lower(query)) != std::string::npos; +} +std::string StoreSize(std::uint64_t bytes) { + if (!bytes) + return "Size not supplied"; + char value[80]; + std::snprintf(value, sizeof(value), bytes >= 1048576 ? "%.1f MiB" : "%.0f KiB", + double(bytes) / (bytes >= 1048576 ? 1048576. : 1024.)); + return value; +} +bool StoreTransferActive(const dkr::store::Download& job) { + return job.state == "Queued" || job.state == "Preparing download" || job.state == "Downloading" || + job.state == "Verifying" || job.state == "Installing"; +} +int& StoreLimit(const std::string& key) { + auto [it, inserted] = g_mod_store.limits.try_emplace(key, 24); + return it->second; +} +void StoreMore(const std::string& key, std::size_t total, float width) { + auto& limit = StoreLimit(key); + StoreNote(std::to_string(std::min(total, limit)) + " of " + std::to_string(total), width); + const auto previous_row = g_mod_store.last_row_action; + if (total > std::size_t(limit) && StoreButton("Show more##" + key)) { + limit += 24; + if (previous_row) { + ImGui::SetFocusID(previous_row, ImGui::GetCurrentWindow()); + g_mod_store.page_focus = previous_row; + } + } +} +void StoreBeginBodyNavigation() { + g_mod_store.body_navigation = true; + g_mod_store.last_row_action = 0; + const auto screen = StoreRoute(); + if (screen != g_mod_store.nav_screen) { + g_mod_store.nav_screen = screen; + const auto saved = g_mod_store.positions[screen]; + ImGui::SetScrollY(saved.scroll); + g_mod_store.restore_focus = saved.focus; + g_mod_store.focus_first = saved.focus == 0; + g_mod_store.restoring = true; + g_mod_store.page_focus = saved.focus; + } +} +void StoreEndBodyNavigation() { + g_mod_store.body_navigation = false; + if (StoreRoute() == g_mod_store.nav_screen) { + auto& saved = g_mod_store.positions[g_mod_store.nav_screen]; + if (!g_mod_store.restoring) + saved.scroll = ImGui::GetScrollY(); + if (g_mod_store.page_focus) + saved.focus = g_mod_store.page_focus; + } + // If a previously focused row was removed, focus the first available control + // on the next frame instead of trapping navigation on a missing item ID. + if (g_mod_store.restore_focus) { + g_mod_store.restore_focus = 0; + g_mod_store.focus_first = true; + } + g_mod_store.restoring = false; +} +void StoreMessage(const std::string& title, const std::string& text, float width) { + if (text.empty()) + return; + StorePanel panel(std::min(width, 1000.F), true); + StoreHeading(title, panel.inner(), 18); + StoreNote(text, panel.inner(), false); + StoreActionLine({{"Copy message", [text] { SDL_SetClipboardText(text.c_str()); }}, + {"Dismiss", [] { dkr::store::service().clear_messages(); }}}, + panel.inner()); +} diff --git a/runtime-recomp/src/game/store_polish_modals.inl b/runtime-recomp/src/game/store_polish_modals.inl new file mode 100644 index 00000000..8b6e6dc7 --- /dev/null +++ b/runtime-recomp/src/game/store_polish_modals.inl @@ -0,0 +1,163 @@ +void StorePolishModals() { + if (!g_mod_store.popup_request.empty()) { + ImGui::OpenPopup(g_mod_store.popup_request.c_str()); + g_mod_store.popup_request.clear(); + } + if (StoreBeginManagerModal("Report a review", 720.F, 440.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Report form", width, {"Send report", "Cancel"}); + StoreHeading("Report this review", ImGui::GetContentRegionAvail().x, 20); + const auto key = "report-" + g_mod_store.report; + StoreField(key.c_str(), "Reason", ImGui::GetContentRegionAvail().x, 1600, true); + ImGui::EndChild(); + const auto store = dkr::store::service().view(); + StoreFooter( + "Send report", + store->user.is_null() || StoreBusyAction(*store) || g_mod_store.report.empty() || + g_mod_store.fields[key].empty(), + [&] { + StoreAction("review.report", {{"reviewId", g_mod_store.report}, {"reason", g_mod_store.fields[key]}}); + ImGui::CloseCurrentPopup(); + }, + width); + ImGui::EndPopup(); + } + if (StoreBeginManagerModal("Delete your library account?", 680.F, 440.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Account deletion", width, {"Delete account", "Cancel"}); + StoreHeading("Delete your library account?", ImGui::GetContentRegionAvail().x, 20); + StoreNote("Your library account and personal data will be deleted. Installed mods and your Discord account are " + "kept. Type DELETE to confirm.", + ImGui::GetContentRegionAvail().x); + StoreField("account-delete", "Confirmation", ImGui::GetContentRegionAvail().x, 6); + ImGui::EndChild(); + const auto store = dkr::store::service().view(); + StoreFooter( + "Delete account", + store->user.is_null() || StoreBusyAction(*store) || g_mod_store.fields["account-delete"] != "DELETE", + [] { + StoreAction("account.delete", {{"confirmation", "DELETE"}}); + ImGui::CloseCurrentPopup(); + }, + width); + ImGui::EndPopup(); + } + if (StoreBeginManagerModal("Confirm Store action", 680.F, 380.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Action summary", width, {g_mod_store.confirm_label.c_str(), "Cancel"}); + StoreHeading(g_mod_store.confirm_title, ImGui::GetContentRegionAvail().x, 21); + StoreNote(g_mod_store.confirm_message, ImGui::GetContentRegionAvail().x, false); + ImGui::EndChild(); + StoreFooter( + g_mod_store.confirm_label.empty() ? "Confirm" : g_mod_store.confirm_label, + !g_mod_store.confirmation || g_mod_store.confirm_identity != g_mod_store.identity || + StoreBusyAction(*dkr::store::service().view()), + [] { + auto action = std::move(g_mod_store.confirmation); + ImGui::CloseCurrentPopup(); + if (action) + action(); + }, + width); + ImGui::EndPopup(); + } + if (StoreBeginManagerModal("Use older mod release?", 700.F, 380.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Older release", width, {"Use this release", "Cancel"}); + StoreHeading("Use the publisher's older release?", ImGui::GetContentRegionAvail().x, 20); + StoreNote("This replaces your selected installation with the older release. Bulk updates never do this " + "automatically.", + ImGui::GetContentRegionAvail().x); + ImGui::EndChild(); + const auto view = dkr::store::installations().view(); + const StoreJson* update = nullptr; + for (const auto& row : view->updates) + if (StoreText(row, "instance", 32) == g_mod_store.update_selected && + row["release"] == g_mod_store.release_details) + update = &row; + StoreFooter( + "Use this release", !update || view->busy || dkr::runtime::netplay::session().active(), + [&] { + StoreQueueRelease((*update)["item"], (*update)["release"], "update", g_mod_store.update_selected); + ImGui::CloseCurrentPopup(); + }, + width); + ImGui::EndPopup(); + } + if (StoreBeginManagerModal("Leave unsaved listing?", 640.F, 300.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Unsaved listing", width, {"Keep editing", "Discard changes"}); + StoreHeading("Keep your changes?", ImGui::GetContentRegionAvail().x); + StoreNote("This listing has unsaved changes. Keep editing, or discard them before leaving.", + ImGui::GetContentRegionAvail().x); + ImGui::EndChild(); + StoreActionLine({{"Keep editing", + [] { + g_mod_store.pending_navigation = {}; + ImGui::CloseCurrentPopup(); + }}, + {"Discard changes", + [] { + auto action = std::move(g_mod_store.pending_navigation); + g_mod_store.dirty = false; + g_mod_store.editing = false; + ++g_mod_store.editor_generation; + ImGui::CloseCurrentPopup(); + if (action) + action(); + }}}, + width); + if (StoreModalBack()) { + g_mod_store.pending_navigation = {}; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + if (StoreBeginManagerModal("Mod gallery", 980.F, 680.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Gallery image", width, {"Previous", "Next", "Close"}); + StoreHeading(g_mod_store.gallery_title, ImGui::GetContentRegionAvail().x, 20); + const int count = int(g_mod_store.gallery.size()); + if (count) { + g_mod_store.gallery_index = std::clamp(g_mod_store.gallery_index, 0, count - 1); + const float inner = ImGui::GetContentRegionAvail().x; + StoreCover({{"image", g_mod_store.gallery[g_mod_store.gallery_index]}}, inner, + std::max(80.F, std::min(inner * .56F, ImGui::GetContentRegionAvail().y - 45.F)), true); + StoreNote(std::to_string(g_mod_store.gallery_index + 1) + " / " + std::to_string(count), inner); + } + ImGui::EndChild(); + StoreActionLine({{"Previous", + [count] { + if (count) + g_mod_store.gallery_index = (g_mod_store.gallery_index + count - 1) % count; + }}, + {"Next", + [count] { + if (count) + g_mod_store.gallery_index = (g_mod_store.gallery_index + 1) % count; + }}, + {"Close", [] { ImGui::CloseCurrentPopup(); }}}, + width); + if (StoreModalBack()) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + if (StoreBeginManagerModal("Release information", 800.F, 560.F)) { + const float width = ImGui::GetContentRegionAvail().x; + StoreModalBody("Release text", width, {"Close"}); + const float inner = ImGui::GetContentRegionAvail().x; + StoreHeading(g_mod_store.release_title, inner, 21); + StoreNote("Version " + StoreText(g_mod_store.release_details, "version", 80), inner, false); + const auto notes = StoreText(g_mod_store.release_details, "notes", 8192); + StoreNote(notes.empty() ? "No release notes supplied." : notes, inner); + const auto requirements = StoreText(g_mod_store.release_details, "requirements", 4096); + if (!requirements.empty()) { + StoreHeading("Requirements", inner, 18); + StoreNote(requirements, inner); + } + ImGui::EndChild(); + if (StoreButton("Close") || StoreModalBack()) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} diff --git a/runtime-recomp/src/game/store_staff_pages.inl b/runtime-recomp/src/game/store_staff_pages.inl new file mode 100644 index 00000000..5c832699 --- /dev/null +++ b/runtime-recomp/src/game/store_staff_pages.inl @@ -0,0 +1,637 @@ +// Staff presentation uses the existing authenticated actions and revision +// fences. No role is inferred from UI state and no publication is automatic. +void StoreUseAsset(const StoreJson& asset) { + StoreEnsureEditor(); + g_mod_store.dirty = true; + g_mod_store.previewed = false; + const bool image = StoreText(asset, "kind", 32) == "image"; + if (image) + g_mod_store.fields["draft-image"] = StoreText(asset, "url", 1500); + else { + g_mod_store.fields["draft-fileUrl"] = StoreText(asset, "url", 1500); + g_mod_store.fields["draft-fileName"] = StoreText(asset, "fileName", 180); + g_mod_store.fields["draft-format"] = StoreText(asset, "format", 40); + } + g_mod_store.picker_upload = false; + g_mod_store.editor_step = image ? 1 : 2; + g_mod_store.staff_tab = 2; +} +void StoreUploads(float width, const dkr::store::Snapshot& store) { + if (g_mod_store.picker_upload && StoreBack("Back to listing", [] { + g_mod_store.picker_upload = false; + g_mod_store.staff_tab = 2; + })) + return; + StoreHeading(g_mod_store.picker_upload ? "Choose a listing file" : "Uploads & hosted files", width, 22); + int category = 0; + for (int i = 1; i < 6; ++i) + if (g_mod_store.upload_category == kStoreCategoryIds[i]) + category = i - 1; + category = + StoreCombo("Category", category, {"Tracks", "Characters", "Vehicles", "Custom Magic Codes", "Texture packs"}, + std::min(width, 350.F)); + g_mod_store.upload_category = kStoreCategoryIds[category + 1]; + StoreActionLine({{"Upload package", [] { StoreChooseUpload("download"); }}, + {"Upload image", [] { StoreChooseUpload("image"); }}, + {"Refresh files", [] { dkr::store::service().load_staff_files(); }}}, + width); + StoreField("hosted-search", "Find a file", std::min(width, 760.F), 160); + g_mod_store.upload_selection = g_mod_store.upload_selection.empty() ? "hosted" : g_mod_store.upload_selection; + const int section = StoreTabLine({"Hosted files", "Upload history"}, + g_mod_store.upload_selection == "hosted" ? 0 : 1, width, "file-section"); + g_mod_store.upload_selection = section ? "history" : "hosted"; + if (!store.upload.empty() && (store.uploading || StoreText(store.upload, "state", 40) != "completed")) { + StorePanel active(width); + StoreHeading(StoreText(store.upload, "fileName", 180), active.inner(), 18); + StoreBadge(StoreText(store.upload, "state", 40), active.inner()); + if (store.upload.contains("offset") && store.upload.contains("size") && store.upload["offset"].is_number() && + store.upload["size"].is_number()) { + const auto total = store.upload["size"].get(), offset = store.upload["offset"].get(); + if (total > 0) + ImGui::ProgressBar(std::clamp(float(offset / total), 0.F, 1.F), {active.inner(), 12.F}, ""); + StoreNote(StoreSize(std::uint64_t(std::max(0., offset))) + " / " + + StoreSize(std::uint64_t(std::max(0., total))), + active.inner()); + } + if (store.uploading && StoreButton("Pause upload")) + dkr::store::service().pause_upload(); + } + if (!store.upload.empty() && StoreText(store.upload, "state", 40) == "completed" && + store.upload.contains("asset")) { + StorePanel completed(width); + StoreHeading("Upload ready", completed.inner(), 18); + StoreNote(StoreText(store.upload, "fileName", 180), completed.inner()); + if (StoreButton("Use this file", 0, true)) + StoreUseAsset(store.upload["asset"]); + } + std::size_t matching = 0; + const auto& rows = section ? store.uploads : store.assets; + if (rows.is_array()) + for (const auto& row : rows) { + if (StoreText(row, "category", 32) != g_mod_store.upload_category || + !StoreMatches(StoreText(row, "fileName", 180), g_mod_store.fields["hosted-search"])) + continue; + if (g_mod_store.picker_upload && !g_mod_store.upload_kind.empty() && + StoreText(row, "kind", 32) != g_mod_store.upload_kind) + continue; + if (++matching > std::size_t(StoreLimit(section ? "upload-history" : "hosted-files"))) + continue; + ImGui::PushID((StoreText(row, "id", 160) + StoreText(row, "url", 1500)).c_str()); + StorePanel card(width); + const float inner = card.inner(); + StoreHeading(StoreText(row, "fileName", 180), inner, 18); + const auto state = StoreText(row, "state", 40); + StoreNote((section ? state : "Hosted file") + " | " + StoreText(row, "format", 40) + " | " + + StoreSize(row.value("size", std::uint64_t(0))), + inner); + if (!section && StoreText(row, "kind", 32) == "image") + StoreCover({{"image", StoreText(row, "url", 1500)}}, std::min(inner, 220.F), 110.F); + if (!section) { + if (StoreButton("Use in listing")) + StoreUseAsset(row); + } else if (state == "open" || state == "completing") { + if (StoreButton("Resume with original file")) + StoreChooseUpload(StoreText(row, "kind", 32), StoreText(row, "id", 160)); + if (StoreButton("Cancel unfinished upload")) { + const auto id = StoreText(row, "id", 160); + StoreConfirm("Cancel upload?", StoreText(row, "fileName", 180), "Cancel upload", + [id] { dkr::store::service().cancel_upload(id); }); + } + } else if (state == "completed") { + if (row.contains("asset") && StoreButton("Use in listing")) + StoreUseAsset(row["asset"]); + if (StoreButton("Recheck using original file")) + StoreChooseUpload(StoreText(row, "kind", 32), StoreText(row, "id", 160)); + } + ImGui::PopID(); + } + if (!matching) + StoreNotice("No matching files", "Choose another category, change your search, or upload a file.", width); + StoreMore(section ? "upload-history" : "hosted-files", matching, width); +} +void StoreSaveDraft() { + StoreJson input = {{"draft", StoreDraft()}}; + if (!g_mod_store.editor.empty()) { + input["id"] = g_mod_store.editor; + input["expectedRevision"] = StoreInt(g_mod_store.draft, "revision"); + } + StoreAction("catalogue.save", std::move(input)); +} +void StoreEditor(float width, const dkr::store::Snapshot& store) { + if (!dkr::store::staff(store.user)) + return; + if (g_mod_store.related_open) { + if (StoreBack("Back to listing", [] { g_mod_store.related_open = false; })) + return; + StoreHeading("Companion downloads", width, 22); + StoreNote("Choose up to 10. Players install companions separately.", width); + StoreField("related-search", "Find a mod", std::min(width, 760.F), 200); + auto& related = g_mod_store.draft["related"]; + if (!related.is_array()) + related = StoreJson::array(); + std::size_t matching = 0; + for (const auto& item : StoreItems(store)) { + const auto id = StoreText(item, "id", 160); + if (id == StoreText(g_mod_store.draft, "targetId", 160) || id == StoreText(g_mod_store.draft, "id", 160) || + !StoreMatches(StoreText(item, "title", 200), g_mod_store.fields["related-search"])) + continue; + if (++matching > std::size_t(StoreLimit("companions"))) + continue; + bool selected = std::find(related.begin(), related.end(), StoreJson(id)) != related.end(); + ImGui::BeginDisabled(!selected && related.size() >= 10); + if (StoreCheck(StoreText(item, "title", 200) + "###companion-" + id, selected, width)) { + if (selected) + related.push_back(id); + else + related.erase(std::remove(related.begin(), related.end(), StoreJson(id)), related.end()); + g_mod_store.dirty = true; + g_mod_store.previewed = false; + } + ImGui::EndDisabled(); + } + StoreMore("companions", matching, width); + if (StoreButton("Done", 0, true)) + g_mod_store.related_open = false; + return; + } + StoreHeading(g_mod_store.metadata ? "Edit listing details" : "Listing editor", width, 22); + const std::vector steps = + g_mod_store.metadata ? std::vector{"Details", "Artwork", "Review"} + : std::vector{"Details", "Artwork", "Release files", "Preview & publish"}; + g_mod_store.editor_step = StoreTabLine(steps, g_mod_store.editor_step, width, "editor-steps"); + const bool preview = g_mod_store.editor_step == int(steps.size()) - 1; + const auto draft = StoreDraft(); + StoreFormLayout layout("editor-layout", width, 780.F); + layout.sidebar([&](float side) { + StorePanel summary(side); + StoreHeading("Listing preview", summary.inner(), 19); + StoreCover(draft, summary.inner(), 150.F); + StoreHeading(StoreText(draft, "title", 100), summary.inner(), 18); + StoreNote("by " + StoreText(draft, "author", 100), summary.inner()); + StoreBadge(g_mod_store.dirty ? "Unsaved changes" : "Saved", summary.inner(), + g_mod_store.dirty ? PaddockTone::Warning : PaddockTone::On); + }); + StorePanel form(layout.width); + const float inner = form.inner(); + ImGui::BeginDisabled(StoreBusyAction(store)); + if (g_mod_store.editor_step == 0) { + StoreFieldPair("draft-title", "Title", "draft-author", "Creator credit", inner); + int category = 0; + for (int i = 1; i < 6; ++i) + if (g_mod_store.fields["draft-category"] == kStoreCategoryIds[i]) + category = i - 1; + ImGui::BeginDisabled(g_mod_store.metadata); + const int choice = StoreCombo("Category", category, + {"Tracks", "Characters", "Vehicles", "Custom Magic Codes", "Texture packs"}, inner); + ImGui::EndDisabled(); + if (choice != category) { + g_mod_store.fields["draft-category"] = kStoreCategoryIds[choice + 1]; + g_mod_store.dirty = true; + g_mod_store.previewed = false; + } + StoreField("draft-summary", "Description", inner, 4000, true); + StoreField("draft-tags", "Tags, separated by commas", inner, 500); + if (StoreCheck("Feature this listing", g_mod_store.featured, inner)) { + g_mod_store.dirty = true; + g_mod_store.previewed = false; + } + } else if (g_mod_store.editor_step == 1) { + if (StoreButton("Choose or upload artwork", 0, true)) { + g_mod_store.upload_category = g_mod_store.fields["draft-category"]; + g_mod_store.upload_kind = "image"; + g_mod_store.picker_upload = true; + g_mod_store.staff_tab = 3; + dkr::store::service().load_staff_files(); + } + StoreField("draft-image", "Cover image link", inner, 1500); + if (!g_mod_store.fields["draft-image"].empty()) + StoreCover(draft, std::min(inner, 460.F), 210.F); + StoreNote(std::to_string(draft.value("related", StoreJson::array()).size()) + " companion downloads selected", + inner); + if (StoreButton("Choose companion downloads")) + g_mod_store.related_open = true; + } else if (!preview) { + if (StoreButton("Choose or upload package", 0, true)) { + g_mod_store.upload_category = g_mod_store.fields["draft-category"]; + g_mod_store.upload_kind = "download"; + g_mod_store.picker_upload = true; + g_mod_store.staff_tab = 3; + dkr::store::service().load_staff_files(); + } + StoreFieldPair("draft-version", "Release version", "draft-format", "Package format", inner, 80, 40); + StoreField("draft-fileName", "Package filename", inner, 180); + StoreField("draft-fileUrl", "Package download link", inner, 1500); + StoreField("draft-requirements", "Requirements", inner, 2000, true); + StoreField("draft-install", "Installation instructions", inner, 6000, true); + } else { + StoreHeading(StoreText(draft, "title", 100), inner, 23); + StoreNote("by " + StoreText(draft, "author", 100), inner); + StoreNote(StoreText(draft, "summary", 4000), inner, false); + if (!g_mod_store.metadata) { + StoreHeading("Release", inner, 18); + StoreNote(StoreText(draft, "version", 80) + " | " + StoreText(draft, "fileName", 180), inner); + StoreNote(StoreText(draft, "requirements", 2000), inner); + StoreNote(StoreText(draft, "install", 6000), inner); + } + if (!g_mod_store.dirty) + g_mod_store.previewed = true; + } + PaddockGap(12.F); + if (g_mod_store.metadata) { + if (StoreButton("Save listing details", 0, true)) { + auto input = StoreDraft(); + input["id"] = g_mod_store.draft["id"]; + input["expectedRevision"] = StoreInt(g_mod_store.draft, "revision"); + StoreAction("catalogue.update", std::move(input)); + } + } else { + if (StoreButton("Save draft", 0, true)) + StoreSaveDraft(); + if (preview) { + StoreNote(g_mod_store.dirty ? "Save the draft before publishing." + : "Check the listing above before publishing.", + inner); + const auto requests = store.staff.value("requests", StoreJson::array()); + const auto* request = StoreFind(requests, g_mod_store.request); + const bool allowed = + !g_mod_store.dirty && g_mod_store.previewed && + (g_mod_store.request.empty() ? !g_mod_store.editor.empty() + : request && StoreText(*request, "status", 32) == "approved"); + ImGui::BeginDisabled(!allowed); + if (StoreButton("Publish to library", 0, true)) { + if (request) { + const auto input = StoreJson{{"id", g_mod_store.request}, + {"expectedRevision", StoreInt(*request, "revision")}, + {"draft", StoreDraft()}}; + StoreConfirm("Publish this mod?", + StoreText(draft, "title", 100) + " will become available to everyone.", "Publish", + [input] { StoreAction("request.publish", input); }); + } else { + const auto input = StoreJson{{"id", g_mod_store.editor}, + {"expectedRevision", StoreInt(g_mod_store.draft, "revision")}}; + StoreConfirm("Publish this listing?", + StoreText(draft, "title", 100) + " will become available to everyone.", "Publish", + [input] { StoreAction("catalogue.publish", input); }); + } + } + ImGui::EndDisabled(); + } + } + ImGui::EndDisabled(); + StoreActionLine( + {{"Previous section", [steps] { g_mod_store.editor_step = std::max(0, g_mod_store.editor_step - 1); }}, + {"Next section", + [steps] { g_mod_store.editor_step = std::min(int(steps.size()) - 1, g_mod_store.editor_step + 1); }}}, + inner); +} +void StoreStaffRequest(const StoreJson& row, float width, const dkr::store::Snapshot& store) { + if (StoreBack("Back to request queue", [] { g_mod_store.request.clear(); })) + return; + const auto id = StoreText(row, "id", 160), prefix = "staff-" + id + "-"; + StoreHeading(StoreText(row, "title", 200), width, 23); + StoreBadge(StoreText(row, "status", 40), width); + StoreHeading("Submitted material", width, 18); + StoreNote("Creator: " + StoreText(row, "author", 100), width); + for (const char* field : {"summary", "sourceUrl", "downloadUrl", "requirements", "testedVersion", "screenshotUrl"}) + if (!StoreText(row, field, 4000).empty()) + StoreNote(StoreText(row, field, 4000), width, false); + StoreHeading("History", width, 18); + if (row.contains("events")) + for (const auto& event : row["events"]) { + StoreNote(StoreText(event, "label", 200) + " | " + StoreDate(event, "createdAt"), width, false); + StoreNote(StoreText(event, "message", 3000), width); + } + { + StorePanel notes(std::min(width, 860.F)); + const float inner = notes.inner(); + ImGui::BeginDisabled(StoreBusyAction(store)); + StoreHeading("Private staff notes", inner, 18); + StoreNote("Only the library team can see these notes.", inner); + if (row.contains("notes")) + for (const auto& note : row["notes"]) { + StoreNote(StoreText(note, "actorName", 100) + " | " + StoreDate(note, "createdAt"), inner, false); + StoreNote(StoreText(note, "text", 3000).empty() ? StoreText(note, "message", 3000) + : StoreText(note, "text", 3000), + inner); + } + StoreField((prefix + "private").c_str(), "Add a private note", inner, 1600, true); + ImGui::BeginDisabled(g_mod_store.fields[prefix + "private"].empty()); + if (StoreButton("Save private note")) + StoreAction("request.note", {{"id", id}, + {"expectedRevision", StoreInt(row, "revision")}, + {"value", g_mod_store.fields[prefix + "private"]}}); + ImGui::EndDisabled(); + ImGui::EndDisabled(); + } + StorePanel decision(std::min(width, 860.F)); + const float inner = decision.inner(); + StoreHeading("Review decision", inner, 18); + ImGui::BeginDisabled(StoreBusyAction(store)); + const auto state = StoreText(row, "status", 40); + if (state == "pending") { + if (StoreButton("Start review", 0, true)) + StoreAction("request.start", {{"id", id}, {"expectedRevision", StoreInt(row, "revision")}}); + } else if (state == "review") { + StoreHeading("Review checks", inner, 18); + bool all = true; + StoreJson checks = StoreJson::object(); + const char* keys[] = {"source", "files", "requirements", "credits"}; + const char* labels[] = {"Source and creator checked", "Package contents checked", "Requirements checked", + "Credits checked"}; + for (int i = 0; i < 4; ++i) { + const auto key = prefix + keys[i]; + if (!g_mod_store.fields.contains(key)) { + const auto found = row.find("checks"); + g_mod_store.fields[key] = + found != row.end() && found->is_object() && found->value(keys[i], false) ? "1" : "0"; + } + bool checked = g_mod_store.fields[key] == "1"; + if (StoreCheck(labels[i], checked, inner)) + g_mod_store.fields[key] = checked ? "1" : "0"; + checks[keys[i]] = checked; + all = all && checked; + } + StoreField((prefix + "decision").c_str(), "Message to requester (not private)", inner, 2400, true); + const auto message = g_mod_store.fields[prefix + "decision"]; + const auto decide = [id, row, checks, message](std::string decision) { + const StoreJson input{{"id", id}, + {"expectedRevision", StoreInt(row, "revision")}, + {"checks", checks}, + {"decision", decision}, + {"message", message}}; + StoreConfirm("Confirm request decision", StoreText(row, "title", 200) + "\n" + message, "Send decision", + [input] { StoreAction("request.decide", input); }); + }; + ImGui::BeginDisabled(!all); + if (StoreButton("Approve", 0, true)) + decide("approve"); + ImGui::EndDisabled(); + ImGui::BeginDisabled(message.empty()); + StoreActionLine( + {{"Ask for changes", [decide] { decide("changes"); }}, {"Reject", [decide] { decide("reject"); }}}, inner); + ImGui::EndDisabled(); + if (message.empty()) + StoreNote("Add a message before asking for changes or rejecting.", inner); + } else if (state == "approved" && StoreButton("Prepare listing", 0, true)) { + StoreLoadDraft(row); + g_mod_store.request = id; + g_mod_store.fields["draft-fileUrl"] = StoreText(row, "downloadUrl", 1500); + g_mod_store.fields["draft-image"] = StoreText(row, "screenshotUrl", 1500); + if (StoreText(row, "relation", 32) == "update") { + const auto target = StoreText(row, "targetId", 160); + g_mod_store.draft["target"] = "existing"; + g_mod_store.draft["targetId"] = target; + if (const auto* item = StoreFind(StoreItems(store), target)) + g_mod_store.draft["targetRevision"] = StoreInt(*item, "revision"); + } + g_mod_store.staff_tab = 2; + } + ImGui::EndDisabled(); +} +void StoreManagement(float width, const dkr::store::Snapshot& store) { + if (!dkr::store::staff(store.user)) + return; + StoreHeading("Manage library", width); + std::vector tabs{"Requests", "Reports", "Listings", "Uploads"}; + if (dkr::store::admin(store.user)) { + tabs.push_back("Activity"); + tabs.push_back("Staff access"); + } + const auto chosen = StoreTabLine(tabs, g_mod_store.staff_tab, width, "staff-tabs"); + if (chosen != g_mod_store.staff_tab) + StoreNavigate([chosen] { + g_mod_store.staff_tab = chosen; + if (chosen == 3) { + g_mod_store.picker_upload = false; + dkr::store::service().load_staff_files(); + } + }); + if (g_mod_store.staff_tab == 0) { + const auto requests = store.staff.value("requests", StoreJson::array()); + if (!g_mod_store.request.empty()) { + if (const auto* row = StoreFind(requests, g_mod_store.request)) + StoreStaffRequest(*row, width, store); + else { + StoreNote("This request is no longer available.", width); + if (StoreButton("Back to queue")) + g_mod_store.request.clear(); + } + return; + } + StoreField("staff-request-search", "Find a request", std::min(width, 760.F), 200); + g_mod_store.staff_filter = + StoreCombo("Status", g_mod_store.staff_filter, + {"All", "Pending", "In review", "Changes requested", "Approved", "Rejected", "Published"}, + std::min(width, 350.F)); + const char* states[] = {"", "pending", "review", "changes", "approved", "rejected", "published"}; + std::size_t matching = 0; + for (const auto& row : requests) { + if (!StoreMatches(StoreText(row, "title", 200), g_mod_store.fields["staff-request-search"]) || + (g_mod_store.staff_filter && StoreText(row, "status", 40) != states[g_mod_store.staff_filter])) + continue; + if (++matching > std::size_t(StoreLimit("staff-requests"))) + continue; + if (StoreRow(StoreText(row, "id", 160), StoreJson::object(), StoreText(row, "title", 200), + StoreText(row, "status", 40) + " | " + StoreText(row, "author", 100), "Review", width)) + g_mod_store.request = StoreText(row, "id", 160); + } + if (!matching) + StoreNotice("No matching requests", "New submissions appear in this queue.", width); + StoreMore("staff-requests", matching, width); + } else if (g_mod_store.staff_tab == 1) { + const auto reports = store.staff.value("reports", StoreJson::array()); + if (!g_mod_store.staff_report.empty()) { + if (StoreBack("Back to reports", [] { g_mod_store.staff_report.clear(); })) + return; + const auto* report = StoreFind(reports, g_mod_store.staff_report); + if (!report) { + StoreNote("This report is no longer available.", width); + return; + } + // Own the review value before drawing or capturing a decision. + StoreJson review_copy; + const auto review_rows = store.staff.value("reviews", StoreJson::array()); + const auto* review = StoreFind(review_rows, StoreText(*report, "reviewId", 160)); + if (review) + review_copy = *review; + StoreHeading("Reported review", width, 21); + StoreNote(StoreText(*report, "reason", 1600), width, false); + if (!review_copy.is_null()) { + StoreNote(StoreText(review_copy, "displayName", 100), width); + StoreNote(StoreText(review_copy, "text", 4000), width, false); + } + const auto key = "report-decision-" + g_mod_store.staff_report; + ImGui::BeginDisabled(StoreBusyAction(store)); + StoreField(key.c_str(), "Decision reason", std::min(width, 820.F), 1600, true); + for (const auto& [label, action] : std::vector>{ + {"Keep review", "keep"}, {"Hide review", "hide"}, {"Restore review", "restore"}}) { + if (StoreButton(label)) { + const StoreJson input{{"reportId", (*report)["id"]}, + {"expectedRevision", StoreInt(*report, "revision")}, + {"expectedReviewRevision", StoreInt(review_copy, "revision")}, + {"action", action}, + {"reason", g_mod_store.fields[key]}}; + StoreConfirm(label + "?", + StoreText(review_copy, "displayName", 100) + "\n" + g_mod_store.fields[key], label, + [input] { StoreAction("report.resolve", input); }); + } + } + ImGui::EndDisabled(); + return; + } + StoreField("staff-report-search", "Find a report", std::min(width, 760.F), 200); + std::size_t matching = 0; + for (const auto& row : reports) { + if (!StoreMatches(StoreText(row, "reason", 1600), g_mod_store.fields["staff-report-search"])) + continue; + if (++matching > std::size_t(StoreLimit("reports"))) + continue; + if (StoreRow(StoreText(row, "id", 160), StoreJson::object(), "Reported review", + StoreText(row, "reason", 1600), "Review report", width)) + g_mod_store.staff_report = StoreText(row, "id", 160); + } + if (!matching) + StoreNote("No reports match your search.", width); + StoreMore("reports", matching, width); + } else if (g_mod_store.staff_tab == 2) { + if (g_mod_store.editing) { + if (StoreBack( + "Back to listings", + [] { + g_mod_store.editing = false; + g_mod_store.request.clear(); + }, + true)) + return; + StoreEditor(width, store); + return; + } + if (StoreButton("New listing", 0, true)) { + g_mod_store.request.clear(); + StoreLoadDraft(StoreJson::object()); + } + StoreField("staff-listing-search", "Find a listing or draft", std::min(width, 760.F), 200); + const auto query = g_mod_store.fields["staff-listing-search"]; + const auto drafts = store.admin.value("drafts", StoreJson::array()); + std::size_t draft_count = 0; + for (const auto& row : drafts) { + if (StoreText(row, "status", 40) != "draft" || !StoreMatches(StoreText(row, "title", 200), query)) + continue; + if (++draft_count > std::size_t(StoreLimit("staff-drafts"))) + continue; + if (StoreRow(StoreText(row, "id", 160), row, StoreText(row, "title", 200), "Unpublished draft", "Continue", + width)) { + g_mod_store.request.clear(); + StoreLoadDraft(row.contains("draft") ? row["draft"] : row); + g_mod_store.editor = StoreText(row, "id", 160); + g_mod_store.draft["revision"] = StoreInt(row, "revision"); + } + } + if (draft_count) + StoreMore("staff-drafts", draft_count, width); + auto listings = store.admin.value("items", StoreJson::array()); + if (listings.empty()) + listings = StoreItems(store); + std::size_t matching = 0; + for (const auto& row : listings) { + if (!StoreMatches(StoreText(row, "title", 200), query)) + continue; + if (++matching > std::size_t(StoreLimit("staff-listings"))) + continue; + ImGui::PushID(StoreText(row, "id", 160).c_str()); + StorePanel panel(width); + StoreHeading(StoreText(row, "title", 200), panel.inner(), 18); + StoreNote(StoreCategory(row) + " | " + StoreText(row, "version", 80) + " | " + + (row.value("archived", false) ? "Archived" : "Published"), + panel.inner()); + StoreActionLine({{"Edit details", + [row] { + g_mod_store.request.clear(); + StoreLoadDraft(row); + g_mod_store.editor.clear(); + g_mod_store.metadata = true; + }}, + {"Add release", + [row] { + g_mod_store.request.clear(); + StoreLoadDraft(row); + g_mod_store.editor.clear(); + g_mod_store.draft["target"] = "existing"; + g_mod_store.draft["targetId"] = row["id"]; + g_mod_store.draft["targetRevision"] = StoreInt(row, "revision"); + }}}, + panel.inner()); + if (dkr::store::admin(store.user) && + StoreButton(row.value("archived", false) ? "Restore listing" : "Archive listing")) { + const bool archived = row.value("archived", false); + StoreConfirm(archived ? "Restore this listing?" : "Archive this listing?", + StoreText(row, "title", 200) + "\nInstalled copies are kept.", + archived ? "Restore" : "Archive", [row, archived] { + StoreAction("catalogue.archive", {{"id", row["id"]}, + {"archived", !archived}, + {"expectedRevision", StoreInt(row, "revision")}}); + }); + } + ImGui::PopID(); + } + StoreMore("staff-listings", matching, width); + if (!matching && !draft_count) + StoreNote("No listings or drafts match your search.", width); + } else if (g_mod_store.staff_tab == 3) + StoreUploads(width, store); + else if (g_mod_store.staff_tab == 4 && dkr::store::admin(store.user)) { + StoreField("audit-search", "Find activity", std::min(width, 760.F), 200); + auto rows = store.admin.value("audit", StoreJson::array()); + std::stable_sort(rows.begin(), rows.end(), [](const auto& a, const auto& b) { + return StoreText(a, "createdAt", 80) > StoreText(b, "createdAt", 80); + }); + std::size_t matching = 0; + for (const auto& row : rows) { + if (!StoreMatches(StoreText(row, "action", 200) + " " + StoreText(row, "message", 2000), + g_mod_store.fields["audit-search"])) + continue; + if (++matching > std::size_t(StoreLimit("audit"))) + continue; + StorePanel card(width); + StoreHeading(StoreText(row, "action", 200), card.inner(), 18); + StoreNote(StoreText(row, "message", 2000), card.inner()); + StoreNote(StoreDate(row, "createdAt") + " | " + StoreText(row, "actorName", 100), card.inner()); + } + if (!matching) + StoreNote("No activity matches your search.", width); + StoreMore("audit", matching, width); + } else if (dkr::store::admin(store.user)) { + StoreField("staff-access-search", "Find an account", std::min(width, 760.F), 200); + std::size_t matching = 0; + for (const auto& row : store.admin.value("accounts", StoreJson::array())) { + if (!StoreMatches(StoreText(row, "displayName", 100), g_mod_store.fields["staff-access-search"])) + continue; + if (++matching > std::size_t(StoreLimit("staff-access"))) + continue; + const auto id = StoreText(row, "id", 160), key = "staff-role-" + id; + ImGui::PushID(id.c_str()); + StorePanel panel(width); + StoreHeading(StoreText(row, "displayName", 100), panel.inner(), 18); + const auto current = StoreText(row, "role", 40); + StoreNote("Current role: " + current, panel.inner()); + if (!g_mod_store.fields.contains(key)) + g_mod_store.fields[key] = current; + int role = g_mod_store.fields[key] == "admin" ? 2 : g_mod_store.fields[key] == "moderator" ? 1 : 0; + role = StoreCombo("Role", role, {"Player", "Moderator", "Administrator"}, std::min(panel.inner(), 340.F)); + const char* roles[] = {"player", "moderator", "admin"}; + g_mod_store.fields[key] = roles[role]; + ImGui::BeginDisabled(StoreBusyAction(store) || current == roles[role]); + if (StoreButton("Save role")) { + const std::string new_role = roles[role]; + StoreConfirm("Change staff access?", + StoreText(row, "displayName", 100) + "\n" + current + " -> " + new_role, "Change role", + [id, new_role] { StoreAction("admin.role", {{"accountId", id}, {"role", new_role}}); }); + } + ImGui::EndDisabled(); + ImGui::PopID(); + } + if (!matching) + StoreNote("No accounts match your search.", width); + StoreMore("staff-access", matching, width); + } +} diff --git a/runtime-recomp/src/game/store_texture_adapter.inl b/runtime-recomp/src/game/store_texture_adapter.inl new file mode 100644 index 00000000..7a554dc6 --- /dev/null +++ b/runtime-recomp/src/game/store_texture_adapter.inl @@ -0,0 +1,79 @@ +// Appended by the Patch Pipeline to the accepted texture importer. Uses its +// existing parser/converter without publishing an entry or changing settings. +namespace dkr::store { +StorePrepared prepare_store_texture(const std::filesystem::path& source, const std::filesystem::path& stage, + const std::string& title, std::stop_token stop, + const std::function& progress) { + using namespace dkr::runtime::texture_packs; + const auto inspection = InspectArchive(store_io_path(source)); + if (inspection.format != Format::LegacyRice && inspection.format != Format::NativeRt64) + throw std::runtime_error("This texture package cannot be installed: " + inspection.detail); + std::filesystem::create_directories(store_io_path(stage)); + const auto source_name = source.filename().generic_u8string(); + const auto filename = package_filename(title, {source_name.begin(), source_name.end()}); + const auto destination = stage / (inspection.format == Format::LegacyRice ? "textures.rice" : "textures.rtz"); + if (std::filesystem::exists(destination)) + throw std::runtime_error("Texture staging is already in use."); + if (inspection.format == Format::LegacyRice) { + auto archive = RT64::FileSystemZip::create(store_io_path(source), ""); + dkr::runtime::rice_texture::ImportResult result; + std::string error; + if (!archive || !dkr::runtime::rice_texture::convert_archive( + *archive, store_io_path(destination), filename, result, error, + [&](std::size_t done, std::size_t total, const char* note) { + progress(total ? float(double(done) / double(total)) : 0.F, note); + return !stop.stop_requested(); + })) + throw std::runtime_error(error.empty() ? "Could not read the texture package." : error); + if (!InspectDirectory(store_io_path(destination)).compatible) + throw std::runtime_error("Converted texture validation failed."); + } else { + if (!inspection.compatible) + throw std::runtime_error(inspection.detail); + std::filesystem::copy_file(store_io_path(source), store_io_path(destination)); + } + StorePrepared out; + const auto utf8 = destination.generic_u8string(); + out.outputs.push_back( + {{"kind", "texture"}, + {"source", std::string(utf8.begin(), utf8.end())}, + {"fingerprint", ArchiveFingerprint(source)}, + {"filename", + package_filename(title, "textures.zip").substr(0, package_filename(title, "textures.zip").size() - 4) + + (inspection.format == Format::LegacyRice ? ".rice" : ".rtz")}}); + return out; +} +void publish_store_texture(const Json& output, const Json& previous) { + using namespace dkr::runtime::texture_packs; + const auto path = std::filesystem::u8path(text(output, "absolute", 4096)); + if (!IsDirectManagedChild(path)) + throw std::runtime_error("Texture destination is outside the managed library."); + const auto id = StableId(path); + { + std::lock_guard lock(g_mutex); + g_imported_at.try_emplace(id, CurrentTimestamp()); + g_managed_sizes[id] = ManagedSize(path); + if (!text(output, "owner", 256).empty()) { + g_track_pack_owners[id] = {text(output, "owner", 256), text(output, "textureDigest", 64), + text(output, "fingerprint", 128)}; + g_enabled_ids.insert(id); + } + if (previous.is_object() && previous.contains("enabled")) { + if (previous.value("enabled", false)) + g_enabled_ids.insert(id); + else + g_enabled_ids.erase(id); + if (previous.value("hidden", false)) + g_hidden_ids.insert(id); + else + g_hidden_ids.erase(id); + const auto priority = previous.value("priority", std::size_t(0)); + if (priority) + g_priorities[id] = priority; + } + if (!SaveSettingsLocked()) + throw std::runtime_error("Texture preferences could not be committed."); + } + refresh(); +} +} // namespace dkr::store diff --git a/runtime-recomp/src/game/store_track_adapter.inl b/runtime-recomp/src/game/store_track_adapter.inl new file mode 100644 index 00000000..ed36c2a2 --- /dev/null +++ b/runtime-recomp/src/game/store_track_adapter.inl @@ -0,0 +1,47 @@ +// Preparation only. Original install APIs and their runtime identities remain +// unchanged; the Store transaction publishes this validated private directory. +namespace dkr::store { +StorePrepared prepare_store_track(const std::filesystem::path& source, const std::filesystem::path& stage, + const std::string&, std::stop_token stop) { + namespace fs = std::filesystem; + fs::create_directories(store_io_path(stage)); + const auto unpacked = stage / "unpacked"; + fs::create_directory(store_io_path(unpacked)); + fs::path track = source, hd; + std::string error; + if (fs::is_regular_file(source) && (lower_extension(source) == ".zip" || lower_extension(source) == ".dkrmap")) { + if (!extract_zip(store_io_path(source), store_io_path(unpacked), error, stop) || + !locate_unpacked_track(store_io_path(unpacked), track, hd, &error, stop)) + throw std::runtime_error(error.empty() ? "This package does not contain a .dkrmap track." : error); + } else if (!fs::is_directory(source)) + throw std::runtime_error("A track must be a .dkrmap folder or ZIP package."); + if (stop.stop_requested()) + throw std::runtime_error("Track preparation cancelled."); + dkr::runtime::custom_tracks::Track probe; + if (!parse_track(store_io_path(track), probe, error)) + throw std::runtime_error(error); + // Preserve all declared relative asset paths, including external audio. + // HD companion selection is checked against the track's own declaration. + if (hd.empty() && !probe.hd_pack_file.empty() && dkr::runtime::custom_tracks::files::member(probe.hd_pack_file)) + hd = source.parent_path() / fs::u8path(probe.hd_pack_file); + std::string hd_digest; + if (!hd.empty() && fs::is_regular_file(hd) && !probe.hd_pack_digest.empty() && + (!read_hd_pack_digest(hd, hd_digest) || hd_digest != probe.hd_pack_digest)) + throw std::runtime_error("The bundled HD textures do not match this track export."); + const auto payload = stage / "course.dkrmap"; + fs::copy(store_io_path(track), store_io_path(payload), fs::copy_options::recursive); + StorePrepared out; + const auto utf8 = payload.generic_u8string(); + out.outputs.push_back({{"kind", "track"}, + {"source", std::string(utf8.begin(), utf8.end())}, + {"filename", probe.id + ".dkrmap"}, + {"id", probe.id}, + {"name", probe.name}}); + out.native = {{"trackId", probe.id}, {"hdDigest", probe.hd_pack_digest}}; + if (!hd.empty() && fs::is_regular_file(hd)) { + const auto hd_utf8 = hd.generic_u8string(); + out.native["hdSource"] = std::string(hd_utf8.begin(), hd_utf8.end()); + } + return out; +} +} // namespace dkr::store diff --git a/runtime-recomp/src/game/store_track_locator.inl b/runtime-recomp/src/game/store_track_locator.inl new file mode 100644 index 00000000..38ac6f9a --- /dev/null +++ b/runtime-recomp/src/game/store_track_locator.inl @@ -0,0 +1,107 @@ +// Discover a single course inside an already safely extracted archive. Authors +// may include wrapper folders and editor sources beside the actual .dkrmap. +bool locate_unpacked_track(const std::filesystem::path& input_root, std::filesystem::path& track_dir, + std::filesystem::path& hd_pack, std::string* error = nullptr, std::stop_token stop = {}) { + namespace fs = std::filesystem; + track_dir.clear(); + hd_pack.clear(); + const auto fail = [&](const std::string& message) { + if (error) + *error = message; + return false; + }; + const auto root = dkr::store::store_io_path(input_root); + std::string detail; + if (!StoreTrackOrdinary(root, true, detail)) + return fail(detail); + std::vector> pending{{root, 0}}; + fs::path found; + std::size_t entries = 0; + while (!pending.empty()) { + if (stop.stop_requested()) + return fail("Track package discovery cancelled."); + auto [directory, depth] = std::move(pending.back()); + pending.pop_back(); + const auto manifest = directory / "manifest.json"; + std::error_code code; + const bool has_manifest = fs::exists(dkr::store::store_io_path(manifest), code); + if (code) + return fail("Could not inspect the track manifest: " + code.message()); + if (has_manifest) { + if (!StoreTrackOrdinary(manifest, false, detail)) + return fail(detail); + if (!found.empty()) + return fail("The track package contains more than one course. Import each .dkrmap separately."); + found = directory; + // Asset/source subdirectories inside a course are not other courses. + continue; + } + fs::directory_iterator item(directory, code), end; + if (code) + return fail("Could not read the track package folder: " + code.message()); + for (; item != end; item.increment(code)) { + if (code) + break; + if (stop.stop_requested()) + return fail("Track package discovery cancelled."); + if (++entries > 100000) + return fail("The track package contains too many files to locate its course safely."); + const auto state = item->symlink_status(code); + if (code) + break; + const bool directory_entry = fs::is_directory(state); + if (!StoreTrackOrdinary(item->path(), directory_entry, detail)) + return fail(detail); + if (!directory_entry) + continue; + if (depth >= 8) + return fail("The track package has too many wrapper folders. Import its .dkrmap folder directly."); + pending.emplace_back(item->path(), depth + 1); + } + if (code) + return fail("Could not read the track package folder: " + code.message()); + } + if (found.empty()) + return fail("This package does not contain a track manifest.json. Import its .dkrmap folder or ZIP."); + + // Use the companion named by this course, not the first unrelated ZIP in + // the author's source bundle. Stay inside the private extraction root. + fs::path companion; + std::vector bytes; + if (read_file(found / "manifest.json", bytes)) { + const auto manifest = nlohmann::json::parse(bytes.begin(), bytes.end(), nullptr, false); + if (manifest.is_object()) { + const auto hd = manifest.find("hdTexturePack"); + if (hd != manifest.end() && hd->is_object()) { + const auto file = hd->find("file"); + if (file != hd->end() && file->is_string()) { + const auto name = file->get(); + if (dkr::runtime::custom_tracks::files::member(name) && name.find('/') == name.npos) { + std::vector parents; + if (found != root) + parents.push_back(found.parent_path()); + parents.push_back(found); + if (found != root) + parents.push_back(root); + for (const auto& parent : parents) { + const auto candidate = parent / fs::u8path(name); + std::error_code code; + const bool exists = fs::exists(dkr::store::store_io_path(candidate), code); + if (code) + return fail("Could not inspect the track's HD textures: " + code.message()); + if (!exists) + continue; + if (!StoreTrackOrdinary(candidate, false, detail)) + return fail(detail); + companion = candidate; + break; + } + } + } + } + } + } + track_dir = std::move(found); + hd_pack = std::move(companion); + return true; +} diff --git a/runtime-recomp/src/game/store_track_zip.inl b/runtime-recomp/src/game/store_track_zip.inl new file mode 100644 index 00000000..2cba19ce --- /dev/null +++ b/runtime-recomp/src/game/store_track_zip.inl @@ -0,0 +1,178 @@ +// First-party archive I/O, composed inside custom_tracks.cpp's private namespace. +// miniz receives native FILE handles, never an ANSI conversion of a Windows path. +using StoreTrackFile = std::unique_ptr; +StoreTrackFile StoreTrackOpen(const std::filesystem::path& path, bool writing) { + const auto native = dkr::store::store_io_path(path); +#ifdef _WIN32 + return {::_wfopen(native.c_str(), writing ? L"wb" : L"rb"), &std::fclose}; +#else + return {std::fopen(native.c_str(), writing ? "wb" : "rb"), &std::fclose}; +#endif +} +struct StoreTrackArchive { + StoreTrackFile file{nullptr, &std::fclose}; + mz_zip_archive zip{}; + bool opened = false; + explicit StoreTrackArchive(const std::filesystem::path& path) : file(StoreTrackOpen(path, false)) { + std::error_code error; + const auto size = std::filesystem::file_size(dkr::store::store_io_path(path), error); + if (file && !error && size <= 2ULL * 1024 * 1024 * 1024) + opened = mz_zip_reader_init_cfile(&zip, file.get(), size, 0) != MZ_FALSE; + } + ~StoreTrackArchive() { + if (opened) + mz_zip_reader_end(&zip); + } + std::string reason() { + const auto* message = mz_zip_get_error_string(mz_zip_get_last_error(&zip)); + return message ? message : "unreadable archive data"; + } +}; +bool StoreTrackOrdinary(const std::filesystem::path& path, bool directory, std::string& error) { + const auto native = dkr::store::store_io_path(path); + std::error_code code; + const auto state = std::filesystem::symlink_status(native, code); + bool ordinary = !code && !std::filesystem::is_symlink(state) && + (directory ? std::filesystem::is_directory(state) : std::filesystem::is_regular_file(state)); +#ifdef _WIN32 + const auto flags = GetFileAttributesW(native.c_str()); + ordinary = ordinary && flags != INVALID_FILE_ATTRIBUTES && !(flags & FILE_ATTRIBUTE_REPARSE_POINT); +#endif + if (!ordinary) + error = "The track extraction folder contains an unsafe or unavailable path."; + return ordinary; +} +bool StoreTrackZipPath(std::string name, bool directory, std::filesystem::path& relative) { + std::replace(name.begin(), name.end(), '\\', '/'); + if (directory && !name.empty() && name.back() == '/') + name.pop_back(); + if (name.empty() || name.size() > 4096 || name.front() == '/' || name.back() == '/' || + name.find("//") != name.npos || std::any_of(name.begin(), name.end(), [](unsigned char c) { + return c < 32 || c == ':' || c == '<' || c == '>' || c == '"' || c == '|' || c == '?' || c == '*'; + })) + return false; + relative = std::filesystem::u8path(name); + if (relative.is_absolute() || relative.has_root_name()) + return false; + unsigned depth = 0; + for (const auto& part : relative) { + const auto encoded = part.generic_u8string(); + std::string leaf(encoded.begin(), encoded.end()); + if (++depth > 64 || leaf.empty() || leaf == "." || leaf == ".." || leaf.back() == '.' || leaf.back() == ' ') + return false; + auto device = leaf.substr(0, leaf.find('.')); + std::transform(device.begin(), device.end(), device.begin(), + [](unsigned char c) { return char(std::toupper(c)); }); + if (device == "CON" || device == "PRN" || device == "AUX" || device == "NUL" || + (device.size() == 4 && (device.starts_with("COM") || device.starts_with("LPT")) && device[3] >= '0' && + device[3] <= '9')) + return false; + } + return true; +} +bool StoreReadTrackZipEntry(const std::filesystem::path& path, const char* name, std::vector& bytes) { + StoreTrackArchive archive(path); + if (!archive.opened) + return false; + const int index = mz_zip_reader_locate_file(&archive.zip, name, nullptr, 0); + mz_zip_archive_file_stat stat{}; + if (index < 0 || !mz_zip_reader_file_stat(&archive.zip, mz_uint(index), &stat) || + stat.m_uncomp_size > 16U * 1024U * 1024U) + return false; + std::size_t size = 0; + void* data = mz_zip_reader_extract_to_heap(&archive.zip, mz_uint(index), &size, 0); + if (!data) + return false; + const auto* first = static_cast(data); + bytes.assign(first, first + size); + mz_free(data); + return true; +} +bool StoreExtractTrackZip(const std::filesystem::path& path, const std::filesystem::path& destination, + std::string& error, std::stop_token stop) { + StoreTrackArchive archive(path); + if (!archive.opened) { + error = "The track ZIP could not be opened: " + archive.reason(); + return false; + } + if (!StoreTrackOrdinary(destination, true, error)) + return false; + const auto count = mz_zip_reader_get_num_files(&archive.zip); + if (!count || count > 100000) { + error = "The track ZIP has an invalid number of entries."; + return false; + } + std::uint64_t total = 0; + for (mz_uint index = 0; index < count; ++index) { + if (stop.stop_requested()) { + error = "Track ZIP extraction cancelled."; + return false; + } + mz_zip_archive_file_stat stat{}; + const auto length = mz_zip_reader_get_filename(&archive.zip, index, nullptr, 0); + if (!mz_zip_reader_file_stat(&archive.zip, index, &stat) || !length || length > 4097) { + error = "The track ZIP has an unreadable directory entry."; + return false; + } + std::vector encoded(length); + if (mz_zip_reader_get_filename(&archive.zip, index, encoded.data(), length) != length || + std::strlen(encoded.data()) + 1 != length) { + error = "The track ZIP has an invalid filename."; + return false; + } + const std::string name(encoded.data()); + const bool directory = mz_zip_reader_is_file_a_directory(&archive.zip, index) != MZ_FALSE; + const auto unix_type = (stat.m_external_attr >> 16) & 0170000; + std::filesystem::path relative; + if (!StoreTrackZipPath(name, directory, relative) || + (unix_type && unix_type != 0100000 && unix_type != 0040000)) { + error = "The track ZIP contains an unsafe path: " + name; + return false; + } + if (stat.m_is_encrypted || !mz_zip_reader_is_file_supported(&archive.zip, index) || + stat.m_uncomp_size > 512ULL * 1024 * 1024 || stat.m_uncomp_size > 8ULL * 1024 * 1024 * 1024 - total) { + error = "The track ZIP entry is encrypted, unsupported or exceeds its extraction limit: " + name; + return false; + } + total += stat.m_uncomp_size; + auto target = destination; + unsigned component = 0; + const auto parts = std::distance(relative.begin(), relative.end()); + for (const auto& part : relative) { + target /= part; + if (++component == parts && !directory) + break; + std::error_code code; + std::filesystem::create_directory(dkr::store::store_io_path(target), code); + if ((code && code != std::errc::file_exists) || !StoreTrackOrdinary(target, true, error)) { + if (error.empty()) + error = "Could not create the track ZIP folder '" + name + "': " + code.message(); + return false; + } + } + if (directory) + continue; + std::error_code code; + const auto state = std::filesystem::symlink_status(dkr::store::store_io_path(target), code); + if ((!code && state.type() != std::filesystem::file_type::not_found) || + (code && code != std::errc::no_such_file_or_directory)) { + error = "The track ZIP repeats or conflicts with a path: " + name; + return false; + } + auto output = StoreTrackOpen(target, true); + if (!output) { + error = "Could not create track ZIP file '" + name + "': " + std::strerror(errno); + return false; + } + const bool extracted = mz_zip_reader_extract_to_cfile(&archive.zip, index, output.get(), 0) != MZ_FALSE; + const bool closed = std::fclose(output.release()) == 0; + if (!extracted || !closed || + std::filesystem::file_size(dkr::store::store_io_path(target), code) != stat.m_uncomp_size || code) { + error = "Could not extract track ZIP file '" + name + "': " + + (extracted ? std::string("incomplete output; check free disk space and folder permissions") + : archive.reason()); + return false; + } + } + return true; +} diff --git a/runtime-recomp/src/game/store_vehicle_adapter.cpp b/runtime-recomp/src/game/store_vehicle_adapter.cpp new file mode 100644 index 00000000..1b48fcde --- /dev/null +++ b/runtime-recomp/src/game/store_vehicle_adapter.cpp @@ -0,0 +1,163 @@ +#include "store_vehicle_adapter.hpp" +#include "vehicle_library.hpp" +#include "mods/online_mod_cache.hpp" +#include +#include + +namespace dkr::store { +namespace { +namespace cars = dkr::mods::vehicles; +namespace fs = std::filesystem; +using Bytes = dkr::mods::Bytes; +void running(std::stop_token stop) { + if (stop.stop_requested()) throw dkr::mods::Error("Vehicle Store operation cancelled before publication."); +} +std::string folded(std::string value) { + for (auto& c : value) if (c >= 'A' && c <= 'Z') c = char(c - 'A' + 'a'); + return value; +} +std::string path_text(const fs::path& path) { + const auto bytes = path.generic_u8string(); return {bytes.begin(), bytes.end()}; +} +Json member(const cars::Item& item) { + return {{"kind", "vehicle"}, {"key", "vehicle:" + item.digest}, {"id", item.id}, + {"digest", item.digest}, {"name", item.name}, {"type", unsigned(item.type)}, + {"path", "mods/vehicles/packages/" + item.digest + cars::extension(item.type)}, + {"enabled", item.enabled}}; +} +bool owned_elsewhere(const std::string& digest, const Json& owners) { + return std::any_of(owners.begin(), owners.end(), [&](const Json& m) { + return text(m, "kind", 40) == "vehicle" && text(m, "digest", 64) == digest; + }); +} +} + +bool vehicle_store_category(std::string_view value) { return value == "legacy" || value == "vehicles"; } +bool vehicle_store_extension(std::string_view value) { + return value == ".dkrkart" || value == ".dkrhover" || value == ".dkrplane"; +} +StorePrepared prepare_store_vehicle(const fs::path& source, const fs::path& stage, std::stop_token stop) { + running(stop); + // The normal importer validates every inner export, seat table, texture, + // portrait and LOD. Nothing is installed into the live library here. + cars::Library staging(stage / "library"); + const auto batch = staging.prepare_import(source); + running(stop); + staging.import_prepared(batch, cars::ImportPolicy::KeepBoth); + StorePrepared result; + result.native = {{"vehicle", true}, {"members", Json::array()}}; + for (const auto& item : staging.scan(stop)) { + running(stop); + result.native["members"].push_back(member(item)); + result.outputs.push_back({{"kind", "vehicle"}, {"digest", item.digest}, + {"name", item.source_name}, {"type", unsigned(item.type)}, + {"source", path_text(staging.root() / "packages" / (item.digest + cars::extension(item.type)))}, + {"target", "mods/vehicles/packages/" + item.digest + cars::extension(item.type)}}); + result.outputs.push_back({{"kind", "vehicle-portrait"}, {"digest", item.digest}, + {"source", path_text(staging.root() / "portraits" / (item.digest + ".rgba"))}, + {"target", "mods/vehicles/portraits/" + item.digest + ".rgba"}}); + } + if (result.native["members"].empty()) throw dkr::mods::Error("This download contains no supported vehicles."); + return result; +} +Json publish_store_vehicles(const fs::path& config, const StorePrepared& prepared, + const Json& previous, const Json& other_owners, std::stop_token stop) { + cars::Library library(config / "mods/vehicles"); + const auto before = library.scan(stop); + cars::ImportBatch batch; + for (const auto& output : prepared.outputs) if (text(output, "kind", 40) == "vehicle") { + running(stop); + const auto type = cars::Type(output.at("type").get()); + auto p = cars::inspect(dkr::mods::read_file(fs::u8path(text(output, "source", 4096)), cars::MaxPackageBytes), + text(output, "name", 240), type); + if (p->digest != text(output, "digest", 64)) + throw dkr::mods::Error("The prepared vehicle changed before publication."); + batch.packages.push_back(std::move(p)); + } + running(stop); + // Store duplicate choices identify exact owners. KeepBoth imports the same + // validated exports without deleting every unrelated same-name local car. + library.import_prepared(batch, cars::ImportPolicy::KeepBoth); + std::set incoming; + for (const auto& p : batch.packages) incoming.insert(p->digest); + for (const auto& old : previous) { + const auto digest = text(old, "digest", 64); + if (text(old, "kind", 40) == "vehicle" && cars::valid_digest(digest) && !incoming.contains(digest) && + !owned_elsewhere(digest, other_owners) && + std::any_of(before.begin(), before.end(), [&](const auto& item) { return item.digest == digest; })) { + running(stop); library.remove(digest); // Immutable files/history remain recoverable. + } + } + for (const auto& p : batch.packages) { + // Identical already-installed exports retain their current activation. + if (std::any_of(before.begin(), before.end(), [&](const auto& i) { return i.digest == p->digest; })) continue; + const Json* old = nullptr; + for (const auto& m : previous) if (text(m, "kind", 40) == "vehicle" && + m.value("type", 0U) == unsigned(p->type) && folded(text(m, "name", 240)) == folded(p->name)) { old = &m; break; } + if (!old && batch.packages.size() == 1 && previous.size() == 1 && text(previous.front(), "kind", 40) == "vehicle") + old = &previous.front(); + if (old) { + bool enabled = old->value("enabled", true); + for (const auto& item : before) if (item.digest == text(*old, "digest", 64)) enabled = item.enabled; + running(stop); library.enable(p->digest, enabled); + } + } + Json members = Json::array(); + for (const auto& item : library.scan(stop)) if (incoming.contains(item.digest)) members.push_back(member(item)); + if (members.size() != batch.packages.size()) throw dkr::mods::Error("The vehicle library did not publish every prepared car."); + return members; +} +void remove_store_vehicle(const fs::path& config, const Json& m, std::stop_token stop) { + const auto digest = text(m, "digest", 64); + if (!cars::valid_digest(digest)) throw dkr::mods::Error("Invalid vehicle installation receipt."); + cars::Library library(config / "mods/vehicles"); + for (const auto& item : library.scan(stop)) if (item.digest == digest) { + running(stop); library.remove(digest); return; + } +} + +fs::path prepare_vehicle_store_upload(const fs::path& source, const fs::path& cache, std::stop_token stop) { + const auto ext = folded(path_text(source.extension())); + if (!vehicle_store_extension(ext)) return source; + running(stop); + const auto inputs = cars::read_inputs(source); + if (inputs.size() != 1) throw dkr::mods::Error("Choose one standalone vehicle export to upload."); + const auto package = cars::inspect(inputs.front().bytes, inputs.front().name, inputs.front().type); + // The deployed API already accepts ZIP uploads. A deterministic stored ZIP + // preserves the exact inner .dkrkart bytes and keeps resumable uploads stable. + const std::string leaf = std::string("vehicle") + cars::extension(package->type); + const auto& payload = package->source; + std::uint32_t crc = 0xffffffffU; + for (auto byte : payload) { + crc ^= byte; + for (unsigned bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xedb88320U & (0U - (crc & 1U))); + } + crc ^= 0xffffffffU; + Bytes zip; + auto u16 = [&](std::uint16_t n) { zip.push_back(std::uint8_t(n)); zip.push_back(std::uint8_t(n >> 8)); }; + auto u32 = [&](std::uint32_t n) { u16(std::uint16_t(n)); u16(std::uint16_t(n >> 16)); }; + const auto size = std::uint32_t(payload.size()); + u32(0x04034b50);u16(20);u16(0);u16(0);u16(0);u16(33);u32(crc);u32(size);u32(size);u16(std::uint16_t(leaf.size()));u16(0); + zip.insert(zip.end(), leaf.begin(), leaf.end());zip.insert(zip.end(), payload.begin(), payload.end()); + const auto directory = std::uint32_t(zip.size()); + u32(0x02014b50);u16(20);u16(20);u16(0);u16(0);u16(0);u16(33);u32(crc);u32(size);u32(size); + u16(std::uint16_t(leaf.size()));u16(0);u16(0);u16(0);u16(0);u32(0);u32(0);zip.insert(zip.end(), leaf.begin(), leaf.end()); + const auto directory_size = std::uint32_t(zip.size()) - directory; + u32(0x06054b50);u16(0);u16(0);u16(1);u16(1);u32(directory_size);u32(directory);u16(0); + running(stop); + if(zip.size()>cars::MaxPackageBytes) + throw dkr::mods::Error("This export is too large for a vehicle ZIP. Reduce the vehicle package size before uploading."); + dkr::mods::online::check_storage(cache.parent_path(), true); + fs::create_directories(cache);dkr::mods::online::check_storage(cache, true); + const auto folder = cache / dkr::mods::sha256(zip); + fs::create_directory(folder);dkr::mods::online::check_storage(folder, true); + const auto target = folder / fs::u8path(package_filename(package->name, "vehicle.zip")); + if (!fs::exists(target)) dkr::mods::write_new_file(dkr::mods::private_storage_path(target), zip); + else { + dkr::mods::online::check_storage(target, false); + if (dkr::mods::read_file(dkr::mods::private_storage_path(target), cars::MaxPackageBytes + 4096) != zip) + throw dkr::mods::Error("The prepared vehicle upload cache has changed."); + } + return target; +} +} diff --git a/runtime-recomp/src/game/store_vehicle_adapter.hpp b/runtime-recomp/src/game/store_vehicle_adapter.hpp new file mode 100644 index 00000000..c563ae43 --- /dev/null +++ b/runtime-recomp/src/game/store_vehicle_adapter.hpp @@ -0,0 +1,14 @@ +#pragma once +#include "mod_store_install.hpp" +#include + +namespace dkr::store { +bool vehicle_store_category(std::string_view); +bool vehicle_store_extension(std::string_view); +StorePrepared prepare_store_vehicle(const std::filesystem::path&, const std::filesystem::path&, std::stop_token); +Json publish_store_vehicles(const std::filesystem::path& config, const StorePrepared&, + const Json& previous, const Json& other_owners, std::stop_token); +void remove_store_vehicle(const std::filesystem::path& config, const Json& member, std::stop_token); +std::filesystem::path prepare_vehicle_store_upload(const std::filesystem::path&, + const std::filesystem::path& cache, std::stop_token); +} diff --git a/runtime-recomp/src/game/task_submission.cpp b/runtime-recomp/src/game/task_submission.cpp new file mode 100644 index 00000000..5732162d --- /dev/null +++ b/runtime-recomp/src/game/task_submission.cpp @@ -0,0 +1,23 @@ +// Owned adapter around an unchanged SDK entrypoint. The included source is +// never copied, patched or written by this layer; its other exports are kept. +#include "diagnostics.hpp" +#include +#include "sdk_task_source.hpp" +#define osSpTaskStartGo_recomp dkr_stability_original_osSpTaskStartGo_recomp +#include DKR_STABILITY_ORIGINAL_SP +#undef osSpTaskStartGo_recomp + +extern "C" void osSpTaskStartGo_recomp(uint8_t* rdram,recomp_context* ctx) { + bool audio=false; + if(dkr::lobby_stability::bootstrap_match.load()) { + if(!rdram||!ctx)throw std::runtime_error("Invalid bootstrap task submission."); + const auto address=static_cast(ctx->r4); + const auto physical=address&0x1FFFFFFFU; + if((address&3U)||physical>0x800000U-sizeof(OSTask))throw std::runtime_error("Invalid bootstrap task address."); + const OSTask* task=TO_PTR(OSTask,ctx->r4); + audio=task->t.type==M_AUDTASK; + if(audio)dkr::lobby_stability::bootstrap_event("audio-queued-to-SP-worker",0,address); + } + dkr::lobby_stability::BootstrapAudioSubmission submission(audio); + dkr_stability_original_osSpTaskStartGo_recomp(rdram,ctx); +} diff --git a/runtime-recomp/src/game/texture_binding_index.hpp b/runtime-recomp/src/game/texture_binding_index.hpp new file mode 100644 index 00000000..ad6f266b --- /dev/null +++ b/runtime-recomp/src/game/texture_binding_index.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include +#include + +namespace dkr::runtime::surgical { +// Preserve the original FIRST cache occurrence, including invalid texture IDs. +// Raw pointers are deliberately not masked: the original comparison was exact. +class TextureBindingIndex { + struct Binding {std::uint32_t pointer,ordinal;std::uint16_t id;}; + std::vector entries_; +public: + template + TextureBindingIndex(Memory memory,std::uint32_t cache,std::uint32_t count,Resolve resolve) { + entries_.reserve(count); + for(std::uint32_t i=0;ipointer==pointer ? at->id : 65535; + } +}; +} diff --git a/runtime-recomp/src/game/texture_capture_bridge.hpp b/runtime-recomp/src/game/texture_capture_bridge.hpp new file mode 100644 index 00000000..c76ac8e1 --- /dev/null +++ b/runtime-recomp/src/game/texture_capture_bridge.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +// Presentation-only observation ABI. The renderer borrows TMEM only during +// this call. It never holds a game pointer, runs a guest callback or does I/O. +namespace dkr::texture_capture { +struct Tile { + std::uint64_t native_hash=0, rice_alias=0; + std::uint32_t width=0,height=0,tlut=0,fmt=0,siz=0,line=0,tmem=0,palette=0; + std::uint32_t cms=0,cmt=0,masks=0,maskt=0,shifts=0,shiftt=0; + std::uint32_t uls=0,ult=0,lrs=0,lrt=0; + char rice_name[64]{}; +}; +using Observer=void(*)(const Tile&,const std::uint8_t*) noexcept; +using Gate=bool(*)(std::uint64_t) noexcept; +inline std::atomic observer{nullptr}; +inline std::atomic gate{nullptr}; +} diff --git a/runtime-recomp/src/game/texture_decode.hpp b/runtime-recomp/src/game/texture_decode.hpp new file mode 100644 index 00000000..f1b7fb76 --- /dev/null +++ b/runtime-recomp/src/game/texture_decode.hpp @@ -0,0 +1,91 @@ +#pragma once +#include "texture_capture_bridge.hpp" +#include "legacy_mod_format.hpp" +#include +#include + +namespace dkr::runtime::texture_dump { +struct Picture { unsigned width=0,height=0; mods::Bytes rgba; }; +inline std::array rgba16(unsigned v) { + auto c=[](unsigned n){return std::uint8_t((n<<3)|(n>>2));}; + return {c((v>>11)&31),c((v>>6)&31),c((v>>1)&31),std::uint8_t(v&1?255:0)}; +} +inline std::array intensity(unsigned v,unsigned a) { + return {std::uint8_t(v),std::uint8_t(v),std::uint8_t(v),std::uint8_t(a)}; +} +// Same integer channel expansion and TMEM addressing as the pinned renderer's +// Formats.hlsli / TextureDecoder.hlsli. No shader, gamma or filtering is baked in. +inline Picture decode_tmem(const texture_capture::Tile& t,const std::array& m) { + if(!t.width||!t.height||t.width>4096||t.height>4096||std::uint64_t(t.width)*t.height>16*mods::MiB||!t.line||t.siz>3) + throw mods::Error("Invalid captured tile dimensions or stride."); + if(t.fmt==1||t.fmt>4)throw mods::Error("This tile needs an unsupported colour conversion; it was not exported."); + Picture p{t.width,t.height,mods::Bytes(std::size_t(t.width)*t.height*4)}; + const bool split=t.fmt==0&&t.siz==3; + const unsigned mask=(split||t.tlut)?2047:4095,stride=t.line*8,start=t.tmem*8; + for(unsigned y=0;y>1); + const auto load=[&](unsigned rel,unsigned bank=0) { + const unsigned row=(rel/stride)*stride,word=(rel-row)/4; + const unsigned at=(y&1)?start+row+((word^1)*4)+(rel&3):start+rel; + return unsigned(m[((at&mask)|bank)&4095]); + }; + const unsigned a=load(address),b=load(address+1),n=(x&1)?a&15:a>>4; + std::array c{}; + if(t.tlut) { + const unsigned at=2048+((t.siz==0?((t.palette<<4)|n):a)<<3); + const unsigned v=(unsigned(m[at&4095])<<8)|m[(at+1)&4095]; + if(t.tlut==2)c=rgba16(v); + else if(t.tlut==3)c=intensity(v>>8,v&255); + else throw mods::Error("Unknown captured palette mode."); + }else if(t.siz==0) { + if(t.fmt==3){const auto i=n&14;c=intensity((i<<4)|(i<<1)|(i>>2),n&1?255:0);} + else if(t.fmt==2){const auto i=(t.palette<<4)|n;c=intensity(i,i);} + else c=intensity(n*17,n*17); + }else if(t.siz==1) { + c=t.fmt==3?intensity((a>>4)*17,(a&15)*17):intensity(a,a); + }else if(t.siz==2) { + if(t.fmt==0)c=rgba16((a<<8)|b); + else if(t.fmt==3)c=intensity(a,b); + else c={std::uint8_t(a),std::uint8_t(b),std::uint8_t(a),std::uint8_t(b)}; + }else { + const auto hi=split?address:address+2; + const unsigned cc=load(hi,split?2048:0),d=load(hi+1,split?2048:0); + if(t.fmt==0)c={std::uint8_t(a),std::uint8_t(b),std::uint8_t(cc),std::uint8_t(d)}; + else c=(x&1)?std::array{std::uint8_t(a),std::uint8_t(b),std::uint8_t(a),std::uint8_t(b)}: + std::array{std::uint8_t(cc),std::uint8_t(d),std::uint8_t(cc),std::uint8_t(d)}; + } + std::copy(c.begin(),c.end(),p.rgba.begin()+(std::size_t(y)*t.width+x)*4); + } + return p; +} +inline Picture decode_asset(mods::View bytes,unsigned width,unsigned height,unsigned format,bool interlaced,mods::View palette={}) { + static constexpr unsigned bits[]{32,16,8,4,16,8,4,4,8}; + if(!width||!height||format>=9)throw mods::Error("Invalid source texture header."); + const unsigned bpp=bits[format],row=(width*bpp+7)/8; + if(std::size_t(row)*height>bytes.size())throw mods::Error("Source texture pixels are truncated."); + auto value=[&](unsigned y,unsigned offset) { + if(interlaced&&(y&1)) { + const unsigned chunk=bpp==32?8:4,whole=(row/(chunk*2))*(chunk*2); + if(offset>4; + std::array c{}; + switch(format) { + case 0:c={std::uint8_t(a),std::uint8_t(value(y,offset+1)),std::uint8_t(value(y,offset+2)),std::uint8_t(value(y,offset+3))};break; + case 1:c=rgba16((a<<8)|value(y,offset+1));break; + case 2:c=intensity(a,a);break; + case 3:c=intensity(n*17,n*17);break; + case 4:c=intensity(a,value(y,offset+1));break; + case 5:c=intensity((a>>4)*17,(a&15)*17);break; + case 6:{auto i=n&14;c=intensity((i<<4)|(i<<1)|(i>>2),n&1?255:0);break;} + default:{const unsigned i=format==7?n:a;if(i*2+2>palette.size())throw mods::Error("Palette is unavailable; capture this texture in game.");c=rgba16(mods::be16(palette,i*2));break;} + } + std::copy(c.begin(),c.end(),p.rgba.begin()+(std::size_t(y)*width+x)*4); + } + return p; +} +} diff --git a/runtime-recomp/src/game/texture_dump.cpp b/runtime-recomp/src/game/texture_dump.cpp new file mode 100644 index 00000000..8ed132bc --- /dev/null +++ b/runtime-recomp/src/game/texture_dump.cpp @@ -0,0 +1,544 @@ +#include "texture_dump.hpp" +#include "texture_decode.hpp" +#include "legacy_track_catalog.hpp" +#include "vehicle_library.hpp" +#include "custom_tracks.hpp" +#include "runtime_texture_packs.hpp" +#include +#include "stb/stb_image.h" +#include "stb/stb_image_write.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace dkr::runtime::texture_dump { +namespace { +using Json=nlohmann::json; +using namespace dkr::mods; +using Data=dkr::mods::View; +namespace car_assets=dkr::mods::vehicles; +using Clock=std::chrono::steady_clock; +std::string hex(std::uint64_t n,unsigned width=16) {std::ostringstream s;s<=72)break;out+=(c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='-'?char(c):'_';} + return "asset_"+(out.empty()?std::string("unnamed"):out); // No DOS device names, trailing dots or traversal. +} +bool link(const std::filesystem::path& p) { + std::error_code ec;if(std::filesystem::is_symlink(std::filesystem::symlink_status(p,ec)))return true; +#ifdef _WIN32 + auto a=GetFileAttributesW(p.c_str());if(a!=INVALID_FILE_ATTRIBUTES&&(a&FILE_ATTRIBUTE_REPARSE_POINT))return true; +#endif + return false; +} +void directory(const std::filesystem::path& p) { + if(p.empty())throw Error("Choose a texture dump folder."); + if(std::filesystem::exists(p)) {if(link(p)||!std::filesystem::is_directory(p))throw Error("The dump contains a link or a non-folder path.");return;} + if(p.parent_path()==p||p.parent_path().empty())throw Error("The selected drive or parent folder is unavailable."); + directory(p.parent_path());std::filesystem::create_directory(p); +} +std::filesystem::path member(const std::filesystem::path& root,std::string_view name) { + if(name.empty()||name.find('\\')!=name.npos||name.find(':')!=name.npos||name.find('\0')!=name.npos)throw Error("The dump manifest contains an unsafe path."); + auto rel=utf8_path(name);if(rel.is_absolute()||rel.has_root_path())throw Error("The dump manifest contains an absolute path."); + auto at=root;for(const auto& part:rel) { + const auto name=part.string();if(part==".."||part=="."||part.empty()||name.size()>200||name.back()=='.'||name.back()==' ')throw Error("The dump manifest contains an unsafe path."); + for(unsigned char ch:name)if(!((ch>='A'&&ch<='Z')||(ch>='a'&&ch<='z')||(ch>='0'&&ch<='9')||ch=='_'||ch=='-'||ch=='.'))throw Error("The dump manifest contains an unsupported filename."); + auto stem=name.substr(0,name.find('.'));for(auto& ch:stem)ch=char(std::tolower(static_cast(ch))); + if(stem=="con"||stem=="prn"||stem=="aux"||stem=="nul"||(stem.size()==4&&(stem.starts_with("com")||stem.starts_with("lpt"))&&stem[3]>='0'&&stem[3]<='9'))throw Error("The dump manifest contains a reserved filename."); + at/=part;if(link(at))throw Error("A dump path is a link."); + } + return at; +} +void atomic_file(const std::filesystem::path& path,Data bytes,bool replace=false) { + directory(path.parent_path());if(link(path))throw Error("The output file is a link."); + if(std::filesystem::exists(path)&&!replace) { + if(sha256(read_file(path,64*MiB))!=sha256(bytes))throw Error("An existing original has been edited. Choose a new dump folder; nothing was overwritten."); + return; + } + if(std::filesystem::space(path.parent_path()).available sequence{0}; + auto temp=path;temp+=".writing-"+std::to_string(Clock::now().time_since_epoch().count())+"-"+std::to_string(++sequence); + try { + write_new_file(temp,bytes); +#ifdef _WIN32 + if(!MoveFileExW(temp.c_str(),path.c_str(),replace?MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH:MOVEFILE_WRITE_THROUGH))throw Error("Could not commit the texture file. Check folder permissions."); +#else + if(!replace&&std::filesystem::exists(path))throw Error("The output was created by another writer."); + std::filesystem::rename(temp,path); +#endif + }catch(...){std::error_code ec;std::filesystem::remove(temp,ec);throw;} +} +void atomic_json(const std::filesystem::path& p,const Json& j) {auto s=j.dump(2,' ',false,Json::error_handler_t::replace);atomic_file(p,Data(reinterpret_cast(s.data()),s.size()),true);} +Bytes png(const Picture& p) { + Bytes out; + const auto write=[](void* c,void* b,int n){auto& v=*static_cast(c);auto* a=static_cast(b);v.insert(v.end(),a,a+n);}; + if(!stbi_write_png_to_func(write,&out,int(p.width),int(p.height),4,p.rgba.data(),int(p.width*4)))throw Error("Could not encode the texture PNG."); + return out; +} +Picture read_png(Data bytes) { + constexpr std::array signature{137,80,78,71,13,10,26,10}; + if(bytes.size()64*MiB||!stbi_info_from_memory(bytes.data(),int(bytes.size()),&w,&h,&c)||w<=0||h<=0||std::uint64_t(w)*h>16*MiB)throw Error("Source image dimensions exceed the safe PNG workspace budget."); + auto* raw=stbi_load_from_memory(bytes.data(),int(bytes.size()),&w,&h,&c,4);if(!raw)throw Error("Source PNG could not be decoded."); + struct Free{unsigned char* p;~Free(){stbi_image_free(p);}}free{raw}; + return {unsigned(w),unsigned(h),Bytes(raw,raw+std::size_t(w)*h*4)}; +} +Bytes zip_member(Data bytes,const char* name) { + mz_zip_archive zip{};if(!mz_zip_reader_init_mem(&zip,bytes.data(),bytes.size(),0))throw Error("Source artwork archive is unreadable."); + struct End{mz_zip_archive& z;~End(){mz_zip_reader_end(&z);}}end{zip}; + const auto index=mz_zip_reader_locate_file(&zip,name,nullptr,0);mz_zip_archive_file_stat stat{}; + if(index<0||!mz_zip_reader_file_stat(&zip,unsigned(index),&stat)||stat.m_uncomp_size>64*MiB)throw Error("Source artwork entry is missing or too large."); + Bytes out(std::size_t(stat.m_uncomp_size));if(!mz_zip_reader_extract_to_mem(&zip,unsigned(index),out.data(),out.size(),0))throw Error("Source artwork has an invalid archive checksum.");return out; +} +Bytes embedded_png(std::string_view source) { + constexpr std::string_view prefix="data:image/png;base64,"; + if(!source.starts_with(prefix)||source.size()>8*MiB)throw Error("Invalid embedded source PNG."); + source.remove_prefix(prefix.size());Bytes bytes;unsigned accumulator=0,bits=0; + constexpr std::string_view alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for(char c:source){if(c=='=')break;const auto n=alphabet.find(c);if(n==alphabet.npos)throw Error("Invalid source PNG encoding.");accumulator=(accumulator<<6)|unsigned(n);bits+=6;if(bits>=8){bits-=8;bytes.push_back(std::uint8_t(accumulator>>bits));}} + return bytes; +} +std::string pixel_key(const Picture& p) {return std::to_string(p.width)+"x"+std::to_string(p.height)+":"+sha256(p.rgba);} +std::uint64_t rice_alias(std::string_view identity) { + std::uint64_t hash=14695981039346656037ULL;for(unsigned char c:std::string("dkr-r:rice:")+std::string(identity))hash=(hash^c)*1099511628211ULL; + hash^=hash>>33;hash*=0xff51afd7ed558ccdULL;hash^=hash>>33;hash*=0xc4ceb9fe1a85ec53ULL;return hash^(hash>>33); +} +// Conservative subset of DKR's standard material_init LoadBlock recipe. +// Narrow/odd rows, palettes, non-power-of-two masks and oversized images need +// a live observation; never hash bytes outside a source image to guess an ID. +std::string source_rice(Data pixels,unsigned w,unsigned h,unsigned format) { + static constexpr unsigned fmts[]{0,0,4,4,3,3,3},sizes[]{3,2,1,0,2,1,0}; + if(format>=7||!w||!h||(w&(w-1))||(h&(h-1))||w>64||h>64)return {}; + unsigned row=(w<>1; + if(row<8||row%8||std::size_t(row)*h>4096||std::size_t(row)*h>pixels.size())return {}; + std::uint32_t crc=0;std::size_t at=0; + for(int y=int(h)-1;y>=0;--y) {std::uint32_t v=0;for(int x=int(row)-4;x>=0;x-=4){v=be32(pixels,at+x)^unsigned(x);crc=(crc<<4)+(crc>>28);crc+=v;}crc+=v^unsigned(y);at+=row;} + return hex(crc,8)+"#"+std::to_string(fmts[format])+"#"+std::to_string(sizes[format]); +} +Json read_manifest(const std::filesystem::path& root,bool validate_members=true) { + const auto path=root/"dump.json"; + if(!std::filesystem::exists(path)) { + if(std::filesystem::exists(root)&&!std::filesystem::is_empty(root))throw Error("This folder is not a DKR-R texture dump. Choose another destination."); + return {{"schema",1},{"kind","dkr-r-texture-dump"},{"hash_version",5},{"images",Json::array()},{"issues",Json::array()}}; + } + if(link(root)||link(path))throw Error("The dump folder or manifest is a link."); + auto bytes=read_file(path,64*MiB);auto j=Json::parse(bytes); + if(j.value("kind","")!="dkr-r-texture-dump"||j.value("schema",0)!=1||j.value("hash_version",0)!=5||!j.at("images").is_array()||!j.at("issues").is_array())throw Error("This dump uses an unsupported manifest or texture hash version."); + if(validate_members)for(const auto& image:j["images"]){member(root,image.at("original").get());member(root,image.at("working").get());} + return j; +} +bool same_source_format(const Json& source,unsigned fmt,unsigned siz,unsigned tlut) { + if(source.value("runtime",false)||source.value("source_artwork",false)||!source.contains("format")||tlut)return false; + static constexpr unsigned fmts[]{0,0,4,4,3,3,3},sizes[]{3,2,1,0,2,1,0}; + const unsigned format=source.at("format");return format<7&&fmts[format]==fmt&&sizes[format]==siz; +} +struct Workspace { + std::filesystem::path root; + Json doc; + std::map rows; + std::map> pixels; + std::map originals; + std::set verified_originals; + unsigned changes=0; + explicit Workspace(std::filesystem::path p):root(private_storage_path(p)),doc(read_manifest(root)) { + directory(root); + for(std::size_t i=0;i(),i);const auto key=row.at("pixels").get();pixels[key].push_back(i);originals.emplace(key,row.at("original").get());} + } + void flush(){atomic_json(root/"dump.json",doc);changes=0;} + void issue(const std::string& source,const std::string& message) { + Json item={{"source",source},{"message",message}}; + if(std::find(doc["issues"].begin(),doc["issues"].end(),item)==doc["issues"].end())doc["issues"].push_back(std::move(item)); + if(++changes>=32)flush(); + } + bool image(std::string id,std::string owner,std::string name,const Picture& picture,Json meta) { + auto found=rows.find(id);const auto key=pixel_key(picture);bool reused=found!=rows.end(); + std::string original,working; + if(reused) {auto& row=doc["images"][found->second];if(row.at("pixels")!=key)throw Error("A source identity changed. Export to a new folder.");original=row["original"];working=row["working"];} + else {const auto stem=slug(owner)+"/"+slug(name)+"_"+sha256(Data(reinterpret_cast(id.data()),id.size())).substr(0,16);auto same=originals.find(key);original=same==originals.end()?"Originals/texture_"+std::to_string(picture.width)+"x"+std::to_string(picture.height)+"_"+key.substr(key.find(':')+1)+".png":same->second;working="Working/"+stem+".png";originals.emplace(key,original);} + Bytes encoded; + if(!verified_originals.contains(original)){encoded=png(picture);atomic_file(member(root,original),encoded);verified_originals.insert(original);} + const auto edit=member(root,working);if(!std::filesystem::exists(edit)){if(encoded.empty())encoded=read_file(member(root,original),64*MiB);atomic_file(edit,encoded);} + meta["id"]=id;meta["owner"]=owner;meta["name"]=name;meta["width"]=picture.width;meta["height"]=picture.height; + meta["pixels"]=key;meta["original"]=original;meta["working"]=working; + if(!meta.contains("hashes"))meta["hashes"]=Json::array(); + // Capturing before exporting must work too. Equal RGBA pixels alone + // are not provenance: only merge known, palette-free format matches. + if(auto group=pixels.find(key);group!=pixels.end())for(auto index:group->second) { + const auto& runtime=doc["images"][index];if(!runtime.value("runtime",false)||!runtime.contains("tile"))continue; + const auto& tile=runtime["tile"]; + if(same_source_format(meta,tile.at("format"),tile.at("size"),tile.at("tlut"))) + for(const auto& hash:runtime["hashes"])if(std::find(meta["hashes"].begin(),meta["hashes"].end(),hash)==meta["hashes"].end())meta["hashes"].push_back(hash); + } + meta["status"]=meta["hashes"].empty()?"Needs in-game capture":"Ready for replacement"; + if(reused) { + auto& prior=doc["images"][found->second]; + for(const auto& hash:prior.value("hashes",Json::array()))if(std::find(meta["hashes"].begin(),meta["hashes"].end(),hash)==meta["hashes"].end())meta["hashes"].push_back(hash); + meta["status"]=meta["hashes"].empty()?"Needs in-game capture":"Ready for replacement";prior=std::move(meta); + } else {pixels[key].push_back(doc["images"].size());rows.emplace(id,doc["images"].size());doc["images"].push_back(std::move(meta));} + if(++changes>=64)flush();return reused; + } + void associate(const Picture& p,const Json& hashes,unsigned fmt,unsigned siz,unsigned tlut) { + auto found=pixels.find(pixel_key(p));if(found==pixels.end())return; + for(auto i:found->second){auto& row=doc["images"][i];if(!same_source_format(row,fmt,siz,tlut))continue; + for(const auto& hash:hashes)if(std::find(row["hashes"].begin(),row["hashes"].end(),hash)==row["hashes"].end())row["hashes"].push_back(hash); + row["status"]="Ready for replacement";} + } + bool source_png(const std::string& source,const std::string& owner,const std::string& name,Data bytes) { + const auto digest=sha256(bytes);auto p=read_png(bytes); + const auto path="Source_Artwork/"+slug(owner)+"/"+slug(name)+"_"+digest.substr(0,16)+".png"; + atomic_file(member(root,path),bytes); + return image(source+":source:"+digest,owner,name,p,{{"source_id",source},{"source_artwork",true},{"source_png",path},{"source_png_sha256",digest},{"frame",0},{"orientation","as authored"}}); + } +}; +#include "texture_gallery.inl" +struct Packet {texture_capture::Tile tile;std::array bytes;}; +struct Service { + std::mutex mutex;std::condition_variable cv;View view; + std::shared_ptr published;bool dirty=true; + std::deque> jobs;std::deque queue; + std::unordered_set pending,committed; + std::unique_ptr live; + std::atomic cancelled{false},armed{false},pressure{false}; + std::atomic deferred{0}; + bool stopping=false,closing=false; + // Browser requests are coalesced separately from write/capture jobs. The + // cache is worker-owned; UI threads only see immutable result snapshots. + GalleryCache gallery_cache; + std::optional gallery_request; + std::atomic gallery_serial{0}; + std::jthread worker; + Service():worker([this]{run();}){} + ~Service(){texture_capture::observer.store(nullptr,std::memory_order_release);armed=false;cancelled=true;++gallery_serial;{std::lock_guard lock(mutex);closing=true;stopping=true;gallery_request.reset();}cv.notify_all();} + void update(const std::function& action){std::lock_guard lock(mutex);action(view);dirty=true;} + void check(){if(cancelled)throw Error("Export stopped. Completed textures are kept; export again to resume.");} + void progress(const std::string& status){check();update([&](auto& v){v.status=status;});} + bool job(std::function fn) { + std::lock_guard lock(mutex);if(view.busy||view.capturing||view.gallery_loading)return false; + view.busy=true;view.error.clear();view.status="Preparing textures...";dirty=true;cancelled=false;jobs.push_back(std::move(fn));cv.notify_one();return true; + } + bool browse(std::filesystem::path root,std::string search,unsigned page,bool reload) { + root=private_storage_path(root); + std::lock_guard lock(mutex);if(closing||view.busy||view.capturing)return false; + view.gallery_folder=root; + if(root!=view.folder){view.thumbnails.clear();view.gallery_pages=0;view.gallery_matches=0;view.gallery_page=0;++view.gallery_generation;} + const auto serial=++gallery_serial; + gallery_request=GalleryRequest{std::move(root),std::move(search),page,reload,serial}; + view.gallery_loading=true;view.gallery_requested_page=page;view.gallery_error.clear();dirty=true; + cv.notify_one();return true; + } + void close_browser() { + std::lock_guard lock(mutex);++gallery_serial;gallery_request.reset(); + view.gallery_loading=false;dirty=true; + } + void count(bool reused,std::size_t bytes){update([&](auto& v){if(reused)++v.reused;else ++v.exported;v.bytes+=bytes;});} + void error(const std::string& source,const std::string& e,Workspace& ws){ws.issue(source,e);update([&](auto& v){++v.failed;});} + bool needs(std::uint64_t hash)noexcept { + if(!armed.load(std::memory_order_relaxed)||pressure.load(std::memory_order_relaxed))return false; + try{std::unique_lock lock(mutex,std::try_to_lock);if(!lock){++deferred;return false;}return !pending.contains(hash)&&!committed.contains(hash);}catch(...){++deferred;return false;} + } + void observe(const texture_capture::Tile& tile,const std::uint8_t* bytes)noexcept { + if(!armed.load(std::memory_order_relaxed)||pressure.load(std::memory_order_relaxed))return; + try { + std::unique_lock lock(mutex,std::try_to_lock);if(!lock){++deferred;return;} // Cached tiles are observed again on subsequent draws. + if(!armed||pending.contains(tile.native_hash)||committed.contains(tile.native_hash))return; + if(queue.size()>=2048||pending.size()+committed.size()>=524288){pressure=true;view.paused=true;view.status="Capture paused at its memory budget. Let the writer catch up, then Resume; start a fresh capture if the session index is full. Unrecorded textures need to be seen again.";dirty=true;return;} + Packet packet;packet.tile=tile;std::memcpy(packet.bytes.data(),bytes,packet.bytes.size()); + pending.insert(tile.native_hash); + try{queue.push_back(std::move(packet));}catch(...){pending.erase(tile.native_hash);throw;} + view.queued=queue.size();++view.observed;dirty=true;cv.notify_one(); + }catch(...){pressure=true;} + } + void run() { + for(;;) { + std::function job;std::optional packet;std::optional browser;bool finish=false; + {std::unique_lock lock(mutex);cv.wait(lock,[&]{return closing||!jobs.empty()||!queue.empty()||stopping||gallery_request.has_value();});if(closing&&jobs.empty()&&queue.empty()&&!live)return; + if(!jobs.empty()){job=std::move(jobs.front());jobs.pop_front();} + else if(!queue.empty()){packet=std::move(queue.front());queue.pop_front();view.queued=queue.size();} + else if(stopping||closing){stopping=false;finish=true;} + else if(gallery_request){browser=std::move(gallery_request);gallery_request.reset();}} + if(browser) { + try { + auto result=gallery_cache.load(*browser,[&]{return gallery_serial.load()==browser->serial;}); + std::lock_guard lock(mutex); + if(result&&gallery_serial==browser->serial) { + view.folder=std::move(result->folder);view.revision=std::move(result->revision); + view.thumbnails=std::move(result->thumbnails);view.gallery_page=result->page;view.gallery_pages=result->pages; + view.gallery_matches=result->matches;view.gallery_requested_page=result->page; + view.gallery_loading=false;view.gallery_error.clear();++view.gallery_generation; + view.status="Texture browser ready.";dirty=true; + } + }catch(const std::exception& e) { + std::lock_guard lock(mutex); + if(gallery_serial==browser->serial){view.gallery_loading=false;view.gallery_error=e.what();dirty=true;} + } + continue; + } + try { + if(job){job();update([](auto& v){v.busy=false;});} + else if(packet&&live) { + const auto& t=packet->tile;Picture p; + try{p=decode_tmem(t,packet->bytes);} + catch(const std::exception& e){live->issue(hex(t.native_hash),e.what());std::lock_guard lock(mutex);pending.erase(t.native_hash);committed.insert(t.native_hash);++view.failed;dirty=true;continue;} + Json hashes=Json::array({hex(t.native_hash)});if(t.rice_alias&&t.rice_alias!=t.native_hash)hashes.push_back(hex(t.rice_alias)); + Json meta={{"runtime",true},{"frame",0},{"hashes",hashes},{"rice",t.rice_name},{"tile",{{"format",t.fmt},{"size",t.siz},{"line",t.line},{"tmem",t.tmem},{"tlut",t.tlut},{"palette",t.palette},{"cms",t.cms},{"cmt",t.cmt},{"masks",t.masks},{"maskt",t.maskt},{"shifts",t.shifts},{"shiftt",t.shiftt},{"uls",t.uls},{"ult",t.ult},{"lrs",t.lrs},{"lrt",t.lrt}}}}; + count(live->image("runtime:"+hex(t.native_hash),"Runtime",hex(t.native_hash),p,std::move(meta)),p.rgba.size()); + live->associate(p,hashes,t.fmt,t.siz,t.tlut); + {std::lock_guard lock(mutex);pending.erase(t.native_hash);committed.insert(t.native_hash);} + }else if(finish&&live){live->doc["capture_coverage"]="Observed runtime textures only; unseen dynamic variants are not claimed complete.";live->doc["deferred_observations"]=deferred.load();live->flush();live.reset();update([](auto& v){v.capturing=false;v.paused=false;v.status="Capture saved. You can resume it in this folder; unseen/deferred variants may need another visit.";});} + }catch(const std::exception& e) { + if(packet){std::lock_guard lock(mutex);pending.clear();view.failed+=queue.size();queue.clear();view.queued=0;view.paused=true;view.status="Capture paused after a write error. Fix the folder or disk space, then Resume and revisit unsaved textures.";pressure=true;dirty=true;} + else armed=false; + update([&](auto& v){v.error=e.what();v.busy=false;++v.failed;}); + if(job&&live){try{live->flush();}catch(...){}live.reset();update([](auto& v){v.capturing=false;});} + if(finish&&live){live.reset();update([](auto& v){v.capturing=false;v.paused=false;v.status="Capture stopped with a write error. Completed PNGs are kept.";});} + } + } + } +}; +Service& service(){static Service instance;return instance;} +void observe(const texture_capture::Tile& t,const std::uint8_t* p)noexcept{service().observe(t,p);} +bool needs(std::uint64_t hash)noexcept{return service().needs(hash);} +bool wanted(const Options& o,const Source& s) { + return (o.include_disabled||s.enabled)&&(!o.only_selected||o.selected.contains(s.id))&& + ((s.kind=="Game"&&o.game)||(s.kind=="Characters"&&o.characters)||(s.kind=="Tracks"&&o.tracks)||(s.kind=="Vehicles"&&o.vehicles)); +} +struct Catalog { + std::shared_ptr stock; + std::vector characters,legacy; + std::vector modern; + std::vector cars; + std::vector sources; + std::vector> issues; +}; +Catalog catalog(const Options& o,bool scan_library=true) { + auto rom=o.rom; + if(rom.empty()&&std::filesystem::is_regular_file(o.config/"last-rom.txt")){auto bytes=read_file(o.config/"last-rom.txt",8192);std::string name(bytes.begin(),bytes.end());while(!name.empty()&&(name.back()=='\r'||name.back()=='\n'))name.pop_back();rom=utf8_path(name);} + if(rom.empty())throw Error("Select your Game Pak in Play first."); + Catalog c;c.stock=AssetBank::stock(read_file(rom,16*MiB)); + c.sources.push_back({"game:"+c.stock->revision(),"Diddy Kong Racing ("+c.stock->revision()+")","Game",true}); + if(!scan_library)return c; + auto root=o.config/"mods/legacy"; + if(std::filesystem::exists(root)) { + try{c.characters=TrackCatalog::installed_items(root,c.stock->revision(),TrackCatalog::Kind::Character);}catch(const std::exception& e){c.issues.emplace_back("Characters",e.what());} + try{c.legacy=TrackCatalog::installed_items(root,c.stock->revision(),TrackCatalog::Kind::Track);}catch(const std::exception& e){c.issues.emplace_back("Legacy tracks",e.what());} + } + for(const auto& i:c.characters)c.sources.push_back({"character:"+i.id,i.name,"Characters",i.enabled}); + for(const auto& i:c.legacy)c.sources.push_back({"legacy:"+i.id,i.name,"Tracks",i.enabled}); + c.modern=custom_tracks::tracks();for(const auto& i:c.modern)c.sources.push_back({"track:"+i.id,i.name,"Tracks",i.enabled}); + car_assets::Library library(o.config/"mods/vehicles");if(std::filesystem::exists(library.root()))try{c.cars=library.scan();}catch(const std::exception& e){c.issues.emplace_back("Vehicles",e.what());} + for(const auto& i:c.cars)c.sources.push_back({"car:"+i.digest,i.name,"Vehicles",i.enabled}); + return c; +} +std::filesystem::path output(const Options& o,const Catalog& c) { + if(o.destination.empty())throw Error("Choose a destination folder."); + const auto destination=private_storage_path(o.destination);directory(destination); + if(std::filesystem::exists(destination/"dump.json")){auto doc=read_manifest(destination);if(doc.value("rom_revision","")!=c.stock->revision())throw Error("This dump belongs to a different Game Pak revision. Choose another folder.");return destination;} + return private_storage_path(std::filesystem::weakly_canonical(destination)/("DKR-Texture-Dump-"+c.stock->revision())); +} +void begin(Service& s,Workspace& w,const Catalog& c) { + w.doc["rom_revision"]=c.stock->revision();w.doc["rom_sha256"]=c.stock->base_fingerprint(); + w.doc["application_version"]=DKR_RELEASE_VERSION; + w.doc["coverage_note"]="Stored texture records and source artwork are distinct from runtime replacement variants. Only known or observed identities are mapped."; + w.flush();s.update([&](auto& v){v.sources=c.sources;v.revision=c.stock->revision();v.folder=w.root;v.exported=v.reused=v.failed=v.bytes=v.observed=0;v.thumbnails.clear();++v.gallery_generation;}); + for(const auto& [source,error]:c.issues)s.error(source,error,w); +} +void track_source_artwork(Service& s,Workspace& w,const custom_tracks::Track& track,const std::string& source) { + const auto owner="Tracks/"+track.name+"_"+track.id+"/HD_source"; + for(const auto& pack:texture_packs::snapshot(true)) { + if(pack.origin!=texture_packs::Origin::TrackPack||pack.owner_track_id!=track.id)continue; + if(!track.hd_pack_digest.empty()&&pack.texture_digest!=track.hd_pack_digest){s.error(source,"The installed HD pack belongs to a different track export. Native textures were exported; the mismatched source artwork was skipped.",w);continue;} + try { + if(std::filesystem::is_directory(pack.path)) { + for(const auto& entry:std::filesystem::recursive_directory_iterator(pack.path)) { + s.check();if(link(entry.path()))throw Error("A source-artwork path is a link.");if(!entry.is_regular_file())continue; + auto ext=entry.path().extension().string();for(auto& ch:ext)ch=char(std::tolower(static_cast(ch))); + if(ext==".png"){auto data=read_file(entry.path(),64*MiB);s.count(w.source_png(source,owner,entry.path().stem().string(),data),data.size());} + } + }else { + auto input=read_file(pack.path,256*MiB);mz_zip_archive zip{}; + if(!mz_zip_reader_init_mem(&zip,input.data(),input.size(),0))throw Error("The installed HD pack cannot be opened for source export."); + struct End{mz_zip_archive& z;~End(){mz_zip_reader_end(&z);}}end{zip}; + for(unsigned i=0;i(ch))); + if(ext!=".png"||mz_zip_reader_is_file_a_directory(&zip,i))continue; + if(st.m_uncomp_size>64*MiB)throw Error("HD source PNG exceeds the workspace budget.");Bytes pixels(std::size_t(st.m_uncomp_size)); + if(!mz_zip_reader_extract_to_mem(&zip,i,pixels.data(),pixels.size(),0))throw Error("HD source PNG could not be decoded from the archive."); + s.count(w.source_png(source,owner,std::filesystem::path(name).stem().string(),pixels),pixels.size()); + } + } + }catch(const std::exception& e){s.check();s.error(source,e.what(),w);} + } +} +void record(Service& s,Workspace& w,Data bytes,Data palettes,std::string owner,std::string id,unsigned section,unsigned number) { + try { + slice(bytes,0,32);const unsigned count=bytes[0x12];if(!count)throw Error("Texture has no stored frames."); + const auto palette_offset=be16(bytes,8);auto inflated=bytes[0x1d]?inflate_asset(bytes.subspan(32),64*MiB):Bytes{}; + const auto frames=inflated.empty()?bytes:Data(inflated);std::size_t offset=0; + for(unsigned frame=0;frame=9)throw Error("Unknown stored texture format."); + const auto size=std::size_t((width*bits[format]+7)/8)*height; + const auto texels=slice(frames,offset+32,size);Data palette; + if(format>=7)palette=slice(palettes,palette_offset,format==7?32:128); + auto picture=decode_asset(texels,width,height,format,(flags&0x400)!=0,palette); + const auto identity=source_rice(texels,width,height,format); + Json hashes=Json::array();if(!identity.empty())hashes.push_back(hex(rice_alias(identity))); + Json meta={{"source_id",id},{"section",section},{"record",number},{"frame",frame},{"frames",count},{"format",format},{"flags",flags},{"palette_offset",palette_offset},{"delay_ticks",be16(header,0x14)},{"runtime",false},{"hashes",hashes},{"rice",identity},{"identity_recipe",identity.empty()?"needs runtime observation":"DKR standard aligned LoadBlock"},{"orientation","runtime texel order; no authoring vertical flip"}}; + const auto name=(section==2?"3D_":"2D_")+std::to_string(number)+"_frame_"+std::to_string(frame); + meta["payload_sha256"]=sha256(bytes);meta["compressed"]=bytes[0x1d]!=0; + s.count(w.image(id+":"+sha256(bytes)+":"+std::to_string(section)+":"+std::to_string(number)+":"+std::to_string(frame),owner,name,picture,std::move(meta)),picture.rgba.size()); + const auto stride=be16(header,0x16);if(frame+1record(section,i);if(std::ranges::equal(bytes,old))continue;} + if(bytes.empty())continue;record(s,w,bytes,a.stock_section(14),owner,id,section,i); + } +} +void export_catalog(Service& s,Workspace& w,const Catalog& c,const Options& o) { + if(wanted(o,c.sources.front())){s.progress("Exporting original game textures...");bank(s,w,*c.stock,"Game",c.sources.front().id);} + const auto root=o.config/"mods/legacy"; + for(const auto& item:c.characters) { + Source source{"character:"+item.id,item.name,"Characters",item.enabled};if(!wanted(o,source))continue; + s.progress("Exporting "+item.name+"..."); + try { + auto character=read_character_artifact(root/"prepared-characters"/item.group/item.storage,item.artifact,*c.stock); + if(character.root.content_id!=item.id)throw Error("Character metadata changed; refresh the library."); + for(const auto& [key,bytes]:character.records)if(key.first==2||key.first==4)record(s,w,bytes,c.stock->stock_section(14),"Characters/"+item.name+"_"+item.id.substr(0,8),source.id,key.first,key.second); + }catch(const std::exception& e){s.check();s.error(source.id,e.what(),w);} + } + for(const auto& item:c.legacy) { + Source source{"legacy:"+item.id,item.name,"Tracks",item.enabled};if(!wanted(o,source))continue; + s.progress("Exporting "+item.name+"..."); + try {auto track=read_track_artifact(root/"prepared"/item.group/item.storage,item.artifact,c.stock);if(track.root.content_id!=item.id)throw Error("Track metadata changed; refresh the library.");bank(s,w,*track.bank,"Tracks/"+item.name+"_"+item.id.substr(0,8),source.id,c.stock.get());} + catch(const std::exception& e){s.check();s.error(source.id,e.what(),w);} + } + for(const auto& item:c.modern) { + Source source{"track:"+item.id,item.name,"Tracks",item.enabled};if(!wanted(o,source))continue;s.progress("Exporting "+item.name+"..."); + unsigned n2=0,n3=0;for(const auto& e:item.entries) { + if(e.section==custom_tracks::Section::Textures3D)record(s,w,e.bytes,{},"Tracks/"+item.name+"_"+item.id,source.id,2,n3++); + else if(e.section==custom_tracks::Section::Textures2D)record(s,w,e.bytes,{},"Tracks/"+item.name+"_"+item.id,source.id,4,n2++); + } + if(o.source_artwork)track_source_artwork(s,w,item,source.id); + } + car_assets::Library library(o.config/"mods/vehicles"); + for(const auto& item:c.cars) { + Source source{"car:"+item.digest,item.name,"Vehicles",item.enabled};if(!wanted(o,source))continue;s.progress("Exporting "+item.name+"..."); + try { + auto car=library.package(item.digest);const auto owner="Vehicles/"+car->name+"_"+car->digest.substr(0,8); + auto geometry=Json::parse(zip_member(car->source,"geometry.json")); + for(unsigned i=0;itextures.size();++i){const auto& t=car->textures[i];Picture p{t.image.width,t.image.height,t.image.rgba}; + const auto bank_row=(std::size_t(t.image.width)*2+7)&~std::size_t(7);const bool rgba32=bank_row*t.image.height<=2048; + if(!rgba32){const auto native=car_assets::native_texture(t.image);p=decode_asset(Data(native).subspan(32),t.image.width,t.image.height,1,false);} + Json meta={{"source_id",source.id},{"frame",0},{"material",i},{"repeat_s",t.repeat_s},{"repeat_t",t.repeat_t},{"source_artwork",false},{"format",rgba32?0:1},{"orientation","runtime material orientation"},{"package_digest",car->digest}}; + meta["authored_flipped"]=geometry.at("textures").at(i).value("flipped",false);meta["uses"]=Json::array(); + for(unsigned lod=0;lodlods.size();++lod)for(const auto& mesh:car->lods[lod].meshes)if(mesh.texture==int(i))meta["uses"].push_back({{"lod",lod},{"mesh",mesh.id},{"name",mesh.name}}); + s.count(w.image(source.id+":material:"+std::to_string(i),owner,"material_"+std::to_string(i),p,std::move(meta)),p.rgba.size());} + // The picker really uses two RGBA16 tiles, not the full source portrait. + car_assets::Image thumb{64,64,Bytes(64*64*4,0)};const auto& src=car->portrait; + const float factor=std::min(64.F/src.width,64.F/src.height);const auto tw=std::max(1U,unsigned(src.width*factor)),th=std::max(1U,unsigned(src.height*factor)); + for(unsigned y=0;y());s.count(w.source_png(source.id,owner,"source_material_"+std::to_string(material++),bytes),bytes.size());}auto portrait_png=zip_member(car->source,"portrait.png");s.count(w.source_png(source.id,owner,"source_portrait",portrait_png),portrait_png.size());} + else {Picture portrait{car->portrait.width,car->portrait.height,car->portrait.rgba};s.count(w.image(source.id+":portrait",owner,"portrait",portrait,{{"source_id",source.id},{"frame",0},{"source_artwork",true}}),portrait.rgba.size());} + }catch(const std::exception& e){s.check();s.error(source.id,e.what(),w);} + } + w.doc["source_export_finished"]=true;w.flush(); +} +void make_starter(Workspace& w) { + Json textures=Json::array();struct Candidate{std::string path,pixels;bool edited=false;}; + std::map mapped;std::set ambiguous; + for(const auto& row:w.doc["images"]) { + if(row.at("hashes").empty())continue; + const auto path=row.at("working").get();auto bytes=read_file(member(w.root,path),64*MiB);auto picture=read_png(bytes);const auto key=pixel_key(picture); + const bool edited=key!=row.at("pixels").get(); + for(const auto& h:row.at("hashes")) { + auto hash=h.get();if(hash.size()!=16||hash.find_first_not_of("0123456789abcdef")!=hash.npos)throw Error("Invalid replacement identity in the dump manifest."); + auto prior=mapped.find(hash); + if(prior==mapped.end())mapped.emplace(hash,Candidate{path,key,edited}); + else if(prior->second.pixels!=key) { + if(edited&&prior->second.edited)throw Error("A shared texture has conflicting edits. Keep one edited version for that replacement identity."); + if(edited)prior->second={path,key,true}; + else if(!prior->second.edited)ambiguous.insert(hash); + } + } + } + for(const auto& [hash,item]:mapped) { + if(ambiguous.contains(hash)&&!item.edited){w.issue(hash,"Different images share this identity. It was omitted from the starter; use an exact in-game capture.");continue;} + textures.push_back({{"hashes",{{"rt64",hash}}},{"path",item.path}}); + } + if(textures.empty())throw Error("No verified replacement identities yet. Capture textures in game, stop capture, then create the starter pack."); + Json database={{"configuration",{{"configurationVersion",3},{"hashVersion",5},{"autoPath","rt64"},{"defaultOperation","stream"},{"defaultShift","half"}}},{"textures",textures}}; + // Root paths point into Working; originals and reports are never loaded. + atomic_json(w.root/"rt64.json",database); + // Stream a conventional ZIP for the launcher's existing Import Texture + // Pack action. Never include Originals, the ROM, or source/debug reports. + const auto archive=member(w.root,"Texture-Starter.zip");auto temp=archive;temp+=".writing-"+std::to_string(Clock::now().time_since_epoch().count()); + if(link(temp))throw Error("The starter archive path is a link."); +#ifdef _WIN32 + FILE* file=_wfopen(temp.c_str(),L"wb"); +#else + FILE* file=std::fopen(temp.c_str(),"wb"); +#endif + if(!file)throw Error("Could not create the starter ZIP."); + try { + mz_zip_archive zip{};if(!mz_zip_writer_init_cfile(&zip,file,MZ_ZIP_FLAG_WRITE_ZIP64))throw Error("Could not initialize the starter ZIP."); + struct End{mz_zip_archive& z;~End(){mz_zip_writer_end(&z);}}end{zip}; + const auto config=database.dump(2);if(!mz_zip_writer_add_mem(&zip,"rt64.json",config.data(),config.size(),6))throw Error("Could not write the starter database."); + std::set added; + for(const auto& row:textures){const auto path=row.at("path").get();if(!added.insert(path).second)continue;auto bytes=read_file(member(w.root,path),64*MiB);if(!mz_zip_writer_add_mem(&zip,path.c_str(),bytes.data(),bytes.size(),6))throw Error("Could not write a starter image; check free disk space.");} + if(!mz_zip_writer_finalize_archive(&zip))throw Error("Could not finish the starter ZIP; check free disk space."); + }catch(...){std::fclose(file);std::error_code ec;std::filesystem::remove(temp,ec);throw;} + if(std::fclose(file))throw Error("Could not commit the starter ZIP."); +#ifdef _WIN32 + if(!MoveFileExW(temp.c_str(),archive.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))throw Error("Could not replace the starter ZIP."); +#else + std::filesystem::rename(temp,archive); +#endif + const std::string notice="DKR-R texture workspace\n\nEdit the PNGs in Working; Originals are kept unchanged. Create Starter Pack again after editing, then import Texture-Starter.zip through Import Texture Pack. Only verified source/runtime identities are mapped. Unseen runtime variants require Capture next game. Shared hashes replace every use, not only the folder's named owner. Export again to resume without overwriting edits. This starter is a private authoring copy. Distribute your replacement artwork, not the ROM dump.\n"; + atomic_file(w.root/"TEXTURE-WORKSPACE.txt",Data(reinterpret_cast(notice.data()),notice.size()),true); + w.doc["starter_pack_entries"]=textures.size();w.flush(); +} +} + +std::shared_ptr snapshot(){auto& s=service();std::lock_guard lock(s.mutex);if(s.pressure&&!s.view.paused&&s.view.capturing){s.view.paused=true;s.view.status="Capture paused at its memory budget. Resume when ready, then revisit unsaved textures.";s.dirty=true;}if(s.dirty||!s.published){s.published=std::make_shared(s.view);s.dirty=false;}return s.published;} +bool refresh(Options o){auto& s=service();return s.job([&s,o=std::move(o)]{auto c=catalog(o);s.update([&](auto& v){v.sources=c.sources;v.revision=c.stock->revision();v.status="Choose what to export.";});});} +bool export_installed(Options o){auto& s=service();return s.job([&s,o=std::move(o)]{auto c=catalog(o);if(!std::ranges::any_of(c.sources,[&](const auto& source){return wanted(o,source);}))throw Error("Choose at least one texture source to export.");Workspace w(output(o,c));begin(s,w,c);try{export_catalog(s,w,c,o);}catch(...){w.flush();throw;}s.update([&](auto& v){v.status=v.failed?"Export finished with skipped items. See dump.json for reasons.":"Textures exported. Create a starter pack when you are ready to edit.";});});} +bool capture(Options o){auto& s=service();return s.job([&s,o=std::move(o)]{auto c=catalog(o,false);s.live=std::make_unique(output(o,c));begin(s,*s.live,c); + {std::lock_guard lock(s.mutex);if(s.cancelled)throw Error("Capture cancelled.");s.pending.clear();s.committed.clear();s.queue.clear();s.stopping=false;s.view.capturing=true;s.view.paused=false;s.view.status="Capture is on. Play to collect original textures; stop when finished.";s.dirty=true;} + s.deferred=0;s.pressure=false;s.armed=true;texture_capture::gate.store(needs,std::memory_order_release);texture_capture::observer.store(observe,std::memory_order_release); + std::fprintf(stderr,"[texture-dump] presentation-only PNG capture enabled; original TMEM, no GPU readback, bounded asynchronous writer\n"); +});} +void stop_capture(){auto& s=service();s.armed=false;texture_capture::observer.store(nullptr,std::memory_order_release);{std::lock_guard lock(s.mutex);if(!s.view.capturing)return;s.stopping=true;s.view.status="Saving captured textures...";s.dirty=true;}s.cv.notify_one();} +void resume_capture(){auto& s=service();std::lock_guard lock(s.mutex);if(!s.view.capturing||s.stopping||s.queue.size()>=2048)return;s.pressure=false;s.view.paused=false;s.view.error.clear();s.view.status="Capture resumed. Revisit textures missed while paused.";s.dirty=true;} +void cancel(){service().cancelled=true;stop_capture();} +bool starter_pack(std::filesystem::path root){auto& s=service();return s.job([&s,root=std::move(root)]{Workspace w(root);make_starter(w);s.update([](auto& v){v.status="Texture-Starter.zip created. Import it through Import Texture Pack. Recreate it after editing Working.";});});} +bool gallery(std::filesystem::path root,std::string search,unsigned page,bool reload){return service().browse(std::move(root),std::move(search),page,reload);} +void close_gallery(){service().close_browser();} +} diff --git a/runtime-recomp/src/game/texture_dump.hpp b/runtime-recomp/src/game/texture_dump.hpp new file mode 100644 index 00000000..c9f20ac7 --- /dev/null +++ b/runtime-recomp/src/game/texture_dump.hpp @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::texture_dump { +struct Source { + std::string id,name,kind; + bool enabled=true; +}; +struct Options { + std::filesystem::path config,rom,destination; + bool game=true,characters=true,tracks=true,vehicles=true,include_disabled=true,source_artwork=false; + bool only_selected=false; + std::set selected; // Empty means every source in the selected categories. +}; +struct Thumbnail { + std::string name,owner,path,status,preview_key,preview_error; + unsigned width=0,height=0,frame=0,preview_width=0,preview_height=0; + std::vector rgba; +}; +struct View { + bool busy=false,capturing=false,paused=false; + std::uint64_t exported=0,reused=0,failed=0,bytes=0,queued=0,observed=0; + std::filesystem::path folder; + std::string status,error,revision; + std::vector sources; + std::vector thumbnails; + unsigned gallery_page=0,gallery_pages=0; + bool gallery_loading=false; + std::filesystem::path gallery_folder; + unsigned gallery_requested_page=0; + std::uint64_t gallery_matches=0; + std::string gallery_error; + std::uint64_t gallery_generation=0; +}; +std::shared_ptr snapshot(); +bool refresh(Options); +bool export_installed(Options); +bool capture(Options); +void stop_capture(); +void resume_capture(); +void cancel(); +bool gallery(std::filesystem::path folder,std::string search,unsigned page,bool reload=false); +void close_gallery(); +bool starter_pack(std::filesystem::path folder); +} diff --git a/runtime-recomp/src/game/texture_dump_ui.inl b/runtime-recomp/src/game/texture_dump_ui.inl new file mode 100644 index 00000000..1e73967f --- /dev/null +++ b/runtime-recomp/src/game/texture_dump_ui.inl @@ -0,0 +1,373 @@ +// Included after the existing shared Textures page inside its UI namespace. +namespace dumping=dkr::runtime::texture_dump; +dumping::Options g_dump_options; +std::filesystem::path g_dump_config; +std::array g_dump_search{}; +std::string g_dump_ui_status; +std::string g_dump_browser_status; +bool g_dump_browser=false,g_dump_sources=false; +// Keep the existing Texture Packs and CRT page completely unchanged. These +// session-local controls belong only to the new disclosure beneath that page. +bool g_dump_open=false,g_dump_folder_info=false; +enum class DumpPanel { Export,Capture,Dumps }; +DumpPanel g_dump_panel=DumpPanel::Export; +bool g_dump_clear_previews=false; +std::uint64_t g_dump_preview_generation=0; +struct DumpPreviewTexture {SDL_Texture* texture=nullptr;int used=0;}; +std::map g_dump_previews; +void ReleaseTextureDumpTextures() { + for(auto& [key,t]:g_dump_previews)if(t.texture)SDL_DestroyTexture(t.texture); + g_dump_previews.clear(); +} +void InvalidateTextureDumpPreviews(){g_dump_clear_previews=true;} +void TrimDumpPreviews() { + // Evict only between frames, before this frame can reference a texture. + if(g_dump_clear_previews){ReleaseTextureDumpTextures();g_dump_clear_previews=false;} + while(g_dump_previews.size()>128) { + auto oldest=std::min_element(g_dump_previews.begin(),g_dump_previews.end(),[](const auto& a,const auto& b){return a.second.usedsecond.texture)SDL_DestroyTexture(oldest->second.texture); + g_dump_previews.erase(oldest); + } +} +// Content-sized buttons wrap as a group instead of squeezing their labels. +struct DumpActionRow { + float width,left; + bool first=true; + explicit DumpActionRow(float available):width(available),left(ImGui::GetCursorScreenPos().x){} + bool Button(const char* label,bool disabled=false,SettingsButtonTone tone=SettingsButtonTone::Plain, + bool primary=false) { + const float natural=primary?PaddockRaceButtonWidth(label):SettingsButtonWidth(label); + const float size=std::min(width,natural); + if(!first) { + if(ImGui::GetItemRectMax().x+12.F+size<=left+width)ImGui::SameLine(0.F,12.F); + else PaddockGap(10.F); + } + first=false; + if(primary&&natural<=width)return DetailRaceButton(label,size,0x12BFA6,disabled); + // Very narrow overlay panels use the shared wrapping text button. + return SettingsButton(label,size,disabled,primary?SettingsButtonTone::Selected:tone); + } +}; +std::string DumpPathText(const std::filesystem::path& path) { + const auto name=path.u8string();return std::string(name.begin(),name.end()); +} +void ChooseDumpFolder(bool existing=false) { + StartDialogJob([existing]()->std::function { + std::filesystem::path folder;std::string error;const bool chosen=PickFolder(folder,error); + return [chosen,folder,error,existing] { + if(chosen) { + g_dump_ui_status.clear();g_dump_options.destination=folder; + if(existing){g_dump_browser_status.clear();g_dump_panel=DumpPanel::Dumps;g_dump_browser=true;InvalidateTextureDumpPreviews();dumping::gallery(folder,g_dump_search.data(),0);} + }else if(!error.empty())g_dump_ui_status=error; + }; + }); +} +void OpenDumpFolder(const std::filesystem::path& path) { + auto folder=path; +#if defined(_WIN32) + // Shell paths must not retain the extended spelling used for file I/O. + auto name=folder.native(); + if(name.starts_with(L"\\\\?\\UNC\\"))folder=std::filesystem::path(L"\\\\"+name.substr(8)); + else if(name.starts_with(L"\\\\?\\"))folder=std::filesystem::path(name.substr(4)); +#endif + dkr::runtime::support::open_directory(folder,g_dump_ui_status); +} +void DrawDumpDestination(float width,bool locked) { + PaddockGap(14.F); + SettingsLabel("Save to",width); + const bool standard=g_dump_options.destination==g_config_directory/"texture-dumps"; + DetailText(standard?"Default texture dump folder":DumpPathText(g_dump_options.destination),width,kSetMuted,10.F); + DumpActionRow actions(width); + if(actions.Button("Change folder",locked))ChooseDumpFolder(); + if(actions.Button("Show path"))g_dump_folder_info=true; + PaddockGap(18.F); +} +void DrawDumpJob(float width,const std::shared_ptr& state) { + if(!state->busy&&!state->capturing&&state->status.empty()&&state->error.empty()&&g_dump_ui_status.empty())return; + PaddockGap(18.F); + DetailCard(width,{16.F,14.F},[&](float inner) { + const char* title=state->capturing?(state->paused?"CAPTURE PAUSED":"CAPTURING TEXTURES"): + state->busy?"WORKING":"DUMP STATUS"; + DetailHeading(title,inner,kSetWarm,8.F); + DetailText(state->status,inner,kSetMuted,8.F); + if(state->exported||state->reused||state->failed) + DetailText("Saved: "+std::to_string(state->exported)+" Already saved: "+std::to_string(state->reused)+ + " Skipped: "+std::to_string(state->failed),inner,kSetText,8.F); + DetailText(state->error,inner,kSetWarm,8.F); + DetailText(g_dump_ui_status,inner,kSetMuted,8.F); + DumpActionRow actions(inner); + if(state->capturing) { + if(state->paused&&actions.Button("Resume capture"))dumping::resume_capture(); + if(actions.Button("Stop capture"))dumping::stop_capture(); + }else if(state->busy) { + if(actions.Button("Stop job"))dumping::cancel(); + }else if(!state->folder.empty()&&g_dump_panel!=DumpPanel::Dumps) { + if(actions.Button("View dump"))g_dump_panel=DumpPanel::Dumps; + } + }); +} +void DrawDumpPreviewCard(const dumping::Thumbnail& item,float width) { + constexpr float height=220.F; + const auto at=ImGui::GetCursorScreenPos();const ImVec2 end{at.x+width,at.y+height}; + ImGui::Dummy({width,height}); + if(!ImGui::IsRectVisible(at,end))return; + auto* draw=ImGui::GetWindowDrawList(); + PaddockFill(draw,at,end,PaddockRound(12.F),PaddockCol(0x0B2E40)); + PaddockStroke(draw,at,end,PaddockRound(12.F),PaddockCol(0x296B70),1.F); + const float image_x=at.x+(width-96.F)*.5F,image_y=at.y+12.F; + for(unsigned y=0;y<6;++y)for(unsigned x=0;x<6;++x) + draw->AddRectFilled({image_x+x*16.F,image_y+y*16.F},{image_x+(x+1)*16.F,image_y+(y+1)*16.F}, + (x+y)&1?PaddockCol(0x344C56):PaddockCol(0x213843)); + if(g_mod_store_renderer&&!item.rgba.empty()&&item.preview_width&&item.preview_height) { + auto found=g_dump_previews.find(item.preview_key); + if(found==g_dump_previews.end()) { + auto* texture=SDL_CreateTexture(g_mod_store_renderer,SDL_PIXELFORMAT_RGBA32,SDL_TEXTUREACCESS_STATIC,int(item.preview_width),int(item.preview_height)); + if(texture&&SDL_UpdateTexture(texture,nullptr,item.rgba.data(),int(item.preview_width*4))){SDL_DestroyTexture(texture);texture=nullptr;} + if(texture){SDL_SetTextureBlendMode(texture,SDL_BLENDMODE_BLEND);found=g_dump_previews.emplace(item.preview_key,DumpPreviewTexture{texture,ImGui::GetFrameCount()}).first;} + } + if(found!=g_dump_previews.end()) { + found->second.used=ImGui::GetFrameCount(); + const float x=image_x+(96.F-item.preview_width)*.5F,y=image_y+(96.F-item.preview_height)*.5F; + draw->AddImage(reinterpret_cast(found->second.texture),{x,y},{x+item.preview_width,y+item.preview_height}); + } + } + const auto title=PaddockReading(14.F,true,1.35F),detail=PaddockReading(13.F,false,1.4F); + const float text_width=std::max(1.F,width-24.F); + const auto line=[&](const PaddockType& type,const std::string& text,float y,unsigned colour) { + const auto shown=PaddockEllipsize(type,text,text_width); + PaddockDrawRun(draw,type,{at.x+12.F,at.y+y},PaddockCol(colour),shown.data(),shown.data()+shown.size()); + }; + line(title,item.name,120.F,kSetText);line(detail,item.owner,144.F,kSetMuted); + line(detail,std::to_string(item.width)+" x "+std::to_string(item.height)+" | frame "+std::to_string(item.frame+1),166.F,kSetMuted); + line(detail,item.preview_error.empty()?item.status:"Preview unavailable",190.F,item.preview_error.empty()?kSetMuted:kSetWarm); + if(ImGui::IsWindowHovered()&&ImGui::IsMouseHoveringRect(at,end)) { + ImGui::BeginTooltip();ImGui::PushTextWrapPos(320.F); + ImGui::TextUnformatted(item.name.c_str()); + if(!item.preview_error.empty())ImGui::TextWrapped("%s",item.preview_error.c_str()); + ImGui::PopTextWrapPos();ImGui::EndTooltip(); + } +} +void RequestDumpPage(const std::filesystem::path& folder,unsigned page,bool reload=false) { + g_dump_browser_status.clear();dumping::gallery(folder,g_dump_search.data(),page,reload); +} +void DrawDumpBrowser(float width,const std::shared_ptr& state) { + TrimDumpPreviews(); + if(!g_dump_browser)return; + ImGui::OpenPopup("Texture browser##dump"); + const auto display=ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowSize({std::min(1200.F,display.x-32.F),std::min(display.y-32.F,display.y*.9F)},ImGuiCond_Always); + ImGui::SetNextWindowPos({display.x*.5F,display.y*.5F},ImGuiCond_Always,{.5F,.5F}); + // Use the actual padded/rounded launcher frame, not a raw ImGui popup. + if(BeginPaddockModalWindow("Texture browser##dump",ImGuiWindowFlags_NoResize|ImGuiWindowFlags_NoSavedSettings| + ImGuiWindowFlags_NoScrollbar|ImGuiWindowFlags_NoScrollWithMouse)) { + const float available=ImGui::GetContentRegionAvail().x; + const auto folder=state->gallery_folder.empty()?state->folder:state->gallery_folder; + const bool blocked=state->busy||state->capturing; + const unsigned target=state->gallery_loading?state->gallery_requested_page:state->gallery_page; + SettingsLabel("Search textures",available); + { + const PaddockPress press=PaddockBeginPress("##dump-search",{available,48.F}); + auto* draw=ImGui::GetWindowDrawList(); + PaddockFill(draw,press.min,press.max,PaddockRound(12.F),PaddockCol(0x0B2E40)); + PaddockStroke(draw,press.min,press.max,PaddockRound(12.F),PaddockCol(kSetBorder),1.F); + const auto type=PaddockReading(16.F,false,1.4F); + const auto shown=PaddockEllipsize(type,g_dump_search[0]?g_dump_search.data():"Find a texture...",available-32.F); + PaddockDrawRun(draw,type,{press.min.x+16.F,press.min.y+(48.F-type.line)*.5F},PaddockCol(kSetText),shown.data(),shown.data()+shown.size()); + PaddockEndPress(press,12.F); + if(press.pressed&&!blocked)RequestCustomTextKeyboard("Find a texture",g_dump_search.data(),g_dump_search.size(),[folder](const std::string& value){std::snprintf(g_dump_search.data(),g_dump_search.size(),"%s",value.c_str());RequestDumpPage(folder,0);}); + } + PaddockGap(12.F); + DumpActionRow pages(available); + if(pages.Button("First",blocked||!target))RequestDumpPage(folder,0); + if(pages.Button("Previous",blocked||!target))RequestDumpPage(folder,target-1); + if(pages.Button("Next",blocked||!state->gallery_pages||target+1>=state->gallery_pages))RequestDumpPage(folder,target+1); + if(pages.Button("Last",blocked||!state->gallery_pages||target+1>=state->gallery_pages))RequestDumpPage(folder,state->gallery_pages-1); + if(pages.Button("Go to page",blocked||!state->gallery_pages)) { + RequestCustomTextKeyboard("Go to page",std::to_string(target+1),12,[folder,total=state->gallery_pages](const std::string& value) { + unsigned page=0; + for(const char digit:value) { + if(digit<'0'||digit>'9'||page>total){g_dump_browser_status="Enter a page number from 1 to "+std::to_string(total)+".";return;} + page=page*10+unsigned(digit-'0'); + } + if(!page||page>total){g_dump_browser_status="Enter a page number from 1 to "+std::to_string(total)+".";return;} + RequestDumpPage(folder,page-1); + },false); + } + if(pages.Button("Refresh",blocked))RequestDumpPage(folder,target,true); + if(g_dump_search[0]&&pages.Button("Clear search",blocked)){g_dump_search[0]=0;RequestDumpPage(folder,0);} + PaddockGap(10.F); + std::string page_label="Page "+std::to_string(state->gallery_pages?state->gallery_page+1:0)+" / "+std::to_string(state->gallery_pages)+ + " | "+std::to_string(state->gallery_matches)+" textures"; + if(state->gallery_loading)page_label+=" | Loading"+(state->gallery_pages?" page "+std::to_string(target+1):std::string(" textures"))+"..."; + DetailText(page_label,available,kSetMuted,10.F); + DetailText(state->gallery_error,available,kSetWarm,8.F); + DetailText(g_dump_browser_status,available,kSetWarm,8.F); + ImGui::BeginChild("##dump-images",{0,std::max(60.F,ImGui::GetContentRegionAvail().y-64.F)},false,ImGuiWindowFlags_NavFlattened); + if(g_dump_preview_generation!=state->gallery_generation){ImGui::SetScrollY(0.F);g_dump_preview_generation=state->gallery_generation;} + const float grid_width=ImGui::GetContentRegionAvail().x; + const int columns=std::max(1,int(grid_width/216.F)); + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding,{6.F,6.F}); + if(ImGui::BeginTable("##dump-grid",columns,ImGuiTableFlags_SizingStretchSame)) { + ImGuiListClipper clipper;clipper.Begin(int((state->thumbnails.size()+columns-1)/columns),232.F); + while(clipper.Step())for(int row=clipper.DisplayStart;row=state->thumbnails.size())break; + ImGui::TableSetColumnIndex(column);DrawDumpPreviewCard(state->thumbnails[index],ImGui::GetContentRegionAvail().x); + } + } + ImGui::EndTable(); + } + ImGui::PopStyleVar(); + if(state->thumbnails.empty()&&!state->gallery_loading&&state->gallery_error.empty())DetailText("No textures match your search.",grid_width,kSetMuted); + if(!g_mod_store_renderer)DetailText("Image previews are available in the launcher.",grid_width,kSetMuted); + ImGui::EndChild();PaddockGap(14.F); + bool close=false; + { + ImGui::PushID("browser-footer"); + DumpActionRow actions(available); + close=actions.Button("Close"); + const auto hint_type=PaddockReading(13.F,false,1.4F); + const char* hint="LB / RB or Page Up / Down: change page"; + if(SettingsButtonWidth("Close")+PaddockMeasure(hint_type,hint)+24.Fgallery_pages&&target+1gallery_pages&&(ImGui::IsKeyPressed(ImGuiKey_PageDown,false)||ImGui::IsKeyPressed(ImGuiKey_GamepadR1,false)))RequestDumpPage(folder,target+1); + } + if(focused&&(ImGui::IsKeyPressed(ImGuiKey_Escape,false)||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight,false)))close=true; + if(close){g_dump_browser=false;dumping::close_gallery();InvalidateTextureDumpPreviews();ImGui::CloseCurrentPopup();} + // The keyboard must render at the same popup depth as its search field. + DrawTextEntryKeyboard();ImGui::EndPopup(); + } +} +void DrawTextureDumping(float width) { + if(g_dump_config!=g_config_directory) {g_dump_options={};g_dump_config=g_config_directory;g_dump_options.config=g_config_directory;g_dump_options.destination=g_config_directory/"texture-dumps";} + g_dump_options.rom=g_online_mod_selected_rom; + auto state=dumping::snapshot(); + ++g_settings_regular_labels; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,{0.F,0.F}); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign,{.5F,.5F}); + const bool locked=state->busy||state->capturing||state->gallery_loading; + ImGui::PushID("texture-dumping"); + const ImVec2 header=ImGui::GetCursorScreenPos(); + const bool expanded=DetailSectionSign("TEXTURE DUMPING##texture-dump",g_dump_open,width); + const std::string summary=state->capturing?(state->paused?"Capture paused":"Capturing - "+std::to_string(state->exported)+" saved"): + state->busy?"Working...":!state->error.empty()?"Needs attention":state->folder.empty()?"Ready":"Dump ready"; + const auto summary_type=PaddockReading(13.F,true,1.4F); + const float summary_width=PaddockMeasure(summary_type,summary); + if(PaddockSectionTabWidth("TEXTURE DUMPING")+summary_width+24.F<=width) { + PaddockDrawRun(ImGui::GetWindowDrawList(),summary_type, + {header.x+width-summary_width-16.F,header.y+22.F+(PaddockSectionTabHeight()-summary_type.line)*.5F}, + PaddockCol(kSetText),summary.data(),summary.data()+summary.size()); + }else if(locked||!state->error.empty())DetailText(summary,width,kSetMuted,8.F); + if(expanded) { + DumpActionRow tabs(width); + if(tabs.Button("Export",false,g_dump_panel==DumpPanel::Export?SettingsButtonTone::Selected:SettingsButtonTone::Plain))g_dump_panel=DumpPanel::Export; + if(tabs.Button("Capture",false,g_dump_panel==DumpPanel::Capture?SettingsButtonTone::Selected:SettingsButtonTone::Plain))g_dump_panel=DumpPanel::Capture; + if(tabs.Button("My Dumps",false,g_dump_panel==DumpPanel::Dumps?SettingsButtonTone::Selected:SettingsButtonTone::Plain))g_dump_panel=DumpPanel::Dumps; + PaddockGap(18.F); + if(g_dump_panel==DumpPanel::Export) { + DetailCard(width,{18.F,16.F},[&](float inner) { + DetailHeading("WHAT TO EXPORT",inner,kSetWarm,10.F); + SettingsColumns columns(inner,inner<400.F,10.F); + const auto check=[&](const char* id,const char* label,bool& value){columns.Begin();SettingsCheck(id,label,&value,columns.column,{},locked);columns.End();}; + check("##dump-game","Original game",g_dump_options.game); + check("##dump-characters","Characters",g_dump_options.characters); + check("##dump-tracks","Tracks",g_dump_options.tracks); + check("##dump-vehicles","Vehicles",g_dump_options.vehicles); + columns.Finish();PaddockGap(12.F); + SettingsCheck("##dump-disabled","Include disabled mods",&g_dump_options.include_disabled,inner,{},locked); + SettingsCheck("##dump-artwork","Include source artwork",&g_dump_options.source_artwork,inner,{},locked); + PaddockGap(10.F); + DetailText(g_dump_options.only_selected?std::to_string(g_dump_options.selected.size())+" selected sources":"All sources in the selected categories",inner,kSetMuted,8.F); + DumpActionRow actions(inner); + if(actions.Button("Choose mods",locked)){g_dump_sources=true;dumping::refresh(g_dump_options);} + }); + DrawDumpDestination(width,locked); + DumpActionRow actions(width); + const bool any=g_dump_options.game||g_dump_options.characters||g_dump_options.tracks||g_dump_options.vehicles; + if(actions.Button("EXPORT TEXTURES",locked||!any,SettingsButtonTone::Plain,true)) { + g_dump_ui_status.clear();dumping::export_installed(g_dump_options); + } + }else if(g_dump_panel==DumpPanel::Capture) { + DetailCard(width,{18.F,16.F},[&](float inner) { + DetailHeading("CAPTURE AS YOU PLAY",inner,kSetWarm,10.F); + DetailText("Collect original textures as you encounter them in game, including custom tracks, characters and vehicles.",inner,kSetMuted,0.F); + }); + DrawDumpDestination(width,locked); + DumpActionRow actions(width); + if(actions.Button("CAPTURE NEXT GAME",locked,SettingsButtonTone::Plain,true)) { + g_dump_ui_status.clear();dumping::capture(g_dump_options); + } + }else { + DetailCard(width,{18.F,16.F},[&](float inner) { + DetailHeading("CURRENT DUMP",inner,kSetWarm,10.F); + if(state->folder.empty())DetailText("Open an existing dump, or export textures to get started.",inner,kSetMuted,0.F); + else { + DetailText(DumpPathText(state->folder.filename()),inner,kSetText,8.F); + DumpActionRow actions(inner); + if(actions.Button("Browse textures",locked)){g_dump_browser_status.clear();g_dump_browser=true;InvalidateTextureDumpPreviews();dumping::gallery(state->folder,g_dump_search.data(),0);} + if(actions.Button("Create starter pack",locked))dumping::starter_pack(state->folder); + if(actions.Button("Open folder"))OpenDumpFolder(state->folder); + } + }); + PaddockGap(14.F); + DumpActionRow actions(width); + if(actions.Button("Open existing dump",locked))ChooseDumpFolder(true); + } + DrawDumpJob(width,state); + }else if(!state->error.empty()) { + DetailText(state->error,width,kSetWarm,8.F); + } + // Modal ownership remains independent of the disclosure/tab visibility. + { + const PaddockFlatScope paddock; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,{0.F,0.F}); + ++g_paddock_modal_windows; + if(g_dump_sources)ImGui::OpenPopup("Choose texture sources##dump"); + auto display=ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowSize({std::min(850.F,display.x-32.F),std::min(display.y-32.F,display.y*.75F)},ImGuiCond_Always); + ImGui::SetNextWindowPos({display.x*.5F,display.y*.5F},ImGuiCond_Always,{.5F,.5F}); + if(ImGui::BeginPopupModal("Choose texture sources##dump",nullptr,ImGuiWindowFlags_NoResize|ImGuiWindowFlags_NoSavedSettings)) { + const float inner=ImGui::GetContentRegionAvail().x; + DetailText(state->busy?"Loading installed sources...":"Choose the sources to export.",inner,kSetMuted,8.F); + bool all=!g_dump_options.only_selected; + if(SettingsCheck("##all-dump-sources","All sources",&all,inner,{},locked)){g_dump_options.only_selected=!all;g_dump_options.selected.clear();if(!all)for(const auto& source:state->sources)g_dump_options.selected.insert(source.id);} + ImGui::BeginChild("##dump-source-list",{0,std::max(80.F,ImGui::GetContentRegionAvail().y-58.F)},false,ImGuiWindowFlags_NavFlattened); + for(const auto& source:state->sources) { + bool selected=!g_dump_options.only_selected||g_dump_options.selected.contains(source.id); + const auto label=source.name+" ("+source.kind+(source.enabled?"":", disabled")+")"; + ImGui::PushID(source.id.c_str()); + if(SettingsCheck("##source",label,&selected,ImGui::GetContentRegionAvail().x,{},locked)) { + if(!g_dump_options.only_selected){g_dump_options.only_selected=true;for(const auto& item:state->sources)g_dump_options.selected.insert(item.id);} + if(selected)g_dump_options.selected.insert(source.id);else g_dump_options.selected.erase(source.id); + } + ImGui::PopID();PaddockGap(6.F); + } + ImGui::EndChild();PaddockGap(10.F); + if(SettingsButton("Done",std::min(inner,SettingsButtonWidth("Done")))){g_dump_sources=false;ImGui::CloseCurrentPopup();}ImGui::EndPopup(); + } + if(g_dump_folder_info)ImGui::OpenPopup("Dump folder##dump"); + if(BeginSettingsDialog("Dump folder##dump","DUMP FOLDER",720.F)) { + DetailText(DumpPathText(g_dump_options.destination),ImGui::GetContentRegionAvail().x); + if(SettingsButton("Close")){g_dump_folder_info=false;ImGui::CloseCurrentPopup();} + EndSettingsDialog(); + } + DrawDumpBrowser(width,state); + --g_paddock_modal_windows; + ImGui::PopStyleVar(); + } + ImGui::PopID();ImGui::PopStyleVar(2); + --g_settings_regular_labels; +} +void DrawTexturesPage(float width) { + DrawTexturesPageWithoutDump(width); + DrawTextureDumping(width); +} diff --git a/runtime-recomp/src/game/texture_gallery.inl b/runtime-recomp/src/game/texture_gallery.inl new file mode 100644 index 00000000..756b163c --- /dev/null +++ b/runtime-recomp/src/game/texture_gallery.inl @@ -0,0 +1,129 @@ +// Worker-only, read-only browser. Never constructs a writable Workspace or +// rebuilds its export indexes when changing pages. Selected PNG paths still +// pass the same member()/link checks before any filesystem read. +struct GalleryRequest { + std::filesystem::path folder; + std::string search; + unsigned page=0; + bool reload=false; + std::uint64_t serial=0; +}; +struct GalleryResult { + std::filesystem::path folder; + std::string revision; + std::vector thumbnails; + unsigned page=0,pages=0; + std::uint64_t matches=0; +}; +struct GalleryFileStamp { + std::uintmax_t bytes=0; + std::filesystem::file_time_type modified{}; + bool operator==(const GalleryFileStamp&)const=default; +}; +GalleryFileStamp gallery_stamp(const std::filesystem::path& path) { + if(link(path)||!std::filesystem::is_regular_file(path))throw Error("A texture browser file is missing or is a link."); + return {std::filesystem::file_size(path),std::filesystem::last_write_time(path)}; +} +class GalleryCache { + struct Record {Thumbnail item;std::string searchable;}; + struct Preview { + GalleryFileStamp stamp; + unsigned width=0,height=0; + Bytes rgba; + std::uint64_t used=0; + }; + std::filesystem::path root_; + GalleryFileStamp stamp_; + std::string revision_,search_; + std::vector records_; + std::vector matches_; + std::map previews_; + bool indexed_=false,filtered_=false; + std::uint64_t clock_=0,index_generation_=0; + static std::string lower(std::string text) { + for(auto& c:text)if(c>='A'&&c<='Z')c+=32; + return text; + } +public: + static constexpr unsigned page_size=24,preview_capacity=128; + std::optional load(const GalleryRequest& request,const std::function& current) { + const auto start=Clock::now(); + const auto root=private_storage_path(request.folder); + if(link(root))throw Error("The dump folder is a link."); + const auto manifest_stamp=gallery_stamp(root/"dump.json"); + const bool rebuild=request.reload||!indexed_||root!=root_||manifest_stamp!=stamp_; + if(rebuild) { + // Writer/export calls keep the default full validation. This + // read-only index validates file paths lazily on the selected page. + auto doc=read_manifest(root,false); + std::vector records;records.reserve(doc["images"].size()); + for(std::size_t index=0;index();r.item.owner=image.at("owner").get(); + r.item.path=image.at("original").get();r.item.status=image.at("status").get(); + r.item.width=image.at("width");r.item.height=image.at("height");r.item.frame=image.value("frame",0U); + r.searchable=lower(r.item.owner+" "+r.item.name);records.push_back(std::move(r)); + } + if(!current())return {}; + // Do not cache a manifest replaced while its index was being read. + if(gallery_stamp(root/"dump.json")!=manifest_stamp)throw Error("The dump changed while loading. Refresh the browser."); + root_=root;stamp_=manifest_stamp;revision_=doc.value("rom_revision",""); + records_=std::move(records);previews_.clear();indexed_=true;filtered_=false;++index_generation_; + } + const auto search=lower(request.search); + if(!filtered_||search!=search_) { + std::vector matches;matches.reserve(records_.size()); + for(std::size_t index=0;indexsecond.stamp!=stamp) { + auto bytes=read_file(path,64*MiB);int width=0,height=0,channels=0; + if(!stbi_info_from_memory(bytes.data(),int(bytes.size()),&width,&height,&channels)||width<=0||height<=0||std::uint64_t(width)*height>16*MiB) + throw Error("Invalid preview image dimensions."); + auto* decoded=stbi_load_from_memory(bytes.data(),int(bytes.size()),&width,&height,&channels,4); + if(!decoded)throw Error("Could not read this texture preview."); + struct Free{unsigned char* p;~Free(){stbi_image_free(p);}}free{decoded}; + Preview preview;preview.stamp=stamp; + preview.width=width>=height?96:std::max(1,96*width/height); + preview.height=height>=width?96:std::max(1,96*height/width); + preview.rgba.resize(preview.width*preview.height*4); + for(unsigned y=0;y=preview_capacity) { + auto oldest=std::min_element(previews_.begin(),previews_.end(),[](const auto& a,const auto& b){return a.second.usedsecond;preview.used=++clock_; + item.preview_width=preview.width;item.preview_height=preview.height;item.rgba=preview.rgba; + item.preview_key=text_path(root_)+"/"+item.path+":"+std::to_string(index_generation_)+":"+std::to_string(stamp.bytes)+":"+std::to_string(stamp.modified.time_since_epoch().count()); + }catch(const std::exception& e) { + // One damaged PNG must not prevent navigation through the dump. + item.preview_error=e.what(); + } + result.thumbnails.push_back(std::move(item)); + } + if(!current())return {}; + const auto elapsed=std::chrono::duration(Clock::now()-start).count(); + std::fprintf(stderr,"[texture-browser] page=%u/%u matches=%zu index-rebuilt=%u PNG-reads=%u cache-hits=%u elapsed=%.2fms\n", + result.pages?result.page+1:0,result.pages,matches_.size(),unsigned(rebuild),reads,hits,elapsed); + return result; + } +}; diff --git a/runtime-recomp/src/game/title_text_rt64.hpp b/runtime-recomp/src/game/title_text_rt64.hpp new file mode 100644 index 00000000..6f74eb60 --- /dev/null +++ b/runtime-recomp/src/game/title_text_rt64.hpp @@ -0,0 +1,340 @@ +#pragma once +#include "hle/rt64_state.h" +#include "hle/rt64_rdp.h" +#include "hle/rt64_rsp.h" +#include "revision_addresses.hpp" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::presentation { + +// The font renderer has already CPU-clipped a partially visible left glyph +// before packing unsigned 12-bit rectangle coordinates. Recover ONLY complete, +// recognised title-name runs from the immutable task. Never change guest RAM, +// font metrics, timings, texture uploads, colours or unrelated menu scissors. +class TitleTextViewport { + struct Symbols { + std::uint32_t begin,modes,fonts,font_count,kerning,names,colours; + }; + static constexpr std::array symbol_tables_{{ + {0x800E3690U,0x800E36C8U,0x8012A7E4U,0x8012A7E0U,0x8012A7F0U,0x800DF83CU,0x800DF9F8U}, + {0x800E3C20U,0x800E3C58U,0x8012ADA4U,0x8012ADA0U,0x8012ADB0U,0x800DFDBCU,0x800DFF78U} + }}; + struct Glyph { + const RT64::DisplayList* command=nullptr; + int left=0,top=0,right=0,bottom=0,s=0,t=0,atlas=-1; + }; + static constexpr unsigned capacity=96; + std::array glyphs_{}; + unsigned count_=0; + const Symbols* symbols_=nullptr; + const std::uint8_t* ram_=nullptr; + std::uint32_t font_=0; + float cover_=1.F; + bool enabled_=false; + std::array images_{}; + unsigned image_count_=0; + bool credits_=false,quiet_=false; + std::uint32_t font_base_=0,credits_elements_=0; + static constexpr std::array credit_rows_{{0x800E1B50U,0x800E20D0U}}; + + void skipped(unsigned reason,unsigned detail=0) const { + if(quiet_)return; + static thread_local std::uint32_t reported=0; + const auto bit=1U<0 && p<0x800000U && size<=0x800000U-p; + } + std::uint8_t u8(std::uint32_t address) const {return ram_[(address&0x7FFFFFU)^3U];} + std::uint16_t u16(std::uint32_t address) const { + std::uint16_t value;std::memcpy(&value,ram_+((address&0x7FFFFFU)^2U),2);return value; + } + std::uint32_t u32(std::uint32_t address) const { + std::uint32_t value;std::memcpy(&value,ram_+(address&0x7FFFFCU),4);return value; + } + static int signed12(unsigned value) {return value&0x800U?int(value)-4096:int(value);} + bool colour(std::uint32_t env,std::uint32_t prim) const { + if(credits_)return (prim&0xFFFFFF00U)==0xFFFFFF00U; // Exact row colours checked after parsing. + if(prim==0xFFFFFFFFU && env==0xFFFFFF00U)return true; + if((prim&0xFFFFFF00U)!=0xFFFFFF00U)return false; + for(unsigned i=0;i<4;++i) { + const auto p=symbols_->colours+i*5; + const auto expected=(std::uint32_t(u8(p))<<24)|(std::uint32_t(u8(p+1))<<16)| + (std::uint32_t(u8(p+2))<<8)|u8(p+3); + if(env==expected && (prim&255U)==u8(p+4))return true; + } + return false; + } + int atlas(const RT64::DisplayList& command) const { + for(unsigned i=0;i<32;++i) { + const auto header=u32(font_+0x80+i*4); + if(!valid(header,0x20))continue; + if((u32(header+0xC)&0x1FFFFFFFU)==(command.w1&0x1FFFFFFFU) && + u16(header+0xA)==command.p0(16,8))return int(i); + } + return -1; + } + bool match_name(std::uint32_t name) { + if(!valid(name,capacity+1))return false; + unsigned letters=0; + int advance=0,base_x=0,base_y=0; + const bool compact=u32(symbols_->kerning)!=0; + std::array repaired{}; + for(unsigned i=0;i<=capacity;++i) { + const auto ch=u8(name+i); + if(ch==0) { + if(!letters || letters!=count_ || advance>320)return false; + // draw_text used middle/centre alignment. Translate the WHOLE + // string's centre path, not individual glyph positions/widths. + const int centre=base_x/4+(advance>>1); + if(centre< -128 || centre>448 || base_y<128*4 || base_y>240*4)return false; + const int shift=int(std::lround((centre-160.F)*(cover_-1.F)*4.F)); + for(unsigned j=0;j=128 || letters>=count_)return false; + const auto data=font_+0x100+(ch-32)*8; + const auto& raw=glyphs_[letters]; + if(u8(data)==255 || raw.atlas!=u8(data))return false; + const int width=u8(data+6)*4,height=u8(data+7)*4; + const int s=u8(data+4)*32,t=u8(data+5)*32; + if(!width || !height)return false; + if(!letters) { + base_x=raw.right-width-u8(data+2)*4; + base_y=raw.bottom-height-u8(data+3)*4; + } + const int left=base_x+advance*4+u8(data+2)*4,top=base_y+u8(data+3)*4; + const int right=left+width,bottom=top+height; + // Reproduce retail's exact clipping and atlas offset as evidence + // that this command is THIS character, before undoing that clip. + const bool clipped=left<0 && right>0; + if(raw.left!=(clipped?0:left) || raw.right!=right || raw.top!=top || raw.bottom!=bottom || + raw.s!=s+(clipped?-left*8:0) || raw.t!=t)return false; + repaired[letters]=raw; + repaired[letters].left=left;repaired[letters].s=s; + int space=u16(font_+0x20)?u16(font_+0x20):u8(data+1); + if(compact && space)--space; + advance+=space;++letters; + } + return false; + } + bool match_credit(std::uint32_t element,std::uint32_t env,std::uint32_t prim) { + // Only the actual active ASCII credit rows, never arbitrary dialogue. + const auto name=u32(element+0x14); + if(!valid(name,capacity+1) || u8(element+0x13)!=0 || u8(element+0x12)!=4 || + u16(element+4)!=160 || u16(element+0x1E)!=0 || + font_!=font_base_+std::uint32_t(u8(element+0x11))*0x400U)return false; + const auto expected_env=(std::uint32_t(u8(element+12))<<24)| + (std::uint32_t(u8(element+13))<<16)|(std::uint32_t(u8(element+14))<<8)|u8(element+15); + if(env!=expected_env || prim!=(0xFFFFFF00U|u8(element+16)))return false; + const bool compact=u32(symbols_->kerning)!=0; + int alignment_width=0; + unsigned expected_count=0; + for(unsigned i=0;i<=capacity;++i) { + const auto ch=u8(name+i); + if(!ch)break; + if(i==capacity || ch<32 || ch>=128)return false; + int space=0; + if(ch==32)space=u16(font_+0x24); + else { + const auto data=font_+0x100+(ch-32)*8; + if(u8(data)==255)return false; + space=u16(font_+0x20)?u16(font_+0x20):u8(data+1); + ++expected_count; + } + if(compact && space)--space; + alignment_width+=space; + } + if(!expected_count || expected_count!=count_ || alignment_width>512)return false; + std::array repaired{}; + int advance=0,base_x=0,base_y=0; + unsigned letters=0; + for(unsigned i=0;i<=capacity;++i) { + const auto ch=u8(name+i); + if(!ch) { + const int centre=base_x/4+(alignment_width>>1); + // Retail credits enter at 480, hold at 160 and leave at -160. + if(centre< -161 || centre>481 || base_y<0 || base_y>288*4)return false; + const int shift=int(std::lround((centre-160.F)*(cover_-1.F)*4.F)); + for(unsigned j=0;j0; + // Credits can exceed X=512 while entering from the right. Compare + // the PACKED coordinate domain, then restore the full signed quad. + if((raw.left&4095)!=((clipped?0:left)&4095) || (raw.right&4095)!=(right&4095) || + raw.top!=top || raw.bottom!=bottom || raw.s!=s+(clipped?-left*8:0) || raw.t!=t)return false; + repaired[letters]=raw; + repaired[letters].left=left;repaired[letters].right=right;repaired[letters].s=s; + int space=u16(font_+0x20)?u16(font_+0x20):u8(data+1); + if(compact && space)--space; + advance+=space;++letters; + } + return false; + } + +public: + void begin(RT64::State& state,float aspect,bool modern_fit) { + count_=image_count_=0;enabled_=false;credits_=quiet_=false;ram_=state.RDRAM; + if(!modern_fit || !std::isfinite(aspect) || aspect<=4.F/3.F+0.001F)return; + const auto menu=u32(revision_addresses::CurrentMenuId); + credits_=menu==25; + if(!credits_ && (menu!=0 || u32(revision_addresses::TitleRevealTimer)!=0))return; + const bool v80=revision_addresses::CurrentMenuId==revision_addresses::kUsV80.CurrentMenuId; + symbols_=&symbol_tables_[v80?1:0]; + credits_elements_=credit_rows_[v80?1:0]; + const auto fonts=u32(symbols_->fonts);font_base_=fonts; + if(u32(symbols_->font_count)<3 || u32(symbols_->font_count)>32 || !valid(fonts,3*0x400)) { + skipped(1,u32(symbols_->font_count));return; + } + font_=fonts+2*0x400; // ASSET_FONTS_BIGFONT, decomp font.h/asset_enums.h. + if(!u16(font_+0x22) || u16(font_+0x22)>64){skipped(2,u16(font_+0x22));return;} + cover_=std::clamp(aspect/(4.F/3.F),1.F,8.F);enabled_=true; + } + void prepare(RT64::State& state,const RT64::DisplayList* start) { + if(!credits_){prepare_one(state,start);return;} + if(!enabled_ || start->p0(16,1) || + (state.rsp->fromSegmented(start->w1)&0x1FFFFFFFU)!=(symbols_->begin&0x1FFFFFFFU))return; + for(const unsigned font:std::array{2,0}) { + font_=font_base_+font*0x400;quiet_=true; + prepare_one(state,start);quiet_=false; + if(count_)return; + } + skipped(8); + } + void prepare_one(RT64::State& state,const RT64::DisplayList* start) { + if(!enabled_ || start->p0(16,1) || + (state.rsp->fromSegmented(start->w1)&0x1FFFFFFFU)!=(symbols_->begin&0x1FFFFFFFU))return; + count_=0; + const auto offset=reinterpret_cast(start)-ram_; + if(offset<0 || std::uint64_t(offset)+8>0x800000U || (offset&7))return; + std::array raw{}; + unsigned n=0; + int current_atlas=-1; + bool mode=false,ended=false; + std::uint32_t env=0,prim=0; + for(unsigned i=1;i<1024;++i) { + if(std::uint64_t(offset)+(i+1)*8>0x800000U)return; + const auto& cmd=start[i]; + const auto opcode=cmd.w0>>24; + if(opcode==0xB7 && cmd.w1==0x0205U) {ended=true;break;} + if(opcode==0xE7)continue; + if(opcode==0xFA) {prim=cmd.w1;continue;} + if(opcode==0xFB) {env=cmd.w1;continue;} + if(opcode==0x07) { + if((cmd.w1&0x1FFFFFFFU)==(symbols_->modes&0x1FFFFFFFU) && cmd.p0(16,8)==2)mode=true; + else {current_atlas=atlas(cmd);if(current_atlas<0){skipped(3,cmd.w1);return;}} + continue; + } + if(opcode!=0xE4 || !mode || current_atlas<0 || n==capacity || !colour(env,prim) || + std::uint64_t(offset)+(i+3)*8>0x800000U){skipped(4,cmd.w0);return;} + // Derived from DKR's own gbi.h, not F3DEX: B3 is ST, B2 is slope. + if(start[i+1].w0!=0xB3000000U || start[i+2].w0!=0xB2000000U || + start[i+2].w1!=0x04000400U || cmd.p1(24,3)!=0){skipped(5,start[i+1].w0);return;} + raw[n++]={&cmd,signed12(cmd.p1(12,12)),signed12(cmd.p1(0,12)), + signed12(cmd.p0(12,12)),signed12(cmd.p0(0,12)), + std::int16_t(start[i+1].p1(16,16)),std::int16_t(start[i+1].p1(0,16)),current_atlas}; + i+=2; + } + if(!ended || !n){skipped(6,n);return;} + if(credits_) { + for(unsigned row=0;row<9;++row) { + const auto element=credits_elements_+row*0x20; + if(!u32(element+0x14))break; + glyphs_=raw;count_=n; + if(match_credit(element,env,prim))return; + } + } else { + for(unsigned name=0;name<10;++name) { + glyphs_=raw;count_=n; + if(match_name(u32(symbols_->names+name*0x2C)))return; + } + } + count_=0; + skipped(7,n); + } + bool draw(RT64::State& state,RT64::DisplayList** cursor) { + if(!enabled_ || !count_ || state.rdp->colorImage.width!=320)return false; + const Glyph* glyph=nullptr; + for(unsigned i=0;ileft,glyph->top,glyph->right,glyph->bottom,0, + std::int16_t(glyph->s),std::int16_t(glyph->t),1024,1024,false,alignment); + state.flush(); + rdp.scissorRectStack[at]=previous; + rdp.extended.scissorLeftOriginStack[at]=left_origin; + rdp.extended.scissorRightOriginStack[at]=right_origin; + state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); + rdp.setRectAspect(aspect); + *cursor+=2; + static thread_local std::array notices{}; + auto& notice=notices[credits_?1:0]; + if(!notice) { + if(credits_)std::fprintf(stderr,"[credits][text] full-width slides; original font proportions; signed glyphs and spaces\n"); + else std::fprintf(stderr,"[title][names] full-width travel; unclipped signed glyphs; original proportions and colour trails\n"); + notice=true; + } + return true; + } + bool has_images() const {return image_count_!=0;} + template void normalise(Pair& pair) const { + if(pair.colorImage.width==320 && !pair.scissorRect.isNull() && + std::find(images_.begin(),images_.begin()+image_count_,pair.colorImage.address)!=images_.begin()+image_count_) { + pair.scissorRect.ulx=0;pair.scissorRect.lrx=1280; + } + } +}; +} // namespace dkr::runtime::presentation diff --git a/runtime-recomp/src/game/vehicle_base_hashes.hpp b/runtime-recomp/src/game/vehicle_base_hashes.hpp new file mode 100644 index 00000000..5901ecb4 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_base_hashes.hpp @@ -0,0 +1,67 @@ +#pragma once +// SHA-256 only: no ROM/model bytes. Verified native model ownership profiles. +#include +#include +namespace dkr::mods::vehicles { +inline const std::map BaseModelHashes{ + {19,"7da92dbc6706b391359b4603518092911edb11c9f174291e74d3c68bd9141ad0"}, + {20,"fe879659b9bb38b2f65f73b8f037e6210a88323fba091f1971e0fe75ad4a4ff5"}, + {21,"8208c8c3eca95b30af64050e82325ec9730021814b0d5b6f89f353b0526f89ad"}, + {22,"841da0fb29d85ac63a26b3a15d40e3f09379afeab74600acce7de7fabdf318ac"}, + {23,"a5c5015108c39e5e485e74fc07a45c047b994d9b036b36caaa0080f1eaeb4088"}, + {24,"dd07b75514fcdd1dbf4c27315742c50eb13258a1c0264396c76a10cbbe11680e"}, + {25,"8509d353e6adb2e0df898665b760b71a152ab89ad28b78e05c8996d2df285ce9"}, + {26,"33b70089c3c6ddd27c99fa5de018ec135e3cfdfc86ebd04a128051200c0ac6ef"}, + {27,"9918c29719318875f4264fba228b9d35466005b6bc620794c0ceeab5a3b8e7e3"}, + {28,"d20c289e56d47823c09e2b6b835c42d99272626ca75439d9f9026a6e81361bb4"}, + {29,"7ab53d7d295a26bbb5819ad429820dbb0226ac0f4de6399b63e78610ffa94513"}, + {30,"20b2818d5d0ab762f11853e35159100815be8a223148c7b35772fdaf5b3bf39b"}, + {31,"aea401fb04c3823fa2b0aaa26960fe52593c7ac134669554007f3209206393da"}, + {32,"f4c593c3b1ce9f93b410056df44fd24fc8788886b10d97f57dc106cb36e92ee4"}, + {33,"1570f99290222483edb27a034fe7bd0dd60d98adf73acc264c495c107a1d53e7"}, + {34,"d6ffffbfdba404724225b684007de54c6916810d7f2238e8cedfe40abebe0691"}, + {35,"ecbd2f47721a9b04e203829c4170be20674813622b4cad7269c19bd3789edf89"}, + {36,"5a7ddbe3d87effae3bc26c95b2929fe9b2c385f88bf687fdf9b3dd154f735ace"}, + {37,"bde65de3e9cab97b5a59f8dcf22fb424f2a5e78ae91ca0b9d41c0ae71d9a31c9"}, + {38,"c224b9e2465e1e653cbc663929d8e95f0368ecee3799a4cf5941ea3f658413a8"}, + {39,"17b1333524735e1de3678bbaad079ba3fe3acd9d600fb2492fd854202c98a281"}, + {40,"29435326ba92e38b186166372d5ab043180fe0eb9e3a3d5f4616da7ff62a5cea"}, + {41,"cff210ac45bc13a764e3bb25abe9164c1875ca42b3d4ffe4650a62b82f205c1c"}, + {42,"5f23ec4b5efe6367b02d0904020725d0282498731bc35fca3d62fc8ef5521519"}, + {43,"b8d66810976a4aafd8270f2820308b965f8619d5849953da6ffbfa8a7bd12f0a"}, + {44,"e185896a320c1feb95df2c56b83b0e2ced92d2dc624f443471dd27be0e325b51"}, + {45,"4e0b0c985fd1e52e957d001d7957170fac2f09ab8de46b09fa196c4fcd41f87c"}, + {46,"98e756dabd8b3d28f9137c87ab6b36202182a4f50ce322ca78a4764a1db33ee2"}, + {47,"385cf295c452e05fa4234e1762a7ba40f18999e8fd2ae3b7853dcd8319ed689b"}, + {48,"e9b1a57d8c2b2a98c4083517227ba784d5ad6c884aa7f1a6e8f7756d6362923d"}, + {49,"e4a0f2cccde7d9f3a4ca651e9f76188a803444ade56caf67d8c22ff55d7357d9"}, + {50,"25a9f7f2c8188d679714555b1a9527c2cbcf1a00def71df2d1f4ecda08be0eda"}, + {51,"55c27cb6a1e46104e5745286ff9bcb63f47b64d2e9d4f3ddcc6cfc535d23afb1"}, + {52,"90ed07ea6f7d3043cca826dbcc39e4f8906ccb7d49892b2cec019ffb05cbaaf6"}, + {53,"be15f12c2e830ba3616edba68a38996c2177743f65f0aa0146d755ea4cd25130"}, + {54,"016ee10d60624cca9d37bead91872814d323de784a3345a3d226681dc6c212f4"}, + {55,"d96658e772f10af2b3eaa716a680439e3a2a28a69019497dac3f32ff47ef33be"}, + {56,"a9a94ca984c7545c7231500b723a42eff08b2743b1727a43dc8e64258c4d8a01"}, + {57,"d09ad5e5f3f3765f91082a650411a1188b12795d4a9f0abd88bc74ae135bc6d0"}, + {58,"d1839dde0f517cf177f0a3e0b0c69ff31447dba9a4abb57cb01bafa6fb375949"}, + {59,"db2ecf373fa1d983b366a8758c8f214a7eee288e1b0ff9320e9479ca0fab5de1"}, + {60,"404720e7541841c3bf6dcedfab820bf6b8c45d9b0af20f0c63869fc2be18ba05"}, + {61,"aa7b6962705909a1c4a9952310780f2e93d5edeb84c205c855924a482519fd9f"}, + {62,"6750e8d74992a2536a19b451f256a198b7d7960777c0c5891ef455881328c531"}, + {63,"14b0be91cacc13cf4acec2e08f42279df7826fed7ba168c03d90e54ed14b8292"}, + {64,"ac3ec50803809514b2bb230f50e05e40fa5b88e1a3aa0ae8a83d273c2eb1c7d8"}, + {65,"07f71aef4d020c486092d441b1f4ec79d4af63a14a7e78fce78fe446c247cbb7"}, + {66,"77e8937a32f816298a4536de98590c12ec22a49a190de34586fd08622843c0ff"}, + {67,"e001baaff392f81852fe826892b5455c2b7367bfe72777440d9e5c1a6f632f14"}, + {68,"5259fd3d65c06c35f098cc625b11c8072e53d0de630fac9a65d4420de63994cf"}, + {69,"18410505bdfab8f6a7f60f7f174e5afd4ae9645bc421ab1b51669db447f83a67"}, + {70,"66481bc56178e767c7a24f841eb9cefa0161c0f2c1b88c8b577de99668f7ebb9"}, + {71,"8ceba732b83c4f24ac56280aad7f4f2aa2c3172e64417521b7df29bb546ca96d"}, + {72,"3a94738a38e083427113fa057d2f7203c9b3519e4b1ef488f6c1ccc3a768aea3"}, + {73,"c0fe830c72ef37e6f2ebf2dd959bdb7073ecfbacd63534e974f2c0d76d4af64b"}, + {75,"d71304bba9be2a100c55263c1f77d537d7364ecd7ea6496b044790adf4e287d0"}, + {76,"cf8738029b3acc4fe58bd658b718d22b892116eca3694fd0b1e25e14cc098377"}, + {77,"5179c7dac5511d9a5f4823f80bf5b7383bd4973c41958571fab3780ce3c2b70e"}, + {78,"058ebd8cff68afaf1bee173f17acb5d7868808848f5d1da927992dba1549656c"}, +}; +} diff --git a/runtime-recomp/src/game/vehicle_binding.cpp b/runtime-recomp/src/game/vehicle_binding.cpp new file mode 100644 index 00000000..008a7db6 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_binding.cpp @@ -0,0 +1,31 @@ +#include "vehicle_binding.hpp" +#include +#include + +namespace dkr::mods::vehicles { +namespace { +struct Binding { std::weak_ptr owner;std::shared_ptr resources; }; +std::mutex mutex; +std::map bindings; +std::atomic> renderer; +} +void bind_resources(std::shared_ptr owner,std::shared_ptr resources) { + if(!owner||!resources||resources->library.packages.empty())return; + std::lock_guard lock(mutex); + std::erase_if(bindings,[](const auto& b){return b.second.owner.expired();}); + const auto it=bindings.find(owner.get()); + if(it!=bindings.end()&&it->second.resources->identity!=resources->identity)throw Error("A running asset owner cannot change its custom vehicles."); + if(it==bindings.end()&&bindings.size()>=64)throw Error("Too many concurrent custom-vehicle preparation owners."); + bindings.insert_or_assign(owner.get(),Binding{owner,std::move(resources)}); +} +std::shared_ptr resources_for(const void* owner) { + if(!owner)return {}; + std::lock_guard lock(mutex);const auto it=bindings.find(owner); + if(it==bindings.end())return {}; + if(it->second.owner.expired()){bindings.erase(it);return {};} + return it->second.resources; +} +void inherit_resources(const void* source,std::shared_ptr destination) {bind_resources(std::move(destination),resources_for(source));} +void publish_render_resources(std::shared_ptr resources) {renderer.store(std::move(resources),std::memory_order_release);} +std::shared_ptr render_resources() {return renderer.load(std::memory_order_acquire);} +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_binding.hpp b/runtime-recomp/src/game/vehicle_binding.hpp new file mode 100644 index 00000000..ddc3ed53 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_binding.hpp @@ -0,0 +1,12 @@ +#pragma once +#include "vehicle_resources.hpp" + +namespace dkr::mods::vehicles { +// Immutable preparation attachments, not mutable gameplay or global choices. +// This avoids changing any accepted RuntimeSession/RuntimeResources layout. +void bind_resources(std::shared_ptr owner,std::shared_ptr); +std::shared_ptr resources_for(const void* owner); +void inherit_resources(const void* source,std::shared_ptr destination); +void publish_render_resources(std::shared_ptr); +std::shared_ptr render_resources(); +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_character_masks.hpp b/runtime-recomp/src/game/vehicle_character_masks.hpp new file mode 100644 index 00000000..491a0ca6 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_character_masks.hpp @@ -0,0 +1,78 @@ +#pragma once +// Static reviewed ownership metadata only; no game/model/texture bytes. +// SHA covers all decompressed model bytes except relocatable texture IDs. +#include +#include +#include +#include +namespace dkr::mods::vehicles { +struct ReviewedCharacterMask { unsigned vertices,triangles; std::vector> ranges; }; +inline const std::map ReviewedCharacterMasks{ + // DKR_Blastoise - Custom Racer 6 LOD0; keep 378 driver faces. + {"fc0595169658be546bf4161f13184355f03893108060271e395f77a8556219c7",{706,448,{{378,448}}}}, + // DKR_Blastoise - Custom Racer 10 LOD0; keep 367 driver faces. + {"ba8cc38f3d0b7c36aaa33d8653d3e7ea7823d23cfbe63ae9099598811b58fefd",{696,437,{{367,437}}}}, + // DKR_Blastoise - Custom Racer 1 LOD0; keep 378 driver faces. + {"6550b03c5d7d4c9e2f1a77c0b29cc684ad886bf894b01f4264200f4672f34a4a",{706,448,{{378,448}}}}, + // Link LOD0; keep 263 driver faces. + {"2a4eb94c1e3c4cd88a7abab84b3f81886b650b2a5b2ebf407e82a32c51534680",{556,335,{{255,327}}}}, + // Link LOD1; keep 263 driver faces. + {"46a8e9111a9c8f22c839a8b24fd3daa1badeec759471fc2f8d814a3259f1c939",{556,335,{{255,327}}}}, + // Link LOD3; keep 141 driver faces. + {"33080b291c0786903304a49d75e96d85fecf4625008e90ed8dd95ea805b468d9",{358,196,{{141,196}}}}, + // Link LOD4; keep 74 driver faces. + {"bcb68984b9bfeb5a931d63c4a0f5db8c335a7513893a1f6759bac1bb43aef07d",{201,129,{{74,129}}}}, + // Rexy LOD0; keep 117 driver faces. + {"cfb223e014d8f60ce6adc25d67e4996b9d09f36f0d33a0843508f3d814f75b88",{282,193,{{0,64},{181,193}}}}, + // Rexy LOD1; keep 117 driver faces. + {"bea874c27e9b6ff0a0f52f8b715ff8da493f6af5be20d7407e2c57e6bf57d1e0",{282,193,{{0,64},{181,193}}}}, + // Rexy LOD3; keep 71 driver faces. + {"6d98149204e1377bb16a7dc4caaac4474f78be949889368b951865861964f528",{210,130,{{0,47},{118,130}}}}, + // DKR-Yoshi-Racing-Story64v04 - Custom Racer 9 LOD0; keep 322 driver faces. + {"f52e4a5a9782656910f9129b7785e82b1d32060441e51d95a4fa0edd1edb669d",{480,389,{{208,220},{231,233},{249,272},{294,312},{337,343},{383,389}}}}, + // DKR-Yoshi-Racing-Story64v04 - Custom Racer 9 LOD4; keep 71 driver faces. + {"eaf85793a2307437572856cd69396983f0d0402fffdace199a7ffbe2b2c1df76",{155,101,{{54,57},{62,64},{65,80},{84,86},{87,89},{95,101}}}}, + // DKR-Yoshi-Racing-Story64v04 - Custom Racer 5 LOD0; keep 322 driver faces. + {"656cad4efc888abfc31889ef010e1db4fd54e22b42470ab62ae3f42dc2fe23e9",{479,389,{{208,220},{231,233},{249,272},{294,312},{337,343},{383,389}}}}, + // DKR-Yoshi-Racing-Story64v04 - Custom Racer 5 LOD1; keep 322 driver faces. + {"70bf5db2828a47c8cd806058d704b88fcba1b3fbf57a0251d8d4470589c1862c",{473,389,{{208,220},{231,233},{249,272},{294,316},{341,347},{387,389}}}}, + // 007 Bond (car only) LOD0; keep 269 driver faces. + {"557c2cd9aa58225f79941fd4e6537559239f88f2af2909ac376a7e06857a8961",{645,331,{{269,331}}}}, + // Grunty LOD0; keep 462 driver faces. + {"c980b3ad4ed979a16286d84677dce55788cb134e3b4a17607a11d6cebe28f06b",{945,532,{{0,68},{530,532}}}}, + // Grunty LOD1; keep 115 driver faces. + {"b5ba576dfe37b7ad4d08ebb1ee47ccb6c4f16d239ff7f0927ce7a8697670ae91",{367,164,{{0,47},{162,164}}}}, + // Grunty LOD2; keep 73 driver faces. + {"d2fda3ed6688947b8c9ac4a83ee32b0f7ff72fae54eaeca8ffaec59c0c4f2ff1",{266,114,{{0,41}}}}, + // Grunty LOD3; keep 52 driver faces. + {"cedd8a95f452aee0226325155f7953b7f8c405f64d1efb661db9c0daa6d5ac19",{180,77,{{0,25}}}}, + // Grunty LOD4; keep 38 driver faces. + {"9a00037cd1663b49e310ce40b33ab0a16819f58839d5ed6bc99d60a5e9c4499e",{136,57,{{0,19}}}}, + // YOOKA LOD0; keep 262 driver faces. + {"142f943c65de6de9ce95e6f490d4af202a4541ab3facb12e497c2914e106bdf5",{478,329,{{0,61},{323,329}}}}, + // YOOKA LOD1; keep 192 driver faces. + {"115abb12236b36183d8da47bd740cb316b1aed05b6949ffa9f337a57939cde30",{355,241,{{192,241}}}}, + // YOOKA LOD2; keep 144 driver faces. + {"36805d2544fd87828955b653c04dbd44addcd887a32283d77aa6975345aa8f53",{271,188,{{144,188}}}}, + // YOOKA LOD3; keep 116 driver faces. + {"eabc64550216773d48b67007eb94fb5e91ef98738a0f5512dbf8477c1fa89df9",{196,141,{{116,141}}}}, + // YOOKA LOD4; keep 84 driver faces. + {"f7e9a87f261f6d79d2bd943a5f23ab9949a618725e1ff969462f27d1f32071f8",{135,103,{{84,103}}}}, + // TINY KONG LOD0; keep 374 driver faces. + {"169d52f368d3a8aa1fef5160ba92df64c0cdf65126faf6053a482aa7bbfa8340",{521,445,{{368,433},{439,445}}}}, + // TINY KONG LOD1; keep 154 driver faces. + {"c0cc2c84a369c6bcbcb5c5f182feb1ee9b82e0e40ec551babca78391496271d9",{255,203,{{154,203}}}}, + // TINY KONG LOD2; keep 120 driver faces. + {"f155341509539713c34fbf1b1517eb7c78773c8ec72827a358423a8768981155",{204,164,{{120,164}}}}, + // TINY KONG LOD3; keep 92 driver faces. + {"acc1dca251ba11eb3acf5d34538312ce223a6687feb1462477993fcbe3b94c06",{145,117,{{92,117}}}}, + // TINY KONG LOD4; keep 70 driver faces. + {"e376f3ab68e9f72eccd612fd4f8dd0cd890afebb45f94d7f2ec26c2f860694fe",{116,89,{{70,89}}}}, + // Haunter LOD0; keep 302 driver faces. + {"6a8eb2bb216a528216cc3979120ee50fadc1116e4194e6a7b43c75a1c5b5b9bd",{669,395,{{0,4},{306,395}}}}, + // Haunter LOD1; keep 148 driver faces. + {"07b6f01bb6a036f10bde38acd584e1fa73ee9714face71a8362627b297ba6897",{412,241,{{0,4},{16,105}}}}, + // Haunter LOD4; keep 6 driver faces. + {"7ee68ffe76938d7ee506e4d918061a5977fdb088e626cc41525ca9142cce8d92",{8,6,{}}}, +}; +} diff --git a/runtime-recomp/src/game/vehicle_library.cpp b/runtime-recomp/src/game/vehicle_library.cpp new file mode 100644 index 00000000..791937cb --- /dev/null +++ b/runtime-recomp/src/game/vehicle_library.cpp @@ -0,0 +1,270 @@ +#include "vehicle_library.hpp" +#include "magic_code_package.hpp" +#include "online_mod_bundle.hpp" +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#include +#endif + +namespace dkr::mods::vehicles { +namespace { +constexpr unsigned MaxInstalled=512; +constexpr std::size_t MaxIndex=2*MiB,MaxFrozenSource=64*MiB,MaxFrozenDecoded=128*MiB; +[[noreturn]] void bad(const std::string& s){throw Error("Vehicle library: "+s);} +void cancelled(std::stop_token stop){if(stop.stop_requested())bad("operation cancelled.");} +std::string nonce() { + std::random_device random;Bytes bytes(16);for(auto& b:bytes)b=std::uint8_t(random());return sha256(bytes); +} +struct Lock { +#ifdef _WIN32 + HANDLE handle=INVALID_HANDLE_VALUE; +#else + int handle=-1; +#endif + explicit Lock(const std::filesystem::path& root) { + auto ancestor=root; + while(!std::filesystem::exists(ancestor)) { + const auto parent=ancestor.parent_path();if(parent.empty()||parent==ancestor)bad("storage is unavailable.");ancestor=parent; + } + online::check_storage(ancestor,true);std::filesystem::create_directories(root);online::check_storage(root,true); + const auto path=private_storage_path(root/"library.lock"); +#ifdef _WIN32 + handle=CreateFileW(path.c_str(),GENERIC_READ|GENERIC_WRITE,0,nullptr,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL|FILE_FLAG_OPEN_REPARSE_POINT,nullptr); + if(handle==INVALID_HANDLE_VALUE)bad("the library is busy or not writable."); + BY_HANDLE_FILE_INFORMATION info{}; + if(!GetFileInformationByHandle(handle,&info)||(info.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT))) { + CloseHandle(handle);handle=INVALID_HANDLE_VALUE;bad("unsafe library lock."); + } +#else + handle=open(path.c_str(),O_RDWR|O_CREAT|O_CLOEXEC|O_NOFOLLOW,0600); + if(handle<0)bad("the library is not writable."); + struct stat info{}; + if(fstat(handle,&info)||!S_ISREG(info.st_mode)||flock(handle,LOCK_EX|LOCK_NB)) {close(handle);handle=-1;bad("the library is busy or unsafe.");} +#endif + } + ~Lock(){ +#ifdef _WIN32 + if(handle!=INVALID_HANDLE_VALUE)CloseHandle(handle); +#else + if(handle>=0)close(handle); +#endif + } +}; +void sync_directory(const std::filesystem::path& path) { +#ifndef _WIN32 + const int fd=open(path.c_str(),O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); + if(fd<0)bad("could not open storage for its durability checkpoint."); + const auto result=fsync(fd);const auto failure=errno;close(fd); + if(result&&failure!=EINVAL&&failure!=ENOTSUP)bad("could not confirm storage durability."); +#else + (void)path; +#endif +} +Json index(const std::filesystem::path& root) { + const auto path=root/"library.json"; + if(!std::filesystem::exists(path))return {{"schema",1},{"items",Json::object()}}; + online::check_storage(path,false);auto doc=code::parse_document(read_file(path,MaxIndex),MaxIndex); + if(!doc.is_object()||doc.size()!=2||doc.value("schema",0)!=1||!doc.contains("items")||!doc["items"].is_object()||doc["items"].size()>MaxInstalled)bad("invalid catalogue. Existing packages were not changed."); + for(const auto& [hash,item]:doc["items"].items()) { + if(!valid_digest(hash)||!item.is_object()||item.size()!=7||!item.contains("type")||!item["type"].is_number_unsigned()|| + item["type"].get()>2||!item.contains("enabled")||!item["enabled"].is_boolean())bad("invalid catalogue entry."); + for(const auto* field:{"name","source"}) { + if(!item.contains(field)||!item[field].is_string())bad("invalid display metadata."); + const auto& s=item[field].get_ref(); + if(s.empty()||s.size()>240||std::any_of(s.begin(),s.end(),[](unsigned char c){return c<32||c==127;}))bad("invalid display name."); + } + for(const auto* field:{"width","height"})if(!item.contains(field)||!item[field].is_number_unsigned()||!item[field].get()||item[field].get()>512)bad("invalid portrait dimensions."); + if(!item.contains("portrait")||!item["portrait"].is_string()||!valid_digest(item["portrait"].get()))bad("invalid portrait identity."); + } + return doc; +} +void publish(const std::filesystem::path& root,const Json& doc) { + const auto path=root/"library.json";const auto value=doc.dump();if(value.size()>MaxIndex)bad("the catalogue is too large."); + const auto history=root/"history";std::filesystem::create_directory(history);online::check_storage(history,true); + if(std::filesystem::exists(path)) { + online::check_storage(path,false);const auto old=read_file(path,MaxIndex);const auto backup=history/(sha256(old)+".json"); + if(!std::filesystem::exists(backup))write_new_file(backup,old); + else {online::check_storage(backup,false);if(read_file(backup,MaxIndex)!=old)bad("a catalogue backup failed verification.");} + sync_directory(history); + } + const auto temporary=root/("catalog-"+nonce()+".tmp"); + write_new_file(temporary,View(reinterpret_cast(value.data()),value.size())); + try { +#ifdef _WIN32 + if(!MoveFileExW(private_storage_path(temporary).c_str(),private_storage_path(path).c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))bad("could not commit the catalogue."); +#else + std::filesystem::rename(temporary,path);sync_directory(root); +#endif + }catch(...){std::error_code ec;std::filesystem::remove(temporary,ec);throw;} +} +std::shared_ptr load(const std::filesystem::path& root,const Json& doc,std::string_view hash) { + if(!valid_digest(hash)||!doc["items"].contains(std::string(hash)))bad("this vehicle is not installed."); + const auto& item=doc["items"][std::string(hash)];const auto type=Type(item.at("type").get()); + const auto path=root/"packages"/(std::string(hash)+extension(type));online::check_storage(path,false); + const auto bytes=read_file(path,MaxPackageBytes);if(sha256(bytes)!=hash)bad("an installed package failed verification. Import it again."); + auto p=inspect(bytes,item.at("source").get(),type); + if(p->name!=item.at("name").get())bad("the installed name does not match its package."); + return p; +} +std::string install(const std::filesystem::path& root,Json& doc,const Package& p,bool enable) { + auto& items=doc["items"]; + // Identical inner exports (including a ZIP and its standalone .dkrkart) + // have one identity. Reimport never duplicates or silently enables one. + if(items.contains(p.digest)) { + (void)load(root,doc,p.digest);return p.digest; + } + if(items.size()>=MaxInstalled)bad("the installed vehicle limit has been reached."); + if(enable&&std::count_if(items.begin(),items.end(),[](const auto& i){return i.at("enabled").template get();})>=MaxEnabled)enable=false; + const auto packages=root/"packages",portraits=root/"portraits"; + std::filesystem::create_directory(packages);std::filesystem::create_directory(portraits); + online::check_storage(packages,true);online::check_storage(portraits,true); + const auto source=packages/(p.digest+extension(p.type)),portrait=portraits/(p.digest+".rgba"); + Image preview=p.portrait; + if(preview.width>128||preview.height>128) { + const float factor=std::min(128.F/preview.width,128.F/preview.height); + Image small{std::max(1U,unsigned(preview.width*factor)),std::max(1U,unsigned(preview.height*factor)),{}}; + small.rgba.resize(std::size_t(small.width)*small.height*4); + for(unsigned y=0;y='A'&&c<='Z')c=char(c-'A'+'a'); + return name; +} +std::vector conflicts(const Json& doc,const std::vector>& packages) { + std::vector out; + for(const auto& p:packages) { + if(doc["items"].contains(p->digest))continue; + ImportConflict row{p->digest,p->name,{}}; + for(const auto& [hash,item]:doc["items"].items()) + if(item["type"].get()==unsigned(p->type)&&name_key(item["name"].get())==name_key(p->name)) + row.installed.push_back(hash); + if(!row.installed.empty())out.push_back(std::move(row)); + } + return out; +} +} // namespace +std::vector Library::scan(std::stop_token stop)const { + if(!std::filesystem::exists(root_))return {}; + Lock lock(root_);const auto doc=index(root_);std::vector out; + for(const auto& [hash,e]:doc["items"].items()) { + cancelled(stop);Item item;item.digest=hash;item.id="vehicle:"+hash;item.name=e.at("name").get(); + item.source_name=e.at("source").get();item.enabled=e.at("enabled").get();item.type=Type(e.at("type").get()); + item.portrait.width=e.at("width").get();item.portrait.height=e.at("height").get(); + const auto path=root_/"portraits"/(hash+".rgba");online::check_storage(path,false); + item.portrait.rgba=read_file(path,512*512*4); + if(item.portrait.rgba.size()!=std::size_t(item.portrait.width)*item.portrait.height*4||sha256(item.portrait.rgba)!=e.at("portrait").get())bad("an installed portrait failed verification. Import the vehicle again."); + out.push_back(std::move(item)); + } + std::sort(out.begin(),out.end(),[](const auto& a,const auto& b){return std::pair{a.name,a.digest} Library::import_file(const std::filesystem::path& path) { + return import_prepared(prepare_import(path),ImportPolicy::RejectDuplicates); +} +ImportBatch Library::prepare_import(const std::filesystem::path& path)const { + const auto inputs=read_inputs(path);std::vector> prepared; + // Validate the entire user-selected bundle before changing activation. + std::set> names; + for(const auto& input:inputs) { + auto p=inspect(input.bytes,input.name,input.type); + if(!names.insert({unsigned(p->type),name_key(p->name)}).second) + bad("the bundle contains different cars with the same name. Import them separately."); + prepared.push_back(std::move(p)); + } + Lock lock(root_);const auto doc=index(root_); + auto matches=conflicts(doc,prepared); + return {std::move(prepared),std::move(matches)}; +} +std::vector Library::import_prepared(const ImportBatch& batch,ImportPolicy policy) { + if(batch.packages.empty()||batch.packages.size()>16)bad("invalid prepared import."); + Lock lock(root_);auto doc=index(root_);std::vector hashes; + const auto matches=conflicts(doc,batch.packages); + if(policy==ImportPolicy::RejectDuplicates&&!matches.empty()) + bad("a car with this name is installed. Choose Replace older copies or Keep both."); + if(policy==ImportPolicy::ReplaceSameName&&matches!=batch.conflicts) + bad("the library changed while you were choosing. Import the bundle again."); + std::map replacement_enabled; + if(policy==ImportPolicy::ReplaceSameName)for(const auto& row:matches) { + bool enabled=false; + for(const auto& hash:row.installed) { + enabled|=doc["items"].at(hash).at("enabled").get(); + doc["items"].erase(hash); // Old immutable blobs and catalogue history remain recoverable. + } + replacement_enabled.emplace(row.incoming,enabled); + } + for(const auto& p:batch.packages) { + const auto prior=replacement_enabled.find(p->digest); + hashes.push_back(install(root_,doc,*p,prior==replacement_enabled.end()?true:prior->second)); + } + publish(root_,doc);return hashes; +} +std::string Library::import(Input input,bool enable) { + const auto p=inspect(input.bytes,std::move(input.name),input.type);Lock lock(root_);auto doc=index(root_); + auto hash=install(root_,doc,*p,enable);publish(root_,doc);return hash; +} +void Library::enable(std::string_view hash,bool enabled) { + if(!valid_digest(hash))bad("invalid vehicle identity."); + Lock lock(root_);auto doc=index(root_);auto& items=doc["items"]; + if(!items.contains(std::string(hash)))bad("this vehicle is not installed."); + if(enabled) { + (void)load(root_,doc,hash); + const auto count=std::count_if(items.begin(),items.end(),[](const auto& i){return i.at("enabled").template get();}); + if(!items[std::string(hash)].at("enabled").get()&&count>=MaxEnabled)bad("enable up to 16 cars for one session."); + } + items[std::string(hash)]["enabled"]=enabled;publish(root_,doc); +} +void Library::remove(std::string_view hash) { + if(!valid_digest(hash))bad("invalid vehicle identity."); + Lock lock(root_);auto doc=index(root_); + if(!doc["items"].erase(std::string(hash)))bad("this vehicle is not installed."); + // Publish first. Keep immutable blobs for recovery and an admitted match; + // never delete the user's download or another category's files. + publish(root_,doc); +} +std::shared_ptr Library::package(std::string_view hash)const { + Lock lock(root_);return load(root_,index(root_),hash); +} +Frozen freeze_packages(std::vector> packages) { + if(packages.size()>MaxEnabled)bad("too many enabled cars."); + std::sort(packages.begin(),packages.end(),[](const auto& a,const auto& b){return a->digestdigest;}); + std::string canonical="dkr-vehicles-v2:fixed-reference-seats:cars";std::string previous;std::size_t source=0,decoded=0; + for(const auto& p:packages) { + if(!p||!supported(p->type)||!valid_digest(p->digest)||p->digest==previous)bad("invalid or duplicate frozen vehicle."); + previous=p->digest; + if(p->source.size()>MaxFrozenSource-source)bad("enabled car packages exceed the session source budget.");source+=p->source.size(); + std::size_t size=p->portrait.rgba.size(); + for(const auto& t:p->textures)size+=t.image.rgba.size(); + for(const auto& lod:p->lods)for(const auto& m:lod.meshes)size+=m.vertices.size()*sizeof(Vec3)+m.uv.size()*sizeof(Vec2)+m.faces.size()*sizeof(std::array); + if(size>MaxFrozenDecoded-decoded)bad("enabled cars exceed the session model budget.");decoded+=size; + canonical+=':'+p->digest; + } + return {std::move(packages),sha256(View(reinterpret_cast(canonical.data()),canonical.size()))}; +} +Frozen Library::freeze(std::stop_token stop)const { + if(!std::filesystem::exists(root_))return freeze_packages({}); + Lock lock(root_);const auto doc=index(root_);std::vector> out; + for(const auto& [hash,item]:doc["items"].items()) {cancelled(stop);if(item.at("enabled").get())out.push_back(load(root_,doc,hash));} + return freeze_packages(std::move(out)); +} +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_library.hpp b/runtime-recomp/src/game/vehicle_library.hpp new file mode 100644 index 00000000..d376da31 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_library.hpp @@ -0,0 +1,46 @@ +#pragma once +#include "vehicle_package.hpp" +#include +#include + +namespace dkr::mods::vehicles { +struct Item { + std::string id,digest,name,source_name; + Type type=Type::Car; + bool enabled=false; + Image portrait; +}; +struct Frozen { + std::vector> packages; + std::string identity; +}; +enum class ImportPolicy { RejectDuplicates, KeepBoth, ReplaceSameName }; +struct ImportConflict { + std::string incoming,name; + std::vector installed; + bool operator==(const ImportConflict&)const=default; +}; +struct ImportBatch { + std::vector> packages; + std::vector conflicts; +}; +// Blocking filesystem work; launch preparation and the UI job queue are its +// only callers. Gameplay and replay never open a package or a library file. +class Library { +public: + explicit Library(std::filesystem::path root):root_(std::move(root)){} + std::vector scan(std::stop_token={})const; + std::vector import_file(const std::filesystem::path&); + ImportBatch prepare_import(const std::filesystem::path&)const; + std::vector import_prepared(const ImportBatch&,ImportPolicy); + std::string import(Input, bool enable=true); + void enable(std::string_view digest,bool); + void remove(std::string_view digest); + Frozen freeze(std::stop_token={})const; + std::shared_ptr package(std::string_view digest)const; + const std::filesystem::path& root()const{return root_;} +private: + std::filesystem::path root_; +}; +Frozen freeze_packages(std::vector>); +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_online.hpp b/runtime-recomp/src/game/vehicle_online.hpp new file mode 100644 index 00000000..9ee6352a --- /dev/null +++ b/runtime-recomp/src/game/vehicle_online.hpp @@ -0,0 +1,14 @@ +#pragma once +#include "vehicle_protocol.hpp" +#include "vehicle_library.hpp" + +namespace dkr::mods::online { +inline std::shared_ptr verified_car(const Manifest& m,const Content& c,View bytes) { + const auto payload=std::find_if(m.payloads.begin(),m.payloads.end(),[&](const auto& p){return p.digest==c.payload;}); + if(c.kind!=CarContent||c.adapter!=CarAdapterVersion||payload==m.payloads.end()||payload->kind!=CarPayload||bytes.size()!=payload->size||sha256(bytes)!=c.payload) + throw Error("A custom car does not match its exact host payload."); + auto package=vehicles::inspect(bytes,payload->source_label,vehicles::Type::Car); + if(package->digest!=c.payload||package->name!=c.name)throw Error("Custom car metadata differs from the host offer."); + return package; +} +} diff --git a/runtime-recomp/src/game/vehicle_package.cpp b/runtime-recomp/src/game/vehicle_package.cpp new file mode 100644 index 00000000..313ab515 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_package.cpp @@ -0,0 +1,350 @@ +#include "vehicle_package.hpp" +#include "magic_code_package.hpp" +#include "package_compatibility.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::mods::vehicles { +namespace { +[[noreturn]] void bad(const std::string& why) { throw Error("Custom vehicle: " + why); } +std::string lower(std::string s) { + for(auto& c:s)if(c>='A'&&c<='Z')c=char(c-'A'+'a'); + return s; +} +bool safe_name(std::string_view s) { + if(s.empty()||s.size()>240||s.front()=='/'||s.back()=='/'||s.find('\\')!=s.npos)return false; + for(unsigned char c:s)if(c<32||c==127||c==':'||c=='<'||c=='>'||c=='|'||c=='?'||c=='*'||c=='"')return false; + for(std::size_t at=0;at='0'&&stem[3]<='9'))return false; + if(end==s.npos)break;at=end+1; + } + return true; +} +struct Archive { + std::map files; + explicit Archive(View bytes,unsigned maximum=256) { + if(bytes.empty()||bytes.size()>MaxPackageBytes)bad("archive exceeds the 32 MiB limit."); + mz_zip_archive zip{}; + if(!mz_zip_reader_init_mem(&zip,bytes.data(),bytes.size(),0))bad("the archive is unreadable."); + struct End { mz_zip_archive& z;~End(){mz_zip_reader_end(&z);} } end{zip}; + const auto count=mz_zip_reader_get_num_files(&zip); + if(!count||count>maximum)bad("too many files in the archive."); + std::size_t total=0;std::set names; + for(unsigned i=0;i242)bad("invalid archive filename."); + std::string name(size,'\0'); + if(mz_zip_reader_get_filename(&zip,i,name.data(),size)!=size||name.find('\0')!=size-1)bad("ambiguous archive filename."); + name.pop_back();const bool directory=mz_zip_reader_is_file_a_directory(&zip,i)!=0; + if(directory&&name.back()=='/')name.pop_back(); + const auto mode=(stat.m_external_attr>>16)&0170000U; + if(!safe_name(name)||(mode&&mode!=0100000U&&mode!=0040000U)||(stat.m_bit_flag&1))bad("unsafe or encrypted archive entry."); + if(!names.insert(lower(name)).second)bad("duplicate archive filenames."); + if(directory)continue; + if(stat.m_uncomp_size>MaxExpandedBytes-total||stat.m_uncomp_size>MaxPackageBytes)bad("expanded archive exceeds its limit."); + total+=std::size_t(stat.m_uncomp_size);Bytes data(std::size_t(stat.m_uncomp_size)); + if(!mz_zip_reader_extract_to_mem(&zip,i,data.data(),data.size(),0))bad("an archive entry is incomplete or has an invalid checksum."); + files.emplace(std::move(name),std::move(data)); + } + } + const Bytes& require(const std::string& name)const { + const auto it=files.find(name);if(it==files.end())bad("missing " + name);return it->second; + } +}; +float number(const Json& j,float limit=32767.F) { + if(!j.is_number())bad("a model coordinate is not a number."); + const double d=j.get(); + if(!std::isfinite(d)||std::abs(d)>limit)bad("a model coordinate is outside its safe range."); + return float(d); +} +unsigned integer(const Json& j,unsigned limit) { + if(!j.is_number_integer()||j.get()<0||j.get()>limit)bad("an index is outside its safe range."); + return j.get(); +} +Vec3 vector3(const Json& j,float limit=32767.F) { + if(!j.is_array()||j.size()!=3)bad("expected an XYZ coordinate."); + return {number(j[0],limit),number(j[1],limit),number(j[2],limit)}; +} +std::string string(const Json& j,std::size_t limit) { + if(!j.is_string())bad("invalid text metadata."); + auto s=j.get();if(s.empty()||s.size()>limit)bad("invalid text length."); + for(unsigned char c:s)if(c<32||c==127)bad("control characters are not allowed in names."); + return s; +} +Bytes base64(const std::string& s) { + constexpr std::string_view prefix="data:image/png;base64,"; + if(!s.starts_with(prefix)||s.size()>8*MiB)bad("textures must be embedded PNG images."); + const auto encoded=std::string_view(s).substr(prefix.size()); + if(encoded.empty()||encoded.size()%4)bad("invalid embedded PNG encoding."); + Bytes out;out.reserve(encoded.size()/4*3); + const auto digit=[](char c)->int { + if(c>='A'&&c<='Z')return c-'A';if(c>='a'&&c<='z')return c-'a'+26; + if(c>='0'&&c<='9')return c-'0'+52;if(c=='+')return 62;if(c=='/')return 63;return -1; + }; + for(std::size_t i=0;i>4))); + if(!p2)out.push_back(std::uint8_t((b<<4)|(c>>2))); + if(!p3)out.push_back(std::uint8_t((c<<6)|d)); + } + return out; +} +Image image(View bytes,unsigned maximum,std::size_t& budget) { + static constexpr std::uint8_t signature[]{137,80,78,71,13,10,26,10}; + if(bytes.size()<33||bytes.size()>8*MiB||!std::equal(std::begin(signature),std::end(signature),bytes.begin()))bad("invalid PNG image."); + int w=0,h=0,n=0; + if(!stbi_info_from_memory(bytes.data(),int(bytes.size()),&w,&h,&n)||w<=0||h<=0||unsigned(w)>maximum||unsigned(h)>maximum)bad("PNG dimensions exceed the vehicle image limit."); + const auto size=std::size_t(w)*unsigned(h)*4; + if(size>16*MiB-budget)bad("vehicle textures exceed their decoded memory budget."); + auto* decoded=stbi_load_from_memory(bytes.data(),int(bytes.size()),&w,&h,&n,4); + if(!decoded)bad("could not decode a PNG image."); + Image result{unsigned(w),unsigned(h),Bytes(decoded,decoded+size)}; + stbi_image_free(decoded);budget+=size;return result; +} +Json document(View bytes) {return code::parse_document(bytes,MaxPackageBytes);} +Lod geometry(const Json& j,std::size_t textures,float scale) { + if(j.value("schema",0)!=1||!j.contains("meshes")||!j["meshes"].is_array()||j["meshes"].empty()||j["meshes"].size()>64)bad("unsupported mesh layout."); + Lod out;std::set ids;std::size_t vertices=0; + for(const auto& item:j["meshes"]) { + Mesh m;m.id=string(item.at("id"),96);m.name=string(item.at("name"),160); + if(!ids.insert(m.id).second)bad("duplicate mesh identities."); + const auto& v=item.at("vertices");const auto& uv=item.at("uv");const auto& f=item.at("faces"); + if(!v.is_array()||v.empty()||v.size()>8192-vertices||!uv.is_array()||uv.size()!=v.size()||!f.is_array()||f.empty()||f.size()>4096-out.triangles)bad("invalid mesh size, UV count or triangle count."); + vertices+=v.size();out.triangles+=unsigned(f.size()); + for(const auto& p:v)m.vertices.push_back(vector3(p,32700.F/scale)); + for(const auto& p:uv) { + if(!p.is_array()||p.size()!=2)bad("invalid texture coordinates."); + m.uv.push_back({number(p[0],128.F),number(p[1],128.F)}); + } + for(const auto& t:f) { + if(!t.is_array()||t.size()!=3)bad("only triangular faces are supported."); + std::array face{};for(unsigned i=0;i<3;++i)face[i]=integer(t[i],unsigned(v.size()-1)); + if(face[0]==face[1]||face[1]==face[2]||face[2]==face[0])bad("a triangle repeats a vertex."); + m.faces.push_back(face); + } + m.texture=item.value("texture",-1); + if(m.texture < -1 || (m.texture>=0&&std::size_t(m.texture)>=textures))bad("a mesh references a missing texture."); + if(item.contains("color")) { + const auto& c=item["color"];if(!c.is_array()||c.size()!=4)bad("invalid mesh colour."); + for(unsigned i=0;i<4;++i)m.colour[i]=std::uint8_t(integer(c[i],255)); + } + m.double_sided=item.value("double_sided",false); + if(item.value("primitive",0)!=0)bad("unsupported mesh primitive."); + out.meshes.push_back(std::move(m)); + } + return out; +} +} // namespace +bool valid_digest(std::string_view s) { + return s.size()==64&&std::all_of(s.begin(),s.end(),[](char c){return (c>='0'&&c<='9')||(c>='a'&&c<='f');}); +} +Type type_for_extension(std::string_view value) { + const auto s=lower(std::string(value)); + if(s==".dkrkart")return Type::Car;if(s==".dkrhover")return Type::Hovercraft;if(s==".dkrplane")return Type::Plane; + bad("choose a .dkrkart car package or a ZIP containing one."); +} +const char* extension(Type t){switch(t){case Type::Car:return ".dkrkart";case Type::Hovercraft:return ".dkrhover";case Type::Plane:return ".dkrplane";}bad("invalid vehicle class.");} +const char* type_name(Type t){switch(t){case Type::Car:return "Car";case Type::Hovercraft:return "Hovercraft";case Type::Plane:return "Plane";}bad("invalid vehicle class.");} +bool supported(Type t){return t==Type::Car;} +std::vector read_inputs(const std::filesystem::path& path) { + const auto ext=lower(path.extension().string());auto bytes=read_file(path,MaxPackageBytes); + const auto filename=path.filename().u8string();const std::string name(filename.begin(),filename.end()); + if(ext!=".zip")return {{name,std::move(bytes),type_for_extension(ext)}}; + Archive zip(bytes);std::vector out;std::set digests; + for(const auto& [member,data]:zip.files) { + const auto dot=member.rfind('.');const auto suffix=dot==member.npos?std::string{}:lower(member.substr(dot)); + if(suffix!=".dkrkart"&&suffix!=".dkrhover"&&suffix!=".dkrplane")continue; + if(digests.insert(sha256(data)).second)out.push_back({member.substr(member.find_last_of('/')+1),data,type_for_extension(suffix)}); + if(out.size()>16)bad("import up to 16 vehicle packages at once."); + } + if(out.empty())bad("this ZIP contains no vehicle package. Nested ZIPs are not unpacked."); + return out; +} +static std::shared_ptr inspect_impl(View bytes,std::string source_name,Type type) { + if(!supported(type))bad(std::string(type_name(type))+" packages are reserved for a later update. This playtest supports cars."); + Archive zip(bytes);auto p=std::make_shared();p->type=type; + p->digest=sha256(bytes);p->id="vehicle:"+p->digest;p->source.assign(bytes.begin(),bytes.end());p->source_name=std::move(source_name); + const auto manifest=document(zip.require("kart.json")); + if(manifest.value("format","")!="DKR Character Studio Kart"||manifest.value("version",0)!=1)bad("unsupported Studio kart format."); + for(const auto* field:{"vehicle_type","vehicle_class","type"})if(manifest.contains(field)) { + const auto t=lower(string(manifest[field],32)); + if(t!="car"&&t!="kart"&&t!="dkrkart")bad("the manifest vehicle class conflicts with its .dkrkart extension."); + } + p->name=manifest.contains("name")?string(manifest["name"],160):p->source_name; + const auto slash=p->name.find_last_of("/\\");if(slash!=p->name.npos)p->name.erase(0,slash+1); + if(lower(p->name).ends_with(".dkrkart"))p->name.resize(p->name.size()-8); + if(p->name.empty())bad("the car needs a name."); + const auto& settings=manifest.at("settings"); + p->scale=number(settings.at("scale"),4096.F);if(p->scale<0.001F)bad("invalid model scale."); + p->yaw=number(settings.at("yaw"),3600.F);p->driver_yaw=number(settings.value("driver_yaw",Json(0)),3600.F); + p->driver_offset=vector3(settings.value("driver",Json::array({0,0,0})),1024.F); + constexpr const char* drivers[]{"KRUNCH","BUMPER","TIPTUP","CONKER","TIMBER","BANJO","DRUMSTICK","PIPSY","TT","DIDDY"}; + const bool authored=manifest.contains("driver_position_mode")||manifest.contains("driver_seat_positions")|| + settings.contains("driver_seat_positions")||manifest.value("driver_anchor_kind","")=="kart-fixed-base-reference-v1"; + if(authored) { + if(manifest.value("driver_position_mode","")!="per-base-driver"|| + manifest.value("driver_anchor_kind","")!="kart-fixed-base-reference-v1") + bad("this per-driver seating convention is not supported."); + p->seating=SeatingMode::PerBaseDriverV1; + const auto& positions=manifest.at("driver_seat_positions"); + const auto& anchors=manifest.at("driver_anchors"); + if(!positions.is_object()||positions.size()!=10||!anchors.is_object()||anchors.size()!=10) + bad("per-driver seating needs positions and anchors for all ten base drivers."); + for(unsigned i=0;i<10;++i) { + if(!positions.contains(drivers[i])||!anchors.contains(drivers[i])) + bad(std::string("missing seat or anchor for ")+drivers[i]+"."); + p->driver_seats[i]=vector3(positions.at(drivers[i]),1024.F); + p->driver_anchors[i]=vector3(anchors.at(drivers[i]),1024.F); + } + if(settings.contains("driver_seat_positions")&&settings["driver_seat_positions"]!=positions) + bad("the two driver seat tables disagree. Export the car again."); + if(settings.contains("seat")||p->driver_offset.x!=0||p->driver_offset.y!=0||p->driver_offset.z!=0) + bad("per-driver seats cannot be combined with a legacy shared seat or driver offset."); + const auto& transform=manifest.at("kart_transform"); + if(!transform.is_object()||transform.value("order","")!="subtract origin, scale, rotate around +Y, translate"|| + transform.value("units","")!="Y-up Studio units; forward -Z; yaw in degrees"|| + !transform.contains("driver_positions_inherit_transform")|| + !transform["driver_positions_inherit_transform"].is_boolean()|| + transform["driver_positions_inherit_transform"].get()) + bad("unsupported kart transform. Driver seats must stay independent of the kart transform."); + p->origin=vector3(transform.at("origin"),32700.F/p->scale); + p->kart_offset=vector3(transform.at("translation"),1024.F); + if(transform.at("scale")!=settings.at("scale")||transform.at("yaw_degrees")!=settings.at("yaw")|| + manifest.at("geometry_origin")!=transform.at("origin")||settings.at("kart_offset")!=transform.at("translation")) + bad("the kart transform settings disagree. Export the car again."); + p->authored_anchors=true; + }else { + if(manifest.contains("kart_transform")||settings.contains("kart_offset")) + bad("the kart transform needs an explicit per-driver seating convention."); + p->seat=vector3(settings.at("seat"),1024.F); + } + if(manifest.value("forward_axis","")!="-Z")bad("the exporter must use the agreed Y-up, forward -Z convention."); + const auto& policy=manifest.at("vanilla_vehicle_policy"); + if(policy.value("when_custom_kart_active","")!="hide_geometry_and_wheel_sprites"||policy.value("without_custom_kart","")!="use_existing_character_vehicle")bad("unsupported native vehicle visibility policy."); + const std::set required_hidden{"chassis","steering_wheel","rear_spoiler","wheel_sprites","vehicle_attachments"}; + const std::set required_kept{"driver_geometry","driver_animations","driver_voice","physics","collision"}; + const auto hidden=policy.at("hidden_native_parts").get>(),kept=policy.at("preserve").get>(); + if(hidden!=required_hidden||kept!=required_kept)bad("the package requests an unsupported driver/vehicle policy."); + // Adapter defaults for the older neutral-base export. The newer manifest + // can override all ten anchors, never just a partial driver subset. + // Native enum order is NOT the workshop's display order. + if(!authored)p->driver_anchors={Vec3{0,24,0},Vec3{0,24,-9.5F},Vec3{0,9.5F,-4.75F},Vec3{0,9.5F,-4.75F},Vec3{0,14.5F,-4.75F}, + Vec3{0,4.75F,4.75F},Vec3{0,14.5F,-4.75F},Vec3{0,4.75F,-4.75F},Vec3{0,20,0},Vec3{0,4.75F,-19.25F}}; + if(!authored&&manifest.contains("driver_anchors")) { + if(manifest.value("driver_anchor_kind","")!="calibrated-base-pelvis-v1"||!manifest["driver_anchors"].is_object()||manifest["driver_anchors"].size()!=10)bad("unsupported driver anchor table."); + for(unsigned i=0;i<10;++i)p->driver_anchors[i]=vector3(manifest["driver_anchors"].at(drivers[i]),1024.F); + p->authored_anchors=true; + } + constexpr const char* roles[]{"front_left","front_right","rear_left","rear_right"}; + std::set wheel_ids; + const auto& wheels=settings.at("wheels"); + if(!wheels.is_object()||wheels.size()!=4)bad("wheel assignments need the four named wheel roles; unused roles may be empty."); + for(unsigned i=0;i<4;++i) { + const auto& value=wheels.at(roles[i]); + if(authored&&value.is_string()&&value.get_ref().empty())continue; + p->wheels[i]=string(value,96); + if(!wheel_ids.insert(p->wheels[i]).second)bad("a wheel mesh is assigned more than once."); + } + const auto axle=string(settings.at("axle"),1);if(axle!="X"&&axle!="Y"&&axle!="Z")bad("unsupported wheel axle.");p->axle=unsigned(axle=="X"?0:axle=="Y"?1:2); + const auto original=document(zip.require("geometry.json")); + if(authored&&original.at("origin")!=manifest.at("kart_transform").at("origin")) + bad("the geometry origin does not match the kart transform."); + const auto profile=manifest.value("texture_profile",std::string{}); + const bool original_resolution=profile=="original-resolution-v1"; + if(!profile.empty()&&profile!="native-4kib-rgba32-safe-v1"&&!original_resolution)bad("unsupported car texture profile."); + original_textures::seating_contract(manifest,*p); + original_textures::effect_contract(manifest); + const auto& images=original.at("textures");if(!images.is_array()||images.size()>32)bad("too many car textures."); + std::size_t image_budget=0; + for(const auto& t:images) { + Texture texture;texture.image=image(base64(string(t.at("image"),8*MiB)),256,image_budget); + if(integer(t.at("width"),256)!=texture.image.width||integer(t.at("height"),256)!=texture.image.height)bad("texture metadata does not match its PNG."); + const auto& wrap=t.at("wrap"); + for(const auto* axis:{"wrap-s","wrap-t"}) {const auto mode=wrap.value(axis,"Wrap");if(mode!="Wrap"&&mode!="Clamp")bad("unsupported texture wrapping mode.");} + texture.repeat_s=wrap.value("wrap-s","Wrap")=="Wrap";texture.repeat_t=wrap.value("wrap-t","Wrap")=="Wrap"; + const auto texels=std::size_t(texture.image.width)*texture.image.height; + if(!original_resolution&&texels*2>4096)bad("a car texture exceeds the native 4 KiB material limit. Export smaller material textures."); + if(profile=="native-4kib-rgba32-safe-v1"&&texels*4>4096)bad("a car texture exceeds its declared RGBA32-safe 4 KiB profile."); + if((texture.repeat_s&&!std::has_single_bit(texture.image.width))|| + (texture.repeat_t&&!std::has_single_bit(texture.image.height)))bad("repeating car textures need power-of-two dimensions."); + if(t.value("flipped",false))for(unsigned y=0;ytextures.push_back(std::move(texture)); + } + const auto full=geometry(original,p->textures.size(),p->scale); + const auto& lods=manifest.at("lods");if(!lods.is_array()||lods.size()!=6)bad("all six car LODs are required."); + for(unsigned lod=0;lod<6;++lod) { + const auto file="lods/lod"+std::to_string(lod)+".json"; + if(integer(lods[lod].at("level"),5)!=lod||lods[lod].at("file").get()!=file)bad("invalid LOD file mapping."); + const auto doc=document(zip.require(file)); + p->lods[lod]=geometry(doc,p->textures.size(),p->scale); + if(integer(lods[lod].at("triangles"),4096)!=p->lods[lod].triangles)bad("LOD triangle metadata does not match its geometry."); + for(const auto& id:p->wheels)if(!id.empty()&&std::none_of(p->lods[lod].meshes.begin(),p->lods[lod].meshes.end(),[&](const auto& m){return m.id==id;}))bad("a wheel assignment is missing from an LOD."); + if(authored&&doc.contains("origin")&&doc["origin"]!=original.at("origin"))bad("an LOD has a different geometry origin."); + // Material indices have a single owner: the root geometry. Exported + // LODs may repeat them but must not substitute a different image. + if(doc.contains("textures")&&doc["textures"]!=images)bad("LOD textures differ from the root material table."); + } + const auto& pivots=settings.value("wheel_pivots",Json::object()); + for(const auto& lod:p->lods)for(const auto& mesh:lod.meshes)if(mesh.texture>=0) { + const auto& texture=p->textures.at(unsigned(mesh.texture)); + for(const auto& uv:mesh.uv)if(std::abs(uv.x)*texture.image.width*32.F>32700.F||std::abs(uv.y)*texture.image.height*32.F>32700.F) + bad("car UV coordinates exceed the native material range. Reduce the UV repeat count."); + } + if(!pivots.is_object()||pivots.size()>4)bad("invalid wheel pivot table."); + for(const auto& [key,value]:pivots.items()) { + if(wheel_ids.contains(key))continue; + const auto role=std::find(std::begin(roles),std::end(roles),key); + if(role==std::end(roles)||p->wheels[unsigned(role-std::begin(roles))].empty())bad("a wheel pivot has no assigned wheel."); + } + for(unsigned w=0;w<4;++w) { + if(p->wheels[w].empty())continue; + if(pivots.contains(p->wheels[w]))p->pivots[w]=vector3(pivots[p->wheels[w]],32700.F/p->scale); + else if(pivots.contains(roles[w]))p->pivots[w]=vector3(pivots[roles[w]],32700.F/p->scale); + else { + const auto it=std::find_if(p->lods[0].meshes.begin(),p->lods[0].meshes.end(),[&](const auto& m){return m.id==p->wheels[w];}); + auto lo=it->vertices.front(),hi=lo; + for(const auto& v:it->vertices){lo.x=std::min(lo.x,v.x);lo.y=std::min(lo.y,v.y);lo.z=std::min(lo.z,v.z);hi.x=std::max(hi.x,v.x);hi.y=std::max(hi.y,v.y);hi.z=std::max(hi.z,v.z);} + p->pivots[w]={(lo.x+hi.x)*.5F,(lo.y+hi.y)*.5F,(lo.z+hi.z)*.5F}; + } + } + const auto portrait=manifest.at("portrait").at("file").get(); + if(portrait!="portrait.png")bad("unsupported portrait path."); + p->portrait=image(zip.require(portrait),512,image_budget); + if(original_resolution)original_textures::prepare(*p); + return p; +} +std::shared_ptr inspect(View bytes,std::string source_name,Type type) { + try {return inspect_impl(bytes,std::move(source_name),type);} + catch(const Json::exception&) { + bad("the package has a missing or invalid model setting. Export it again with the current Studio version."); + } +} +Bytes native_texture(const Image& image) { + if(!image.width||!image.height||image.width>255||image.height>255||image.rgba.size()!=std::size_t(image.width)*image.height*4)bad("image cannot be represented by a native texture."); + Bytes out(32+image.width*image.height*2,0);out[0]=std::uint8_t(image.width);out[1]=std::uint8_t(image.height);out[2]=1; + out[0x12]=1; // one frame, matching native texture frame-count convention + const auto size=unsigned((out.size()+7)&~std::size_t(7));out.resize(size,0);out[0x16]=std::uint8_t(size>>8);out[0x17]=std::uint8_t(size); + for(std::size_t i=0;i>3)<<11)|(unsigned(c[1]>>3)<<6)|(unsigned(c[2]>>3)<<1)|(c[3]>=128); + out[32+i*2]=std::uint8_t(n>>8);out[33+i*2]=std::uint8_t(n); + } + return out; +} +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_package.hpp b/runtime-recomp/src/game/vehicle_package.hpp new file mode 100644 index 00000000..91e772e7 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_package.hpp @@ -0,0 +1,61 @@ +#pragma once +#include "legacy_mod_format.hpp" +#include "vehicle_protocol.hpp" +#include +#include +#include + +namespace dkr::mods::vehicles { +using Json = nlohmann::json; +enum class Type : unsigned { Car, Hovercraft, Plane }; +inline constexpr unsigned AdapterVersion = online::CarAdapterVersion; +enum class SeatingMode { LegacyShared, PerBaseDriverV1 }; +inline constexpr std::size_t MaxPackageBytes = 32 * MiB, MaxExpandedBytes = 64 * MiB; +inline constexpr unsigned MaxEnabled = 16; +struct Vec2 { float x=0,y=0; }; +struct Vec3 { float x=0,y=0,z=0; }; +struct Image { unsigned width=0,height=0; Bytes rgba; }; +struct Mesh { + std::string id,name; + std::vector vertices; + std::vector uv; + std::vector> faces; + std::array colour{255,255,255,255}; + int texture=-1; + bool double_sided=false; +}; +struct Texture { Image image; bool repeat_s=true,repeat_t=true; }; +struct Lod { std::vector meshes; unsigned triangles=0; }; +struct Package { + Type type=Type::Car; + std::string id,name,digest,source_name; + float scale=1,yaw=0,driver_yaw=0; + Vec3 seat{},driver_offset{}; + SeatingMode seating=SeatingMode::LegacyShared; + std::array driver_seats{}; + Vec3 origin{},kart_offset{}; + // Package anchors are in post-scale, Y-up Studio units. Older exports + // use the reviewed adapter table; they are never inferred from bounds. + std::array driver_anchors{}; + bool authored_anchors=false; + bool per_driver()const{return seating==SeatingMode::PerBaseDriverV1;} + std::array wheels{}; // FL, FR, RL, RR + std::array pivots{}; + unsigned axle=0; + std::array lods; + std::vector textures; + Image portrait; + Bytes source; // Exact inner archive, never the containing download ZIP. +}; +struct Input { std::string name; Bytes bytes; Type type=Type::Car; }; +Type type_for_extension(std::string_view); +const char* extension(Type); +const char* type_name(Type); +bool supported(Type); +bool valid_digest(std::string_view); +// Pure preparation: no code, files, ROM or guest memory is modified. +std::vector read_inputs(const std::filesystem::path&); +std::shared_ptr inspect(View, std::string source_name, Type); +// Native RGBA16 artwork; used by the existing guest menu texture loader. +Bytes native_texture(const Image&); +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_protocol.hpp b/runtime-recomp/src/game/vehicle_protocol.hpp new file mode 100644 index 00000000..b522c15c --- /dev/null +++ b/runtime-recomp/src/game/vehicle_protocol.hpp @@ -0,0 +1,15 @@ +#pragma once +#include "online_mod_manifest.hpp" +#include + +namespace dkr::mods::online { +// Extension values preserve the accepted manifest/ABI layouts. Old builds +// reject the new explicit "car" tags, rather than misreading a car as code, +// a ROM patch or a course. No machine-local path is sent to another player. +inline constexpr Kind CarContent=static_cast(3); +inline constexpr PayloadKind CarPayload=static_cast(3); +inline constexpr unsigned CarAdapterVersion=2; +inline std::size_t car_count(const Manifest& m) { + return std::count_if(m.content.begin(),m.content.end(),[](const auto& c){return c.kind==CarContent;}); +} +} diff --git a/runtime-recomp/src/game/vehicle_resources.cpp b/runtime-recomp/src/game/vehicle_resources.cpp new file mode 100644 index 00000000..b8c023c6 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_resources.cpp @@ -0,0 +1,280 @@ +#include "vehicle_resources.hpp" +#include "vehicle_seat_alignment.hpp" +#include "vehicle_base_hashes.hpp" +#include "vehicle_character_masks.hpp" +#include +#include +#include +#include +#include + +namespace dkr::mods::vehicles { +namespace { +// Reviewed batch ownership in the VERIFIED US asset bank. No material-name +// heuristics are applied to user models. Recognize complete original geometry +// independently of its UVs, or use a separately reviewed digest-bound mask for +// rebuilt exports. Unknown ownership retains Vanilla, never guessed driver cuts. +// Mixed driver/car batches below are refined per triangle, especially T.T.'s +// gloves and Tiptup/Bumper's shared underside material. +const std::map> VehicleBatches{ + {43,{19,20,21,22,23,24,25,26,28}},{44,{9,21,22,23,24,25,26,27,29}}, + {45,{5,16,17,18,19,20,21,22}},{46,{0,11,12,13,14,15}},{47,{3,11,12}}, + {31,{11,12,24,25,26,27,28,29,30,31}},{32,{1,18,19,20,21,22,23,24,25}}, + {33,{1,18,19,20,21,22,23,24}},{34,{1,16,17,18,19,20}},{35,{1,4,10}}, + {37,{17,18,19,20,21,22,23,24,26,27}},{38,{14,15,16,17,18,19,20,22,23}}, + {39,{7,13,14,15,16,17,18,20}},{40,{0,6,11,12,13,14}},{41,{0,6,10}}, + {55,{14,15,19,20,21,22,23,24,25,26}},{56,{1,17,18,19,20,21,22,23,24}}, + {57,{1,16,17,18,19,20,21,22}},{58,{1,15,16,17,18,19}},{59,{0,13,14}}, + {49,{22,23,28,29,30,31,32,33,34,35}},{50,{11,27,28,29,30,31,32,33,34}}, + {51,{4,17,21,22,23,25,26,27}},{52,{4,17,18,20,21,22}},{53,{2,14,15}}, + {61,{22,23,24,25,26,27,28,29,30,31}},{62,{14,21,22,23,24,25,26,27,28}}, + {63,{16,18,19,20,21,22,23,24}},{64,{5,14,15,16,17,18}},{65,{0,2,12}}, + {67,{4,5,29,30,31,32,33,34,35,36}},{68,{2,23,24,25,26,27,28,29,30}}, + {69,{0,10,17,18,19,20,21,22}},{70,{0,13,14,15,16,17}},{71,{0,12,13}}, + {73,{0,1,16,17,18,19,20,21,22,23,24}},{75,{0,1,9,16,17,18,19,20,21}}, + {76,{3,10,11,12,13,14}},{77,{3,10,11}}, + {25,{0,1,17,18,19,20,21,22,23,24}},{26,{0,14,15,16,17,18,19,20,21,22,23}}, + {27,{0,13,14,15,16,17,18,19,20,21}},{28,{0,1,10,11,12,13,14,15,16}},{29,{0,8,9}}, + {19,{18,19,20,21,22,23,24,25,28,29}},{20,{17,18,19,20,21,22,23,24,27}}, + {21,{15,16,17,18,19,20,21,24}},{22,{13,14,15,16,17,18}},{23,{10,11,12}} +}; +[[noreturn]] void bad(const std::string& s){throw Error("Custom vehicle resources: "+s);} +struct Model { + Bytes raw; + unsigned vertices=0,triangles=0,batches=0; + std::vector faces; + std::vector geometry; + std::vector owners; +}; +Model model(const AssetBank& bank,unsigned id) { + Model out;out.raw=inflate_asset(bank.record(29,id),4*MiB);const View raw=out.raw;slice(raw,0,0x80); + out.vertices=be16(raw,0x24);out.triangles=be16(raw,0x26);out.batches=be16(raw,0x28); + const unsigned nt=be16(raw,0x22),vp=be32(raw,4),tp=be32(raw,8),bp=be32(raw,0x38),textures=be32(raw,0); + if(!out.vertices||out.vertices>8192||out.triangles>8192||out.batches>1024||nt>255)bad("driver model exceeds its profile limits."); + slice(raw,vp,out.vertices*10);slice(raw,tp,out.triangles*16);slice(raw,bp,(out.batches+1)*12);slice(raw,textures,nt*8); + out.faces.resize(out.triangles);out.geometry.resize(out.triangles);out.owners.resize(out.triangles,UINT32_MAX); + for(unsigned b=0;bve||ve>out.vertices||ts>te||te>out.triangles||(texture!=255&&texture>=nt))bad("driver model has an invalid batch."); + for(unsigned t=ts;t,3> positions{}; + for(unsigned corner=0;corner<3;++corner) { + const auto index=raw[at+1+corner];if(index>=ve-vs)bad("driver model indexes outside its batch."); + const auto vertex=slice(raw,vp+(vs+index)*10,6),uv=slice(raw,at+4+corner*4,4); + signature.insert(signature.end(),vertex.begin(),vertex.end());signature.insert(signature.end(),uv.begin(),uv.end()); + std::copy(vertex.begin(),vertex.end(),positions[corner].begin()); + } + out.faces[t]=sha256(signature); + std::sort(positions.begin(),positions.end());Bytes geometry; + for(const auto& p:positions)geometry.insert(geometry.end(),p.begin(),p.end()); + out.geometry[t]=sha256(geometry); + } + } + if(std::ranges::any_of(out.owners,[](unsigned b){return b==UINT32_MAX;}))bad("driver model has unowned triangles."); + return out; +} +std::string ownership_digest(const Model& m) { + // Asset namespace allocation changes ONLY the numeric texture IDs in the + // native model. Exclude those relocations, not geometry, UVs, animation map, + // material layout, counts, batch ownership, flags or other model bytes. + auto normalized=m.raw;const auto tx=be32(normalized,0);const auto count=be16(normalized,0x22); + for(unsigned i=0;i mask(const Model& m,unsigned id) { + const auto found=VehicleBatches.find(id);if(found==VehicleBatches.end())bad("unreviewed base car model."); + std::vector result(m.triangles); + for(const auto batch:found->second)if(batch>=m.batches)bad("base car batch profile changed."); + for(unsigned i=0;isecond,m.owners[i])!=found->second.end(); + const auto preserve=[&](unsigned first,unsigned end){if(end>result.size())bad("base car triangle profile changed.");std::fill(result.begin()+first,result.begin()+end,0);}; + if(id==26){preserve(151,157);preserve(159,165);} + if(id==27){preserve(111,116);preserve(118,123);} + if(id==28)preserve(93,99); + if(id==40)preserve(15,21); + // Bumper's low-detail cap shares the same underside batch as the spoiler. + if(id==35)preserve(11,13); + // Tiptup's low-detail shell keeps the front portion of this mixed batch. + if(id==41)preserve(18,22); + if(std::count(result.begin(),result.end(),std::uint8_t(1))<8)bad("base car profile contains too few vehicle faces."); + return result; +} +std::vector models(const AssetBank& bank,unsigned header) { + const auto h=bank.record(34,header);slice(h,0,0x78); + const unsigned n=h[0x55],at=be32(h,0x10); + if(h[0x53]||!n||n>32)bad("a custom character has no compatible car LOD list."); + slice(h,at,n*4);std::vector ids; + for(unsigned i=0;i Resources::apply(std::shared_ptr bank)const {return AssetBank::append_artwork(std::move(bank),artwork);} +std::shared_ptr prepare_resources(std::shared_ptr original,std::shared_ptr boot, + std::shared_ptr characters,Frozen frozen) { + if(!original||!boot||original->base_fingerprint()!=boot->base_fingerprint())bad("car resources require the admitted original Game Pak."); + auto out=std::make_shared();out->library=std::move(frozen);if(out->library.packages.empty())return out; + constexpr unsigned headers[]{2,3,4,5,6,7,8,9,1,0}; + constexpr const char* names[]{"Krunch","Bumper","Tiptup","Conker","Timber","Banjo","Drumstick","Pipsy","T.T.","Diddy"}; + std::array,10> originals; + std::array,5>,10> masks; + for(unsigned base=0;base<10;++base) { + const auto list=models(*original,headers[base]); + if(list.size()<5)bad("the selected native driver has an unreviewed LOD layout."); + DriverProfile p;p.base=base;p.header=headers[base];p.name=names[base];p.native_scale=std::bit_cast(be32(original->record(34,p.header),0x0c)); + if(!std::isfinite(p.native_scale)||p.native_scale<=0||p.native_scale>16)bad("invalid base car scale."); + for(unsigned lod=0;lod<5;++lod) { + auto& m=originals[base][lod];m=model(*original,list[lod]); + const auto known=BaseModelHashes.find(list[lod]); + if(known==BaseModelHashes.end()||sha256(m.raw)!=known->second) + bad("the selected native driver model variant has not been reviewed for safe car replacement."); + masks[base][lod]=mask(m,list[lod]); + p.lods[lod]={list[lod],m.vertices,m.triangles,masks[base][lod],sha256(m.raw)}; + } + out->drivers.push_back(std::move(p)); + } + if(characters)for(const auto& character:characters->characters) { + DriverProfile p;p.base=character.base_character;p.header=character.headers[0];p.character_id=character.id;p.name=character.name; + if(p.base>=10)bad("invalid inherited driver."); + try { + p.native_scale=std::bit_cast(be32(boot->record(34,p.header),0x0c)); + if(!std::isfinite(p.native_scale)||p.native_scale<=0||p.native_scale>16)bad("invalid custom driver scale."); + const auto list=models(*boot,p.header); + for(unsigned lod=0;lod<5;++lod) { + const auto model_id=list[std::min(lod,list.size()-1)]; + const auto candidate=model(*boot,model_id); + std::map> available; + for(unsigned i=0;i removed(candidate.triangles,0); + bool matched=false; + // Individually reviewed re-exported car geometry: masks are + // immutable, relocatable and exact to this complete model. + if(const auto reviewed=ReviewedCharacterMasks.find(ownership_digest(candidate));reviewed!=ReviewedCharacterMasks.end()) { + const auto& r=reviewed->second; + if(r.vertices!=candidate.vertices||r.triangles!=candidate.triangles)bad("reviewed character model counts changed."); + for(const auto& [first,end]:r.ranges) { + if(first>=end||end>removed.size())bad("reviewed character mask exceeds its model."); + for(unsigned i=first;i required; + for(unsigned i=0;isecond.size()!=count){exact=false;break;} + } + if(!exact)continue; + for(const auto& [signature,count]:required)for(const auto index:available.at(signature))removed[index]=1; + matched=true; + } + // Retextured/reordered stock geometry is still the same car. + // Some exports use another driver's chassis with inherited + // behaviour. Require a COMPLETE unambiguous donor, never a + // percentage threshold or blanket static-vertex suppression. + if(!matched) { + std::map> geometry; + for(unsigned i=0;i required;std::set driver; + for(unsigned i=0;isecond.size()!=count){exact=false;break;} + } + if(!exact)continue; + for(const auto& [signature,count]:required)for(const auto i:geometry.at(signature))removed[i]=1; + matched=true; + } + } + if(!matched)throw Error("Car/driver ownership is not yet recognized at LOD "+std::to_string(lod)+". Original vehicle retained."); + p.lods[lod]={model_id,candidate.vertices,candidate.triangles,std::move(removed),sha256(candidate.raw)}; + } + }catch(const std::exception& e){p.issue=e.what();} + std::fprintf(stderr,"[custom-vehicles][character] name=%s id=%s header=%u base=%u status=%s%s%s\n", + p.name.c_str(),p.character_id.c_str(),p.header,p.base,p.compatible()?"ready":"vanilla-only", + p.compatible()?"":" reason=",p.issue.c_str()); + out->drivers.push_back(std::move(p)); + } + unsigned next=unsigned(boot->record_count(4)); + for(const auto& p:out->library.packages) { + if(next>32765)bad("the portrait namespace is full."); + std::size_t bytes=0; + float radius=0,pivot_radius=0; + for(const auto& lod:p->lods)for(const auto& mesh:lod.meshes)for(const auto& source:mesh.vertices) { + const auto v=dkr::runtime::vehicles::seating::source_point(*p,source); + radius=std::max(radius,std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z)); + } + for(unsigned w=0;w<4;++w)if(!p->wheels[w].empty()) { + const auto v=dkr::runtime::vehicles::seating::source_point(*p,p->pivots[w]); + pivot_radius=std::max(pivot_radius,std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z)); + } + const auto units=std::min(64.F,30000.F/std::max(1.F,(radius+2*pivot_radius)*p->scale)); + if(!std::isfinite(units)||units<=0)bad("car vertex precision cannot be represented safely."); + out->vertex_units.push_back(units); + std::vector pixels; + for(const auto& t:p->textures) { + const auto size=std::size_t(t.image.width)*t.image.height*2; + if(t.image.width>128||t.image.height>128||size>4096)bad("car textures must fit a native 4 KiB material tile (for example 32x32 or 64x32)."); + // RGBA32 stores RG in the lower 2 KiB bank and BA in the upper + // bank. Account for each padded row, not just the texel count. + // All supplied native-4kib-rgba32-safe exports fit unchanged. + // Keep the old RGBA16 path for larger already-supported tiles; + // never resize images, expand TMEM limits or change menu artwork. + const auto bank_row=(std::size_t(t.image.width)*2+7)&~std::size_t(7); + const bool rgba32=bank_row*t.image.height<=2048; + if(rgba32)pixels.push_back({t.image.rgba,true}); + else { + auto encoded=native_texture(t.image); + pixels.push_back({Bytes(encoded.begin()+32,encoded.begin()+32+size),false}); + } + bytes+=pixels.back().bytes.size(); + } + if(bytes>256*1024)bad("a car exceeds its per-racer texture budget."); + out->texture_pixels.push_back(std::move(pixels)); + const auto portrait=thumbnail(p->portrait);out->portraits.push_back(next); + for(unsigned half=0;half<2;++half) + out->artwork.emplace(AssetKey{4,next++},native_texture(portrait_tile(portrait,half))); + } + const auto ready=std::count_if(out->drivers.begin(),out->drivers.end(),[](const auto& d){return d.compatible();}); + std::fprintf(stderr,"[custom-vehicles][compatibility] reviewed-character-ownership=v1 ready=%zu total=%zu; all-five-lods; no material-name cuts\n",std::size_t(ready),out->drivers.size()); + std::string identity="dkr-car-resources-v5-source-colour-fixed-seats:"+boot->fingerprint()+":"+out->library.identity; + for(const auto& d:out->drivers) { + identity+=':'+std::to_string(d.header)+":"+d.character_id+":"+d.issue+":"+std::to_string(std::bit_cast(d.native_scale)); + if(d.compatible())for(const auto& lod:d.lods)identity+=':'+lod.identity+":"+sha256(lod.hide); + } + for(const auto& [key,value]:out->artwork)identity+=':'+std::to_string(key.second)+":"+sha256(value); + out->identity=sha256(View(reinterpret_cast(identity.data()),identity.size()));return out; +} +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_resources.hpp b/runtime-recomp/src/game/vehicle_resources.hpp new file mode 100644 index 00000000..62ae85b6 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_resources.hpp @@ -0,0 +1,38 @@ +#pragma once +#include "vehicle_library.hpp" +#include "legacy_character_materialize.hpp" + +namespace dkr::mods::vehicles { +struct ModelProfile { + unsigned model=0,vertices=0,triangles=0; + std::vector hide; + std::string identity; +}; +struct DriverProfile { + unsigned header=0,base=0; + float native_scale=1; + std::string character_id,name,issue; + std::array lods; + bool compatible()const{return issue.empty();} +}; +struct MaterialPixels { + Bytes bytes; + bool rgba32=false; // Exact PNG channels when the two TMEM banks fit. +}; +struct Resources { + Frozen library; + std::vector drivers; + std::vector portraits; // first ID of two consecutive 64x32 menu tiles + AssetBank::Overrides artwork; + std::vector> texture_pixels; // Prepared once; menu artwork remains RGBA16. + std::vector vertex_units; // fixed car precision, independent of driver scale + std::string identity; + const DriverProfile* driver(unsigned header)const; + const DriverProfile* character(std::string_view id,unsigned base)const; + std::shared_ptr apply(std::shared_ptr)const; +}; +// Preparation is linear in driver models + car packages, not a cartesian +// character x car asset library. The driver animation files remain unchanged. +std::shared_ptr prepare_resources(std::shared_ptr original, + std::shared_ptr boot,std::shared_ptr,Frozen); +} // namespace dkr::mods::vehicles diff --git a/runtime-recomp/src/game/vehicle_rt64.cpp b/runtime-recomp/src/game/vehicle_rt64.cpp new file mode 100644 index 00000000..c5acfeaf --- /dev/null +++ b/runtime-recomp/src/game/vehicle_rt64.cpp @@ -0,0 +1,339 @@ +#include "driver_anchor_units.hpp" +#include "vehicle_rt64.hpp" +#include "detail_contract.hpp" +#include "vehicle_wheel_interpolation.hpp" +#include "vehicle_seat_alignment.hpp" +#include "native_draw_scope_rt64.hpp" +#include "presentation_identity.hpp" +#include "runtime_enhancements.hpp" +#include "gbi/rt64_gbi_rdp.h" +#include "hle/rt64_workload_queue.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::vehicles { +namespace { +constexpr unsigned Scratch=0x00c00000U; +thread_local const RenderPass* current_pass=nullptr; +// Version-1 Studio exports contain native-sized model coordinates after the +// authored package scale, not world coordinates. Use one quarter-scale native +// reference (the clean Tiptup model's 0.25 scale), never the selected driver's +// individual 0.21..0.28 scale. Keeping this fixed lets any driver use the same +// car without making the car itself larger/smaller on character changes. +constexpr float KartModelToWorld=seating::StudioToWorld; +// Studio's ground/reference frame sits 2.5 local world units below the +// native racer draw root. This common frame conversion follows suspension; +// it is NOT kart_transform.translation and never moves the guest/ground. +// Apply it to the car and driver once, outside their authored transforms. +constexpr float KartRideHeight=-2.5F; +// Legacy shared-seat exports only. Fixed-reference seats never inherit it. +constexpr float DriverSeatAdvance=20.0F; +std::atomic diagnostic_count{0}; +unsigned read(const RT64::State& s,unsigned p,unsigned n=4) { + if(p<0x80000000U||p>0x807fffffU||n>0x80800000U-p)throw mods::Error("Custom car draw has an invalid snapshot address."); + p-=0x80000000U;unsigned v=0;for(unsigned i=0;i(read(s,p));if(!std::isfinite(v))throw mods::Error("Custom car draw has a non-finite transform.");return v;} +bool finite(const hlslpp::float4x4& m) {for(unsigned r=0;r<4;++r)for(unsigned c=0;c<4;++c)if(!std::isfinite(float(m[r][c])))return false;return true;} +hlslpp::float4x4 translate(float x,float y,float z) {auto m=hlslpp::float4x4::identity();m[3]=hlslpp::float4(x,y,z,1);return m;} +hlslpp::float4x4 yaw(float angle) {auto m=hlslpp::float4x4::identity();const auto c=std::cos(angle),s=std::sin(angle);m[0]=hlslpp::float4(c,0,-s,0);m[2]=hlslpp::float4(s,0,c,0);return m;} +constexpr float Tau=2*std::numbers::pi_v; +mods::vehicles::Vec3 seat_target(const mods::vehicles::Package& car,unsigned base) { + if(car.per_driver()) { + auto target=seating::destination(car,base);target.y+=KartRideHeight; + return target; // No kart origin/scale/yaw/translation or legacy trim. + } + // Forward is the declared FRONT axle, not the model's arbitrary origin, + // bounding-box centre or the camera. Apply the same yaw as the car mesh. + // This also handles an export imported with a different orientation. + const auto& p=car.pivots; + const float dx=(p[0].x+p[1].x-p[2].x-p[3].x)*.5F; + const float dz=(p[0].z+p[1].z-p[2].z-p[3].z)*.5F; + const float length=std::hypot(dx,dz),angle=car.yaw*Tau/360.F; + float forward_x=0,forward_z=-1; // Agreed native forward convention. + if(std::isfinite(length)&&length>1e-6F) { + const auto cosine=std::cos(angle),sine=std::sin(angle); + forward_x=(dx*cosine+dz*sine)/length; + forward_z=(-dx*sine+dz*cosine)/length; + } + return {(car.seat.x+car.driver_offset.x)*KartModelToWorld+forward_x*DriverSeatAdvance, + (car.seat.y+car.driver_offset.y)*KartModelToWorld+KartRideHeight, + (car.seat.z+car.driver_offset.z)*KartModelToWorld+forward_z*DriverSeatAdvance}; +} +// RGB uses vertex shade. Opacity MUST use primitive alpha: the RSP fog +// shader replaces shade alpha with fog density (zero near the camera). +// Multiplying texture alpha by that value made otherwise valid cars vanish. +constexpr std::uint32_t Mux0=(1U<<20)|(4U<<15)|(1U<<12)|(3U<<9)|(1U<<5)|4U; +constexpr std::uint32_t Mux1=(8U<<28)|(8U<<24)|(1U<<21)|(3U<<18)|(7U<<15)|(7U<<12)|(7U<<9)|(7U<<6)|(7U<<3)|7U; +constexpr std::uint64_t TextureShade=(std::uint64_t(Mux1)<<32)|Mux0; +// Untextured RGB uses shade; untextured alpha uses the same primitive opacity. +constexpr std::uint32_t Shade0=(15U<<20)|(31U<<15)|(7U<<12)|(7U<<9)|(15U<<5)|31U; +constexpr std::uint32_t Shade1=(15U<<28)|(15U<<24)|(7U<<21)|(7U<<18)|(4U<<15)|(3U<<12)|(4U<<9)|(7U<<6)|(4U<<3)|3U; +constexpr std::uint64_t Shade=(std::uint64_t(Shade1)<<32)|Shade0; +static_assert(((TextureShade>>9)&7U)==3U && ((TextureShade>>50)&7U)==3U); +static_assert(((Shade>>44)&7U)==3U && ((Shade>>32)&7U)==3U); +} +RenderPass::RenderPass(RT64::State& s,std::shared_ptr r,Select select): + state_(s),resources_(std::move(r)),select_(select) { + // Snapshot the SAME effective option used by the character renderer once + // per decoder task. Accurate/profile gating stays identical; neither a + // host preference nor a requested-but-inactive option can force detail. + maximum_detail_=enhancements::maximum_detail_enabled(); + auto& workload=s.ext.workloadQueue->workloads[s.ext.workloadQueue->writeCursor]; + if(resources_&&!resources_->library.packages.empty()) + wheel_interpolation::begin(workload,s.workloadId+1,resources_); + previous_pass_=current_pass;current_pass=this; +} +RenderPass::~RenderPass(){current_pass=previous_pass_;} +const RenderPass* active_render_pass() noexcept{return current_pass;} +bool RenderPass::marker(unsigned kind,unsigned value) { + if(kind>17)return false; + if(kind==0){if(object_||scope_||!value)return false;scope_=value;setup_=0;return true;} + if(kind>=7) { + if(object_||!scope_)return false; + if(kind==7){if(setup_&1U)return false;draw_scale_=std::bit_cast(value);setup_|=1U;} + else if(kind<=11) { + const auto bit=1U<<(kind-7);if(setup_&bit)return false; + spin_[kind-8]=(spin_[kind-8]&0xffffffff00000000ULL)|std::uint64_t(value);setup_|=bit; + }else if(kind==12) { + if(setup_&32U)return false; + // Native sprite-wheel yaw and this package-space Y rotation use + // opposite steering signs. Change only the visual conversion; + // the native wheel angle/input and checkpoint remain untouched. + steer_=-float(std::int16_t(value))*Tau/65536.F;setup_|=32U; + }else if(kind<=16) { + const auto bit=1U<<(kind-7);if(setup_&bit)return false; + spin_[kind-13]=(spin_[kind-13]&0xffffffffULL)|(std::uint64_t(value)<<32);setup_|=bit; + }else { + if(setup_&1024U)return false;wheel_epoch_=value;setup_|=1024U; + } + return true; + } + if(kind==1){if(object_||!scope_||setup_!=2047)return false;begin(value);object_=value;return true;} + if(kind==2) { + if(!object_||value!=object_||wheel_)return false; + if(active_&&!replacement_submitted_)diagnostic("no replacement submitted; original retained"); + object_=scope_=0;active_=root_ready_=replacement_submitted_=false;return true; + } + if(!object_)return false; + if(kind==3||kind==5){if(wheel_)return false;wheel_=value;hide_attachment_=kind==3;} + else {if(wheel_!=value||hide_attachment_!=(kind==4))return false;wheel_=0;hide_attachment_=false;} + return true; +} +void RenderPass::begin(unsigned object) { + active_=root_ready_=replacement_submitted_=false;profile_=nullptr;driver_=nullptr;wheel_=0; + if(!resources_||resources_->library.packages.empty())return; + try { + car_=(scope_&255U)-1;player_=(scope_>>24)&7U;view_=(scope_>>27)&3U;original_lod_=(scope_>>8)&255U; + const auto lod=(scope_>>16)&255U; + if(car_>=resources_->library.packages.size()||lod>4)return; + // Driver ownership still matches the submitted native LOD. Only the + // custom car's presentation mesh is pinned; no guest/simulation write. + vehicle_lod_=maximum_detail_?0U:std::min(original_lod_,5U); + driver_=resources_->driver(read(state_,object+0x2c,2)); + if(!driver_||!driver_->compatible())return;profile_=&driver_->lods[lod]; + const auto instances=read(state_,object+0x68),instance=read(state_,instances+lod*4),model=read(state_,instance); + if(read(state_,model+0x24,2)!=profile_->vertices||read(state_,model+0x26,2)!=profile_->triangles)return; + triangles_=read(state_,model+8)&0x00ffffffU; + if(triangles_>0x800000U||profile_->triangles>(0x800000U-triangles_)/16)return; + scale_=draw_scale_;if(!std::isfinite(scale_)||scale_<.001F||scale_>64)return; + // Choice, live draw scale, steering and camera-independent wheel phase + // arrived in the bounded display stream, not a later/recycled object. + const auto points=read(state_,object+0x60),racer=read(state_,object+0x64); + if(!points||read(state_,points)<4||read(state_,points)>10||!racer)return; + auto& addresses=texture_addresses_[car_]; + if(addresses.empty()) { + const auto& pixels=resources_->texture_pixels.at(car_);std::size_t total=0;for(const auto& b:pixels)total+=(b.bytes.size()+7)&~std::size_t(7); + if(total>0x00f00000U-texture_cursor_)return; + for(const auto& b:pixels) { + addresses.push_back(texture_cursor_); + for(unsigned i=0;ilibrary.packages[car_];const auto anchor=original_textures::model_anchor(car,driver_->base,driver_->native_scale); + auto target=seat_target(car,driver_->base); + const auto reference=seating::reference_placement(car); + const auto angle=car.driver_yaw*Tau/360.F,c=std::cos(angle),s=std::sin(angle); + target.x+=reference.x*c+reference.z*s;target.y+=reference.y;target.z+=-reference.x*s+reference.z*c; + auto local=hlslpp::mul(translate(-anchor.x,-anchor.y,-anchor.z),yaw(car.driver_yaw*Tau/360.F)); + // All post-scale Studio positions now share one unit conversion. + // Divide the anchor by the driver's immutable native scale once to + // express it in that driver's model coordinates. Its animation and + // live linear transform remain unchanged; never shrink the character. + local=hlslpp::mul(local,translate(target.x/scale_,target.y/scale_,target.z/scale_)); + adjustment_=hlslpp::mul(hlslpp::mul(inverse,local),root_);root_ready_=finite(adjustment_); + if(!root_ready_){active_=false;return;} + // Submit at the original object root, BEFORE any native chassis or + // wheel is hidden. The end marker arrives after native mtx_pop and + // attachment/material teardown, and must not be our first draw point. + try{replacement_submitted_=draw();} + catch(const std::exception& e){diagnostic(e.what());replacement_submitted_=false;} + if(!replacement_submitted_){active_=root_ready_=false;return;} + } + if(root_ready_) { + rsp.modelMatrixStack[index]=hlslpp::mul(rsp.modelMatrixStack[index],adjustment_); + rsp.modelViewProjChanged=true; + } +} +bool RenderPass::suppress(unsigned triangle)const { + if(!active_||!root_ready_||!replacement_submitted_)return false; + if(wheel_&&hide_attachment_)return true; + if(trianglehide.size()&&profile_->hide[i]!=0; +} +const mods::vehicles::ModelProfile* RenderPass::detail_profile(unsigned object,unsigned instance)const { + if(object!=object_||!active_||!root_ready_||!replacement_submitted_||wheel_)return nullptr; + const auto slots=read(state_,object+0x68); + if(!driver_||!slots||read(state_,slots)!=instance) + throw std::runtime_error("high-detail car driver has no matching LOD0 instance"); + const auto model=read(state_,instance);const auto& high=driver_->lods[0]; + if(read(state_,model+0x24,2)!=high.vertices||read(state_,model+0x26,2)!=high.triangles) + throw std::runtime_error("high-detail car driver ownership differs from LOD0"); + return &high; +} +bool RenderPass::draw() { + auto& s=state_;auto& rsp=*s.rsp;auto& rdp=*s.rdp; + if(!select_||rdp.colorCombinerStackSize>=RDP_EXTENDED_STACK_SIZE||rdp.primColorStackSize>=RDP_EXTENDED_STACK_SIZE||rdp.envColorStackSize>=RDP_EXTENDED_STACK_SIZE){diagnostic("draw-state stack unavailable; original retained");return false;} + NativeDrawScope restore(s); +#if defined(DKR_ENHANCED_WATER) + struct WaterScope { + RT64::State& state;decltype(s.waterMaterial) previous; + explicit WaterScope(RT64::State& value):state(value),previous(value.waterMaterial){state.waterMaterial={};state.updateDrawStatusAttribute(RT64::DrawAttribute::WaterMaterial);} + ~WaterScope(){state.waterMaterial=previous;state.updateDrawStatusAttribute(RT64::DrawAttribute::WaterMaterial);} + } water(s); +#endif + auto& workload=s.ext.workloadQueue->workloads[s.ext.workloadQueue->writeCursor]; + auto& draw=workload.drawData; + const auto first_index=draw.faceIndices.size(); + const auto& car=*resources_->library.packages[car_]; + const float angle=car.yaw*Tau/360.F,car_cos=std::cos(angle),car_sin=std::sin(angle); + const auto units=resources_->vertex_units.at(car_); + // Remove only the selected driver's live draw scale and vertex scratch + // precision, then restore the common native-sized vehicle reference. + // Previously the last step was missing, rendering model units as world + // units. Translation/projection and the artist's package scale are kept. + auto car_root=root_;for(unsigned row=0;row<3;++row)car_root[row]=root_[row]*(KartModelToWorld/(units*scale_)); + // The root's Y basis already contains the native live draw scale. Remove + // it once to move the entire car (including all four wheels) by the same + // fixed local distance as its seat. Do not double-apply vertex precision + // or the package scale, and do not change the canonical guest transform. + const auto offset=seating::scaled(car.kart_offset,KartModelToWorld); + car_root[3]=root_[3]+root_[0]*(offset.x/scale_)+root_[1]*((KartRideHeight+offset.y)/scale_)+root_[2]*(offset.z/scale_); + rsp.modelMatrixStack[1]=car_root;rsp.modelMatrixStackSize=2; + rsp.modelMatrixPhysicalAddressStack[1]=rsp.modelMatrixSegmentedAddressStack[1]=Scratch; + rsp.modelViewProjChanged=true;rsp.modelViewProjInserted=false; + const auto alpha=read(s,object_+0x39,1); + rdp.setEnvColor(0); + unsigned mesh_index=0;bool in_front=false;float min_w=1e30F,max_w=-1e30F; + for(const auto& mesh:car.lods[vehicle_lod_].meshes) { + int wheel=-1;for(unsigned i=0;i<4;++i)if(car.wheels[i]==mesh.id)wheel=int(i); + wheel_interpolation::Sample motion; + if(wheel>=0) { + const auto pivot=seating::source_point(car,car.pivots[unsigned(wheel)]);const auto precision=car.scale*units; + motion.pivot={pivot.x*precision,pivot.y*precision,pivot.z*precision}; + motion.phase=spin_[unsigned(wheel)];motion.axle=car.axle;motion.yaw=angle; + motion.steering=steer_;motion.front=wheel<2;motion.epoch=wheel_epoch_;motion.object=object_; + motion.scene=presentation::task_scene_generation();motion.allowed=presentation::task_interpolation_allowed(); + motion.base=hlslpp::mul(car_root,rsp.extended.viewProjMatrix); + rsp.modelMatrixStack[1]=hlslpp::mul(wheel_interpolation::local(motion,wheel_interpolation::phase_angle(motion.phase),steer_),car_root); + }else rsp.modelMatrixStack[1]=car_root; + const bool textured=mesh.texture>=0; + const auto opacity=unsigned(mesh.colour[3])*alpha/255; + rdp.setPrimColor(0,0,0xffffff00U|opacity); + // Alpha coverage keeps transparent texels from writing solid depth. + // No lighting passes, rays, or additional render target are required. + // These pixel-art material images are already small in the export. + // Point sampling retains their edges instead of adding another blur. + // NativeDrawScope restores the caller's filter/material state. + rdp.setOtherMode(G_TF_POINT|G_TP_PERSP,(alpha<255||mesh.colour[3]<255)?0x005049d8U:textured?0x00553078U:0x00552078U); + if(textured) { + const auto& texture=car.textures.at(unsigned(mesh.texture));const auto width=texture.image.width,height=texture.image.height; + const auto format=resources_->texture_pixels.at(car_).at(unsigned(mesh.texture)).rgba32?G_IM_SIZ_32b:G_IM_SIZ_16b; + rdp.setTextureImage(G_IM_FMT_RGBA,format,width,texture_addresses_[car_].at(unsigned(mesh.texture))); + const auto mask=[](unsigned n){unsigned bits=0;while((1U<0.F? + rsp.cullBothMask&~rsp.cullFrontMask:rsp.cullFrontMask); + unsigned chunk=0; + for(std::size_t first=0;first(10,mesh.faces.size()-first)); + // Disjoint fields: no XOR overlap between a different car, mesh, + // view or chunk. Vertex correspondence never crosses an LOD. + const auto identity=0xc0000000U|((presentation::task_scene_generation()&7U)<<27)| + (view_<<25)|(player_<<22)|(vehicle_lod_<<19)|(car_<<15)|(mesh_index<<9)|chunk; + // Wheel geometry stays fixed. Interpolate its pivot-local angular + // motion at presentation time, never chord/morph its vertices. + select_(rsp,identity,wheel<0); + // Each chunk owns its transform group, even though it uses the + // same physical scratch address. Don't inherit the previous + // chunk's transform index after selecting a new group identity. + rsp.modelViewProjChanged=true; + for(unsigned face=0;face=0) { + motion.identity=identity;motion.transform=rsp.curTransformIndex; + wheel_interpolation::record(workload,s.workloadId+1,resources_,motion); + } + for(unsigned v=0;v0; + } + for(unsigned face=0;facenative_scale,KartRideHeight,car.per_driver()?0.F:DriverSeatAdvance,(car.per_driver()&&car.authored_anchors)?"raw-native-v1":"studio-to-world",car.per_driver()?"per-base-driver-v1":"legacy-shared",driver_->base,vehicle_lod_,unsigned(maximum_detail_)); + // A degenerate/behind-camera replacement must never make the original + // disappear. The immutable package already guarantees complete meshes. + detail::note({presentation::task_scene_generation(),object_,view_,original_lod_,vehicle_lod_,2,maximum_detail_, + indices!=0&&in_front?"custom vehicle submitted":"custom vehicle not visible; original retained"}); + return indices!=0&&in_front; +} +} // namespace dkr::runtime::vehicles diff --git a/runtime-recomp/src/game/vehicle_rt64.hpp b/runtime-recomp/src/game/vehicle_rt64.hpp new file mode 100644 index 00000000..f0269f4b --- /dev/null +++ b/runtime-recomp/src/game/vehicle_rt64.hpp @@ -0,0 +1,43 @@ +#pragma once +#include "vehicle_binding.hpp" +#include "common/rt64_hlslpp.h" + +namespace RT64 {struct State;struct RSP;} +namespace dkr::runtime::vehicles { +// All addresses refer to a task-owned snapshot/private decoder, never live RAM. +class RenderPass { +public: + using Select=void(*)(RT64::RSP&,std::uint32_t,bool); + explicit RenderPass(RT64::State&,std::shared_ptr,Select); + ~RenderPass(); + RenderPass(const RenderPass&)=delete; + RenderPass& operator=(const RenderPass&)=delete; + bool marker(unsigned kind,unsigned value); + void matrix(unsigned index); + bool suppress(std::uint32_t triangle)const; + // Presentation-only LOD0 redraws must use the HIGH model's ownership mask, + // not the submitted low model's indices. Null means no custom car scope. + const mods::vehicles::ModelProfile* detail_profile(unsigned object,unsigned instance)const; +private: + void begin(unsigned object); + bool draw(); + void diagnostic(const char* message)const; + RT64::State& state_; + const RenderPass* previous_pass_=nullptr; + std::shared_ptr resources_; + Select select_; + unsigned scope_=0,object_=0,wheel_=0,triangles_=0,car_=0,original_lod_=0,vehicle_lod_=0,player_=0,view_=0; + unsigned texture_cursor_=0x00e00000U; + unsigned setup_=0,wheel_epoch_=0; + const mods::vehicles::DriverProfile* driver_=nullptr; + const mods::vehicles::ModelProfile* profile_=nullptr; + std::array,16> texture_addresses_; + hlslpp::float4x4 root_=hlslpp::float4x4::identity(),adjustment_=hlslpp::float4x4::identity(); + float scale_=1,draw_scale_=1,steer_=0; + std::array spin_{}; + bool active_=false,root_ready_=false,replacement_submitted_=false,hide_attachment_=false; + bool maximum_detail_=false; // Local presentation policy, never checkpointed. +}; +// Decoder-thread scope only; never simulation/checkpoint state. +const RenderPass* active_render_pass() noexcept; +} // namespace dkr::runtime::vehicles diff --git a/runtime-recomp/src/game/vehicle_seat_alignment.hpp b/runtime-recomp/src/game/vehicle_seat_alignment.hpp new file mode 100644 index 00000000..3fffce3b --- /dev/null +++ b/runtime-recomp/src/game/vehicle_seat_alignment.hpp @@ -0,0 +1,36 @@ +#pragma once +#include "vehicle_package.hpp" + +namespace dkr::runtime::vehicles::seating { +// The export's seat, offset AND calibrated neutral anchors share the same +// post-scale Studio coordinates. They must receive the same model-to-world +// conversion. Treating just the anchors as world units amplified the donor +// differences by four (Banjo/Waluigi versus Diddy/Rayman: 24 instead of 6). +inline constexpr float StudioToWorld=.25F; +constexpr mods::vehicles::Vec3 scaled(mods::vehicles::Vec3 value,float factor) { + return {value.x*factor,value.y*factor,value.z*factor}; +} +inline mods::vehicles::Vec3 anchor(const mods::vehicles::Package& car,unsigned base) { + return scaled(car.driver_anchors.at(base),StudioToWorld); +} +inline mods::vehicles::Vec3 reference_placement(const mods::vehicles::Package& car) { + // The fixed-reference contract maps directly onto the authored seat. + // Its anchors are immutable per KART, not one global calibration table. + if(car.per_driver())return {}; + // Retain the accepted reference placement at neutral scale while fixing + // RELATIVE donor alignment. This one shared calibration applies to the + // car, not a Waluigi/Rayman name, body bounds, character height or LOD. + // Diddy is the neutral reference of this version-1 export convention. + return scaled(car.driver_anchors[9],StudioToWorld-1.F); +} +inline mods::vehicles::Vec3 source_point(const mods::vehicles::Package& car,mods::vehicles::Vec3 value) { + // Both mesh vertices and pivot positions start in the same source space. + // Seats are already AFTER model scale/yaw and never pass through here. + if(car.per_driver())return {value.x-car.origin.x,value.y-car.origin.y,value.z-car.origin.z}; + return value; +} +inline mods::vehicles::Vec3 destination(const mods::vehicles::Package& car,unsigned base) { + return scaled(car.driver_seats.at(base),StudioToWorld); +} +static_assert(scaled({0,0,24},StudioToWorld).z==6.F); +} // namespace dkr::runtime::vehicles::seating diff --git a/runtime-recomp/src/game/vehicle_store_ui.inl b/runtime-recomp/src/game/vehicle_store_ui.inl new file mode 100644 index 00000000..496ce29e --- /dev/null +++ b/runtime-recomp/src/game/vehicle_store_ui.inl @@ -0,0 +1,16 @@ +// Implemented after vehicle_ui.inl. Store UI calls the declarations above its +// include, so the established vehicle library stays in its original position. +bool StoreVehiclesBusy() { + const auto view = g_vehicle_library.view(); return view->busy || bool(view->pending); +} +std::shared_ptr StoreVehiclesSnapshot() { return g_vehicle_library.view(); } +void StoreRefreshVehicles() { g_vehicle_library.job(); } +dkr::store::Json StoreVehicleInventory() { + dkr::store::Json result = dkr::store::Json::array(); + const auto view = g_vehicle_library.view(); + if (!view->error.empty() || view->busy) return result; + for (const auto& item : view->items) result.push_back({{"kind", "vehicle"}, {"key", "vehicle:" + item.digest}, + {"id", item.id}, {"name", item.name}, {"digest", item.digest}, {"type", unsigned(item.type)}, + {"path", "mods/vehicles/packages/" + item.digest + vehicle_mods::extension(item.type)}, {"enabled", item.enabled}}); + return result; +} diff --git a/runtime-recomp/src/game/vehicle_symbols.hpp b/runtime-recomp/src/game/vehicle_symbols.hpp new file mode 100644 index 00000000..329019de --- /dev/null +++ b/runtime-recomp/src/game/vehicle_symbols.hpp @@ -0,0 +1,6 @@ +#pragma once +// Generated from the admitted, revision-specific SDK symbol tables. +namespace dkr::runtime::vehicles { +inline constexpr Symbols VehicleSymbols77{0x801263e0U,0x801269c0U,0x801269c4U,0x800df480U,0x800df4bcU,0x800df47cU,0x801267d8U,0x80126818U,0x80126830U,0x801263a0U,0x800e3690U,0x800e36c8U,0x800df4b8U,0x800df470U,0x8011ae8cU,0x8011ae90U,0x8011b0b4U,0x800df75cU,0x800df454U,0x800e0660U,0x800e0678U,0x800e0624U,0x800dd434U,0x8011d55cU,0x8011aeecU}; +inline constexpr Symbols VehicleSymbols80{0x80126980U,0x80126f80U,0x80126f84U,0x800dfa00U,0x800dfa3cU,0x800df9fcU,0x80126d78U,0x80126db8U,0x80126dd0U,0x8012693cU,0x800e3c20U,0x800e3c58U,0x800dfa38U,0x800df9f0U,0x8011b40cU,0x8011b410U,0x8011b634U,0x800dfcdcU,0x800df9d4U,0x800e0be0U,0x800e0bf8U,0x800e0ba4U,0x800dd9a4U,0x8011dadcU,0x8011b46cU}; +} diff --git a/runtime-recomp/src/game/vehicle_texture_scope.hpp b/runtime-recomp/src/game/vehicle_texture_scope.hpp new file mode 100644 index 00000000..864f95e5 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_texture_scope.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "local_scenery_arena.hpp" +#include "hle/rt64_rdp.h" +#include +#include + +namespace dkr::runtime::local_scenery { +// A native billboard must not leave its texture or palette in canonical +// TMEM. Save at the FIRST native load, after any preceding guest loads, and +// restore AFTER this call's texture sampling/uploads, not during DL decoding. +// Staged RT64 invokes this adapter; the dependency checkout is unchanged. +class TextureReplayScope final { +public: + TextureReplayScope(RT64::RDP& rdp,bool enabled):rdp_(rdp),enabled_(enabled) {} + ~TextureReplayScope() { after_call(); } + void before_load(std::uint32_t address) noexcept { + if(!enabled_&&!active_&&!(address>=0x00E00000U&&address<0x00F00000U))return; + // Online labels use a disjoint, bounded portion of decoder-only RAM. + const bool native=(address>=kTextureBegin&&address=0x00D00000U&&address<0x00F00000U)|| + (address>=0x00F00000U&&address<0x01000000U); + if(!native) { after_call();return; } + if(active_)return; + std::memcpy(tmem_.data(),rdp_.TMEM,sizeof(rdp_.TMEM)); + std::memcpy(rice_.data(),rdp_.rice.lastLoadOpByTMEM,sizeof(rdp_.rice.lastLoadOpByTMEM)); + active_=true; + } + void after_call() noexcept { + if(!active_)return; + std::memcpy(rdp_.TMEM,tmem_.data(),sizeof(rdp_.TMEM)); + std::memcpy(rdp_.rice.lastLoadOpByTMEM,rice_.data(),sizeof(rdp_.rice.lastLoadOpByTMEM)); + active_=false; + } +private: + RT64::RDP& rdp_; + bool enabled_=false; + bool active_=false; + std::array tmem_; + std::array rice_; +}; +} diff --git a/runtime-recomp/src/game/vehicle_ui.inl b/runtime-recomp/src/game/vehicle_ui.inl new file mode 100644 index 00000000..6b3aa3d5 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_ui.inl @@ -0,0 +1,206 @@ +// Included inside the launcher namespace after the existing library/Store UI. +namespace vehicle_mods=dkr::mods::vehicles; +struct VehicleLibraryView { + std::vector items; + bool busy=false; + std::string error,message; + std::shared_ptr pending; +}; +class VehicleLibraryUI { + std::mutex mutex_; + std::filesystem::path root_; + std::shared_ptr view_=std::make_shared(); + std::jthread worker_; +public: + ~VehicleLibraryUI(){if(worker_.joinable()){worker_.request_stop();worker_.join();}} + std::shared_ptr view(){std::lock_guard lock(mutex_);return view_;} + bool job(std::function action={},std::string done={}) { + std::filesystem::path root; + {std::lock_guard lock(mutex_);if(view_->busy||root_.empty()||(dkr::store::installations().view()->ready&&dkr::store::installations().view()->busy))return false; + auto next=std::make_shared(*view_);next->busy=true;next->error.clear();next->message.clear();view_=next;root=root_;} + if(worker_.joinable())worker_.join(); + try { + worker_=std::jthread([this,root,action=std::move(action),done=std::move(done)](std::stop_token stop) { + auto next=std::make_shared(); + try {vehicle_mods::Library library(root);if(action)action(library,*next);next->items=library.scan(stop);if(!next->pending)next->message=done;} + catch(const std::exception& e){std::lock_guard lock(mutex_);next->items=view_->items;next->pending=view_->pending;next->error=e.what();} + std::lock_guard lock(mutex_);view_=std::move(next); + }); + }catch(const std::exception& e){std::lock_guard lock(mutex_);auto next=std::make_shared(*view_);next->busy=false;next->error=e.what();view_=next;return false;} + return true; + } + void configure(std::filesystem::path root){if(worker_.joinable())worker_.join();{std::lock_guard lock(mutex_);root_=std::move(root);}job();} +}; +VehicleLibraryUI g_vehicle_library; +std::array g_vehicle_search{}; +std::string g_vehicle_details; +bool g_vehicle_details_request=false,g_vehicle_remove_confirm=false; +std::map g_vehicle_portraits; +int g_vehicle_last_draw=-2; +const VehicleLibraryView* g_vehicle_preview_view=nullptr; +void ReleaseVehicleTextures() { + for(auto& [hash,t]:g_vehicle_portraits)if(t)SDL_DestroyTexture(t); + g_vehicle_portraits.clear(); +} +void DrawVehiclePortrait(const vehicle_mods::Item& item,float width,float height) { + const auto at=ImGui::GetCursorScreenPos(); + if(g_mod_store_renderer&&ImGui::IsRectVisible({width,height})) { + auto it=g_vehicle_portraits.find(item.digest); + if(it==g_vehicle_portraits.end()&&g_vehicle_portraits.size()<512) { + const auto& p=item.portrait; + auto* t=SDL_CreateTexture(g_mod_store_renderer,SDL_PIXELFORMAT_RGBA32,SDL_TEXTUREACCESS_STATIC,int(p.width),int(p.height)); + if(t&&SDL_UpdateTexture(t,nullptr,p.rgba.data(),int(p.width*4))){SDL_DestroyTexture(t);t=nullptr;} + if(t)SDL_SetTextureBlendMode(t,SDL_BLENDMODE_BLEND); + it=g_vehicle_portraits.emplace(item.digest,t).first; + } + if(it!=g_vehicle_portraits.end()&&it->second) { + const float ratio=float(item.portrait.width)/item.portrait.height; + const float w=std::min(width,height*ratio),h=w/ratio; + ImGui::GetWindowDrawList()->AddImage(reinterpret_cast(it->second), + {at.x+(width-w)*.5F,at.y+(height-h)*.5F},{at.x+(width+w)*.5F,at.y+(height+h)*.5F}); + } + } + ImGui::Dummy({width,height}); +} +void ImportVehicleWithDialog() { + if(dkr::store::installations().view()->busy||g_vehicle_library.view()->busy||g_vehicle_library.view()->pending||DialogJobRunning())return; + StartDialogJob([]()->std::function { + std::filesystem::path source;std::string error; + if(NFD_Init()!=NFD_OKAY)error="The file picker could not open."; + else { + nfdu8char_t* result=nullptr; + const nfdfilteritem_t filters[]={{"Custom car or vehicle ZIP","dkrkart,zip"}}; + const auto dialog=NFD_OpenDialogU8(&result,filters,1,nullptr); + if(dialog==NFD_OKAY){source=std::filesystem::u8path(result);NFD_FreePathU8(result);} + else if(dialog==NFD_ERROR)error=NFD_GetError(); + NFD_Quit(); + } + return [source,error] { + if(!error.empty()){g_legacy_import_status=error;return;} + if(source.empty())return; + g_mods_page.category=3;g_mods_page.section=kModsSectionLibrary; + g_vehicle_library.job([source](auto& library,auto& view){ + auto batch=library.prepare_import(source); + if(!batch.conflicts.empty())view.pending=std::make_shared(std::move(batch)); + else library.import_prepared(batch,vehicle_mods::ImportPolicy::RejectDuplicates); + },"Cars imported. Choose yours after selecting Car in Track Select."); + }; + }); +} +void DrawVehicleLibrary(float requested_width,bool locked) { + const auto snapshot=g_vehicle_library.view(); + const auto frame=ImGui::GetFrameCount(); + if(frame!=g_vehicle_last_draw+1&&!locked&&!snapshot->busy&&!snapshot->pending)g_vehicle_library.job(); + g_vehicle_last_draw=frame; + if(g_vehicle_preview_view!=snapshot.get()){ReleaseVehicleTextures();g_vehicle_preview_view=snapshot.get();} + const bool busy=locked||snapshot->busy||bool(snapshot->pending); + const float width=std::max(1.F,std::min(requested_width,ImGui::GetContentRegionAvail().x)); + ImGui::PushID("vehicle-card-library"); + const auto search=PaddockSearch("search",g_vehicle_search.data(),g_vehicle_search.size(),"Search vehicles...",width); + if(search==PaddockFieldResult::GamepadActivated) + RequestCustomTextKeyboard("Search vehicles",g_vehicle_search.data(),g_vehicle_search.size(),[](const std::string& s){g_vehicle_search.fill(0);std::copy_n(s.begin(),std::min(s.size(),g_vehicle_search.size()-1),g_vehicle_search.begin());}); + PaddockGap(14); + if(snapshot->busy)DrawToolIntro("Updating vehicle library...",width); + if(!snapshot->error.empty())DrawIssue(snapshot->error,dkr::runtime::copy::Area::Mods); + if(!snapshot->message.empty())DrawToolIntro(snapshot->message,width); + auto query=std::string(g_vehicle_search.data()); + std::transform(query.begin(),query.end(),query.begin(),[](unsigned char c){return char(std::tolower(c));}); + std::vector shown; + for(const auto& item:snapshot->items) { + auto name=item.name;std::transform(name.begin(),name.end(),name.begin(),[](unsigned char c){return char(std::tolower(c));}); + if(query.empty()||name.find(query)!=name.npos)shown.push_back(&item); + } + const auto count=std::count_if(snapshot->items.begin(),snapshot->items.end(),[](const auto& item){return item.enabled;}); + PaddockText(PaddockReading(12,false,1.5F),PaddockRgb(0x9BB0BD),std::to_string(count)+" cars enabled. Track Select only.",width);PaddockGap(16); + if(shown.empty()) { + PaddockBox box(width,{24,28});PaddockHeading(snapshot->items.empty()?"Your vehicle library starts here":"No matching vehicles",24,0xFFF0C2,box.Inner()); + PaddockGap(12);ImGui::BeginDisabled(busy); + if(PaddockButton("Import car",PaddockButtonKind::Primary))ImportVehicleWithDialog(); + ImGui::EndDisabled();box.End(CupCardBackground); + }else { + const int columns=std::max(1,int((width+kModsCardGap)/(265+kModsCardGap))); + const float card_width=std::floor((width-kModsCardGap*(columns-1))/columns); + const auto grid=ImGui::GetCursorScreenPos();float y=grid.y; + for(std::size_t first=0;first cards;float height=0; + for(std::size_t i=first;i shown; + if(snapshot->pending&&(shown.owner_before(snapshot->pending)||snapshot->pending.owner_before(shown))) { + shown=snapshot->pending;ImGui::OpenPopup(name); + } + if(!ImGui::IsPopupOpen(name))return; + const auto screen=ImGui::GetIO().DisplaySize; + const float width=std::max(240.F,std::min(640.F,screen.x-48)); + ImGui::SetNextWindowSizeConstraints({width,0},{width,std::max(240.F,screen.y-48)}); + const PaddockFlatScope paddock; + if(!BeginPaddockModalWindow(name,ImGuiWindowFlags_AlwaysAutoResize))return; + if(!snapshot->pending){ImGui::CloseCurrentPopup();ImGui::EndPopup();return;} + PaddockModalText("Some cars have the same names as cars in your library. Replace the older copies, or keep both versions?"); + for(const auto& row:snapshot->pending->conflicts)PaddockModalText(row.name); + PaddockGap(10); + PaddockModalText("Replacing keeps the old files in recovery history. Your downloads and saves are not changed."); + if(!snapshot->error.empty())PaddockModalText(snapshot->error); + if(snapshot->busy)PaddockModalText("Importing cars..."); + ImGui::BeginDisabled(locked||snapshot->busy); + const auto batch=snapshot->pending; + if(PaddockButton("Replace older copies",PaddockButtonKind::Primary)) + g_vehicle_library.job([batch](auto& library,auto&){library.import_prepared(*batch,vehicle_mods::ImportPolicy::ReplaceSameName);},"Cars updated. Older copies were kept in recovery history."); + PaddockGap(8); + if(PaddockButton("Keep both versions",PaddockButtonKind::Plain)) + g_vehicle_library.job([batch](auto& library,auto&){library.import_prepared(*batch,vehicle_mods::ImportPolicy::KeepBoth);},"Cars imported. Both versions were kept."); + PaddockGap(8); + if(PaddockButton("Cancel",PaddockButtonKind::Link))g_vehicle_library.job(); + ImGui::EndDisabled();ImGui::EndPopup(); +} +void DrawVehicleDetails(bool locked) { + DrawVehicleImportConfirm(locked); + constexpr const char* name="Vehicle details"; + if(g_vehicle_details_request){ImGui::OpenPopup(name);g_vehicle_details_request=false;g_vehicle_remove_confirm=false;} + if(!ImGui::IsPopupOpen(name))return; + const auto screen=ImGui::GetIO().DisplaySize; + const float width=std::max(240.F,std::min(620.F,screen.x-48)); + ImGui::SetNextWindowSizeConstraints({width,0},{width,std::max(240.F,screen.y-48)}); + const PaddockFlatScope paddock; + if(!BeginPaddockModalWindow(name,ImGuiWindowFlags_AlwaysAutoResize))return; + const auto snapshot=g_vehicle_library.view(); + const auto it=std::find_if(snapshot->items.begin(),snapshot->items.end(),[](const auto& item){return item.digest==g_vehicle_details;}); + if(it!=snapshot->items.end()) { + const auto inner=ImGui::GetContentRegionAvail().x; + PaddockHeading(it->name,28,0xFFCD67,inner);PaddockGap(12);DrawVehiclePortrait(*it,inner,180);PaddockGap(12); + PaddockModalText("Replaces the car visuals, not your chosen character or its handling. Available from Track Select."); + ImGui::BeginDisabled(locked||snapshot->busy); + bool enabled=it->enabled; + if(PaddockSwitch("Enabled for next game",&enabled,"On","Off"))g_vehicle_library.job([hash=it->digest,enabled](auto& library,auto&){library.enable(hash,enabled);}); + PaddockGap(14); + if(!g_vehicle_remove_confirm) {if(PaddockButton("Remove from library",PaddockButtonKind::Link))g_vehicle_remove_confirm=true;} + else { + PaddockModalText("Remove this car? Your download and saves will be kept."); + if(PaddockButton("Remove car",PaddockButtonKind::Plain)) {g_vehicle_library.job([hash=it->digest](auto& library,auto&){library.remove(hash);},"Car removed. Your download was kept.");ImGui::CloseCurrentPopup();} + } + ImGui::EndDisabled(); + }else PaddockModalText("This car is no longer installed."); + PaddockGap(18);if(PaddockButton("Close",PaddockButtonKind::Link))ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); +} diff --git a/runtime-recomp/src/game/vehicle_wheel_interpolation.hpp b/runtime-recomp/src/game/vehicle_wheel_interpolation.hpp new file mode 100644 index 00000000..a52c4169 --- /dev/null +++ b/runtime-recomp/src/game/vehicle_wheel_interpolation.hpp @@ -0,0 +1,170 @@ +#pragma once +// Authored presentation sidecar. The Patch Pipeline includes this only in +// isolated car/RT64 source copies; no checked-out dependency is modified. +#include "common/rt64_hlslpp.h" +#include "hle/rt64_workload.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::vehicles::wheel_interpolation { +// Keep the shared renderer header compatible with RT64's C++17 target. +inline constexpr double Tau=6.283185307179586476925286766559; +inline constexpr double Turn=4294967296.0; +inline constexpr std::size_t MaxSamples=8192; +struct Sample { + hlslpp::float4x4 base=hlslpp::float4x4::identity(); + std::array pivot{}; + std::uint64_t phase=0; + std::uint32_t transform=0,identity=0,scene=0,epoch=0,object=0; + unsigned axle=0; + float yaw=0,steering=0; + bool front=false,allowed=false; +}; +struct Capture { + // Control-block identity prevents resource-address reuse without pinning + // an entire retired mod catalogue just to retain a few wheel matrices. + std::weak_ptr owner; + std::uint64_t token=0; + std::vector samples; +}; +struct Slot { + const RT64::Workload* workload=nullptr; + std::uint64_t serial=0; + std::shared_ptr pending; + std::shared_ptr sealed; +}; +struct Registry { + std::mutex mutex; + // RT64 has four workload slots. Bound this sidecar even across renderer + // recreation; an evicted record simply uses the existing snap/fallback. + std::array slots; + std::uint64_t serial=0; +}; +inline Registry registry; +inline std::atomic diagnostic_count{0}; +inline bool finite(const hlslpp::float4x4& matrix) { + for(unsigned r=0;r<4;++r)for(unsigned c=0;c<4;++c) + if(!std::isfinite(float(matrix[r][c])))return false; + return true; +} +inline hlslpp::float4x4 translation(float x,float y,float z) { + auto matrix=hlslpp::float4x4::identity();matrix[3]=hlslpp::float4(x,y,z,1);return matrix; +} +inline hlslpp::float4x4 rotation(unsigned axle,float angle) { + auto matrix=hlslpp::float4x4::identity();const float c=std::cos(angle),s=std::sin(angle); + if(axle==0){matrix[1]=hlslpp::float4(0,c,s,0);matrix[2]=hlslpp::float4(0,-s,c,0);} + else if(axle==1){matrix[0]=hlslpp::float4(c,0,-s,0);matrix[2]=hlslpp::float4(s,0,c,0);} + else {matrix[0]=hlslpp::float4(c,s,0,0);matrix[1]=hlslpp::float4(-s,c,0,0);} + return matrix; +} +inline float phase_angle(std::uint64_t phase) { + return float(double(std::uint32_t(phase))*(Tau/Turn)); +} +inline std::int64_t phase_travel(std::uint64_t previous,std::uint64_t current) { + const auto bits=current-previous;std::int64_t value; + std::memcpy(&value,&bits,sizeof(value));return value; +} +inline hlslpp::float4x4 local(const Sample& sample,float spin,float steering) { + // Static vertices already contain the authored yaw. Undo it before the + // pivot-local rotation, then restore it. No camera/projection decomposition. + auto matrix=hlslpp::mul(rotation(1,-sample.yaw),translation(-sample.pivot[0],-sample.pivot[1],-sample.pivot[2])); + matrix=hlslpp::mul(matrix,rotation(sample.axle,spin)); + if(sample.front)matrix=hlslpp::mul(matrix,rotation(1,steering)); + matrix=hlslpp::mul(matrix,translation(sample.pivot[0],sample.pivot[1],sample.pivot[2])); + return hlslpp::mul(matrix,rotation(1,sample.yaw)); +} +inline Slot& slot_for(const RT64::Workload& workload) { + for(auto& slot:registry.slots)if(slot.workload==&workload)return slot; + auto& slot=*std::min_element(registry.slots.begin(),registry.slots.end(), + [](const Slot& a,const Slot& b){return a.serial owner) { + slot.serial=++registry.serial; + if(!slot.pending||slot.pending->token!=token||slot.pending->owner.owner_before(owner)||owner.owner_before(slot.pending->owner)) { + slot.pending=std::make_shared();slot.pending->token=token;slot.pending->owner=std::move(owner); + slot.pending->samples.reserve(128); + } + return *slot.pending; +} +inline void begin(const RT64::Workload& workload,std::uint64_t token,std::shared_ptr owner) { + std::lock_guard lock(registry.mutex); + // A task may append to an existing workload. Preserve earlier transforms, + // but discard anything belonging to a recycled/reset draw-data range. + const auto first=workload.drawData.worldTransforms.size(); + for(auto& slot:registry.slots)if(slot.workload==&workload) { + if(!slot.pending)return; + if(slot.pending->token!=token||slot.pending->owner.owner_before(owner)||owner.owner_before(slot.pending->owner)) {slot.pending.reset();return;} + auto& samples=slot.pending->samples; + auto at=std::lower_bound(samples.begin(),samples.end(),first, + [](const Sample& sample,std::size_t index){return sample.transform owner,Sample sample) { + if(sample.transform>=workload.drawData.worldTransforms.size()||sample.axle>2||!finite(sample.base))return; + std::lock_guard lock(registry.mutex);auto& capture=writable(slot_for(workload),token,std::move(owner)); + if(!capture.samples.empty()&&capture.samples.back().transform>=sample.transform)return; + if(capture.samples.size()token==token?std::move(slot.pending):nullptr; + // Only the decoder had a mutable reference. Published samples are now + // immutable and remain alive until every render-frame reader is done. + slot.pending.reset();return; + } +} +inline std::shared_ptr capture(const RT64::Workload& workload) { + std::lock_guard lock(registry.mutex); + for(const auto& slot:registry.slots)if(slot.workload==&workload&&slot.sealed&&slot.sealed->token==workload.workloadId) + return slot.sealed; + return {}; +} +inline const Sample* find(const Capture& capture,std::size_t index) { + const auto at=std::lower_bound(capture.samples.begin(),capture.samples.end(),index, + [](const Sample& sample,std::size_t value){return sample.transformtransform==index?&*at:nullptr; +} +inline hlslpp::float4x4 at_weight(const Sample& previous,const Sample& current,float weight, + const hlslpp::float4x4& previous_exact,const hlslpp::float4x4& current_exact) { + if(weight<=0)return previous_exact;if(weight>=1)return current_exact; + // Unsigned modular storage makes checkpoint addition defined. Recover the + // signed accumulated travel, NOT the shortest arc between wrapped angles. + const auto travel=phase_travel(previous.phase,current.phase); + const double phase=double(std::uint32_t(previous.phase))+double(travel)*double(weight); + const float spin=float(std::remainder(phase,Turn)*(Tau/Turn)); + const float steering=previous.steering+float(std::remainder(double(current.steering-previous.steering),Tau))*weight; + auto base=current.base; + for(unsigned row=0;row<4;++row)base[row]=previous.base[row]+(current.base[row]-previous.base[row])*weight; + return hlslpp::mul(local(current,spin,steering),base); +} +inline bool interpolate(const std::shared_ptr& previous,const std::shared_ptr& current, + std::size_t previous_index,std::size_t current_index,float previous_weight,float current_weight, + const hlslpp::float4x4& previous_exact,const hlslpp::float4x4& current_exact, + hlslpp::float4x4& previous_matrix,hlslpp::float4x4& current_matrix) { + if(!previous||!current||previous->owner.owner_before(current->owner)||current->owner.owner_before(previous->owner))return false; + const auto* a=find(*previous,previous_index);const auto* b=find(*current,current_index); + if(!a||!b||!a->allowed||!b->allowed||a->identity!=b->identity||a->scene!=b->scene||a->epoch!=b->epoch|| + a->object!=b->object||a->axle!=b->axle||a->front!=b->front||a->pivot!=b->pivot||a->yaw!=b->yaw|| + !std::isfinite(previous_weight)||!std::isfinite(current_weight))return false; + const auto pm=at_weight(*a,*b,previous_weight,previous_exact,current_exact); + const auto cm=at_weight(*a,*b,current_weight,previous_exact,current_exact); + if(!finite(pm)||!finite(cm))return false; + previous_matrix=pm;current_matrix=cm; + if(current_weight>0&¤t_weight<1&&diagnostic_count.fetch_add(1,std::memory_order_relaxed)<4) + std::fprintf(stderr,"[custom-vehicles][wheel-interpolation] arc-phase64 weight=%.4f travel-turns=%.5f; static mesh, no extra render pass\n", + current_weight,double(phase_travel(a->phase,b->phase))/Turn); + return true; +} +} // namespace dkr::runtime::vehicles::wheel_interpolation diff --git a/runtime-recomp/src/game/vehicle_world.cpp b/runtime-recomp/src/game/vehicle_world.cpp new file mode 100644 index 00000000..cd81d88b --- /dev/null +++ b/runtime-recomp/src/game/vehicle_world.cpp @@ -0,0 +1,436 @@ +#include "vehicle_world.hpp" +#include "vehicle_symbols.hpp" +#include "legacy_character_menu.hpp" +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::vehicles { +namespace { +using namespace dkr::mods; +[[noreturn]] void bad(const std::string& s){throw Error("Custom car selection: "+s);} +class Memory { + std::span ram_; + std::size_t offset(std::uint32_t p,unsigned n)const { + if(p<0x80000000U||p>0x9fffffffU||ram_.size()%4)bad("invalid guest address."); + const auto o=p-0x80000000U;if(o>ram_.size()||n>ram_.size()-o)bad("guest range exceeds its owner.");return o; + } +public: + explicit Memory(std::span ram):ram_(ram){} + unsigned get(std::uint32_t p,unsigned n=4)const {const auto at=offset(p,n);unsigned value=0;for(unsigned i=0;i>((n-1-i)*8));} + void bytes(std::uint32_t p,View value){const auto at=offset(p,unsigned(value.size()));for(unsigned i=0;i(static_cast(p));} +class Calls { + std::span ram_;recomp_context context_;Memory g_; +public: + std::uint32_t stack; + Calls(std::span ram,const recomp_context& context):ram_(ram),context_(context),g_(ram),stack(std::uint32_t(context.r29)) { + if((stack&7)||stack<0x80000400U||stack>0x9fffffffU)bad("invalid callback stack."); + stack-=0x400;g_.get(stack);g_.get(stack+0x3fc); + } + unsigned call(RecompiledEntrypoint fn,std::initializer_list args) { + if(!fn||args.size()>8)bad("native menu callback is unavailable."); + auto c=context_;c.r29=pointer(stack);c.f_odd=c.mips3_float_mode?&c.f1.u32l:&c.f0.u32h;unsigned i=0; + for(const auto v:args){switch(i){case 0:c.r4=pointer(v);break;case 1:c.r5=pointer(v);break;case 2:c.r6=pointer(v);break;case 3:c.r7=pointer(v);break;default:g_.set(stack+i*4,v);break;}++i;} + fn(ram_.data(),&c);return unsigned(c.r2); + } +}; +} +const Symbols& symbols(bool rev80){return rev80?VehicleSymbols80:VehicleSymbols77;} +World::World(std::shared_ptr resources,bool rev80):resources_(std::move(resources)),rev80_(rev80) { + if(!resources_||resources_->library.packages.empty()||resources_->library.packages.size()!=resources_->portraits.size())bad("missing immutable car resources."); +} +void World::initialize(std::span ram,recomp_context& ctx,const GamePayload& f) { + if(scratch_||!portraits_.empty())return; + Calls c(ram,ctx);Memory g(ram);scratch_=c.call(f.asset_allocate,{512,0x7f7f7fff}); + if(!scratch_)bad("not enough native memory for vehicle selection.");g.bytes(scratch_,Bytes(512)); + try {for(auto id:resources_->portraits)for(unsigned half=0;half<2;++half) { + const auto p=c.call(f.menu_texture_load,{id+half});if(!p)bad("a vehicle portrait could not be loaded."); + portraits_.push_back(p);if(g.get(p,1)!=64||g.get(p+1,1)!=32)bad("a vehicle portrait tile has invalid dimensions."); + }} + catch(...){release(ram,ctx,f);throw;} +} +void World::release(std::span ram,recomp_context& ctx,const GamePayload& f) { + Calls c(ram,ctx);for(auto p:portraits_)c.call(f.menu_texture_free,{p});portraits_.clear(); + if(scratch_){c.call(f.asset_release,{scratch_});scratch_=0;} +} +bool World::menu(std::span ram,recomp_context& ctx,unsigned event,const GamePayload& f, + const std::function& header) { + Memory g(ram);const auto& s=symbols(rev80_); + const auto sound=[&](unsigned id){Calls c(ram,ctx);c.call(f.menu_sound_play,{id,0});}; + if(event==100) { + in_tracks_=true;picker_=false;option_pending_=false;before_=-2;committed_={};active_={};ready_={};wheel_phases_={};++wheel_epoch_; + cpu_choices_={};cpu_objects_={};race_humans_=race_racers_=spawn_slot_=0; + initialize(ram,ctx,f);return false; + } + if(event==104) {picker_=false;before_=-2;if(scratch_||!portraits_.empty())release(ram,ctx,f);return false;} + if(event==105) {option_pending_=true;committed_={};cpu_choices_={};cpu_objects_={};race_humans_=race_racers_=spawn_slot_=0;wheel_phases_={};++wheel_epoch_;return false;} + if(event==103)return picker_; // Native renderer keeps the title/records. + if(event==102) { + const auto after=int(std::int32_t(g.get(s.stage))); + if(!picker_&&before_==0&&after>0&&g.get(s.delay)==0&&in_tracks_) { + players_=g.get(s.players);if(!players_||players_>4)bad("invalid number of players."); + bool any=false; + for(unsigned p=0;p<4;++p) { + const auto* profile=pdriver(header(p)):nullptr; + active_[p]=pcompatible(); + if(pname.c_str():"unresolved", + active_[p]?"available":"not-offered",profile&&!profile->compatible()?" reason=":"", + profile&&!profile->compatible()?profile->issue.c_str():""); + ready_[p]=!active_[p];if(!active_[p])committed_[p]=0; + any|=active_[p]; + } + option_pending_=false; + if(any){next_=after;picker_=true;} + } + before_=-2;return false; + } + if(event!=101)bad("unknown menu event."); + if(!picker_) { + before_=int(std::int32_t(g.get(s.stage))); + if(option_pending_&&before_>=2&&before_<=3) { + before_=0;menu(ram,ctx,102,f,header);if(picker_)return true; + } + return false; + } + bool moved=false,confirm=false; + for(unsigned p=0;p0||buttons&0x0100U)?1:(axis<0||buttons&0x0200U)?-1:0; + if(direction) { + const unsigned n=unsigned(resources_->library.packages.size()+1); + selected_[p]=unsigned((int(selected_[p])+direction+int(n))%int(n));moved=true; + }else if(buttons&(0x8000U|0x1000U)){ready_[p]=true;confirm=true;} + } + if(moved)sound(0xeb);else if(confirm)sound(0xef); + bool done=true;for(unsigned p=0;p ram,const recomp_context& ctx,unsigned event) { + Memory g(ram);const auto& s=symbols(rev80_); + if(event==0) { + cpu_choices_={};cpu_objects_={};race_humans_=race_racers_=spawn_slot_=0; + wheel_phases_={};++wheel_epoch_;return; + } + if(event==3) { + const auto pending=spawn_slot_;spawn_slot_=0; + if(!pending)return; + const auto object=std::uint32_t(ctx.r2); + // racerIndex/vehicleID are assigned AFTER this spawn continuation. + // Bind only its actual returned primary object, then verify those + // fields when it is updated/drawn. Ghosts cannot inherit its choice. + if(object&&g.get(object+0x48,2)==1&&g.get(object+0x64))cpu_objects_[pending-1]=object; + return; + } + if(event==2) { + spawn_slot_=0;if(!race_racers_)return; + const auto index=std::uint32_t(ctx.r20),entry=std::uint32_t(ctx.r22); + if(index>=race_racers_)bad("primary racer index exceeds its car roster."); + if(index>=race_humans_&&g.get(entry+0xe,2)==4)spawn_slot_=index+1; + return; + } + if(event!=1)bad("unknown car race lifecycle event."); + if(!in_tracks_||!g.get(s.tracks_mode))return; + const auto sp=std::uint32_t(ctx.r29); + if((sp&7)||sp<0x80000000U||sp>0x9ffffe00U)bad("invalid car race setup frame."); + // ABI reviewed by legacy_character_ai_policy for BOTH admitted revisions. + const auto humans=g.get(sp+0x144),racers=g.get(std::uint32_t(ctx.r6)),type=g.get(sp+0x68),mode=g.get(sp+0x138); + if(!humans||humans>4||racers8)bad("invalid car race topology."); + if(mode==1||!(type==0||(type>=64&&type<=66))||racers==humans)return; + race_humans_=humans;race_racers_=racers;++race_serial_; + // Read, but NEVER advance, native RNG. This separate integer stream has + // no wall clock, platform distribution, host entropy or render dependency. + // Its results and exact primary-object bindings are rollback state. + std::uint64_t random=std::uint64_t(g.get(s.rng_seed))^(race_serial_*0x9e3779b97f4a7c15ULL); + const auto count=std::uint64_t(resources_->library.packages.size()+1); // Vanilla remains in the pool. + for(unsigned i=humans;i>30))*0xbf58476d1ce4e5b9ULL; + value=(value^(value>>27))*0x94d049bb133111ebULL;value^=value>>31; + }while(value<(std::uint64_t(0)-count)%count); + cpu_choices_[i]=unsigned(value%count); + } + static std::atomic reports{0}; + if(reports.fetch_add(1,std::memory_order_relaxed)<16) + std::fprintf(stderr,"[custom-vehicles][cpu-roster] race=%llu humans=%u racers=%u choices=%u,%u,%u,%u,%u,%u,%u,%u; deterministic primary racers only\n", + static_cast(race_serial_),humans,racers,cpu_choices_[0],cpu_choices_[1],cpu_choices_[2],cpu_choices_[3],cpu_choices_[4],cpu_choices_[5],cpu_choices_[6],cpu_choices_[7]); +} +unsigned World::slot(std::span ram,std::uint32_t object,std::uint32_t racer)const { + Memory g(ram);const auto player=g.get(racer,2); + if(player<4)return !race_racers_||player=race_racers_||index=8)return 0; + if(index>=race_humans_&&index ram,std::uint32_t object) { + Memory g(ram);const auto& s=symbols(rev80_); + if(!in_tracks_||!g.get(s.tracks_mode)||g.get(object+0x48,2)!=1)return 0; + const auto racer=g.get(object+0x64);if(!racer||g.get(racer+0x1d6,1)!=0)return 0; + const auto player=slot(ram,object,racer),selected=choice(player);if(!selected)return 0; + const auto* profile=resources_->driver(g.get(object+0x2c,2)); + if(!profile||!profile->compatible()||profile->base!=g.get(racer+3,1))return 0; + const auto lod=g.get(object+0x3a,1);if(lod>31)return 0; + // The retail farthest model bakes the stock kart into the driver's proxy. + // Keep only this selected driver's last separable LOD, never force all + // racers to maximum detail or use the withdrawn whole-model redraw path. + const auto view=g.get(s.view);if(view>3)return 0; + const auto draw_lod=std::min(lod,4U);g.set(object+0x3a,draw_lod,1); + const auto scope=selected|(lod<<8)|(draw_lod<<16)|(player<<24)|(view<<27); + if(rendering_)bad("nested racer model rendering."); + rendering_=object;wheel_=0; + marker(g,s,0,scope);marker(g,s,7,g.get(object+8)); + for(unsigned w=0;w<4;++w) { + marker(g,s,8+w,std::uint32_t(wheel_phases_[player][w])); + marker(g,s,13+w,std::uint32_t(wheel_phases_[player][w]>>32)); + } + marker(g,s,17,wheel_epoch_); + const auto points=g.get(object+0x60); + const auto front=points&&g.get(points)>=4&&g.get(points)<=10?g.get(points+12):0; + // Vec3s stores named rotations as Y, X, Z in the native ABI. + marker(g,s,12,front?g.get(front,2):0); + marker(g,s,1,object); + return scope; +} +void World::render_end(std::span ram,std::uint32_t object,std::uint32_t scope) { + if(scope) { + Memory g(ram);if(rendering_!=object||wheel_)bad("unbalanced vehicle model scope."); + marker(g,symbols(rev80_),2,object);g.set(object+0x3a,(scope>>8)&255U,1);rendering_=0; + } +} +void World::wheel_scope(std::span ram,std::uint32_t object,bool begin) { + if(!rendering_)return; + Memory g(ram);const auto points=g.get(rendering_+0x60); + bool owned=false; + if(points&&g.get(points)>=4&&g.get(points)<=10)for(unsigned i=0;i<4;++i)owned|=g.get(points+4+i*4)==object; + if(begin){if(wheel_)bad("nested car attachment scope.");wheel_=object;wheel_hidden_=owned;} + else {if(wheel_!=object)bad("unbalanced car wheel scope.");wheel_=0;} + // Other attachments keep their original draw and transform. In + // particular, carried eggs must not inherit the driver's seat alignment. + marker(g,symbols(rev80_),wheel_hidden_?(begin?3:4):(begin?5:6),object); +} +void World::advance_wheels(std::span ram,std::uint32_t object,float dt) { + Memory g(ram);const auto& s=symbols(rev80_); + if(!in_tracks_||!g.get(s.tracks_mode)||g.get(object+0x48,2)!=1)return; + const auto racer=g.get(object+0x64);if(!racer||g.get(racer+0x1d6,1)!=0)return; + auto player=slot(ram,object,racer); + // update_player_racer saves the human port in gCurrentPlayerIndex, then + // temporarily writes PLAYER_COMPUTER while the finished racer drives on. + // render_begin sees the restored port, but this native wheel callback runs + // INSIDE that handoff. Recover only its verified primary human object; + // never reinterpret a real CPU, ghost or arbitrary actor as a player. + if(player>=8&&g.get(racer,2)==0xffffU&&g.get(racer+0x1d8,1)==1) { + const auto owner=g.get(s.current_player),ports=g.get(s.racers_by_port); + if(owner<4&&(!race_racers_||owner reported_ports{0}; + if(!(reported_ports.fetch_or(1U<driver(g.get(object+0x2c,2));if(!profile||!profile->compatible())return; + const auto points=g.get(object+0x60);if(!points||g.get(points)<4||g.get(points)>10)return; + const auto real=[&](unsigned address){const auto v=std::bit_cast(g.get(address));if(!std::isfinite(v))bad("non-finite native wheel input.");return v;}; + if(!std::isfinite(dt)||dt<=0)return; + dt=std::min(dt,1024.F); // Bound visual arithmetic, never reject a long native tick. + const bool braking=real(racer+0xb8)>.4F; + const auto velocity=braking&&real(racer+0xb4)<.4F?0.F:std::clamp(real(racer+0x2c),-5.F,5.F); + // A turn is 2^32 units. Convert each f32 to fixed point independently; + // the multiply/divide and wrapping state are then entirely integer. No + // wall clock, camera-facing sprite index, fmod or platform trig enters a + // checkpoint. The two power-of-two conversions are exact before truncation. + const auto speed=std::int64_t(velocity*65536.F),time=std::int64_t(dt*65536.F); + const auto burnout=g.get(racer+0x1fb,1); + constexpr unsigned order[]{2,3,0,1}; + for(unsigned w=0;w<4;++w) { + const auto native=order[w],wheel=g.get(points+4+native*4),header=g.get(wheel+0x40); + const auto frames=g.get(header+0x55,1);if(!frames)continue; + if(!(braking&&native>=2)&&!(burnout&&native<3)) + wheel_phases_[player][w]+=std::uint64_t(-(speed*time)/(10LL*frames)); + if(burnout&&native<2)wheel_phases_[player][w]+=(std::uint64_t(1)<<32)/frames; + } +} +Bytes World::checkpoint()const { + CheckpointWriter out;out.u32(5);out.text(resources_->identity);out.flag(rev80_);out.flag(in_tracks_);out.flag(picker_);out.flag(option_pending_);out.u32(wheel_epoch_); + out.u32(race_humans_);out.u32(race_racers_);out.u32(spawn_slot_);out.u64(race_serial_); + for(unsigned i=0;i<8;++i){out.u32(cpu_choices_[i]);out.u32(cpu_objects_[i]);for(auto phase:wheel_phases_[i])out.u64(phase);} + out.u32(unsigned(before_+2));out.u32(unsigned(next_));out.u32(players_);out.u32(scratch_);out.u32(unsigned(portraits_.size())); + for(auto p:portraits_)out.u32(p); + for(unsigned i=0;i<4;++i){out.u32(selected_[i]);out.u32(committed_[i]);out.flag(active_[i]);out.flag(ready_[i]);} + return std::move(out).finish(); +} +std::shared_ptr World::stage_checkpoint(View bytes)const { + CheckpointReader in(bytes); + if(in.u32()!=5||in.text()!=resources_->identity||in.flag()!=rev80_)bad("checkpoint uses a different car catalogue."); + auto out=std::make_shared(resources_,rev80_);out->in_tracks_=in.flag();out->picker_=in.flag();out->option_pending_=in.flag();out->wheel_epoch_=in.u32(); + out->race_humans_=in.bounded(4);out->race_racers_=in.bounded(8);out->spawn_slot_=in.bounded(8);out->race_serial_=in.u64(); + if((out->race_racers_&&(!out->race_humans_||out->race_racers_<=out->race_humans_))|| + (!out->race_racers_&&out->race_humans_)|| + (out->spawn_slot_&&(out->spawn_slot_<=out->race_humans_||out->spawn_slot_>out->race_racers_)))bad("invalid CPU car topology checkpoint."); + std::set objects; + for(unsigned i=0;i<8;++i) { + out->cpu_choices_[i]=in.bounded(unsigned(resources_->library.packages.size()));out->cpu_objects_[i]=in.u32(); + const auto object=out->cpu_objects_[i]; + if((irace_humans_||i>=out->race_racers_)&&(out->cpu_choices_[i]||object))bad("CPU car belongs to an inactive racer."); + if(object&&(object<0x80000000U||object>0x807fff90U||object%4||!objects.insert(object).second))bad("invalid CPU car primary object checkpoint."); + for(auto& phase:out->wheel_phases_[i])phase=in.u64(); + } + out->before_=int(in.bounded(6))-2;out->next_=int(in.bounded(3));out->players_=in.bounded(4);out->scratch_=in.u32(); + const auto count=in.bounded(unsigned(resources_->portraits.size()*2)); + if((!out->scratch_&&count)||(out->scratch_&&(out->scratch_<0x80000000U||out->scratch_>0x807ffe00U||out->scratch_%8))|| + (count&&count!=resources_->portraits.size()*2))bad("invalid menu resource checkpoint."); + for(unsigned i=0;i0x807fffe0U||p%8)bad("invalid portrait checkpoint.");out->portraits_.push_back(p);} + for(unsigned i=0;i<4;++i) {out->selected_[i]=in.bounded(unsigned(resources_->library.packages.size()));out->committed_[i]=in.bounded(unsigned(resources_->library.packages.size()));out->active_[i]=in.flag();out->ready_[i]=in.flag();} + if(out->picker_&&(!out->in_tracks_||!out->players_||!out->scratch_||!count||out->next_<1))bad("invalid picker checkpoint."); + in.end();return out; +} +void World::draw(std::span ram,recomp_context& ctx,const GamePayload& f, + RecompiledEntrypoint frame,RecompiledEntrypoint text_width) { + if(!picker_)return; + if(!scratch_||portraits_.size()!=resources_->portraits.size()*2||!players_||players_>4||!frame||!text_width) + bad("vehicle picker lost its prepared native menu resources."); + Calls c(ram,ctx);Memory g(ram);const auto& s=symbols(rev80_); + const auto label=[&](std::string value) { + if(value.size()>255)value.resize(255);Bytes bytes; + for(unsigned char ch:value)bytes.push_back(ch>=32&&ch<127?ch:'?');bytes.push_back(0);g.bytes(scratch_+128,bytes); + }; + const auto text=[&](const std::string& value,unsigned x,unsigned y,unsigned font,unsigned colour=0xffffffffU) { + label(value);c.call(f.menu_font,{font});c.call(f.menu_background,{0,0,0,0}); + // Native fun-font labels use the same small offset shadow as the + // surrounding game menus. Never tint the track name/records above us. + if(font==0) { + c.call(f.menu_colour,{0,0,0,255,128}); + c.call(f.menu_text,{s.display_list,x+1,y+1,scratch_+128,12}); + } + c.call(f.menu_colour,{colour>>24,(colour>>16)&255,(colour>>8)&255,0,colour&255}); + c.call(f.menu_text,{s.display_list,x,y,scratch_+128,12}); + }; + const auto width=[&](const std::string& value,unsigned font) { + label(value);return c.call(text_width,{scratch_+128,0,font}); + }; + const auto image=[&](unsigned list,float x,float y,float scale,unsigned colour=0xffffffffU) { + if(!list||!g.get(list))return false; + c.call(f.menu_texture_draw,{s.display_list,list,std::bit_cast(x),std::bit_cast(y), + std::bit_cast(scale),std::bit_cast(scale),colour,0});return true; + }; + const auto wooden_frame=[&](float x,float y,float scale) { + // gMenuImages is a guest POINTER to the allocated 18-entry array, + // not the array itself. Never treat the symbol's neighbouring globals + // as a MenuAsset or overwrite them while placing a wooden frame. + const auto images=g.get(s.menu_images);if(!images)return; + struct Placement { + Memory& g;unsigned address,height;std::array words;unsigned old_height; + Placement(Memory& memory,unsigned at,unsigned h):g(memory),address(at),height(h),old_height(g.get(h)) { + for(unsigned i=0;i<8;++i)words[i]=g.get(address+i*4); + } + ~Placement(){for(unsigned i=0;i<8;++i)g.set(address+i*4,words[i]);g.set(height,old_height);} + } saved(g,images+7*32,s.frame_height); + const auto original_scale=std::bit_cast(saved.words[2]); + if(!std::isfinite(original_scale)||original_scale<=0)return; + g.set(saved.address+8,std::bit_cast(original_scale*scale)); + g.set(saved.address+12,std::bit_cast(x-160.F)); + g.set(saved.address+16,std::bit_cast(120.F-y)); + g.set(s.frame_height,std::bit_cast(1.F)); + c.call(f.menu_render_reset,{s.display_list});c.call(frame,{7}); + }; + // The original renderer has already drawn the live course, colourful + // track title, real records and balloons. No full-screen panel or guessed + // duplicate title is drawn here; only its lower choice widgets change. + const bool compact=players_>=3; + const float scale=compact?.70F:1.F,centre_y=173.F,span=64.F*scale; + const float column=320.F/float(players_); + for(unsigned p=0;p",unsigned(centre+offset),unsigned(centre_y),0); + text(std::to_string(selected+1)+" / "+std::to_string(resources_->library.packages.size()+1),unsigned(centre),135,1); + }else text("READY",unsigned(centre),135,1,0xa0ff80ffU); + std::string name=selected?resources_->library.packages.at(selected-1)->name:"Vanilla"; + if(!active_[p]) { + const auto type=g.get(s.vehicles+p,1);if(type==1)name="Hovercraft";else if(type==2)name="Plane"; + } + unsigned font=players_==1?0:1; + const unsigned maximum=unsigned(std::min(296.F,column-8.F)); + if(font==0&&width(name,font)>maximum)font=1; + // Measure actual native glyph widths, not a fixed character count. + // Names wrap below the frame instead of stretching a menu card. + unsigned line=0;std::size_t first=0; + while(firstfirst)end=space; + } + auto part=name.substr(first,end-first); + if(line==2&&endmaximum)part.pop_back();part+="..."; + } + text(part,unsigned(centre),211+line*(font==0?12:8),font);first=end;++line; + } + } + text("A: SELECT B: BACK",160,236,1); + c.call(f.menu_font,{2});c.call(f.menu_background,{0,0,0,0});c.call(f.menu_colour,{255,255,255,0,255}); + c.call(f.menu_render_reset,{s.display_list}); + static std::atomic reports{0}; + if(reports.fetch_add(1,std::memory_order_relaxed)<1) + std::fprintf(stderr,"[custom-vehicles][native-menu] native title/records, wooden frames and arrows; portraits=64x32+64x32; outward-arrows=alpha-blended; players=%u\n",players_); +} +} // namespace dkr::runtime::vehicles diff --git a/runtime-recomp/src/game/vehicle_world.hpp b/runtime-recomp/src/game/vehicle_world.hpp new file mode 100644 index 00000000..fc71825d --- /dev/null +++ b/runtime-recomp/src/game/vehicle_world.hpp @@ -0,0 +1,64 @@ +#pragma once +#include "vehicle_resources.hpp" +#include "legacy_checkpoint.hpp" +#include "game_payload.hpp" +#include + +namespace dkr::runtime::vehicles { +// Sourced from both revision-specific SDK symbol tables by the Patch Pipeline. +struct Symbols { + std::uint32_t stage,vehicles,confirmed,ready,players,delay,buttons,stick_x,stick_y, + display_list,dialogue_begin,dialogue_modes,tracks_mode,current_menu, + object_display_list,object_matrix,view,menu_images,frame_height,player_images,arrows,race_images,rng_seed, + current_player,racers_by_port; +}; +const Symbols& symbols(bool revision80); +class World { +public: + using Resources=dkr::mods::vehicles::Resources; + explicit World(std::shared_ptr,bool revision80); + // 100 initialize, 101 before option input, 102 after option input, + // 103 draw options, 104 leave Track Select. True replaces that one call. + bool menu(std::span,recomp_context&,unsigned,const GamePayload&, + const std::function& character_header); + // Callbacks arrive from the currently executing normal/private guest + // variant. Do not look up a global function from the other online world. + void draw(std::span,recomp_context&,const GamePayload&, + RecompiledEntrypoint frame,RecompiledEntrypoint text_width); + // Read-only identity query, apart from the scoped far-LOD cap restored by + // render_end. It never allocates models, changes physics or advances time. + std::uint32_t render_begin(std::span,std::uint32_t object); + void render_end(std::span,std::uint32_t object,std::uint32_t scope); + void wheel_scope(std::span,std::uint32_t object,bool begin); + void advance_wheels(std::span,std::uint32_t object,float native_update_rate); + // Reviewed primary-racer setup hooks, including builds with no custom + // characters. Choices are made on the logic path, never by the renderer. + void race(std::span,const recomp_context&,unsigned event); + dkr::mods::Bytes checkpoint()const; + std::shared_ptr stage_checkpoint(dkr::mods::View)const; + const std::shared_ptr& resources()const{return resources_;} +private: + void initialize(std::span,recomp_context&,const GamePayload&); + void release(std::span,recomp_context&,const GamePayload&); + unsigned slot(std::span,std::uint32_t object,std::uint32_t racer)const; + unsigned choice(unsigned slot)const; + std::shared_ptr resources_; + bool rev80_=false,in_tracks_=false,picker_=false,option_pending_=false; + int before_=-2,next_=0; + unsigned players_=0; + std::array selected_{},committed_{}; + // Low 32 bits are one turn; upper bits retain direction/full revolutions. + // This is deterministic simulation state, not a wall-clock animation. + std::array,8> wheel_phases_{}; + std::uint32_t wheel_epoch_=0; + std::array cpu_choices_{},cpu_objects_{}; + unsigned race_humans_=0,race_racers_=0,spawn_slot_=0; + std::uint64_t race_serial_=0; + std::array active_{},ready_{}; + std::uint32_t scratch_=0; + std::vector portraits_; + // Render-call scratch only. Never part of simulation/checkpoint state. + std::uint32_t rendering_=0,wheel_=0; + bool wheel_hidden_=false; +}; +} // namespace dkr::runtime::vehicles diff --git a/runtime-recomp/src/game/visual_model_kernel.c b/runtime-recomp/src/game/visual_model_kernel.c index 4f858f41..fdfeb96e 100644 --- a/runtime-recomp/src/game/visual_model_kernel.c +++ b/runtime-recomp/src/game/visual_model_kernel.c @@ -7,11 +7,11 @@ #include #include -enum { ARENA=0xE00000, ARENA_END=0xEF0000, OBJECT=0xE00000, - HEADER=0xE00100, INSTANCE=0xE00200, SLOTS=0xE00300, - VERTICES=0xE10000, VERTEX2=0xE20000, DELTAS=0xE30000, - FRACTION=0xE40000, COMMANDS=0xE50000, COMMAND_END=0xE90000, - STACK=0xEE0000, MAX_VERTICES=4096, MAX_SHADOWS=256 }; +enum { ARENA=0xC10000, ARENA_END=0xD00000, OBJECT=0xC10000, + HEADER=0xC10100, INSTANCE=0xC10200, SLOTS=0xC10300, + VERTICES=0xC20000, VERTEX2=0xC40000, DELTAS=0xC60000, + FRACTION=0xC80000, COMMANDS=0xC90000, COMMAND_END=0xCD0000, + STACK=0xCF0000, MAX_VERTICES=8192, MAX_SHADOWS=256 }; /* Separate TLS for the two revisions, and separate from the owned CPU fence. */ #if defined(_MSC_VER) #define VISUAL_TLS __declspec(thread) diff --git a/runtime-recomp/src/game/water_residency_rt64.hpp b/runtime-recomp/src/game/water_residency_rt64.hpp new file mode 100644 index 00000000..90ae118b --- /dev/null +++ b/runtime-recomp/src/game/water_residency_rt64.hpp @@ -0,0 +1,138 @@ +#pragma once +#include "enhanced_water_rt64.hpp" +#include "polish_symbols.hpp" +#include "rice_texture_pack_policy.hpp" +#include "liquid_replacement_gate.hpp" +#include "performance_observation.hpp" +#include "render/rt64_texture_cache.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dkr::runtime::water { +template +class LiquidResidencyT { + RT64::TextureCache* cache_=nullptr; + std::uint64_t generation_=0,evictions_=0; + std::uint32_t scene_=~0U,loaded_=~0U,model_=0; + streaming::SelectionPtr selection_; + typename Clock::time_point retry_after_{}; + unsigned retries_=0; + bool pending_=false; + static std::uint32_t crc(const std::uint8_t* source,unsigned row,unsigned height) { + std::uint32_t result=0; + for(int y=int(height)-1;y>=0;--y) { + std::uint32_t value=0; + for(int x=int(row)-4;x>=0;x-=4) { + std::memcpy(&value,source+x,4);value^=std::uint32_t(x); + result=(result<<4)+((result>>28)&15);result+=value; + } + value^=std::uint32_t(y);result+=value;source+=row; + } + return result; + } + static void enqueue(RT64::TextureCache& cache,const std::vector& batch) { + if(batch.empty())return; + // The map lock has been released. Preserve the upload worker lock order. + {std::scoped_lock lock(cache.uploadQueueMutex); + cache.resolvedPathQueue.insert(cache.resolvedPathQueue.end(),batch.begin(),batch.end());} + cache.uploadQueueChanged.notify_all(); + } + std::vector missing(RT64::ReplacementMap& map) { + std::vector batch; + std::set> requested;pending_=false; + if(!selection_)return batch; + for(const auto& group:selection_->groups)if(group.complete)for(const auto& member:group.members) { + if(map.getFromRelativePath(member.layer,member.path))continue; + pending_=true; + if(member.layer>=map.fileSystemResolvedPaths.size())continue; + const auto at=map.fileSystemResolvedPaths[member.layer].find(member.hash); + if(at==map.fileSystemResolvedPaths[member.layer].end())continue; + const auto& path=at->second; + if(path.resolvedOperation==RT64::ReplacementOperation::Stream && batch.size()<128 && + requested.emplace(path.fileSystemIndex,path.relativePath).second)batch.push_back(path); + } + return batch; + } +public: + void publish(const void* workload,std::uint64_t frame)const {streaming::publish(workload,frame,selection_);} + void prepare(RT64::State& state,const enhanced::Registry& materials,std::uint32_t scene,bool original) { + auto* cache=state.ext.textureCache; + if(!cache || !original) {selection_.reset();loaded_=~0U;model_=0;return;} + const auto generation=cache->getReplacementGeneration(); + enhanced::Memory m(std::span(state.RDRAM,0x800000)); + const auto loaded=m.u32(revision_addresses::NumberOfLoadedTextures),model=m.u32(enhanced::level_model_symbol()); + const bool reset=cache!=cache_ || generation!=generation_ || scene!=scene_; + if(reset) {cache_=cache;generation_=generation;scene_=scene;loaded_=~0U;model_=0;selection_.reset();retries_=0;} + std::unique_lock map_lock(cache->textureMapMutex); + auto& map=cache->textureMap.replacementMap; + if(map.fileSystems.empty()) {selection_.reset();return;} + const auto evictions=streaming::eviction_epoch(&map); + const auto now=Clock::now(); + if(loaded==loaded_ && model==model_ && selection_) { + if(evictions!=evictions_) {evictions_=evictions;pending_=true;retries_=0;} + if(!pending_ || retries_>=5 || now headers; + for(const auto address:materials.liquid_headers())headers.push_back({address,false}); + const auto& symbols=presentation::polish_symbols[revision_addresses::CurrentMenuId==revision_addresses::kUsV80.CurrentMenuId?1:0]; + if(const auto waves=m.u32(symbols[7]);waves && m.valid(waves,32))headers.push_back({waves,true}); + std::vector families; + unsigned inspected=0,incomplete=0,unsupported=0; + for(const auto source:headers) { + const auto first=source.address; + if(!first || (first&3U) || !m.valid(first,32))continue; + const unsigned count=std::max(1U,unsigned(m.u16(first+0x12)>>8)); + const unsigned stride=m.u16(first+0x16); + if(count<2)continue; + if(count>64 || stride<32 || !m.valid(first,std::uint64_t(count)*stride) || inspected+count>512) {++unsupported;continue;} + streaming::Family family; + bool valid=true;unsigned matched=0;char first_missing[40]{}; + for(unsigned frame=0;frame formats{0,0,4,4,3,3,3},sizes{3,2,1,0,2,1,0}; + if(format>=formats.size() || !width || !height || (source.square && width!=16 && width!=32)) {valid=false;break;} + const unsigned size=sizes[format],row=width<>1; + const auto bytes=std::uint64_t(row)*height; + if(row<4 || (row&3U) || !bytes || bytes>stride-32 || bytes>65536 || !m.valid(header+32,bytes)) {valid=false;break;} + ++inspected; + char identity[40];std::snprintf(identity,sizeof(identity),"%08x#%u#%u",crc(state.RDRAM+((header+32)&0x7FFFFFU),row,height),formats[format],size); + const auto alias=rice_texture::native_alias(identity); + streaming::Member member{alias};bool found=false; + for(auto layer=map.fileSystemResolvedPaths.rbegin();layer!=map.fileSystemResolvedPaths.rend();++layer) { + const auto at=layer->find(alias);if(at==layer->end())continue; + member.layer=at->second.fileSystemIndex;member.path=at->second.relativePath;found=true;++matched;break; + } + if(!found && !first_missing[0])std::snprintf(first_missing,sizeof(first_missing),"%s",identity); + family.complete &= found;family.members.push_back(std::move(member)); + } + if(!valid) {++unsupported;continue;} + if(!matched)continue; + if(!family.complete) { + ++incomplete;static unsigned reports=0; + if(reports++<64)std::fprintf(stderr,"[water][coverage] scene=%u header=%08X frames=%u matched=%u missing=%u first-missing=%s; whole animation native because the pack is incomplete\n",scene,first,count,matched,count-matched,first_missing); + } + families.push_back(std::move(family)); + } + const auto family_count=families.size(); + selection_=streaming::select_families(&map,scene,std::move(families)); + auto batch=missing(map);retry_after_=now+std::chrono::seconds(1); + map_lock.unlock();enqueue(*cache,batch); + std::fprintf(stderr,"[water][residency] scene=%u headers=%zu identities=%u families=%zu queued=%zu incomplete=%u unsupported=%u; immutable workload families; asynchronous complete-family selection\n",scene,headers.size(),inspected,family_count,batch.size(),incomplete,unsupported); + } +}; +using LiquidResidency=LiquidResidencyT<>; +inline LiquidResidency& residency(){static thread_local LiquidResidency value;return value;} +} diff --git a/runtime-recomp/src/game/wrapped_input.inl b/runtime-recomp/src/game/wrapped_input.inl new file mode 100644 index 00000000..9314cc1c --- /dev/null +++ b/runtime-recomp/src/game/wrapped_input.inl @@ -0,0 +1,198 @@ +// Included only in the build-local imgui_widgets.cpp. The opt-in is scoped to +// one review widget; ordinary launcher inputs retain upstream behaviour. +static thread_local float DkrReviewWrapWidth = 0.0f; + +static float DkrReviewAdvance(ImWchar c) +{ + ImGuiContext& g = *GImGui; + return c == '\r' || c == '\n' ? 0.0f : g.Font->GetCharAdvance(c) * (g.FontSize / g.Font->FontSize); +} + +static float DkrReviewMeasure(const ImWchar* begin, const ImWchar* end) +{ + float width = 0.0f; + for (; begin < end; ++begin) + width += DkrReviewAdvance(*begin); + return width; +} + +// Keep every character (including spaces) in the edit buffer. Both STB's +// hit-testing/navigation and our drawing use these exact visual row breaks. +static const ImWchar* DkrReviewRowEnd(const ImWchar* begin, const ImWchar* end) +{ + float width = 0.0f; + const ImWchar* word_break = NULL; + for (const ImWchar* p = begin; p < end; ++p) + { + if (*p == '\n') + return p + 1; + const float advance = DkrReviewAdvance(*p); + if (width + advance > DkrReviewWrapWidth && p > begin) + return word_break ? word_break : p; + width += advance; + if (*p == ' ' || *p == '\t' || *p == '-') + word_break = p + 1; + } + return end; +} + +static const ImWchar* DkrReviewInkEnd(const ImWchar* begin, const ImWchar* end) +{ + while (end > begin && (end[-1] == '\n' || end[-1] == '\r')) + --end; + return end; +} + +static ImVec2 DkrReviewPosition(const ImWchar* begin, const ImWchar* end, int index, bool row_end = false) +{ + const ImWchar* cursor = begin + ImClamp(index, 0, (int)(end - begin)); + float y = GImGui->FontSize; + while (begin < end) + { + const ImWchar* next = DkrReviewRowEnd(begin, end); + if (cursor < next || (row_end && cursor == next && next[-1] != '\n') || + (next == end && end[-1] != '\n')) + return ImVec2(DkrReviewMeasure(begin, cursor), y); + begin = next; + y += GImGui->FontSize; + } + return ImVec2(0.0f, y); +} + +static int DkrReviewRowBoundary(ImGuiInputTextState* state, int index, bool last) +{ + const ImWchar* begin = state->TextW.Data; + const ImWchar* end = begin + state->CurLenW; + const ImWchar* cursor = begin + ImClamp(index, 0, state->CurLenW); + for (const ImWchar* row = begin; row < end; ) + { + const ImWchar* next = DkrReviewRowEnd(row, end); + if (cursor < next || (state->Stb.cursor_at_end_of_line && cursor == next && next[-1] != '\n') || + (next == end && end[-1] != '\n')) + return (int)((last ? DkrReviewInkEnd(row, next) : row) - begin); + row = next; + } + return state->CurLenW; +} + +static int DkrReviewPreviousRow(ImGuiInputTextState* state, int current) +{ + const ImWchar* begin = state->TextW.Data; + const ImWchar* end = begin + state->CurLenW; + int previous = 0; + for (const ImWchar* row = begin; row < end && row - begin < current; ) + { + previous = (int)(row - begin); + row = DkrReviewRowEnd(row, end); + } + return previous; +} + +static bool DkrReviewVerticalRowEnd(ImGuiInputTextState* state) +{ + const ImWchar* begin = state->TextW.Data; + const ImWchar* end = begin + state->CurLenW; + for (const ImWchar* row = begin; row < end; ) + { + const ImWchar* next = DkrReviewRowEnd(row, end); + if (next - begin == state->Stb.cursor && next[-1] != '\n') + return state->Stb.has_preferred_x && state->Stb.preferred_x >= DkrReviewMeasure(row, next) - 0.5f; + row = next; + } + return false; +} + +static float DkrReviewHeight(const ImWchar* begin, const ImWchar* end) +{ + float height = GImGui->FontSize; + while (begin < end) + { + const ImWchar* next = DkrReviewRowEnd(begin, end); + if (next < end || next[-1] == '\n') + height += GImGui->FontSize; + begin = next; + } + return height; +} + +static void DkrReviewSelection(ImDrawList* draw, ImVec2 origin, const ImVec4& clip, + const ImWchar* begin, const ImWchar* end, + int selected_begin, int selected_end, ImU32 colour) +{ + int first = 0; + for (const ImWchar* row = begin; row < end; ) + { + const ImWchar* next = DkrReviewRowEnd(row, end); + const int last = (int)(next - begin); + const int a = ImMax(first, selected_begin), b = ImMin(last, selected_end); + if (a < b) + { + const float x = DkrReviewMeasure(row, begin + a); + const float width = ImMax(1.0f, DkrReviewMeasure(begin + a, begin + b)); + ImRect rect(origin + ImVec2(x, 0), origin + ImVec2(x + width, GImGui->FontSize)); + rect.ClipWith(ImRect(clip.x, clip.y, clip.z, clip.w)); + if (rect.GetWidth() > 0 && rect.GetHeight() > 0) + draw->AddRectFilled(rect.Min, rect.Max, colour); + } + origin.y += GImGui->FontSize; + first = last; + row = next; + } +} + +// UTF-8 remains the sole stored/submitted representation. The temporary wide +// buffer is only a layout view, as in upstream InputText's active edit state. +static float DkrReviewDraw(ImDrawList* draw, ImVec2 origin, ImU32 colour, + const char* text, const char* text_end) +{ + if (!text_end) + text_end = text + strlen(text); + ImVector wide; + wide.resize((int)(text_end - text) + 1); + const int length = ImTextStrFromUtf8(wide.Data, wide.Size, text, text_end); + const ImWchar* end = wide.Data + length; + const float height = DkrReviewHeight(wide.Data, end); + ImVector row_text; + row_text.resize(length * 4 + 1); + for (const ImWchar* row = wide.Data; row < end; ) + { + const ImWchar* next = DkrReviewRowEnd(row, end); + const ImWchar* ink_end = DkrReviewInkEnd(row, next); + const int bytes = ImTextStrToUtf8(row_text.Data, row_text.Size, row, ink_end); + draw->AddText(GImGui->Font, GImGui->FontSize, origin, colour, row_text.Data, row_text.Data + bytes); + origin.y += GImGui->FontSize; + row = next; + } + return height; +} + +// Public only to the launcher translation unit. No global flag, ABI changes, +// new dependency version or modification of the checked-out ImGui is needed. +bool DkrReviewInputText(const char* label, char* buffer, size_t capacity, const ImVec2& size) +{ + struct WrapScope + { + float previous = DkrReviewWrapWidth; + ~WrapScope() { DkrReviewWrapWidth = previous; } + } scope; + // Reserve the scrollbar from the start: typing the first overflowing row + // must not change line widths and cause an oscillating reflow. + DkrReviewWrapWidth = ImMax(1.0f, size.x - ImGui::GetStyle().FramePadding.x * 2.0f + - ImGui::GetStyle().ScrollbarSize - 1.0f); + const ImGuiID id = ImGui::GetID(label); + ImGuiStorage* storage = ImGui::GetStateStorage(); + const ImGuiID width_id = ImHashStr("review-wrap-width", 0, id); + const ImGuiID font_id = ImHashStr("review-wrap-font", 0, id); + if (storage->GetFloat(width_id) != DkrReviewWrapWidth || storage->GetFloat(font_id) != GImGui->FontSize) + { + if (ImGuiInputTextState* state = ImGui::GetInputTextState(id)) + { + state->CursorFollow = true; + state->Stb.has_preferred_x = 0; + state->Stb.cursor_at_end_of_line = 0; + } + storage->SetFloat(width_id, DkrReviewWrapWidth); + storage->SetFloat(font_id, GImGui->FontSize); + } + return ImGui::InputTextMultiline(label, buffer, capacity, size, ImGuiInputTextFlags_NoHorizontalScroll); +} diff --git a/runtime-recomp/tests/replay_probe/owned_game.hpp b/runtime-recomp/tests/replay_probe/owned_game.hpp index 4aaa4c5c..195e359f 100644 --- a/runtime-recomp/tests/replay_probe/owned_game.hpp +++ b/runtime-recomp/tests/replay_probe/owned_game.hpp @@ -25,6 +25,7 @@ struct OwnedBootOptions { std::shared_ptr code; std::vector code_bootstrap; std::uint64_t code_seed=0x444B52434F444531ULL; + bool development=false; }; struct ConfirmedAudio { std::uint64_t epoch = 0; @@ -59,6 +60,7 @@ class OwnedGame : public SceneSimulation { virtual std::span confirmed_paks() const = 0; virtual bool install_initial_paks(std::uint64_t epoch,std::span images,std::string& error) = 0; virtual void set_confirmed_rumble_sink(ConfirmedRumbleSink sink) = 0; + virtual std::span confirmed_records() const {return {};} }; // Production registry contains only linked, isolated full-scene engines. bool owned_adapter_available(dkr::runtime::rom::Revision revision); diff --git a/runtime-recomp/tests/replay_probe/probe_component.c b/runtime-recomp/tests/replay_probe/probe_component.c index 5f99aee1..6eeec20e 100644 --- a/runtime-recomp/tests/replay_probe/probe_component.c +++ b/runtime-recomp/tests/replay_probe/probe_component.c @@ -17,6 +17,15 @@ static void code_event(unsigned event,uint8_t* ram,struct recomp_context* contex (void)event;(void)ram;(void)context; #endif } +void dkr_probe_sdk2_pulse(uint8_t* ram,struct recomp_context* context) { + context->r2=1; +#if DKR_PROBE_HAS_MOD_SERVICES + uint64_t result=1; + if(dkr_probe_mod_dispatch("dkr_sdk2_pulse",ram,context,0,0,0,0,&result)!=1) + dkr_probe_block("owned-mod-service-failure"); + context->r2=result; +#endif +} void dkr_probe_component_inputs(const dkr_probe_pad inputs[4]) { memcpy(frame_inputs, inputs, sizeof(frame_inputs)); } @@ -180,7 +189,7 @@ void dkr_probe_authored_component_tick(uint8_t* rdram, struct recomp_context* ct // Fixed-cadence experiment is NOT the VI-derived retail frame skipper. // Retain the retail timer's CPU-only decrement/reset/copy semantics. if(!dkr_probe_input_enabled() || !dkr_probe_magic_enabled() || - MEM_W(0,rate)!=2 || (unsigned)MEM_BU(0,timer)>2 || (unsigned)MEM_W(0,task)>1) + (MEM_W(0,rate)<1 || MEM_W(0,rate)>4) || (unsigned)MEM_BU(0,timer)>2 || (unsigned)MEM_W(0,task)>1) dkr_probe_block("unsupported-authored-cpu-owner-state"); if(!dkr_probe_scene_pending()) { code_event(DKR_CODE_EVENT_FRAME,rdram,ctx); diff --git a/runtime-recomp/tests/replay_probe/probe_component.h b/runtime-recomp/tests/replay_probe/probe_component.h index e52e64d6..8e65d2de 100644 --- a/runtime-recomp/tests/replay_probe/probe_component.h +++ b/runtime-recomp/tests/replay_probe/probe_component.h @@ -16,6 +16,12 @@ void dkr_probe_water_component_tick(uint8_t* ram, struct recomp_context* context void dkr_probe_mode_component_tick(uint8_t* ram, struct recomp_context* context); // Retains the audited main-loop CPU span including RSP/RDP setup, background, // missing-controller HUD and complete DL terminators. No task/VI completion. +#if DKR_PROBE_REVISION == 77 +#define dkr_probe_sdk2_pulse dkr_experimental_v77_sdk2_pulse +#else +#define dkr_probe_sdk2_pulse dkr_experimental_v80_sdk2_pulse +#endif +void dkr_probe_sdk2_pulse(uint8_t*,struct recomp_context*); void dkr_probe_authored_component_tick(uint8_t* ram, struct recomp_context* context); // Confirmed private game-to-game transaction resumes the saved guest stack. // Does not sample input again or rerun the suspended scene's CPU prefix. diff --git a/runtime-recomp/tests/replay_probe/probe_driver.cpp b/runtime-recomp/tests/replay_probe/probe_driver.cpp index cae18a22..2b5d1c73 100644 --- a/runtime-recomp/tests/replay_probe/probe_driver.cpp +++ b/runtime-recomp/tests/replay_probe/probe_driver.cpp @@ -1,3 +1,11 @@ +#include "../../src/game/observer_music.hpp" +#include "owned_observer.hpp" +#include "scene_load_work.hpp" +#include "../../src/game/resident_music.hpp" +#include "../../src/game/achievements.hpp" +#include +#include "custom_records.hpp" +namespace dkr::runtime::saves::records {void install(legacy::ModWorld&,std::uint8_t*);} #include "probe_driver.hpp" #include "owned_game.hpp" #include "rom_revision.hpp" @@ -7,9 +15,9 @@ #include "probe_input.h" #include "probe_pak.h" #include "probe_magic.h" -#include "netplay/experimental_eeprom.hpp" -#include "netplay/experimental_pak.hpp" -#include "netplay/experimental_magic_codes.hpp" +#include "../../src/game/netplay/experimental_eeprom.hpp" +#include "../../src/game/netplay/experimental_pak.hpp" +#include "../../src/game/netplay/experimental_magic_codes.hpp" #include "netplay/experimental_guest_memory.h" #include "netplay/experimental_checkpoint_hash.hpp" #include "netplay/experimental_performance.hpp" @@ -44,6 +52,8 @@ #include "funcs.h" #if DKR_PROBE_HAS_MOD_SERVICES #include "owned_mod_calls.hpp" +#include "owned_vehicle_menu.hpp" +#include "owned_vehicle_calls.inl" #endif namespace { @@ -192,6 +202,9 @@ class Component final : public SceneSimulation { mods_->select_course(id,carrier); }); code_->trophy_selector([this]{return mods_?mods_->selected_trophy():std::string{};}); + code_->sdk2_current_course([this]{return mods_?mods_->current_course():std::string{};}); + if(code_->sdk2_wants_assets())code_->sdk2_assets(mods_?mods_->asset_inventory():std::string{}); + code_->sdk2_development(boot.development); code_->attach(ram_,true);code_->commit(code_->stage(boot.code_bootstrap)); std::fprintf(stderr,"[rollback][code] packages=%zu identity=%s bootstrap-restored=1\n", boot.code->ordered.size(),boot.code->identity.c_str()); @@ -200,9 +213,15 @@ class Component final : public SceneSimulation { throw std::runtime_error("This owned CPU payload has no code-mod service boundary."); #endif } else if(!boot.code_bootstrap.empty())throw std::runtime_error("Code bootstrap has no frozen package identity."); +#if DKR_PROBE_HAS_MOD_SERVICES + // Disk data was frozen before native boot. Only the host has a seed; + // the client's canonical initial checkpoint comes from that host. + if(mods_)dkr::runtime::saves::records::install(*mods_,ram_.data()); +#endif } ~Component() { dkr_probe_scenery_observe(nullptr,nullptr);dkr_probe_input_configure(0); dkr_probe_bind_eeprom(nullptr);dkr_probe_bind_paks(nullptr);dkr_probe_bind_magic(nullptr); } std::span confirmed_save() const {return save_.confirmed_image();} + std::span confirmed_records() const {return confirmed_records_;} std::span confirmed_paks() const {return paks_.confirmed_images();} bool install_initial_paks(std::uint64_t epoch,std::span images,std::string& error) { if(!input_phases_ || epoch!=current_epoch_ || confirmed_count_ || audio_.next_frame || @@ -270,6 +289,9 @@ class Component final : public SceneSimulation { return std::uint8_t(host_only ? 1:owner_mask_ ? owner_mask_:15); } RestoreStep prepare_tick(std::uint64_t epoch,std::uint32_t frame,bool agreed,std::string& error) override { +#if DKR_PROBE_HAS_MOD_SERVICES + if(code_)code_->sdk2_frame_agreed(agreed); +#endif if(!menu_tick_active())return RestoreStep::Ready; if(epoch!=current_epoch_ || (agreed && frame!=confirmed_count_) || native_.pending_scene_site) { error="Menu tick has invalid owned admission.";return RestoreStep::Failed; @@ -312,7 +334,64 @@ class Component final : public SceneSimulation { #endif return true; } - bool restore(std::span input, std::string& error) override { + struct ConfirmedAdmission { Eeprom save;Paks paks;MagicCodes magic; }; + bool admit_observer(const dkr::runtime::spectators::Baseline& baseline,std::string& error) { + if(!baseline.state||baseline.schema!=contract().schema||baseline.state->size()!=contract().state_bytes|| + !baseline.next.epoch||baseline.next.frame==UINT32_MAX||!observer_retired_||*observer_retired_!=baseline.next) { + error="Confirmed observer admission has no matching retired owner/layout.";return false; + } + const auto bytes=std::span(*baseline.state); + dkr_probe_native_state incoming{};ComponentAudio audio{}; + std::memcpy(&incoming,bytes.data()+state_.snapshot_size(),sizeof(incoming)); + std::memcpy(&audio,bytes.data()+state_.snapshot_size()+sizeof(native_),sizeof(audio)); + if(incoming.pending_scene_site||incoming.scene_unload_phase||audio.next_frame!=baseline.next.frame|| + !valid_native(incoming)||!valid_audio(audio,0)) { + error="Observer baseline is not a complete confirmed gameplay boundary.";return false; + } + ConfirmedAdmission staged{save_,paks_,magic_}; + if(!staged.save.admit_confirmed(baseline.next.epoch,baseline.next.frame, + bytes.last(Eeprom::kCheckpointBytes+Paks::kCheckpointBytes).first(Eeprom::kCheckpointBytes))|| + !staged.paks.admit_confirmed(baseline.next.epoch,baseline.next.frame,bytes.last(Paks::kCheckpointBytes))|| + !staged.magic.admit_confirmed(baseline.next.epoch,baseline.next.frame,baseline.magic_actions, + bytes.subspan(state_.snapshot_size()+sizeof(native_)+sizeof(audio_),MagicCodes::kCheckpointBytes))) { + error="Observer baseline has inconsistent irreversible participants.";return false; + } + if(!restore_state(bytes,error,&staged))return false; + current_epoch_=baseline.next.epoch;confirmed_count_=baseline.next.frame;confirmed_scene_=0; + commits_.clear();achievement_frames_.clear();scenery_.reset(native_.presentation.scene); + published_preview_loads_=native_.preview_loads;observer_retired_.reset();return true; + } + dkr::runtime::spectators::Work retire_observer(dkr::runtime::spectators::Cursor target,std::string& error) { + using dkr::runtime::spectators::Work; + if(!presentation_||!target.epoch||target.epochepoch||(target.epoch==observer_retired_->epoch&&target.frameframe)) { + error="Observer retirement cannot move backwards.";return Work::Failed; + } + if(target.epoch!=observer_retired_->epoch)observer_epoch_advanced_=false; + observer_retired_=target; + } + if(!observer_retired_) { + if(!presentation_->retire_for_restore(current_epoch_,0)) { + error="Observer presentation generation refused retirement.";return Work::Failed; + } + observer_retired_=target;observer_epoch_advanced_=false; + } + if(!presentation_->quiescent())return Work::Pending; + if(target.epoch>current_epoch_&&!observer_epoch_advanced_) { + if(!presentation_->begin_epoch(target.epoch)) { + error="Observer presentation epoch could not advance after retirement.";return Work::Failed; + } + observer_epoch_advanced_=true; + } + return Work::Ready; + } + void observer_read_only(bool enabled) {observer_read_only_=enabled;achievement_frames_.clear();} + std::uint32_t observer_magic_actions()const {return magic_.confirmed_actions();} + bool restore(std::span input,std::string& error) override {return restore_state(input,error,nullptr);} + bool restore_state(std::span input,std::string& error,const ConfirmedAdmission* admission) { if (input.size() != contract().state_bytes) return false; dkr_probe_native_state value{}; std::memcpy(&value,input.data()+state_.snapshot_size(),sizeof(value)); @@ -321,9 +400,9 @@ class Component final : public SceneSimulation { ComponentAudio audio{}; if(audio_phases_) { std::memcpy(&audio,input.data()+state_.snapshot_size()+sizeof(native_),sizeof(audio)); - if(!valid_audio(audio)) return false; + if(!valid_audio(audio,admission?0:confirmed_count_)) return false; } - dkr_probe_input_state pads{}; auto staged_save=save_;auto staged_paks=paks_;auto staged_magic=magic_; + dkr_probe_input_state pads{}; auto staged_save=admission?admission->save:save_;auto staged_paks=admission?admission->paks:paks_;auto staged_magic=admission?admission->magic:magic_; if(input_phases_) { std::memcpy(&pads,input.data()+input.size()-Eeprom::kCheckpointBytes-Paks::kCheckpointBytes-sizeof(pads),sizeof(pads)); if(!valid_input(pads) || !staged_save.restore(input.last(Eeprom::kCheckpointBytes+Paks::kCheckpointBytes).first(Eeprom::kCheckpointBytes)) || @@ -339,7 +418,16 @@ class Component final : public SceneSimulation { const auto count=dkr::mods::be32(block,64); if(count>dkr::mods::CheckpointWriter::Budget || !std::all_of(block.begin()+68+count,block.end(),[](auto byte){return byte==0;}))return false; - staged_mods=mods_->stage_checkpoint(block.subspan(68,count)); + if(admission) { + // ConfirmedAdmission exists only after the authenticated + // baseline/layout and irreversible participants were checked. + // Keep the lease in staged_mods until the restore commits. + staged_mods=dkr::spectator_admission::prepare_verified_music([&] { + return mods_->stage_checkpoint(block.subspan(68,count)); + }); + } else { + staged_mods=mods_->stage_checkpoint(block.subspan(68,count)); + } }catch(const std::exception& e){error=e.what();return false;} if(code_)try { const auto block=input.subspan(code_offset(),code_bytes());const auto count=dkr::mods::be32(block,0); @@ -361,6 +449,11 @@ class Component final : public SceneSimulation { if(input_phases_) { input_=pads;save_=std::move(staged_save);paks_=std::move(staged_paks);magic_=std::move(staged_magic); } draw_events_.clear();retiring_.reset();preparing_tick_.reset();prepared_menu_tick_.reset();return true; } + bool development_command(std::uint64_t epoch,std::uint32_t frame,const dkr::sdk2::Command& command,std::string& error)override { + if(command.empty()){if(code_)code_->sdk2_command({});return true;} + if(!code_){error="No admitted code world for this development command.";return false;} + try{code_->sdk2_command(command);return true;}catch(const std::exception& e){error=e.what();return false;} + } bool tick(std::uint32_t frame, const FrameInputs& inputs, TickOutput& output, std::string& error) override { dkr_probe_boss_diagnostic_tick(current_epoch_,frame); if(audio_phases_ && (frame!=audio_.next_frame || frame==UINT32_MAX)) return false; @@ -400,12 +493,21 @@ class Component final : public SceneSimulation { dkr_probe_input_arguments(pads.data(),flags); dkr_probe_reset_poll_configure(1); } + bool sdk2_idle=false; +#if DKR_PROBE_HAS_MOD_SERVICES + if(code_) { + const auto control=run_private(dkr_probe_sdk2_pulse,ram_.data(),ram_.size(),&context_,20000000); + if(!control.completed){error=code_->error().empty()?"Development control boundary failed.":code_->error();return false;} + sdk2_idle=context_.r2==0; + } +#endif const auto previous_preview_loads=native_.preview_loads; const auto tick_started=std::chrono::steady_clock::now(); dkr_probe_result result; { performance::Scope timing(performance::Stage::GuestTick); - result = run_private(authored_cpu_ ? dkr_probe_authored_component_tick : mode_phases_ ? dkr_probe_mode_component_tick : water_phases_ ? dkr_probe_water_component_tick : dkr_probe_component_tick, + if(sdk2_idle){result={};result.completed=1;} + else result = run_private(authored_cpu_ ? dkr_probe_authored_component_tick : mode_phases_ ? dkr_probe_mode_component_tick : water_phases_ ? dkr_probe_water_component_tick : dkr_probe_component_tick, ram_.data(),ram_.size(),&context_,menu_tick ? 40000000:5000000); } dkr_probe_menu_tick_configure(0); @@ -460,8 +562,8 @@ class Component final : public SceneSimulation { // records every tick needlessly zeroed ~608 KiB even for small scenes. const auto observed_count=dkr_probe_draw_capture(nullptr,0); if(observed_count>DKR_OWNED_MAX_DRAW_EVENTS) {error="Owned draw metadata exceeded its bound.";return false;} - draw_events_.resize(observed_count); - const auto draw_count=dkr_probe_draw_capture(draw_events_.data(),unsigned(draw_events_.size())); + if(!sdk2_idle)draw_events_.resize(observed_count); + const auto draw_count=sdk2_idle?observed_count:dkr_probe_draw_capture(draw_events_.data(),unsigned(draw_events_.size())); if(draw_count!=observed_count) {error="Owned draw metadata changed during capture.";return false;} std::array effects; const auto count = dkr_probe_effects_capture(effects.data(),effects.size()); @@ -571,12 +673,32 @@ class Component final : public SceneSimulation { } #if DKR_PROBE_HAS_MOD_SERVICES if(code_)try { + prepend_effect_envelope(output.effects,{'D','K','D','T'},std::array{unsigned(output.effects.size()),frame,unsigned(!sdk2_idle)}); const auto logs=dkr::mods::code::encode_effects(code_->linked(),code_->effects()); const auto inner=unsigned(output.effects.size()); prepend_effect_envelope(output.effects,{'D','K','G','C'},std::array{inner,unsigned(logs.size())}); output.effects.insert(output.effects.end(),logs.begin(),logs.end()); }catch(const std::exception& e){error=e.what();return false;} #endif + if(authored_cpu_) { + auto records=dkr::runtime::saves::records::encode({}); +#if DKR_PROBE_HAS_MOD_SERVICES + if(mods_)records=dkr::runtime::saves::records::snapshot(*mods_,ram_.data()); +#endif + const auto inner=unsigned(output.effects.size()); + prepend_effect_envelope(output.effects,{'D','K','R','C'},std::array{inner,unsigned(records.size())}); + output.effects.insert(output.effects.end(),records.begin(),records.end()); + } + if(authored_cpu_&&!sdk2_idle) { + auto observed=dkr::runtime::achievements::capture(ram_.data(),ram_.size(),DKR_PROBE_REVISION); + observed.custom=observed.level>=65; +#if DKR_PROBE_HAS_MOD_SERVICES + if(mods_) {std::uint64_t custom=0;const std::uint64_t level=std::uint32_t(observed.level); + if(!mods_->dispatch("dkr_achievements_context",ram_.data(),&context_,&level,1,nullptr,0,custom))observed.custom=observed.custom||bool(custom);} +#endif + if(!observer_read_only_)achievement_frames_.insert_or_assign(frame,observed); + if(achievement_frames_.size()>1024)achievement_frames_.erase(achievement_frames_.begin()); + } return true; } bool commit(std::uint64_t epoch, std::uint32_t frame, std::span effects, @@ -584,6 +706,16 @@ class Component final : public SceneSimulation { if(input_phases_ && epoch!=current_epoch_)return false; if (frame != confirmed_count_ || frame==UINT32_MAX || (boundary && (!native_.pending_scene_site || confirmed_scene_))) return false; const auto original=effects; + std::span course_records; + if(authored_cpu_) { + if(effects.size()<12||!std::equal(effects.begin(),effects.begin()+4,"DKRC"))return false; + const auto inner=unsigned(dkr::runtime::saves::records::integer(effects,4,4)); + const auto length=unsigned(dkr::runtime::saves::records::integer(effects,8,4)); + if(inner>131072||length>20000||effects.size()!=12+inner+length)return false; + course_records=effects.subspan(12+inner,length); + if(!dkr::runtime::saves::records::valid(course_records))return false; + effects=effects.subspan(12,inner); + } #if DKR_PROBE_HAS_MOD_SERVICES std::vector code_logs; if(code_)try { @@ -594,6 +726,15 @@ class Component final : public SceneSimulation { code_logs=dkr::mods::code::decode_effects(code_->linked(),effects.subspan(12+inner,logs)); effects=effects.subspan(12,inner); }catch(const std::exception& e){error=e.what();return false;} +#endif + bool has_development_journal=false; +#if DKR_PROBE_HAS_MOD_SERVICES + if(code_) { + if(effects.size()<16||!std::equal(effects.begin(),effects.begin()+4,"DKDT"))return false; + const auto control_word=[&](unsigned offset){std::uint32_t value=0;for(unsigned b=0;b<4;++b)value|=std::uint32_t(effects[offset+b])<<(b*8);return value;}; + if(control_word(8)!=frame||control_word(12)>1||control_word(4)!=effects.size()-16)return false; + has_development_journal=true;effects=effects.subspan(16); + } #endif if(authored_cpu_ && !boundary) { if(effects.size()<56 || !std::equal(effects.begin(),effects.begin()+4,"DKGV"))return false; @@ -604,7 +745,8 @@ class Component final : public SceneSimulation { (word(20)-word(16))%8 || word(20)-word(16)<16 || word(24)<0x80000000U || word(24)>0x807DA800U || (word(24)&7) || word(28)<0x80000000U || word(28)>0x807DA800U || (word(28)&7) || - word(32)>1 || word(36)!=frame+1)return false; + word(32)>1 || !word(36) || word(36)>frame+1)return false; + if(!has_development_journal&&word(36)!=frame+1)return false; // Zero explicitly denotes omitted private evidence in production. // All descriptor/journal validation above and checkpoint hashes // in Driver remain mandatory; never accept hidden proof bytes. @@ -651,7 +793,12 @@ class Component final : public SceneSimulation { if(boundary) confirmed_scene_=native_.pending_scene_site; if(retain_proofs_)commits_.emplace_back(original.begin(),original.end()); if(input_phases_) {save_=std::move(staged_save);paks_=std::move(staged_paks);magic_=std::move(staged_magic);} + confirmed_records_.assign(course_records.begin(),course_records.end()); ++confirmed_count_; + if(const auto observation=achievement_frames_.find(frame);observation!=achievement_frames_.end()) { + if(scene_sessions_&&!observer_read_only_)dkr::runtime::achievements::submit(observation->second,epoch,frame); + achievement_frames_.erase(achievement_frames_.begin(),std::next(observation)); + } #if DKR_PROBE_HAS_MOD_SERVICES for(const auto& log:code_logs)std::fprintf(stderr,"[custom-magic-codes][%s] %s\n",log.package.c_str(),log.text.c_str()); #endif @@ -678,6 +825,7 @@ class Component final : public SceneSimulation { assert(authored_cpu_ && input_phases_);scene_sessions_=true;presentation_=&box; retain_proofs_=false;audio_sink_=std::move(sink);independent_restore_=independent_restore; scenery_.reset(native_.presentation.scene); + achievement_frames_.clear(); dkr_probe_scenery_observe([](void* user,const std::uint8_t* ram,std::uint32_t object,std::uint32_t scene,std::uint32_t table) { auto& capture=static_cast(user)->scenery_; if(!object)capture.reset(scene); @@ -851,6 +999,7 @@ class Component final : public SceneSimulation { dkr_probe_audio_guards_restore(audio_.guards.data());dkr_probe_effects_begin(); const auto stack=context_.r29; context_.r4=map;context_.r5=players_minus_one;context_.r6=0;context_.r7=0; + dkr::mods::online::resident_music::PreparationScope music_loading; const auto result=run_private(dkr_probe_scene_load_cpu,ram_.data(),ram_.size(),&context_,20000000); native_=dkr_probe_native_capture();input_=dkr_probe_input_capture(); dkr_probe_audio_guards_capture(audio_.guards.data()); @@ -911,10 +1060,16 @@ class Component final : public SceneSimulation { background_context.r29=std::int32_t(DKR_OWNED_PREVIEW_STACK_TOP); const auto loaded_site=confirmed_scene_; const auto loader_started=std::chrono::steady_clock::now(); + dkr::mods::online::resident_music::PreparationScope music_loading(true,!background); + const auto scene_work_limit=dkr_scene_work::limit(background); const auto result=run_private(background ? dkr_probe_menu_background_resume : dkr_probe_authored_scene_resume, - ram_.data(),ram_.size(),background ? &background_context:&context_,40000000); + ram_.data(),ram_.size(),background ? &background_context:&context_,scene_work_limit); const auto loader_us=std::chrono::duration_cast( std::chrono::steady_clock::now()-loader_started).count(); + std::fprintf(stderr,"[rollback][scene-work] scene-load-work-v1 site=%u background=%u budget=%llu used=%llu memory-ops=%llu entries=%llu elapsed-us=%lld completed=%u\n", + loaded_site,unsigned(background),static_cast(scene_work_limit), + static_cast(result.operations),static_cast(result.memory_accesses), + static_cast(result.guest_entries),static_cast(loader_us),unsigned(result.completed)); native_=dkr_probe_native_capture();input_=dkr_probe_input_capture(); dkr_probe_audio_guards_capture(audio_.guards.data()); std::array motors{};const auto motor_count=dkr_probe_motor_events(motors.data()); @@ -922,6 +1077,8 @@ class Component final : public SceneSimulation { if(!result.completed || native_.pending_scene_site || !valid_native(native_) || context_.r29!=stack+stack_delta || !valid_input(input_) || native_.video_frames!=frame) { error=result.blocked ? result.blocked:"Incomplete confirmed suspended frame"; + if(result.blocked&&std::strcmp(result.blocked,"operation-budget")==0) + error+=" (scene loading used its "+std::to_string(scene_work_limit)+" operation allowance)"; std::cerr<<"Actual scene resumption fenced: "<sdk2_dispatch_pulse():1; + if(status<0)return -1;*result=unsigned(status);return 1; + } if(std::strcmp(name,"dkr_code_enter")==0||std::strcmp(name,"dkr_code_leave")==0||std::strcmp(name,"dkr_code_event")==0||std::strcmp(name,"dkr_code_indirect")==0||std::strcmp(name,"dkr_code_lap_address")==0) { if(!world.code_||count!=1||!args||args[0]>UINT32_MAX)return -1; if(std::strcmp(name,"dkr_code_event")==0){*result=0;return world.code_->dispatch_event(unsigned(args[0]),ram,ctx);} @@ -1164,6 +1332,7 @@ class Component final : public SceneSimulation { RuntimeState state_; recomp_context context_{}; recomp_context audio_context_{}; + std::map achievement_frames_; std::vector ram_; dkr_probe_native_state native_{}; std::uint32_t owner_mask_=0; @@ -1172,6 +1341,8 @@ class Component final : public SceneSimulation { std::vector draw_events_; // Derived anew by each completed tick, never a live registry. LocalSceneryCapture scenery_; // Immutable visuals; NEVER checkpoint/network state. std::uint32_t reported_scenery_scene_=UINT32_MAX; + bool observer_read_only_=false,observer_epoch_advanced_=false; + std::optional observer_retired_; std::uint32_t confirmed_count_=0; // Non-rewinding, independent of test proof retention. std::uint32_t published_preview_loads_=0; // Presentation-only cursor; never captured or used for CPU admission. bool retain_proofs_=true; @@ -1191,6 +1362,7 @@ class Component final : public SceneSimulation { ComponentAudio audio_{}; unsigned audio_rate_=0; unsigned confirmed_scene_=0; + dkr::runtime::saves::records::Bytes confirmed_records_; std::uint64_t current_epoch_=0; // Irreversible owner cursor, not speculative RAM. bool scene_sessions_=false; std::vector> preparations_; // At most two PRIVATE proofs. @@ -1241,8 +1413,14 @@ class OwnedAdapter final : public OwnedGame { if(!ready)throw std::runtime_error("Retail Title preparation did not reach its confirmed boundary."); } world_.configure_owned_output(box,std::move(sink),true); - } - ~OwnedAdapter() {dkr_probe_rom(nullptr,0);} + using namespace dkr::runtime::spectators; + register_owned_observer(static_cast(this),{this, + [](void* p,Cursor at,std::string& e){auto& self=*static_cast(p);return self.check(e)?self.world_.retire_observer(at,e):Work::Failed;}, + [](void* p,const Baseline& b,std::string& e){auto& self=*static_cast(p);return self.check(e)&&self.world_.admit_observer(b,e);}, + [](void* p){return static_cast(p)->world_.observer_magic_actions();}, + [](void* p,bool value){static_cast(p)->world_.observer_read_only(value);}}); + } + ~OwnedAdapter() {dkr::runtime::spectators::unregister_owned_observer(static_cast(this));dkr_probe_rom(nullptr,0);} OwnedSceneView scene_view() const override { // This view is copied by the simulation owner before passing UI status // through its existing mutex. The UI cannot read live guest RAM. @@ -1260,6 +1438,9 @@ class OwnedAdapter final : public OwnedGame { RestoreStep prepare_restore(std::uint64_t epoch,std::uint32_t first,std::string& error) override { return check(error)?world_.prepare_restore(epoch,first,error):RestoreStep::Failed; } + bool development_command(std::uint64_t epoch,std::uint32_t frame,const dkr::sdk2::Command& command,std::string& error)override { + return check(error)&&world_.development_command(epoch,frame,command,error); + } bool tick(std::uint32_t frame,const FrameInputs& inputs,TickOutput& output,std::string& error) override { return check(error) && world_.tick(frame,inputs,output,error); } @@ -1277,6 +1458,7 @@ class OwnedAdapter final : public OwnedGame { bool admit_initial(std::span state,std::string& error) override {return check(error) && world_.admit_initial(state,error);} std::uint32_t confirmed_count() const override {return world_.confirmed_count();} std::span confirmed_save() const override {return world_.confirmed_save();} + std::span confirmed_records() const override {return world_.confirmed_records();} std::span confirmed_paks() const override {return world_.confirmed_paks();} bool install_initial_paks(std::uint64_t epoch,std::span images,std::string& error) override {return check(error)&&world_.install_initial_paks(epoch,images,error);} void set_confirmed_rumble_sink(ConfirmedRumbleSink sink) override {if(std::this_thread::get_id()==owner_)world_.set_confirmed_rumble_sink(std::move(sink));} diff --git a/runtime-recomp/tests/replay_probe/probe_eeprom.cpp b/runtime-recomp/tests/replay_probe/probe_eeprom.cpp index 8c1d8605..4366f824 100644 --- a/runtime-recomp/tests/replay_probe/probe_eeprom.cpp +++ b/runtime-recomp/tests/replay_probe/probe_eeprom.cpp @@ -1,5 +1,5 @@ #include "probe_bridge.h" -#include "netplay/experimental_eeprom.hpp" +#include "../../src/game/netplay/experimental_eeprom.hpp" namespace { dkr::runtime::netplay::experimental::Eeprom* owned=nullptr; } extern "C" void dkr_probe_bind_eeprom(void* save) { owned=static_cast(save); } extern "C" int dkr_probe_eeprom_enabled(void) { diff --git a/runtime-recomp/tests/replay_probe/probe_input.cpp b/runtime-recomp/tests/replay_probe/probe_input.cpp index cf900ab2..776eef75 100644 --- a/runtime-recomp/tests/replay_probe/probe_input.cpp +++ b/runtime-recomp/tests/replay_probe/probe_input.cpp @@ -1,6 +1,6 @@ #include "probe_input.h" #include "recomp.h" -#include "netplay/experimental_eeprom.hpp" +#include "../../src/game/netplay/experimental_eeprom.hpp" #include "netplay/experimental_rollback.hpp" #include "netplay/replay_qualification.hpp" #include "netplay/runtime_state.hpp" diff --git a/runtime-recomp/tests/replay_probe/probe_magic.cpp b/runtime-recomp/tests/replay_probe/probe_magic.cpp index a0689dd8..6aadab93 100644 --- a/runtime-recomp/tests/replay_probe/probe_magic.cpp +++ b/runtime-recomp/tests/replay_probe/probe_magic.cpp @@ -1,5 +1,5 @@ #include "probe_magic.h" -#include "netplay/experimental_magic_codes.hpp" +#include "../../src/game/netplay/experimental_magic_codes.hpp" #include "revision_addresses.hpp" namespace {dkr::runtime::netplay::experimental::MagicCodes* owner=nullptr;} extern "C" void dkr_probe_bind_magic(void* value){owner=static_cast(value);} diff --git a/runtime-recomp/tests/replay_probe/probe_native.c b/runtime-recomp/tests/replay_probe/probe_native.c index 32febb94..3d846c47 100644 --- a/runtime-recomp/tests/replay_probe/probe_native.c +++ b/runtime-recomp/tests/replay_probe/probe_native.c @@ -1,3 +1,4 @@ +#include "../../src/game/primary_heap.h" #include "recomp.h" #include "funcs.h" #include "probe_bridge.h" @@ -24,7 +25,7 @@ static int active_menu_tick; static int inline_menu_preview; static dkr_owned_draw_event draw_events[DKR_OWNED_MAX_DRAW_EVENTS]; static unsigned draw_count; -static dkr_owned_draw_event local_models[128],local_objects[512]; +static dkr_owned_draw_event local_models[256],local_objects[512]; static unsigned local_model_count,local_object_count; static int local_model_overflow,local_object_overflow,local_flushed; static int draw_invalid; @@ -55,7 +56,7 @@ static void local_emit(const dkr_owned_draw_event* event) { if(!dkr_owned_draw_event_valid(event))return; if(event->kind==DKR_OWNED_LOCAL_MODEL) { if(local_model_overflow)return; - if(local_model_count==128){local_model_overflow=1;local_model_count=0;return;} + if(local_model_count==256){local_model_overflow=1;local_model_count=0;return;} local_models[local_model_count++]=*event; } else if(event->kind==DKR_OWNED_LOCAL_OBJECT) { if(local_object_overflow)return; @@ -755,7 +756,10 @@ void dkr_probe_scene_load_reset(uint8_t* rdram,recomp_context* ctx) { dkr_probe_block("unowned-confirmed-scene-reset"); uint64_t ignored=0; dkr_probe_mod_dispatch("dkr_legacy_scene_begin",rdram,ctx,0,0,0,0,&ignored); - dkr_probe_mod_dispatch("dkr_owned_course_prepare",rdram,ctx,0,0,0,0,&ignored); + // Inline Track Select previews are confirmed but silent, just like + // deferred menu backgrounds. This does not authorise an audio decoder. + const uint64_t silent_music_preview=inline_track_preview(rdram) && confirmed_menu_tick; + dkr_probe_mod_dispatch("dkr_owned_course_prepare",rdram,ctx,&silent_music_preview,1,0,0,&ignored); // Accurate/4:3 private world has no live interpolation registry and every // reversible drawing scope is balanced. No mod session exists here. The // Scene reset clears diagnostic/work counters, not event-pool ownership. @@ -977,6 +981,9 @@ static int owned_presentation_hook(const char* name,uint8_t* rdram,recomp_contex return 1; } if(strcmp(name,"dkr_split_screen_world_aspect_end")==0) { + uint32_t camera_addresses[8];dkr_probe_presentation_addresses(camera_addresses); + draw_marker(rdram,ctx->r17,DKR_OWNED_CAMERA_WORLD_END, + (uint32_t)MEM_W(0,guest_address(camera_addresses[7]))&3U); dkr_probe_online_racers(rdram,(uint32_t)ctx->r17,parity_emit); if(framed_scope)draw_marker(rdram,ctx->r17,DKR_OWNED_FRAMED_END,0); if(split_world_scope)draw_marker(rdram,ctx->r17,DKR_OWNED_SPLIT_WORLD_END,0); @@ -985,8 +992,11 @@ static int owned_presentation_hook(const char* name,uint8_t* rdram,recomp_contex if(strcmp(name,"dkr_track_select_lens_flare_tint_begin")==0) { uint32_t a[8];dkr_probe_presentation_addresses(a);int32_t b[8]; for(unsigned i=0;i<4;++i) {b[i]=MEM_W(i*4,guest_address(a[0]));b[i+4]=MEM_W(0x20+i*4,guest_address(a[0]));} - lens_scope=dkr_probe_lens_scope(b,MEM_W(0,guest_address(a[6]))); - if(lens_scope)draw_marker(rdram,ctx->r17,DKR_OWNED_LENS_BEGIN,0); + const int postrace=dkr_probe_postrace_scope(MEM_W(0,guest_address(a[1])), + (int8_t)MEM_B(0,guest_address(a[2])),MEM_W(0,guest_address(a[3])), + MEM_W(0,guest_address(a[4])),MEM_W(0,guest_address(a[5]))); + lens_scope=postrace||dkr_probe_lens_scope(b,MEM_W(0,guest_address(a[6]))); + if(lens_scope)draw_marker(rdram,ctx->r17,DKR_OWNED_LENS_BEGIN,postrace&&!state.postrace_contracted?1U:0U); return 1; } if(strcmp(name,"dkr_track_select_lens_flare_tint_end")==0) { @@ -1103,6 +1113,12 @@ int dkr_probe_native(const char* name, uint8_t* ram, struct recomp_context* cont dkr_probe_checkpoint(); uint64_t mod_result=0; if(dkr_probe_mod_dispatch(name,ram,context,0,0,0,0,&mod_result))return (int)mod_result; + // No asset world (vanilla/code-only): same known-pool policy. + // A handled custom admission above always keeps precedence. + if(strcmp(name,"dkr_legacy_heap_capacity")==0) { + context->r15=(int32_t)dkr_primary_heap_end((uint32_t)context->r15,0x800000U); + return 0; + } /* modern_camera.cpp: the local Modern camera never moves an owned or replayed frame, so there is no pose to hand back here. */ if(strcmp(name,"dkr_modern_camera_restore")==0)return 0; diff --git a/runtime-recomp/tests/replay_probe/probe_pak.cpp b/runtime-recomp/tests/replay_probe/probe_pak.cpp index 126e0561..30c91800 100644 --- a/runtime-recomp/tests/replay_probe/probe_pak.cpp +++ b/runtime-recomp/tests/replay_probe/probe_pak.cpp @@ -1,5 +1,5 @@ #include "probe_pak.h" -#include "netplay/experimental_pak.hpp" +#include "../../src/game/netplay/experimental_pak.hpp" #include namespace { dkr::runtime::netplay::experimental::Paks* owned=nullptr; } extern "C" void dkr_probe_bind_paks(void* paks) { owned=static_cast(paks); } diff --git a/scripts/fold/released-bases.txt b/scripts/fold/released-bases.txt new file mode 100644 index 00000000..127d432b --- /dev/null +++ b/scripts/fold/released-bases.txt @@ -0,0 +1,99 @@ +runtime-recomp/src/game/camera_obstruction.cpp +runtime-recomp/src/game/enhanced_water_registry.hpp +runtime-recomp/src/game/enhanced_water_rt64.hpp +runtime-recomp/src/game/f3ddkr_rt64.cpp +runtime-recomp/src/game/f3ddkr_rt64.hpp +runtime-recomp/src/game/game_main.cpp +runtime-recomp/src/game/game_registration.cpp +runtime-recomp/src/game/local_model_rt64.cpp +runtime-recomp/src/game/mods/legacy_asset_bus.cpp +runtime-recomp/src/game/mods/legacy_character_materialize.cpp +runtime-recomp/src/game/mods/legacy_character_materialize.hpp +runtime-recomp/src/game/mods/legacy_mod_format.cpp +runtime-recomp/src/game/mods/legacy_mod_launch.cpp +runtime-recomp/src/game/mods/legacy_track_catalog.cpp +runtime-recomp/src/game/mods/legacy_track_prepare_job.cpp +runtime-recomp/src/game/mods/legacy_track_prepare_job.hpp +runtime-recomp/src/game/mods/magic_code_game_world.cpp +runtime-recomp/src/game/mods/magic_code_game_world.hpp +runtime-recomp/src/game/mods/magic_code_library.cpp +runtime-recomp/src/game/mods/magic_code_library.hpp +runtime-recomp/src/game/mods/magic_code_link.cpp +runtime-recomp/src/game/mods/magic_code_package.cpp +runtime-recomp/src/game/mods/magic_code_package.hpp +runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp +runtime-recomp/src/game/mods/magic_code_trophy_catalog.hpp +runtime-recomp/src/game/mods/magic_code_vm.cpp +runtime-recomp/src/game/mods/magic_code_world.cpp +runtime-recomp/src/game/mods/magic_code_world.hpp +runtime-recomp/src/game/mods/online_mod_manifest.cpp +runtime-recomp/src/game/mods/online_mod_prepare.cpp +runtime-recomp/src/game/mods/online_mod_profile.cpp +runtime-recomp/src/game/mods/online_mod_runtime.cpp +runtime-recomp/src/game/mods/online_mod_sync.cpp +runtime-recomp/src/game/netplay/direct_session.cpp +runtime-recomp/src/game/netplay/experimental_confirmed_output.hpp +runtime-recomp/src/game/netplay/experimental_draw_events.h +runtime-recomp/src/game/netplay/experimental_eeprom.hpp +runtime-recomp/src/game/netplay/experimental_lobby_admission.cpp +runtime-recomp/src/game/netplay/experimental_magic_codes.hpp +runtime-recomp/src/game/netplay/experimental_network.cpp +runtime-recomp/src/game/netplay/experimental_network.hpp +runtime-recomp/src/game/netplay/experimental_owner_inputs.cpp +runtime-recomp/src/game/netplay/experimental_owner_inputs.hpp +runtime-recomp/src/game/netplay/experimental_pak.hpp +runtime-recomp/src/game/netplay/experimental_pump.cpp +runtime-recomp/src/game/netplay/experimental_pump.hpp +runtime-recomp/src/game/netplay/experimental_rollback.cpp +runtime-recomp/src/game/netplay/experimental_rollback.hpp +runtime-recomp/src/game/netplay/experimental_runtime.cpp +runtime-recomp/src/game/netplay/experimental_session.cpp +runtime-recomp/src/game/netplay/experimental_session.hpp +runtime-recomp/src/game/netplay/experimental_timeline.cpp +runtime-recomp/src/game/netplay/experimental_timeline.hpp +runtime-recomp/src/game/netplay/netplay_protocol.cpp +runtime-recomp/src/game/netplay/netplay_types.cpp +runtime-recomp/src/game/netplay/netplay_types.hpp +runtime-recomp/src/game/netplay/online_mod_admission.cpp +runtime-recomp/src/game/netplay/online_mod_sync_link.hpp +runtime-recomp/src/game/netplay/quick_join_transport.cpp +runtime-recomp/src/game/online_view_rt64.cpp +runtime-recomp/src/game/presentation_identity.cpp +runtime-recomp/src/game/presentation_identity.hpp +runtime-recomp/src/game/presentation_marker_policy.hpp +runtime-recomp/src/game/replay_probe_capture.cpp +runtime-recomp/src/game/rt64_renderer.cpp +runtime-recomp/src/game/rt64_renderer.hpp +runtime-recomp/src/game/runtime_camera_tuning_ui.inl +runtime-recomp/src/game/runtime_code_mods_ui.inl +runtime-recomp/src/game/runtime_details_ui.inl +runtime-recomp/src/game/runtime_enhancements.cpp +runtime-recomp/src/game/runtime_hud_layout.cpp +runtime-recomp/src/game/runtime_input.cpp +runtime-recomp/src/game/runtime_input.hpp +runtime-recomp/src/game/runtime_legacy_mods.cpp +runtime-recomp/src/game/runtime_legacy_mods.hpp +runtime-recomp/src/game/runtime_mod_library_ui.inl +runtime-recomp/src/game/runtime_netplay.cpp +runtime-recomp/src/game/runtime_online_mods_ui.inl +runtime-recomp/src/game/runtime_online_ui.inl +runtime-recomp/src/game/runtime_platform.cpp +runtime-recomp/src/game/runtime_rice_texture_import.cpp +runtime-recomp/src/game/runtime_save_routing.cpp +runtime-recomp/src/game/runtime_save_routing.hpp +runtime-recomp/src/game/runtime_saves_ui.inl +runtime-recomp/src/game/runtime_stubs.cpp +runtime-recomp/src/game/runtime_texture_packs.cpp +runtime-recomp/src/game/runtime_ui.cpp +runtime-recomp/src/game/save_manager.cpp +runtime-recomp/src/game/save_manager.hpp +runtime-recomp/src/game/visual_model_kernel.c +runtime-recomp/tests/replay_probe/owned_game.hpp +runtime-recomp/tests/replay_probe/probe_component.c +runtime-recomp/tests/replay_probe/probe_component.h +runtime-recomp/tests/replay_probe/probe_driver.cpp +runtime-recomp/tests/replay_probe/probe_eeprom.cpp +runtime-recomp/tests/replay_probe/probe_input.cpp +runtime-recomp/tests/replay_probe/probe_magic.cpp +runtime-recomp/tests/replay_probe/probe_native.c +runtime-recomp/tests/replay_probe/probe_pak.cpp From 0c868ab2439e0ff6731988e18577f3e394312875 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 18:58:54 -0500 Subject: [PATCH 03/62] Add the fold equivalence gate (levels 1 to 3) run_equivalence.sh configures this tree with the reference's options and no overlay, then compares compile settings and link lines, header closures and bytes, preprocessed tokens, and header-variant objects. --- scripts/fold/allowed-deltas.json | 22 + scripts/fold/equivalence.py | 944 +++++++++++++++++++++++++++++++ scripts/fold/run_equivalence.sh | 84 +++ 3 files changed, 1050 insertions(+) create mode 100644 scripts/fold/allowed-deltas.json create mode 100644 scripts/fold/equivalence.py create mode 100755 scripts/fold/run_equivalence.sh diff --git a/scripts/fold/allowed-deltas.json b/scripts/fold/allowed-deltas.json new file mode 100644 index 00000000..f7b3b9a8 --- /dev/null +++ b/scripts/fold/allowed-deltas.json @@ -0,0 +1,22 @@ +{ + "schema": 1, + "about": "Differences between the folded build and the v1.0.11 reference that scripts/fold/equivalence.py reports and that are explained. Each entry quotes the exact difference line; anything not listed fails.", + "deltas": [ + { + "difference": "DKRPortGame: runtime-recomp/src/game/game_main.cpp: include dirs lost ['runtime-recomp/src'] extra []", + "reason": "A layer re-added the parent of game_main.cpp's staged directory (prior/..). No include in the TU or its headers resolves through runtime-recomp/src: dropping it from the reference include path leaves all 233 resolved includes unchanged." + }, + { + "difference": "DKRPortGame: runtime-recomp/src/game/runtime_legacy_mods.cpp: include dirs lost ['/generated'] extra []", + "reason": "The parent of a layer's generated stage directory (prior/..). No include in the TU or its headers resolves through it: dropping it leaves all 166 resolved includes unchanged." + }, + { + "difference": "DKRPortGame: runtime-recomp/src/game/runtime_ui.cpp: include dirs lost ['/generated'] extra []", + "reason": "The parent of a layer's generated stage directory (prior/..). No include in the TU or its headers resolves through it: dropping it leaves all 842 resolved includes unchanged." + }, + { + "difference": "DKRPortGame: runtime-recomp/src/game/rt64_renderer.cpp: object sections differ: data .eh_frame, data .group", + "reason": "Header variant mobile_graphics_preset.hpp (PR 68): its new inline helpers instantiate GraphicsConfig's destructor earlier, so that COMDAT is emitted first. All 79 sections exist on both sides and every text section and relocation is identical; only the unwind-table entry order (.eh_frame) and the COMDAT group section indices (.group) follow the new emission order." + } + ] +} diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py new file mode 100644 index 00000000..ed44154a --- /dev/null +++ b/scripts/fold/equivalence.py @@ -0,0 +1,944 @@ +#!/usr/bin/env python3 +"""Compare a folded build of runtime-recomp/ with the v1.0.11 reference. + +Fold plan 2.4. The reference is the materialized release chain +(scripts/fold/apply_closure.py describes it); the folded build is a plain +configure of this tree with the same options and no overlay. Paths are +compared through the same mapping apply_closure.py used to write the files, +so a staged path and its folded path count as equal. + +Subcommands (run_equivalence.sh drives them; the ones that compile run in +the build image): + + options the reference's configure options for the folded tree + shipped the compile commands behind the four shipped binaries + depscan the headers each shipped TU reads (-M -MG) + preprocess -E -P output of each shipped TU + targets per-source compile settings (CMake File API codemodel of both + configures: language, defines, include directories, compile + fragments), link lines and target dependencies + level1 the same shipped TUs, the same header closure per TU, and the + same bytes per file once #include spellings, line endings and + the one absolute define apply_closure.py makes portable are + set aside + level2 preprocessed tokens (file path strings normalized) and compile + flags of every shipped first-party TU + level3 objects of the TUs level 2 lets differ for a header variant, + compiled from path-normalized preprocessed output + +Differences that are expected are listed with their reason and do not fail: +the TUs scripts/fold/header-variants.json names (which then need level 3), +the online identity values fold step B7 freezes, the TUs that belong to later +fold steps (dependency patches B3, guest hooks B4), and the entries of +scripts/fold/allowed-deltas.json. Anything else fails. + +Exit status: 0 no unexplained differences, 1 unexplained differences, +2 usage errors. +""" + +from __future__ import annotations + +import argparse +from collections import defaultdict +from collections.abc import Iterable, Iterator +from concurrent.futures import ThreadPoolExecutor +import dataclasses +import difflib +import functools +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys +from typing import cast + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import apply_closure # noqa: E402 + +ROOT = apply_closure.ROOT +ALLOWED = ROOT / "scripts/fold/allowed-deltas.json" +BINARIES = ( + "bin/Release/DKR-R", + "bin/Release/libexec/dkr-r/DKR-R-ModWorker", + "bin/Release/libexec/dkr-r/DKR-R-Updater", + "bin/Release/libexec/dkr-r/DKR-R-InputHost", +) +# Online identity: fold step B7 replaces these computed values with the +# v1.0.11 constants, so until then they differ by design. +IDENTITY_DEFINES = re.compile(r"^(DKR_OWNED_BUILD_ID(_V\d+)?|DKR_ENGINE_IDENTITY|DKR_LOBBY_STABILITY_CONTRACT)=") +IDENTITY_DEFINE_FLAG = re.compile(r"^-D(DKR_OWNED_BUILD_ID(_V\d+)?|DKR_ENGINE_IDENTITY|DKR_LOBBY_STABILITY_CONTRACT)=") + + +@dataclasses.dataclass(frozen=True) +class Group: + language: str + defines: tuple[str, ...] + includes: tuple[str, ...] + fragments: tuple[str, ...] + + +@dataclasses.dataclass +class Model: + source_dir: Path + build_dir: Path + targets: dict[str, dict[Path, Group]] # target -> source -> compile settings + links: dict[str, tuple[str, ...]] # executable -> link command fragments + dependencies: dict[str, frozenset[str]] # target -> targets it depends on + + +def load_codemodel(reply: Path) -> Model: + index = json.loads(sorted(reply.glob("index-*.json"))[-1].read_text(encoding="utf-8")) + codemodel = json.loads((reply / index["reply"]["codemodel-v2"]["jsonFile"]).read_text(encoding="utf-8")) + source_dir = Path(codemodel["paths"]["source"]) + build_dir = Path(codemodel["paths"]["build"]) + targets: dict[str, dict[Path, Group]] = {} + links: dict[str, tuple[str, ...]] = {} + dependencies: dict[str, frozenset[str]] = {} + for entry in codemodel["configurations"][0]["targets"]: + target = json.loads((reply / entry["jsonFile"]).read_text(encoding="utf-8")) + if "link" in target: + links[target["name"]] = tuple(f["fragment"] for f in target["link"]["commandFragments"]) + dependencies[target["name"]] = frozenset(d["id"].split("::")[0] for d in target.get("dependencies", [])) + groups = [ + Group( + language=group["language"], + defines=tuple(sorted(d["define"] for d in group.get("defines", []))), + includes=tuple(str(apply_closure.norm(i["path"])) for i in group.get("includes", [])), + fragments=tuple(f["fragment"] for f in group.get("compileCommandFragments", [])), + ) + for group in target.get("compileGroups", []) + ] + sources: dict[Path, Group] = {} + for source in target.get("sources", []): + if "compileGroupIndex" not in source: + continue + path = Path(source["path"]) + sources[apply_closure.norm(path if path.is_absolute() else source_dir / path)] = groups[ + source["compileGroupIndex"] + ] + targets[target["name"]] = sources + return Model(source_dir, build_dir, targets, links, dependencies) + + +class Mapping: + """Reference paths in folded-tree terms (apply_closure.Folder plus the build directory).""" + + def __init__(self, reference_dir: Path, folded_build: Path) -> None: + self.reference = apply_closure.load_reference(reference_dir, ROOT) + result = apply_closure.plan(self.reference, ROOT) + if result.problems: + raise SystemExit("equivalence: apply_closure.py --check fails; fix that first") + self.folder = apply_closure.Folder(self.reference, ROOT, result.destinations) + self.folded_build = folded_build + self.result = result + self.aliases = self.build_aliases() + + def build_aliases(self) -> dict[str, str]: + """Build directories named after a content hash whose inputs include + script bytes (code-dispatch: scripts/stage_magic_code_payload.py, which + the tooling commits reformatted). Paired by revision; the run script + checks that each pair holds the same files.""" + aliases: dict[str, str] = {} + for revision in ("77", "80"): + old = sorted((self.reference.build / "generated").glob(f"code-dispatch-v{revision}-*")) + new = sorted((self.folded_build / "generated").glob(f"code-dispatch-v{revision}-*")) + if len(old) == 1 and len(new) == 1: + aliases[old[0].name] = new[0].name + return aliases + + def build_path(self, path: Path) -> Path: + parts = list(path.relative_to(self.reference.build).parts) + if len(parts) > 1 and parts[0] == "generated" and parts[1] in self.aliases: + parts[1] = self.aliases[parts[1]] + return self.folded_build.joinpath(*parts) + + def file(self, path: Path) -> Path | None: + folded = self.folder.file(path) + if folded is not None and folded.is_relative_to(self.reference.build): + return self.build_path(folded) + return folded + + def directory(self, path: Path) -> Path | None: + folded = self.folder.directory(path) + if folded is not None and folded.is_relative_to(self.reference.build): + return self.build_path(folded) + return folded + + def text(self, value: str) -> str: + """A flag or define with reference paths rewritten.""" + out = value + for old, new in self.aliases.items(): + out = out.replace(f"/generated/{old}", f"/generated/{new}") + out = out.replace(str(self.reference.build) + "/", str(self.folded_build) + "/") + return out.replace(str(self.reference.probe / "repo") + "/", str(ROOT) + "/") + + def deferred(self, path: Path) -> str | None: + """Why a reference TU has no folded counterpart yet, or None.""" + layer = self.folder.layer_of(path) + rows = {row.staged: row for row in self.reference.rows} + row = rows.get(path) + if row is not None and row.kind == "dependency-transform": + return "dependency transform (fold step B3)" + if row is not None and row.kind in ("guest-transform", "guest-derived"): + return "guest code (fold step B4)" + if layer is not None: + return f"staged by layer {layer} (fold step B3 or B4)" + return None + + +def normalized_includes(mapping: Mapping, includes: Iterable[str]) -> tuple[str, ...]: + out: list[str] = [] + for include in includes: + folded = mapping.directory(apply_closure.norm(include)) + if folded is not None and str(folded) not in out: + out.append(str(folded)) + return tuple(out) + + +@functools.cache +def headers_under(directory: str) -> frozenset[str]: + """Relative paths of the headers an #include could name under a directory.""" + found: set[str] = set() + for parent, _, files in os.walk(directory): + for name in files: + if name.endswith((".h", ".hh", ".hpp", ".hxx", ".inl", ".inc")): + found.add(os.path.relpath(os.path.join(parent, name), directory)) + return frozenset(found) + + +def order_collisions(before: tuple[str, ...], after: tuple[str, ...]) -> list[str]: + """Header names that two directories whose relative order changed both hold. + An empty list means the reordering cannot change any lookup.""" + position = {directory: index for index, directory in enumerate(after)} + shared: set[str] = set() + for i, first in enumerate(before): + for second in before[i + 1 :]: + if position[second] < position[first]: + shared |= headers_under(first) & headers_under(second) + return sorted(shared) + + +def compare_targets( + mapping: Mapping, reference: Model, folded: Model, shipped_by_target: dict[str, set[Path]] +) -> Iterator[str]: + """One line per unexplained difference in the shipped TUs' compile settings.""" + for name, shipped in sorted(shipped_by_target.items()): + ref_sources = reference.targets.get(name, {}) + new_sources = folded.targets.get(name) + if new_sources is None: + yield f"{name}: target missing from the folded build" + continue + mapped: dict[Path, tuple[Path, Group]] = {} + for path, group in ref_sources.items(): + if path not in shipped: + continue + target = mapping.file(path) + if target is None: + reason = mapping.deferred(path) + if reason is None: + yield f"{name}: {path}: no folded counterpart" + continue + mapped[target] = (path, group) + for target, (path, ref) in sorted(mapped.items()): + new = new_sources.get(target) + if new is None: + yield f"{name}: {target.relative_to(ROOT) if target.is_relative_to(ROOT) else target}: not built" + continue + label = f"{name}: {target.relative_to(ROOT) if target.is_relative_to(ROOT) else target}" + if ref.language != new.language: + yield f"{label}: language {ref.language} -> {new.language}" + ref_defines = {mapping.text(d) for d in ref.defines if not IDENTITY_DEFINES.match(d)} + new_defines = {d for d in new.defines if not IDENTITY_DEFINES.match(d)} + for define in sorted(ref_defines - new_defines): + yield f"{label}: lost define {define}" + for define in sorted(new_defines - ref_defines): + yield f"{label}: extra define {define}" + # GCC keeps the first of repeated -I directories, so compare + # deduplicated lists. The layers re-added a relocated TU's original + # directory as an include; folded, the TU is back in it and quote + # lookup searches it first anyway, so it is left out of both sides + # (level 1 still sees any header it would resolve differently). + own = str(target.parent) + ref_includes = tuple(i for i in normalized_includes(mapping, ref.includes) if i != own) + new_includes = tuple(i for i in dict.fromkeys(new.includes) if i != own) + if ref_includes != new_includes: + lost = [i for i in ref_includes if i not in new_includes] + extra = [i for i in new_includes if i not in ref_includes] + if lost or extra: + yield f"{label}: include dirs lost {lost} extra {extra}" + else: + shared = order_collisions(ref_includes, new_includes) + if shared: + yield f"{label}: include dir order differs and changes lookup of {shared}" + ref_fragments = tuple(mapping.text(f) for f in ref.fragments) + if ref_fragments != new.fragments: + yield f"{label}: flags {list(ref_fragments)} -> {list(new.fragments)}" + extra_sources = { + path + for path in new_sources + if path not in mapped + and path.is_relative_to(ROOT / "runtime-recomp") + and new_sources[path].language in ("C", "CXX") + } + for path in sorted(extra_sources): + yield f"{name}: {path.relative_to(ROOT)}: built, but not in the reference" + + +def shipped_pairs(compdb: Path) -> dict[str, set[Path]]: + """target -> sources it compiles into the four shipped binaries.""" + pairs: dict[str, set[Path]] = defaultdict(set) + for entry in json.loads(compdb.read_text(encoding="utf-8")): + match = re.search(r"CMakeFiles/([^/]+)\.dir/", entry["output"]) + if match: + pairs[match.group(1)].add(apply_closure.norm(Path(entry["directory"]) / entry["file"])) + return pairs + + +def compare_links( + mapping: Mapping, reference: Model, folded: Model, shipped_by_target: dict[str, set[Path]] +) -> Iterator[str]: + """The four shipped executables' link lines, and every shipped target's dependencies.""" + for name in sorted(shipped_by_target): + if name in folded.dependencies and reference.dependencies.get(name) != folded.dependencies[name]: + old, new = reference.dependencies.get(name, frozenset()), folded.dependencies[name] + yield f"{name}: dependencies lost {sorted(old - new)} extra {sorted(new - old)}" + for name, fragments in sorted(reference.links.items()): + if name not in folded.links or name not in shipped_by_target: + continue + old = tuple(mapping.text(fragment) for fragment in fragments) + if old != folded.links[name]: + yield f"{name}: link line {list(old)} -> {list(folded.links[name])}" + + +def load_allowed(path: Path) -> list[dict[str, str]]: + return list(json.loads(path.read_text(encoding="utf-8"))["deltas"]) if path.exists() else [] + + +def explained(problem: str, allowed: list[dict[str, str]]) -> str | None: + for entry in allowed: + if problem == entry["difference"]: + return entry["reason"] + return None + + +def run_targets(args: argparse.Namespace) -> int: + mapping = Mapping(args.reference, args.folded_build) + reference = load_codemodel(args.reference / "codemodel") + folded = load_codemodel(args.folded_build / ".cmake/api/v1/reply") + shipped = shipped_pairs(args.reference / "shipped-compdb.json") + if args.target: + shipped = {name: sources for name, sources in shipped.items() if name in args.target} + allowed = load_allowed(ALLOWED) + found = list(compare_targets(mapping, reference, folded, shipped)) + found += compare_links(mapping, reference, folded, shipped) + problems: list[str] = [] + for problem in found: + portable = problem.replace(str(args.folded_build), "").replace(str(ROOT) + "/", "") + reason = explained(portable, allowed) + if reason is None: + problems.append(portable) + print(portable) + elif args.verbose: + print(f"allowed: {portable} ({reason})") + print( + f"targets: {len(shipped)} compared, {len(found) - len(problems)} allowed, " + f"{len(problems)} unexplained differences", + file=sys.stderr, + ) + return 1 if problems else 0 + + +def write_shipped(args: argparse.Namespace) -> int: + """The compile commands whose objects link into the four shipped binaries.""" + build: Path = args.build + compdb = json.loads( + subprocess.run(["ninja", "-C", str(build), "-t", "compdb"], check=True, capture_output=True, text=True).stdout + ) + inputs = subprocess.run( + ["ninja", "-C", str(build), "-t", "inputs", *BINARIES], check=True, capture_output=True, text=True + ).stdout.split() + objects = {line for line in inputs if line.endswith(".o")} + kept = [entry for entry in compdb if entry["output"] in objects] + args.out.write_text(json.dumps(kept, indent=1) + "\n", encoding="utf-8") + print(f"shipped TUs: {len(kept)}", file=sys.stderr) + return 0 + + +def compile_args(entry: dict[str, str]) -> list[str]: + """A compile command without its output, dependency-file and -c arguments.""" + args = shlex.split(entry["command"]) + out: list[str] = [] + index = 0 + while index < len(args): + arg = args[index] + if arg in ("-o", "-MF", "-MT", "-MQ"): + index += 2 + continue + if arg not in ("-c", "-MD", "-MMD"): + out.append(arg) + index += 1 + return out + + +def first_party_or_prepared(entry: dict[str, str], build: Path) -> bool: + """TUs the closure is about: everything outside extern/ and the dependency + build trees (generated payload TUs included, fold plan review F10).""" + source = apply_closure.norm(Path(entry["directory"]) / entry["file"]) + if "/extern/" in str(source): + return False + return not source.is_relative_to(build) or source.is_relative_to(build / "generated") + + +def run_tool(command: list[str], cwd: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, cwd=cwd, capture_output=True, text=True, env={**os.environ, "SOURCE_DATE_EPOCH": "0"} + ) + + +TU = tuple[str, Path] # (CMake target, source) + + +def tu_key(entry: dict[str, str]) -> TU: + match = re.search(r"CMakeFiles/([^/]+)\.dir/", entry["output"]) + return (match.group(1) if match else entry["output"]), apply_closure.norm(Path(entry["directory"]) / entry["file"]) + + +def load_entries(compdb: Path) -> dict[TU, dict[str, str]]: + return {tu_key(entry): entry for entry in json.loads(compdb.read_text(encoding="utf-8"))} + + +def output_name(key: TU) -> str: + return hashlib.sha256(f"{key[0]}|{key[1]}".encode()).hexdigest()[:24] + + +def run_depscan(args: argparse.Namespace) -> int: + """Run inside the build image: the headers each TU reads (-M -MG).""" + entries = [e for e in load_entries(args.compdb).values() if first_party_or_prepared(e, args.build)] + + def scan(entry: dict[str, str]) -> dict[str, object]: + target, source = tu_key(entry) + done = run_tool([*compile_args(entry), "-M", "-MG", "-MF", "/dev/stdout", "-w"], entry["directory"]) + if done.returncode: + return {"target": target, "source": str(source), "deps": None, "error": done.stderr[-400:]} + text = done.stdout.replace("\\\n", " ") + deps = text.split(":", 1)[1].split() if ":" in text else [] + folded = [str(apply_closure.norm(Path(entry["directory"]) / dep)) for dep in deps] + return {"target": target, "source": str(source), "deps": folded, "error": None} + + with ThreadPoolExecutor(args.jobs) as pool: + results = list(pool.map(scan, entries)) + args.out.write_text(json.dumps(results) + "\n", encoding="utf-8") + errors = [result for result in results if result["error"]] + for result in errors: + print(f"{result['source']}: {result['error']}", file=sys.stderr) + print(f"depscan: {len(results)} TUs, {len(errors)} errors", file=sys.stderr) + return 1 if errors else 0 + + +def run_preprocess(args: argparse.Namespace) -> int: + """Run inside the build image: -E -P output of every first-party TU. + TUs the build generates (compiled shaders) do not exist after a configure + and are skipped.""" + candidates = [e for e in load_entries(args.compdb).values() if first_party_or_prepared(e, args.build)] + entries = [e for e in candidates if tu_key(e)[1].is_file()] + print(f"preprocess: skipping {len(candidates) - len(entries)} build-time generated TUs", file=sys.stderr) + args.out.mkdir(parents=True, exist_ok=True) + + def preprocess(entry: dict[str, str]) -> tuple[TU, str | None]: + key = tu_key(entry) + out = args.out / f"{output_name(key)}.i" + done = run_tool([*compile_args(entry), "-E", "-P", "-w", "-o", str(out)], entry["directory"]) + return key, None if done.returncode == 0 else f"exit {done.returncode}: {done.stderr[:600]}" + + with ThreadPoolExecutor(args.jobs) as pool: + results = list(pool.map(preprocess, entries)) + # rt64's water renderer includes compiled shader headers the build writes later. + pending = [ + (key, error) for key, error in results if error and re.search(r"shaders/[^:\s]+\.h: No such file", error) + ] + errors = [(key, error) for key, error in results if error and (key, error) not in pending] + for key, error in errors: + print(f"{key[0]}: {key[1]}: {error}", file=sys.stderr) + (args.out / "pending.json").write_text( + json.dumps(sorted(f"{output_name(key)}.i" for key, _ in pending)) + "\n", encoding="utf-8" + ) + print( + f"preprocess: {len(results)} TUs, {len(pending)} need build-time shader headers, {len(errors)} errors", + file=sys.stderr, + ) + return 1 if errors else 0 + + +INCLUDE_SPELLING = re.compile(rb'^([ \t]*#[ \t]*include(?:_next)?[ \t]*)[<"][^>"\r\n]*[>"]', re.M) + + +def comparable(data: bytes) -> bytes: + """File bytes with what the fold may change set aside: #include spellings, + line endings and the absolute define apply_closure.py makes portable.""" + data = INCLUDE_SPELLING.sub(rb"\1<>", data.replace(b"\r\n", b"\n")) + for _, absolute, portable in apply_closure.ABSOLUTE_DEFINES: + data = re.sub(rb'"/[^"\n]*/' + re.escape(absolute.encode()) + rb'"', b'"' + portable.encode() + b'"', data) + return data + + +def variant_tus(root: Path, *, forwarders: bool) -> dict[str, set[str]]: + """header -> TUs that compiled against a version the fold did not keep. + Forwarder TUs read other bytes but the same tokens; ``forwarders`` says + whether to count them.""" + found: dict[str, set[str]] = {} + for header, entry in apply_closure.load_variants(root).items(): + others = cast("list[dict[str, object]]", entry.get("others", [])) + tus: set[str] = set() + for other in others: + if forwarders or not other.get("forwards_to_chosen"): + tus |= set(cast("list[str]", other.get("tus", []))) + found[header] = tus + return found + + +def load_deps(path: Path) -> dict[TU, frozenset[Path]]: + return { + (entry["target"], apply_closure.norm(entry["source"])): frozenset(apply_closure.norm(d) for d in entry["deps"]) + for entry in json.loads(path.read_text(encoding="utf-8")) + if entry["deps"] is not None + } + + +@dataclasses.dataclass +class Pairing: + """Reference TUs and their folded counterparts, keyed by (target, source).""" + + pairs: dict[TU, TU] # reference -> folded + notes: list[str] + unpaired_folded: set[TU] + + +def pair_tus(mapping: Mapping, reference: dict[TU, dict[str, str]], folded: dict[TU, dict[str, str]]) -> Pairing: + first_party = {row.staged for row in mapping.reference.rows if row.kind == "first-party"} + pairs: dict[TU, TU] = {} + notes: list[str] = [] + for target, source in sorted(reference): + deps = mapping.reference.deps.get(source, frozenset()) + if source not in first_party and not deps & first_party: + continue + folded_source = mapping.file(source) + if folded_source is None: + notes.append(f"{target}: {source}: {mapping.deferred(source) or 'no folded counterpart'}") + continue + pairs[(target, source)] = (target, folded_source) + every = {(target, mapping.file(source)) for target, source in reference} + unpaired = {key for key in folded if key[1].is_relative_to(ROOT / "runtime-recomp")} - every + return Pairing(pairs, notes, unpaired) + + +def run_level1(args: argparse.Namespace) -> int: + mapping = Mapping(args.reference, args.folded_build) + first_party = {row.staged for row in mapping.reference.rows if row.kind == "first-party"} + reference_entries = load_entries(args.reference / "shipped-compdb.json") + folded_entries = load_entries(args.folded_compdb) + reference_deps = load_deps(args.reference / "depscan-by-target.json") + folded_deps = load_deps(args.folded_deps) + pairing = pair_tus(mapping, reference_entries, folded_entries) + allow = variant_tus(ROOT, forwarders=True) + allowed = load_allowed(ALLOWED) + problems: list[str] = [] + notes = list(pairing.notes) + same_bytes: dict[tuple[Path, Path], bool] = {} + + def name(path: Path) -> str: + return str(path).replace(str(args.folded_build), "").replace(str(ROOT) + "/", "") + + for old_key, new_key in sorted(pairing.pairs.items()): + label = f"{new_key[0]}: {name(new_key[1])}" + tu_name = apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) + if new_key not in folded_entries: + problems.append(f"{label}: not a shipped TU of the folded build") + continue + old, new = reference_deps.get(old_key), folded_deps.get(new_key) + if old is None or new is None: + problems.append(f"{label}: no dependency scan") + continue + expected: set[Path] = set() + for dep in old: + folded = mapping.file(dep) + if folded is None: + reason = mapping.deferred(dep) + if reason is None: + problems.append(f"{label}: read {name(dep)}, which the fold drops") + else: + notes.append(f"{label}: read {name(dep)}: {reason}") + continue + expected.add(folded) + if dep not in first_party: + continue + if (dep, folded) not in same_bytes: + same_bytes[(dep, folded)] = folded.is_file() and comparable(dep.read_bytes()) == comparable( + folded.read_bytes() + ) + if not same_bytes[(dep, folded)] and tu_name not in allow.get(name(folded), set()): + problems.append(f"{label}: {name(folded)} differs from what the reference TU read") + problems += [f"{label}: no longer reads {name(dep)}" for dep in sorted(expected - new) if dep.exists()] + problems += [f"{label}: now reads {name(dep)}" for dep in sorted(new - expected)] + problems += [ + f"{key[0]}: {name(key[1])}: shipped by the folded build only" for key in sorted(pairing.unpaired_folded) + ] + unexplained = [problem for problem in problems if explained(problem, allowed) is None] + for problem in unexplained: + print(problem) + if args.verbose: + for note in notes: + print(f"note: {note}") + print( + f"level1: {len(pairing.pairs)} TUs, {len(same_bytes)} files byte-checked, {len(notes)} notes " + f"(fold steps B3/B4), {len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", + file=sys.stderr, + ) + return 1 if unexplained else 0 + + +QUOTED_PATH = re.compile(r'"(/[^"\n]+)"') +DIGEST = re.compile(r"[0-9a-f]{64}") + + +def path_string(match: re.Match[str], mapping: Mapping | None) -> str: + """A quoted absolute path, normalized and (with a mapping) folded.""" + path = apply_closure.norm(match.group(1)) + folded = mapping.file(path) if mapping is not None else path + return f'"{folded if folded is not None else path}"' + + +def tokens(text: str, mapping: Mapping | None) -> list[str]: + """Whitespace-separated tokens. File path strings (__FILE__, assert + messages) are normalized, and with a mapping, reference paths are + rewritten to their folded paths: a respelled include changes the spelling + __FILE__ reports, not the file.""" + + return QUOTED_PATH.sub(lambda match: path_string(match, mapping), text).split() + + +def normalized_command(mapping: Mapping | None, entry: dict[str, str]) -> list[str]: + """A compile command without the compiler, output, include-path and source + arguments (include paths are compared by the targets subcommand).""" + args = compile_args(entry)[1:] + # The layers set LANGUAGE on relocated sources, so CMake spelled out the + # language their extension already implies. + language = {".c": "c", ".cc": "c++", ".cpp": "c++", ".cxx": "c++"}.get(Path(entry["file"]).suffix) + for index in range(len(args) - 1): + if args[index] == "-x" and args[index + 1] == language: + args = args[:index] + args[index + 2 :] + break + out: list[str] = [] + skip = False + for arg in args: + if skip: + skip = False + continue + if arg in ("-I", "-isystem", "-iquote", "-idirafter"): + skip = True + continue + if arg.startswith(("-I", "-isystem", "-iquote", "-idirafter")) or arg == entry["file"]: + continue + out.append(mapping.text(arg) if mapping is not None else arg) + return out + + +def run_level2(args: argparse.Namespace) -> int: + mapping = Mapping(args.reference, args.folded_build) + reference_entries = load_entries(args.reference / "shipped-compdb.json") + folded_entries = load_entries(args.folded_compdb) + pairing = pair_tus(mapping, reference_entries, folded_entries) + split = variant_tus(ROOT, forwarders=False) + level3_names = set[str]().union(*split.values()) + allowed = load_allowed(ALLOWED) + problems: list[str] = [] + level3: list[str] = [] + identity_only: list[str] = [] + pending: set[str] = set() + for directory in (args.reference_pp, args.folded_pp): + if (directory / "pending.json").exists(): + pending |= set(json.loads((directory / "pending.json").read_text(encoding="utf-8"))) + same = 0 + for old_key, new_key in sorted(pairing.pairs.items()): + if new_key not in folded_entries: + continue + label = f"{new_key[0]}: {str(new_key[1]).replace(str(ROOT) + '/', '')}" + old_cmd = normalized_command(mapping, reference_entries[old_key]) + new_cmd = normalized_command(None, folded_entries[new_key]) + old_flags = [a for a in old_cmd if not IDENTITY_DEFINE_FLAG.match(a)] + new_flags = [a for a in new_cmd if not IDENTITY_DEFINE_FLAG.match(a)] + if old_flags != new_flags: + problems.append(f"{label}: compile flags {old_cmd} -> {new_cmd}") + identity_changed = old_cmd != new_cmd + old_file = args.reference_pp / f"{output_name(old_key)}.i" + new_file = args.folded_pp / f"{output_name(new_key)}.i" + if old_file.name in pending or new_file.name in pending: + continue # third-party TU waiting for build-time shader headers + if not old_file.is_file() or not new_file.is_file(): + missing = "reference" if not old_file.is_file() else "folded" + problems.append(f"{label}: no {missing} preprocessed output") + continue + old = tokens(old_file.read_text(errors="surrogateescape"), mapping) + new = tokens(new_file.read_text(errors="surrogateescape"), None) + if old == new: + same += 1 + continue + if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) in level3_names: + level3.append(label) + continue + # Fold step B7 freezes the online identity; until then a TU whose + # identity defines changed may differ in those 64-hex digests only. + if identity_changed and [DIGEST.sub("", t) for t in old] == [DIGEST.sub("", t) for t in new]: + identity_only.append(label) + continue + first = next((i for i, (a, b) in enumerate(zip(old, new)) if a != b), min(len(old), len(new))) + was, now = " ".join(old[first : first + 8]), " ".join(new[first : first + 8]) + problems.append(f"{label}: tokens differ at {first}: {was!r} vs {now!r}") + unexplained = [problem for problem in problems if explained(problem, allowed) is None] + for problem in unexplained: + print(problem) + for label in level3: + print(f"level 3 required: {label}") + for label in identity_only: + print(f"identity only (B7): {label}") + print( + f"level2: {same} TUs token-identical, {len(level3)} header-variant TUs left for level 3, " + f"{len(identity_only)} identity-only (B7), {len(pending)} waiting for build-time shaders, " + f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", + file=sys.stderr, + ) + return 1 if unexplained else 0 + + +def object_sections(path: Path) -> dict[str, list[str]]: + """Disassembly with relocations and the contents of every section except + comments, notes and debug information, one block per section, sorted by + section name (COMDAT order follows first use, which a header variant may + move).""" + blocks: dict[str, list[str]] = defaultdict(list) + disassembly = subprocess.run( + ["objdump", "-d", "-r", "--no-show-raw-insn", str(path)], check=True, capture_output=True, text=True + ).stdout + section = "" + for line in disassembly.splitlines(): + if line.startswith("Disassembly of section "): + section = "text " + line.removeprefix("Disassembly of section ").rstrip(":") + elif section and "file format" not in line: + blocks[section].append(line) + dump = subprocess.run(["objdump", "-s", str(path)], check=True, capture_output=True, text=True).stdout + section = "" + for line in dump.splitlines(): + if line.startswith("Contents of section "): + name = line.removeprefix("Contents of section ").rstrip(":") + section = "" if name.startswith((".comment", ".note", ".debug")) else "data " + name + elif section: + blocks[section].append(line) + return dict(blocks) + + +def object_text(sections: dict[str, list[str]]) -> str: + return "\n".join(f"== {name}\n" + "\n".join(lines) for name, lines in sorted(sections.items())) + + +PREPROCESSOR_FLAGS = ("-D", "-U", "-I", "-isystem", "-iquote", "-idirafter", "-include", "-imacros") + + +def codegen_args(entry: dict[str, str]) -> list[str]: + """A compile command minus preprocessor options, for compiling -E output.""" + args = compile_args(entry) + out: list[str] = [args[0]] + index = 1 + while index < len(args): + arg = args[index] + if arg in PREPROCESSOR_FLAGS: + index += 2 + continue + if arg.startswith(PREPROCESSOR_FLAGS) or arg == entry["file"] or arg.endswith(entry["file"]): + index += 1 + continue + if arg == "-x": + index += 2 + continue + out.append(arg) + index += 1 + return out + + +def run_level3(args: argparse.Namespace) -> int: + """Run inside the build image: object equality for the header-variant TUs + (the ones level 2 lets differ). Each side's -E -P output is compiled with + its file path strings normalized as level 2 compares them, so only the + tokens the header variant changes can make the objects differ.""" + mapping = Mapping(args.reference, args.folded_build) + reference_entries = load_entries(args.reference / "shipped-compdb.json") + folded_entries = load_entries(args.folded_compdb) + pairing = pair_tus(mapping, reference_entries, folded_entries) + split = set[str]().union(*variant_tus(ROOT, forwarders=False).values()) + args.out.mkdir(parents=True, exist_ok=True) + problems: list[str] = [] + checked: list[str] = [] + for old_key, new_key in sorted(pairing.pairs.items()): + if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) not in split or new_key not in folded_entries: + continue + label = f"{new_key[0]}: {str(new_key[1]).replace(str(ROOT) + '/', '')}" + old_text = (args.reference_pp / f"{output_name(old_key)}.i").read_text(errors="surrogateescape") + new_text = (args.folded_pp / f"{output_name(new_key)}.i").read_text(errors="surrogateescape") + if tokens(old_text, mapping) == tokens(new_text, None): + continue # already token-identical at level 2 + language = "cpp-output" if new_key[1].suffix == ".c" else "c++-cpp-output" + objects: list[dict[str, list[str]]] = [] + sides = ( + ("reference", old_key, reference_entries, args.reference_pp, mapping), + ("folded", new_key, folded_entries, args.folded_pp, None), + ) + for side, key, entries, pp, side_mapping in sides: + source = (pp / f"{output_name(key)}.i").read_text(errors="surrogateescape") + normalized = QUOTED_PATH.sub(lambda m, sm=side_mapping: path_string(m, sm), source) + text = args.out / f"{side}-{output_name(key)}.i" + text.write_text(normalized, errors="surrogateescape") + obj = text.with_suffix(".o") + entry = entries[key] + done = run_tool([*codegen_args(entry), "-x", language, str(text), "-c", "-o", str(obj)], entry["directory"]) + if done.returncode: + problems.append(f"{label}: {side} compile failed: {done.stderr[-300:]}") + break + objects.append(object_sections(obj)) + if len(objects) != 2: + continue + checked.append(label) + old_sections, new_sections = objects + differ = sorted(n for n in set(old_sections) | set(new_sections) if old_sections.get(n) != new_sections.get(n)) + if differ: + old_text, new_text = object_text(old_sections), object_text(new_sections) + (args.out / f"{output_name(new_key)}.diff").write_text( + "".join(difflib.unified_diff(old_text.splitlines(True), new_text.splitlines(True))), encoding="utf-8" + ) + problems.append(f"{label}: object sections differ: {', '.join(differ)}") + allowed = load_allowed(ALLOWED) + unexplained = [problem for problem in problems if explained(problem, allowed) is None] + for label in checked: + if not any(problem.startswith(label) for problem in problems): + print(f"identical objects: {label}") + for problem in problems: + reason = explained(problem, allowed) + print(problem if reason is None else f"allowed: {problem} ({reason})") + print( + f"level3: {len(checked)} header-variant TUs compiled, {len(problems) - len(unexplained)} allowed, " + f"{len(unexplained)} unexplained (diffs in {args.out})", + file=sys.stderr, + ) + return 1 if unexplained else 0 + + +# The reference configure options the folded tree takes too: the build type +# and the DKR_* options, except the ones only the release chain read (stage +# and capsule paths, evidence and materialization outputs). +FOLDED_OPTION = re.compile(r"^(CMAKE_BUILD_TYPE|DKR_\w+)$") +CHAIN_ONLY = re.compile(r"^DKR_(MATERIALIZE_\w+|RELEASE_EVIDENCE|RELEASE_CAPSULE|\w+_STAGE)$") + + +def folded_options(reference: Path) -> tuple[list[str], set[Path]]: + """The reference's -D options for a folded configure, and the paths + outside the work tree they name (to mount into the build image).""" + script = (reference / "configure-linux.sh").read_text(encoding="utf-8") + command = next(line for line in script.splitlines() if line.startswith("cmake ")) + options: list[str] = [] + capsule: Path | None = None + for arg in shlex.split(command): + if not arg.startswith("-D"): + continue + name, _, value = arg[2:].partition("=") + name = name.split(":")[0] + if name == "DKR_RELEASE_CAPSULE": + capsule = Path(value) + if FOLDED_OPTION.match(name) and not CHAIN_ONLY.match(name): + options.append(arg) + if capsule is None: + raise SystemExit("equivalence: the reference configure names no capsule") + # Camera++ shadow kernels: the chain read them from the capsule role; the + # folded tree takes them as prepared inputs until fold step B4 generates them. + for revision in ("77", "80"): + options.append(f"-DDKR_CAMERA_SHADOW_KERNEL_V{revision}={capsule}/roles/CAMERA_SHADOW/kernel-v{revision}") + mounts = { + Path(arg.partition("=")[2]) + for arg in options + if "=" in arg and arg.partition("=")[2].startswith("/") and not Path(arg.partition("=")[2]).is_relative_to(ROOT) + } + return options, mounts + + +def run_options(args: argparse.Namespace) -> int: + if args.reference_build: + print(apply_closure.load_reference(args.reference, ROOT).build) + return 0 + options, mounts = folded_options(args.reference) + if args.mounts: + print("\n".join(sorted({str(p) for p in mounts}))) + else: + print("\n".join(options)) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + + def compared(name: str, help_text: str) -> argparse.ArgumentParser: + command = sub.add_parser(name, help=help_text) + command.add_argument("--reference", type=Path, required=True, help="the reference directory") + command.add_argument("--folded-build", type=Path, required=True, help="the folded build directory") + command.add_argument("--verbose", action="store_true", help="also list allowed differences and notes") + return command + + targets = compared("targets", "compare per-source compile settings and link lines") + targets.add_argument("--target", action="append", help="limit to these targets") + level1 = compared("level1", "compare TU sets, header closures and file bytes") + level1.add_argument("--folded-compdb", type=Path, required=True) + level1.add_argument("--folded-deps", type=Path, required=True) + level2 = compared("level2", "compare preprocessed token streams and compile flags") + level2.add_argument("--folded-compdb", type=Path, required=True) + level2.add_argument("--reference-pp", type=Path, required=True) + level2.add_argument("--folded-pp", type=Path, required=True) + options = sub.add_parser("options", help="print the folded configure options, one per line") + options.add_argument("--reference", type=Path, required=True) + options.add_argument("--mounts", action="store_true", help="print the paths outside the tree they name instead") + options.add_argument("--reference-build", action="store_true", help="print the reference build directory instead") + level3 = compared("level3", "object equality for the header-variant TUs (in the build image)") + level3.add_argument("--folded-compdb", type=Path, required=True) + level3.add_argument("--reference-pp", type=Path, required=True) + level3.add_argument("--folded-pp", type=Path, required=True) + level3.add_argument("--out", type=Path, required=True) + shipped = sub.add_parser("shipped", help="write the shipped compile commands of a build") + for command in (shipped,): + command.add_argument("--build", type=Path, required=True) + command.add_argument("--out", type=Path, required=True) + for name, help_text in ( + ("depscan", "scan header dependencies (in the build image)"), + ("preprocess", "preprocess first-party TUs (in the build image)"), + ): + command = sub.add_parser(name, help=help_text) + command.add_argument("--compdb", type=Path, required=True) + command.add_argument("--build", type=Path, required=True) + command.add_argument("--out", type=Path, required=True) + command.add_argument("--jobs", type=int, default=os.cpu_count() or 4) + args = parser.parse_args(argv) + for key in ("reference", "folded_build", "build"): + if getattr(args, key, None) is not None: + setattr(args, key, getattr(args, key).resolve()) + handlers = { + "targets": run_targets, + "level1": run_level1, + "level2": run_level2, + "level3": run_level3, + "shipped": write_shipped, + "options": run_options, + "depscan": run_depscan, + "preprocess": run_preprocess, + } + return handlers[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fold/run_equivalence.sh b/scripts/fold/run_equivalence.sh new file mode 100755 index 00000000..3ded8667 --- /dev/null +++ b/scripts/fold/run_equivalence.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Fold equivalence gate, levels 1 to 3 (fold plan 2.4), Linux x86_64. +# +# scripts/fold/run_equivalence.sh REFERENCE_DIR [BUILD_DIR] +# +# REFERENCE_DIR is the materialized v1.0.11 reference (final-sources.json, +# shipped-compdb.json, depscan.json, configure-linux.sh, codemodel/; see +# docs/fold-in/REFERENCE.md). It holds ROM-derived payload paths, so it lives +# on the devops machine, never in git. BUILD_DIR defaults to +# build/fold-equivalence. Everything that compiles runs in the build image +# (DKR_EQUIVALENCE_IMAGE, default dkr-ci-linux-full:latest). +# +# Steps: configure this tree with the reference's options minus the release +# chain's, then compare per-source compile settings and link lines (targets), +# TU sets, header closures and file bytes (level 1), preprocessed tokens plus +# compile flags (level 2), and objects for the TUs level 2 lets differ because +# of a header variant (level 3; DKR_EQUIVALENCE_LEVEL3=0 skips it, it compiles +# them twice). Exit status is non-zero on any unexplained +# difference; scripts/fold/allowed-deltas.json and header-variants.json list +# the explained ones. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +reference="$(cd "${1:?usage: run_equivalence.sh REFERENCE_DIR [BUILD_DIR]}" && pwd)" +build="${2:-$root/build/fold-equivalence}" +image="${DKR_EQUIVALENCE_IMAGE:-dkr-ci-linux-full:latest}" +jobs="${DKR_EQUIVALENCE_JOBS:-12}" +tool="$root/scripts/fold/equivalence.py" +out="$build/equivalence" +mkdir -p "$out" + +# A worktree's .git points into the main checkout; the configure reads it. +git_common="$(cd "$root" && cd "$(git rev-parse --git-common-dir)" && pwd)" +mounts=(-v "$root:$root" -v "$reference:$reference" -v "$git_common:$git_common:ro") +while IFS= read -r path; do + [ -n "$path" ] && mounts+=(-v "$path:$path:ro") +done < <(python3 "$tool" options --reference "$reference" --mounts) +in_image() { + docker run --rm --platform linux/amd64 "${mounts[@]}" -w "$root" "$image" bash -c \ + "set -e; (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq libcurl4-openssl-dev) >/dev/null 2>&1; git config --global --add safe.directory '*'; $1" +} + +options=() +while IFS= read -r option; do + options+=("$option") +done < <(python3 "$tool" options --reference "$reference") +printf -v configure '%q ' cmake -S "$root/runtime-recomp" -B "$build" -G Ninja "${options[@]}" + +echo "== configure (no overlay, no project include)" +mkdir -p "$build/.cmake/api/v1/query" +touch "$build/.cmake/api/v1/query/codemodel-v2" +in_image "$configure >'$out/configure.log' 2>&1 || { tail -30 '$out/configure.log'; exit 1; }" + +echo "== shipped compile commands and header scan" +in_image "python3 -B '$tool' shipped --build '$build' --out '$out/shipped-compdb.json' \ + && python3 -B '$tool' depscan --compdb '$out/shipped-compdb.json' --build '$build' --out '$out/depscan.json' --jobs $jobs" +ref_build="$(python3 "$tool" options --reference "$reference" --reference-build)" +if [ ! -f "$reference/depscan-by-target.json" ]; then + in_image "python3 -B '$tool' depscan --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$reference/depscan-by-target.json' --jobs $jobs" +fi + +status=0 +echo "== targets" +python3 "$tool" targets --reference "$reference" --folded-build "$build" || status=1 +echo "== level 1" +python3 "$tool" level1 --reference "$reference" --folded-build "$build" \ + --folded-compdb "$out/shipped-compdb.json" --folded-deps "$out/depscan.json" || status=1 + +echo "== level 2" +rm -rf "$out/pp" +in_image "python3 -B '$tool' preprocess --compdb '$out/shipped-compdb.json' --build '$build' --out '$out/pp' --jobs $jobs" +if [ ! -d "$reference/pp" ]; then + in_image "python3 -B '$tool' preprocess --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$reference/pp' --jobs $jobs" +fi +python3 "$tool" level2 --reference "$reference" --folded-build "$build" \ + --folded-compdb "$out/shipped-compdb.json" --reference-pp "$reference/pp" --folded-pp "$out/pp" || status=1 + +if [ "${DKR_EQUIVALENCE_LEVEL3:-1}" != 0 ]; then + echo "== level 3" + rm -rf "$out/level3" + in_image "python3 -B '$tool' level3 --reference '$reference' --folded-build '$build' \ + --folded-compdb '$out/shipped-compdb.json' --reference-pp '$reference/pp' --folded-pp '$out/pp' \ + --out '$out/level3'" || status=1 +fi +exit "$status" From 394ffb7f2de4a301a79d907299a70db711ece153 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 18:59:22 -0500 Subject: [PATCH 04/62] Declare the folded sources and include dirs in CMake Add the TUs the layers added to DKRNetplayCore, DKRPortGame and the mod libraries, the shared-saves include order, and the task_submission.cpp wrapper in librecomp. Keep the v1.0.11 network version and launcher defines. --- runtime-recomp/CMakeLists.txt | 66 +++++++++++++++++---- runtime-recomp/cmake/CustomMagicCodes.cmake | 6 +- runtime-recomp/cmake/LegacyMods.cmake | 11 +++- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index be6679ff..37087471 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -5,12 +5,6 @@ if(APPLE) enable_language(OBJC OBJCXX) endif() -file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/../VERSION" DKR_NETWORK_RELEASE_VERSION - LIMIT_COUNT 1) -if(DKR_NETWORK_RELEASE_VERSION STREQUAL "") - set(DKR_NETWORK_RELEASE_VERSION "development") -endif() - set(DKR_RELEASE_VERSION "" CACHE STRING "Public DKR-R release version") if(DKR_RELEASE_VERSION STREQUAL "") file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/../VERSION" DKR_RELEASE_VERSION @@ -171,6 +165,18 @@ endif() # runtime preparation notice before distributing a linked executable. add_subdirectory("${DKR_MODERN_RUNTIME_SOURCE}" "${CMAKE_BINARY_DIR}/n64-modern-runtime") include("${CMAKE_CURRENT_LIST_DIR}/cmake/PerformancePatches.cmake") +# Lobby stability's owned adapter for osSpTaskStartGo: task_submission.cpp +# includes librecomp's unchanged sp.cpp (named in sdk_task_source.hpp), so +# librecomp compiles the wrapper in its place. +set_source_files_properties("${DKR_MODERN_RUNTIME_SOURCE}/librecomp/src/sp.cpp" + TARGET_DIRECTORY librecomp PROPERTIES HEADER_FILE_ONLY ON) +target_sources(librecomp PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game/task_submission.cpp") +if(MSVC) + # The C entrypoint forwards C++ failures through a scoped permission; /EHc + # would let MSVC skip that unwind. + set_source_files_properties("${CMAKE_CURRENT_LIST_DIR}/src/game/task_submission.cpp" + TARGET_DIRECTORY librecomp PROPERTIES COMPILE_OPTIONS "/EHs;/EHc-") +endif() if(MSVC) # The pinned dependencies enable multi-processor compilation internally. # Serialize writes to each target PDB from the project boundary so clean @@ -314,6 +320,20 @@ add_library(DKRNetplayCore STATIC src/game/netplay/secure_channel.cpp src/game/netplay/session_transport.cpp src/game/netplay/windows_firewall.cpp + # Spectator parties, lobby restoration, rollback connection checks and the + # online directory. + src/game/party.cpp + src/game/confirmed_feed.cpp + src/game/party_codec.cpp + src/game/checkpoint_transfer.cpp + src/game/observer_playback.cpp + src/game/confirmed_observer.cpp + src/game/owner_export.cpp + src/game/party_link.cpp + src/game/owned_observer.cpp + src/game/lobby_adapter.cpp + src/game/rollback_check.cpp + src/game/online_directory.cpp ) target_include_directories(DKRNetplayCore PUBLIC "${CMAKE_CURRENT_LIST_DIR}/src/game" @@ -332,6 +352,7 @@ else() endif() if(WIN32) target_link_libraries(DKRNetplayCore PUBLIC ws2_32 bcrypt iphlpapi ole32 oleaut32 shell32) + target_link_libraries(DKRNetplayCore PRIVATE Crypt32) endif() if(DKR_RUNTIME_BUILD_RT64 AND DKR_RUNTIME_BUILD_SDL3_INPUT_HOST) @@ -811,6 +832,18 @@ if(DKR_RUNTIME_BUILD_GENERATED) src/game/runtime_telemetry.cpp src/game/startup_performance.cpp src/game/virtual_pak.cpp + src/game/mod_store.cpp + src/game/mod_store_names.cpp + src/game/mod_store_install.cpp + src/game/vehicle_world.cpp + src/game/render_heap_support.cpp + src/game/texture_dump.cpp + src/game/achievements.cpp + src/game/achievement_catalogue.cpp + src/game/party_runtime.cpp + src/game/invite_handoff.cpp + src/game/store_vehicle_adapter.cpp + src/game/producer_snapshot.cpp ${DKR_GENERATED_RSP} ) # Identical contact arithmetic in native and owned adapters. In particular, @@ -861,6 +894,7 @@ if(DKR_RUNTIME_BUILD_GENERATED) src/game/runtime_rice_texture_import.cpp src/game/runtime_texture_packs.cpp src/game/runtime_ui.cpp + src/game/vehicle_rt64.cpp "${DKR_RT64_SOURCE}/src/contrib/imgui/backends/imgui_impl_sdlrenderer2.cpp" ) endif() @@ -887,6 +921,7 @@ if(DKR_RUNTIME_BUILD_GENERATED) endif() target_include_directories(DKRPortGame PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" + "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe" "${CMAKE_CURRENT_LIST_DIR}/third_party/dr_libs" "${DKR_RECOMP_SOURCE}/include" "${DKR_MODERN_RUNTIME_SOURCE}/N64Recomp/include" @@ -900,10 +935,13 @@ if(DKR_RUNTIME_BUILD_GENERATED) DKR_LEGACY_QUALIFICATION=$ DKR_RUNTIME_HAS_SDL3_INPUT_HOST=$ DKR_RELEASE_VERSION="${DKR_RELEASE_VERSION}" - DKR_NETWORK_RELEASE_VERSION="${DKR_NETWORK_RELEASE_VERSION}" + # Peers see the configured release version, not the VERSION file's. + DKR_NETWORK_RELEASE_VERSION="${DKR_RELEASE_VERSION}" DKR_PATCH_POLICY_SHA256="${DKR_PATCH_POLICY_SHA256}" NOMINMAX ) + # Shared saves and the store read their JSON with nlohmann/json. + target_include_directories(DKRPortGame PRIVATE "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include") target_link_libraries(DKRPortGame PRIVATE DKRUpdateClient) if(DKR_EXPERIMENTAL_RACE_TEST) target_compile_definitions(DKRPortGame PRIVATE DKR_EXPERIMENTAL_RACE_TEST=1) @@ -930,6 +968,9 @@ if(DKR_RUNTIME_BUILD_GENERATED) add_dependencies(DKRPortGame DKRLegacyModWorker) if(MSVC) target_compile_options(DKRPortGame PRIVATE /FS) + # The SDK 2 schema and workspace pages add template sections to the + # single launcher TU; extended COFF keeps it under the section limit. + target_compile_options(DKRPortGame PRIVATE $<$:/bigobj>) endif() if(WIN32) target_link_libraries(DKRPortGame PRIVATE ws2_32) @@ -1167,10 +1208,10 @@ if(DKR_EXPERIMENTAL_OWNED_GENERATED) else() set(DKR_OWNED_FULL_SCENES 1) endif() - if(DKR_EXPERIMENTAL_RACE_TEST AND DKR_OWNED_FULL_SCENES) - set_property(SOURCE src/game/runtime_ui.cpp APPEND PROPERTY - COMPILE_DEFINITIONS DKR_EXPERIMENTAL_FULL_SCENES=1) - endif() + # v1.0.11 compiled runtime_ui.cpp without DKR_EXPERIMENTAL_FULL_SCENES + # (the release chain dropped this per-source define when it relocated + # the launcher), so its full-scene branches stay off until that is + # changed on purpose. if(DKR_EXPERIMENTAL_RACE_TEST AND NOT DKR_OWNED_FULL_SCENES) message(FATAL_ERROR "Integrated rollback requires the full-scene Patch Pipeline; race-only payloads cannot be shipped here") endif() @@ -2296,8 +2337,11 @@ if(BUILD_TESTING) src/game/save_manager.cpp src/game/netplay/experimental_pak.cpp ) + # save_manager.cpp is the shared-saves version, which reads its records + # index with nlohmann/json. target_include_directories(DKRSaveManagerTests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" + "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include" ) add_test(NAME DKRSaveManager COMMAND DKRSaveManagerTests) diff --git a/runtime-recomp/cmake/CustomMagicCodes.cmake b/runtime-recomp/cmake/CustomMagicCodes.cmake index 724e9889..e7c22b2c 100644 --- a/runtime-recomp/cmake/CustomMagicCodes.cmake +++ b/runtime-recomp/cmake/CustomMagicCodes.cmake @@ -10,11 +10,13 @@ add_library(DKRCustomMagicCodes STATIC EXCLUDE_FROM_ALL "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/magic_code_game_world.cpp" "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/magic_code_game_services.cpp" "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/magic_code_effects.cpp" - "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/magic_code_trophy_definition.cpp") + "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/magic_code_trophy_definition.cpp" + "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/sdk2_bridge.cpp") target_sources(DKRCustomMagicCodes PRIVATE "${DKRPORT_ROOT}/runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp") target_compile_features(DKRCustomMagicCodes PUBLIC cxx_std_20) target_include_directories(DKRCustomMagicCodes PUBLIC - "${DKRPORT_ROOT}/runtime-recomp/src/game/mods" "${DKR_RECOMP_SOURCE}/include") + "${DKRPORT_ROOT}/runtime-recomp/src/game/mods" "${DKR_RECOMP_SOURCE}/include" + "${DKRPORT_ROOT}/runtime-recomp/include") target_include_directories(DKRCustomMagicCodes BEFORE PRIVATE "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib") target_compile_definitions(DKRCustomMagicCodes PRIVATE NOMINMAX) diff --git a/runtime-recomp/cmake/LegacyMods.cmake b/runtime-recomp/cmake/LegacyMods.cmake index 3f7907dc..c2134d47 100644 --- a/runtime-recomp/cmake/LegacyMods.cmake +++ b/runtime-recomp/cmake/LegacyMods.cmake @@ -187,7 +187,8 @@ add_library(DKRLegacyImportLibrary STATIC "${_dkr_mod_src}/legacy_import_library "${_dkr_mod_src}/online_mod_native_prepare.cpp" "${_dkr_mod_src}/online_mod_sync.cpp" "${_dkr_mod_src}/legacy_mod_library.cpp" - "${_dkr_mod_src}/legacy_track_catalog.cpp") + "${_dkr_mod_src}/legacy_track_catalog.cpp" + "${_dkr_mod_src}/../offline_keep.cpp") target_link_libraries(DKRLegacyImportLibrary PUBLIC DKRLegacyModCore DKRLegacyModProcess) target_include_directories(DKRLegacyImportLibrary PRIVATE "${DKR_DEPENDENCIES_DIR}/n64-modern-runtime/N64Recomp/include") @@ -195,8 +196,12 @@ target_include_directories(DKRLegacyImportLibrary PRIVATE add_library(DKRLegacyModLaunch STATIC "${_dkr_mod_src}/legacy_mod_launch.cpp" "${_dkr_mod_src}/online_mod_runtime.cpp" "${_dkr_mod_src}/online_mod_music.cpp" - "${_dkr_mod_src}/../custom_music_decode.cpp") -target_include_directories(DKRLegacyModLaunch PRIVATE "${DKRPORT_ROOT}/runtime-recomp/third_party/dr_libs") + "${_dkr_mod_src}/../custom_music_decode.cpp" + "${_dkr_mod_src}/../resident_music.cpp") +target_include_directories(DKRLegacyModLaunch BEFORE PRIVATE "${_dkr_mod_src}/.." + "${DKRPORT_ROOT}/runtime-recomp/tests/replay_probe") +target_include_directories(DKRLegacyModLaunch PRIVATE "${DKRPORT_ROOT}/runtime-recomp/third_party/dr_libs" + "${_dkr_mod_src}") # .dkrmap table logic. A mod launch publishes the tracks' artwork into the # character-augmented boot bank, so the launch owns this dependency. # A track's native music sequence is validated at scan, so its checker From 20a22ea4276a4dfacaa386e216a8790181247348 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 18:59:32 -0500 Subject: [PATCH 05/62] Add the Camera++ and custom vehicle targets to CMake DKRCameraPresentation with per-revision scene kernels from prepared inputs, DKRCustomVehicles with its original-texture checks (ARM64 -ffp-contract=off from v1.0.11), and the store, camera and vehicle links in release order. --- architecture/pattern-guard-baseline.txt | 1 + runtime-recomp/CMakeLists.txt | 19 +++ runtime-recomp/cmake/Camera.cmake | 63 ++++++++++ runtime-recomp/cmake/CustomVehicles.cmake | 51 ++++++++ .../tests/vehicle_package_checks.cpp | 112 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 runtime-recomp/cmake/Camera.cmake create mode 100644 runtime-recomp/cmake/CustomVehicles.cmake create mode 100644 runtime-recomp/tests/vehicle_package_checks.cpp diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index 12c8ae62..7b35d215 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -581,6 +581,7 @@ patches/release-prep/v1.0.11/BUILDING.txt PG013 patches/release-prep/v1.0.11/RELEASE-NOTES.txt PG013 patches/release-prep/v1.0.11/ReleaseOverlay.cmake PG001 patches/release-prep/v1.0.11/ReleaseOverlay.cmake PG002 +patches/release-prep/v1.0.11/ReleaseOverlay.cmake PG003 patches/release-prep/v1.0.11/UPDATER.txt PG013 patches/release-prep/v1.0.11/audit.py PG013 patches/release-prep/v1.0.11/author.py PG013 diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index 37087471..de7152ed 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -664,6 +664,7 @@ if(DKR_RUNTIME_BUILD_GENERATED) find_package(Python3 REQUIRED COMPONENTS Interpreter) include("${CMAKE_CURRENT_LIST_DIR}/cmake/LegacyMods.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomMagicCodes.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomVehicles.cmake") foreach(config IN ITEMS Debug Release RelWithDebInfo MinSizeRel) string(TOUPPER "${config}" config_upper) set_target_properties(DKRLegacyModWorker PROPERTIES @@ -1368,6 +1369,24 @@ if(TARGET DKRPortGame AND DKR_RUNTIME_BUILD_RT64 AND NOT APPLE) endif() endif() +if(DKR_RUNTIME_BUILD_GENERATED) + # Camera++, the Mod Store HTTP backend and custom vehicles, linked after + # the owned engines in the order v1.0.11 linked them. + include(cmake/Camera.cmake) + target_link_libraries(DKRPortGame PRIVATE DKRCameraPresentation) + if(WIN32) + target_link_libraries(DKRPortGame PRIVATE winhttp crypt32 bcrypt) + elseif(NOT ANDROID AND NOT APPLE) + find_package(CURL REQUIRED) + target_link_libraries(DKRPortGame PRIVATE CURL::libcurl) + endif() + target_link_libraries(DKRPortGame PRIVATE DKRCustomVehicles) + if(UNIX) + # The online directory's Linux keyring is loaded at run time. + target_link_libraries(DKRPortGame PRIVATE ${CMAKE_DL_LIBS}) + endif() +endif() + if(DKR_EXPERIMENTAL_RACE_TEST AND NOT TARGET DKRExperimentalRaceTest) message(FATAL_ERROR "Experimental race test requires RT64, WebRTC and the reviewed isolated owned Patch Pipeline output") endif() diff --git a/runtime-recomp/cmake/Camera.cmake b/runtime-recomp/cmake/Camera.cmake new file mode 100644 index 00000000..2762dfbf --- /dev/null +++ b/runtime-recomp/cmake/Camera.cmake @@ -0,0 +1,63 @@ +# Camera++: the local presentation provider (DKRCameraPresentation) and one +# draw-only scene kernel per revision. The kernels compile guest-derived code +# prepared outside the build: DKR_CAMERA_KERNEL_V holds +# camera_scene_functions.c from patches/camera-plus/scene_kernel_pipeline.py, +# and DKR_CAMERA_SHADOW_KERNEL_V holds the canonical-shadow +# camera_scene_hooks.c and camera_shadow_kernel.c. Neither input is modified. +set(DKR_CAMERA_KERNEL_V77 "" CACHE PATH "Prepared Camera++ scene kernel for revision 77") +set(DKR_CAMERA_KERNEL_V80 "" CACHE PATH "Prepared Camera++ scene kernel for revision 80") +set(DKR_CAMERA_SHADOW_KERNEL_V77 "" CACHE PATH "Prepared Camera++ shadow kernel for revision 77") +set(DKR_CAMERA_SHADOW_KERNEL_V80 "" CACHE PATH "Prepared Camera++ shadow kernel for revision 80") + +function(dkr_camera_scene_kernel target revision) + set(kernel "${DKR_CAMERA_KERNEL_V${revision}}") + set(shadow "${DKR_CAMERA_SHADOW_KERNEL_V${revision}}") + if(NOT EXISTS "${kernel}/manifest.json") + message(FATAL_ERROR "Set DKR_CAMERA_KERNEL_V${revision} to a NEW draw-only Camera++ kernel") + endif() + if(NOT EXISTS "${shadow}/camera_scene_hooks.c" OR NOT EXISTS "${shadow}/camera_shadow_kernel.c") + message(FATAL_ERROR "Set DKR_CAMERA_SHADOW_KERNEL_V${revision} to the prepared Camera++ shadow kernel") + endif() + add_library(${target} STATIC + "${kernel}/camera_scene_functions.c" + "${shadow}/camera_scene_hooks.c" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../src/game/camera_kernel.c" + "${shadow}/camera_shadow_kernel.c") + target_include_directories(${target} BEFORE PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../src/game" "${kernel}") + target_compile_features(${target} PRIVATE c_std_11) + foreach(helper access step fault) + target_compile_definitions(${target} PRIVATE dkr_camera_${helper}=dkr_camera_v${revision}_${helper}) + endforeach() + target_compile_definitions(${target} PRIVATE DKR_SHADOW_ENTRY=dkr_camera_v${revision}_shadow_draw) + if(MSVC) + target_compile_options(${target} PRIVATE /fp:strict /FS /wd4244 /wd4267) + else() + target_compile_options(${target} PRIVATE -fno-strict-aliasing -ffp-contract=off + -fno-fast-math -frounding-math -Werror=implicit-function-declaration) + target_link_libraries(${target} PRIVATE m) + endif() +endfunction() + +add_library(DKRCameraPresentation STATIC + src/game/camera_presentation.cpp + src/game/camera_scene.cpp + src/game/camera_snapshot.cpp + src/game/camera_settings.cpp + src/game/camera_actions.cpp + src/game/camera_session.cpp + src/game/camera_runtime.cpp + src/game/camera_rt64.cpp) +target_compile_features(DKRCameraPresentation PUBLIC cxx_std_20) +target_include_directories(DKRCameraPresentation PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game") +target_include_directories(DKRCameraPresentation PRIVATE "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib") +target_link_libraries(DKRCameraPresentation PUBLIC DKRCustomMagicCodes) +if(MSVC) + target_compile_options(DKRCameraPresentation PRIVATE /fp:strict /W4) +else() + target_compile_options(DKRCameraPresentation PRIVATE -ffp-contract=off -fno-fast-math -Wall -Wextra) +endif() +dkr_camera_scene_kernel(DKRCameraSceneV77 77) +dkr_camera_scene_kernel(DKRCameraSceneV80 80) +target_link_libraries(DKRCameraPresentation PRIVATE DKRCameraSceneV77 DKRCameraSceneV80) diff --git a/runtime-recomp/cmake/CustomVehicles.cmake b/runtime-recomp/cmake/CustomVehicles.cmake new file mode 100644 index 00000000..57dc8283 --- /dev/null +++ b/runtime-recomp/cmake/CustomVehicles.cmake @@ -0,0 +1,51 @@ +# Custom vehicles: package parsing, the local library, resource preparation +# and character binding. The launcher, renderer and guest bridges live in +# DKRPortGame and DKRLegacyModLaunch. +add_library(DKRCustomVehicles STATIC + "${DKRPORT_ROOT}/runtime-recomp/src/game/vehicle_package.cpp" + "${DKRPORT_ROOT}/runtime-recomp/src/game/vehicle_library.cpp" + "${DKRPORT_ROOT}/runtime-recomp/src/game/vehicle_resources.cpp" + "${DKRPORT_ROOT}/runtime-recomp/src/game/vehicle_binding.cpp") +target_compile_features(DKRCustomVehicles PUBLIC cxx_std_20) +target_compile_definitions(DKRCustomVehicles PRIVATE NOMINMAX) +target_include_directories(DKRCustomVehicles PUBLIC + "${DKRPORT_ROOT}/runtime-recomp/src/game/mods" + "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib" + "${DKR_DEPENDENCIES_DIR}/n64-modern-runtime/thirdparty") +target_link_libraries(DKRCustomVehicles PUBLIC DKRCustomMagicCodes DKRLegacyModCore) +target_link_libraries(DKRLegacyImportLibrary PRIVATE DKRCustomVehicles) +target_link_libraries(DKRLegacyModLaunch PRIVATE DKRCustomVehicles) + +# Original-texture package checks (synthetic packages, no game data). Built on +# demand; the sanitizer variant compiles the preparation source directly. +add_executable(DKRVehicleOriginalChecks EXCLUDE_FROM_ALL + "${DKRPORT_ROOT}/runtime-recomp/tests/vehicle_package_checks.cpp") +target_compile_features(DKRVehicleOriginalChecks PRIVATE cxx_std_20) +target_include_directories(DKRVehicleOriginalChecks PRIVATE "${DKRPORT_ROOT}/runtime-recomp/src/game") +target_link_libraries(DKRVehicleOriginalChecks PRIVATE DKRCustomVehicles) +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" AND NOT WIN32) + add_executable(DKRVehicleOriginalSanitizerChecks EXCLUDE_FROM_ALL + "${DKRPORT_ROOT}/runtime-recomp/tests/vehicle_package_checks.cpp" + "${DKRPORT_ROOT}/runtime-recomp/src/game/vehicle_package.cpp") + target_compile_features(DKRVehicleOriginalSanitizerChecks PRIVATE cxx_std_20) + target_compile_definitions(DKRVehicleOriginalSanitizerChecks PRIVATE NOMINMAX) + target_compile_options(DKRVehicleOriginalSanitizerChecks PRIVATE + -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(DKRVehicleOriginalSanitizerChecks PRIVATE -fsanitize=address,undefined) + target_include_directories(DKRVehicleOriginalSanitizerChecks PRIVATE + "${DKRPORT_ROOT}/runtime-recomp/src/game" "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib") + target_link_libraries(DKRVehicleOriginalSanitizerChecks PRIVATE DKRCustomMagicCodes DKRLegacyModCore) +endif() + +# v1.0.11 vehicle numeric alignment: on ARM64, GCC and Clang may fuse the +# preparation's multiply-adds, which x86-64 never does. Keep vehicle_package.cpp +# and its checks unfused there. +if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$" AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + set_property(SOURCE "${DKRPORT_ROOT}/runtime-recomp/src/game/vehicle_package.cpp" + APPEND PROPERTY COMPILE_OPTIONS -ffp-contract=off) + foreach(target DKRVehicleOriginalChecks DKRVehicleOriginalSanitizerChecks) + if(TARGET ${target}) + target_compile_options(${target} PRIVATE -ffp-contract=off) + endif() + endforeach() +endif() diff --git a/runtime-recomp/tests/vehicle_package_checks.cpp b/runtime-recomp/tests/vehicle_package_checks.cpp new file mode 100644 index 00000000..a8d0e18b --- /dev/null +++ b/runtime-recomp/tests/vehicle_package_checks.cpp @@ -0,0 +1,112 @@ +#include "package_compatibility.hpp" +#include "driver_anchor_units.hpp" +#include +#include +#include +#include +using namespace dkr::mods; +using namespace dkr::mods::vehicles; +namespace ot=dkr::mods::vehicles::original_textures; +namespace { +unsigned checks=0; +void check(bool ok,const char* why){++checks;if(!ok)throw std::runtime_error(why);} +Bytes read(const char* name){std::ifstream file(std::filesystem::u8path(name),std::ios::binary);if(!file)throw std::runtime_error("Missing package fixture"); + Bytes data((std::istreambuf_iterator(file)),{});if(data.size()>MaxPackageBytes)throw std::runtime_error("Fixture exceeds package limit");return data;} +std::string fingerprint(const Package& p){ + Bytes out;const auto number=[&](unsigned v){for(unsigned i=0;i<4;++i)out.push_back(std::uint8_t(v>>(i*8)));}; + const auto real=[&](float v){number(std::bit_cast(v));};const auto vec=[&](Vec3 v){real(v.x);real(v.y);real(v.z);}; + const auto text=[&](const std::string& s){number(unsigned(s.size()));out.insert(out.end(),s.begin(),s.end());}; + text(p.id);text(p.name);text(p.digest);real(p.scale);real(p.yaw);real(p.driver_yaw);vec(p.seat);vec(p.driver_offset); + number(unsigned(p.seating));number(p.authored_anchors);vec(p.origin);vec(p.kart_offset);for(auto v:p.driver_anchors)vec(v);for(auto v:p.driver_seats)vec(v); + number(p.axle);for(const auto& w:p.wheels)text(w);for(auto v:p.pivots)vec(v); + number(unsigned(p.textures.size()));for(const auto& t:p.textures){number(t.image.width);number(t.image.height);number(t.repeat_s);number(t.repeat_t);out.insert(out.end(),t.image.rgba.begin(),t.image.rgba.end());} + for(const auto& lod:p.lods){number(lod.triangles);number(unsigned(lod.meshes.size()));for(const auto& m:lod.meshes){text(m.id);text(m.name);number(unsigned(m.texture));number(m.double_sided);out.insert(out.end(),m.colour.begin(),m.colour.end()); + number(unsigned(m.vertices.size()));for(auto v:m.vertices)vec(v);for(auto uv:m.uv){real(uv.x);real(uv.y);}for(auto f:m.faces)for(auto v:f)number(v);}} + number(p.portrait.width);number(p.portrait.height);out.insert(out.end(),p.portrait.rgba.begin(),p.portrait.rgba.end());return sha256(out); +} +Package square(bool repeat_s=true,bool repeat_t=true,unsigned width=128,unsigned height=64){ + Package car;Texture t;t.repeat_s=repeat_s;t.repeat_t=repeat_t;t.image={width,height,Bytes(width*height*4)}; + for(unsigned y=0;y sample(const Texture& t,double u,double v){ + const auto axis=[](double v,unsigned size,bool repeat){int i=int(std::floor(v*size+1e-8));if(!repeat)return unsigned(std::clamp(i,0,int(size)-1));i%=int(size);return unsigned(i<0?i+int(size):i);}; + const auto p=(axis(v,t.image.height,t.repeat_t)*t.image.width+axis(u,t.image.width,t.repeat_s))*4; + return {t.image.rgba[p],t.image.rgba[p+1],t.image.rgba[p+2],t.image.rgba[p+3]}; +} +void pixels(const Package& car,const Texture& original){ + double area=0;for(const auto& m:car.lods[0].meshes)for(auto f:m.faces){auto a=m.vertices[f[0]],b=m.vertices[f[1]],c=m.vertices[f[2]];area+=std::abs((b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x))*.5;} + check(std::abs(area-1)<1e-5,"Clipping changed the covered geometry area"); + for(unsigned y=0;y<33;++y)for(unsigned x=0;x<33;++x){ + const double px=(x+.317)/33,py=(y+.613)/33,u=px*2-.25,v=py*2-.25; + // Keep samples away from the renderer's 1/32-texel quantization boundary. + if(std::abs(u*original.image.width-std::round(u*original.image.width))<.05 || std::abs(v*original.image.height-std::round(v*original.image.height))<.05)continue; + const auto wanted=sample(original,u,v);unsigned found=0; + for(const auto& m:car.lods[0].meshes)for(auto f:m.faces){const auto a=m.vertices[f[0]],b=m.vertices[f[1]],c=m.vertices[f[2]]; + const double d=(b.y-c.y)*(a.x-c.x)+(c.x-b.x)*(a.y-c.y);if(std::abs(d)<1e-15)continue; + const auto w0=((b.y-c.y)*(px-c.x)+(c.x-b.x)*(py-c.y))/d; + const auto w1=((c.y-a.y)*(px-c.x)+(a.x-c.x)*(py-c.y))/d,w2=1-w0-w1; + if(w0< -1e-6||w1< -1e-6||w2< -1e-6)continue; + const auto t0=m.uv[f[0]],t1=m.uv[f[1]],t2=m.uv[f[2]]; + check(sample(car.textures.at(unsigned(m.texture)),w0*t0.x+w1*t1.x+w2*t2.x,w0*t0.y+w1*t1.y+w2*t2.y)==wanted,"Tiled artwork changed a sampled RGBA pixel");++found; + } + check(found>0,"Clipping left a hole"); + } +} +void synthetic(){ + for(const auto repeat_s:{false,true})for(const auto repeat_t:{false,true}){ + auto car=square(repeat_s,repeat_t);const auto original=car.textures[0];ot::prepare(car); + check(car.textures.size()==15,"Unexpected 128x64 tile count"); + for(const auto& t:car.textures){const auto row=(t.image.width*2+7)&~7U;check(t.image.rgba.size()<=4096 && row*t.image.height<=2048,"Material exceeds RGBA32 TMEM banks");} + for(const auto& lod:car.lods){check(lod.triangles<=4096 && lod.meshes.size()<=64,"Draw bounds exceeded");for(const auto& m:lod.meshes)check(m.id=="wheel" && m.double_sided,"Wheel ownership or culling changed");} + check(car.pivots[0].x==.5F && car.pivots[0].y==.5F,"Tiling recentered the authored wheel pivot");pixels(car,original); + } + auto small=square(true,true,32,16);auto expected=small;ot::prepare(small); + check(fingerprint(small)==fingerprint(expected),"Already safe artwork changed"); + auto oversized=square(true,true,256,256);bool rejected=false; + try{ot::prepare(oversized);}catch(const Error&){rejected=true;} + check(rejected && oversized.textures.size()==1 && oversized.lods[0].triangles==2,"Tile-budget failure published partial preparation"); + for(const float scale:{.21F,.25F,.28F}){ + Package car;car.seating=SeatingMode::PerBaseDriverV1;car.driver_anchors[0]={0,32,11}; + const auto legacy=dkr::runtime::vehicles::original_textures::model_anchor(car,0,scale); + check(std::abs(legacy.y-8.F/scale)<1e-5,"Old per-driver anchor units changed"); + car.authored_anchors=true; + const auto raw=dkr::runtime::vehicles::original_textures::model_anchor(car,0,scale); + check(raw.y==32 && raw.z==11,"Explicit native anchor was scaled a second time"); + car.seating=SeatingMode::LegacyShared; + check(std::abs(dkr::runtime::vehicles::original_textures::model_anchor(car,0,scale).y-8.F/scale)<1e-5,"Legacy authored anchor selected the new contract"); + } + // Degenerate UVs at a wrap boundary select one cell, not overlapping copies. + auto fixed=square();for(auto& lod:fixed.lods)for(auto& mesh:lod.meshes)for(auto& uv:mesh.uv)uv={1,1};ot::prepare(fixed); + check(fixed.lods[0].triangles==2,"Constant boundary UVs duplicated triangles"); +} +} +int main(int argc,char** argv){try{ + if(argc==3 && std::string_view(argv[1])=="--fingerprint"){ + const auto data=read(argv[2]);const auto p=inspect(data,"fixture.dkrkart",Type::Car);std::cout<source==data && p->digest==sha256(data),"Package source or identity was rewritten"); + check(p->per_driver() && p->authored_anchors,"Explicit seating contract not selected"); + check(p->textures.size()==18,"Unexpected real-package tile count"); + for(const auto& t:p->textures)check(t.image.rgba.size()<=4096,"Real package exceeds material limit"); + std::cout<<"Real archive parsed as data: safe-tiles="<textures.size()<<" lod-triangles="; + for(const auto& lod:p->lods)std::cout< Date: Sun, 25 Oct 2026 19:22:48 -0500 Subject: [PATCH 06/62] Mark launcher copy that v1.0.11 superseded, fix two moved entries 71 reviewed strings are gone from the shipped launcher; the test asserts they stay absent until the Phase C copy review in docs/fold-in/. --- docs/fold-in/launcher-copy-review.md | 118 ++++++++++++++++++ .../tests/launcher_copy_inventory.json | 4 +- .../tests/launcher_copy_source_tests.py | 94 +++++++++++++- 3 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 docs/fold-in/launcher-copy-review.md diff --git a/docs/fold-in/launcher-copy-review.md b/docs/fold-in/launcher-copy-review.md new file mode 100644 index 00000000..de04d91e --- /dev/null +++ b/docs/fold-in/launcher-copy-review.md @@ -0,0 +1,118 @@ +# Launcher copy review (Phase C) + +`runtime-recomp/tests/launcher_copy_inventory.json` pins reviewed launcher copy. Before the fold it was checked against main's launcher sources, which v1.0.11 did not ship: the release layers replaced parts of the launcher, and the fold wrote the shipped bytes back. The 71 entries below describe copy the shipped launcher no longer has. + +Until this review, `launcher_copy_source_tests.py` lists them in `SUPERSEDED` and asserts that each string stays absent, so none can come back unreviewed. Every other entry is still enforced. Each entry is attributed to the first release layer, in chain order, whose staged copy of the file no longer held the string. + +The maintainer owns the review: for each entry, find the shipped string that replaced it, re-baseline the inventory entry against it (or retire it), and remove it from `SUPERSEDED`. + +## shared-saves (34) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-157 | `runtime_saves_ui.inl` | Back up, transfer and edit your saves. | +| COPY-158 | `runtime_saves_ui.inl` | Includes all three slots, unlocks and records. | +| COPY-159 | `runtime_saves_ui.inl` | A backup is created before replacing your save. | +| COPY-160 | `runtime_saves_ui.inl` | Includes offline Adventure progress and Controller Paks. Online and modded saves are not included. | +| COPY-161 | `runtime_saves_ui.inl` | EXPORT OFFLINE SAVE BUNDLE | +| COPY-162 | `runtime_saves_ui.inl` | IMPORT OFFLINE SAVE BUNDLE | +| COPY-163 | `runtime_saves_ui.inl` | Four virtual memory cards. Included in offline save bundles when available. | +| COPY-164 | `runtime_saves_ui.inl` | Repair this save? Your progress won't be edited. | +| COPY-165 | `runtime_saves_ui.inl` | The original will be backed up. | +| COPY-166 | `runtime_saves_ui.inl` | Reset all three slots, unlocks and records? A backup will be created first. | +| COPY-167 | `runtime_saves_ui.inl` | Unsaved editor changes will be discarded. | +| COPY-168 | `runtime_saves_ui.inl` | Complete this Adventure slot? | +| COPY-169 | `runtime_saves_ui.inl` | Use Apply changes to save this edit. | +| COPY-170 | `runtime_saves_ui.inl` | Unlock Adventure Two, Drumstick and all T.T. victories? | +| COPY-171 | `runtime_saves_ui.inl` | Discard unsaved changes? | +| COPY-172 | `runtime_saves_ui.inl` | Discard edits to all three slots and shared unlocks? | +| COPY-173 | `runtime_saves_ui.inl` | Apply these edits to your offline save? A backup will be created first. | +| COPY-175 | `runtime_saves_ui.inl` | Offline Adventure save. Online and modded saves aren't affected. | +| COPY-176 | `runtime_saves_ui.inl` | Save repaired. Original kept in Backups. | +| COPY-177 | `runtime_saves_ui.inl` | This save doesn't need repair. | +| COPY-178 | `runtime_saves_ui.inl` | Changes saved. Previous progress kept in Backups. | +| COPY-179 | `runtime_saves_ui.inl` | Reading saves... | +| COPY-180 | `runtime_saves_ui.inl` | Online progress is separate from offline saves. | +| COPY-181 | `runtime_saves_ui.inl` | Each mod selection keeps separate progress. | +| COPY-182 | `runtime_saves_ui.inl` | Restore a surviving copy to recover progress. Recovery needs an existing save or backup. | +| COPY-183 | `runtime_saves_ui.inl` | No saves found in this category. | +| COPY-184 | `runtime_saves_ui.inl` | SAVE EDITING UNAVAILABLE | +| COPY-185 | `runtime_saves_ui.inl` | Return to the launcher before importing, restoring or resetting saves. | +| COPY-186 | `runtime_saves_ui.inl` | Leave the lobby before changing offline saves. | +| COPY-187 | `runtime_saves_ui.inl` | Start an Adventure, import a save or create one below. | +| COPY-188 | `runtime_saves_ui.inl` | Repair this save or restore a working backup. | +| COPY-397 | `runtime_ui.cpp` | Legacy mod selections keep separate saves. | +| COPY-413 | `runtime_ui.cpp` | Offline save bundle imported. Previous saves were backed up. | +| COPY-414 | `runtime_ui.cpp` | Offline save bundle exported. | + +## camera-plus (14) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-053 | `runtime_graphics_ui.inl` | A smoother chase camera. The right stick turns the camera instead of sending C-buttons. Offline only. | +| COPY-054 | `runtime_graphics_ui.inl` | Hold Look back to see behind you. Change its binding in Controls. Offline only. | +| COPY-055 | `runtime_graphics_ui.inl` | Adjust camera (F7) | +| COPY-056 | `runtime_graphics_ui.inl` | Adjust the camera while driving. Use the mouse. | +| COPY-314 | `runtime_camera_tuning_ui.inl` | How closely the camera follows the racer through turns and drifts. | +| COPY-315 | `runtime_camera_tuning_ui.inl` | How smoothly the camera turns. Damping below 1 adds overshoot. | +| COPY-316 | `runtime_camera_tuning_ui.inl` | Turn the camera with the right stick or mouse. Hold the right mouse button to keep the view; Escape frees the pointer. | +| COPY-317 | `runtime_camera_tuning_ui.inl` | Shake from boosts, landings and impacts. Zero turns shake off. | +| COPY-318 | `runtime_camera_tuning_ui.inl` | Lower values smooth the camera after collisions. 100% follows the impact immediately. | +| COPY-319 | `runtime_camera_tuning_ui.inl` | Camera settings updated. Your custom values are kept. | +| COPY-321 | `runtime_camera_tuning_ui.inl` | Start an offline race to adjust the camera. | +| COPY-322 | `runtime_camera_tuning_ui.inl` | Custom. Gold labels mark changed settings. | +| COPY-323 | `runtime_camera_tuning_ui.inl` | Records camera movement in the log until turned off or DKR-R closes. | +| COPY-324 | `runtime_camera_tuning_ui.inl` | Player %u camera. Changes apply while driving. | + +## rollback-only (10) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-196 | `runtime_online_ui.inl` | Host Prediction | +| COPY-197 | `runtime_online_ui.inl` | Strict Input Sync | +| COPY-198 | `runtime_online_ui.inl` | Experimental Rollback | +| COPY-199 | `runtime_online_ui.inl` | Experimental Rollback | +| COPY-200 | `runtime_online_ui.inl` | Uses Experimental Rollback. | +| COPY-202 | `runtime_online_ui.inl` | Higher values allow more time for late inputs, but can increase replay work. Automatic input delay is a good starting point. | +| COPY-203 | `runtime_online_ui.inl` | Use Host Prediction | +| COPY-205 | `runtime_online_ui.inl` | The host handles missing inputs. Response time depends on the connection. | +| COPY-206 | `runtime_online_ui.inl` | Waits for every player's input. Best for stable, low-latency connections. | +| COPY-250 | `runtime_online_ui.inl` | Everyone selects Ready, then the host starts the game. Auto uses Experimental Rollback. | + +## rollback-connection (7) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-195 | `runtime_online_ui.inl` | Online mode | +| COPY-229 | `runtime_online_ui.inl` | Game in sync | +| COPY-230 | `runtime_online_ui.inl` | Checking game sync... | +| COPY-232 | `runtime_online_ui.inl` | Game input recording enabled. | +| COPY-242 | `runtime_online_ui.inl` | Connection test results | +| COPY-246 | `runtime_online_ui.inl` | A 7-second connection check. Results don't measure game performance. | +| COPY-247 | `runtime_online_ui.inl` | Good - fewer network interruptions expected | + +## online-directory (3) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-248 | `runtime_online_ui.inl` | Create a lobby and share its 5-character code, or enter a friend's code to join. | +| COPY-249 | `runtime_online_ui.inl` | The host approves joining players. You can also join friends through Open Lobbies. | +| COPY-253 | `runtime_online_ui.inl` | Create a lobby or join with a code. | + +## all-player-detail (1) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-063 | `runtime_graphics_ui.inl` | Use the most detailed racer models. | + +## lobby-stability (1) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-268 | `runtime_online_mods_ui.inl` | New offline copies start disabled. Your existing mod selections and saves are kept. | + +## launcher-polish (1) + +| Entry | File | Reviewed copy (pre-v1.0.11) | +|---|---|---| +| COPY-399 | `runtime_ui.cpp` | Legacy patches need the ROM version they were made for. Native tracks don't need patch preparation. | diff --git a/runtime-recomp/tests/launcher_copy_inventory.json b/runtime-recomp/tests/launcher_copy_inventory.json index 40a3500b..a1ac4da9 100644 --- a/runtime-recomp/tests/launcher_copy_inventory.json +++ b/runtime-recomp/tests/launcher_copy_inventory.json @@ -1613,7 +1613,7 @@ }, { "id": "COPY-231", - "file": "runtime_online_ui.inl", + "file": "runtime_ui.cpp", "previous": "Connection variation detected. DKR-R is preparing a synchronized recovery point; keep racing.", "approved": "Restoring game sync...", "verification": "literal" @@ -2236,7 +2236,7 @@ }, { "id": "COPY-320", - "file": "runtime_camera_tuning_ui.inl", + "file": "runtime_details_ui.inl", "previous": "Needs the Modern presentation profile (Graphics).", "approved": "Requires Modern graphics.", "verification": "literal" diff --git a/runtime-recomp/tests/launcher_copy_source_tests.py b/runtime-recomp/tests/launcher_copy_source_tests.py index 5292527c..22cb9562 100644 --- a/runtime-recomp/tests/launcher_copy_source_tests.py +++ b/runtime-recomp/tests/launcher_copy_source_tests.py @@ -8,6 +8,83 @@ ROOT = Path(__file__).resolve().parents[2] GAME = ROOT / "runtime-recomp/src/game" +# Reviewed copy that v1.0.11 no longer ships: the named release layer replaced +# that part of the launcher, and the fold wrote the shipped bytes back. Each +# string must stay absent; docs/fold-in/launcher-copy-review.md lists them for +# the Phase C copy review, which re-baselines them against the shipped strings. +SUPERSEDED = { + "COPY-053": "camera-plus, v1.0.11", + "COPY-054": "camera-plus, v1.0.11", + "COPY-055": "camera-plus, v1.0.11", + "COPY-056": "camera-plus, v1.0.11", + "COPY-063": "all-player-detail, v1.0.11", + "COPY-157": "shared-saves, v1.0.11", + "COPY-158": "shared-saves, v1.0.11", + "COPY-159": "shared-saves, v1.0.11", + "COPY-160": "shared-saves, v1.0.11", + "COPY-161": "shared-saves, v1.0.11", + "COPY-162": "shared-saves, v1.0.11", + "COPY-163": "shared-saves, v1.0.11", + "COPY-164": "shared-saves, v1.0.11", + "COPY-165": "shared-saves, v1.0.11", + "COPY-166": "shared-saves, v1.0.11", + "COPY-167": "shared-saves, v1.0.11", + "COPY-168": "shared-saves, v1.0.11", + "COPY-169": "shared-saves, v1.0.11", + "COPY-170": "shared-saves, v1.0.11", + "COPY-171": "shared-saves, v1.0.11", + "COPY-172": "shared-saves, v1.0.11", + "COPY-173": "shared-saves, v1.0.11", + "COPY-175": "shared-saves, v1.0.11", + "COPY-176": "shared-saves, v1.0.11", + "COPY-177": "shared-saves, v1.0.11", + "COPY-178": "shared-saves, v1.0.11", + "COPY-179": "shared-saves, v1.0.11", + "COPY-180": "shared-saves, v1.0.11", + "COPY-181": "shared-saves, v1.0.11", + "COPY-182": "shared-saves, v1.0.11", + "COPY-183": "shared-saves, v1.0.11", + "COPY-184": "shared-saves, v1.0.11", + "COPY-185": "shared-saves, v1.0.11", + "COPY-186": "shared-saves, v1.0.11", + "COPY-187": "shared-saves, v1.0.11", + "COPY-188": "shared-saves, v1.0.11", + "COPY-195": "rollback-connection, v1.0.11", + "COPY-196": "rollback-only, v1.0.11", + "COPY-197": "rollback-only, v1.0.11", + "COPY-198": "rollback-only, v1.0.11", + "COPY-199": "rollback-only, v1.0.11", + "COPY-200": "rollback-only, v1.0.11", + "COPY-202": "rollback-only, v1.0.11", + "COPY-203": "rollback-only, v1.0.11", + "COPY-205": "rollback-only, v1.0.11", + "COPY-206": "rollback-only, v1.0.11", + "COPY-229": "rollback-connection, v1.0.11", + "COPY-230": "rollback-connection, v1.0.11", + "COPY-232": "rollback-connection, v1.0.11", + "COPY-242": "rollback-connection, v1.0.11", + "COPY-246": "rollback-connection, v1.0.11", + "COPY-247": "rollback-connection, v1.0.11", + "COPY-248": "online-directory, v1.0.11", + "COPY-249": "online-directory, v1.0.11", + "COPY-250": "rollback-only, v1.0.11", + "COPY-253": "online-directory, v1.0.11", + "COPY-268": "lobby-stability, v1.0.11", + "COPY-314": "camera-plus, v1.0.11", + "COPY-315": "camera-plus, v1.0.11", + "COPY-316": "camera-plus, v1.0.11", + "COPY-317": "camera-plus, v1.0.11", + "COPY-318": "camera-plus, v1.0.11", + "COPY-319": "camera-plus, v1.0.11", + "COPY-321": "camera-plus, v1.0.11", + "COPY-322": "camera-plus, v1.0.11", + "COPY-323": "camera-plus, v1.0.11", + "COPY-324": "camera-plus, v1.0.11", + "COPY-397": "shared-saves, v1.0.11", + "COPY-399": "launcher-polish, v1.0.11", + "COPY-413": "shared-saves, v1.0.11", + "COPY-414": "shared-saves, v1.0.11", +} def source(name): @@ -26,6 +103,10 @@ def literals(text): class LauncherCopyTests(unittest.TestCase): + def test_superseded_entries_exist(self): + inventory = json.loads((Path(__file__).with_name("launcher_copy_inventory.json")).read_text(encoding="utf-8")) + self.assertLessEqual(set(SUPERSEDED), {entry["id"] for entry in inventory["entries"]}) + def test_reviewed_inventory(self): inventory = json.loads((Path(__file__).with_name("launcher_copy_inventory.json")).read_text(encoding="utf-8")) self.assertGreaterEqual(len(inventory["entries"]), 400) @@ -33,7 +114,10 @@ def test_reviewed_inventory(self): for entry in inventory["entries"]: with self.subTest(entry=entry["id"]): values = sources.setdefault(entry["file"], literals(source(entry["file"]))) - if entry["verification"] == "literal": + if entry["id"] in SUPERSEDED: + self.assertEqual(entry["verification"], "literal") + self.assertNotIn(entry["approved"], values, f"superseded ({SUPERSEDED[entry['id']]}) but present") + elif entry["verification"] == "literal": self.assertIn(entry["approved"], values) elif entry["verification"] == "removed": self.assertNotIn(entry["previous"], values) @@ -127,10 +211,10 @@ def test_streamer_privacy_is_shared_and_persistent(self): self.assertIn("OnlineTextForDisplay(text)", online) def test_save_and_mod_safety_stays_visible(self): - saves = source("runtime_saves_ui.inl") - self.assertIn("Reset all three slots, unlocks and records?", saves) - self.assertIn("A backup will be created first.", saves) - self.assertIn("Offline Adventure save. Online and modded saves aren't affected.", saves) + # The saves page here was replaced by shared-saves in v1.0.11; these + # are COPY-166 and COPY-175 and are checked as superseded above. + self.assertIn("COPY-166", SUPERSEDED) + self.assertIn("COPY-175", SUPERSEDED) mods = source("runtime_mod_library_ui.inl") self.assertIn("Other mods, original source files and saves are kept.", mods) self.assertIn("mod_support(item.details", mods) From 7cc60d191b7aaea3dd89ed6befd99c3485f89200 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 20:41:21 -0500 Subject: [PATCH 07/62] Split DKRPortGame into an object library, add host-full-nogen DKRPortGameCore carries the sources and settings; the exe links it only with a generated payload, so every launcher module compiles without one. --- runtime-recomp/CMakeLists.txt | 351 ++++++++++++---------- runtime-recomp/CMakePresets.json | 29 ++ runtime-recomp/cmake/CustomVehicles.cmake | 6 + runtime-recomp/cmake/LegacyMods.cmake | 2 + runtime-recomp/tests/stb_image_provider.c | 4 + scripts/fold/allowed-deltas.json | 4 + scripts/fold/equivalence.py | 32 +- 7 files changed, 270 insertions(+), 158 deletions(-) create mode 100644 runtime-recomp/tests/stb_image_provider.c diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index de7152ed..50e0c461 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -660,17 +660,20 @@ if(DKR_RUNTIME_BUILD_RT64) endif() endif() +# The mod, magic-code and vehicle libraries need no generated payload, so they +# exist in every configure: host builds compile and test them too. +find_package(Python3 REQUIRED COMPONENTS Interpreter) +include("${CMAKE_CURRENT_LIST_DIR}/cmake/LegacyMods.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomMagicCodes.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomVehicles.cmake") +foreach(config IN ITEMS Debug Release RelWithDebInfo MinSizeRel) + string(TOUPPER "${config}" config_upper) + set_target_properties(DKRLegacyModWorker PROPERTIES + RUNTIME_OUTPUT_DIRECTORY_${config_upper} + "${CMAKE_BINARY_DIR}/bin/${config}/libexec/dkr-r") +endforeach() + if(DKR_RUNTIME_BUILD_GENERATED) - find_package(Python3 REQUIRED COMPONENTS Interpreter) - include("${CMAKE_CURRENT_LIST_DIR}/cmake/LegacyMods.cmake") - include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomMagicCodes.cmake") - include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomVehicles.cmake") - foreach(config IN ITEMS Debug Release RelWithDebInfo MinSizeRel) - string(TOUPPER "${config}" config_upper) - set_target_properties(DKRLegacyModWorker PROPERTIES - RUNTIME_OUTPUT_DIRECTORY_${config_upper} - "${CMAKE_BINARY_DIR}/bin/${config}/libexec/dkr-r") - endforeach() set(DKR_V80_SYMBOL_MAP "${CMAKE_BINARY_DIR}/generated/dkr_v80_symbol_map.hpp") include("${CMAKE_CURRENT_LIST_DIR}/cmake/RevisionSymbolMap.cmake") @@ -785,12 +788,19 @@ if(DKR_RUNTIME_BUILD_GENERATED) "${DKR_V80_SYMBOL_MAP}") endif() -if(DKR_RUNTIME_BUILD_GENERATED) - file(GLOB DKR_GENERATED_RSP CONFIGURE_DEPENDS "${DKR_GENERATED_RSP_SOURCE}/*.cpp") - if(NOT DKR_GENERATED_RSP) - message(FATAL_ERROR - "No RSPRecomp output was found under ${DKR_GENERATED_RSP_SOURCE}. " - "Run Generate-DKR-RSP.cmd first.") +# DKRPortGameCore holds every game TU and its compile settings. Only the +# RSPRecomp output needs a generated payload, so RT64 host builds without one +# (the host-full-nogen preset) compile the core too. DKRPortGame links the core +# with the recompiled payloads into DKR-R. +if(DKR_RUNTIME_BUILD_GENERATED OR DKR_RUNTIME_BUILD_RT64) + set(DKR_GENERATED_RSP "") + if(DKR_RUNTIME_BUILD_GENERATED) + file(GLOB DKR_GENERATED_RSP CONFIGURE_DEPENDS "${DKR_GENERATED_RSP_SOURCE}/*.cpp") + if(NOT DKR_GENERATED_RSP) + message(FATAL_ERROR + "No RSPRecomp output was found under ${DKR_GENERATED_RSP_SOURCE}. " + "Run Generate-DKR-RSP.cmd first.") + endif() endif() set(DKR_GAME_SOURCES src/game/runtime_code_mods.cpp @@ -899,19 +909,36 @@ if(DKR_RUNTIME_BUILD_GENERATED) "${DKR_RT64_SOURCE}/src/contrib/imgui/backends/imgui_impl_sdlrenderer2.cpp" ) endif() - if(WIN32) - list(APPEND DKR_GAME_SOURCES - resources/dkr-r.rc - ) - string(REPLACE "." "," DKR_RELEASE_RESOURCE_VERSION "${DKR_RELEASE_VERSION_NUMERIC}") - set_property(SOURCE resources/dkr-r.rc APPEND PROPERTY COMPILE_DEFINITIONS - "DKR_RELEASE_RESOURCE_VERSION=${DKR_RELEASE_RESOURCE_VERSION},${DKR_RELEASE_BUILD_NUMBER}") + if(ANDROID) + list(APPEND DKR_GAME_SOURCES src/android/android_platform.cpp src/android/mobile_ui.cpp) + endif() + add_library(DKRPortGameCore OBJECT ${DKR_GAME_SOURCES}) + if(NOT DKR_RUNTIME_BUILD_GENERATED) + # host-full-nogen compiles the shipped configuration, which always has + # rollback (the owned engines) and local model detail. Their payloads + # only come with a generated build, so nothing here is linked. + target_sources(DKRPortGameCore PRIVATE src/game/netplay/experimental_runtime.cpp src/game/local_model_rt64.cpp) + target_compile_definitions(DKRPortGameCore PRIVATE DKR_EXPERIMENTAL_RACE_TEST=1 DKR_LOCAL_MODEL_DETAIL=1) endif() if(ANDROID) - add_library(DKRPortGame SHARED ${DKR_GAME_SOURCES} src/android/android_platform.cpp src/android/mobile_ui.cpp) - target_link_libraries(DKRPortGame PRIVATE SDL2::SDL2 SDL2::SDL2main android log) - else() - add_executable(DKRPortGame ${DKR_GAME_SOURCES}) + target_link_libraries(DKRPortGameCore PRIVATE SDL2::SDL2 SDL2::SDL2main android log) + endif() + if(DKR_RUNTIME_BUILD_GENERATED) + if(ANDROID) + add_library(DKRPortGame SHARED) + else() + add_executable(DKRPortGame) + endif() + # The core's objects, then its link libraries in the order it lists + # them. LINK_ONLY: the core already compiled their interface sources. + target_link_libraries(DKRPortGame PRIVATE DKRPortGameCore + "$>") + if(WIN32) + target_sources(DKRPortGame PRIVATE resources/dkr-r.rc) + string(REPLACE "." "," DKR_RELEASE_RESOURCE_VERSION "${DKR_RELEASE_VERSION_NUMERIC}") + set_property(SOURCE resources/dkr-r.rc APPEND PROPERTY COMPILE_DEFINITIONS + "DKR_RELEASE_RESOURCE_VERSION=${DKR_RELEASE_RESOURCE_VERSION},${DKR_RELEASE_BUILD_NUMBER}") + endif() endif() if(MSVC) # These new bridge callbacks hold leases across guest scheduler calls. @@ -920,7 +947,7 @@ if(DKR_RUNTIME_BUILD_GENERATED) src/game/legacy_runtime_qualification.cpp APPEND PROPERTY COMPILE_OPTIONS /EHs /EHc-) endif() - target_include_directories(DKRPortGame PRIVATE + target_include_directories(DKRPortGameCore PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe" "${CMAKE_CURRENT_LIST_DIR}/third_party/dr_libs" @@ -931,7 +958,7 @@ if(DKR_RUNTIME_BUILD_GENERATED) "${DKR_GENERATED_RSP_SOURCE}" "${DKR_MODERN_RUNTIME_SOURCE}/thirdparty" ) - target_compile_definitions(DKRPortGame PRIVATE + target_compile_definitions(DKRPortGameCore PRIVATE DKR_RUNTIME_HAS_RT64=$ DKR_LEGACY_QUALIFICATION=$ DKR_RUNTIME_HAS_SDL3_INPUT_HOST=$ @@ -942,42 +969,43 @@ if(DKR_RUNTIME_BUILD_GENERATED) NOMINMAX ) # Shared saves and the store read their JSON with nlohmann/json. - target_include_directories(DKRPortGame PRIVATE "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include") - target_link_libraries(DKRPortGame PRIVATE DKRUpdateClient) + target_include_directories(DKRPortGameCore PRIVATE "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include") + target_link_libraries(DKRPortGameCore PRIVATE DKRUpdateClient) if(DKR_EXPERIMENTAL_RACE_TEST) - target_compile_definitions(DKRPortGame PRIVATE DKR_EXPERIMENTAL_RACE_TEST=1) + target_compile_definitions(DKRPortGameCore PRIVATE DKR_EXPERIMENTAL_RACE_TEST=1) endif() if(NOT MSVC AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$") # librecomp's generated RSP vector implementation uses SSE4.1 # intrinsics on x86-64. GCC and Clang require the target feature to be # enabled explicitly, while MSVC exposes these intrinsics by default. - target_compile_options(DKRPortGame PRIVATE -msse4.1) + target_compile_options(DKRPortGameCore PRIVATE -msse4.1) endif() # Keep the generated payload archives before their shared runtime # providers. GNU ld scans static archives from left to right, so placing # librecomp first leaves the v1.1 payload's imported OS/compiler helpers # unresolved on Linux. MSVC is order-independent, and this ordering keeps # both platforms on the same single-runtime link graph. - target_link_libraries(DKRPortGame PRIVATE - DKRRecompiledFuncsV77 - DKRRecompiledFuncsV80 - ultramodern - librecomp) - target_link_libraries(DKRPortGame PRIVATE DKRNetplayCore) - target_link_libraries(DKRPortGame PRIVATE DKRLegacyModLaunch DKRLegacyGuestIO) - target_link_libraries(DKRPortGame PRIVATE DKRCustomMagicCodes) - add_dependencies(DKRPortGame DKRLegacyModWorker) + if(DKR_RUNTIME_BUILD_GENERATED) + target_link_libraries(DKRPortGameCore PRIVATE DKRRecompiledFuncsV77 DKRRecompiledFuncsV80) + endif() + target_link_libraries(DKRPortGameCore PRIVATE ultramodern librecomp) + target_link_libraries(DKRPortGameCore PRIVATE DKRNetplayCore) + target_link_libraries(DKRPortGameCore PRIVATE DKRLegacyModLaunch DKRLegacyGuestIO) + target_link_libraries(DKRPortGameCore PRIVATE DKRCustomMagicCodes) + if(TARGET DKRPortGame) + add_dependencies(DKRPortGame DKRLegacyModWorker) + endif() if(MSVC) - target_compile_options(DKRPortGame PRIVATE /FS) + target_compile_options(DKRPortGameCore PRIVATE /FS) # The SDK 2 schema and workspace pages add template sections to the # single launcher TU; extended COFF keeps it under the section limit. - target_compile_options(DKRPortGame PRIVATE $<$:/bigobj>) + target_compile_options(DKRPortGameCore PRIVATE $<$:/bigobj>) endif() if(WIN32) - target_link_libraries(DKRPortGame PRIVATE ws2_32) + target_link_libraries(DKRPortGameCore PRIVATE ws2_32) endif() if(DKR_RUNTIME_BUILD_RT64 AND TARGET rt64) - target_include_directories(DKRPortGame PRIVATE + target_include_directories(DKRPortGameCore PRIVATE "$" "${DKR_RT64_SOURCE}/src" "${DKR_RT64_SOURCE}/src/include" @@ -989,108 +1017,116 @@ if(DKR_RUNTIME_BUILD_GENERATED) "${DKR_RT64_SOURCE}/src/contrib/nativefiledialog-extended/src/include" "${CMAKE_BINARY_DIR}/src" ) - target_link_libraries(DKRPortGame PRIVATE rt64) + target_link_libraries(DKRPortGameCore PRIVATE rt64) if(APPLE) - target_include_directories(DKRPortGame PRIVATE + target_include_directories(DKRPortGameCore PRIVATE "${DKR_RT64_SOURCE}/src/contrib/metal-cpp" ) endif() if(WIN32) - target_include_directories(DKRPortGame PRIVATE + target_include_directories(DKRPortGameCore PRIVATE "${DKR_RT64_SOURCE}/src/contrib/mupen64plus-win32-deps/SDL2-2.26.3/include" ) - target_link_directories(DKRPortGame PRIVATE - "${DKR_RT64_SOURCE}/src/contrib/mupen64plus-win32-deps/SDL2-2.26.3/lib/x64" - ) - target_link_libraries(DKRPortGame PRIVATE SDL2) - add_custom_command(TARGET DKRPortGame POST_BUILD - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${DKR_RT64_SOURCE}/src/contrib/mupen64plus-win32-deps/SDL2-2.26.3/lib/x64/SDL2.dll" - "$/SDL2.dll" - VERBATIM - ) + target_link_libraries(DKRPortGameCore PRIVATE SDL2) + if(TARGET DKRPortGame) + target_link_directories(DKRPortGame PRIVATE + "${DKR_RT64_SOURCE}/src/contrib/mupen64plus-win32-deps/SDL2-2.26.3/lib/x64" + ) + add_custom_command(TARGET DKRPortGame POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${DKR_RT64_SOURCE}/src/contrib/mupen64plus-win32-deps/SDL2-2.26.3/lib/x64/SDL2.dll" + "$/SDL2.dll" + VERBATIM + ) + endif() elseif(IOS) - dkr_ios_finish(DKRPortGame) + if(TARGET DKRPortGame) + dkr_ios_finish(DKRPortGame) + endif() else() find_package(SDL2 REQUIRED) - target_include_directories(DKRPortGame PRIVATE ${SDL2_INCLUDE_DIRS}) - target_link_libraries(DKRPortGame PRIVATE ${SDL2_LIBRARIES}) + target_include_directories(DKRPortGameCore PRIVATE ${SDL2_INCLUDE_DIRS}) + target_link_libraries(DKRPortGameCore PRIVATE ${SDL2_LIBRARIES}) endif() - if(WIN32) + if(TARGET DKRPortGame) + if(WIN32) + add_custom_command(TARGET DKRPortGame POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${DKR_RT64_SOURCE}/src/contrib/dxc/bin/x64/dxcompiler.dll" + "$/dxcompiler.dll" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${DKR_RT64_SOURCE}/src/contrib/dxc/bin/x64/dxil.dll" + "$/dxil.dll" + VERBATIM + ) + endif() + set(DKR_GAME_ASSET_DIRECTORY "$") + if(WIN32) + set(DKR_GAME_ASSET_DIRECTORY "$") + endif() add_custom_command(TARGET DKRPortGame POST_BUILD COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${DKR_RT64_SOURCE}/src/contrib/dxc/bin/x64/dxcompiler.dll" - "$/dxcompiler.dll" + "${CMAKE_BINARY_DIR}/dkr-release.json" + "${DKR_GAME_ASSET_DIRECTORY}/dkr-release.json" + COMMAND "${CMAKE_COMMAND}" -E make_directory + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Icons/DKR-R-Logo.bmp" + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons/DKR-R-Logo.bmp" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Icons/DKR-R-Spinning-Icon.png" + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons/DKR-R-Spinning-Icon.png" COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${DKR_RT64_SOURCE}/src/contrib/dxc/bin/x64/dxil.dll" - "$/dxil.dll" + "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Icons/DKR-R-Short-Logo.png" + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons/DKR-R-Short-Logo.png" + COMMAND "${CMAKE_COMMAND}" -E make_directory + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Backgrounds" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Backgrounds/DKR-R-Launcher-Background.png" + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Backgrounds/DKR-R-Launcher-Background.png" + COMMAND "${CMAKE_COMMAND}" -E make_directory + "${DKR_GAME_ASSET_DIRECTORY}/assets/filters" + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${CMAKE_CURRENT_LIST_DIR}/../assets/filters" + "${DKR_GAME_ASSET_DIRECTORY}/assets/filters" + COMMAND "${CMAKE_COMMAND}" -E make_directory + "${DKR_GAME_ASSET_DIRECTORY}/assets/controllers" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${CMAKE_CURRENT_LIST_DIR}/../assets/controllers/gamecontrollerdb.txt" + "${DKR_GAME_ASSET_DIRECTORY}/assets/controllers/gamecontrollerdb.txt" + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/CustomTrackBackgrounds" + "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/CustomTrackBackgrounds" + COMMENT "Staging the DKR-R launcher, graphics and controller assets" VERBATIM ) endif() - set(DKR_GAME_ASSET_DIRECTORY "$") - if(WIN32) - set(DKR_GAME_ASSET_DIRECTORY "$") - endif() - add_custom_command(TARGET DKRPortGame POST_BUILD - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${CMAKE_BINARY_DIR}/dkr-release.json" - "${DKR_GAME_ASSET_DIRECTORY}/dkr-release.json" - COMMAND "${CMAKE_COMMAND}" -E make_directory - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons" - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Icons/DKR-R-Logo.bmp" - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons/DKR-R-Logo.bmp" - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Icons/DKR-R-Spinning-Icon.png" - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons/DKR-R-Spinning-Icon.png" - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Icons/DKR-R-Short-Logo.png" - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Icons/DKR-R-Short-Logo.png" - COMMAND "${CMAKE_COMMAND}" -E make_directory - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Backgrounds" - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/Backgrounds/DKR-R-Launcher-Background.png" - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/Backgrounds/DKR-R-Launcher-Background.png" - COMMAND "${CMAKE_COMMAND}" -E make_directory - "${DKR_GAME_ASSET_DIRECTORY}/assets/filters" - COMMAND "${CMAKE_COMMAND}" -E copy_directory - "${CMAKE_CURRENT_LIST_DIR}/../assets/filters" - "${DKR_GAME_ASSET_DIRECTORY}/assets/filters" - COMMAND "${CMAKE_COMMAND}" -E make_directory - "${DKR_GAME_ASSET_DIRECTORY}/assets/controllers" - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${CMAKE_CURRENT_LIST_DIR}/../assets/controllers/gamecontrollerdb.txt" - "${DKR_GAME_ASSET_DIRECTORY}/assets/controllers/gamecontrollerdb.txt" - COMMAND "${CMAKE_COMMAND}" -E copy_directory - "${CMAKE_CURRENT_LIST_DIR}/../assets/ui/CustomTrackBackgrounds" - "${DKR_GAME_ASSET_DIRECTORY}/assets/ui/CustomTrackBackgrounds" - COMMENT "Staging the DKR-R launcher, graphics and controller assets" - VERBATIM - ) - endif() - if(WIN32) - target_link_libraries(DKRPortGame PRIVATE user32 shell32 dbghelp bcrypt dxgi) endif() - set_target_properties(DKRPortGame PROPERTIES - OUTPUT_NAME "DKR-R" - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" - ) if(WIN32) - set_target_properties(DKRPortGame PROPERTIES WIN32_EXECUTABLE TRUE) - set_target_properties(DKRPortGame PROPERTIES OUTPUT_NAME "DKR-R-Runtime") - set_target_properties(DKRPortGame PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/libexec/dkr-r") - add_dependencies(DKRPortGame DKRUpdateLauncher DKRUpdateHelper) + target_link_libraries(DKRPortGameCore PRIVATE user32 shell32 dbghelp bcrypt dxgi) endif() - foreach(config Debug Release RelWithDebInfo MinSizeRel) - string(TOUPPER "${config}" config_upper) - set(_dkr_game_output "${CMAKE_BINARY_DIR}/bin/${config}") - if(WIN32) - string(APPEND _dkr_game_output "/libexec/dkr-r") - endif() + if(TARGET DKRPortGame) set_target_properties(DKRPortGame PROPERTIES - RUNTIME_OUTPUT_DIRECTORY_${config_upper} "${_dkr_game_output}" + OUTPUT_NAME "DKR-R" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" ) - endforeach() + if(WIN32) + set_target_properties(DKRPortGame PROPERTIES WIN32_EXECUTABLE TRUE) + set_target_properties(DKRPortGame PROPERTIES OUTPUT_NAME "DKR-R-Runtime") + set_target_properties(DKRPortGame PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/libexec/dkr-r") + add_dependencies(DKRPortGame DKRUpdateLauncher DKRUpdateHelper) + endif() + foreach(config Debug Release RelWithDebInfo MinSizeRel) + string(TOUPPER "${config}" config_upper) + set(_dkr_game_output "${CMAKE_BINARY_DIR}/bin/${config}") + if(WIN32) + string(APPEND _dkr_game_output "/libexec/dkr-r") + endif() + set_target_properties(DKRPortGame PROPERTIES + RUNTIME_OUTPUT_DIRECTORY_${config_upper} "${_dkr_game_output}" + ) + endforeach() + endif() endif() add_executable(DKRRuntimeProbe src/runtime_probe.cpp) @@ -1103,9 +1139,9 @@ if(DKR_RUNTIME_BUILD_GENERATED AND DKR_RUNTIME_BUILD_RT64 AND DKR_VISUAL_MODEL_G include(cmake/LocalModelDetail.cmake) dkr_add_local_model_kernel(DKRLocalModelV77 "${DKR_VISUAL_MODEL_GENERATED_V77}" 77) dkr_add_local_model_kernel(DKRLocalModelV80 "${DKR_VISUAL_MODEL_GENERATED_V80}" 80) - target_sources(DKRPortGame PRIVATE src/game/local_model_rt64.cpp) - target_compile_definitions(DKRPortGame PRIVATE DKR_LOCAL_MODEL_DETAIL=1) - target_link_libraries(DKRPortGame PRIVATE DKRLocalModelV77 DKRLocalModelV80) + target_sources(DKRPortGameCore PRIVATE src/game/local_model_rt64.cpp) + target_compile_definitions(DKRPortGameCore PRIVATE DKR_LOCAL_MODEL_DETAIL=1) + target_link_libraries(DKRPortGameCore PRIVATE DKRLocalModelV77 DKRLocalModelV80) endif() if(DKR_EXPERIMENTAL_OWNED_GENERATED) include(cmake/ExperimentalOwnedGame.cmake) @@ -1239,7 +1275,7 @@ endif() if(TARGET DKRPortGame AND (DKR_EXPERIMENTAL_RENDER_QUALIFICATION OR DKR_EXPERIMENTAL_RACE_TEST)) # Private RT64 references arrive after the static core on ELF. An explicit # object keeps its retirement seam available without changing stable links. - target_sources(DKRPortGame PRIVATE src/game/netplay/experimental_present_gate.cpp) + target_sources(DKRPortGameCore PRIVATE src/game/netplay/experimental_present_gate.cpp) # RT64's staged retirement seam is also used by renderer regression tests. # Propagate the narrow implementation, not the whole online core, to every # RT64 consumer so static-link order cannot leave unresolved gate symbols. @@ -1255,19 +1291,19 @@ if(TARGET DKRPortGame AND DKR_RUNTIME_BUILD_RT64 AND NOT APPLE) tests/experimental_gpu_probe.cpp ${DKR_PRESENTATION_PROBE_SOURCES} src/game/netplay/experimental_checkpoint_hash.cpp) target_include_directories(DKRExperimentalGpuProbe PRIVATE - "$" + "$" "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe") target_compile_definitions(DKRExperimentalGpuProbe PRIVATE - "$" SDL_MAIN_HANDLED=1) + "$" SDL_MAIN_HANDLED=1) if(DKR_EXPERIMENTAL_RENDER_QUALIFICATION) target_compile_definitions(DKRExperimentalGpuProbe PRIVATE DKR_EXPERIMENTAL_RENDER_QUALIFICATION=1) endif() target_compile_options(DKRExperimentalGpuProbe PRIVATE - "$") + "$") target_link_directories(DKRExperimentalGpuProbe PRIVATE "$") target_link_libraries(DKRExperimentalGpuProbe PRIVATE - "$") + "$") # PRIVATE interactive owner/renderer handoff qualification, never packaged. if(TARGET DKRExperimentalOwnedGame AND DKR_NETPLAY_WEBRTC AND (DKR_EXPERIMENTAL_RENDER_QUALIFICATION OR DKR_EXPERIMENTAL_RACE_TEST)) if(DKR_EXPERIMENTAL_RACE_TEST) @@ -1291,9 +1327,9 @@ if(TARGET DKRPortGame AND DKR_RUNTIME_BUILD_RT64 AND NOT APPLE) tests/experimental_interactive_probe.cpp ${DKR_PRESENTATION_PROBE_SOURCES}) endif() target_include_directories(${DKR_INTERACTIVE_TARGET} PRIVATE - "$") + "$") target_compile_definitions(${DKR_INTERACTIVE_TARGET} PRIVATE - "$" + "$" SDL_MAIN_HANDLED=1 DKR_OWNED_TEST_REVISION=${DKR_OWNED_REVISION} DKR_OWNED_FULL_SCENES=${DKR_OWNED_FULL_SCENES}) @@ -1337,7 +1373,7 @@ if(TARGET DKRPortGame AND DKR_RUNTIME_BUILD_RT64 AND NOT APPLE) string(SHA256 DKR_OWNED_BUILD_ID "${DKR_OWNED_ID_PARTS}|v77:${DKR_OWNED_BUILD_ID_V77}|v80:${DKR_OWNED_BUILD_ID_V80}") message(STATUS "Owned rollback contract: ${DKR_OWNED_BUILD_ID}") if(DKR_EXPERIMENTAL_RACE_TEST) - target_link_libraries(DKRPortGame PRIVATE DKRExperimentalOwnedGame) + target_link_libraries(DKRPortGameCore PRIVATE DKRExperimentalOwnedGame) set_property(SOURCE src/game/netplay/experimental_runtime.cpp APPEND PROPERTY COMPILE_DEFINITIONS DKR_OWNED_BUILD_ID="${DKR_OWNED_BUILD_ID}") endif() @@ -1346,11 +1382,11 @@ if(TARGET DKRPortGame AND DKR_RUNTIME_BUILD_RT64 AND NOT APPLE) set_property(SOURCE ${DKR_INTERACTIVE_SOURCE} APPEND PROPERTY COMPILE_DEFINITIONS DKR_OWNED_BUILD_ID="${DKR_OWNED_BUILD_ID}") target_compile_options(${DKR_INTERACTIVE_TARGET} PRIVATE - "$") + "$") target_link_directories(${DKR_INTERACTIVE_TARGET} PRIVATE "$") target_link_libraries(${DKR_INTERACTIVE_TARGET} PRIVATE - DKRExperimentalOwnedGame "$") + DKRExperimentalOwnedGame "$") endif() if(WIN32) foreach(DKR_PROBE_DLL @@ -1373,17 +1409,17 @@ if(DKR_RUNTIME_BUILD_GENERATED) # Camera++, the Mod Store HTTP backend and custom vehicles, linked after # the owned engines in the order v1.0.11 linked them. include(cmake/Camera.cmake) - target_link_libraries(DKRPortGame PRIVATE DKRCameraPresentation) + target_link_libraries(DKRPortGameCore PRIVATE DKRCameraPresentation) if(WIN32) - target_link_libraries(DKRPortGame PRIVATE winhttp crypt32 bcrypt) + target_link_libraries(DKRPortGameCore PRIVATE winhttp crypt32 bcrypt) elseif(NOT ANDROID AND NOT APPLE) find_package(CURL REQUIRED) - target_link_libraries(DKRPortGame PRIVATE CURL::libcurl) + target_link_libraries(DKRPortGameCore PRIVATE CURL::libcurl) endif() - target_link_libraries(DKRPortGame PRIVATE DKRCustomVehicles) + target_link_libraries(DKRPortGameCore PRIVATE DKRCustomVehicles) if(UNIX) # The online directory's Linux keyring is loaded at run time. - target_link_libraries(DKRPortGame PRIVATE ${CMAKE_DL_LIBS}) + target_link_libraries(DKRPortGameCore PRIVATE ${CMAKE_DL_LIBS}) endif() endif() @@ -1825,7 +1861,9 @@ if(BUILD_TESTING) target_link_libraries(DKRExperimentalSessionTests PRIVATE DKRNetplayCore) add_test(NAME DKRExperimentalSession COMMAND DKRExperimentalSessionTests) add_executable(DKRExperimentalNetworkTests tests/experimental_network_tests.cpp) - target_link_libraries(DKRExperimentalNetworkTests PRIVATE DKRNetplayCore) + # experimental_network.cpp carries SDK 2 tool packets (sdk2_bridge.cpp in + # DKRCustomMagicCodes); the game resolves them at its own link. + target_link_libraries(DKRExperimentalNetworkTests PRIVATE DKRNetplayCore DKRCustomMagicCodes) add_test(NAME DKRExperimentalNetwork COMMAND DKRExperimentalNetworkTests) add_executable(DKRProbeMemoryInlineTests tests/probe_memory_inline_tests.c tests/replay_probe/probe_bridge.c) target_include_directories(DKRProbeMemoryInlineTests PRIVATE src/game) @@ -1883,7 +1921,7 @@ if(BUILD_TESTING) add_test(NAME DKRQuickJoinLifecycle COMMAND DKRQuickJoinLifecycleTests) set_tests_properties(DKRQuickJoinLifecycle PROPERTIES TIMEOUT 90) add_executable(DKRExperimentalQuickJoinTests tests/experimental_network_tests.cpp) - target_link_libraries(DKRExperimentalQuickJoinTests PRIVATE DKRNetplayCore) + target_link_libraries(DKRExperimentalQuickJoinTests PRIVATE DKRNetplayCore DKRCustomMagicCodes) target_include_directories(DKRExperimentalQuickJoinTests PRIVATE "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include") target_compile_definitions(DKRExperimentalQuickJoinTests PRIVATE DKR_EXPERIMENTAL_QUICKJOIN_TEST=1) add_test(NAME DKRExperimentalQuickJoin COMMAND DKRExperimentalQuickJoinTests) @@ -1955,8 +1993,11 @@ if(BUILD_TESTING) if(TARGET DKRLegacyGuestIO) add_executable(DKRLegacyCacheCapacityTests tests/legacy_cache_capacity_tests.cpp) target_link_libraries(DKRLegacyCacheCapacityTests PRIVATE DKRLegacyModCore) + # The mod launch prepares shared saves, so save_manager.cpp comes along. add_executable(DKRLegacyDkrmapArtworkTests tests/legacy_dkrmap_artwork_tests.cpp src/game/dkr_save_codec.cpp - src/game/rom_revision.cpp src/game/startup_performance.cpp) + src/game/rom_revision.cpp src/game/startup_performance.cpp src/game/save_manager.cpp + src/game/netplay/experimental_pak.cpp) + target_include_directories(DKRLegacyDkrmapArtworkTests PRIVATE "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include") target_link_libraries(DKRLegacyDkrmapArtworkTests PRIVATE DKRLegacyModLaunch) add_test(NAME DKRLegacyDkrmapArtwork COMMAND DKRLegacyDkrmapArtworkTests) add_executable(DKRLegacyTrackMenuAdapterTests tests/legacy_track_menu_adapter_tests.cpp) @@ -2138,7 +2179,7 @@ if(BUILD_TESTING) add_executable(DKRSplitScreenRT64Tests tests/split_screen_rt64_tests.cpp) target_include_directories(DKRSplitScreenRT64Tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" - "$") + "$") target_link_libraries(DKRSplitScreenRT64Tests PRIVATE rt64) target_compile_definitions(DKRSplitScreenRT64Tests PRIVATE NOMINMAX) if(WIN32) @@ -2157,7 +2198,7 @@ if(BUILD_TESTING) add_executable(DKRWaterUVTests tests/water_uv_rt64_tests.cpp) target_include_directories(DKRWaterUVTests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" - "$") + "$") target_link_libraries(DKRWaterUVTests PRIVATE rt64) target_compile_definitions(DKRWaterUVTests PRIVATE NOMINMAX) if(WIN32) @@ -2177,7 +2218,7 @@ if(BUILD_TESTING) add_executable(DKRHudGroupsTests tests/hud_group_layout_tests.cpp) target_include_directories(DKRHudGroupsTests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" - "$") + "$") target_link_libraries(DKRHudGroupsTests PRIVATE rt64) target_compile_definitions(DKRHudGroupsTests PRIVATE NOMINMAX) if(WIN32) @@ -2199,7 +2240,7 @@ if(BUILD_TESTING) "${DKR_RT64_SOURCE}/src/contrib/imgui/backends/imgui_impl_sdlrenderer2.cpp") target_include_directories(DKRHudEditorTests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src/game" - "$") + "$") target_link_libraries(DKRHudEditorTests PRIVATE rt64) target_compile_definitions(DKRHudEditorTests PRIVATE NOMINMAX) if(WIN32) @@ -2219,7 +2260,7 @@ if(BUILD_TESTING) add_test(NAME DKRHudBasicSettings COMMAND DKRHudEditorTests) target_include_directories(DKRLiveLodTests PRIVATE - "$") + "$") target_link_libraries(DKRLiveLodTests PRIVATE rt64) target_compile_definitions(DKRLiveLodTests PRIVATE NOMINMAX) if(WIN32) @@ -2236,7 +2277,7 @@ if(BUILD_TESTING) add_test(NAME DKRLiveLod COMMAND DKRLiveLodTests) add_executable(DKRGeneratedMipsTests tests/generated_mips_tests.cpp) target_include_directories(DKRGeneratedMipsTests PRIVATE "${DKR_RT64_SOURCE}/src/render") - target_include_directories(DKRGeneratedMipsTests PRIVATE "$") + target_include_directories(DKRGeneratedMipsTests PRIVATE "$") target_compile_definitions(DKRGeneratedMipsTests PRIVATE NOMINMAX) target_link_libraries(DKRGeneratedMipsTests PRIVATE rt64) if(WIN32) @@ -2247,7 +2288,7 @@ if(BUILD_TESTING) endif() add_test(NAME DKRGeneratedMips COMMAND DKRGeneratedMipsTests) add_executable(DKRMipmapLoadingTests tests/mipmap_loading_tests.cpp) - target_include_directories(DKRMipmapLoadingTests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src" "$") + target_include_directories(DKRMipmapLoadingTests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src" "$") target_compile_definitions(DKRMipmapLoadingTests PRIVATE NOMINMAX) target_link_libraries(DKRMipmapLoadingTests PRIVATE rt64) if(WIN32) @@ -2362,6 +2403,8 @@ if(BUILD_TESTING) "${CMAKE_CURRENT_LIST_DIR}/src/game" "${DKR_LIBDATACHANNEL_SOURCE}/deps/json/include" ) + # Shared-saves groups identical copies by content hash (dkr::mods::sha256). + target_link_libraries(DKRSaveManagerTests PRIVATE DKRLegacyModCore) add_test(NAME DKRSaveManager COMMAND DKRSaveManagerTests) add_executable(DKRSaveStorageTests tests/save_storage_tests.cpp src/game/dkr_save_codec.cpp) diff --git a/runtime-recomp/CMakePresets.json b/runtime-recomp/CMakePresets.json index 2a370152..70538879 100644 --- a/runtime-recomp/CMakePresets.json +++ b/runtime-recomp/CMakePresets.json @@ -62,6 +62,14 @@ "CMAKE_BUILD_TYPE": "RelWithDebInfo", "DKR_HOST_SANITIZERS": "ON" } + }, + { + "name": "host-full-nogen", + "displayName": "ROM-free build of every module, RT64 on, no generated payload", + "inherits": "host", + "cacheVariables": { + "DKR_RUNTIME_BUILD_RT64": "ON" + } } ], "buildPresets": [ @@ -89,6 +97,11 @@ "name": "host-sanitizers", "configurePreset": "host-sanitizers", "configuration": "RelWithDebInfo" + }, + { + "name": "host-full-nogen", + "configurePreset": "host-full-nogen", + "configuration": "Release" } ], "testPresets": [ @@ -165,6 +178,22 @@ "ASAN_OPTIONS": "halt_on_error=1:abort_on_error=1", "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1" } + }, + { + "name": "host-full-nogen", + "configurePreset": "host-full-nogen", + "configuration": "Release", + "filter": { + "include": { + "name": "^DKR" + } + }, + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error" + } } ] } diff --git a/runtime-recomp/cmake/CustomVehicles.cmake b/runtime-recomp/cmake/CustomVehicles.cmake index 57dc8283..fc623815 100644 --- a/runtime-recomp/cmake/CustomVehicles.cmake +++ b/runtime-recomp/cmake/CustomVehicles.cmake @@ -13,6 +13,12 @@ target_include_directories(DKRCustomVehicles PUBLIC "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib" "${DKR_DEPENDENCIES_DIR}/n64-modern-runtime/thirdparty") target_link_libraries(DKRCustomVehicles PUBLIC DKRCustomMagicCodes DKRLegacyModCore) +if(NOT TARGET rt64) + # Package images decode with stb_image, which RT64 provides in the game. + add_library(DKRStbImage STATIC "${DKRPORT_ROOT}/runtime-recomp/tests/stb_image_provider.c") + target_include_directories(DKRStbImage PRIVATE "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib") + target_link_libraries(DKRCustomVehicles PRIVATE DKRStbImage) +endif() target_link_libraries(DKRLegacyImportLibrary PRIVATE DKRCustomVehicles) target_link_libraries(DKRLegacyModLaunch PRIVATE DKRCustomVehicles) diff --git a/runtime-recomp/cmake/LegacyMods.cmake b/runtime-recomp/cmake/LegacyMods.cmake index c2134d47..07ba620c 100644 --- a/runtime-recomp/cmake/LegacyMods.cmake +++ b/runtime-recomp/cmake/LegacyMods.cmake @@ -130,6 +130,8 @@ if(TARGET rt64) target_link_libraries(DKRLegacyModCore PRIVATE rt64) else() target_link_libraries(DKRLegacyModCore PRIVATE DKRLegacyModMiniz) + # RT64's interface carries src/game (the vehicle protocol headers) otherwise. + target_include_directories(DKRLegacyModCore PRIVATE "${_dkr_mod_src}/..") endif() add_library(DKRLegacyGuestIO STATIC "${_dkr_mod_src}/legacy_runtime_io.cpp" diff --git a/runtime-recomp/tests/stb_image_provider.c b/runtime-recomp/tests/stb_image_provider.c new file mode 100644 index 00000000..e99f42fa --- /dev/null +++ b/runtime-recomp/tests/stb_image_provider.c @@ -0,0 +1,4 @@ +// stb_image for builds without RT64 (host tests). The game links RT64, whose +// texture cache already compiles this implementation from the same header. +#define STB_IMAGE_IMPLEMENTATION +#include "stb/stb_image.h" diff --git a/scripts/fold/allowed-deltas.json b/scripts/fold/allowed-deltas.json index f7b3b9a8..3c73a903 100644 --- a/scripts/fold/allowed-deltas.json +++ b/scripts/fold/allowed-deltas.json @@ -17,6 +17,10 @@ { "difference": "DKRPortGame: runtime-recomp/src/game/rt64_renderer.cpp: object sections differ: data .eh_frame, data .group", "reason": "Header variant mobile_graphics_preset.hpp (PR 68): its new inline helpers instantiate GraphicsConfig's destructor earlier, so that COMDAT is emitted first. All 79 sections exist on both sides and every text section and relocation is identical; only the unwind-table entry order (.eh_frame) and the COMDAT group section indices (.group) follow the new emission order." + }, + { + "difference": "DKRPortGame: link order moved ['-lm', 'libDKRCameraSceneV77.a', 'libDKRCameraSceneV80.a'], everything else in order", + "reason": "The game's TUs compile in the DKRPortGameCore object library (so host builds without a payload compile them), and DKR-R links the core's libraries LINK_ONLY. CMake now places DKRCameraPresentation's private dependencies (the two camera scene kernels and libm) at their first transitive occurrence instead of the end. They still follow DKRCameraPresentation, their only user, and every other library keeps its position." } ] } diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index ed44154a..635e276d 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -60,6 +60,11 @@ ROOT = apply_closure.ROOT ALLOWED = ROOT / "scripts/fold/allowed-deltas.json" +# Folded targets that compile what one reference target compiled: the game's +# TUs moved into an object library so host builds without a generated payload +# compile them too. Compile units and dependencies are compared under the +# reference name; the executable's link line is compared as it is. +COMPILED_BY = {"DKRPortGameCore": "DKRPortGame"} BINARIES = ( "bin/Release/DKR-R", "bin/Release/libexec/dkr-r/DKR-R-ModWorker", @@ -120,6 +125,11 @@ def load_codemodel(reply: Path) -> Model: source["compileGroupIndex"] ] targets[target["name"]] = sources + for folded, original in COMPILED_BY.items(): + if folded in targets: + targets[original] = {**targets.get(original, {}), **targets.pop(folded)} + merged = dependencies.get(original, frozenset()) | dependencies.pop(folded, frozenset()) + dependencies[original] = merged - {folded} return Model(source_dir, build_dir, targets, links, dependencies) @@ -293,10 +303,17 @@ def shipped_pairs(compdb: Path) -> dict[str, set[Path]]: for entry in json.loads(compdb.read_text(encoding="utf-8")): match = re.search(r"CMakeFiles/([^/]+)\.dir/", entry["output"]) if match: - pairs[match.group(1)].add(apply_closure.norm(Path(entry["directory"]) / entry["file"])) + target = COMPILED_BY.get(match.group(1), match.group(1)) + pairs[target].add(apply_closure.norm(Path(entry["directory"]) / entry["file"])) return pairs +def common_order(old: tuple[str, ...], new: tuple[str, ...]) -> list[str]: + """The longest subsequence both link lines share, in order.""" + matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False) + return [item for block in matcher.get_matching_blocks() for item in old[block.a : block.a + block.size]] + + def compare_links( mapping: Mapping, reference: Model, folded: Model, shipped_by_target: dict[str, set[Path]] ) -> Iterator[str]: @@ -309,8 +326,14 @@ def compare_links( if name not in folded.links or name not in shipped_by_target: continue old = tuple(mapping.text(fragment) for fragment in fragments) - if old != folded.links[name]: - yield f"{name}: link line {list(old)} -> {list(folded.links[name])}" + new = folded.links[name] + if old == new: + continue + moved = sorted({f for f in set(old) | set(new) if old.count(f) == new.count(f)} - set(common_order(old, new))) + if sorted(old) == sorted(new) and moved: + yield f"{name}: link order moved {moved}, everything else in order" + else: + yield f"{name}: link line {list(old)} -> {list(new)}" def load_allowed(path: Path) -> list[dict[str, str]]: @@ -403,7 +426,8 @@ def run_tool(command: list[str], cwd: str) -> subprocess.CompletedProcess[str]: def tu_key(entry: dict[str, str]) -> TU: match = re.search(r"CMakeFiles/([^/]+)\.dir/", entry["output"]) - return (match.group(1) if match else entry["output"]), apply_closure.norm(Path(entry["directory"]) / entry["file"]) + target = match.group(1) if match else entry["output"] + return COMPILED_BY.get(target, target), apply_closure.norm(Path(entry["directory"]) / entry["file"]) def load_entries(compdb: Path) -> dict[TU, dict[str, str]]: From c8977f3ec4f084c41522b42707cd661393b49e57 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 20:41:27 -0500 Subject: [PATCH 08/62] Assert shipped v1.0.11 behaviour in six stale host tests Each change cites the layer that made it. Removed features (legacy sync modes, host prediction, per-mod host saves) keep a must-stay-gone check. --- docs/fold-in/host-test-triage.md | 19 + runtime-recomp/tests/direct_session_tests.cpp | 1240 ++--------------- .../tests/experimental_owner_input_tests.cpp | 19 +- runtime-recomp/tests/netplay_core_tests.cpp | 38 +- .../tests/netplay_input_latency_tests.cpp | 36 +- .../tests/online_mod_admission_tests.cpp | 11 +- runtime-recomp/tests/save_manager_tests.cpp | 28 +- 7 files changed, 194 insertions(+), 1197 deletions(-) create mode 100644 docs/fold-in/host-test-triage.md diff --git a/docs/fold-in/host-test-triage.md b/docs/fold-in/host-test-triage.md new file mode 100644 index 00000000..cf65dfd7 --- /dev/null +++ b/docs/fold-in/host-test-triage.md @@ -0,0 +1,19 @@ +# Host test triage + +Linux host ctest (`host` and `host-full-nogen` presets) after the fold. The base +tree's tests describe pre-layer behaviour: the layers changed the sources but +not these tests, so they only broke once the layers were folded in (the +pre-fold base build failed just `DKRExperimentalQuickJoin`). Each one now +asserts what v1.0.11 ships. Where a feature was removed +outright, the test keeps a "must stay gone" check instead of dropping it. + +| Test | What changed | Layer | Action | +|---|---|---|---| +| `DKRExperimentalNetwork`, `DKRExperimentalQuickJoin`, `DKRQuickJoinLifecycle`, `DKRLegacyDkrmapArtwork` | link-only: shipped sources reach into custom magic codes, legacy mods and the save manager | several | link the libraries or sources they need (CMake only, no test edits) | +| `DKRDirectSession` | lobbies are Rollback-only and need the owned engines; Lockstep and host-prediction lobbies are refused; native Gekko transport and host prediction retired | rollback-only | `default_rules()` plus `enable_owned_backend(true)`; legacy launches, the Gekko transport and the host-prediction soak are superseded, with refusal checks | +| `DKRDirectSession` | legacy connection-test scoring, preflight expiry and checkpoint exchange replaced by the isolated Rollback diagnostic | rollback-connection | superseded; the scores stay 0, there's no inconclusive status, and checkpoint requests don't start a test | +| `DKRNetplayCore` | `valid_rules`, `valid_launch_descriptor` and `decode_lobby_state` refuse Lockstep and host prediction | rollback-only | assert the Rollback rules and launch; Lockstep and host-prediction checks are superseded, and they're refused | +| `DKRNetplayInputLatency` | the native relay commits confirmed frames only, with no host prediction window | rollback-only | fixture runs Rollback lobbies; the bounds test asserts commit-to-confirmed and zero prediction waits; host-prediction matrix superseded (lobby refused), strict matrix kept | +| `DKROnlineModAdmission` | admission runs only for the Rollback backend | rollback-only | legacy backends superseded (their lobbies are refused); the owned-engine mismatch always blocks admission | +| `DKRExperimentalOwnerInput` | DKRX v3: command mask byte (53-byte header), 64 bytes of SDK2 command words per commanded input, only owner 0 may author commands | sdk2 | assert v3 sizes; command round trip on an owner-0 packet, refused for other owners | +| `DKRSaveManager` | import refuses a save that needs repair; continuing seeds the first host lobby from personal progress; host progress no longer keyed by mod manifest | shared-saves | assert refusal (live save untouched) and the shared host Adventure; per-mod folders superseded (must not exist) | diff --git a/runtime-recomp/tests/direct_session_tests.cpp b/runtime-recomp/tests/direct_session_tests.cpp index ce8df68b..afa53301 100644 --- a/runtime-recomp/tests/direct_session_tests.cpp +++ b/runtime-recomp/tests/direct_session_tests.cpp @@ -1,6 +1,7 @@ #include "direct_session.hpp" #include "online_input_broker.hpp" #include "progress_budget.hpp" +#include "rollback_policy.hpp" #include #include @@ -117,7 +118,9 @@ struct DirectSessionTestAccess { std::string error; std::scoped_lock lock(receiver.mutex_); assert(!receiver.accept_start_descriptor(start, error)); - assert(error.find("still in development") != std::string::npos); + // v1.0.11 (rollback-only): Rollback is the only mode; without the + // owned engines it reports the build as incomplete. + assert(error.find("Rollback is unavailable in this build") != std::string::npos); assert(!receiver.launch_descriptor_); assert(receiver.launch_stage_ == LaunchStage::Idle); } @@ -126,12 +129,15 @@ struct DirectSessionTestAccess { // Keep these large view temporaries out of main's existing multi-peer // fixture stack frame, particularly on Windows' 1 MiB default stack. const auto before = host.view(); + // v1.0.11 (rollback-only): the inadmissible request is a legacy sync + // mode now; Rollback itself is admitted once the owned engines exist. Rules experiment = rules; - experiment.synchronization = SynchronizationMode::ExperimentalRollback; + experiment.synchronization = SynchronizationMode::Lockstep; + experiment.rollback_window = 0U; std::string error; assert(!host.host(0U, "127.0.0.1", "Experiment", ConnectionMethod::Lan, "Host", experiment, error)); - assert(error.find("still in development") != std::string::npos); + assert(error == rollback_policy::unsupported_lobby); const auto after = host.view(); assert(after.state == before.state); assert(after.match_id == before.match_id); @@ -379,10 +385,11 @@ struct DirectSessionTestAccess { slot == 1U ? 10.0 : slot == 2U ? 120.0 : 500.0); } } + // Superseded in v1.0.11 (rollback-connection): the legacy per-guest + // score is never computed; the isolated Rollback check in + // assessment.hpp rates connections instead. It must stay gone. preflight.service_connection_test_locked(std::chrono::steady_clock::now()); - assert(preflight.connection_test_results_[1].score == 10U); - assert(preflight.connection_test_results_[2].score < 10U); - assert(preflight.connection_test_results_[3].score < preflight.connection_test_results_[2].score); + for (unsigned slot = 1; slot < 4; ++slot) assert(preflight.connection_test_results_[slot].score == 0U); } } static void stale_boundary_and_history_regression() { @@ -419,8 +426,11 @@ struct DirectSessionTestAccess { // history even if the caller did not explicitly disconnect first. std::string join_error; DirectSession reset_host; + // v1.0.11 (rollback-only): lobbies are Rollback lobbies, which need the + // owned engines; a unit test declares them available. + assert(reset_host.enable_owned_backend(true)); assert(reset_host.host(0U, "127.0.0.1", "Reset test", - ConnectionMethod::Lan, "Host", Rules{}, join_error)); + ConnectionMethod::Lan, "Host", rollback_policy::default_rules(), join_error)); assert(client.join(reset_host.view().invite, "Reset test", join_error)); { std::scoped_lock lock(client.mutex_); @@ -672,9 +682,12 @@ struct DirectSessionTestAccess { const auto deadline = session.connection_test_drain_end_; session.handle_client_packet(packet); // Duplicate Begin cannot extend the deadline. assert(session.connection_test_drain_end_ == deadline); + // Superseded in v1.0.11 (rollback-connection): the legacy preflight no + // longer concludes from this service; the isolated Rollback check + // replaced it. It must stay gone. session.service_connection_test_locked(deadline + std::chrono::seconds(1)); - assert(!session.connection_test_active_ && session.terminal_connection_test_id_ == 8U); - assert(session.status_.find("inconclusive") != std::string::npos); + assert(session.connection_test_active_ && session.terminal_connection_test_id_ == 7U); + assert(session.status_.find("inconclusive") == std::string::npos); } static void expire_launch_countdown(DirectSession& session) { std::scoped_lock lock(session.mutex_); @@ -1145,6 +1158,9 @@ void configure_online_session( dkr::runtime::netplay::DirectSession& session, dkr::runtime::netplay::CompatibilityManifest value = manifest()) { session.configure_manifest(value); + // v1.0.11 (rollback-only): every lobby is a Rollback lobby, which host and + // clients admit only with the owned engines; the shipped game has them. + assert(session.enable_owned_backend(true)); session.configure_session_save( online_test_save(), [](std::uint64_t match_id, std::span bytes, @@ -1309,8 +1325,9 @@ int main() { host_invite_file != nullptr && *host_invite_file != '\0') { DirectSession host_probe; configure_online_session(host_probe); - Rules rules{}; + Rules rules = rollback_policy::default_rules(); std::string error; + assert(host_probe.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines if (!host_probe.host(0U, test_host_address(), "Admission Host", ConnectionMethod::VirtualLan, "Host", rules, error)) { @@ -1461,8 +1478,9 @@ int main() { DirectSession worker_client; configure_online_session(worker_host); configure_online_session(worker_client); - Rules worker_rules{}; + Rules worker_rules = rollback_policy::default_rules(); std::string worker_error; + assert(worker_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines if (!worker_host.host(0U, test_host_address(), "Worker Lobby", ConnectionMethod::VirtualLan, "Host", worker_rules, worker_error)) { @@ -1519,8 +1537,9 @@ int main() { DirectSession friend_client; configure_online_session(friend_host); configure_online_session(friend_client); - Rules rules{}; + Rules rules = rollback_policy::default_rules(); std::string error; + assert(friend_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(friend_host.host(0U, test_host_address(), "Friend Invite Lobby", ConnectionMethod::VirtualLan, "Host", rules, error)); secure::Key admission{}; @@ -1611,8 +1630,9 @@ int main() { install_error.clear(); return true; }); - Rules diagnostic_rules{}; + Rules diagnostic_rules = rollback_policy::default_rules(); std::string diagnostic_error; + assert(diagnostic_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(diagnostic_host.host(0U, test_host_address(), "Save Diagnostic", ConnectionMethod::Lan, "Host", diagnostic_rules, diagnostic_error)); @@ -1643,8 +1663,9 @@ int main() { client_manifest.session_save_hash ^= 1U; no_save_host.configure_manifest(host_manifest); mismatched_client.configure_manifest(client_manifest); - Rules rules{}; + Rules rules = rollback_policy::default_rules(); std::string error; + assert(no_save_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(no_save_host.host(0U, test_host_address(), "No Save Lobby", ConnectionMethod::Lan, "Host", rules, error)); assert(mismatched_client.join(no_save_host.view().invite, @@ -1659,8 +1680,9 @@ int main() { DirectSession rejected_client; configure_online_session(guarded_host); configure_online_session(rejected_client); - Rules guarded_rules{}; + Rules guarded_rules = rollback_policy::default_rules(); std::string guarded_error; + assert(guarded_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(guarded_host.host(0U, test_host_address(), "Guarded Lobby", ConnectionMethod::Lan, "Host", guarded_rules, guarded_error)); const std::string first_invite = guarded_host.view().invite; @@ -1692,9 +1714,10 @@ int main() { DirectSession checkpoint_client; configure_online_session(checkpoint_host); configure_online_session(checkpoint_client); - Rules checkpoint_rules{}; + Rules checkpoint_rules = rollback_policy::default_rules(); checkpoint_rules.automatic_input_delay = false; std::string checkpoint_error; + assert(checkpoint_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(checkpoint_host.host(0U, test_host_address(), "Checkpoint Lobby", ConnectionMethod::Lan, "Host", checkpoint_rules, checkpoint_error)); @@ -1705,48 +1728,15 @@ int main() { checkpoint_host.view().pending_joins[0].request_id, checkpoint_error)); pump_pair(checkpoint_host, checkpoint_client); + // v1.0.11 (rollback-connection): the connection check is the isolated + // Rollback diagnostic, which never starts the legacy preflight. The + // legacy scores and the legacy cold-boot checkpoint exchange this + // scenario used to drive are superseded (Rollback lobbies exchange + // checkpoints in the owned engines) and must stay gone. assert(checkpoint_host.request_connection_test(checkpoint_error)); - assert(checkpoint_host.view().connection_test_active); - assert(!checkpoint_host.request_start(checkpoint_error)); - const auto preflight_deadline = std::chrono::steady_clock::now() + - std::chrono::seconds(10); - while (checkpoint_host.view().connection_test_active && - std::chrono::steady_clock::now() < preflight_deadline) { - pump_pair(checkpoint_host, checkpoint_client, 1); - } + assert(!checkpoint_host.view().connection_test_active); pump_pair(checkpoint_host, checkpoint_client, 50); - const auto host_preflight = checkpoint_host.view(); - const auto client_preflight = checkpoint_client.view(); - assert(!host_preflight.connection_test_active); - assert(host_preflight.connection_test_result_generation != 0U); - assert(client_preflight.connection_test_result_generation != 0U); - assert(host_preflight.connection_test_results[0].valid); - assert(host_preflight.connection_test_results[1].valid); - assert(client_preflight.connection_test_results[0].valid); - assert(client_preflight.connection_test_results[1].valid); - assert(host_preflight.connection_test_results[0].score == 10U); - assert(host_preflight.connection_test_results[1].score >= 1U); - assert(host_preflight.connection_test_results[1].score <= 10U); - assert(checkpoint_host.set_ready(true, checkpoint_error)); - assert(checkpoint_client.set_ready(true, checkpoint_error)); - pump_pair(checkpoint_host, checkpoint_client); - assert(checkpoint_host.request_start(checkpoint_error)); - assert(checkpoint_host.view().launch_countdown_active); - DirectSessionTestAccess::expire_launch_countdown(checkpoint_host); - pump_pair(checkpoint_host, checkpoint_client, 200); - assert(checkpoint_host.consume_launch_request()); - assert(checkpoint_client.consume_launch_request()); - mark_online_game_loaded(checkpoint_host, 0x1111111111111111ULL); - mark_online_game_loaded(checkpoint_client, 0x2222222222222222ULL); - // The launch countdown adds one reliable lobby generation before the - // cold-boot exchange. Give both local UDP workers the same bounded - // settling window used by the ordinary pair helper before asserting - // the deliberately mismatched checkpoint failure. - pump_pair(checkpoint_host, checkpoint_client, 300); - assert(checkpoint_host.view().state == ConnectionState::Failed); - assert(checkpoint_client.view().state == ConnectionState::Failed); - assert(checkpoint_host.view().status.find("checkpoint") != - std::string::npos); + assert(checkpoint_host.view().connection_test_result_generation == 0U); } if constexpr (kSupportedOnlinePlayers == 4U) { const auto check_multi_racer = []() { @@ -1772,12 +1762,12 @@ int main() { for (DirectSession* session : sessions) { configure_online_session(*session); } - Rules four_rules{}; + Rules four_rules = rollback_policy::default_rules(); four_rules.maximum_players = static_cast(PlayerCount); four_rules.automatic_input_delay = false; four_rules.manual_input_delay = 2U; - four_rules.synchronization = SynchronizationMode::Rollback; std::string four_error; + assert(four_host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(four_host.host(0U, test_host_address(), "Four Racer Lobby", ConnectionMethod::Lan, "Host", four_rules, four_error)); @@ -1810,345 +1800,37 @@ int main() { assert(sessions[slot]->view().local_slot == slot); assert(sessions[slot]->set_ready(true, four_error)); } - pump_sessions(sessions, 80); - assert(four_host.request_start(four_error)); - assert(four_host.view().launch_countdown_active); - DirectSessionTestAccess::expire_launch_countdown(four_host); - pump_sessions(sessions, 80); - for (DirectSession* session : sessions) { - assert(session->consume_launch_request()); - const auto descriptor = session->launch_descriptor(); - assert(descriptor); - assert(descriptor->occupied_mask == (1U << PlayerCount) - 1U); - assert(descriptor->player_count == PlayerCount); - mark_online_game_loaded(*session, 0x44524B5241434534ULL); - } - pump_sessions(sessions, 100); - for (DirectSession* session : sessions) assert(session->running()); - - // Track construction can begin on a different frontend tick on each - // machine. The first racer to enter load must park all four input - // timelines without consuming frame zero or declaring a disconnect. - for (std::size_t index = 1U; index < sessions.size(); ++index) { - assert(sessions[index]->begin_gameplay_handoff( - 0U, 73U, four_error)); - } - assert(four_host.begin_gameplay_handoff(0U, 73U, four_error)); - pump_sessions(sessions, 80); - for (DirectSession* session : sessions) { - assert(session->running()); - assert(session->gameplay_handoff_suspended(0U)); - } - - for (DirectSession* session : sessions) { - session->begin_authoritative_phase(); - } - std::array, PlayerCount> ready; - std::array ready_errors; - for (std::size_t index = 0U; index < sessions.size(); ++index) { - ready[index] = std::async( - std::launch::async, [&, index] { - return sessions[index]->wait_gameplay_ready( - 73U, 73U, PlayerCount, std::chrono::seconds(3), - ready_errors[index]); - }); - } - for (auto& future : ready) assert(future.get()); - - std::vector baseline(3713U); - for (std::size_t index = 0U; index < baseline.size(); ++index) { - baseline[index] = static_cast(index * 29U + 11U); - } - assert(four_host.publish_authoritative_state(0U, baseline, - four_error)); - std::array, PlayerCount - 1U> installers; - for (std::size_t index = 0U; index < installers.size(); ++index) { - installers[index] = std::async( - std::launch::async, [&, index] { - std::vector received; - DirectSession* client_session = sessions[index + 1U]; - if (!client_session->wait_authoritative_state( - 0U, received, std::chrono::seconds(3)) || - received != baseline) { - return false; - } - client_session->confirm_authoritative_state(0U, false); - return true; - }); - } - assert(four_host.wait_authoritative_acknowledgements( - 0U, std::chrono::seconds(3))); - for (auto& future : installers) assert(future.get()); - - // Player 1 publishes Arm first. Every machine installs the fresh input - // epoch while still parked; only the later Go broadcast releases it. - assert(four_host.poll_gameplay_resume(73U, PlayerCount, four_error) == - SessionPollResult::Pending); - pump_sessions(sessions, 40); - SessionPollResult host_go = SessionPollResult::Pending; - for (int attempt = 0; attempt < 200 && - host_go == SessionPollResult::Pending; - ++attempt) { - for (std::size_t index = 1U; index < sessions.size(); ++index) { - assert(sessions[index]->poll_gameplay_resume( - 73U, PlayerCount, four_error) != - SessionPollResult::Failed); - } - pump_sessions(sessions, 1); - host_go = four_host.poll_gameplay_resume(73U, PlayerCount, four_error); - } - assert(host_go == SessionPollResult::Ready); - for (std::size_t index = 1U; index < sessions.size(); ++index) { - pump_pair_until(four_host, *sessions[index], "multi-racer Go", [&] { - const auto result = sessions[index]->poll_gameplay_resume(73U, PlayerCount, four_error); - assert(result != SessionPollResult::Failed); - return result == SessionPollResult::Ready; - }); - assert(sessions[index]->poll_gameplay_resume( - 73U, PlayerCount, four_error) == SessionPollResult::Ready); - std::uint32_t resume_frame = ~0U; - assert(sessions[index]->complete_gameplay_handoff( - resume_frame, four_error)); - assert(resume_frame == 0U); - } - std::uint32_t host_resume_frame = ~0U; - assert(four_host.complete_gameplay_handoff(host_resume_frame, - four_error)); - assert(host_resume_frame == 0U); - - // Player 1's live race stream is latest-wins: publishing several - // authored frames before the worker drains its queue must deliver the - // newest complete state without an obsolete-frame backlog. - std::vector live_state(3713U); - for (std::uint32_t frame = 0U; frame < 8U; ++frame) { - for (std::size_t index = 0U; index < live_state.size(); ++index) { - live_state[index] = static_cast( - index * 17U + frame * 31U); - } - assert(four_host.publish_live_replica( - frame, live_state, frame % 6U == 0U, four_error)); - } - pump_sessions(sessions, 120); - for (std::size_t index = 1U; index < sessions.size(); ++index) { - std::vector received; - SessionPollResult result = SessionPollResult::Pending; - for (int attempt = 0; attempt < 200 && - result == SessionPollResult::Pending; - ++attempt) { - result = sessions[index]->poll_live_replica( - 7U, received, four_error); - pump_sessions(sessions, 1); - } - assert(result == SessionPollResult::Ready); - assert(received == live_state); - } - - // A client which temporarily falls behind may take the newest complete - // disposable sample in a bounded window. This is deliberately - // separate from strict lifecycle checkpoints, which remain exact. - for (std::uint32_t frame = 8U; frame <= 10U; ++frame) { - for (std::size_t index = 0U; index < live_state.size(); ++index) { - live_state[index] = static_cast( - index * 23U + frame * 19U); - } - assert(four_host.publish_live_replica( - frame, live_state, frame % 6U == 0U, four_error)); - } - pump_sessions(sessions, 120); - for (std::size_t index = 1U; index < sessions.size(); ++index) { - std::uint32_t received_frame = 0U; - std::vector received; - SessionPollResult result = SessionPollResult::Pending; - for (int attempt = 0; attempt < 200 && - result == SessionPollResult::Pending; - ++attempt) { - result = sessions[index]->poll_latest_live_replica( - 8U, 10U, received_frame, received, four_error); - pump_sessions(sessions, 1); - } - assert(result == SessionPollResult::Ready); - assert(received_frame == 10U); - assert(received == live_state); - } - - for (std::uint32_t frame = 0U; frame < 64U; ++frame) { - std::array committed{}; - std::array, PlayerCount> input_futures; - for (std::size_t index = 0U; index < sessions.size(); ++index) { - input_futures[index] = std::async( - std::launch::async, [&, index, frame] { - const PackedInput local{ - static_cast(0x1000U << index), - static_cast(12 + index * 9), - static_cast(-8 - index * 7)}; - return sessions[index]->synchronize_inputs( - frame, local, committed[index], - std::chrono::seconds(3)); - }); - } - for (auto& future : input_futures) assert(future.get()); - for (std::size_t index = 1U; index < committed.size(); ++index) { - assert(committed[index] == committed[0]); - } - if (frame == 7U) { - // A redundant authored boundary must be an idempotent read of - // the exact locally-consumed frame. It must not advance the - // commit chain or prevent the following frame from committing. - for (std::size_t index = 0U; index < sessions.size(); ++index) { - FrameInputs repeated{}; - const PackedInput different_local{ - static_cast(0x0001U << index), - static_cast(-60 + index), - static_cast(60 - index)}; - assert(sessions[index]->synchronize_inputs_result( - frame, different_local, repeated, - std::chrono::milliseconds(0)) == - InputSynchronizationResult::AlreadyCommitted); - assert(repeated == committed[index]); - assert(sessions[index]->running()); - assert(DirectSessionTestAccess::next_commit_frame( - *sessions[index]) == frame + 1U); - } - } - if (frame >= four_rules.manual_input_delay) { - for (std::size_t slot = 0U; slot < sessions.size(); ++slot) { - assert(committed[0][slot].buttons == - static_cast(0x1000U << slot)); - } - } - } - - // Rollback packets are peer-addressed but relayed by Player 1. Prove - // that simultaneous client-to-client traffic reaches the intended - // slots instead of colliding in the host queue. - const std::array from_two{2U, 3U, 4U}; - const std::array from_three{3U, 2U, 4U}; - assert(four_client_2.send_rollback_packet(2U, from_two)); - assert(four_client_3.send_rollback_packet(1U, from_three)); - pump_sessions(sessions, 80); - const auto at_three = four_client_3.take_rollback_packets(); - const auto at_two = four_client_2.take_rollback_packets(); - assert(at_three.size() == 1U && at_three[0].source_slot == 1U && - at_three[0].bytes == std::vector( - from_two.begin(), from_two.end())); - assert(at_two.size() == 1U && at_two[0].source_slot == 2U && - at_two[0].bytes == std::vector( - from_three.begin(), from_three.end())); + // v1.0.11 (rollback-only): from here the scenario drove the legacy + // host-prediction launch and relayed input epochs through + // DirectSession. Rollback lobbies run gameplay in the owned engines + // instead, which a unit test does not have; that path is superseded + // here, and the private replay and playtests (fold level 4) cover the + // Rollback launch. The lobby contract above still holds for 3 and 4 + // racers. }; check_multi_racer.template operator()<3U>(); check_multi_racer.template operator()<4U>(); } { - // The race-start handoff is shared by both network modes. Exercise a - // complete strict-lockstep launch separately so a Rollback-only pass - // cannot conceal a client left on the black loading frame. + // Superseded in v1.0.11 (rollback-only): strict lockstep is no longer + // a lobby mode, so its launch is gone. It must stay gone: a Lockstep + // lobby is refused with the unsupported-mode message. DirectSession lockstep_host; - DirectSession lockstep_client; - configure_online_session(lockstep_host); - configure_online_session(lockstep_client); - Rules lockstep_rules{}; - lockstep_rules.automatic_input_delay = false; - lockstep_rules.manual_input_delay = 2U; + Rules lockstep_rules = rollback_policy::default_rules(); lockstep_rules.synchronization = SynchronizationMode::Lockstep; lockstep_rules.rollback_window = 0U; std::string lockstep_error; - assert(lockstep_host.host( - 0U, test_host_address(), "Lockstep Barrier Lobby", + assert(lockstep_host.enable_owned_backend(true)); + assert(!lockstep_host.host(0U, test_host_address(), "Lockstep Lobby", ConnectionMethod::Lan, "Host", lockstep_rules, lockstep_error)); - assert(lockstep_client.join(lockstep_host.view().invite, "Client", - lockstep_error)); - pump_pair(lockstep_host, lockstep_client, 80); - assert(lockstep_host.approve_join( - lockstep_host.view().pending_joins.front().request_id, - lockstep_error)); - pump_pair(lockstep_host, lockstep_client, 80); - assert(lockstep_host.set_ready(true, lockstep_error)); - assert(lockstep_client.set_ready(true, lockstep_error)); - pump_pair(lockstep_host, lockstep_client, 40); - assert(lockstep_host.request_start(lockstep_error)); - assert(lockstep_host.view().launch_countdown_active); - DirectSessionTestAccess::expire_launch_countdown(lockstep_host); - pump_pair_until(lockstep_host, lockstep_client, "lockstep launch release", [&] { - return DirectSessionTestAccess::launch_pending(lockstep_host) && - DirectSessionTestAccess::launch_pending(lockstep_client); - }); - assert(lockstep_host.consume_launch_request()); - assert(lockstep_client.consume_launch_request()); - mark_online_game_loaded(lockstep_host, 0x4C4F434B53544550ULL); - mark_online_game_loaded(lockstep_client, 0x4C4F434B53544550ULL); - pump_pair_until(lockstep_host, lockstep_client, "loaded lockstep race", [&] { - return lockstep_host.running() && lockstep_client.running(); - }); - assert(lockstep_host.running()); - assert(lockstep_client.running()); - assert(lockstep_client.begin_gameplay_handoff( - 0U, 81U, lockstep_error)); - assert(lockstep_host.begin_gameplay_handoff( - 0U, 81U, lockstep_error)); - pump_pair(lockstep_host, lockstep_client, 40); - lockstep_host.begin_authoritative_phase(); - lockstep_client.begin_authoritative_phase(); - auto host_ready = std::async(std::launch::async, [&] { - return lockstep_host.wait_gameplay_ready( - 81U, 81U, 2U, std::chrono::seconds(2), lockstep_error); - }); - std::string client_ready_error; - auto client_ready = std::async(std::launch::async, [&] { - return lockstep_client.wait_gameplay_ready( - 81U, 81U, 2U, std::chrono::seconds(2), - client_ready_error); - }); - assert(host_ready.get()); - assert(client_ready.get()); - std::vector baseline(3713U, 0x5AU); - assert(lockstep_host.publish_authoritative_state( - 0U, baseline, lockstep_error)); - auto baseline_client = std::async(std::launch::async, [&] { - std::vector received; - if (!lockstep_client.wait_authoritative_state( - 0U, received, std::chrono::seconds(2)) || - received != baseline) { - return false; - } - lockstep_client.confirm_authoritative_state(0U, false); - return true; - }); - assert(lockstep_host.wait_authoritative_acknowledgements( - 0U, std::chrono::seconds(2))); - assert(baseline_client.get()); - assert(lockstep_host.poll_gameplay_resume( - 81U, 2U, lockstep_error) == - SessionPollResult::Pending); - pump_pair_until(lockstep_host, lockstep_client, "lockstep Arm announcement", [&] { - return DirectSessionTestAccess::resume_arm_announced(lockstep_client); - }); - assert(lockstep_client.poll_gameplay_resume( - 81U, 2U, client_ready_error) == - SessionPollResult::Pending); - pump_pair_until(lockstep_host, lockstep_client, "lockstep_host resume Ready", [&] { - const auto result = lockstep_host.poll_gameplay_resume(81U, 2U, lockstep_error); - assert(result != SessionPollResult::Failed); - return result == SessionPollResult::Ready; - }); - pump_pair_until(lockstep_host, lockstep_client, "lockstep_client resume Ready", [&] { - const auto result = lockstep_client.poll_gameplay_resume(81U, 2U, client_ready_error); - assert(result != SessionPollResult::Failed); - return result == SessionPollResult::Ready; - }); - std::uint32_t client_frame = ~0U; - assert(lockstep_client.complete_gameplay_handoff( - client_frame, lockstep_error)); - std::uint32_t host_frame = ~0U; - assert(lockstep_host.complete_gameplay_handoff( - host_frame, lockstep_error)); - assert(host_frame == 0U && client_frame == host_frame); + assert(lockstep_error == rollback_policy::unsupported_lobby); } DirectSession host; DirectSession client; configure_online_session(host); configure_online_session(client); - Rules rules{}; + Rules rules = rollback_policy::default_rules(); rules.automatic_input_delay = false; rules.manual_input_delay = 2U; // Exercise the smallest practical runway used by real rollback lobbies. @@ -2156,6 +1838,7 @@ int main() { // block the authored game thread for an additional repair grace period. rules.rollback_window = 3U; std::string error; + assert(host.enable_owned_backend(true)); // v1.0.11: Rollback lobbies need the owned engines assert(host.host(0U, test_host_address(), "Loopback Lobby", ConnectionMethod::Lan, "Host", rules, error)); assert(host.view().invite.starts_with("dkr-r://v7/")); @@ -2395,789 +2078,34 @@ int main() { stale_before + 1U); assert(host.running()); - // Resume must open the scene-scoped rollback transport in both directions. - // A barrier that looks complete to the UI but still drops Gekko input - // packets would leave both games running with permanently neutral pads. + // Superseded in v1.0.11 (rollback-only): the Gekko rollback transport + // served host-prediction lobbies. Rollback lobbies carry owned-engine + // packets instead (send_owned_packet). It must stay gone: the legacy + // transport refuses to send in a Rollback lobby. const std::array host_rollback{ 0x44U, 0x4BU, 0x52U, 0x01U, 0x80U, 25U, 0xFBU}; - assert(host.send_rollback_packet(1U, host_rollback)); - decltype(client.take_rollback_packets()) client_rollback; - pump_pair_until(host, client, "client rollback packet", [&] { - client_rollback = client.take_rollback_packets(); - return !client_rollback.empty(); - }); - assert(client_rollback.size() == 1U); - assert(client_rollback[0].source_slot == 0U); - assert(client_rollback[0].bytes == std::vector( - host_rollback.begin(), host_rollback.end())); - - const std::array client_rollback_payload{ - 0x44U, 0x4BU, 0x52U, 0x02U, 0x40U, 0xE2U, 8U}; - assert(client.send_rollback_packet(0U, client_rollback_payload)); - decltype(host.take_rollback_packets()) host_rollback_packets; - pump_pair_until(host, client, "host rollback packet", [&] { - host_rollback_packets = host.take_rollback_packets(); - return !host_rollback_packets.empty(); - }); - assert(host_rollback_packets.size() == 1U); - assert(host_rollback_packets[0].source_slot == 1U); - assert(host_rollback_packets[0].bytes == std::vector( - client_rollback_payload.begin(), client_rollback_payload.end())); - - // Player 1 may predict only the configured window before Player 2 - // submits its race input. Exhaustion yields Pending without blocking. - // Player 2 then catches up. Published host frames are immutable: a late - // guest sample must never rewind a partially captured native world. The - // client consumes the already-authored neutral prediction and later - // frames use the newly confirmed input normally. - const std::uint32_t prediction_burst_frames = - rules.manual_input_delay + rules.rollback_window; + assert(!host.send_rollback_packet(1U, host_rollback)); + + // Superseded in v1.0.11 (rollback-only): the rest of this scenario soaked + // host prediction (the prediction window, forced predictions, repair of + // host-predicted commits, and the finish and track lifecycles built on + // them). Host prediction belongs to the retired host-prediction mode + // (host_may_predict_input); a Rollback lobby's native relay commits only + // confirmed frames, and the owned engines do the rollback. It must stay + // gone: Player 1 gets just the input-delay runway, then waits without + // predicting. for (std::uint32_t frame = host_resume_frame; - frame < host_resume_frame + prediction_burst_frames; ++frame) { + frame < host_resume_frame + rules.manual_input_delay; ++frame) { FrameInputs host_inputs{}; - const auto synchronize_started = std::chrono::steady_clock::now(); - assert(host.synchronize_inputs_result( - frame, {0x8000U, 25, -5}, host_inputs, - std::chrono::milliseconds(0)) == + assert(host.synchronize_inputs_result(frame, {0x8000U, 25, -5}, host_inputs, + std::chrono::milliseconds(0)) == InputSynchronizationResult::Committed); - const auto synchronize_elapsed = - std::chrono::steady_clock::now() - synchronize_started; - assert(synchronize_elapsed < std::chrono::milliseconds(25)); pump_pair(host, client, 4); } FrameInputs waiting_inputs{}; - assert(host.synchronize_inputs_result( - host_resume_frame + prediction_burst_frames, {}, waiting_inputs, - std::chrono::milliseconds(0)) == InputSynchronizationResult::Pending); - assert(DirectSessionTestAccess::forced_prediction_frames(host) == 0U); - assert(host.view().prediction_limit_waits > 0U); - // The completed-frame watermark is distinct from commit delivery. Once a - // rollback guest reaches the measured lead ceiling, Player 1 must park; - // the old 96-frame exception allowed 3.2 seconds of permanent client debt. - client.report_simulation_progress(host_resume_frame); - pump_pair_until(host, client, "race completed-frame watermark", [&] { - return host.host_should_backpressure( - host_resume_frame + prediction_burst_frames, 3U) && - host.view().recovering_peer_count == 1U; - }); - assert(host.host_should_backpressure( - host_resume_frame + prediction_burst_frames, 3U)); - assert(host.view().recovering_peer_count == 1U); - assert(host.host_should_backpressure(host_resume_frame + 100U, 3U)); - for (std::uint32_t frame = host_resume_frame; - frame < host_resume_frame + prediction_burst_frames; ++frame) { - FrameInputs client_inputs{}; - assert(client.synchronize_inputs( - frame, {0x4000U, -30, 8}, client_inputs, - std::chrono::seconds(2))); - client.report_simulation_progress(frame); - pump_pair(host, client, 30); - FrameInputs corrected_inputs{}; - std::uint16_t corrected_revision = 0U; - assert(host.authoritative_inputs_for( - frame, corrected_inputs, corrected_revision)); - assert(client_inputs == corrected_inputs); - - // The transport result, rather than whichever SDL profile happened - // to poll last, is the immutable four-port frame presented to DKR. - OnlineInputBroker host_broker; - OnlineInputBroker client_broker; - host_broker.configure(true, 0x03U, 0U); - client_broker.configure(true, 0x03U, 1U); - host_broker.set_local_profile(2U); - client_broker.set_local_profile(3U); - host_broker.capture_local(2U, 0x1000U, 0.25F, -0.25F, false); - client_broker.capture_local(3U, 0x2000U, -0.5F, 0.5F, false); - assert(host_broker.publish(frame, InputFrameSource::GameplayLockstep, - corrected_inputs)); - assert(client_broker.publish(frame, - InputFrameSource::GameplayLockstep, - client_inputs)); - assert(host_broker.input_for_port(0U) == corrected_inputs[0]); - assert(host_broker.input_for_port(1U) == corrected_inputs[1]); - assert(client_broker.input_for_port(0U) == client_inputs[0]); - assert(client_broker.input_for_port(1U) == client_inputs[1]); - assert(host_broker.input_for_port(2U) == PackedInput{}); - assert(client_broker.input_for_port(3U) == PackedInput{}); - - // A later physical poll cannot erase or replace that committed frame. - host_broker.capture_local(2U, 0xFFFFU, -1.0F, 1.0F, true); - client_broker.capture_local(3U, 0xFFFFU, 1.0F, -1.0F, true); - assert(host_broker.input_for_port(0U) == corrected_inputs[0]); - assert(client_broker.input_for_port(1U) == client_inputs[1]); - if (frame >= host_resume_frame + 2U) { - assert(corrected_inputs[0].buttons == 0x8000U); - } - assert(client_inputs[1] == corrected_inputs[1]); - assert(client_inputs[1] == PackedInput{}); - } - assert(!host.host_should_backpressure( - host_resume_frame + prediction_burst_frames, 3U)); - pump_pair(host, client, 120); - assert(DirectSessionTestAccess::applied_correction_generation(client) == - 0U); - assert(!DirectSessionTestAccess::correction_transmission_pending(host)); - // Acknowledgement is contiguous rather than merely "newest observed". - // Host-relative guest submission can legitimately fill additional - // hold-last slots when several Player-1 commits arrive in one pump. Both - // ends must agree on the frame after the newest sample actually submitted, - // and that cursor must cover the original prediction burst. - const SessionView client_input_view = client.view(); - const std::uint32_t expected_first_missing = - client_input_view.local_input_submitted_frame + 1U; - assert(expected_first_missing >= host_resume_frame + - prediction_burst_frames + rules.manual_input_delay); - assert(DirectSessionTestAccess::peer_first_missing_input(host, 1U) == - expected_first_missing); - assert(DirectSessionTestAccess::host_first_missing_input(client) == - expected_first_missing); - - // A single lost authoritative commit used to park Player 2 forever and, - // once Player 1 exhausted its prediction runway, freeze both game threads - // while audio continued. Reproduce that exact failure by discarding a - // commit after delivery. The guest must request the missing hash-chain - // prefix and resume without tearing down or restarting the race. - const std::uint32_t repaired_commit_frame = - host_resume_frame + prediction_burst_frames; - FrameInputs repair_host_inputs{}; - assert(host.synchronize_inputs_result( - repaired_commit_frame, {0x8000U, 25, -5}, repair_host_inputs, - std::chrono::milliseconds(0)) == - InputSynchronizationResult::Committed); - pump_pair_until(host, client, "repair fixture commit delivery", [&] { - return DirectSessionTestAccess::has_frame_commit(client, repaired_commit_frame); - }); - assert(DirectSessionTestAccess::has_frame_commit( - client, repaired_commit_frame)); - DirectSessionTestAccess::discard_frame_commit( - client, repaired_commit_frame); - const std::uint64_t repair_requests_before = - DirectSessionTestAccess::commit_repair_requests_sent(client); - FrameInputs repair_client_inputs{}; - assert(client.synchronize_inputs_result( - repaired_commit_frame, {0x4000U, -30, 8}, - repair_client_inputs, std::chrono::milliseconds(0)) == - InputSynchronizationResult::Pending); - pump_pair_until(host, client, "commit repair request", [&] { - return DirectSessionTestAccess::commit_repair_requests_sent(client) > repair_requests_before && - DirectSessionTestAccess::commit_repair_requests_received(host) > 0U; - }); - assert(DirectSessionTestAccess::commit_repair_requests_sent(client) > - repair_requests_before); - assert(DirectSessionTestAccess::commit_repair_requests_received(host) > - 0U); - assert(client.synchronize_inputs( - repaired_commit_frame, {0x4000U, -30, 8}, repair_client_inputs, - std::chrono::seconds(2))); - FrameInputs repaired_authoritative_inputs{}; - std::uint16_t repaired_authoritative_revision = 0U; - assert(host.authoritative_inputs_for( - repaired_commit_frame, repaired_authoritative_inputs, - repaired_authoritative_revision)); - assert(repair_client_inputs == repaired_authoritative_inputs); - assert(host.running()); - assert(client.running()); - - // A short one-packet repair is not representative of a Wi-Fi/VPN latency - // spike. Retain and recover more than the old 64-frame host history in two - // requested batches. The client must walk the immutable hash chain all the - // way back to live play without a future-boundary recovery it cannot reach. - constexpr std::uint32_t sustained_outage_frames = 96U; - const std::uint32_t sustained_outage_begin = repaired_commit_frame + 1U; - for (std::uint32_t frame = sustained_outage_begin; - frame < sustained_outage_begin + sustained_outage_frames; ++frame) { - // This test loses downstream commits, not upstream controls. Keep - // real client inputs available; unlimited guessed-input advancement - // is no longer permitted merely to manufacture a long commit history. - protocol::InputBatch available{}; - available.epoch = DirectSessionTestAccess::authority_epoch(host); - available.player_slot = 1U; - available.first_frame = frame; - available.inputs = {{0x4000U, -30, 8}}; - available.revisions = {100U}; - DirectSessionTestAccess::inject_client_input(host, 1U, available); - FrameInputs host_inputs{}; - assert(host.synchronize_inputs_result( - frame, {0x8000U, 25, -5}, host_inputs, - std::chrono::milliseconds(0)) == - InputSynchronizationResult::Committed); - pump_pair(host, client, 2); - } - pump_pair(host, client, 80); - assert(DirectSessionTestAccess::has_frame_commit( - client, sustained_outage_begin)); - assert(DirectSessionTestAccess::has_frame_commit( - client, sustained_outage_begin + sustained_outage_frames - 1U)); - DirectSessionTestAccess::discard_frame_commit_range( - client, sustained_outage_begin, sustained_outage_frames); - - const std::uint64_t sustained_requests_before = - DirectSessionTestAccess::commit_repair_requests_sent(client); - for (std::uint32_t frame = sustained_outage_begin; - frame < sustained_outage_begin + sustained_outage_frames; ++frame) { - FrameInputs client_inputs{}; - const auto deadline = std::chrono::steady_clock::now() + - std::chrono::seconds(2); - for (;;) { - const InputSynchronizationResult result = - client.synchronize_inputs_result( - frame, {0x4000U, -30, 8}, client_inputs, - std::chrono::milliseconds(0)); - if (result == InputSynchronizationResult::Committed) break; - assert(result == InputSynchronizationResult::Pending); - assert(std::chrono::steady_clock::now() < deadline); - pump_pair(host, client, 4); - } - FrameInputs host_inputs{}; - std::uint16_t revision = 0U; - assert(host.authoritative_inputs_for(frame, host_inputs, revision)); - assert(client_inputs == host_inputs); - } - assert(DirectSessionTestAccess::commit_repair_requests_sent(client) > - sustained_requests_before); - assert(host.running()); - assert(client.running()); - - // Exercise repeated fragmented state delivery and acknowledgements before - // the longer lifecycle soak. Production sends this payload only at an - // explicit recovery boundary or the final finish seal. - std::uint32_t expected_authoritative_frame = 0U; - const std::uint32_t lifecycle_begin = - sustained_outage_begin + sustained_outage_frames; - for (std::uint32_t frame = lifecycle_begin; - frame < lifecycle_begin + 29U; ++frame) { - FrameInputs host_inputs{}; - FrameInputs client_inputs{}; - auto host_future = std::async(std::launch::async, [&] { - return host.synchronize_inputs(frame, {0x8000U, 25, -5}, - host_inputs, - std::chrono::seconds(2)); - }); - auto client_future = std::async(std::launch::async, [&] { - return client.synchronize_inputs(frame, {0x4000U, -30, 8}, - client_inputs, - std::chrono::seconds(2)); - }); - assert(host_future.get()); - assert(client_future.get()); - assert(host_inputs == client_inputs); - if ((frame % 6U) == 0U) { - std::vector checkpoint(3713U); - for (std::size_t index = 0U; index < checkpoint.size(); ++index) { - checkpoint[index] = static_cast( - index * 37U + frame); - } - assert(host.publish_authoritative_state(frame, checkpoint, error)); - expected_authoritative_frame = frame; - std::vector received; - assert(client.wait_authoritative_state( - frame, received, std::chrono::seconds(2))); - assert(received == checkpoint); - host.confirm_authoritative_state(frame, false); - client.confirm_authoritative_state(frame, false); - } - } - pump_pair(host, client, 30); - assert(expected_authoritative_frame != 0U); - assert(host.view().last_authoritative_frame == - expected_authoritative_frame); - assert(client.view().last_authoritative_frame == - expected_authoritative_frame); - - // Exercise one minute of authored 30 Hz play, including three explicit - // race/post-race authority lifecycles. - const std::uint32_t soak_begin = lifecycle_begin + 29U; - constexpr std::uint32_t soak_frames = 30U * 60U; - constexpr std::uint32_t phase_frames = soak_frames / 3U; - std::atomic host_ok{true}; - std::atomic client_ok{true}; - for (std::uint32_t phase = 0U; phase < 3U; ++phase) { - if (phase != 0U) { - host.begin_authoritative_phase(); - client.begin_authoritative_phase(); - } - const std::uint32_t first = soak_begin + phase * phase_frames; - const std::uint32_t last = first + phase_frames; - auto host_soak = std::async(std::launch::async, [&] { - for (std::uint32_t frame = first; frame < last; ++frame) { - FrameInputs inputs{}; - if (!host.synchronize_inputs( - frame, {0x8000U, 25, -5}, inputs, - std::chrono::seconds(2))) { - host_ok = false; - return; - } - } - }); - auto client_soak = std::async(std::launch::async, [&] { - for (std::uint32_t frame = first; frame < last; ++frame) { - FrameInputs inputs{}; - if (!client.synchronize_inputs( - frame, {0x4000U, -30, 8}, inputs, - std::chrono::seconds(2))) { - client_ok = false; - return; - } - } - }); - host_soak.get(); - client_soak.get(); - assert(host_ok.load()); - assert(client_ok.load()); - host.end_authoritative_phase(); - client.end_authoritative_phase(); - assert(host.running()); - assert(client.running()); - } - host.begin_authoritative_phase(); - client.begin_authoritative_phase(); - host.submit_state_hash(60U, 0xAABBCCDDEEFF0011ULL); - client.submit_state_hash(60U, 0xAABBCCDDEEFF0011ULL); - pump_pair_until(host, client, "verified state hashes", [&] { - return host.view().last_verified_frame == 60U && client.view().last_verified_frame == 60U; - }); - assert(host.view().last_verified_frame == 60U); - assert(client.view().last_verified_frame == 60U); - assert(host.view().rollback_certified); - assert(client.view().rollback_certified); - - host.submit_state_hash(120U, 0x1111111111111111ULL); - client.submit_state_hash(120U, 0x2222222222222222ULL); - pump_pair_until(host, client, "recovery announcement", [&] { - return host.recovery_frame().has_value() && client.recovery_frame().has_value(); - }); - assert(host.view().state == ConnectionState::Running); - assert(client.view().state == ConnectionState::Running); - // A completed-frame digest mismatch schedules exactly one future repair - // boundary. Normal frames no longer carry a competing live replica. - assert(host.recovery_frame().has_value()); - assert(client.recovery_frame().has_value()); - assert(host.view().authoritative_corrections >= 1U); - - // An explicit request coalesces with the already scheduled repair rather - // than creating a second recovery transaction. - assert(client.request_rollback_recovery( - 120U, "rollback gameplay state")); - pump_pair(host, client, 30); - const auto recovery = host.recovery_frame(); - assert(recovery.has_value()); - assert(client.recovery_frame() == recovery); - - const std::uint32_t next_frame = soak_begin + soak_frames; - for (std::uint32_t frame = next_frame; frame <= *recovery; ++frame) { - FrameInputs host_inputs{}; - FrameInputs client_inputs{}; - auto host_future = std::async(std::launch::async, [&] { - return host.synchronize_inputs(frame, {0x8000U, 25, -5}, - host_inputs, - std::chrono::seconds(2)); - }); - auto client_future = std::async(std::launch::async, [&] { - return client.synchronize_inputs(frame, {0x4000U, -30, 8}, - client_inputs, - std::chrono::seconds(2)); - }); - assert(host_future.get()); - assert(client_future.get()); - assert(host_inputs == client_inputs); - } - - std::vector host_state(3713U); - for (std::size_t index = 0U; index < host_state.size(); ++index) { - host_state[index] = static_cast(index * 37U); - } - assert(host.publish_authoritative_state(*recovery, host_state, error)); - std::vector client_state; - assert(client.wait_authoritative_state(*recovery, client_state, - std::chrono::seconds(2))); - assert(client_state == host_state); - host.confirm_authoritative_state(*recovery, false); - client.confirm_authoritative_state(*recovery, true); - auto host_recovery = std::async(std::launch::async, [&] { - return host.wait_recovery_complete(*recovery, - std::chrono::seconds(2)); - }); - auto client_recovery = std::async(std::launch::async, [&] { - return client.wait_recovery_complete(*recovery, - std::chrono::seconds(2)); - }); - assert(host_recovery.get()); - assert(client_recovery.get()); - assert(host.view().state == ConnectionState::Running); - assert(client.view().state == ConnectionState::Running); - assert(client.view().authoritative_corrections == 1U); - assert(DirectSessionTestAccess::rollback_transport_epoch(host) == 2U); - assert(DirectSessionTestAccess::rollback_transport_epoch(client) == 2U); - - // The post-race transition uses the immutable authored ledger and does not - // install a competing snapshot while DKR's racer topology is torn down. - // Schedule another mismatch first to prove the finish announcement safely - // supersedes an older future recovery instead of deadlocking that client. - const std::uint32_t prefinish_mismatch = *recovery + 1U; - assert(client.request_rollback_recovery( - prefinish_mismatch, "rollback gameplay state")); - pump_pair(host, client, 30); - assert(host.recovery_frame().has_value()); - assert(client.recovery_frame().has_value()); - std::uint32_t finish_frame = prefinish_mismatch; - std::uint32_t client_finish_frame = finish_frame; - std::string client_finish_error; - // The finish hook is deliberately non-blocking. Before Player 1's - // announcement arrives the client reports Pending as false/no-error, then - // succeeds when polled again after the network worker receives it. - assert(!client.begin_finish_seal(client_finish_frame, 1U, - client_finish_error)); - assert(client_finish_error.empty()); - assert(host.begin_finish_seal(finish_frame, 1U, error)); - pump_pair_until(host, client, "client finish announcement", [&] { - const bool ready = client.begin_finish_seal(client_finish_frame, 1U, client_finish_error); - assert(client_finish_error.empty()); - return ready; - }); - assert(client_finish_frame == finish_frame); - // Finishes deliberately do not transfer another state snapshot. Both - // peers consumed the same immutable authored commits; only the lifecycle - // transition must be synchronized while DKR tears race objects down. - auto host_transition = std::async(std::launch::async, [&] { - return host.synchronize_transition(finish_frame, 1U, - std::chrono::seconds(2)); - }); - auto client_transition = std::async(std::launch::async, [&] { - return client.synchronize_transition(finish_frame, 1U, - std::chrono::seconds(2)); - }); - assert(host_transition.get()); - assert(client_transition.get()); - assert(host.complete_finish_seal(finish_frame, error)); - assert(client.complete_finish_seal(finish_frame, error)); - host.end_authoritative_phase(); - client.end_authoritative_phase(); - assert(host.running()); - assert(client.running()); - - // Race teardown must not cancel Player 1's Resume retry window. A delayed - // or duplicate client Acknowledge is normal for UDP and must remain - // idempotent instead of disconnecting the session in the results screen. - assert(DirectSessionTestAccess::has_resumed_transition_barrier(host)); - const std::uint64_t packets_before_late_ack = - DirectSessionTestAccess::packets_sent(host); - DirectSessionTestAccess::inject_transition_acknowledgement( - host, 1U, DirectSessionTestAccess::scene_epoch(host), finish_frame, 1U); - DirectSessionTestAccess::inject_transition_acknowledgement( - host, 1U, DirectSessionTestAccess::scene_epoch(host), finish_frame, 1U); - const auto late_ack_reply_deadline = std::chrono::steady_clock::now() + - std::chrono::seconds(2); - while (DirectSessionTestAccess::packets_sent(host) < - packets_before_late_ack + 2U && - std::chrono::steady_clock::now() < late_ack_reply_deadline) { - pump_pair(host, client, 1); - } - assert(host.running()); - assert(DirectSessionTestAccess::packets_sent(host) >= - packets_before_late_ack + 2U); - - // Results and menus continue on the same immutable input ledger, but have - // no rollback-state contract. A temporarily parked guest reports its last - // genuinely completed frontend frame; Player 1 may predict within the - // ordinary lead window, then must hold before private input latency can - // grow without bound. - const std::uint32_t post_race_frame = - DirectSessionTestAccess::next_commit_frame(host); - assert(post_race_frame == - DirectSessionTestAccess::next_commit_frame(client)); - const std::uint32_t post_race_completed = - post_race_frame == 0U ? 0U : post_race_frame - 1U; - client.report_simulation_progress( - post_race_completed, TimelineProgressScope::Frontend); - pump_pair(host, client, 20); - std::uint32_t frontend_host_frame = post_race_frame; - bool frontend_host_held = false; - for (std::uint32_t attempt = 0U; attempt < 20U; ++attempt) { - if (host.host_should_backpressure( - frontend_host_frame, 8U, - TimelineProgressScope::Frontend)) { - frontend_host_held = true; - break; - } - FrameInputs host_inputs{}; - assert(host.synchronize_inputs_result( - frontend_host_frame, {0x8000U, 20, -5}, host_inputs, - std::chrono::milliseconds(0)) == - InputSynchronizationResult::Committed); - ++frontend_host_frame; - pump_pair(host, client, 4); - } - assert(frontend_host_held); - assert(host.view().maximum_peer_frame_debt > 8U); - assert(host.view().maximum_peer_frame_debt <= 9U); - - // The guest retires each normal visible menu commit and reports completion. - // No snapshot, hidden native loop or new race handoff is required to - // release Player 1's bounded hold. - for (std::uint32_t frame = post_race_frame; - frame < frontend_host_frame; ++frame) { - FrameInputs client_inputs{}; - assert(client.synchronize_inputs( - frame, {0x4000U, -18, 7}, client_inputs, - std::chrono::seconds(2))); - client.report_simulation_progress( - frame, TimelineProgressScope::Frontend); - pump_pair(host, client, 8); - } - pump_pair_until(host, client, "frontend completed-frame watermark", [&] { - return !host.host_should_backpressure( - frontend_host_frame, 8U, TimelineProgressScope::Frontend); - }); - assert(!host.host_should_backpressure( - frontend_host_frame, 8U, TimelineProgressScope::Frontend)); - - // Explicit hash-chain repair must remain available outside Racing. Remove - // the next already-delivered frontend commit, request it with the ordinary - // zero-timeout admission path, and prove Player 1 serves retained history. - const std::uint32_t repair_frame = frontend_host_frame; - FrameInputs frontend_repair_host_inputs{}; - assert(host.synchronize_inputs_result( - repair_frame, {0x8000U, 21, -6}, frontend_repair_host_inputs, - std::chrono::milliseconds(0)) == - InputSynchronizationResult::Committed); - ++frontend_host_frame; - pump_pair_until(host, client, "frontend commit delivery", [&] { - return DirectSessionTestAccess::has_frame_commit(client, repair_frame); - }); - assert(DirectSessionTestAccess::has_frame_commit(client, repair_frame)); - DirectSessionTestAccess::discard_frame_commit(client, repair_frame); - const auto frontend_repair_requests_before = - DirectSessionTestAccess::commit_repair_requests_received(host); - FrameInputs pending_repair_inputs{}; - assert(client.synchronize_inputs_result( - repair_frame, {0x4000U, -19, 8}, pending_repair_inputs, - std::chrono::milliseconds(0)) == + assert(host.synchronize_inputs_result(host_resume_frame + rules.manual_input_delay, {}, + waiting_inputs, std::chrono::milliseconds(0)) == InputSynchronizationResult::Pending); - pump_pair_until(host, client, "frontend commit repair", [&] { - return DirectSessionTestAccess::commit_repair_requests_received(host) > - frontend_repair_requests_before && - DirectSessionTestAccess::has_frame_commit(client, repair_frame); - }); - assert(DirectSessionTestAccess::commit_repair_requests_received(host) > - frontend_repair_requests_before); - assert(DirectSessionTestAccess::has_frame_commit(client, repair_frame)); - assert(client.synchronize_inputs( - repair_frame, {0x4000U, -19, 8}, pending_repair_inputs, - std::chrono::seconds(2))); - client.report_simulation_progress( - repair_frame, TimelineProgressScope::Frontend); - pump_pair(host, client, 20); - assert(DirectSessionTestAccess::next_commit_frame(host) == - DirectSessionTestAccess::next_commit_frame(client)); - - // Re-enter a complete second track lifecycle on the same connection. This - // is the production path that previously retained a stale recovery, - // barrier or input epoch and froze on the black loading screen after one - // or two successful races. - const std::uint32_t second_race_frame = - DirectSessionTestAccess::next_commit_frame(host); - assert(second_race_frame == - DirectSessionTestAccess::next_commit_frame(client)); - assert(client.begin_gameplay_handoff(second_race_frame, 91U, error)); - assert(host.begin_gameplay_handoff(second_race_frame, 91U, error)); - pump_pair(host, client, 40); - host.begin_authoritative_phase(); - client.begin_authoritative_phase(); - - std::string second_host_ready_error; - std::string second_client_ready_error; - auto second_host_ready = std::async(std::launch::async, [&] { - return host.wait_gameplay_ready( - 91U, 91U, 8U, std::chrono::seconds(2), - second_host_ready_error); - }); - auto second_client_ready = std::async(std::launch::async, [&] { - return client.wait_gameplay_ready( - 91U, 91U, 8U, std::chrono::seconds(2), - second_client_ready_error); - }); - assert(second_host_ready.get()); - assert(second_client_ready.get()); - - std::vector second_baseline(3713U); - for (std::size_t index = 0U; index < second_baseline.size(); ++index) { - second_baseline[index] = static_cast(index * 13U + 7U); - } - assert(host.publish_authoritative_state(0U, second_baseline, error)); - std::vector second_received; - assert(client.wait_authoritative_state( - 0U, second_received, std::chrono::seconds(2))); - assert(second_received == second_baseline); - client.confirm_authoritative_state(0U, false); - assert(host.wait_authoritative_acknowledgements( - 0U, std::chrono::seconds(2))); - - assert(host.poll_gameplay_resume(91U, 8U, error) == - SessionPollResult::Pending); - SessionPollResult second_host_go = SessionPollResult::Pending; - for (int attempt = 0; attempt < 200 && - second_host_go == SessionPollResult::Pending; - ++attempt) { - assert(client.poll_gameplay_resume(91U, 8U, error) != - SessionPollResult::Failed); - pump_pair(host, client, 1); - second_host_go = host.poll_gameplay_resume(91U, 8U, error); - } - assert(second_host_go == SessionPollResult::Ready); - pump_pair_until(host, client, "second race Go", [&] { - const auto result = client.poll_gameplay_resume(91U, 8U, error); - assert(result != SessionPollResult::Failed); - return result == SessionPollResult::Ready; - }); - std::uint32_t second_host_resume = 0U; - std::uint32_t second_client_resume = 0U; - assert(host.complete_gameplay_handoff(second_host_resume, error)); - assert(client.complete_gameplay_handoff(second_client_resume, error)); - assert(second_host_resume == second_race_frame); - assert(second_client_resume == second_race_frame); - // A completed race must not carry its accumulated pacing watermark into - // the next track. Both occupied slots begin the fresh gameplay epoch at - // exactly the frame preceding the shared resume boundary, so old debt can - // neither throttle Player 1 nor become additional guest input latency. - const std::uint32_t second_initial_progress = - second_race_frame == 0U ? 0U : second_race_frame - 1U; - for (std::uint8_t slot = 0U; slot < 2U; ++slot) { - assert(DirectSessionTestAccess::simulation_completed_frame( - host, slot) == second_initial_progress); - assert(DirectSessionTestAccess::simulation_completed_frame( - client, slot) == second_initial_progress); - assert(DirectSessionTestAccess::simulation_progress_present( - host, slot)); - assert(DirectSessionTestAccess::simulation_progress_present( - client, slot)); - } + assert(host.view().prediction_limit_waits == 0U); - for (std::uint32_t frame = second_race_frame; - frame < second_race_frame + 120U; ++frame) { - FrameInputs host_inputs{}; - FrameInputs client_inputs{}; - auto host_frame = std::async(std::launch::async, [&] { - return host.synchronize_inputs( - frame, {0x8000U, 42, -21}, host_inputs, - std::chrono::seconds(2)); - }); - auto client_frame = std::async(std::launch::async, [&] { - return client.synchronize_inputs( - frame, {0x4000U, -37, 19}, client_inputs, - std::chrono::seconds(2)); - }); - assert(host_frame.get()); - assert(client_frame.get()); - assert(host_inputs == client_inputs); - } - assert(host.running()); - assert(client.running()); - - // Reward cinematic -> another cinematic -> hub -> boss -> ordinary race. - // The first three request the SAME hub destination but resolve differently. - // Each must finish its own baseline/Arm/Go exchange and advance the input - // epoch; a zero-racer scene may not strand or reuse the previous handoff. - std::uint32_t previous_chain_epoch = host.view().input_epoch; - const std::array, 5> scene_chain{{ - {13U, 0U}, {14U, 0U}, {12U, 1U}, {81U, 2U}, {91U, 6U}}}; - for (const auto& [resolved_map, racers] : scene_chain) { - const auto boundary = DirectSessionTestAccess::next_commit_frame(host); - assert(boundary == DirectSessionTestAccess::next_commit_frame(client)); - host.end_authoritative_phase(); - client.end_authoritative_phase(); - const auto requested = resolved_map <= 14U ? 12U : resolved_map; - assert(host.begin_gameplay_handoff(boundary, requested, error)); - pump_pair_until(host, client, "chained handoff announcement", [&] { - return client.gameplay_handoff_suspended(boundary); - }); - assert(client.gameplay_handoff_suspended(boundary)); - assert(client.begin_gameplay_handoff(boundary, requested, error)); - host.begin_authoritative_phase(); - client.begin_authoritative_phase(); - auto chain_host_ready = std::async(std::launch::async, [&] { - std::string ready_error; - return host.wait_gameplay_ready(requested, resolved_map, racers, - std::chrono::seconds(2), ready_error); - }); - std::string ready_error; - assert(client.wait_gameplay_ready(requested, resolved_map, racers, - std::chrono::seconds(2), ready_error)); - assert(chain_host_ready.get()); - const std::vector baseline(317U, - static_cast(resolved_map)); - assert(host.publish_authoritative_state(0U, baseline, error)); - std::vector received; - assert(client.wait_authoritative_state(0U, received, std::chrono::seconds(2))); - assert(received == baseline); - client.confirm_authoritative_state(0U, false); - assert(host.wait_authoritative_acknowledgements(0U, std::chrono::seconds(2))); - SessionPollResult host_go = SessionPollResult::Pending; - for (int attempt = 0; attempt < 400 && host_go == SessionPollResult::Pending; ++attempt) { - host_go = host.poll_gameplay_resume(resolved_map, racers, error); - assert(host_go != SessionPollResult::Failed); - assert(client.poll_gameplay_resume(resolved_map, racers, error) != SessionPollResult::Failed); - pump_pair(host, client, 2); - } - assert(host_go == SessionPollResult::Ready); - std::uint32_t host_resume = ~0U, client_resume = ~0U; - assert(host.complete_gameplay_handoff(host_resume, error)); - // Cinematic host retires racer authority immediately. Client may still - // need its Go retry; retained terminal replies survive that teardown. - if (racers == 0U) host.end_authoritative_phase(); - pump_pair_until(host, client, "chained retained Go", [&] { - const auto result = client.poll_gameplay_resume(resolved_map, racers, error); - assert(result != SessionPollResult::Failed); - return result == SessionPollResult::Ready; - }); - assert(client.complete_gameplay_handoff(client_resume, error)); - if (racers == 0U) client.end_authoritative_phase(); - assert(host_resume == boundary && client_resume == boundary); - assert(host.view().input_epoch == previous_chain_epoch + 1U); - assert(host.view().input_epoch == client.view().input_epoch); - previous_chain_epoch = host.view().input_epoch; - for (std::uint32_t frame = boundary; frame < boundary + 20U; ++frame) { - FrameInputs host_inputs{}, client_inputs{}; - assert(host.synchronize_inputs(frame, {}, host_inputs, std::chrono::seconds(2))); - assert(client.synchronize_inputs(frame, {}, client_inputs, std::chrono::seconds(2))); - assert(host_inputs == client_inputs); - if (racers == 0U) { - host.report_simulation_progress(frame, TimelineProgressScope::Frontend); - client.report_simulation_progress(frame, TimelineProgressScope::Frontend); - } - } - assert(host.running() && client.running()); - } - std::cout << "Consecutive cinematic/hub/boss/race epoch chain passed\n"; - - // A requested boss destination is allowed to resolve to a different scene, - // but both peers must still agree on that resolved scene before either can - // install a baseline. Conflicting resolved maps fail closed rather than - // starting two different simulations. - const std::uint32_t mismatched_scene_frame = - DirectSessionTestAccess::next_commit_frame(host); - assert(mismatched_scene_frame == - DirectSessionTestAccess::next_commit_frame(client)); - assert(client.begin_gameplay_handoff( - mismatched_scene_frame, 123U, error)); - assert(host.begin_gameplay_handoff( - mismatched_scene_frame, 123U, error)); - pump_pair(host, client, 40); - host.begin_authoritative_phase(); - client.begin_authoritative_phase(); - std::string mismatched_host_error; - std::string mismatched_client_error; - auto mismatched_host = std::async(std::launch::async, [&] { - return host.wait_gameplay_ready( - 123U, 223U, 2U, std::chrono::milliseconds(500), - mismatched_host_error); - }); - auto mismatched_client = std::async(std::launch::async, [&] { - return client.wait_gameplay_ready( - 123U, 224U, 2U, std::chrono::milliseconds(500), - mismatched_client_error); - }); - assert(!mismatched_host.get()); - assert(!mismatched_client.get()); - assert(!mismatched_host_error.empty()); - assert(!mismatched_client_error.empty()); } diff --git a/runtime-recomp/tests/experimental_owner_input_tests.cpp b/runtime-recomp/tests/experimental_owner_input_tests.cpp index ab41616c..c278c40a 100644 --- a/runtime-recomp/tests/experimental_owner_input_tests.cpp +++ b/runtime-recomp/tests/experimental_owner_input_tests.cpp @@ -16,17 +16,30 @@ void codec_tests() { p.epoch = 0x123456789ABCDEF0ULL; p.players = 4; p.owner = 3; p.count = 8; p.first_frame = 41; p.received_next = {2,3,5,7}; for (unsigned i = 0; i < 8; ++i) p.inputs[i] = sample(3, i); + // v1.0.11 (sdk2 capsule role): DKRX v3 adds a command mask byte to the + // header and 64 bytes of SDK2 command words to each input that has one. + // Only Player 1 (owner 0) may author commands. const auto bytes = encode_owner_inputs(p); - assert(bytes.size() == kOwnerInputMaximumBytes && bytes[8] == 0xF0 && bytes[15] == 0x12); + assert(bytes.size() == 53U + 4U * kOwnerInputBatch && bytes[4] == 3U && bytes[8] == 0xF0 && bytes[15] == 0x12); const auto decoded = decode_owner_inputs(bytes); assert(decoded && decoded->epoch == p.epoch && decoded->inputs == p.inputs && decoded->received_next == p.received_next); + { + auto commanded = p; + for (unsigned i = 0; i < 8; ++i) commanded.commands[i].words = {i + 1U, 2U, 3U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, 4U}; + assert(encode_owner_inputs(commanded).empty()); + commanded.owner = 0; + const auto full = encode_owner_inputs(commanded); + assert(full.size() == kOwnerInputMaximumBytes); + const auto round_trip = decode_owner_inputs(full); + assert(round_trip && round_trip->commands == commanded.commands && round_trip->inputs == p.inputs); + } for (unsigned length = 0; length < bytes.size(); ++length) assert(!decode_owner_inputs(std::span(bytes).first(length))); auto invalid = bytes; invalid.push_back(0); assert(!decode_owner_inputs(invalid)); for (unsigned at : {0U,4U,5U,6U,7U}) { invalid = bytes; invalid[at] = 255; assert(!decode_owner_inputs(invalid)); } - p.players = 2; p.owner = 1; p.received_next = {}; p.count = 0; p.first_frame = 0; - assert(encode_owner_inputs(p).size() == 52); + p.players = 2; p.owner = 1; p.received_next = {}; p.count = 0; p.first_frame = 0; p.commands = {}; + assert(encode_owner_inputs(p).size() == 53); p.received_next[2] = 1; assert(encode_owner_inputs(p).empty()); p.received_next = {}; p.count = 8; p.first_frame = UINT32_MAX - 3; assert(encode_owner_inputs(p).empty()); } diff --git a/runtime-recomp/tests/netplay_core_tests.cpp b/runtime-recomp/tests/netplay_core_tests.cpp index aafc4140..e949bfc2 100644 --- a/runtime-recomp/tests/netplay_core_tests.cpp +++ b/runtime-recomp/tests/netplay_core_tests.cpp @@ -1,4 +1,5 @@ #include "netplay_lobby.hpp" +#include "rollback_policy.hpp" #include "netplay_build_identity.hpp" #include "netplay_pacing_policy.hpp" #include "two_player_adventure_policy.hpp" @@ -581,7 +582,9 @@ int main() { std::string error; Lobby lobby; - Rules rules{}; + // v1.0.11 (rollback-only): lobbies are Rollback lobbies. + Rules rules = rollback_policy::default_rules(); + rules.maximum_players = 2U; assert(lobby.create("room-1", "ABC123", "Timber's Test Track", Visibility::Private, "host", "Host", Manifest(), rules, error)); @@ -1039,24 +1042,30 @@ int main() { state_payload.phase = RoomPhase::ReadyCheck; state_payload.room_name = "Protocol Test"; state_payload.visibility = Visibility::Public; - state_payload.rules.automatic_input_delay = false; + // v1.0.11 (rollback-only): lobby state carries Rollback rules. + state_payload.rules = rollback_policy::default_rules(); state_payload.rules.manual_input_delay = 3U; - state_payload.rules.synchronization = SynchronizationMode::Lockstep; - state_payload.rules.rollback_window = 0U; state_payload.input_delay_frames = 3U; state_payload.players[0] = {true, true, false, 0U, "Host"}; state_payload.players[1] = {true, false, false, 1U, "Client"}; state_payload.players[2].slot = 2U; state_payload.players[3].slot = 3U; - const auto encoded_state = protocol::encode_lobby_state(state_payload); protocol::LobbyStatePayload decoded_state{}; + { + // Superseded: a lockstep lobby state no longer decodes. It must stay gone. + auto lockstep_state = state_payload; + lockstep_state.rules.synchronization = SynchronizationMode::Lockstep; + lockstep_state.rules.rollback_window = 0U; + assert(!protocol::decode_lobby_state(protocol::encode_lobby_state(lockstep_state), decoded_state, error)); + } + const auto encoded_state = protocol::encode_lobby_state(state_payload); assert(protocol::decode_lobby_state(encoded_state, decoded_state, error)); assert(decoded_state.generation == 14U); assert(decoded_state.room_name == "Protocol Test"); assert(decoded_state.visibility == Visibility::Public); assert(decoded_state.rules.manual_input_delay == 3U); - assert(decoded_state.rules.synchronization == SynchronizationMode::Lockstep); - assert(decoded_state.rules.rollback_window == 0U); + assert(decoded_state.rules.synchronization == rollback_policy::mode); + assert(decoded_state.rules.rollback_window == rollback_policy::prediction_window); assert(decoded_state.input_delay_frames == 3U); assert(decoded_state.players[1].display_name == "Client"); protocol::StateHashPayload state_hash{ @@ -1103,8 +1112,12 @@ int main() { launch.player_count = 2U; launch.input_delay_frames = 3U; launch.rollback_window = 10U; + // v1.0.11 (rollback-only): launches are Rollback launches, and the + // retired host-prediction mode must stay refused. launch.synchronization = SynchronizationMode::Rollback; launch.host_control = HostControlPolicy::GuidedUntilCharacterSelect; + assert(!valid_launch_descriptor(launch)); + launch.synchronization = rollback_policy::mode; assert(valid_launch_descriptor(launch)); protocol::StartPayload start{0U, launch, launch_descriptor_hash(launch)}; const auto encoded_start = protocol::encode_start(start); @@ -1119,12 +1132,15 @@ int main() { invalid_lockstep.synchronization = SynchronizationMode::Lockstep; assert(!valid_launch_descriptor(invalid_lockstep)); invalid_lockstep.rollback_window = 0U; - assert(valid_launch_descriptor(invalid_lockstep)); + assert(!valid_launch_descriptor(invalid_lockstep)); // superseded: lockstep + // Superseded in v1.0.11 (rollback-only): lockstep and host-prediction + // lobbies are no longer valid rules. They must stay gone. Rules lockstep_rules{}; lockstep_rules.synchronization = SynchronizationMode::Lockstep; - assert(!valid_rules(lockstep_rules)); lockstep_rules.rollback_window = 0U; - assert(valid_rules(lockstep_rules)); + assert(!valid_rules(lockstep_rules)); + assert(!valid_rules(Rules{})); + assert(valid_rules(rollback_policy::default_rules())); Rules unsupported_rules{}; unsupported_rules.maximum_players = 5U; assert(!valid_rules(unsupported_rules)); @@ -1137,7 +1153,7 @@ int main() { supported.player_count = players; supported.occupied_mask = (1U << players) - 1U; assert(valid_launch_descriptor(supported)); - auto rules = lockstep_rules; + auto rules = rollback_policy::default_rules(); rules.maximum_players = players; assert(valid_rules(rules)); } diff --git a/runtime-recomp/tests/netplay_input_latency_tests.cpp b/runtime-recomp/tests/netplay_input_latency_tests.cpp index ee823eca..9fb354c0 100644 --- a/runtime-recomp/tests/netplay_input_latency_tests.cpp +++ b/runtime-recomp/tests/netplay_input_latency_tests.cpp @@ -110,8 +110,12 @@ struct DirectSessionTestAccess { auto& d = *s.launch_descriptor_; d.match_id = s.match_id_; d.player_count = static_cast(players); d.occupied_mask = static_cast((1U << players) - 1U); - d.synchronization = lockstep ? SynchronizationMode::Lockstep : SynchronizationMode::Rollback; - d.rollback_window = lockstep ? 0 : 2; d.input_delay_frames = s.input_delay_; + // v1.0.11 (rollback-only): every session is a Rollback lobby, whose + // native relay commits confirmed frames only (no host prediction), as + // lockstep did. The retired host-prediction runs are checked below. + (void)lockstep; + d.synchronization = SynchronizationMode::ExperimentalRollback; + d.rollback_window = 2; d.input_delay_frames = s.input_delay_; Rules rules{}; rules.maximum_players = static_cast(players); rules.automatic_input_delay = false; rules.manual_input_delay = s.input_delay_; rules.rollback_window = d.rollback_window; rules.synchronization = d.synchronization; @@ -145,6 +149,14 @@ struct DirectSessionTestAccess { else s.handle_client_packet(packet); } } + // Superseded in v1.0.11 (rollback-only): host prediction (the prediction + // window and its latency matrix above the strict runs) belonged to the + // retired host-prediction mode. It must stay gone: such a lobby is refused. + static void host_prediction_retired() { + Lobby lobby; Rules rules{}; std::string error; + rules.synchronization = SynchronizationMode::Rollback; + assert(!lobby.create("test", "test-code", "test", Visibility::Private, "host", "host", {}, rules, error)); + } static void revisions_and_bounds() { TimedWire wire; const auto key = secure::generate_key(); @@ -167,19 +179,22 @@ struct DirectSessionTestAccess { batch.inputs[0].buttons = 0x8000; batch.revisions[0] = 2; inject(protocol::MessageType::Input); assert(host.timeline_.inputs_for(1)[1].buttons == 0); + // v1.0.11 (rollback-only): Player 1 commits only confirmed frames (the + // input delay and Player 2's sample at frame 1), then waits without + // predicting; host prediction used to run two frames further. FrameInputs result{}; - for (unsigned frame = 0; frame < 4; ++frame) { + for (unsigned frame = 0; frame < 2; ++frame) { assert(host.synchronize_inputs_result(frame, {}, result, std::chrono::milliseconds(0)) == InputSynchronizationResult::Committed); } const auto immutable = host.frame_commits_.at(1); const auto began = std::chrono::steady_clock::now(); for (unsigned i = 0; i < 100; ++i) { - assert(host.synchronize_inputs_result(4, {}, result, + assert(host.synchronize_inputs_result(2, {}, result, std::chrono::milliseconds(0)) == InputSynchronizationResult::Pending); } assert(std::chrono::steady_clock::now() - began < std::chrono::seconds(1)); - assert(host.next_commit_frame_ == 4 && host.prediction_limit_waits_ > 0); + assert(host.next_commit_frame_ == 2 && host.prediction_limit_waits_ == 0); batch.revisions[0] = 4; const auto late_before = host.late_inputs_discarded_; inject(protocol::MessageType::Input); @@ -524,22 +539,13 @@ int main() { using dkr::runtime::netplay::DirectSessionTestAccess; DirectSessionTestAccess::revisions_and_bounds(); DirectSessionTestAccess::failed_connection_retry(); + DirectSessionTestAccess::host_prediction_retired(); for (unsigned players : {2U, 3U, 4U}) { - for (unsigned rtt : {0U, 20U, 60U, 100U, 150U, 200U}) - DirectSessionTestAccess::paced(players, rtt, false, false, false, false); - DirectSessionTestAccess::paced(players, 100, true, false, true, false); - DirectSessionTestAccess::paced(players, 100, true, true, true, false); DirectSessionTestAccess::paced(players, 20, false, false, false, true); } for (unsigned players : {2U, 3U, 4U}) { for (bool automatic : {false, true}) { for (bool quick_join : {false, true}) { - for (unsigned rtt : {0U, 20U, 60U, 100U, 150U, 200U}) { - DirectSessionTestAccess::run(players, rtt / 2, rtt / 2, - 0, 0, automatic, false, false, quick_join); - } - DirectSessionTestAccess::run(players, 30, 70, 35, 7, automatic, false, false, quick_join); - DirectSessionTestAccess::run(players, 100, 100, 50, 5, automatic, true, false, quick_join); DirectSessionTestAccess::run(players, 10, 10, 0, 0, automatic, false, true, quick_join); DirectSessionTestAccess::run(players, 30, 70, 35, 7, automatic, false, true, quick_join); } diff --git a/runtime-recomp/tests/online_mod_admission_tests.cpp b/runtime-recomp/tests/online_mod_admission_tests.cpp index 119bc7ce..35a7f66d 100644 --- a/runtime-recomp/tests/online_mod_admission_tests.cpp +++ b/runtime-recomp/tests/online_mod_admission_tests.cpp @@ -46,6 +46,13 @@ struct DirectSessionTestAccess { auto route=std::make_unique();auto* counters=route.get();host.transport_=std::move(route); Rules rules;rules.maximum_players=4;rules.synchronization=mode; if(mode==SynchronizationMode::Lockstep)rules.rollback_window=0; + // Superseded in v1.0.11 (rollback-only): the host-prediction and + // lockstep backends are gone. They must stay gone: their lobbies are + // refused, so admission only runs for the Rollback backend. + if(mode!=SynchronizationMode::ExperimentalRollback){ + check(!host.lobby_.create("room","modtest","Mods",Visibility::Private,"100","Host",host.manifest_,rules,error)); + return; + } check(host.lobby_.create("room","modtest","Mods",Visibility::Private,"100","Host",host.manifest_,rules,error)); host.state_=ConnectionState::Hosting;host.is_host_=true;host.sender_id_=100;host.match_id_=101;host.room_view_=host.lobby_.room(); check(host.admission_incompatibility(m).empty()); @@ -69,7 +76,7 @@ struct DirectSessionTestAccess { mismatch=m;mismatch.owned_engine_hash=wrong; check(incompatibility_reason(m,mismatch,true).find("Replay engine")!=std::string::npos); check(incompatibility_reason(m,mismatch,false).empty()); - check(host.admission_incompatibility(mismatch).empty()==(mode!=SynchronizationMode::ExperimentalRollback)); + check(!host.admission_incompatibility(mismatch).empty()); mismatch=m;mismatch.mod_manifest_hash=digest;mismatch.owned_resources_hash=wrong; check(!incompatibility_reason(host.manifest_,mismatch).empty()); // Provisional consent may prepare missing resources, but cannot claim @@ -177,5 +184,5 @@ int main(){try{ for(auto revision:{Revision::UsV77,Revision::UsV80}) for(auto mode:{SynchronizationMode::Rollback,SynchronizationMode::Lockstep,SynchronizationMode::ExperimentalRollback}) DirectSessionTestAccess::run(revision,mode); - std::cout< Date: Sun, 25 Oct 2026 20:41:32 -0500 Subject: [PATCH 09/62] Pin the equivalence image by ID, add its reconstructed Dockerfile --- docker/ci-linux-full/Dockerfile | 19 +++++++++++++++++++ scripts/fold/run_equivalence.sh | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 docker/ci-linux-full/Dockerfile diff --git a/docker/ci-linux-full/Dockerfile b/docker/ci-linux-full/Dockerfile new file mode 100644 index 00000000..548071d5 --- /dev/null +++ b/docker/ci-linux-full/Dockerfile @@ -0,0 +1,19 @@ +# Linux build image for the fold equivalence gate (scripts/fold/run_equivalence.sh) +# and the Linux reference materialization. +# +# Reconstructed from `docker history` of the local image the v1.0.11 reference +# was built with (dkr-ci-linux-full, image ID +# sha256:a40e107015464b92bff4e383545938f206590f73a6d1302516cef012e4f66439). +# That image's Ubuntu base layer (sha256:d81fb2a7...) no longer matches any +# local ubuntu:24.04, and apt resolves current package versions, so building +# this file gives an equivalent image, not that one. The gate pins the image ID +# above; publish a build of this file to a registry and pin its digest here and +# in the gate to make it reproducible (fold plan 2.2 step 4). +FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git pkg-config python3 ca-certificates \ + libsdl2-dev libgtk-3-dev libvulkan-dev libx11-dev libxext-dev libwayland-dev \ + libcurl4-openssl-dev \ + && rm -rf /var/lib/apt/lists/* +CMD ["/bin/bash"] diff --git a/scripts/fold/run_equivalence.sh b/scripts/fold/run_equivalence.sh index 3ded8667..6401ef64 100755 --- a/scripts/fold/run_equivalence.sh +++ b/scripts/fold/run_equivalence.sh @@ -8,7 +8,8 @@ # docs/fold-in/REFERENCE.md). It holds ROM-derived payload paths, so it lives # on the devops machine, never in git. BUILD_DIR defaults to # build/fold-equivalence. Everything that compiles runs in the build image -# (DKR_EQUIVALENCE_IMAGE, default dkr-ci-linux-full:latest). +# (DKR_EQUIVALENCE_IMAGE, default: the exact image the v1.0.11 reference was +# built with, by image ID; docker/ci-linux-full/Dockerfile is its recipe). # # Steps: configure this tree with the reference's options minus the release # chain's, then compare per-source compile settings and link lines (targets), @@ -22,7 +23,7 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" reference="$(cd "${1:?usage: run_equivalence.sh REFERENCE_DIR [BUILD_DIR]}" && pwd)" build="${2:-$root/build/fold-equivalence}" -image="${DKR_EQUIVALENCE_IMAGE:-dkr-ci-linux-full:latest}" +image="${DKR_EQUIVALENCE_IMAGE:-sha256:a40e107015464b92bff4e383545938f206590f73a6d1302516cef012e4f66439}" jobs="${DKR_EQUIVALENCE_JOBS:-12}" tool="$root/scripts/fold/equivalence.py" out="$build/equivalence" @@ -36,7 +37,7 @@ while IFS= read -r path; do done < <(python3 "$tool" options --reference "$reference" --mounts) in_image() { docker run --rm --platform linux/amd64 "${mounts[@]}" -w "$root" "$image" bash -c \ - "set -e; (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq libcurl4-openssl-dev) >/dev/null 2>&1; git config --global --add safe.directory '*'; $1" + "set -e; dpkg -s libcurl4-openssl-dev >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq libcurl4-openssl-dev) >/dev/null 2>&1; git config --global --add safe.directory '*'; $1" } options=() From fe0f338c37cbce5d36ac6e8edca218ae4555d456 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 20:41:39 -0500 Subject: [PATCH 10/62] Inline the startup-memory stage, list open fold items The base CMake no longer includes patches/startup-memory/RuntimeMemory.cmake. open-items.md holds maintainer questions, B6 notes and the patches/ readers. --- docs/fold-in/open-items.md | 44 +++++++++++++++++++ runtime-recomp/cmake/PerformancePatches.cmake | 4 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 docs/fold-in/open-items.md diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md new file mode 100644 index 00000000..0a6b92e1 --- /dev/null +++ b/docs/fold-in/open-items.md @@ -0,0 +1,44 @@ +# Fold open items + +## For the maintainer + +1. **`DKR_EXPERIMENTAL_FULL_SCENES` on the launcher.** The base CMake adds + it to `runtime_ui.cpp` when the owned payload has full scenes. v1.0.11 + compiled the launcher without it: a release layer relocated + `runtime_ui.cpp` and did not carry that per-source define over. The three + `#if defined(DKR_EXPERIMENTAL_FULL_SCENES)` blocks in `runtime_ui.cpp` + were therefore off in the shipped build, and the fold keeps them off + (`runtime-recomp/CMakeLists.txt` says so where the define used to be set). + Was dropping it a bug, or intended? Turning it back on is a behaviour + change for after the fold. +2. **Launcher copy.** 71 reviewed strings are superseded by v1.0.11; see + [launcher-copy-review.md](launcher-copy-review.md). + +## For B6 (native macOS) + +- `netplay/online_directory.cpp` (online-directory layer) holds a + `std::atomic>`. libstdc++ supports that C++20 + specialization; Apple's libc++ does not (`std::atomic` requires a + trivially copyable `T`), so the macOS host build stops there. v1.0.11 was + Linux and Windows only, so no shipped build hit it. It needs an + `__APPLE__`-guarded alternative (a mutex-guarded `shared_ptr`, or + `std::atomic_load`/`std::atomic_store` on a plain one). +- The Mod Store links CURL only off Windows, Android and Apple, as the + release did; plan B2 lists CURL on Apple for B6. + +## `patches/` content the build still reads (before B8) + +Everything else under `patches/` that the base CMake reads is a dependency +`*.patch` (fold step B3 registers them). + +| Directory | Read by | What it is | Plan | +|---|---|---|---| +| `enhanced-water/native/` | `cmake/EnhancedWater.cmake` (copies the HLSL and headers into RT64's staged tree), `DKREnhancedWaterPolicyTests` include path, `scripts/compose-water-*.py` (write it) | first-party RT64 additions: water and lava shaders, `WaterRayScene.cpp`, policy headers | B3 or B8: either first-party source under `runtime-recomp/` staged into RT64 the same way, or new files inside a registered RT64 patch. Not moved yet: the compose scripts still write into this directory | +| `startup-memory/` | `RuntimeMemory.cmake` was included by `cmake/PerformancePatches.cmake` | one `dkr_stage_patch` call plus `runtime-memory.patch` | call folded into `PerformancePatches.cmake`; the patch is registered in B3, the directory's other files go in B8 | +| `custom-magic-codes/` | `cmake/PerformancePatches.cmake` | `runtime-code-heap.patch` only | B3 registers it | +| `save-layout/` | `scripts/save_layout_pipeline.py` (`guest_save_layout.h`) | guest header for the owned-payload pipeline | B4, with the guest hooks | +| `character-portraits/` | `scripts/character_portrait_pipeline.py` (`*.inc`) | guest inputs for the portrait payload | B4, with the guest hooks | + +`camera-plus/` is also still read, by `scripts/camera_plus_pipeline.py` and +the scene-kernel pipeline that produce the Camera++ kernels; B4 replaces +them with the generator. diff --git a/runtime-recomp/cmake/PerformancePatches.cmake b/runtime-recomp/cmake/PerformancePatches.cmake index 317c859d..d2a15f3d 100644 --- a/runtime-recomp/cmake/PerformancePatches.cmake +++ b/runtime-recomp/cmake/PerformancePatches.cmake @@ -24,7 +24,9 @@ if(DKR_REPLAY_QUALIFICATION OR DKR_EXPERIMENTAL_RACE_TEST) endif() dkr_stage_patch(librecomp "${DKR_CODE_RUNTIME_INPUT}" custom-magic-code-heap "${DKRPORT_ROOT}/patches/custom-magic-codes/runtime-code-heap.patch" librecomp/src/recomp.cpp) -include("${DKRPORT_ROOT}/patches/startup-memory/RuntimeMemory.cmake") +# Startup memory: applied to the custom-magic-code-heap copy above. +dkr_stage_patch(librecomp "${CMAKE_BINARY_DIR}/generated/custom-magic-code-heap" startup-memory + "${DKRPORT_ROOT}/patches/startup-memory/runtime-memory.patch" librecomp/src/recomp.cpp) if(BUILD_TESTING AND NOT ANDROID) add_executable(DKRNativeGuestLifetimeTests "${DKRPORT_ROOT}/runtime-recomp/tests/native_guest_lifetime_tests.cpp") target_compile_definitions(DKRNativeGuestLifetimeTests PRIVATE From 18723b590ba7f43f59743b953466134c20d4e0a5 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 20:59:13 -0500 Subject: [PATCH 11/62] Register dkr_stage_patch patches in the manifest's stagedPatches The guard and the pinned-bytes check read them; PG007 now also flags a registered staged patch no runtime-recomp CMake applies. Baseline shrinks. --- architecture/pattern-guard-baseline.txt | 73 ----- patches/manifest.json | 274 ++++++++++++++++++ runtime-recomp/tests/pinned_bytes_tests.py | 6 + scripts/check_pinned_bytes.py | 5 +- scripts/pattern_guard.py | 41 ++- .../PG007/negative/patches/manifest.json | 6 + .../patches/rt64/staged/0002-staged.patch | 6 + .../negative/runtime-recomp/CMakeLists.txt | 2 + .../PG007/positive/patches/manifest.json | 6 + 9 files changed, 338 insertions(+), 81 deletions(-) create mode 100644 tests/fixtures/pattern-guard/PG007/negative/patches/rt64/staged/0002-staged.patch diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index 7b35d215..7900cad4 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -35,33 +35,6 @@ patches/all-player-detail/native/detail_contract.hpp PG006 patches/all-player-detail/native/detail_observer.inl PG006 patches/all-player-detail/package.py PG013 patches/all-player-detail/pipeline.py PG004 -patches/android/README.md PG013 -patches/android/background-shader-priority.patch PG013 -patches/android/buffer-small-upload.patch PG013 -patches/android/buffer-worker-lifecycle.patch PG013 -patches/android/frame-budget.patch PG013 -patches/android/inspector-prerotation.patch PG013 -patches/android/present-surface-ownership.patch PG013 -patches/android/render-idle-priority.patch PG013 -patches/android/render-pass-retention.patch PG013 -patches/android/render-stage-timing.patch PG013 -patches/android/renderer-transfer-qualification.patch PG013 -patches/android/rt64-rgba-presentation.patch PG013 -patches/android/sdl2-vulkan-surface-lifecycle.patch PG013 -patches/android/texture-memory-pressure.patch PG013 -patches/android/vi-prerotation.patch PG013 -patches/android/video-library-prerotation.patch PG013 -patches/android/vulkan-capability-preflight.patch PG013 -patches/android/vulkan-copy-contract.patch PG013 -patches/android/vulkan-descriptor-safety.patch PG013 -patches/android/vulkan-mapped-memory.patch PG013 -patches/android/vulkan-pass-retention.patch PG013 -patches/android/vulkan-pipeline-cache.patch PG013 -patches/android/vulkan-prerotation.patch PG013 -patches/android/vulkan-read-barriers.patch PG013 -patches/android/vulkan-resource-failures.patch PG013 -patches/android/vulkan-surface-capabilities.patch PG013 -patches/android/vulkan-surface-recovery.patch PG013 patches/camera-plus/CMakeLists.txt PG001 patches/camera-plus/IMPLEMENTATION.md PG013 patches/camera-plus/NativeOverlay.cmake PG001 @@ -136,7 +109,6 @@ patches/credits-viewport/credit_match.inl PG006 patches/credits-viewport/credits_sky_rt64.hpp PG006 patches/credits-viewport/package.py PG013 patches/credits-viewport/pipeline.py PG004 -patches/custom-magic-codes/runtime-code-heap.patch PG013 patches/custom-vehicles/PLAYTEST.txt PG013 patches/custom-vehicles/REPAIR-NOTES.txt PG013 patches/custom-vehicles/ReleaseOverlay.cmake PG001 @@ -194,22 +166,6 @@ patches/detail-records-repair/native/records_guest.inl PG006 patches/detail-records-repair/package.py PG013 patches/detail-records-repair/pipeline.py PG004 patches/enhanced-water/LICENSE-WAVE-RACE.txt PG013 -patches/enhanced-water/dkr-adapter.patch PG013 -patches/enhanced-water/dkr-budget.patch PG013 -patches/enhanced-water/dkr-field-portability.patch PG013 -patches/enhanced-water/dkr-lava-glow.patch PG013 -patches/enhanced-water/dkr-lava-lighting.patch PG013 -patches/enhanced-water/dkr-lava.patch PG013 -patches/enhanced-water/dkr-performance.patch PG013 -patches/enhanced-water/dkr-projection.patch PG013 -patches/enhanced-water/dkr-ray-capabilities.patch PG013 -patches/enhanced-water/dkr-rays.patch PG013 -patches/enhanced-water/dkr-recovery.patch PG013 -patches/enhanced-water/dkr-reflection-search.patch PG013 -patches/enhanced-water/dkr-safety.patch PG013 -patches/enhanced-water/dkr-stability.patch PG013 -patches/enhanced-water/dkr-views.patch PG013 -patches/enhanced-water/dkr-waterfall.patch PG013 patches/enhanced-water/native/LavaFlow.hlsli PG006 patches/enhanced-water/native/LavaLighting.hlsli PG006 patches/enhanced-water/native/LavaLightingBounds.h PG006 @@ -233,7 +189,6 @@ patches/enhanced-water/native/WaterWorkPolicy.h PG006 patches/enhanced-water/native/WaterfallFlow.hlsli PG006 patches/enhanced-water/native/WaterfallPS.hlsl PG006 patches/enhanced-water/native/WaterfallVS.hlsl PG006 -patches/enhanced-water/renderer.patch PG013 patches/graphics-handoff/PIPELINE.txt PG013 patches/graphics-handoff/PLAYTEST.txt PG013 patches/graphics-handoff/ReleaseOverlay.cmake PG001 @@ -418,10 +373,6 @@ patches/music-preview-repair/audit.py PG013 patches/music-preview-repair/configure.py PG013 patches/music-preview-repair/package.py PG013 patches/music-preview-repair/pipeline.py PG004 -patches/netplay/experimental-present-queue.patch PG013 -patches/netplay/experimental-present-state.patch PG013 -patches/netplay/experimental-quiesced-bootstrap.patch PG013 -patches/netplay/experimental-workload-history.patch PG013 patches/offline-retention-repair/IMPLEMENTATION.txt PG013 patches/offline-retention-repair/PLAYTEST.txt PG013 patches/offline-retention-repair/ReleaseOverlay.cmake PG001 @@ -517,23 +468,6 @@ patches/performance-surgical/pipeline.py PG004 patches/performance-surgical/presentation_pacer.hpp PG006 patches/performance-surgical/reusable_flow_map.hpp PG006 patches/performance-surgical/texture_binding_index.hpp PG006 -patches/performance/present-queue-wait.patch PG013 -patches/performance/presentation-wait.patch PG013 -patches/performance/renderer-measurements.patch PG013 -patches/performance/renderer-workers.patch PG013 -patches/performance/runtime-deferred-events.patch PG013 -patches/performance/runtime-guest-cancellation.patch PG013 -patches/performance/runtime-guest-lifetime.patch PG013 -patches/performance/runtime-host-task-lifetime.patch PG013 -patches/performance/runtime-idle-diagnostics.patch PG013 -patches/performance/runtime-session-bootstrap.patch PG013 -patches/performance/runtime-snapshot-pool.patch PG013 -patches/performance/runtime-vi-bootstrap.patch PG013 -patches/performance/shader-cache-wait.patch PG013 -patches/performance/vulkan-measurements.patch PG013 -patches/performance/water-renderer-history.patch PG013 -patches/performance/water-scroll-interpolation.patch PG013 -patches/performance/workload-queue-wait.patch PG013 patches/presentation-polish/NativeOverlay.cmake PG001 patches/presentation-polish/NativeOverlay.cmake PG002 patches/presentation-polish/NativeOverlay.cmake PG003 @@ -984,8 +918,6 @@ patches/rollback-scoreboard/native/scoreboard_wire.inl PG006 patches/rollback-scoreboard/package.py PG013 patches/rollback-scoreboard/pipeline.py PG004 patches/rollback-scoreboard/transforms.py PG013 -patches/rt64/texture-layer-mip-cache.patch PG007 -patches/rt64/texture-layer-priority.patch PG007 patches/save-layout/README.md PG013 patches/save-layout/guest_save_layout.h PG006 patches/save-layout/inspect_packages.py PG013 @@ -1156,12 +1088,10 @@ patches/spectators/ui_pipeline.py PG004 patches/startup-memory/BUILDING.txt PG013 patches/startup-memory/DIAGNOSIS.txt PG013 patches/startup-memory/RuntimeMemory.cmake PG001 -patches/startup-memory/RuntimeMemory.cmake PG007 patches/startup-memory/history/RELEASE-VALIDATION-1.0.7.txt PG013 patches/startup-memory/inspect108.py PG004 patches/startup-memory/pipeline.py PG004 patches/startup-memory/release108.py PG004 -patches/startup-memory/runtime-memory.patch PG013 patches/store-vehicles/IMPLEMENTATION.txt PG013 patches/store-vehicles/PLAYTEST.txt PG013 patches/store-vehicles/ReleaseOverlay.cmake PG001 @@ -1250,9 +1180,6 @@ patches/viewport-flow/flow_vertex_scope_rt64.hpp PG006 patches/viewport-flow/intro_viewport_rt64.hpp PG006 patches/viewport-flow/package.py PG013 patches/viewport-flow/pipeline.py PG004 -runtime-recomp/CMakeLists.txt PG007 -runtime-recomp/cmake/PerformancePatches.cmake PG007 -runtime-recomp/cmake/android/PreRotation.cmake PG007 runtime-recomp/src/game/achievement_symbols.hpp PG011 runtime-recomp/src/game/netplay/experimental_runtime.cpp PG010 runtime-recomp/src/game/polish_symbols.hpp PG011 diff --git a/patches/manifest.json b/patches/manifest.json index 5cfd7e29..5b3a7037 100644 --- a/patches/manifest.json +++ b/patches/manifest.json @@ -334,5 +334,279 @@ } ] } + ], + "stagedPatches": [ + { + "path": "patches/android/background-shader-priority.patch", + "sha256": "b9488dfc19966959bc2fb83c71e265cbf10acf49d120ea6ae96a3f82a901631e" + }, + { + "path": "patches/android/buffer-small-upload.patch", + "sha256": "940ddcea6a1260cfbd9aee7a6de84eb68578642bed0ca469c5a7131185d155d3" + }, + { + "path": "patches/android/buffer-worker-lifecycle.patch", + "sha256": "b91ae701ac83c58a988a4f7dfd650631e4b2c3b64f296a776dae4d3a66110433" + }, + { + "path": "patches/android/frame-budget.patch", + "sha256": "ac8fcdf8f168e6d825f6ac31fd30477bbb5507b43c0554df53f24db0629d6bbd" + }, + { + "path": "patches/android/inspector-prerotation.patch", + "sha256": "b5460d5411aec6d2990c7eec431168bdb5d051caeb402cf3a3076bbe557cdba1" + }, + { + "path": "patches/android/present-surface-ownership.patch", + "sha256": "b704680d869afb14e7975aaa401d097b99b07dfc5bf2432069b052fb2b6399d0" + }, + { + "path": "patches/android/render-idle-priority.patch", + "sha256": "7e00da02d9c776c111d1dc31a33570e7e46da919a160eb3439326389d0f182d8" + }, + { + "path": "patches/android/render-pass-retention.patch", + "sha256": "3ed3d03a01f446605a1696fa89c490fd2d7812c1139af3dc403377307debd248" + }, + { + "path": "patches/android/render-stage-timing.patch", + "sha256": "7b6fa825f6bd4ec47c13b0c82279a4b9e62139a1669754c852c16f7324dc21f1" + }, + { + "path": "patches/android/renderer-transfer-qualification.patch", + "sha256": "57188fbcd9199fd3897c9930703fa410dc00d4c3b4ccbe2dadfa18e36b6accab" + }, + { + "path": "patches/android/rt64-rgba-presentation.patch", + "sha256": "13747dda882b50f31c941a4b6e93f528057d1401b8978363d7cd6acc64881b7f" + }, + { + "path": "patches/android/sdl2-vulkan-surface-lifecycle.patch", + "sha256": "5dfbd27dbcf06506eef46d089818cc05e9a817712ae190afcb4829ed0027e28b" + }, + { + "path": "patches/android/texture-memory-pressure.patch", + "sha256": "c6f2055f0b96ec95db84277c0e28bc570ca79d3c7408b42c0d2e1e47f3d3008b" + }, + { + "path": "patches/android/vi-prerotation.patch", + "sha256": "76c9b532e027ae7b879dd117ca6a554ddad98e6097b444dc3924182a48a41187" + }, + { + "path": "patches/android/video-library-prerotation.patch", + "sha256": "7504d48436720478d5a623a206c7dbd9740f132e8da3e39e36b7c818cdf7eb7f" + }, + { + "path": "patches/android/vulkan-capability-preflight.patch", + "sha256": "1b5789bcba4db61242b50d393c2ae6df2f571e53733f3792dcb45a15c81d51d1" + }, + { + "path": "patches/android/vulkan-copy-contract.patch", + "sha256": "ee8ace2f9e9cadda5ed53a412b9d1c64b47e4bf48550e74c9f89c767296a4cb7" + }, + { + "path": "patches/android/vulkan-descriptor-safety.patch", + "sha256": "88edddb2307215de7a5c4af04482b32e767b9d16e0bb7ce621a2a47f78b1764f" + }, + { + "path": "patches/android/vulkan-mapped-memory.patch", + "sha256": "50d40e66fcf1d918d7a0a424dfbb44c8344b3dce65df99d9b4243aaef664acab" + }, + { + "path": "patches/android/vulkan-pass-retention.patch", + "sha256": "adb4fe7276dd12fc1d1aa8dec749d11f40ce575216fb42f0af1f063cecd3f420" + }, + { + "path": "patches/android/vulkan-pipeline-cache.patch", + "sha256": "b59d22a1cc929aef635ef32e71858895e46f62da78aafab2b01b4e293d17b494" + }, + { + "path": "patches/android/vulkan-prerotation.patch", + "sha256": "ae950cf5882919214800728f904e566074aee01f388bb171bcf032cf2baa403f" + }, + { + "path": "patches/android/vulkan-read-barriers.patch", + "sha256": "3d1413df16d70c6ddada26da38f1aa0e798707a3c8597386de3a05addbc4738a" + }, + { + "path": "patches/android/vulkan-resource-failures.patch", + "sha256": "1d742c35f46fc24d2a14748f3ac383891991e5a24a54a35b8d09053f01fb1896" + }, + { + "path": "patches/android/vulkan-surface-capabilities.patch", + "sha256": "1f670f9001d18bfd980a4a05ef7ce3c83b3ba2a66ec071ac47e0e8f35c294c3a" + }, + { + "path": "patches/android/vulkan-surface-recovery.patch", + "sha256": "1374f8d309d8f2636b371890eda0f9bd8a9683bf77172307dfb25f204cfd8005" + }, + { + "path": "patches/custom-magic-codes/runtime-code-heap.patch", + "sha256": "e8ae00e32a057cb017114117c4d6241f68a26326820ed4ae3153e22d7a7ec827" + }, + { + "path": "patches/enhanced-water/dkr-adapter.patch", + "sha256": "4642c14a3d04dce9ef214f94c97c414e568e423684e320d61bca50f89bbb5f64" + }, + { + "path": "patches/enhanced-water/dkr-budget.patch", + "sha256": "f6fb8a7c2ec16b35d48a3a036c3912037d383c0adcd6e385f3bb64b903cfa423" + }, + { + "path": "patches/enhanced-water/dkr-field-portability.patch", + "sha256": "7cbd337b32d9803d8c513cc57f87382eba686adf479f8ec21dcd5af40c8c620e" + }, + { + "path": "patches/enhanced-water/dkr-lava-glow.patch", + "sha256": "5c4a7b0fb397a635320e3a235e87acb4f59db4a0c9bcbcc5ef976c3583c84b81" + }, + { + "path": "patches/enhanced-water/dkr-lava-lighting.patch", + "sha256": "2a7e6f3e8a58c15e2f70b3413cc0940a9ebf9fce71597131fe3090af596c1e0d" + }, + { + "path": "patches/enhanced-water/dkr-lava.patch", + "sha256": "e382907945305090804beef96c523620c88f8da3ccb032c0fb4739f16c812981" + }, + { + "path": "patches/enhanced-water/dkr-performance.patch", + "sha256": "015b9ebf09156e17eece641499b245a032e00cbc56a7d509dcce63e3932f588e" + }, + { + "path": "patches/enhanced-water/dkr-projection.patch", + "sha256": "8b3d334f91e7b9b286c4cc6f1dfdb098c7c7d366e8c222ba535663293befcbed" + }, + { + "path": "patches/enhanced-water/dkr-ray-capabilities.patch", + "sha256": "9545995da3b0f92b1eb8736aa3b12431efcc5eae8ae6e0cec971016dd07c9a11" + }, + { + "path": "patches/enhanced-water/dkr-rays.patch", + "sha256": "fd1ddf89af0fa3a60eea3062a40f58da7b704e5b2c567e50297f06bfdddd5393" + }, + { + "path": "patches/enhanced-water/dkr-recovery.patch", + "sha256": "2b162c46fc5dc79dc327c22f2d9470cdc8ea34a65970330b0cc78d3041b82037" + }, + { + "path": "patches/enhanced-water/dkr-reflection-search.patch", + "sha256": "3a8ad3a9ba77234e6aa53b90b4e5187874faf07932e39d390f97f88d9d692457" + }, + { + "path": "patches/enhanced-water/dkr-safety.patch", + "sha256": "b3638a3f68a1afd83f6d45fa15d80a2166f98dcb9f1dc3f1972159ffc6a5c118" + }, + { + "path": "patches/enhanced-water/dkr-stability.patch", + "sha256": "75f1860b0fcf35b6d89f126ab82878d1466ed6fd645601c4c46d5b8d7a225131" + }, + { + "path": "patches/enhanced-water/dkr-views.patch", + "sha256": "1bbd2534efc86bf666f53e71db5fc83c4e42c1195fea34ce3c5ef300ec241649" + }, + { + "path": "patches/enhanced-water/dkr-waterfall.patch", + "sha256": "16958771541b0c783a78e33c5463de2f3e3149ddb59bc6f47fe21902dfe63b47" + }, + { + "path": "patches/enhanced-water/renderer.patch", + "sha256": "5570a4cfee2853cdb15894d1ee61c892c52ae5edd3d8c1336884e00ba398e3d2" + }, + { + "path": "patches/netplay/experimental-present-queue.patch", + "sha256": "0eb6f94fad077db2d39553ba6dd764b00c4e1d59e87b7555a0dac668f854d583" + }, + { + "path": "patches/netplay/experimental-present-state.patch", + "sha256": "aa9006ed30b5c1aa35ae82ae0c72f03055995a0543859f22ce0a140db85d52a7" + }, + { + "path": "patches/netplay/experimental-quiesced-bootstrap.patch", + "sha256": "08eaed04e74da17ba58d572303b99aeb9bd9447f57003201827b57889f5d7983" + }, + { + "path": "patches/netplay/experimental-workload-history.patch", + "sha256": "015a91eb28b2f0f045ccc77ab3f1aedd97e430923431188144b2fd1217f7ba90" + }, + { + "path": "patches/performance/present-queue-wait.patch", + "sha256": "8203d2400a8164e636fb7f3f483fc5fee38c3ed3022df819b614b75fd7242c78" + }, + { + "path": "patches/performance/presentation-wait.patch", + "sha256": "b340f5b36ada3e7704ee29f3eb0f57e1f9d33d838788ecb49b04394283c01bf6" + }, + { + "path": "patches/performance/renderer-measurements.patch", + "sha256": "9f08d380bf00f0e101172cac8f7b57b49a55ededd0fe18dd486b0317f0bc4ad0" + }, + { + "path": "patches/performance/renderer-workers.patch", + "sha256": "379797cb6180e2834ffdce42ed387960f07cde1ddc368b74199dccee5525c6b1" + }, + { + "path": "patches/performance/runtime-deferred-events.patch", + "sha256": "39f15b90ce7721c91dc21c23923a9f91742ab42eeb9a17954106914a1f64ac24" + }, + { + "path": "patches/performance/runtime-guest-cancellation.patch", + "sha256": "7105adf224f400815c11fabbcf766ecf3b5c003188ccfa770b3b6724e458cad9" + }, + { + "path": "patches/performance/runtime-guest-lifetime.patch", + "sha256": "c16029e5d16856667764729d642929446512fef8b7fe70fb4ef6e9cddabdc5e2" + }, + { + "path": "patches/performance/runtime-host-task-lifetime.patch", + "sha256": "5bbca94badfd302b9ea7a00ab8dbc1aadd6f1874bc12a4ac9c7495edf4f891de" + }, + { + "path": "patches/performance/runtime-idle-diagnostics.patch", + "sha256": "49905bdbcb6109b6e03a5f85691c172d0d9b241abf306aefe23e5df87aac9328" + }, + { + "path": "patches/performance/runtime-session-bootstrap.patch", + "sha256": "1079c6b4c5be4957d73f9c96811a38586ca74c3091b2d148d7e31ba15f7d0389" + }, + { + "path": "patches/performance/runtime-snapshot-pool.patch", + "sha256": "ee2431a615ac0585889cb927710587ddb7619e06f9cc175d7c2d2d0262aabbb9" + }, + { + "path": "patches/performance/runtime-vi-bootstrap.patch", + "sha256": "9e8a0bf597611f7bd21efd58af81cb9e4cf984269eb0351652fcd0ce2599521b" + }, + { + "path": "patches/performance/shader-cache-wait.patch", + "sha256": "9dc859d4efce3e830248d55d0c43eb09c813086daa6e29a8aa0cf0517cb2ae1b" + }, + { + "path": "patches/performance/vulkan-measurements.patch", + "sha256": "e3ec8d53e11d415c090b32a476604230bf14ba26b0bb679898b243d1ed2d59eb" + }, + { + "path": "patches/performance/water-renderer-history.patch", + "sha256": "fd038ead338a0b2b17f6a3c6de0955cbdbe795a0ecf1f35f46fc260a926946ab" + }, + { + "path": "patches/performance/water-scroll-interpolation.patch", + "sha256": "fc21772817323efa6e7d4ae5ce960b398110005a0563191a92a4cfcb0256574f" + }, + { + "path": "patches/performance/workload-queue-wait.patch", + "sha256": "a36a946498f9ccf96781c30025c8baefbf3c78ae37f73d76a1574e8fa1711aa5" + }, + { + "path": "patches/rt64/texture-layer-mip-cache.patch", + "sha256": "78f925d234f30d66b35e8cf74de2b7f162f58b2ffb4fd4aa7baf7fe3000a0ad5" + }, + { + "path": "patches/rt64/texture-layer-priority.patch", + "sha256": "55cbaa6031f8b1b89bb272c8a233e1acc68058db9a7a7d6ec9913ea4ff8c57d5" + }, + { + "path": "patches/startup-memory/runtime-memory.patch", + "sha256": "07c4e61dec8b7388cc9475f31177cb2f584bd90cd108fe3b4f091d25e05a4126" + } ] } diff --git a/runtime-recomp/tests/pinned_bytes_tests.py b/runtime-recomp/tests/pinned_bytes_tests.py index 75f0148f..b7d0b40f 100644 --- a/runtime-recomp/tests/pinned_bytes_tests.py +++ b/runtime-recomp/tests/pinned_bytes_tests.py @@ -56,6 +56,12 @@ def test_each_kind_of_pin_is_found(self): self.assertIn("its file name", sources) self.assertTrue(any(source.startswith("modding/reference-us-v") for source in sources), sources) + def test_staged_patches_are_pinned(self): + manifest = json.loads((ROOT / "patches/manifest.json").read_text(encoding="utf-8")) + staged = {patch["path"] for patch in manifest["stagedPatches"]} + self.assertTrue(staged) + self.assertLessEqual(staged, set(check_pinned_bytes.pins(ROOT))) + def test_pre_commit_never_hands_a_pinned_file_to_a_fixer(self): exclude = top_level_exclude() reachable = [path for path in check_pinned_bytes.pins(ROOT) if not exclude.search(path)] diff --git a/scripts/check_pinned_bytes.py b/scripts/check_pinned_bytes.py index f2cabb09..f4c4559f 100755 --- a/scripts/check_pinned_bytes.py +++ b/scripts/check_pinned_bytes.py @@ -5,7 +5,8 @@ where they carry trailing whitespace or CRLF line endings that a formatter would "fix": -* every patch listed in patches/manifest.json (``dependencies[].patches[]``); +* every patch listed in patches/manifest.json (``dependencies[].patches[]`` + and ``stagedPatches[]``); * the address-only SDK kits, modding/reference-us-v*/, against the ``files`` map in each kit's receipt.json; * the content-addressed source capsules, patches/release-prep/*/source-capsule-*/, @@ -108,6 +109,8 @@ def pins(root: Path) -> dict[str, tuple[str, str]]: for dependency in manifest["dependencies"]: for patch in dependency.get("patches", []): pinned[patch["path"]] = (patch["sha256"], PATCH_MANIFEST) + for patch in manifest.get("stagedPatches", []): + pinned[patch["path"]] = (patch["sha256"], PATCH_MANIFEST) for receipt in sorted(root.glob(RECEIPTS)): source = receipt.relative_to(root).as_posix() kit = receipt.parent.relative_to(root).as_posix() diff --git a/scripts/pattern_guard.py b/scripts/pattern_guard.py index 90b4f85a..00560323 100644 --- a/scripts/pattern_guard.py +++ b/scripts/pattern_guard.py @@ -126,8 +126,9 @@ class Rule: "PG007", "unregistered-dependency-patch", "a dependency *.patch, or a dkr_stage_patch() patch, missing from patches/manifest.json " - "or with a stale sha256", - "register the patch with its sha256 in patches/manifest.json", + "or with a stale sha256; or a stagedPatches entry no runtime-recomp CMake applies", + "register the patch with its sha256 in patches/manifest.json (stagedPatches for " + "dkr_stage_patch), or drop the entry with the call", ), Rule( "PG008", @@ -185,6 +186,9 @@ class Rule: # Fold-only (step B1): writes the shipped closure into runtime-recomp/ once, # respelling staged #include paths. Deleted with scripts/fold/ after C11. "scripts/fold/apply_closure.py": "writes the shipped v1.0.11 closure back into the tree once", + # Fold-only (step B3): diffs the stage chain against the shipped dependency + # transforms into registered stage patches, respelling the same includes. + "scripts/fold/dependency_stages.py": "writes the v1.0.11 dependency transforms as stage patches", } GUEST_REWRITE_ALLOWED = {"scripts/guest_hooks.py", "runtime-recomp/cmake/GuestHooks.cmake"} IDENTITY_ALLOWED_SUFFIX = "netplay/legacy_identity.hpp" @@ -415,16 +419,35 @@ def python(self, path: str) -> ast.Module | None: @functools.cached_property def registered_patches(self) -> dict[str, str]: - """patches/manifest.json path -> sha256 for every dependency patch.""" + """patches/manifest.json path -> sha256 for every dependency patch: + the ones applied to a checkout (``dependencies[].patches[]``) and the + ones dkr_stage_patch applies to a staged copy (``stagedPatches[]``).""" try: manifest = json.loads(self.text("patches/manifest.json") or "{}") except json.JSONDecodeError: return {} + entries = [ + *(patch for dependency in manifest.get("dependencies", []) for patch in dependency.get("patches", [])), + *manifest.get("stagedPatches", []), + ] + return {patch["path"]: patch.get("sha256", "") for patch in entries if "path" in patch} + + @functools.cached_property + def staged_patch_entries(self) -> list[str]: + try: + manifest = json.loads(self.text("patches/manifest.json") or "{}") + except json.JSONDecodeError: + return [] + return [patch["path"] for patch in manifest.get("stagedPatches", []) if "path" in patch] + + @functools.cached_property + def cmake_patch_references(self) -> set[str]: + """patches/....patch paths named anywhere in runtime-recomp's CMake.""" return { - patch["path"]: patch.get("sha256", "") - for dependency in manifest.get("dependencies", []) - for patch in dependency.get("patches", []) - if "path" in patch + match.group(1) + for path in self.files + if path.startswith("runtime-recomp/") and is_cmake(path) + for match in re.finditer(r"(?:^|[/}\s\"])(patches/[^\s\")]+\.patch)", self.text(path) or "") } @functools.cached_property @@ -753,6 +776,10 @@ def first_party_under_patches(tree: Tree, path: str) -> Iterator[Finding]: def unregistered_dependency_patch(tree: Tree, path: str) -> Iterator[Finding]: """PG007: dependency patches and staged patches go through one registry.""" registered = tree.registered_patches + if path == "patches/manifest.json": + for entry in tree.staged_patch_entries: + if entry not in tree.cmake_patch_references: + yield Finding(path, 1, "PG007", f"staged patch {entry} is not applied by runtime-recomp CMake") if path.endswith(".patch") and "/".join(path.split("/")[:2]) in tree.dependency_dirs: if path not in registered: yield Finding(path, 1, "PG007", "dependency patch is not in patches/manifest.json") diff --git a/tests/fixtures/pattern-guard/PG007/negative/patches/manifest.json b/tests/fixtures/pattern-guard/PG007/negative/patches/manifest.json index a1bbecc4..b33c2575 100644 --- a/tests/fixtures/pattern-guard/PG007/negative/patches/manifest.json +++ b/tests/fixtures/pattern-guard/PG007/negative/patches/manifest.json @@ -12,5 +12,11 @@ } ] } + ], + "stagedPatches": [ + { + "path": "patches/rt64/staged/0002-staged.patch", + "sha256": "12d4abba5da0b4f2440519aab67a2e9dfb1e2606d32a2e912a063590b5c2b8ee" + } ] } diff --git a/tests/fixtures/pattern-guard/PG007/negative/patches/rt64/staged/0002-staged.patch b/tests/fixtures/pattern-guard/PG007/negative/patches/rt64/staged/0002-staged.patch new file mode 100644 index 00000000..864c786f --- /dev/null +++ b/tests/fixtures/pattern-guard/PG007/negative/patches/rt64/staged/0002-staged.patch @@ -0,0 +1,6 @@ +diff --git a/src/render/rt64_texture_cache.cpp b/src/render/rt64_texture_cache.cpp +--- a/src/render/rt64_texture_cache.cpp ++++ b/src/render/rt64_texture_cache.cpp +@@ -1 +1 @@ +-int cache_limit = 1; ++int cache_limit = 2; diff --git a/tests/fixtures/pattern-guard/PG007/negative/runtime-recomp/CMakeLists.txt b/tests/fixtures/pattern-guard/PG007/negative/runtime-recomp/CMakeLists.txt index b5eba5c5..6a1e9642 100644 --- a/tests/fixtures/pattern-guard/PG007/negative/runtime-recomp/CMakeLists.txt +++ b/tests/fixtures/pattern-guard/PG007/negative/runtime-recomp/CMakeLists.txt @@ -1,2 +1,4 @@ dkr_stage_patch(rt64 "${DKR_RT64_SOURCE}" staged "${DKRPORT_ROOT}/patches/rt64/0001-cache-limit.patch" src/render/rt64_texture_cache.cpp) +dkr_stage_patch(rt64 "${CMAKE_BINARY_DIR}/generated/staged" staged-on-staged + "${DKRPORT_ROOT}/patches/rt64/staged/0002-staged.patch" src/render/rt64_texture_cache.cpp) diff --git a/tests/fixtures/pattern-guard/PG007/positive/patches/manifest.json b/tests/fixtures/pattern-guard/PG007/positive/patches/manifest.json index 836c3fac..f64e7c2a 100644 --- a/tests/fixtures/pattern-guard/PG007/positive/patches/manifest.json +++ b/tests/fixtures/pattern-guard/PG007/positive/patches/manifest.json @@ -12,5 +12,11 @@ } ] } + ], + "stagedPatches": [ + { + "path": "patches/rt64/staged/0004-not-applied.patch", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } ] } From 5c43102bbf6189014a6646ba58ade65d6eaf1cd1 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 20:59:37 -0500 Subject: [PATCH 12/62] Apply the v1.0.11 dependency transforms as registered stage patches dependency_stages.py derives six patches from the chain output; the gate pairs the 11 shipped files with the staged copies and checks their bytes. --- docs/fold-in/open-items.md | 22 ++ patches/manifest.json | 24 ++ .../staged/0001-graphics-handoff-events.patch | 55 ++++ .../rt64/staged/0001-render-stability.patch | 276 ++++++++++++++++++ .../0002-vehicle-transform-processor.patch | 34 +++ .../staged/0003-present-queue-pacer.patch | 37 +++ .../0004-texture-capture-observer.patch | 56 ++++ .../staged/0005-review-editor-imgui.patch | 185 ++++++++++++ runtime-recomp/CMakeLists.txt | 25 ++ runtime-recomp/cmake/PerformancePatches.cmake | 4 + runtime-recomp/cmake/StagedPatch.cmake | 28 +- scripts/fold/dependency_stages.py | 276 ++++++++++++++++++ scripts/fold/equivalence.py | 13 +- scripts/fold/run_equivalence.sh | 6 +- 14 files changed, 1035 insertions(+), 6 deletions(-) create mode 100644 patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch create mode 100644 patches/rt64/staged/0001-render-stability.patch create mode 100644 patches/rt64/staged/0002-vehicle-transform-processor.patch create mode 100644 patches/rt64/staged/0003-present-queue-pacer.patch create mode 100644 patches/rt64/staged/0004-texture-capture-observer.patch create mode 100644 patches/rt64/staged/0005-review-editor-imgui.patch create mode 100644 scripts/fold/dependency_stages.py diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 0a6b92e1..5cc46689 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -26,6 +26,28 @@ - The Mod Store links CURL only off Windows, Android and Apple, as the release did; plan B2 lists CURL on Apple for B6. +## Dependency stage patches (B3) + +- The 11 dependency transforms are six registered stage patches + (`patches/rt64/staged/`, `patches/n64-modern-runtime/staged/`), derived by + `scripts/fold/dependency_stages.py` from the Linux chain output and applied + after that chain. The gate checks them on Linux. Windows takes the same + CMake branches for these files, so its chain input should be the same, but + nobody has configured it. Android stages five of the files further + (`android-*` stages), and the shipped layers applied on top of whatever the + chain produced, so the Android `git apply` is unverified until the + `android` job runs. +- `0001-render-stability` and `0003-present-queue-pacer` are diffs against + the rollback stages of `rt64_state`, `rt64_workload_queue` and + `rt64_present_queue`. v1.0.11 only shipped that chain + (`DKR_EXPERIMENTAL_RACE_TEST=ON`). A configure without those stages + (`host-full-nogen`, or any RT64 build with the option off) doesn't apply the + two patches and keeps the earlier renderer behaviour. They add no symbols + that first-party code calls, so such builds still link. +- The shipped layers kept the replaced source's include directories; the + stage calls pass `KEEP_INCLUDE_DIRECTORIES` to do the same. The other 54 + calls are unchanged. + ## `patches/` content the build still reads (before B8) Everything else under `patches/` that the base CMake reads is a dependency diff --git a/patches/manifest.json b/patches/manifest.json index 5b3a7037..4aeef9a8 100644 --- a/patches/manifest.json +++ b/patches/manifest.json @@ -512,6 +512,10 @@ "path": "patches/enhanced-water/renderer.patch", "sha256": "5570a4cfee2853cdb15894d1ee61c892c52ae5edd3d8c1336884e00ba398e3d2" }, + { + "path": "patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch", + "sha256": "4b7d1641142574190d634f50ad1626a792cf5c3c9fcc67eb87181f4dc46e7761" + }, { "path": "patches/netplay/experimental-present-queue.patch", "sha256": "0eb6f94fad077db2d39553ba6dd764b00c4e1d59e87b7555a0dac668f854d583" @@ -596,6 +600,26 @@ "path": "patches/performance/workload-queue-wait.patch", "sha256": "a36a946498f9ccf96781c30025c8baefbf3c78ae37f73d76a1574e8fa1711aa5" }, + { + "path": "patches/rt64/staged/0001-render-stability.patch", + "sha256": "c9512ef3c2e5a7ce880fd9975553722f80c33478e151f42725a61c02924de1ba" + }, + { + "path": "patches/rt64/staged/0002-vehicle-transform-processor.patch", + "sha256": "b88ad6044008390949f4a8471bf9b5eb9dab846e7284c4cd9f50c373e58aca2b" + }, + { + "path": "patches/rt64/staged/0003-present-queue-pacer.patch", + "sha256": "b58452c473d4de00e45ee815afc16e40f6db035238d8e16d4b419a45c9eb2c70" + }, + { + "path": "patches/rt64/staged/0004-texture-capture-observer.patch", + "sha256": "832b008cb45d948a07aa5aefc8e63f745cb2dac1e63852078e42469bd4aa01a9" + }, + { + "path": "patches/rt64/staged/0005-review-editor-imgui.patch", + "sha256": "0eb51bdf29fecc325a36acd97b91c6eb8d429d7930ad9294bb81072cc8691d32" + }, { "path": "patches/rt64/texture-layer-mip-cache.patch", "sha256": "78f925d234f30d66b35e8cf74de2b7f162f58b2ffb4fd4aa7baf7fe3000a0ad5" diff --git a/patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch b/patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch new file mode 100644 index 00000000..2d407d25 --- /dev/null +++ b/patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch @@ -0,0 +1,55 @@ +diff --git a/ultramodern/src/events.cpp b/ultramodern/src/events.cpp +--- a/ultramodern/src/events.cpp ++++ b/ultramodern/src/events.cpp +@@ -1,3 +1,4 @@ ++#include "producer_snapshot.hpp" + #include + #include + #include +@@ -30,7 +31,7 @@ void ultramodern::events::set_callbacks(const ultramodern::events::callbacks_t& + } + + using RdramSnapshotPool = dkr::runtime::GraphicsSnapshotPool<0x800000>; +-static RdramSnapshotPool rdram_snapshot_pool; ++// Pool ownership is shared with the earlier producer handoff. + namespace host_tasks = dkr::runtime::host_tasks; + + struct SpTaskAction { +@@ -634,6 +635,8 @@ void ultramodern::submit_rsp_task(RDRAM_ARG PTR(OSTask) task_) { + events_callbacks.presentation_allowed_callback == nullptr || + events_callbacks.presentation_allowed_callback(); + if (!presentation_allowed) { ++ dkr::runtime::graphics_handoff::discard(rdram, ++ static_cast(task->t.data_ptr),task->t.data_size); + // Historical/catch-up frames do not reach the renderer, but their + // emulated graphics task must retain the normal worker-owned SP/DP + // lifecycle. Completing both edges inline can publish DP before +@@ -645,8 +648,10 @@ void ultramodern::submit_rsp_task(RDRAM_ARG PTR(OSTask) task_) { + return; + } + constexpr size_t RDRAMSnapshotSize = 0x800000; +- auto rdram_snapshot = rdram_snapshot_pool.acquire(); +- { ++ auto rdram_snapshot = dkr::runtime::graphics_handoff::take( ++ rdram,static_cast(task->t.data_ptr),task->t.data_size); ++ if (!rdram_snapshot) { ++ rdram_snapshot = dkr::runtime::graphics_handoff::acquire(); + dkr::runtime::performance_trace::Scope measure( + dkr::runtime::performance_trace::Region::SnapshotCopy, RDRAMSnapshotSize); + std::memcpy(rdram_snapshot.get(), rdram, RDRAMSnapshotSize); +@@ -673,6 +678,7 @@ void ultramodern::send_si_message() { + } + + static void reset_event_state() { ++ dkr::runtime::graphics_handoff::reset(); + Action action; + while (events_context.action_queue.try_dequeue(action)) {} + +@@ -777,6 +783,7 @@ void ultramodern::join_event_threads() { + + Action action; + while (events_context.action_queue.try_dequeue(action)) {} ++ dkr::runtime::graphics_handoff::reset(); + QueuedRspTask task; + while (events_context.sp_task_queue.try_dequeue(task)) {} + } diff --git a/patches/rt64/staged/0001-render-stability.patch b/patches/rt64/staged/0001-render-stability.patch new file mode 100644 index 00000000..0c2f0aca --- /dev/null +++ b/patches/rt64/staged/0001-render-stability.patch @@ -0,0 +1,276 @@ +diff --git a/src/hle/rt64_state.cpp b/src/hle/rt64_state.cpp +--- a/src/hle/rt64_state.cpp ++++ b/src/hle/rt64_state.cpp +@@ -1,9 +1,11 @@ ++#include "liquid_replacement_gate.hpp" + // + // RT64 + // + + #include "netplay/experimental_present_gate.hpp" +-#include "local_scenery_texture_scope.hpp" ++#include "vehicle_texture_scope.hpp" ++#include "vehicle_wheel_interpolation.hpp" + #include "rt64_state.h" + #include "performance_trace.hpp" + +@@ -1337,7 +1339,10 @@ namespace RT64 { + + // Synchronize all texture uploads before filling out the GPU tiles and update the texture cache on the framebuffer renderer. + ext.textureCache->waitForGPUUploads(); +- framebufferRenderer->updateTextureCache(ext.textureCache); ++ { ++ dkr::runtime::water::streaming::WorkloadScope liquid_scope(&workload,workload.submissionFrame); ++ framebufferRenderer->updateTextureCache(ext.textureCache); ++ } + + thread_local std::vector bufferUploaders; + bufferUploaders.clear(); +@@ -2757,6 +2762,7 @@ namespace RT64 { + } + + void State::advanceWorkload(Workload &workload, bool paused) { ++ dkr::runtime::vehicles::wheel_interpolation::seal(workload,workloadId+1); + workload.workloadId = ++workloadId; + dkr::runtime::netplay::experimental::present_gate::tag_workload( + this, &workload, workload.workloadId, paused); +diff --git a/src/hle/rt64_workload_queue.cpp b/src/hle/rt64_workload_queue.cpp +--- a/src/hle/rt64_workload_queue.cpp ++++ b/src/hle/rt64_workload_queue.cpp +@@ -1,3 +1,4 @@ ++#include "liquid_replacement_gate.hpp" + // + // RT64 + // +@@ -600,7 +601,10 @@ namespace RT64 { + ext.textureCache->incrementLock(); + + // Reset the texture cache vectors for the framebuffer renderer. +- framebufferRenderer->updateTextureCache(ext.textureCache); ++ { ++ dkr::runtime::water::streaming::WorkloadScope liquid_scope(&workload,workload.submissionFrame); ++ framebufferRenderer->updateTextureCache(ext.textureCache); ++ } + + for (uint32_t f = 0; f < fbPairCount; f++) { + const FramebufferPair &fbPair = workload.fbPairs[f]; +diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp +--- a/src/render/rt64_framebuffer_renderer.cpp ++++ b/src/render/rt64_framebuffer_renderer.cpp +@@ -3,6 +3,7 @@ + // + + #include "rt64_framebuffer_renderer.h" ++#include "liquid_replacement_gate.hpp" + #include "rt64_generated_mip_config.h" + #include "rt64_generated_mip_scope.h" + #include "WaterProjection.h" +@@ -170,6 +171,7 @@ namespace RT64 { + } + + FramebufferRenderer::~FramebufferRenderer() { ++ dkr::runtime::water::streaming::release_frame(this); + dummyColorTargetView.reset(); + dummyDepthTargetView.reset(); + dummyColorTarget.reset(); +@@ -230,6 +232,8 @@ namespace RT64 { + textureCacheSize = static_cast(textureCacheTextures.size()); + textureCacheGlobalVersion = textureCache->textureMap.globalVersion; + textureCacheReplacementMapEnabled = textureCache->textureMap.replacementMapEnabled; ++ dkr::runtime::water::streaming::capture(this,textureCache->textureMap, ++ textureCacheTextureReplacements,textureCacheVersions,textureCacheGlobalVersion); + dynamicTextureViewVector.clear(); + dynamicTextureBarrierVector.clear(); + } +@@ -273,6 +277,17 @@ namespace RT64 { + bool generatedMipmaps = false; + textureCache->useTexture(callTile.tmemHashOrID, submissionFrame, textureIndex, gpuTile.tcScale, gpuTile.textureDimensions, textureReplaced, hasMipmaps, shiftedByHalf, generatedMipmaps); + ++ // The workload's descriptor snapshot is authoritative while a ++ // liquid family streams. Native dimensions/flags MUST match ++ // its native descriptor; toggling highRes alone is incorrect. ++ if(dkr::runtime::water::streaming::blocked(this,callTile.tmemHashOrID) && ++ textureIndexwidth),float(native->height),float(std::max(native->mipmaps,1U))}; ++ hasMipmaps=native->mipmaps>1;generatedMipmaps=native->generatedMipmaps;shiftedByHalf=false; ++ } ++ + // Describe the GPU tile for a regular texture. + gpuTile.ulScale.x = gpuTile.tcScale.x; + gpuTile.ulScale.y = gpuTile.tcScale.y; +diff --git a/src/render/rt64_generated_mips.h b/src/render/rt64_generated_mips.h +--- a/src/render/rt64_generated_mips.h ++++ b/src/render/rt64_generated_mips.h +@@ -8,6 +8,8 @@ + #include "rt64_shader_library.h" + #include "rt64_descriptor_sets.h" + #include "rt64_generated_mip_config.h" ++#include "native_resource_safety.hpp" ++#include "performance_switches.hpp" + + namespace RT64::GeneratedMips { + // Bound work per PNG and scratch retained by an entire native upload batch. +@@ -25,8 +27,8 @@ namespace RT64::GeneratedMips { + struct Level { uint32_t width, height; std::vector pixels; }; + // Integer alpha-weighted RGB; average alpha. Deliberately NOT a binary coverage filter. + // Partition odd dimensions into disjoint boxes, including their final row/column. +- inline Level downsample(const Level &src) { +- Level dst{std::max(src.width / 2, 1U), std::max(src.height / 2, 1U), {}}; ++ inline void downsample_into(const Level &src,Level &dst) { ++ dst.width=std::max(src.width/2,1U);dst.height=std::max(src.height/2,1U); + dst.pixels.resize(size_t(dst.width) * dst.height * 4); + for (uint32_t y = 0; y < dst.height; ++y) for (uint32_t x = 0; x < dst.width; ++x) { + uint32_t sums[4]{}, raw[3]{}, count = 0; +@@ -41,8 +43,28 @@ namespace RT64::GeneratedMips { + p[c] = uint8_t(sums[3] ? (sums[c] + sums[3] / 2) / sums[3] : (raw[c] + count / 2) / count); + p[3] = uint8_t((sums[3] + count / 2) / count); + } +- return dst; + } ++ inline Level downsample(const Level& src) {Level dst;downsample_into(src,dst);return dst;} ++// Included after GeneratedMips::Level/downsample; DKR's own extension only. ++struct CpuMipScratch { ++ std::vector levels; ++ std::vector offsets; ++ bool busy=false; ++}; ++struct CpuMipLease { ++ CpuMipScratch& scratch; ++ explicit CpuMipLease(CpuMipScratch& value):scratch(value){scratch.busy=true;} ++ ~CpuMipLease() { ++ std::uint64_t retained=0; ++ for(const auto& level:scratch.levels)retained+=level.pixels.capacity(); ++ // Bound retained CPU payload per worker. Larger images still use the ++ // unchanged upload path, but their temporary CPU storage is released. ++ if(retained>8ULL*1024*1024) {std::vector().swap(scratch.levels);std::vector().swap(scratch.offsets);} ++ scratch.busy=false; ++ } ++}; ++inline CpuMipScratch& reusable_mip_scratch() {static thread_local CpuMipScratch value;return value;} ++ + struct Scratch { + std::vector> textures; + std::vector> descriptors; +@@ -50,23 +72,27 @@ namespace RT64::GeneratedMips { + }; + inline bool uploadRGBA32(Texture &dst, RenderDevice *device, RenderCommandList *cmd, + const uint8_t *bytes, size_t byteCount, uint32_t w, uint32_t h, uint32_t rowPitch, +- std::unique_ptr &upload) { +- const uint32_t count = levelCount(w,h); +- if(count<=1 || uint64_t(rowPitch)byteCount) return false; +- std::vector levels; +- std::vector offsets; ++ std::unique_ptr &upload,bool single_level=false) { ++ const uint32_t count = single_level ? 1U : levelCount(w,h); ++ if((count<=1 && !single_level) || !bytes || !w || !h || w>16384 || h>16384 || uint64_t(w)*h>64ULL*1024*1024 || uint64_t(rowPitch)byteCount) return false; ++ CpuMipScratch local; ++ auto& reusable=reusable_mip_scratch(); ++ auto& scratch=dkr::runtime::surgical::mip_scratch() && !reusable.busy ? reusable : local; ++ CpuMipLease cpu_lease(scratch); ++ auto& levels=scratch.levels;auto& offsets=scratch.offsets; + std::unique_ptr chain; + std::unique_ptr staging; + uint64_t total=0; + try { +- levels.push_back({w,h,{}}); levels[0].pixels.resize(size_t(w)*h*4); ++ levels.resize(count);offsets.clear();offsets.reserve(count); ++ levels[0].width=w;levels[0].height=h;levels[0].pixels.resize(size_t(w)*h*4); + for(uint32_t y=0;yMaxScratchBytes) return false; ++ if(!single_level && total>MaxScratchBytes) return false; + chain=device->createTexture(RenderTextureDesc::Texture2D(w,h,count,RenderFormat::R8G8B8A8_UNORM)); + staging=device->createBuffer(RenderBufferDesc::UploadBuffer(total)); +- if(!chain || !staging) return false; ++ if(!resource_valid(chain.get()) || !resource_valid(staging.get())) return false; + } catch(const std::bad_alloc &) { return false; } + auto *data=static_cast(staging->map()); + if(!data) return false; +@@ -77,7 +103,7 @@ namespace RT64::GeneratedMips { + for(uint32_t m=0;mcopyTextureRegion(RenderTextureCopyLocation::Subresource(chain.get(),m),RenderTextureCopyLocation::PlacedFootprint(staging.get(),RenderFormat::R8G8B8A8_UNORM,l.width,l.height,1,((l.width*4+255)&~255U)/4,offsets[m])); } + dst.texture=std::move(chain);upload=std::move(staging);dst.width=w;dst.height=h; +- dst.format=RenderFormat::R8G8B8A8_UNORM;dst.mipmaps=count;dst.memorySize=total;dst.generatedMipmaps=true; ++ dst.format=RenderFormat::R8G8B8A8_UNORM;dst.mipmaps=count;dst.memorySize=total;dst.generatedMipmaps=count>1; + return true; + } + // Record only after native RGBA decode. Keep Scratch alive until the upload fence. +@@ -100,13 +126,13 @@ namespace RT64::GeneratedMips { + // Allocate everything before recording or changing the original Texture. + try { + chain = device->createTexture(RenderTextureDesc::Texture2D(dst.width, dst.height, count, dst.format)); +- if (!chain) return false; ++ if (!resource_valid(chain.get())) return false; + w = dst.width; h = dst.height; + for (uint32_t m = 1; m < count; ++m) { + w = std::max(w / 2, 1U); h = std::max(h / 2, 1U); + auto tex = device->createTexture(RenderTextureDesc::Texture2D(w, h, 1, dst.format, + RenderTextureFlag::STORAGE | RenderTextureFlag::UNORDERED_ACCESS)); +- if (!tex) return false; ++ if (!resource_valid(tex.get())) return false; + auto set = std::make_unique(device); + set->setTexture(set->TMEM, m == 1 ? dst.texture.get() : scratch.back().get(), RenderTextureLayout::SHADER_READ); + set->setTexture(set->RGBA32, tex.get(), RenderTextureLayout::GENERAL); +diff --git a/src/render/rt64_texture_cache.cpp b/src/render/rt64_texture_cache.cpp +--- a/src/render/rt64_texture_cache.cpp ++++ b/src/render/rt64_texture_cache.cpp +@@ -20,6 +20,7 @@ + + #include "rt64_texture_cache.h" + #include "rt64_generated_mips.h" ++#include "liquid_replacement_gate.hpp" + + #define ONLY_USE_LOW_MIP_CACHE 0 + +@@ -35,6 +36,7 @@ namespace RT64 { + } + + ReplacementMap::~ReplacementMap() { ++ dkr::runtime::water::streaming::forget(this); + for (auto it : loadedTextureMap) { + delete it.second.texture; + } +@@ -45,6 +47,7 @@ namespace RT64 { + } + + void ReplacementMap::clear(std::vector &evictedTextures) { ++ dkr::runtime::water::streaming::forget(this); + for (auto it : loadedTextureMap) { + evictedTextures.emplace_back(it.second.texture); + } +@@ -80,6 +83,7 @@ namespace RT64 { + + // Take texture off the unused list. + unusedTextureList.pop_back(); ++ dkr::runtime::water::streaming::evicted(this); + cachedTexturePoolSize -= lastUnusedTexture->memorySize; + } + } +@@ -128,6 +132,7 @@ namespace RT64 { + entry.fileSystemIndex = fileSystemIndex; + entry.relativePath = relativePath; + entry.texture = texture; ++ dkr::runtime::water::streaming::changed(this); + + if (referenceCounted) { + entry.unusedTextureListIterator = unusedTextureList.insert(unusedTextureList.begin(), texture); +@@ -952,11 +957,11 @@ namespace RT64 { + if (data != nullptr) { + uint32_t rowPitch = uint32_t(width) * 4; + size_t byteCount = uint32_t(height) * rowPitch; +- if (!generateMipmaps || !GeneratedMips::uploadRGBA32(*replacementTexture, device, commandList, data, byteCount, uint32_t(width), uint32_t(height), rowPitch, dstUploadResource)) { +- TextureCache::setRGBA32(replacementTexture, device, commandList, data, byteCount, uint32_t(width), uint32_t(height), rowPitch, dstUploadResource, resourcePool, uploadResourcePoolMutex); +- } ++ loadedTexture = generateMipmaps && GeneratedMips::uploadRGBA32(*replacementTexture, device, commandList, data, byteCount, uint32_t(width), uint32_t(height), rowPitch, dstUploadResource); ++ if(!loadedTexture) ++ loadedTexture=GeneratedMips::uploadRGBA32(*replacementTexture,device,commandList,data,byteCount,uint32_t(width),uint32_t(height),rowPitch,dstUploadResource,true); + stbi_image_free(data); +- loadedTexture = true; ++ if(!loadedTexture)GeneratedMips::upload_failure(); + } + + break; diff --git a/patches/rt64/staged/0002-vehicle-transform-processor.patch b/patches/rt64/staged/0002-vehicle-transform-processor.patch new file mode 100644 index 00000000..25ac8391 --- /dev/null +++ b/patches/rt64/staged/0002-vehicle-transform-processor.patch @@ -0,0 +1,34 @@ +diff --git a/src/render/rt64_transform_processor.cpp b/src/render/rt64_transform_processor.cpp +--- a/src/render/rt64_transform_processor.cpp ++++ b/src/render/rt64_transform_processor.cpp +@@ -3,6 +3,7 @@ + // + + #include "rt64_transform_processor.h" ++#include "vehicle_wheel_interpolation.hpp" + + #include "common/rt64_math.h" + #include "hle/rt64_game_frame.h" +@@ -37,6 +38,8 @@ namespace RT64 { + if (prevFrameValid) { + const GameFrameMap::WorkloadMap &workloadMap = p.curFrame->frameMap.workloads[w]; + const DrawData &prevDrawData = p.workloadQueue->workloads[workloadMap.prevWorkloadIndex].drawData; ++ const auto currentWheels=dkr::runtime::vehicles::wheel_interpolation::capture(workload); ++ const auto previousWheels=dkr::runtime::vehicles::wheel_interpolation::capture(p.workloadQueue->workloads[workloadMap.prevWorkloadIndex]); + for (size_t t = 0; t < drawData.worldTransforms.size(); t++) { + const GameFrameMap::TransformMap &transformMap = workloadMap.transforms[t]; + if (transformMap.mapped) { +@@ -44,6 +47,13 @@ namespace RT64 { + const hlslpp::float4x4 &curTransform = drawData.worldTransforms[t]; + prevMatrix = transformMap.rigidBody.lerp(p.prevFrameWeight, prevTransform, curTransform, true); + curMatrix = transformMap.rigidBody.lerp(p.curFrameWeight, prevTransform, curTransform, true); ++ if(currentWheels&&dkr::runtime::vehicles::wheel_interpolation::find(*currentWheels,t)) { ++ if(!dkr::runtime::vehicles::wheel_interpolation::interpolate(previousWheels,currentWheels, ++ transformMap.prevTransformIndex,t,p.prevFrameWeight,p.curFrameWeight, ++ prevTransform,curTransform,prevMatrix,curMatrix)) { ++ prevMatrix=curTransform;curMatrix=curTransform; ++ } ++ } + invMatrix = hlslpp::inverse(curMatrix); + invTMatrix = hlslpp::transpose(invMatrix); + lerpWorldTransforms.emplace_back(curMatrix); diff --git a/patches/rt64/staged/0003-present-queue-pacer.patch b/patches/rt64/staged/0003-present-queue-pacer.patch new file mode 100644 index 00000000..a2635aeb --- /dev/null +++ b/patches/rt64/staged/0003-present-queue-pacer.patch @@ -0,0 +1,37 @@ +diff --git a/src/hle/rt64_present_queue.cpp b/src/hle/rt64_present_queue.cpp +--- a/src/hle/rt64_present_queue.cpp ++++ b/src/hle/rt64_present_queue.cpp +@@ -6,6 +6,7 @@ + #include "netplay/experimental_present_gate.hpp" + #include "netplay/experimental_performance.hpp" + #include "rt64_present_queue.h" ++#include "presentation_pacer.hpp" + + #include "common/rt64_thread.h" + #include "rhi/rt64_render_hooks.h" +@@ -398,7 +399,9 @@ namespace RT64 { + if (presentFrame && swapChainValid) { + // Wait until the approximate time the next present should be at the current intended rate. + if ((presentTimestamp != Timestamp()) && (targetRate > 0) && (targetRate > viOriginalRate)) { +- Timer::preciseSleepUntil(presentTimestamp + std::chrono::nanoseconds(1'000'000'000 / targetRate)); ++ if(dkr::runtime::surgical::monotonic_pacer()) ++ dkr::runtime::surgical::presentation_pacer().wait(this,presentTimestamp,targetRate); ++ else Timer::preciseSleepUntil(presentTimestamp + std::chrono::nanoseconds(1'000'000'000 / targetRate)); + } + + if (presentWaitEnabled) { +@@ -407,9 +410,13 @@ namespace RT64 { + + RenderCommandSemaphore *waitSemaphore = drawSemaphores[swapChainIndex].get(); + presentTimestamp = Timer::current(); ++ dkr::runtime::surgical::presentation_pacer().submitted(this,presentTimestamp,targetRate); + swapChainValid = ext.swapChain->present(swapChainIndex, &waitSemaphore, 1); + presentProfiler.logAndRestart(); +- if (swapChainValid) dkr::runtime::performance_trace::record_present(); ++ if (swapChainValid) { ++ dkr::runtime::performance_trace::record_present(); ++ dkr::runtime::surgical::observe_present(ext.swapChain->getWidth(),ext.swapChain->getHeight(),targetRate); ++ } + if (swapChainValid) { + ext.sharedResources->totalPresentations.fetch_add( + 1, std::memory_order_relaxed); diff --git a/patches/rt64/staged/0004-texture-capture-observer.patch b/patches/rt64/staged/0004-texture-capture-observer.patch new file mode 100644 index 00000000..86bacb1d --- /dev/null +++ b/patches/rt64/staged/0004-texture-capture-observer.patch @@ -0,0 +1,56 @@ +diff --git a/src/hle/rt64_rdp_tmem.cpp b/src/hle/rt64_rdp_tmem.cpp +--- a/src/hle/rt64_rdp_tmem.cpp ++++ b/src/hle/rt64_rdp_tmem.cpp +@@ -3,6 +3,7 @@ + // + + #include "rt64_rdp_tmem.h" ++#include "texture_capture_bridge.hpp" + + #include + #include +@@ -140,7 +141,7 @@ namespace RT64 { + } + + bool calculateRiceAlias(State *state, const LoadTile &drawTile, +- uint32_t tlut, uint64_t &alias) { ++ uint32_t tlut, uint64_t &alias, char* capturedIdentity=nullptr) { + const LoadOperation &loadOp = state->rdp->rice.lastLoadOpByTMEM[drawTile.tmem]; + int bytesPerRow = 0; + int width = 0; +@@ -250,6 +251,7 @@ namespace RT64 { + if (appended <= 0 || identityLength + appended >= int(sizeof(identityBuffer))) return false; + } + ++ if(capturedIdentity)std::snprintf(capturedIdentity,64,"%s",identityBuffer); + alias = riceNativeAlias(identityBuffer); + return true; + } +@@ -319,6 +321,27 @@ namespace RT64 { + textureCache->queueGPUUploadTMEM(hash, creationFrame, TMEM, RDP_TMEM_BYTES, width, height, tlut, loadTile, true); + } + ++ // Observation is independent of upload/cache misses and replacement ++ // selection. No guest, GPU lifetime, replacement or simulation state is changed. ++ if (const auto sink=dkr::texture_capture::observer.load(std::memory_order_acquire)) { ++ const auto gate=dkr::texture_capture::gate.load(std::memory_order_acquire); ++ if(gate&&gate(nativeHash)) { ++ dkr::texture_capture::Tile tile{}; ++ tile.native_hash=nativeHash; ++ const auto alias=riceAliasMap.find(nativeHash); ++ if(alias!=riceAliasMap.end())tile.rice_alias=alias->second; ++ uint64_t observedAlias=0; ++ if(!calculateRiceAlias(state,loadTile,tlut,observedAlias,tile.rice_name)||observedAlias!=tile.rice_alias) ++ tile.rice_name[0]=0; // Do not invent Rice names from reused source memory. ++ tile.width=width;tile.height=height;tile.tlut=tlut; ++ tile.fmt=loadTile.fmt;tile.siz=loadTile.siz;tile.line=loadTile.line; ++ tile.tmem=loadTile.tmem;tile.palette=loadTile.palette; ++ tile.cms=loadTile.cms;tile.cmt=loadTile.cmt;tile.masks=loadTile.masks;tile.maskt=loadTile.maskt; ++ tile.shifts=loadTile.shifts;tile.shiftt=loadTile.shiftt; ++ tile.uls=loadTile.uls;tile.ult=loadTile.ult;tile.lrs=loadTile.lrs;tile.lrt=loadTile.lrt; ++ sink(tile,TMEM); ++ } ++ } + // Dump memory contents into a file if the process is active. + if (!state->dumpingTexturesDirectory.empty()) { + dumpTexture(nativeHash, state, loadTile, width, height, tlut); diff --git a/patches/rt64/staged/0005-review-editor-imgui.patch b/patches/rt64/staged/0005-review-editor-imgui.patch new file mode 100644 index 00000000..ac89e847 --- /dev/null +++ b/patches/rt64/staged/0005-review-editor-imgui.patch @@ -0,0 +1,185 @@ +diff --git a/src/contrib/imgui/imgui_widgets.cpp b/src/contrib/imgui/imgui_widgets.cpp +--- a/src/contrib/imgui/imgui_widgets.cpp ++++ b/src/contrib/imgui/imgui_widgets.cpp +@@ -3698,6 +3698,8 @@ static ImVec2 InputTextCalcTextSizeW(ImGuiContext* ctx, const ImWchar* text_begi + return text_size; + } + ++#include "wrapped_input.inl" ++ + // Wrapper for stb_textedit.h to edit text (our wrapper is for: statically sized buffer, single-line, wchar characters. InputText converts between UTF-8 and wchar) + namespace ImStb + { +@@ -3711,7 +3713,16 @@ static void STB_TEXTEDIT_LAYOUTROW(StbTexteditRow* r, ImGuiInputTextState* ob + { + const ImWchar* text = obj->TextW.Data; + const ImWchar* text_remaining = NULL; +- const ImVec2 size = InputTextCalcTextSizeW(obj->Ctx, text + line_start_idx, text + obj->CurLenW, &text_remaining, NULL, true); ++ ImVec2 size; ++ if (DkrReviewWrapWidth > 0.0f) ++ { ++ text_remaining = DkrReviewRowEnd(text + line_start_idx, text + obj->CurLenW); ++ size = ImVec2(DkrReviewMeasure(text + line_start_idx, text_remaining), obj->Ctx->FontSize); ++ } ++ else ++ { ++ size = InputTextCalcTextSizeW(obj->Ctx, text + line_start_idx, text + obj->CurLenW, &text_remaining, NULL, true); ++ } + r->x0 = 0.0f; + r->x1 = size.x; + r->baseline_y_delta = size.y; +@@ -3849,6 +3860,14 @@ static void stb_textedit_replace(ImGuiInputTextState* str, STB_TexteditState* st + void ImGuiInputTextState::OnKeyPressed(int key) + { + stb_textedit_key(this, &Stb, key); ++ if (DkrReviewWrapWidth > 0.0f) ++ { ++ const int plain_key = key & ~STB_TEXTEDIT_K_SHIFT; ++ const bool vertical = plain_key == STB_TEXTEDIT_K_UP || plain_key == STB_TEXTEDIT_K_DOWN || ++ plain_key == STB_TEXTEDIT_K_PGUP || plain_key == STB_TEXTEDIT_K_PGDOWN; ++ Stb.cursor_at_end_of_line = plain_key == STB_TEXTEDIT_K_LINEEND || ++ (vertical && DkrReviewVerticalRowEnd(this)); ++ } + CursorFollow = true; + CursorAnimReset(); + } +@@ -4139,7 +4158,7 @@ bool ImGui::InputTextEx(const char* label, const char* hint, char* buf, int buf_ + PushStyleVar(ImGuiStyleVar_ChildRounding, style.FrameRounding); + PushStyleVar(ImGuiStyleVar_ChildBorderSize, style.FrameBorderSize); + PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); // Ensure no clip rect so mouse hover can reach FramePadding edges +- bool child_visible = BeginChildEx(label, id, frame_bb.GetSize(), true, ImGuiWindowFlags_NoMove); ++ bool child_visible = BeginChildEx(label, id, frame_bb.GetSize(), true, ImGuiWindowFlags_NoMove | (DkrReviewWrapWidth > 0.0f ? ImGuiWindowFlags_AlwaysVerticalScrollbar : 0)); + g.NavActivateId = backup_activate_id; + PopStyleVar(3); + PopStyleColor(); +@@ -4845,6 +4864,14 @@ bool ImGui::InputTextEx(const char* label, const char* hint, char* buf, int buf_ + const ImWchar* text_begin = state->TextW.Data; + ImVec2 cursor_offset, select_start_offset; + ++ if (DkrReviewWrapWidth > 0.0f) ++ { ++ const ImWchar* text_end = text_begin + state->CurLenW; ++ cursor_offset = DkrReviewPosition(text_begin, text_end, state->Stb.cursor, state->Stb.cursor_at_end_of_line != 0); ++ select_start_offset = DkrReviewPosition(text_begin, text_end, ImMin(state->Stb.select_start, state->Stb.select_end)); ++ text_size = ImVec2(inner_size.x, DkrReviewHeight(text_begin, text_end)); ++ } ++ else + { + // Find lines numbers straddling 'cursor' (slot 0) and 'select_start' (slot 1) positions. + const ImWchar* searches_input_ptr[2] = { NULL, NULL }; +@@ -4932,7 +4959,15 @@ bool ImGui::InputTextEx(const char* label, const char* hint, char* buf, int buf_ + + // Draw selection + const ImVec2 draw_scroll = ImVec2(state->ScrollX, 0.0f); +- if (render_selection) ++ if (render_selection && DkrReviewWrapWidth > 0.0f) ++ { ++ DkrReviewSelection(draw_window->DrawList, draw_pos - draw_scroll, clip_rect, ++ text_begin, text_begin + state->CurLenW, ++ ImMin(state->Stb.select_start, state->Stb.select_end), ++ ImMax(state->Stb.select_start, state->Stb.select_end), ++ GetColorU32(ImGuiCol_TextSelectedBg, render_cursor ? 1.0f : 0.6f)); ++ } ++ else if (render_selection) + { + const ImWchar* text_selected_begin = text_begin + ImMin(state->Stb.select_start, state->Stb.select_end); + const ImWchar* text_selected_end = text_begin + ImMax(state->Stb.select_start, state->Stb.select_end); +@@ -4971,7 +5006,10 @@ bool ImGui::InputTextEx(const char* label, const char* hint, char* buf, int buf_ + if (is_multiline || (buf_display_end - buf_display) < buf_display_max_length) + { + ImU32 col = GetColorU32(is_displaying_hint ? ImGuiCol_TextDisabled : ImGuiCol_Text); +- draw_window->DrawList->AddText(g.Font, g.FontSize, draw_pos - draw_scroll, col, buf_display, buf_display_end, 0.0f, is_multiline ? NULL : &clip_rect); ++ if (DkrReviewWrapWidth > 0.0f) ++ text_size.y = DkrReviewDraw(draw_window->DrawList, draw_pos - draw_scroll, col, buf_display, buf_display_end); ++ else ++ draw_window->DrawList->AddText(g.Font, g.FontSize, draw_pos - draw_scroll, col, buf_display, buf_display_end, 0.0f, is_multiline ? NULL : &clip_rect); + } + + // Draw blinking cursor +@@ -5006,7 +5044,10 @@ bool ImGui::InputTextEx(const char* label, const char* hint, char* buf, int buf_ + if (is_multiline || (buf_display_end - buf_display) < buf_display_max_length) + { + ImU32 col = GetColorU32(is_displaying_hint ? ImGuiCol_TextDisabled : ImGuiCol_Text); +- draw_window->DrawList->AddText(g.Font, g.FontSize, draw_pos, col, buf_display, buf_display_end, 0.0f, is_multiline ? NULL : &clip_rect); ++ if (DkrReviewWrapWidth > 0.0f) ++ text_size.y = DkrReviewDraw(draw_window->DrawList, draw_pos, col, buf_display, buf_display_end); ++ else ++ draw_window->DrawList->AddText(g.Font, g.FontSize, draw_pos, col, buf_display, buf_display_end, 0.0f, is_multiline ? NULL : &clip_rect); + } + } + +diff --git a/src/contrib/imgui/imstb_textedit.h b/src/contrib/imgui/imstb_textedit.h +--- a/src/contrib/imgui/imstb_textedit.h ++++ b/src/contrib/imgui/imstb_textedit.h +@@ -461,6 +461,7 @@ static int stb_text_locate_coord(IMSTB_TEXTEDIT_STRING *str, float x, float y) + // API click: on mouse down, move the cursor to the clicked location, and reset the selection + static void stb_textedit_click(IMSTB_TEXTEDIT_STRING *str, STB_TexteditState *state, float x, float y) + { ++ if (DkrReviewWrapWidth > 0.0f) state->cursor_at_end_of_line = 0; + // In single-line mode, just always make y = 0. This lets the drag keep working if the mouse + // goes off the top or bottom of the text + if( state->single_line ) +@@ -479,6 +480,7 @@ static void stb_textedit_click(IMSTB_TEXTEDIT_STRING *str, STB_TexteditState *st + // API drag: on mouse drag, move the cursor and selection endpoint to the clicked location + static void stb_textedit_drag(IMSTB_TEXTEDIT_STRING *str, STB_TexteditState *state, float x, float y) + { ++ if (DkrReviewWrapWidth > 0.0f) state->cursor_at_end_of_line = 0; + int p = 0; + + // In single-line mode, just always make y = 0. This lets the drag keep working if the mouse +@@ -542,7 +544,7 @@ static void stb_textedit_find_charpos(StbFindState *find, IMSTB_TEXTEDIT_STRING + + for(;;) { + STB_TEXTEDIT_LAYOUTROW(&r, str, i); +- if (n < i + r.num_chars) ++ if (n < i + r.num_chars || (DkrReviewWrapWidth > 0.0f && str->Stb.cursor_at_end_of_line && n == i + r.num_chars && r.num_chars > 0 && STB_TEXTEDIT_GETCHAR(str, n - 1) != STB_TEXTEDIT_NEWLINE)) + break; + if (i + r.num_chars == z && z > 0 && STB_TEXTEDIT_GETCHAR(str, z - 1) != STB_TEXTEDIT_NEWLINE) // [DEAR IMGUI] special handling for last line + break; // [DEAR IMGUI] +@@ -975,7 +977,9 @@ retry: + // go to previous line + // (we need to scan previous line the hard way. maybe we could expose this as a new API function?) + prev_scan = find.prev_first > 0 ? find.prev_first - 1 : 0; +- while (prev_scan > 0 && STB_TEXTEDIT_GETCHAR(str, prev_scan - 1) != STB_TEXTEDIT_NEWLINE) ++ if (DkrReviewWrapWidth > 0.0f) ++ prev_scan = DkrReviewPreviousRow(str, find.prev_first); ++ else while (prev_scan > 0 && STB_TEXTEDIT_GETCHAR(str, prev_scan - 1) != STB_TEXTEDIT_NEWLINE) + --prev_scan; + find.first_char = find.prev_first; + find.prev_first = prev_scan; +@@ -1053,6 +1057,8 @@ retry: + stb_textedit_move_to_first(state); + if (state->single_line) + state->cursor = 0; ++ else if (DkrReviewWrapWidth > 0.0f) ++ state->cursor = DkrReviewRowBoundary(str, state->cursor, false); + else while (state->cursor > 0 && STB_TEXTEDIT_GETCHAR(str, state->cursor-1) != STB_TEXTEDIT_NEWLINE) + --state->cursor; + state->has_preferred_x = 0; +@@ -1067,6 +1073,8 @@ retry: + stb_textedit_move_to_first(state); + if (state->single_line) + state->cursor = n; ++ else if (DkrReviewWrapWidth > 0.0f) ++ state->cursor = DkrReviewRowBoundary(str, state->cursor, true); + else while (state->cursor < n && STB_TEXTEDIT_GETCHAR(str, state->cursor) != STB_TEXTEDIT_NEWLINE) + ++state->cursor; + state->has_preferred_x = 0; +@@ -1081,6 +1089,8 @@ retry: + stb_textedit_prep_selection_at_cursor(state); + if (state->single_line) + state->cursor = 0; ++ else if (DkrReviewWrapWidth > 0.0f) ++ state->cursor = DkrReviewRowBoundary(str, state->cursor, false); + else while (state->cursor > 0 && STB_TEXTEDIT_GETCHAR(str, state->cursor-1) != STB_TEXTEDIT_NEWLINE) + --state->cursor; + state->select_end = state->cursor; +@@ -1096,6 +1106,8 @@ retry: + stb_textedit_prep_selection_at_cursor(state); + if (state->single_line) + state->cursor = n; ++ else if (DkrReviewWrapWidth > 0.0f) ++ state->cursor = DkrReviewRowBoundary(str, state->cursor, true); + else while (state->cursor < n && STB_TEXTEDIT_GETCHAR(str, state->cursor) != STB_TEXTEDIT_NEWLINE) + ++state->cursor; + state->select_end = state->cursor; diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index 50e0c461..604b83a9 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -653,6 +653,31 @@ if(DKR_RUNTIME_BUILD_RT64) endif() include("${CMAKE_CURRENT_LIST_DIR}/cmake/EnhancedWater.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/HleCommandGuard.cmake") + # v1.0.11 renderer changes, applied to the end of the chain above + # (scripts/fold/dependency_stages.py derives them). The render-stability + # and present-pacer patches sit on the rollback stages of rt64_state, + # rt64_workload_queue and rt64_present_queue, the only chain v1.0.11 + # shipped; without those stages the renderer keeps its earlier behaviour. + set(rt64_chain "${CMAKE_BINARY_DIR}/generated/hle-command-guard") + if(DKR_EXPERIMENTAL_RENDER_QUALIFICATION OR DKR_EXPERIMENTAL_RACE_TEST) + dkr_stage_patch(rt64 "${rt64_chain}" rt64-render-stability + "${DKRPORT_ROOT}/patches/rt64/staged/0001-render-stability.patch" + src/render/rt64_texture_cache.cpp src/render/rt64_framebuffer_renderer.cpp + src/hle/rt64_state.cpp src/hle/rt64_workload_queue.cpp + HEADERS src/render/rt64_generated_mips.h KEEP_INCLUDE_DIRECTORIES) + dkr_stage_patch(rt64 "${rt64_chain}" rt64-present-pacer + "${DKRPORT_ROOT}/patches/rt64/staged/0003-present-queue-pacer.patch" src/hle/rt64_present_queue.cpp + KEEP_INCLUDE_DIRECTORIES) + endif() + dkr_stage_patch(rt64 "${rt64_chain}" rt64-vehicle-transform + "${DKRPORT_ROOT}/patches/rt64/staged/0002-vehicle-transform-processor.patch" + src/render/rt64_transform_processor.cpp KEEP_INCLUDE_DIRECTORIES) + dkr_stage_patch(rt64 "${rt64_chain}" rt64-texture-capture + "${DKRPORT_ROOT}/patches/rt64/staged/0004-texture-capture-observer.patch" src/hle/rt64_rdp_tmem.cpp + KEEP_INCLUDE_DIRECTORIES) + dkr_stage_patch(rt64 "${DKR_RT64_SOURCE}" rt64-review-editor + "${DKRPORT_ROOT}/patches/rt64/staged/0005-review-editor-imgui.patch" + src/contrib/imgui/imgui_widgets.cpp HEADERS src/contrib/imgui/imstb_textedit.h KEEP_INCLUDE_DIRECTORIES) if(MSVC AND TARGET rt64) # RT64 enables /MP internally. Ensure all compiler workers serialize # writes to the target PDB so clean RelWithDebInfo builds are reliable. diff --git a/runtime-recomp/cmake/PerformancePatches.cmake b/runtime-recomp/cmake/PerformancePatches.cmake index d2a15f3d..c0e7bc53 100644 --- a/runtime-recomp/cmake/PerformancePatches.cmake +++ b/runtime-recomp/cmake/PerformancePatches.cmake @@ -7,6 +7,10 @@ dkr_stage_patch(ultramodern "${CMAKE_BINARY_DIR}/generated/performance-runtime" "${DKRPORT_ROOT}/patches/performance/runtime-host-task-lifetime.patch" ultramodern/src/events.cpp) dkr_stage_patch(ultramodern "${CMAKE_BINARY_DIR}/generated/host-task-lifetime" vi-bootstrap "${DKRPORT_ROOT}/patches/performance/runtime-vi-bootstrap.patch" ultramodern/src/events.cpp) +# v1.0.11 producer snapshot handoff (scripts/fold/dependency_stages.py). +dkr_stage_patch(ultramodern "${CMAKE_BINARY_DIR}/generated/vi-bootstrap" ultramodern-graphics-handoff + "${DKRPORT_ROOT}/patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch" + ultramodern/src/events.cpp KEEP_INCLUDE_DIRECTORIES) dkr_stage_patch(librecomp "${DKR_MODERN_RUNTIME_SOURCE}" session-bootstrap "${DKRPORT_ROOT}/patches/performance/runtime-session-bootstrap.patch" librecomp/src/recomp.cpp) dkr_stage_patch(ultramodern "${DKR_MODERN_RUNTIME_SOURCE}" guest-lifetime diff --git a/runtime-recomp/cmake/StagedPatch.cmake b/runtime-recomp/cmake/StagedPatch.cmake index 089104b0..e09c6615 100644 --- a/runtime-recomp/cmake/StagedPatch.cmake +++ b/runtime-recomp/cmake/StagedPatch.cmake @@ -1,13 +1,24 @@ # Compose patches in disposable build-local scratch; publish only changed bytes. +# +# dkr_stage_patch(target source stage_name patch file... [HEADERS header...] +# [KEEP_INCLUDE_DIRECTORIES]) +# +# Each file replaces the one source of the same name in target. HEADERS are +# patched and published next to them but swap nothing: a staged source finds +# them first through its own directory, every other source keeps the original. +# KEEP_INCLUDE_DIRECTORIES keeps the replaced source's own include directories +# after the staged copy's source directory (a stage on top of a stage). include_guard(GLOBAL) find_package(Git REQUIRED) function(dkr_stage_patch target source stage_name patch) + cmake_parse_arguments(PARSE_ARGV 4 arg "KEEP_INCLUDE_DIRECTORIES" "" "HEADERS") set(stage "${CMAKE_BINARY_DIR}/generated/${stage_name}") set(scratch "${CMAKE_BINARY_DIR}/patch-work/${stage_name}") file(REMOVE_RECURSE "${scratch}") - set(files ${ARGN}) + set(files ${arg_UNPARSED_ARGUMENTS}) + set(staged ${files} ${arg_HEADERS}) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${patch}") - foreach(file IN LISTS files) + foreach(file IN LISTS staged) get_filename_component(parent "${file}" DIRECTORY) file(MAKE_DIRECTORY "${scratch}/${parent}") configure_file("${source}/${file}" "${scratch}/${file}" COPYONLY) @@ -27,7 +38,7 @@ function(dkr_stage_patch target source stage_name patch) message(FATAL_ERROR "${stage_name} ${mode} failed: ${error}") endif() endforeach() - foreach(file IN LISTS files) + foreach(file IN LISTS staged) get_filename_component(parent "${file}" DIRECTORY) file(MAKE_DIRECTORY "${stage}/${parent}") execute_process(COMMAND "${CMAKE_COMMAND}" -E copy_if_different @@ -45,8 +56,17 @@ function(dkr_stage_patch target source stage_name patch) list(REMOVE_ITEM sources ${matches}) list(APPEND sources "${stage}/${file}") get_filename_component(parent "${file}" DIRECTORY) + set(includes "${source}/${parent}") + if(arg_KEEP_INCLUDE_DIRECTORIES) + get_source_file_property(previous "${matches}" TARGET_DIRECTORY ${target} INCLUDE_DIRECTORIES) + if(previous) + list(APPEND includes ${previous}) + endif() + endif() + list(APPEND includes "${DKRPORT_ROOT}/runtime-recomp/src/game") + list(REMOVE_DUPLICATES includes) set_source_files_properties("${stage}/${file}" TARGET_DIRECTORY ${target} - PROPERTIES INCLUDE_DIRECTORIES "${source}/${parent};${DKRPORT_ROOT}/runtime-recomp/src/game") + PROPERTIES INCLUDE_DIRECTORIES "${includes}") endforeach() set_property(TARGET ${target} PROPERTY SOURCES "${sources}") endfunction() diff --git a/scripts/fold/dependency_stages.py b/scripts/fold/dependency_stages.py new file mode 100644 index 00000000..5d9a664d --- /dev/null +++ b/scripts/fold/dependency_stages.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Stage patches for the dependency transforms (fold step B3). + +v1.0.11 changed eleven third-party files (RT64, ultramodern) through release +layers: a pipeline rewrote the file the configure-time stage chain had already +produced and swapped its copy into the target. The fold keeps those files as +ordinary staged patches, applied by ``dkr_stage_patch`` after the chain: + +* STAGES names each patch, the chain output it applies to and the reference + files it reproduces; +* ``--write`` diffs each chain output against the shipped bytes and writes the + patch; ``--check`` regenerates the patches and fails if a committed one + differs or if a staged copy in the configured build is not the shipped + file (includes respelled). + +Both read a configured folded build (the chain output, at the input stage) and +the materialized v1.0.11 reference (docs/fold-in/REFERENCE.md). The shipped +files named staged headers by absolute path on the configuring machine; those +directives are respelled to the name the staged copy reaches through its own +directory or the src/game include path, which the equivalence gate's level 1 +(header closure) and level 2 (preprocessed tokens) then verify. + +The equivalence gate reads STAGES to pair each reference file with its folded +staged copy, ``generated//``. + + dependency_stages.py --reference DIR [--build DIR] --write + dependency_stages.py --reference DIR [--build DIR] --check + +Exit status: 0 on success, 1 when a patch is stale or a directive cannot be +respelled, 2 on usage errors. +""" + +from __future__ import annotations + +import argparse +import dataclasses +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts" / "fold")) +import apply_closure # noqa: E402 + + +@dataclasses.dataclass(frozen=True) +class Stage: + patch: str # repo-relative patch path + name: str # dkr_stage_patch stage name: generated// + source: str # chain output the patch applies to: generated/ or extern/ + files: dict[str, str] # path inside the stage -> reference original_path (sources) + headers: dict[str, str] = dataclasses.field(default_factory=dict) # same, staged headers + + +STAGES = ( + # Liquid family gate (render-stability), with the liquid-streaming + # generated-mips helpers rt64_texture_cache.cpp is their only reader of. + Stage( + "patches/rt64/staged/0001-render-stability.patch", + "rt64-render-stability", + "generated/hle-command-guard", + { + "src/render/rt64_texture_cache.cpp": "rt64/src/render/rt64_texture_cache.cpp", + "src/render/rt64_framebuffer_renderer.cpp": "rt64/src/render/rt64_framebuffer_renderer.cpp", + "src/hle/rt64_state.cpp": "rt64/src/hle/rt64_state.cpp", + "src/hle/rt64_workload_queue.cpp": "rt64/src/hle/rt64_workload_queue.cpp", + }, + {"src/render/rt64_generated_mips.h": "rt64/src/render/rt64_generated_mips.h"}, + ), + # Wheel interpolation (custom-vehicles). + Stage( + "patches/rt64/staged/0002-vehicle-transform-processor.patch", + "rt64-vehicle-transform", + "generated/hle-command-guard", + {"src/render/rt64_transform_processor.cpp": "rt64/src/render/rt64_transform_processor.cpp"}, + ), + # Present-queue pacer (surgical-performance). + Stage( + "patches/rt64/staged/0003-present-queue-pacer.patch", + "rt64-present-pacer", + "generated/hle-command-guard", + {"src/hle/rt64_present_queue.cpp": "rt64/src/hle/rt64_present_queue.cpp"}, + ), + # Texture-capture observer (texture-dumping). + Stage( + "patches/rt64/staged/0004-texture-capture-observer.patch", + "rt64-texture-capture", + "generated/hle-command-guard", + {"src/hle/rt64_rdp_tmem.cpp": "rt64/src/hle/rt64_rdp_tmem.cpp"}, + ), + # Review editor text widgets (review-editor). + Stage( + "patches/rt64/staged/0005-review-editor-imgui.patch", + "rt64-review-editor", + "extern/rt64", + {"src/contrib/imgui/imgui_widgets.cpp": "rt64/src/contrib/imgui/imgui_widgets.cpp"}, + {"src/contrib/imgui/imstb_textedit.h": "rt64/src/contrib/imgui/imstb_textedit.h"}, + ), + # Producer snapshot handoff (graphics-handoff). + Stage( + "patches/n64-modern-runtime/staged/0001-graphics-handoff-events.patch", + "ultramodern-graphics-handoff", + "generated/vi-bootstrap", + {"ultramodern/src/events.cpp": "n64-modern-runtime/ultramodern/src/events.cpp"}, + ), +) + + +def entries(stage: Stage) -> dict[str, str]: + return {**stage.files, **stage.headers} + + +def folded_paths(build: Path) -> dict[str, Path]: + """Reference original_path -> the folded staged copy.""" + return { + original: build / "generated" / stage.name / relative + for stage in STAGES + for relative, original in entries(stage).items() + } + + +def reference_rows(reference: apply_closure.Reference) -> dict[str, apply_closure.Row]: + rows = {row.original: row for row in reference.rows if row.kind == "dependency-transform"} + covered = {original for stage in STAGES for original in entries(stage).values()} + if set(rows) != covered: + raise SystemExit( + f"dependency_stages: STAGES covers {sorted(covered ^ set(rows))} differently from the reference" + ) + return rows + + +def input_path(stage: Stage, build: Path) -> Path: + base = ROOT if stage.source.startswith("extern/") else build + return base / stage.source + + +def respelled( + row: apply_closure.Row, + reference: apply_closure.Reference, + folder: apply_closure.Folder, + by_staged: dict[Path, str], + stage: Stage, + relative: str, + chain: bytes, +) -> tuple[bytes, list[str]]: + """Shipped bytes with every path-like include respelled for the folded stage. + + A directive that names a path (absolute on the configuring machine, or + ``../`` from a staged directory) is resolved the way a reference TU + resolved it and respelled to reach the folded counterpart: a header staged + alongside, the chain output's own header, or first-party code through the + src/game include path. Bare names, and spellings the chain output already + uses (its source directory stays on the staged copy's include path), are + left alone. + """ + data = row.staged.read_bytes() + problems: list[str] = [] + parent = Path(relative).parent + resolver = apply_closure.Resolver() + searches = [search for tu, search in reference.compdb.items() if row.staged in reference.deps.get(tu, ())] + if row.staged in reference.compdb: + searches.insert(0, reference.compdb[row.staged]) + kept = {spelling for _, _, spelling, _ in apply_closure.directives(chain)} + pieces: list[bytes] = [] + cursor = 0 + for line, quoted, spelling, (start, end) in apply_closure.directives(data): + if "/" not in spelling or spelling in kept: + continue + resolved = {resolver(spelling, quoted, row.staged.parent, search) for search in searches} - {None} + if len(resolved) != 1: + problems.append(f"{stage.patch}: {relative}:{line}: {spelling} resolves to {sorted(map(str, resolved))}") + continue + (target,) = resolved + if not spelling.startswith(("/", "../")) and folder.file(target) is not None: + continue # a dependency-relative path (common/rt64_thread.h) the chain keeps + new: str | None = None + if target in by_staged and Path(by_staged[target]).parent == parent: + new = Path(by_staged[target]).name # a header staged next to this file + elif target.is_relative_to(reference.build / "generated" / Path(stage.source).name): + inner = target.relative_to(reference.build / "generated" / Path(stage.source).name) + if inner.parent == parent: + new = inner.name # the chain output's own header, via the source's include path + else: + folded = folder.file(target) + game = ROOT / "runtime-recomp" / "src" / "game" + if folded is not None and folded.is_relative_to(game): + new = folded.relative_to(game).as_posix() # first-party, via src/game + if new is None or not quoted: + problems.append(f"{stage.patch}: {relative}:{line}: cannot respell {spelling}") + continue + if new == spelling: + continue + pieces += [data[cursor:start], new.encode()] + cursor = end + pieces.append(data[cursor:]) + return b"".join(pieces), problems + + +def diff(stage: Stage, contents: dict[str, tuple[bytes, bytes]]) -> bytes: + """A git-apply patch from each file's chain bytes to its shipped bytes.""" + with tempfile.TemporaryDirectory() as scratch: + work = Path(scratch) + for relative, (old, new) in contents.items(): + for side, data in (("a", old), ("b", new)): + path = work / side / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + done = subprocess.run( + ["git", "diff", "--no-index", "--no-prefix", "--no-color", "--full-index", "a", "b"], + cwd=work, + capture_output=True, + ) + if done.returncode not in (0, 1): + raise SystemExit(done.stderr.decode()) + # The side directories are named a/ and b/, so --no-prefix yields the usual + # a/ b/ headers. Blob ids say nothing about the chain input. + return re.sub(rb"(?m)^index [0-9a-f]+\.\.[0-9a-f]+( \d+)?\n", b"", done.stdout) + + +def build_patches(reference_dir: Path, build: Path) -> tuple[dict[str, bytes], dict[Path, bytes], list[str]]: + """Each patch's bytes, each staged copy's expected bytes, and problems.""" + reference = apply_closure.load_reference(reference_dir, ROOT) + rows = reference_rows(reference) + folder = apply_closure.Folder(reference, ROOT, apply_closure.plan(reference, ROOT).destinations) + by_staged = {rows[original].staged: relative for stage in STAGES for relative, original in entries(stage).items()} + patches: dict[str, bytes] = {} + expected: dict[Path, bytes] = {} + problems: list[str] = [] + for stage in STAGES: + contents: dict[str, tuple[bytes, bytes]] = {} + for relative, original in entries(stage).items(): + chain = input_path(stage, build) / relative + if not chain.is_file(): + problems.append(f"{stage.patch}: chain output {chain} is missing (configure the folded build first)") + continue + old = chain.read_bytes() + shipped, issues = respelled(rows[original], reference, folder, by_staged, stage, relative, old) + problems += issues + contents[relative] = (old, shipped) + expected[build / "generated" / stage.name / relative] = shipped + patches[stage.patch] = diff(stage, contents) + return patches, expected, problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--reference", type=Path, required=True) + parser.add_argument("--build", type=Path, default=ROOT / "build" / "fold-equivalence") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--write", action="store_true") + mode.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + patches, expected, problems = build_patches(args.reference.resolve(), args.build.resolve()) + for path, data in patches.items(): + current = ROOT / path + if args.write and not problems: + current.parent.mkdir(parents=True, exist_ok=True) + current.write_bytes(data) + elif args.check and (not current.is_file() or current.read_bytes() != data): + problems.append(f"{path}: stale; rerun with --write") + if args.check: + # What the configure staged must be the shipped bytes (includes respelled). + for staged, data in expected.items(): + if not staged.is_file() or staged.read_bytes() != data: + problems.append(f"{staged}: staged copy differs from the shipped file") + for problem in problems: + print(problem, file=sys.stderr) + if not problems: + print(f"dependency_stages: {len(patches)} patches {'written' if args.write else 'current'}") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 635e276d..6c7e95cf 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -57,6 +57,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import apply_closure # noqa: E402 +import dependency_stages # noqa: E402 ROOT = apply_closure.ROOT ALLOWED = ROOT / "scripts/fold/allowed-deltas.json" @@ -145,6 +146,14 @@ def __init__(self, reference_dir: Path, folded_build: Path) -> None: self.folded_build = folded_build self.result = result self.aliases = self.build_aliases() + # Dependency transforms (B3): each shipped copy pairs with its stage + # patch's output in the folded build. + staged = dependency_stages.folded_paths(folded_build) + self.dependency = { + row.staged: staged[row.original] + for row in self.reference.rows + if row.kind == "dependency-transform" and row.original in staged + } def build_aliases(self) -> dict[str, str]: """Build directories named after a content hash whose inputs include @@ -166,6 +175,8 @@ def build_path(self, path: Path) -> Path: return self.folded_build.joinpath(*parts) def file(self, path: Path) -> Path | None: + if path in self.dependency: + return self.dependency[path] folded = self.folder.file(path) if folded is not None and folded.is_relative_to(self.reference.build): return self.build_path(folded) @@ -190,7 +201,7 @@ def deferred(self, path: Path) -> str | None: layer = self.folder.layer_of(path) rows = {row.staged: row for row in self.reference.rows} row = rows.get(path) - if row is not None and row.kind == "dependency-transform": + if row is not None and row.kind == "dependency-transform" and path not in self.dependency: return "dependency transform (fold step B3)" if row is not None and row.kind in ("guest-transform", "guest-derived"): return "guest code (fold step B4)" diff --git a/scripts/fold/run_equivalence.sh b/scripts/fold/run_equivalence.sh index 6401ef64..ccc4c7e1 100755 --- a/scripts/fold/run_equivalence.sh +++ b/scripts/fold/run_equivalence.sh @@ -12,7 +12,9 @@ # built with, by image ID; docker/ci-linux-full/Dockerfile is its recipe). # # Steps: configure this tree with the reference's options minus the release -# chain's, then compare per-source compile settings and link lines (targets), +# chain's, check the dependency stage patches reproduce the shipped +# third-party files (dependency_stages.py), then compare per-source compile +# settings and link lines (targets), # TU sets, header closures and file bytes (level 1), preprocessed tokens plus # compile flags (level 2), and objects for the TUs level 2 lets differ because # of a header variant (level 3; DKR_EQUIVALENCE_LEVEL3=0 skips it, it compiles @@ -60,6 +62,8 @@ if [ ! -f "$reference/depscan-by-target.json" ]; then fi status=0 +echo "== dependency stages" +python3 "$root/scripts/fold/dependency_stages.py" --reference "$reference" --build "$build" --check || status=1 echo "== targets" python3 "$tool" targets --reference "$reference" --folded-build "$build" || status=1 echo "== level 1" From 87e09e9707825ec1a3dcd87a22b6302667468443 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:08:38 -0500 Subject: [PATCH 13/62] Apply guest hooks from hooks.toml instead of layer pipelines scripts/guest_hooks.py applies 14 declared hooks at configure time; each must hit once per payload. The 40 hooked files match v1.0.11; the gate pairs them. --- runtime-recomp/CMakeLists.txt | 2 + .../cmake/ExperimentalOwnedGame.cmake | 1 + runtime-recomp/cmake/GuestHooks.cmake | 49 ++++ runtime-recomp/guest-hooks/hooks.toml | 209 +++++++++++++++ runtime-recomp/tests/guest_hooks_tests.py | 241 ++++++++++++++++++ scripts/fold/allowed-deltas.json | 32 +++ scripts/fold/equivalence.py | 26 +- scripts/guest_hooks.py | 222 ++++++++++++++++ 8 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 runtime-recomp/cmake/GuestHooks.cmake create mode 100644 runtime-recomp/guest-hooks/hooks.toml create mode 100644 runtime-recomp/tests/guest_hooks_tests.py create mode 100644 scripts/guest_hooks.py diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index 604b83a9..1f4aa746 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -690,6 +690,7 @@ endif() find_package(Python3 REQUIRED COMPONENTS Interpreter) include("${CMAKE_CURRENT_LIST_DIR}/cmake/LegacyMods.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomMagicCodes.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/cmake/GuestHooks.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomVehicles.cmake") foreach(config IN ITEMS Debug Release RelWithDebInfo MinSizeRel) string(TOUPPER "${config}" config_upper) @@ -714,6 +715,7 @@ if(DKR_RUNTIME_BUILD_GENERATED) file(GLOB_RECURSE GENERATED_C CONFIGURE_DEPENDS "${SOURCE_DIR}/*.c") file(GLOB_RECURSE GENERATED_CXX CONFIGURE_DEPENDS "${SOURCE_DIR}/*.cpp") set(GENERATED_FILES ${GENERATED_C} ${GENERATED_CXX}) + dkr_apply_guest_hooks(GENERATED_FILES "${SOURCE_DIR}" "${REVISION}" funcs) list(LENGTH GENERATED_FILES GENERATED_COUNT) if(GENERATED_COUNT EQUAL 0) message(FATAL_ERROR diff --git a/runtime-recomp/cmake/ExperimentalOwnedGame.cmake b/runtime-recomp/cmake/ExperimentalOwnedGame.cmake index 9519e857..8f52423c 100644 --- a/runtime-recomp/cmake/ExperimentalOwnedGame.cmake +++ b/runtime-recomp/cmake/ExperimentalOwnedGame.cmake @@ -55,6 +55,7 @@ function(dkr_add_experimental_owned_game target generated) get_filename_component(root "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/.." ABSOLUTE) set(probe "${root}/tests/replay_probe") file(GLOB guest CONFIGURE_DEPENDS "${generated}/guest_*.c") + dkr_apply_guest_hooks(guest "${generated}" "${revision}" owned) add_library(${target} STATIC EXCLUDE_FROM_ALL "${probe}/probe_bridge.c" "${probe}/probe_native.c" "${probe}/probe_eeprom.cpp" "${probe}/probe_policies.cpp" "${probe}/probe_presentation.c" "${probe}/probe_component.c" "${probe}/probe_driver.cpp" diff --git a/runtime-recomp/cmake/GuestHooks.cmake b/runtime-recomp/cmake/GuestHooks.cmake new file mode 100644 index 00000000..5ef0e481 --- /dev/null +++ b/runtime-recomp/cmake/GuestHooks.cmake @@ -0,0 +1,49 @@ +# Guest hooks: runtime-recomp/guest-hooks/hooks.toml applied to recompiled +# guest code by scripts/guest_hooks.py, at configure time like the code-mod +# dispatch staging. Protected generated input is never edited: hooked copies +# go to a content-addressed build directory and replace their originals in the +# payload's source list. +# +# dkr_apply_guest_hooks( ) +include_guard(GLOBAL) +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +function(dkr_apply_guest_hooks sources_var source revision payload) + set(spec "${DKRPORT_ROOT}/runtime-recomp/guest-hooks/hooks.toml") + set(script "${DKRPORT_ROOT}/scripts/guest_hooks.py") + set(symbols "${DKRPORT_ROOT}/modding/reference-us-v${revision}/data.toml") + set(parts "") + foreach(input IN ITEMS "${spec}" "${script}" "${symbols}") + file(SHA256 "${input}" digest) + string(APPEND parts "${input}:${digest}|") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${input}") + endforeach() + file(GLOB inputs CONFIGURE_DEPENDS "${source}/*.c") + foreach(input IN LISTS inputs) + file(SHA256 "${input}" digest) + get_filename_component(name "${input}" NAME) + string(APPEND parts "${name}:${digest}|") + endforeach() + string(SHA256 identity "${payload}|${revision}|${parts}") + set(out "${CMAKE_BINARY_DIR}/generated/guest-hooks-${payload}-v${revision}-${identity}") + if(NOT EXISTS "${out}/hooked.txt") + execute_process(COMMAND "${Python3_EXECUTABLE}" "${script}" apply --spec "${spec}" + --in "${source}" --out "${out}" --revision "${revision}" --payload "${payload}" + RESULT_VARIABLE status) + if(NOT status EQUAL 0) + file(REMOVE_RECURSE "${out}") + message(FATAL_ERROR "Guest hooks for US v${revision} ${payload} did not apply; see above") + endif() + endif() + file(STRINGS "${out}/hooked.txt" hooked) + set(sources ${${sources_var}}) + foreach(name IN LISTS hooked) + list(FIND sources "${source}/${name}" at) + if(at EQUAL -1) + message(FATAL_ERROR "Guest hooks: ${name} is not a source of the US v${revision} ${payload} payload") + endif() + list(REMOVE_AT sources ${at}) + list(INSERT sources ${at} "${out}/${name}") + endforeach() + set(${sources_var} ${sources} PARENT_SCOPE) +endfunction() diff --git a/runtime-recomp/guest-hooks/hooks.toml b/runtime-recomp/guest-hooks/hooks.toml new file mode 100644 index 00000000..e4f7ee2e --- /dev/null +++ b/runtime-recomp/guest-hooks/hooks.toml @@ -0,0 +1,209 @@ +# Guest hooks: what the runtime adds to N64Recomp output (scripts/guest_hooks.py +# documents the format). payloads: funcs = DKRRecompiledFuncsV77/V80, owned = +# the experimental owned game. Every hook applies exactly once per payload. +schema = 1 + +# Achievements: observe the frame after each main loop pass (achievements). +[[hook]] +id = "achievements-observe" +function = "main_game_loop" +payloads = ["funcs"] +returns = '''extern void dkr_achievements_observe(uint8_t*,unsigned); + dkr_achievements_observe(rdram,{revision}); + ''' + +# Identical racer residency on every peer (all-player-detail). Both +# revisions save the spawn flags at sp+6c; clear their skip bits for racers. +[[hook]] +id = "all-player-residency" +function = "spawn_object" +payloads = ["funcs", "owned"] +[[hook.edits]] +match = '''ctx->r3; + after_2:''' +replace = '''ctx->r3; + after_2: + // all-player-residency-v1: every racer spawn uses identical residency on + // all peers, independent of local quality. Ghost/boss calls with no skip + // bits are unchanged, as are intentional NO_LODS previews. + if((uint32_t)ctx->r2>=0x80000000U&&(uint32_t)ctx->r2<=0x807FFF88U&& + MEM_BU(0x54,ctx->r2)==1U) + MEM_W(0X6C,ctx->r29)&=~12U; +''' + +# Detail observer around each mesh draw (all-player-detail). +[[hook]] +id = "detail-mesh" +function = "render_mesh" +payloads = ["funcs"] +entry = ''' uint32_t detail_event[19]={0}; + extern void dkr_detail_mesh_begin(uint8_t*,recomp_context*,uint32_t*); + extern void dkr_detail_mesh_end(uint8_t*,recomp_context*,uint32_t*); + dkr_detail_mesh_begin(rdram,ctx,detail_event); +''' +returns = '''dkr_detail_mesh_end(rdram,ctx,detail_event); + ''' + +# Custom vehicles: character events around model and wheel draws. +[[hook]] +id = "vehicle-model-scope" +function = "render_3d_model" +payloads = ["funcs", "owned"] +entry = ''' const uint32_t vehicle_object=(uint32_t)ctx->r4; + uint32_t vehicle_scope=0; + { recomp_context vc=*ctx; vc.r2=0; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); + dkr_legacy_character_event(rdram,&vc,200U,vehicle_object); vehicle_scope=(uint32_t)vc.r2; } +''' +returns = '{ recomp_context vc=*ctx; vc.r8=vehicle_scope; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); dkr_legacy_character_event(rdram,&vc,201U,vehicle_object); } ' + +[[hook]] +id = "vehicle-wheel-scope" +function = "render_sprite_billboard" +payloads = ["funcs", "owned"] +entry = ''' const uint32_t vehicle_wheel=(uint32_t)ctx->r7; +{ recomp_context vc=*ctx; vc.r8=0; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); dkr_legacy_character_event(rdram,&vc,202U,vehicle_wheel); } +''' +returns = '{ recomp_context vc=*ctx; vc.r8=0; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); dkr_legacy_character_event(rdram,&vc,203U,vehicle_wheel); } ' + +[[hook]] +id = "vehicle-object-event" +function = "func_80053750" +payloads = ["funcs", "owned"] +entry = '''{ recomp_context vc=*ctx; vc.r8=0; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); dkr_legacy_character_event(rdram,&vc,204U,(uint32_t)ctx->r4); } +''' + +# Custom vehicles: the track menu's vehicle picker. +[[hook]] +id = "track-select-init" +function = "menu_track_select_init" +payloads = ["funcs", "owned"] +returns = '{ recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); dkr_legacy_track_menu(rdram,&vc,100U,0,0U); } ' + +[[hook]] +id = "track-select-unload" +function = "menu_track_select_unload" +payloads = ["funcs", "owned"] +entry = '''{ recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); dkr_legacy_track_menu(rdram,&vc,104U,0,0U); } +''' + +[[hook]] +id = "track-menu-input" +function = "func_80092188" +payloads = ["funcs", "owned"] +entry = ''' { recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); if(dkr_legacy_track_menu(rdram,&vc,101U,0,0U))return; } +''' +returns = '{ recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); dkr_legacy_track_menu(rdram,&vc,102U,0,0U); } ' + +[[hook]] +id = "track-menu-assets" +function = "trackmenu_assets" +payloads = ["funcs", "owned"] +entry = ''' const unsigned vehicle_asset_type=(unsigned)ctx->r4; +''' +returns = 'if(vehicle_asset_type==1U){ recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); dkr_legacy_track_menu(rdram,&vc,105U,0,0U); } ' + +# The picker replaces only the lower vehicle/TT/CPU widgets; the native +# camera, title, timestamps, balloons, delay slots and epilogue stay. +[[hook]] +id = "track-menu-picker" +function = "trackmenu_setup_render" +payloads = ["funcs"] +entry = ''' int vehicle_picker=0; + { recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); + vehicle_picker=dkr_legacy_track_menu(rdram,&vc,103U,0,0U); } +''' +[hook.vars.77] +branch = "// 0x800915AC: beq $t5, $at, L_80091F08\n if (ctx->r13 == ctx->r1) {" +picked = "// 0x800915AC: beq $t5, $at, L_80091F08\n if (vehicle_picker || (ctx->r13 == ctx->r1)) {" +lower = "80092010" +finish = "80092150" +[hook.vars.80] +branch = "// 0x80091AA4: beq $t7, $at, L_80092400\n if (ctx->r15 == ctx->r1) {" +picked = "// 0x80091AA4: beq $t7, $at, L_80092400\n if (vehicle_picker || (ctx->r15 == ctx->r1)) {" +lower = "8009252C" +finish = "8009266C" +[[hook.edits]] +match = "{branch}" +replace = "{picked}" +[[hook.edits]] +match = "L_{lower}:" +replace = '''L_{lower}: + if(vehicle_picker) { + recomp_context vc=*ctx; + extern void dkr_vehicle_menu_draw(uint8_t*,recomp_context*,void(*)(uint8_t*,recomp_context*),void(*)(uint8_t*,recomp_context*)); + dkr_vehicle_menu_draw(rdram,&vc,menu_element_render,get_text_width); + goto L_{finish}; + }''' + +# The owned game draws the picker through its checked mod dispatch +# (online-vehicle-menu) and blocks rather than draw nothing. +[[hook]] +id = "owned-track-menu-picker" +function = "trackmenu_setup_render" +payloads = ["owned"] +entry = ''' int vehicle_picker=0; + { recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); + vehicle_picker=dkr_legacy_track_menu(rdram,&vc,103U,0,0U); } +''' +[hook.vars.77] +branch = "// 0x800915AC: beq $t5, $at, L_80091F08\n if (ctx->r13 == ctx->r1) {" +picked = "// 0x800915AC: beq $t5, $at, L_80091F08\n if (vehicle_picker || (ctx->r13 == ctx->r1)) {" +lower = "80092010" +finish = "80092150" +[hook.vars.80] +branch = "// 0x80091AA4: beq $t7, $at, L_80092400\n if (ctx->r15 == ctx->r1) {" +picked = "// 0x80091AA4: beq $t7, $at, L_80092400\n if (vehicle_picker || (ctx->r15 == ctx->r1)) {" +lower = "8009252C" +finish = "8009266C" +[[hook.edits]] +match = "{branch}" +replace = "{picked}" +[[hook.edits]] +match = "L_{lower}:" +replace = '''L_{lower}: + if(vehicle_picker) { + recomp_context vc=*ctx; + uint64_t vehicle_draw_result=0; + if(dkr_probe_mod_dispatch("dkr_owned_vehicle_menu_draw",rdram,&vc,0,0,0,0,&vehicle_draw_result)!=1 || !vehicle_draw_result) + dkr_probe_block("owned-vehicle-menu-draw-unavailable"); + goto L_{finish}; + }''' + +# Resize the display-list heap before any generated track or renderer +# borrows a heap cursor (mod-runtime-repair). +[[hook]] +id = "init-track-heap-repair" +function = "init_track" +payloads = ["funcs", "owned"] +entry = ''' /* post-mod-init-track=v1: before callbacks have already run, but no + generated track or renderer may have borrowed a heap cursor yet. */ + const int32_t repair_players=(int32_t)ctx->r6; + const int32_t repair_previous=MEM_W(0,(int32_t){symbol:gPrevPlayerCount}U); + if(repair_players>=0&&repair_players<4&&repair_previous!=repair_players) { + recomp_context repair_context=*ctx; + if(repair_players>repair_previous) { + extern void dkr_render_heap_reserve(uint8_t*,recomp_context*); + dkr_render_heap_reserve(rdram,&repair_context); + } + repair_context.r4=repair_players; + alloc_displaylist_heap(rdram,&repair_context); + repair_context.r4=repair_previous;repair_context.r5=repair_players; + repair_context.r6=MEM_W(0,(int32_t){symbol:gPrevPlayerCount}U); + extern void dkr_render_heap_sync_notice(uint8_t*,recomp_context*); + dkr_render_heap_sync_notice(rdram,&repair_context); + } +''' + +# Primary drivers keep identical resident models on all peers +# (vehicle-seating); fp is the vehicle, a3 the Settings row. +[[hook]] +id = "primary-driver-residency" +function = "track_setup_racers" +payloads = ["funcs", "owned"] +[[hook.edits]] +match = ''' MEM_W(0X5C, ctx->r29) = ctx->r7; + spawn_object(rdram, ctx);''' +replace = ''' MEM_W(0X5C, ctx->r29) = ctx->r7; + // Identical resident models on all peers; graphics stays local. + if((uint32_t)ctx->r30<=2U)ctx->r5&=~(gpr)12U; // primary-driver-lod-residency-v2: fp is vehicle, a3 is Settings row + spawn_object(rdram, ctx);''' diff --git a/runtime-recomp/tests/guest_hooks_tests.py b/runtime-recomp/tests/guest_hooks_tests.py new file mode 100644 index 00000000..ac40afed --- /dev/null +++ b/runtime-recomp/tests/guest_hooks_tests.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""scripts/guest_hooks.py on small recompiler-shaped fixtures (no ROM content): +each edit kind, the exactly-once rule, revision values, and the real spec.""" + +import importlib.util +from pathlib import Path +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("guest_hooks", ROOT / "scripts/guest_hooks.py") +guest_hooks = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(guest_hooks) + +GUEST = textwrap.dedent( + """\ + #include "funcs.h" + + RECOMP_FUNC void draw_thing(uint8_t* rdram, recomp_context* ctx) { + if (dkr_code_dispatch(rdram, ctx)) return; + dkr_code_body_draw_thing(rdram, ctx); + } + static void dkr_code_body_draw_thing(uint8_t* rdram, recomp_context* ctx) { + uint64_t hi = 0, lo = 0, result = 0; + // 0x80001000: beq $t5, $at, L_80001020 + if (ctx->r13 == ctx->r1) { + goto L_80001020; + } + /* a comment with a } brace */ + L_80001020: + ctx->r29 = ADD32(ctx->r29, 0X18); + return; + } + RECOMP_FUNC void plain(uint8_t* rdram, recomp_context* ctx) { + ctx->r2 = 0; + return; + } + """ +) + + +def write(directory: Path, files: dict[str, str]) -> list[Path]: + for name, text in files.items(): + (directory / name).write_text(text, encoding="utf-8") + return sorted(directory / name for name in files) + + +class GuestHookTests(unittest.TestCase): + def setUp(self): + self.scratch = tempfile.TemporaryDirectory() + self.root = Path(self.scratch.name) + for revision, vram in (("77", 0x8012350C), ("80", 0x80123A8C)): + kit = self.root / f"modding/reference-us-v{revision}" + kit.mkdir(parents=True) + (kit / "data.toml").write_text( + f'[[section]]\nsymbols = [{{ name = "gCounter", vram = 0x{vram:08X} }}]\n', encoding="utf-8" + ) + self.guest = self.root / "guest" + self.guest.mkdir() + + def tearDown(self): + self.scratch.cleanup() + + def hooks(self, spec: str, revision="77", payload="funcs"): + path = self.root / "hooks.toml" + path.write_text("schema = 1\n" + textwrap.dedent(spec), encoding="utf-8") + return guest_hooks.load(path, revision, payload, self.root) + + def test_entry_returns_and_edits_change_only_the_body(self): + hooks = self.hooks( + """ + [[hook]] + id = "thing" + function = "draw_thing" + payloads = ["funcs"] + entry = ''' int seen=0; + ''' + returns = 'observe(rdram,{revision}); ' + [[hook.edits]] + match = "if (ctx->r13 == ctx->r1) {" + replace = "if (seen || (ctx->r13 == ctx->r1)) {" + """ + ) + out, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": GUEST}), hooks) + self.assertEqual(problems, []) + text = out["funcs_1.c"] + self.assertIn( + "static void dkr_code_body_draw_thing(uint8_t* rdram, recomp_context* ctx) {\n int seen=0;\n\n", text + ) + self.assertIn(" observe(rdram,77); return;\n}\nRECOMP_FUNC void plain", text) + self.assertIn("if (seen || (ctx->r13 == ctx->r1)) {", text) + # The dispatcher's own early return and the next function are untouched. + self.assertIn("if (dkr_code_dispatch(rdram, ctx)) return;", text) + self.assertIn(" ctx->r2 = 0;\n return;\n}", text) + + def test_function_without_dispatcher_hooks_the_function_itself(self): + hooks = self.hooks( + """ + [[hook]] + id = "plain" + function = "plain" + payloads = ["funcs"] + returns = 'done(); ' + """ + ) + out, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": GUEST}), hooks) + self.assertEqual(problems, []) + self.assertIn(" ctx->r2 = 0;\n done(); return;\n}", out["funcs_1.c"]) + + def test_revision_values_and_symbols(self): + spec = """ + [[hook]] + id = "values" + function = "plain" + payloads = ["funcs"] + entry = ''' read({symbol:gCounter}U,{label}); + ''' + [hook.vars.77] + label = "L_77" + [hook.vars.80] + label = "L_80" + """ + for revision, expected in (("77", "read(0x8012350CU,L_77);"), ("80", "read(0x80123A8CU,L_80);")): + with self.subTest(revision=revision): + (hook,) = self.hooks(spec, revision) + self.assertEqual(hook.entry, f" {expected}\n") + + def test_unknown_values_fail(self): + for value in ("{symbol:gMissing}", "{undefined}"): + with self.subTest(value=value), self.assertRaises(guest_hooks.HookError): + self.hooks(f""" + [[hook]] + id = "bad" + function = "plain" + payloads = ["funcs"] + entry = '{value}' + """) + + def test_payloads_select_hooks(self): + spec = """ + [[hook]] + id = "owned-only" + function = "plain" + payloads = ["owned"] + returns = 'x(); ' + """ + self.assertEqual(self.hooks(spec, payload="funcs"), []) + self.assertEqual(len(self.hooks(spec, payload="owned")), 1) + + def test_exactly_once(self): + cases = { + "missing function": ("absent", {"funcs_1.c": GUEST}, "found 0 times"), + "function in two files": ("plain", {"funcs_1.c": GUEST, "funcs_2.c": GUEST}, "found 2 times"), + } + for label, (function, files, message) in cases.items(): + with self.subTest(label), tempfile.TemporaryDirectory() as directory: + hooks = self.hooks(f""" + [[hook]] + id = "once" + function = "{function}" + payloads = ["funcs"] + returns = 'x(); ' + """) + _, problems = guest_hooks.apply(write(Path(directory), files), hooks) + self.assertTrue(any(message in problem for problem in problems), problems) + + def test_edit_must_match_once(self): + for match, count in (("not there", 0), ("ctx->r", None)): + with self.subTest(match=match): + hooks = self.hooks(f""" + [[hook]] + id = "edit" + function = "draw_thing" + payloads = ["funcs"] + [[hook.edits]] + match = "{match}" + replace = "y" + """) + _, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": GUEST}), hooks) + self.assertTrue(any("not once" in problem for problem in problems), problems) + + def test_returns_needs_a_return(self): + hooks = self.hooks( + """ + [[hook]] + id = "fence" + function = "draw_thing" + payloads = ["funcs"] + returns = 'x(); ' + """ + ) + head, tail = GUEST.split(" return;\n}\nRECOMP_FUNC void plain") + text = head + "}\nRECOMP_FUNC void plain" + tail + _, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": text}), hooks) + self.assertTrue(any("has no return" in problem for problem in problems), problems) + + def test_apply_writes_hooked_files_and_list(self): + (self.root / "hooks.toml").write_text( + 'schema = 1\n[[hook]]\nid = "p"\nfunction = "plain"\npayloads = ["funcs"]\nreturns = "x(); "\n', + encoding="utf-8", + ) + write(self.guest, {"funcs_1.c": GUEST, "funcs_2.c": "int unrelated;\n"}) + out = self.root / "out" + original = guest_hooks.ROOT + guest_hooks.ROOT = self.root + try: + status = guest_hooks.main( + [ + "apply", + "--spec", + str(self.root / "hooks.toml"), + "--in", + str(self.guest), + "--out", + str(out), + "--revision", + "77", + "--payload", + "funcs", + ] + ) + finally: + guest_hooks.ROOT = original + self.assertEqual(status, 0) + self.assertEqual((out / "hooked.txt").read_text(encoding="utf-8"), "funcs_1.c\n") + self.assertFalse((out / "funcs_2.c").exists()) + self.assertEqual((self.guest / "funcs_1.c").read_text(encoding="utf-8"), GUEST) + + +class RepositorySpecTests(unittest.TestCase): + def test_spec_loads_for_every_payload_and_revision(self): + spec = ROOT / "runtime-recomp/guest-hooks/hooks.toml" + for revision in guest_hooks.REVISIONS: + for payload in guest_hooks.PAYLOADS: + with self.subTest(revision=revision, payload=payload): + self.assertTrue(guest_hooks.load(spec, revision, payload)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/fold/allowed-deltas.json b/scripts/fold/allowed-deltas.json index 3c73a903..a0412071 100644 --- a/scripts/fold/allowed-deltas.json +++ b/scripts/fold/allowed-deltas.json @@ -21,6 +21,38 @@ { "difference": "DKRPortGame: link order moved ['-lm', 'libDKRCameraSceneV77.a', 'libDKRCameraSceneV80.a'], everything else in order", "reason": "The game's TUs compile in the DKRPortGameCore object library (so host builds without a payload compile them), and DKR-R links the core's libraries LINK_ONLY. CMake now places DKRCameraPresentation's private dependencies (the two camera scene kernels and libm) at their first transitive occurrence instead of the end. They still follow DKRCameraPresentation, their only user, and every other library keeps its position." + }, + { + "difference": "DKRRecompiledFuncsV77: build/fold-equivalence/generated/guest-hooks-funcs-v77/funcs_12.c: include dirs lost ['/generated'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRRecompiledFuncsV77: build/fold-equivalence/generated/guest-hooks-funcs-v77/funcs_14.c: include dirs lost ['/generated'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRRecompiledFuncsV77: build/fold-equivalence/generated/guest-hooks-funcs-v77/funcs_31.c: include dirs lost ['/generated'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRRecompiledFuncsV80: build/fold-equivalence/generated/guest-hooks-funcs-v80/funcs_12.c: include dirs lost ['/generated'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRRecompiledFuncsV80: build/fold-equivalence/generated/guest-hooks-funcs-v80/funcs_14.c: include dirs lost ['/generated'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRRecompiledFuncsV80: build/fold-equivalence/generated/guest-hooks-funcs-v80/funcs_31.c: include dirs lost ['/generated'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRExperimentalOwnedGameV77: build/fold-equivalence/generated/guest-hooks-owned-v77/guest_26.c: include dirs lost ['build/owned-id/final'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." + }, + { + "difference": "DKRExperimentalOwnedGameV80: build/fold-equivalence/generated/guest-hooks-owned-v80/guest_26.c: include dirs lost ['build/owned-id/final'] extra []", + "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." } ] } diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 6c7e95cf..2f8dfd11 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -154,6 +154,12 @@ def __init__(self, reference_dir: Path, folded_build: Path) -> None: for row in self.reference.rows if row.kind == "dependency-transform" and row.original in staged } + # Guest transforms (B4): each shipped funcs_N.c/guest_N.c pairs with + # its guest-hooks copy (cmake/GuestHooks.cmake). + for row in self.reference.rows: + hooked = self.guest_hooked(row) if row.kind == "guest-transform" else None + if hooked is not None: + self.dependency[row.staged] = hooked def build_aliases(self) -> dict[str, str]: """Build directories named after a content hash whose inputs include @@ -168,6 +174,21 @@ def build_aliases(self) -> dict[str, str]: aliases[old[0].name] = new[0].name return aliases + def guest_hooked(self, row: apply_closure.Row) -> Path | None: + match = re.search(r"/DKR(RecompiledFuncs|ExperimentalOwnedGame)V(77|80)/", str(row.staged)) + if match is None: + match = re.search(r"/guest(77|80)/", str(row.staged)) + payload, revision = ("funcs", match.group(1)) if match else (None, None) + else: + payload = "funcs" if match.group(1) == "RecompiledFuncs" else "owned" + revision = match.group(2) + if payload is None: + return None + dirs = sorted((self.folded_build / "generated").glob(f"guest-hooks-{payload}-v{revision}-*")) + if len(dirs) != 1 or not (dirs[0] / row.staged.name).is_file(): + return None + return dirs[0] / row.staged.name + def build_path(self, path: Path) -> Path: parts = list(path.relative_to(self.reference.build).parts) if len(parts) > 1 and parts[0] == "generated" and parts[1] in self.aliases: @@ -203,7 +224,7 @@ def deferred(self, path: Path) -> str | None: row = rows.get(path) if row is not None and row.kind == "dependency-transform" and path not in self.dependency: return "dependency transform (fold step B3)" - if row is not None and row.kind in ("guest-transform", "guest-derived"): + if row is not None and row.kind in ("guest-transform", "guest-derived") and path not in self.dependency: return "guest code (fold step B4)" if layer is not None: return f"staged by layer {layer} (fold step B3 or B4)" @@ -371,6 +392,9 @@ def run_targets(args: argparse.Namespace) -> int: problems: list[str] = [] for problem in found: portable = problem.replace(str(args.folded_build), "").replace(str(ROOT) + "/", "") + # Guest-hook copies live in a content-addressed directory; name it by + # payload and revision so a recorded delta survives a spec edit. + portable = re.sub(r"/generated/(guest-hooks-[a-z]+-v\d+)-[0-9a-f]{64}/", r"/generated/\1/", portable) reason = explained(portable, allowed) if reason is None: problems.append(portable) diff --git a/scripts/guest_hooks.py b/scripts/guest_hooks.py new file mode 100644 index 00000000..a59e1a7c --- /dev/null +++ b/scripts/guest_hooks.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Apply the declared guest hooks to recompiled guest code. + +runtime-recomp/guest-hooks/hooks.toml lists every hook the runtime puts into +N64Recomp output: the guest function, which payloads it applies to (``funcs``: +the DKRRecompiledFuncsV77/V80 payload, ``owned``: the experimental owned game), +and up to three edits inside that function's body: + +* ``entry``: text inserted at the top of the body; +* ``returns``: text put before every ``return;`` of the original body; +* ``edits``: exact ``match`` -> ``replace`` substitutions, each of which must + occur exactly once in the body. + +Text may use ``{revision}`` (77 or 80), ``{symbol:NAME}`` (the symbol's vram +from modding/reference-us-v/data.toml, as 0x%08X) and the hook's own +per-revision ``vars``. The body is the generated ``dkr_code_body_`` +when the code-mod dispatcher wrapped the function, else the ``RECOMP_FUNC`` +itself. + +Each hook must apply in exactly one file of the payload, or nothing is written. +Hooked files go to OUT under their own names, and OUT/hooked.txt lists them; +the build compiles those in place of the originals (cmake/GuestHooks.cmake). + + guest_hooks.py apply --spec hooks.toml --in DIR --out DIR --revision 77 --payload funcs + +Exit status: 0 on success, 1 when a hook does not apply exactly once, 2 on +usage errors. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterator +import dataclasses +from pathlib import Path +import re +import sys + +import tomllib + +ROOT = Path(__file__).resolve().parents[1] +PAYLOADS = ("funcs", "owned") +REVISIONS = ("77", "80") + + +class HookError(Exception): + pass + + +@dataclasses.dataclass(frozen=True) +class Hook: + id: str + function: str + payloads: tuple[str, ...] + entry: str + returns: str + edits: tuple[tuple[str, str], ...] + + +def symbols(revision: str, root: Path | None = None) -> dict[str, int]: + data = tomllib.loads(((root or ROOT) / f"modding/reference-us-v{revision}/data.toml").read_text(encoding="utf-8")) + return {symbol["name"]: symbol["vram"] for section in data["section"] for symbol in section.get("symbols", [])} + + +def expand(text: str, revision: str, variables: dict[str, str], table: dict[str, int]) -> str: + def value(match: re.Match[str]) -> str: + key = match.group(1) + if key == "revision": + return revision + if key.startswith("symbol:"): + name = key.removeprefix("symbol:") + if name not in table: + raise HookError(f"unknown symbol {name} for US v{revision}") + return f"0x{table[name]:08X}" + if key not in variables: + raise HookError(f"no value for {{{key}}} in US v{revision}") + return variables[key] + + return re.sub(r"\{([A-Za-z_][A-Za-z0-9_:]*)\}", value, text) + + +def load(spec: Path, revision: str, payload: str, root: Path | None = None) -> list[Hook]: + data = tomllib.loads(spec.read_text(encoding="utf-8")) + if data.get("schema") != 1: + raise HookError(f"{spec}: unsupported schema") + table = symbols(revision, root) + hooks: list[Hook] = [] + seen: set[str] = set() + for entry in data.get("hook", []): + hook_id = entry["id"] + if hook_id in seen: + raise HookError(f"duplicate hook id {hook_id}") + seen.add(hook_id) + payloads = tuple(entry["payloads"]) + if not payloads or set(payloads) - set(PAYLOADS): + raise HookError(f"{hook_id}: payloads must be a subset of {PAYLOADS}") + if payload not in payloads: + continue + variables = {key: str(value) for key, value in entry.get("vars", {}).get(revision, {}).items()} + + def text(value: str) -> str: + return expand(value, revision, variables, table) + + edits = tuple((text(edit["match"]), text(edit["replace"])) for edit in entry.get("edits", [])) + hook = Hook( + hook_id, entry["function"], payloads, text(entry.get("entry", "")), text(entry.get("returns", "")), edits + ) + if not (hook.entry or hook.returns or hook.edits): + raise HookError(f"{hook_id}: no entry, returns or edits") + hooks.append(hook) + return hooks + + +def bodies(text: str, function: str) -> list[tuple[int, int]]: + """Spans of the function's body, between its braces: the dispatcher's + wrapped body when there is one, else the function itself.""" + found: list[tuple[int, int]] = [] + for prefix in ("static void dkr_code_body_", "RECOMP_FUNC void "): + pattern = re.escape(prefix + function) + r"\(uint8_t\* rdram, recomp_context\* ctx\) \{" + for match in re.finditer(pattern, text): + found.append((match.end(), closing_brace(text, match.end(), function))) + if found: + return found + return found + + +def closing_brace(text: str, begin: int, function: str) -> int: + depth = 1 + for token in re.finditer(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\{|\}', text[begin:]): + depth += {"{": 1, "}": -1}.get(token.group(), 0) + if depth == 0: + return begin + token.start() + raise HookError(f"{function}: unbalanced braces") + + +def hooked_body(body: str, hook: Hook) -> str: + for old, new in hook.edits: + count = body.count(old) + if count != 1: + raise HookError(f"{hook.id}: edit matches {count} times in {hook.function}, not once: {old[:60]!r}") + body = body.replace(old, new) + if hook.returns: + body, count = re.subn(r"\breturn;", lambda _: hook.returns + "return;", body) + if count == 0: + raise HookError(f"{hook.id}: {hook.function} has no return") + if hook.entry: + body = "\n" + hook.entry + body + return body + + +def apply(sources: list[Path], hooks: list[Hook]) -> tuple[dict[str, str], list[str]]: + """Hooked file text by file name, and one problem per hook that did not + apply exactly once.""" + hits: dict[str, list[str]] = {hook.id: [] for hook in hooks} + out: dict[str, str] = {} + problems: list[str] = [] + for path in sources: + text = path.read_text(encoding="utf-8") + changed = False + for hook in hooks: + spans = bodies(text, hook.function) + if not spans: + continue + hits[hook.id] += [path.name] * len(spans) + if len(spans) != 1: + continue + start, end = spans[0] + try: + text = text[:start] + hooked_body(text[start:end], hook) + text[end:] + except HookError as error: + problems.append(f"{path.name}: {error}") + continue + changed = True + if changed: + out[path.name] = text + for hook in hooks: + if len(hits[hook.id]) != 1: + problems.append(f"{hook.id}: {hook.function} found {len(hits[hook.id])} times {hits[hook.id]}, not once") + return out, problems + + +def guest_sources(directory: Path) -> Iterator[Path]: + yield from sorted(path for path in directory.glob("*.c") if path.is_file()) + + +def run_apply(args: argparse.Namespace) -> int: + try: + hooks = load(args.spec, args.revision, args.payload) + hooked, problems = apply(list(guest_sources(args.input)), hooks) + except HookError as error: + problems, hooked = [str(error)], {} + for problem in problems: + print(f"guest_hooks: {problem}", file=sys.stderr) + if problems: + return 1 + args.out.mkdir(parents=True, exist_ok=True) + if args.out.resolve() == args.input.resolve(): + print("guest_hooks: --out must differ from --in", file=sys.stderr) + return 2 + for name, text in sorted(hooked.items()): + target = args.out / name + if not target.exists() or target.read_text(encoding="utf-8") != text: + target.write_text(text, encoding="utf-8", newline="\n") + (args.out / "hooked.txt").write_text("".join(f"{name}\n" for name in sorted(hooked)), encoding="utf-8") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + apply_parser = commands.add_parser("apply", help="write the hooked files of one payload") + apply_parser.add_argument("--spec", type=Path, required=True) + apply_parser.add_argument("--in", dest="input", type=Path, required=True) + apply_parser.add_argument("--out", type=Path, required=True) + apply_parser.add_argument("--revision", choices=REVISIONS, required=True) + apply_parser.add_argument("--payload", choices=PAYLOADS, required=True) + args = parser.parse_args(argv) + return run_apply(args) + + +if __name__ == "__main__": + sys.exit(main()) From c6b643e930c4027bfe247f7bfdbeaec13c65898a Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:08:44 -0500 Subject: [PATCH 14/62] Fold the Camera++ shadow kernel, derive its hook stubs in Camera.cmake camera_shadow_kernel.c is first-party source; the stubs it implements are dropped from the prepared kernel's list, so no capsule role is read. --- runtime-recomp/cmake/Camera.cmake | 46 ++++++++++++---- .../src/game/camera_shadow_kernel.c | 53 +++++++++++++++++++ scripts/fold/equivalence.py | 15 ++++-- scripts/pattern_guard.py | 3 ++ 4 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 runtime-recomp/src/game/camera_shadow_kernel.c diff --git a/runtime-recomp/cmake/Camera.cmake b/runtime-recomp/cmake/Camera.cmake index 2762dfbf..fc6a60dd 100644 --- a/runtime-recomp/cmake/Camera.cmake +++ b/runtime-recomp/cmake/Camera.cmake @@ -1,28 +1,52 @@ # Camera++: the local presentation provider (DKRCameraPresentation) and one # draw-only scene kernel per revision. The kernels compile guest-derived code # prepared outside the build: DKR_CAMERA_KERNEL_V holds -# camera_scene_functions.c from patches/camera-plus/scene_kernel_pipeline.py, -# and DKR_CAMERA_SHADOW_KERNEL_V holds the canonical-shadow -# camera_scene_hooks.c and camera_shadow_kernel.c. Neither input is modified. +# camera_scene_functions.c and camera_scene_hooks.c from +# patches/camera-plus/scene_kernel_pipeline.py. Neither input is modified. set(DKR_CAMERA_KERNEL_V77 "" CACHE PATH "Prepared Camera++ scene kernel for revision 77") set(DKR_CAMERA_KERNEL_V80 "" CACHE PATH "Prepared Camera++ scene kernel for revision 80") -set(DKR_CAMERA_SHADOW_KERNEL_V77 "" CACHE PATH "Prepared Camera++ shadow kernel for revision 77") -set(DKR_CAMERA_SHADOW_KERNEL_V80 "" CACHE PATH "Prepared Camera++ shadow kernel for revision 80") + +# The prepared hooks file stubs every scene hook. src/game/camera_shadow_kernel.c +# implements the shadow ones, so the kernel compiles the stubs of all the others. +function(dkr_camera_hook_stubs out kernel revision) + set(shadow "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../src/game/camera_shadow_kernel.c") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${kernel}/camera_scene_hooks.c" "${shadow}") + file(READ "${kernel}/camera_scene_hooks.c" stubs) + string(REPLACE "\r\n" "\n" stubs "${stubs}") + file(STRINGS "${shadow}" defined REGEX "^unsigned dkr_[a-z_]+\\(uint8_t\\* ram,recomp_context\\* ctx,\\.\\.\\.\\) \\{$") + if(NOT defined) + message(FATAL_ERROR "camera_shadow_kernel.c defines no scene hooks") + endif() + foreach(line IN LISTS defined) + string(REGEX MATCH "^unsigned (dkr_[a-z_]+)" name "${line}") + set(stub "unsigned dkr_camera_v${revision}_${CMAKE_MATCH_1}(uint8_t* ram,recomp_context* ctx,...){(void)ram;(void)ctx;return 0;}\n") + string(FIND "${stubs}" "${stub}" at) + if(at EQUAL -1) + message(FATAL_ERROR "The prepared US v${revision} Camera++ kernel has no stub for ${CMAKE_MATCH_1}") + endif() + string(REPLACE "${stub}" "" stubs "${stubs}") + endforeach() + set(path "${CMAKE_BINARY_DIR}/generated/camera-shadow-v${revision}/camera_scene_hooks.c") + if(EXISTS "${path}") + file(READ "${path}" current) + endif() + if(NOT EXISTS "${path}" OR NOT current STREQUAL stubs) + file(WRITE "${path}" "${stubs}") + endif() + set(${out} "${path}" PARENT_SCOPE) +endfunction() function(dkr_camera_scene_kernel target revision) set(kernel "${DKR_CAMERA_KERNEL_V${revision}}") - set(shadow "${DKR_CAMERA_SHADOW_KERNEL_V${revision}}") if(NOT EXISTS "${kernel}/manifest.json") message(FATAL_ERROR "Set DKR_CAMERA_KERNEL_V${revision} to a NEW draw-only Camera++ kernel") endif() - if(NOT EXISTS "${shadow}/camera_scene_hooks.c" OR NOT EXISTS "${shadow}/camera_shadow_kernel.c") - message(FATAL_ERROR "Set DKR_CAMERA_SHADOW_KERNEL_V${revision} to the prepared Camera++ shadow kernel") - endif() + dkr_camera_hook_stubs(hooks "${kernel}" "${revision}") add_library(${target} STATIC "${kernel}/camera_scene_functions.c" - "${shadow}/camera_scene_hooks.c" + "${hooks}" "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../src/game/camera_kernel.c" - "${shadow}/camera_shadow_kernel.c") + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../src/game/camera_shadow_kernel.c") target_include_directories(${target} BEFORE PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../src/game" "${kernel}") target_compile_features(${target} PRIVATE c_std_11) foreach(helper access step fault) diff --git a/runtime-recomp/src/game/camera_shadow_kernel.c b/runtime-recomp/src/game/camera_shadow_kernel.c new file mode 100644 index 00000000..fbcf9aec --- /dev/null +++ b/runtime-recomp/src/game/camera_shadow_kernel.c @@ -0,0 +1,53 @@ +/* Private draw-copy observation only. Never registers live game identities or + * advances shadow simulation. The original kernel's C fault fence owns errors. */ +#include "camera_scene_addresses.h" +#include "funcs.h" +#include "camera_shadow_kernel.h" +#include +#if defined(_MSC_VER) +#define SHADOW_TLS __declspec(thread) +#else +#define SHADOW_TLS _Thread_local +#endif +static SHADOW_TLS dkr_camera_shadows* shadow_output; +static SHADOW_TLS unsigned shadow_open; +static uint32_t shadow_word(uint32_t address) { + return *(uint32_t*)dkr_camera_access((uint64_t)(int64_t)(int32_t)address,4); +} +static uint32_t command(void) { + const uint32_t address=shadow_word(CAMERA_gTrackDL); + if(address<0x80800000U||address>0x80900000U||(address&7)) + dkr_camera_fault("camera shadow command span"); + return address; +} +unsigned dkr_shadow_interpolation_begin(uint8_t* ram,recomp_context* ctx,...) { + (void)ram; + if(!shadow_output||shadow_open||shadow_output->count==DKR_CAMERA_MAX_SHADOWS) + dkr_camera_fault("camera shadow metadata budget/scope"); + dkr_camera_shadow* entry=&shadow_output->entries[shadow_output->count++]; + entry->begin=command();entry->end=0; + entry->object=(uint32_t)ctx->r6;entry->shadow=(uint32_t)ctx->r7; + shadow_open=1; + return 0; +} +unsigned dkr_shadow_interpolation_end(uint8_t* ram,recomp_context* ctx,...) { + (void)ram;(void)ctx; + if(!shadow_open)return 0; /* Retail early-return path has no matching begin. */ + shadow_output->entries[shadow_output->count-1].end=command(); + shadow_open=0; + return 0; +} +int DKR_SHADOW_ENTRY(uint8_t* ram,size_t bytes,const dkr_camera_draw_request* request, + dkr_camera_draw_result* result,dkr_camera_shadows* shadows) { + if(!shadows||shadow_output)return 0; + memset(shadows,0,sizeof(*shadows));shadow_output=shadows;shadow_open=0; + const int okay=CAMERA_ENTRY(ram,bytes,request,result); + const int complete=!shadow_open; + shadow_output=0;shadow_open=0; + if(!okay||!complete) { + shadows->count=0; + if(okay&&result)result->failure="camera shadow scope did not close"; + return 0; + } + return 1; +} diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 2f8dfd11..96ce099d 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -160,6 +160,17 @@ def __init__(self, reference_dir: Path, folded_build: Path) -> None: hooked = self.guest_hooked(row) if row.kind == "guest-transform" else None if hooked is not None: self.dependency[row.staged] = hooked + # Camera++ shadow kernels (B4): camera_shadow_kernel.c is first-party + # source; Camera.cmake derives camera_scene_hooks.c per revision. + for row in self.reference.rows: + shadow = re.search(r"/roles/CAMERA_SHADOW/kernel-v(77|80)/([^/]+)$", str(row.staged)) + if row.kind != "guest-derived" or shadow is None: + continue + if shadow.group(2) == "camera_scene_hooks.c": + hooks = folded_build / "generated" / f"camera-shadow-v{shadow.group(1)}" / shadow.group(2) + self.dependency[row.staged] = hooks + else: + self.dependency[row.staged] = ROOT / "runtime-recomp/src/game" / shadow.group(2) def build_aliases(self) -> dict[str, str]: """Build directories named after a content hash whose inputs include @@ -916,10 +927,6 @@ def folded_options(reference: Path) -> tuple[list[str], set[Path]]: options.append(arg) if capsule is None: raise SystemExit("equivalence: the reference configure names no capsule") - # Camera++ shadow kernels: the chain read them from the capsule role; the - # folded tree takes them as prepared inputs until fold step B4 generates them. - for revision in ("77", "80"): - options.append(f"-DDKR_CAMERA_SHADOW_KERNEL_V{revision}={capsule}/roles/CAMERA_SHADOW/kernel-v{revision}") mounts = { Path(arg.partition("=")[2]) for arg in options diff --git a/scripts/pattern_guard.py b/scripts/pattern_guard.py index 00560323..a6c6bd94 100644 --- a/scripts/pattern_guard.py +++ b/scripts/pattern_guard.py @@ -189,6 +189,9 @@ class Rule: # Fold-only (step B3): diffs the stage chain against the shipped dependency # transforms into registered stage patches, respelling the same includes. "scripts/fold/dependency_stages.py": "writes the v1.0.11 dependency transforms as stage patches", + # Fold-only: compares the folded build with the reference; it writes + # reports and scratch objects, never a source file. + "scripts/fold/equivalence.py": "reads the folded tree to compare it, writes only reports", } GUEST_REWRITE_ALLOWED = {"scripts/guest_hooks.py", "runtime-recomp/cmake/GuestHooks.cmake"} IDENTITY_ALLOWED_SUFFIX = "netplay/legacy_identity.hpp" From 14cf8ab15167cec8adc73453373d925e0b8b256a Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:16:19 -0500 Subject: [PATCH 15/62] Generate the guest symbol headers from runtime-recomp/symbols gen_guest_symbols.py writes the four headers into the build tree from the pinned symbol tables; the committed copies go. Golden hashes guard them. --- architecture/pattern-guard-baseline.txt | 3 - docs/fold-in/open-items.md | 22 +++ runtime-recomp/CMakeLists.txt | 2 + runtime-recomp/cmake/GuestSymbols.cmake | 26 ++++ .../src/game/achievement_symbols.hpp | 6 - runtime-recomp/src/game/polish_symbols.hpp | 9 -- .../src/game/render_heap_addresses.hpp | 6 - runtime-recomp/src/game/vehicle_symbols.hpp | 6 - .../symbols/achievement_symbols.toml | 30 ++++ runtime-recomp/symbols/polish_symbols.toml | 26 ++++ .../symbols/render_heap_addresses.toml | 15 ++ runtime-recomp/symbols/vehicle_symbols.toml | 39 +++++ .../tests/gen_guest_symbols_tests.py | 129 ++++++++++++++++ scripts/fold/apply_closure.py | 12 ++ scripts/fold/equivalence.py | 10 +- scripts/gen_guest_symbols.py | 144 ++++++++++++++++++ 16 files changed, 454 insertions(+), 31 deletions(-) create mode 100644 runtime-recomp/cmake/GuestSymbols.cmake delete mode 100644 runtime-recomp/src/game/achievement_symbols.hpp delete mode 100644 runtime-recomp/src/game/polish_symbols.hpp delete mode 100644 runtime-recomp/src/game/render_heap_addresses.hpp delete mode 100644 runtime-recomp/src/game/vehicle_symbols.hpp create mode 100644 runtime-recomp/symbols/achievement_symbols.toml create mode 100644 runtime-recomp/symbols/polish_symbols.toml create mode 100644 runtime-recomp/symbols/render_heap_addresses.toml create mode 100644 runtime-recomp/symbols/vehicle_symbols.toml create mode 100644 runtime-recomp/tests/gen_guest_symbols_tests.py create mode 100644 scripts/gen_guest_symbols.py diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index 7900cad4..d7b27469 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -1180,10 +1180,7 @@ patches/viewport-flow/flow_vertex_scope_rt64.hpp PG006 patches/viewport-flow/intro_viewport_rt64.hpp PG006 patches/viewport-flow/package.py PG013 patches/viewport-flow/pipeline.py PG004 -runtime-recomp/src/game/achievement_symbols.hpp PG011 runtime-recomp/src/game/netplay/experimental_runtime.cpp PG010 -runtime-recomp/src/game/polish_symbols.hpp PG011 -runtime-recomp/src/game/vehicle_symbols.hpp PG011 scripts/adapt-water-reference.py PG004 scripts/camera_plus_pipeline.py PG004 scripts/character_portrait_pipeline.py PG004 diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 5cc46689..041b9e47 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -48,6 +48,28 @@ stage calls pass `KEEP_INCLUDE_DIRECTORIES` to do the same. The other 54 calls are unchanged. +## Guest hooks and symbols (B4) + +- `runtime-recomp/guest-hooks/hooks.toml` replaces the guest-code rewrites of + six layers. `scripts/guest_hooks.py` runs at configure time from + `cmake/GuestHooks.cmake`, like the code-mod dispatch staging. The plan had an + `add_custom_command`, but at configure time the gate's header scan and + preprocessing see the hooked files without a build step. +- The Camera++ shadow kernel is first-party (`src/game/camera_shadow_kernel.c`), + and `Camera.cmake` derives its hook stubs from the prepared kernel, so + `DKR_CAMERA_SHADOW_KERNEL_V*` is gone. `DKR_CAMERA_KERNEL_V*` is still a + prepared input from `patches/camera-plus/scene_kernel_pipeline.py`, so moving + that pipeline out of `patches/` is B8's call. +- `scripts/gen_guest_symbols.py` generates the four pure symbol headers + (achievement, polish, vehicle, render heap) into `/generated/symbols`. + `title_text_rt64.hpp` and `credits_sky_rt64.hpp` keep their tables inline, + among hand-written code; `gen_guest_symbols_tests.py` checks the tables + against the pinned decomp table. +- The polish header reads `gRareCopyrightString`, which only the decomp symbol + table has, so a `host-full-nogen` configure needs + `extern/dkr-decomp/ver/symbols`. That's the sparse "symbols" dependency + profile the plan describes for the `linux` job. + ## `patches/` content the build still reads (before B8) Everything else under `patches/` that the base CMake reads is a dependency diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index 1f4aa746..fa30a58f 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -691,6 +691,7 @@ find_package(Python3 REQUIRED COMPONENTS Interpreter) include("${CMAKE_CURRENT_LIST_DIR}/cmake/LegacyMods.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomMagicCodes.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/GuestHooks.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/cmake/GuestSymbols.cmake") include("${CMAKE_CURRENT_LIST_DIR}/cmake/CustomVehicles.cmake") foreach(config IN ITEMS Debug Release RelWithDebInfo MinSizeRel) string(TOUPPER "${config}" config_upper) @@ -940,6 +941,7 @@ if(DKR_RUNTIME_BUILD_GENERATED OR DKR_RUNTIME_BUILD_RT64) list(APPEND DKR_GAME_SOURCES src/android/android_platform.cpp src/android/mobile_ui.cpp) endif() add_library(DKRPortGameCore OBJECT ${DKR_GAME_SOURCES}) + dkr_guest_symbols(DKRPortGameCore) if(NOT DKR_RUNTIME_BUILD_GENERATED) # host-full-nogen compiles the shipped configuration, which always has # rollback (the owned engines) and local model detail. Their payloads diff --git a/runtime-recomp/cmake/GuestSymbols.cmake b/runtime-recomp/cmake/GuestSymbols.cmake new file mode 100644 index 00000000..a8b2ef6b --- /dev/null +++ b/runtime-recomp/cmake/GuestSymbols.cmake @@ -0,0 +1,26 @@ +# Guest symbol headers: runtime-recomp/symbols/*.toml, generated by +# scripts/gen_guest_symbols.py into the build tree at configure time. Nothing +# generated is committed; the requests and the pinned symbol tables are the +# inputs. +# +# dkr_guest_symbols() generate and put them on the target's include path +include_guard(GLOBAL) +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +function(dkr_guest_symbols target) + set(out "${CMAKE_BINARY_DIR}/generated/symbols") + set(script "${DKRPORT_ROOT}/scripts/gen_guest_symbols.py") + file(GLOB requests CONFIGURE_DEPENDS "${DKRPORT_ROOT}/runtime-recomp/symbols/*.toml") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${script}" ${requests}) + foreach(revision IN ITEMS 77 80) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${DKRPORT_ROOT}/modding/reference-us-v${revision}/data.toml" + "${DKRPORT_ROOT}/extern/dkr-decomp/ver/symbols/symbol_addrs.us.v${revision}.txt") + endforeach() + execute_process(COMMAND "${Python3_EXECUTABLE}" "${script}" --out "${out}" ${requests} + RESULT_VARIABLE status) + if(NOT status EQUAL 0) + message(FATAL_ERROR "Guest symbol headers could not be generated; see above") + endif() + target_include_directories(${target} PRIVATE "${out}") +endfunction() diff --git a/runtime-recomp/src/game/achievement_symbols.hpp b/runtime-recomp/src/game/achievement_symbols.hpp deleted file mode 100644 index 7d9f0fc5..00000000 --- a/runtime-recomp/src/game/achievement_symbols.hpp +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once -namespace dkr::runtime::achievements { -struct Symbols {std::uint32_t settings;std::uint32_t mode;std::uint32_t menu;std::uint32_t tracks;std::uint32_t adventure_two;std::uint32_t paused;std::uint32_t slot;std::uint32_t map;std::uint32_t trial;std::uint32_t header;std::uint32_t racers;std::uint32_t tt_time;std::uint32_t staff_ghost;std::uint32_t tt_vehicle;std::uint32_t trophy_world;std::uint32_t trophy_round;}; -inline constexpr Symbols symbols77{0x80123510U,0x801234ecU,0x800df470U,0x800df4b8U,0x800df494U,0x80123515U,0x800df4ccU,0x80121164U,0x8011aef5U,0x800dc91cU,0x8011aeecU,0x8011ae80U,0x800dc734U,0x800dc728U,0x800e0fe8U,0x800e0fecU}; -inline constexpr Symbols symbols80{0x80123a90U,0x80123a6cU,0x800df9f0U,0x800dfa38U,0x800dfa14U,0x80123a95U,0x800dfa4cU,0x801216e4U,0x8011b475U,0x800dce8cU,0x8011b46cU,0x8011b400U,0x800dcca4U,0x800dcc98U,0x800e1568U,0x800e156cU}; -} diff --git a/runtime-recomp/src/game/polish_symbols.hpp b/runtime-recomp/src/game/polish_symbols.hpp deleted file mode 100644 index 835c83bb..00000000 --- a/runtime-recomp/src/game/polish_symbols.hpp +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once -#include -#include -namespace dkr::runtime::presentation { -inline constexpr std::array,2> polish_symbols{{ - {0x800E3690U,0x800E36C8U,0x8012A7E4U,0x8012A7E0U,0x8012A7F0U,0x800E1DF0U,0x8011B0B8U,0x800E30D0U}, - {0x800E3C20U,0x800E3C58U,0x8012ADA4U,0x8012ADA0U,0x8012ADB0U,0x800E2370U,0x8011B638U,0x800E3660U} -}}; -} diff --git a/runtime-recomp/src/game/render_heap_addresses.hpp b/runtime-recomp/src/game/render_heap_addresses.hpp deleted file mode 100644 index c3e8daea..00000000 --- a/runtime-recomp/src/game/render_heap_addresses.hpp +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once -#include -namespace dkr::runtime::render_heap_addresses { -inline constexpr std::uint32_t Pool77=0x80123580U; -inline constexpr std::uint32_t Pool80=0x80123B00U; -} diff --git a/runtime-recomp/src/game/vehicle_symbols.hpp b/runtime-recomp/src/game/vehicle_symbols.hpp deleted file mode 100644 index 329019de..00000000 --- a/runtime-recomp/src/game/vehicle_symbols.hpp +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once -// Generated from the admitted, revision-specific SDK symbol tables. -namespace dkr::runtime::vehicles { -inline constexpr Symbols VehicleSymbols77{0x801263e0U,0x801269c0U,0x801269c4U,0x800df480U,0x800df4bcU,0x800df47cU,0x801267d8U,0x80126818U,0x80126830U,0x801263a0U,0x800e3690U,0x800e36c8U,0x800df4b8U,0x800df470U,0x8011ae8cU,0x8011ae90U,0x8011b0b4U,0x800df75cU,0x800df454U,0x800e0660U,0x800e0678U,0x800e0624U,0x800dd434U,0x8011d55cU,0x8011aeecU}; -inline constexpr Symbols VehicleSymbols80{0x80126980U,0x80126f80U,0x80126f84U,0x800dfa00U,0x800dfa3cU,0x800df9fcU,0x80126d78U,0x80126db8U,0x80126dd0U,0x8012693cU,0x800e3c20U,0x800e3c58U,0x800dfa38U,0x800df9f0U,0x8011b40cU,0x8011b410U,0x8011b634U,0x800dfcdcU,0x800df9d4U,0x800e0be0U,0x800e0bf8U,0x800e0ba4U,0x800dd9a4U,0x8011dadcU,0x8011b46cU}; -} diff --git a/runtime-recomp/symbols/achievement_symbols.toml b/runtime-recomp/symbols/achievement_symbols.toml new file mode 100644 index 00000000..0ed190c3 --- /dev/null +++ b/runtime-recomp/symbols/achievement_symbols.toml @@ -0,0 +1,30 @@ +# Guest addresses the achievement observer reads (achievements.cpp). +schema = 1 +header = "achievement_symbols.hpp" +table = "sdk" +hex = "lower" +symbols = [ + "gSettingsPtr", + "gGameMode", + "gCurrentMenuId", + "gIsInTracksMode", + "gIsInAdventureTwo", + "gIsPaused", + "gSaveFileIndex", + "gMapId", + "gIsTimeTrial", + "gCurrentLevelHeader2", + "gRacersByPort", + "gTTGhostTimeToBeat", + "gTimeTrialStaffGhost", + "gTimeTrialVehicle", + "gTrophyRaceWorldId", + "gTrophyRaceRound", +] +template = '''#pragma once +namespace dkr::runtime::achievements { +struct Symbols {std::uint32_t settings;std::uint32_t mode;std::uint32_t menu;std::uint32_t tracks;std::uint32_t adventure_two;std::uint32_t paused;std::uint32_t slot;std::uint32_t map;std::uint32_t trial;std::uint32_t header;std::uint32_t racers;std::uint32_t tt_time;std::uint32_t staff_ghost;std::uint32_t tt_vehicle;std::uint32_t trophy_world;std::uint32_t trophy_round;}; +inline constexpr Symbols symbols77{@ALL:77@}; +inline constexpr Symbols symbols80{@ALL:80@}; +} +''' diff --git a/runtime-recomp/symbols/polish_symbols.toml b/runtime-recomp/symbols/polish_symbols.toml new file mode 100644 index 00000000..9c956633 --- /dev/null +++ b/runtime-recomp/symbols/polish_symbols.toml @@ -0,0 +1,26 @@ +# Presentation polish: dialogue box, fonts, copyright, skydome and wave texture +# (intro_caption_rt64.hpp, intro_sky_rt64.hpp, water_residency_rt64.hpp). +schema = 1 +header = "polish_symbols.hpp" +table = "decomp" +hex = "upper" +symbols = [ + "dDialogueBoxBegin", + "dDialogueBoxDrawModes", + "gFonts", + "gNumberOfFonts", + "gCompactKerning", + "gRareCopyrightString", + "gSkydomeSegment", + "gWaveTextureHeader", +] +template = '''#pragma once +#include +#include +namespace dkr::runtime::presentation { +inline constexpr std::array,2> polish_symbols{{ + {@ALL:77@}, + {@ALL:80@} +}}; +} +''' diff --git a/runtime-recomp/symbols/render_heap_addresses.toml b/runtime-recomp/symbols/render_heap_addresses.toml new file mode 100644 index 00000000..a7639481 --- /dev/null +++ b/runtime-recomp/symbols/render_heap_addresses.toml @@ -0,0 +1,15 @@ +# The guest memory pool table the render-heap repair resizes (render_heap_support.cpp). +schema = 1 +header = "render_heap_addresses.hpp" +table = "sdk" +hex = "upper" +symbols = [ + "gMemoryPools", +] +template = '''#pragma once +#include +namespace dkr::runtime::render_heap_addresses { +inline constexpr std::uint32_t Pool77=@gMemoryPools:77@; +inline constexpr std::uint32_t Pool80=@gMemoryPools:80@; +} +''' diff --git a/runtime-recomp/symbols/vehicle_symbols.toml b/runtime-recomp/symbols/vehicle_symbols.toml new file mode 100644 index 00000000..a1fdb241 --- /dev/null +++ b/runtime-recomp/symbols/vehicle_symbols.toml @@ -0,0 +1,39 @@ +# Track menu, scene and racer state the custom vehicles read (vehicle_world.cpp). +schema = 1 +header = "vehicle_symbols.hpp" +table = "sdk" +hex = "lower" +symbols = [ + "gMenuStage", + "gPlayerSelectVehicle", + "gPlayerSelectConfirm", + "gNumberOfReadyPlayers", + "gNumberOfActivePlayers", + "gMenuDelay", + "gMenuButtons", + "gMenuStickX", + "gMenuStickY", + "sMenuCurrDisplayList", + "dDialogueBoxBegin", + "dDialogueBoxDrawModes", + "gIsInTracksMode", + "gCurrentMenuId", + "gObjectCurrDisplayList", + "gObjectCurrMatrix", + "gSceneCurrentPlayerID", + "gMenuImages", + "gTrackSelectWoodFrameHeightScale", + "gTrackSelectPlayerImage", + "gMenuSelectionArrows", + "gRaceSelectionImages", + "gCurrentRNGSeed", + "gCurrentPlayerIndex", + "gRacersByPort", +] +template = '''#pragma once +// Generated from the admitted, revision-specific SDK symbol tables. +namespace dkr::runtime::vehicles { +inline constexpr Symbols VehicleSymbols77{@ALL:77@}; +inline constexpr Symbols VehicleSymbols80{@ALL:80@}; +} +''' diff --git a/runtime-recomp/tests/gen_guest_symbols_tests.py b/runtime-recomp/tests/gen_guest_symbols_tests.py new file mode 100644 index 00000000..96106187 --- /dev/null +++ b/runtime-recomp/tests/gen_guest_symbols_tests.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""scripts/gen_guest_symbols.py: the committed requests reproduce the shipped +v1.0.11 headers (golden hashes, updated deliberately when a symbol table pin +moves), and missing or ambiguous symbols fail.""" + +import hashlib +import importlib.util +from pathlib import Path +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("gen_guest_symbols", ROOT / "scripts/gen_guest_symbols.py") +gen_guest_symbols = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gen_guest_symbols) + +GOLDEN = { + "achievement_symbols.hpp": "36b9daa3", + "polish_symbols.hpp": "4eac568d", + "render_heap_addresses.hpp": "0cbe828b", + "vehicle_symbols.hpp": "a3c8f2a1", +} +DECOMP = ROOT / "extern/dkr-decomp/ver/symbols" + + +class RepositoryRequestTests(unittest.TestCase): + def test_requests_reproduce_the_shipped_headers(self): + for spec in sorted(gen_guest_symbols.SPECS.glob("*.toml")): + request = gen_guest_symbols.load(spec) + with self.subTest(header=request.header): + if request.table == "decomp" and not DECOMP.is_dir(): + self.skipTest("needs extern/dkr-decomp") + text = gen_guest_symbols.render(request, ROOT) + self.assertEqual(hashlib.sha256(text.encode()).hexdigest()[:8], GOLDEN[request.header]) + + @unittest.skipUnless(DECOMP.is_dir(), "needs extern/dkr-decomp") + def test_inline_title_and_credits_tables_match_the_decomp(self): + # title_text_rt64.hpp and credits_sky_rt64.hpp carry their tables + # inline, among hand-written code; they must still be what the pinned + # symbol table says. + def table(*names): + rows = [] + for revision in gen_guest_symbols.REVISIONS: + found = gen_guest_symbols.table("decomp", revision, ROOT) + rows.append(",".join(f"0x{found[name][0]:08X}U" for name in names)) + return rows + + game = ROOT / "runtime-recomp/src/game" + title = (game / "title_text_rt64.hpp").read_text(encoding="utf-8") + rows = table( + "dDialogueBoxBegin", + "dDialogueBoxDrawModes", + "gFonts", + "gNumberOfFonts", + "gCompactKerning", + "gTitleCinematicText", + "gTitleCinematicTextColours", + ) + self.assertIn(" {" + rows[0] + "},\n {" + rows[1] + "}\n", title) + self.assertIn("credit_rows_{{" + ",".join(table("gCreditsMenuElements")) + "}}", title) + credits = (game / "credits_sky_rt64.hpp").read_text(encoding="utf-8") + rows = table("gVoidData", "gVoidVerts", "gVoidCurrVerts", "gVoidVertexFlip", "gVoidVertCount") + self.assertIn(" {" + rows[0] + "},\n {" + rows[1] + "}\n", credits) + + def test_every_golden_header_has_a_request(self): + headers = {gen_guest_symbols.load(spec).header for spec in gen_guest_symbols.SPECS.glob("*.toml")} + self.assertEqual(headers, set(GOLDEN)) + + +class GeneratorTests(unittest.TestCase): + def setUp(self): + self.scratch = tempfile.TemporaryDirectory() + self.root = Path(self.scratch.name) + for revision, base in (("77", 0x80100000), ("80", 0x80200000)): + kit = self.root / f"modding/reference-us-v{revision}" + kit.mkdir(parents=True) + (kit / "data.toml").write_text( + textwrap.dedent(f"""\ + [[section]] + symbols = [ + {{ name = "gOne", vram = 0x{base + 0xAB:08X} }}, + {{ name = "gTwice", vram = 0x{base:08X} }}, + {{ name = "gTwice", vram = 0x{base + 4:08X} }}, + ] + """), + encoding="utf-8", + ) + decomp = self.root / "extern/dkr-decomp/ver/symbols" + decomp.mkdir(parents=True, exist_ok=True) + (decomp / f"symbol_addrs.us.v{revision}.txt").write_text( + f"gOne = 0x{base + 0xAB:08X}; // type:u32\n", encoding="utf-8" + ) + + def tearDown(self): + self.scratch.cleanup() + + def request(self, symbols, template, table="sdk", hexcase="upper"): + path = self.root / "request.toml" + listed = ", ".join(f'"{name}"' for name in symbols) + path.write_text( + f'schema = 1\nheader = "x.hpp"\ntable = "{table}"\nhex = "{hexcase}"\nsymbols = [{listed}]\n' + f"template = '''{template}'''\n", + encoding="utf-8", + ) + return gen_guest_symbols.load(path) + + def test_placeholders_and_formats(self): + request = self.request(["gOne"], "{@ALL:77@} @gOne:80@\n", hexcase="lower") + self.assertEqual(gen_guest_symbols.render(request, self.root), "{0x801000abU} 0x802000abU\n") + request = self.request(["gOne"], "@gOne:77@\n", table="decomp") + self.assertEqual(gen_guest_symbols.render(request, self.root), "0x801000ABU\n") + + def test_missing_ambiguous_and_unlisted_symbols_fail(self): + for symbols, template in ((["gAbsent"], "@ALL:77@"), (["gTwice"], "@ALL:77@"), (["gOne"], "@gTwice:77@")): + with self.subTest(symbols=symbols, template=template), self.assertRaises(gen_guest_symbols.SymbolError): + gen_guest_symbols.render(self.request(symbols, template), self.root) + + def test_check_mode_reports_a_stale_header(self): + out = self.root / "out" + spec = gen_guest_symbols.SPECS / "render_heap_addresses.toml" + self.assertEqual(gen_guest_symbols.main(["--out", str(out), str(spec)]), 0) + self.assertEqual(gen_guest_symbols.main(["--out", str(out), "--check", str(spec)]), 0) + (out / "render_heap_addresses.hpp").write_text("stale\n", encoding="utf-8") + self.assertEqual(gen_guest_symbols.main(["--out", str(out), "--check", str(spec)]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/fold/apply_closure.py b/scripts/fold/apply_closure.py index 8583e874..fec5c832 100644 --- a/scripts/fold/apply_closure.py +++ b/scripts/fold/apply_closure.py @@ -55,6 +55,8 @@ import shlex import sys +import tomllib + ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "scripts")) import check_pinned_bytes # noqa: E402 @@ -93,6 +95,14 @@ ("runtime-recomp/src/game/sdk_task_source.hpp", "extern/n64-modern-runtime/librecomp/src/sp.cpp", "sp.cpp"), ) +# Headers scripts/gen_guest_symbols.py generates into the build tree (fold +# step B4). They keep their shipped destination for include resolution, but +# the closure never writes them: runtime-recomp/symbols/*.toml is the source. +GENERATED = frozenset( + "runtime-recomp/src/game/" + tomllib.loads(path.read_text(encoding="utf-8"))["header"] + for path in (ROOT / "runtime-recomp" / "symbols").glob("*.toml") +) + INCLUDE = re.compile(rb'^[ \t]*#[ \t]*include(?:_next)?[ \t]*([<"])([^>"\r\n]+)[>"]', re.M) @@ -737,6 +747,8 @@ def main(argv: list[str] | None = None) -> int: released = released_bases(root, result.contents) written = 0 for dest, data in sorted(result.contents.items()): + if dest in GENERATED: + continue target = root / dest if target.exists() and target.read_bytes() == data: continue diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 96ce099d..da88aba8 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -160,6 +160,10 @@ def __init__(self, reference_dir: Path, folded_build: Path) -> None: hooked = self.guest_hooked(row) if row.kind == "guest-transform" else None if hooked is not None: self.dependency[row.staged] = hooked + # Guest symbol headers (B4) are generated into the build tree. + for staged, dest in result.destinations.items(): + if dest in apply_closure.GENERATED: + self.dependency[staged] = folded_build / "generated" / "symbols" / Path(dest).name # Camera++ shadow kernels (B4): camera_shadow_kernel.c is first-party # source; Camera.cmake derives camera_scene_hooks.c per revision. for row in self.reference.rows: @@ -314,9 +318,13 @@ def compare_targets( # directory as an include; folded, the TU is back in it and quote # lookup searches it first anyway, so it is left out of both sides # (level 1 still sees any header it would resolve differently). + # The guest symbol headers (B4) are generated into their own + # directory; it holds nothing else, and level 1 checks every + # includer still reads the shipped bytes from it. own = str(target.parent) + symbols = str(mapping.folded_build / "generated" / "symbols") ref_includes = tuple(i for i in normalized_includes(mapping, ref.includes) if i != own) - new_includes = tuple(i for i in dict.fromkeys(new.includes) if i != own) + new_includes = tuple(i for i in dict.fromkeys(new.includes) if i not in (own, symbols)) if ref_includes != new_includes: lost = [i for i in ref_includes if i not in new_includes] extra = [i for i in new_includes if i not in ref_includes] diff --git a/scripts/gen_guest_symbols.py b/scripts/gen_guest_symbols.py new file mode 100644 index 00000000..ebab007e --- /dev/null +++ b/scripts/gen_guest_symbols.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Generate the guest symbol headers from runtime-recomp/symbols/*.toml. + +Each request file names one header, the symbol table its addresses come from +and a template for the header text: + +* ``table = "sdk"``: modding/reference-us-v/data.toml, the admitted + address-only SDK kit; +* ``table = "decomp"``: extern/dkr-decomp/ver/symbols/symbol_addrs.us.v.txt. + +``@ALL:@`` in the template becomes every listed symbol's address for +that revision, comma-separated, and ``@:@`` one symbol's. An +address prints as ``0x%08X`` (``hex = "upper"``) or ``0x%08x`` +(``hex = "lower"``) followed by ``U``. A symbol that is missing or listed more +than once in its table is an error; nothing is guessed. + + gen_guest_symbols.py --out DIR [SPEC...] write the headers + gen_guest_symbols.py --out DIR --check [SPEC...] fail unless DIR holds them + +With no SPEC every runtime-recomp/symbols/*.toml is generated. Headers are +written only when their bytes change. + +Exit status: 0 on success, 1 on a missing/ambiguous symbol or a --check +difference, 2 on usage errors. +""" + +from __future__ import annotations + +import argparse +import dataclasses +from pathlib import Path +import re +import sys + +import tomllib + +ROOT = Path(__file__).resolve().parents[1] +SPECS = ROOT / "runtime-recomp" / "symbols" +REVISIONS = ("77", "80") +DECOMP_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*) = (0x[0-9A-Fa-f]+);", re.M) + + +class SymbolError(Exception): + pass + + +@dataclasses.dataclass(frozen=True) +class Request: + header: str + table: str + hex: str + symbols: tuple[str, ...] + template: str + + +def load(path: Path) -> Request: + data = tomllib.loads(path.read_text(encoding="utf-8")) + if data.get("schema") != 1: + raise SymbolError(f"{path.name}: unsupported schema") + request = Request(data["header"], data["table"], data["hex"], tuple(data["symbols"]), data["template"]) + if request.table not in ("sdk", "decomp") or request.hex not in ("upper", "lower"): + raise SymbolError(f"{path.name}: table must be sdk or decomp, hex upper or lower") + if "/" in request.header or not request.header.endswith(".hpp"): + raise SymbolError(f"{path.name}: header must be a bare *.hpp name") + return request + + +def table(kind: str, revision: str, root: Path) -> dict[str, list[int]]: + """Every address each symbol has in one revision's table.""" + found: dict[str, list[int]] = {} + if kind == "sdk": + data = tomllib.loads((root / f"modding/reference-us-v{revision}/data.toml").read_text(encoding="utf-8")) + for section in data["section"]: + for symbol in section.get("symbols", []): + found.setdefault(symbol["name"], []).append(symbol["vram"]) + else: + text = (root / f"extern/dkr-decomp/ver/symbols/symbol_addrs.us.v{revision}.txt").read_text(encoding="utf-8") + for name, value in DECOMP_LINE.findall(text): + found.setdefault(name, []).append(int(value, 16)) + return found + + +def render(request: Request, root: Path) -> str: + def address(name: str, revision: str, addresses: dict[str, list[int]]) -> str: + values = addresses.get(name, []) + if len(values) != 1: + raise SymbolError( + f"{request.header}: {name} has {len(values)} addresses in the US v{revision} {request.table} table" + ) + return (f"0x{values[0]:08X}" if request.hex == "upper" else f"0x{values[0]:08x}") + "U" + + tables = {revision: table(request.table, revision, root) for revision in REVISIONS} + + def substitute(match: re.Match[str]) -> str: + name, revision = match.group(1), match.group(2) + if name == "ALL": + return ",".join(address(symbol, revision, tables[revision]) for symbol in request.symbols) + if name not in request.symbols: + raise SymbolError(f"{request.header}: @{name}:{revision}@ is not a listed symbol") + return address(name, revision, tables[revision]) + + return re.sub(r"@([A-Za-z_][A-Za-z0-9_]*):(77|80)@", substitute, request.template) + + +def generate(specs: list[Path], root: Path | None = None) -> dict[str, str]: + headers: dict[str, str] = {} + for spec in specs: + request = load(spec) + if request.header in headers: + raise SymbolError(f"{request.header} is requested twice") + headers[request.header] = render(request, root or ROOT) + return headers + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--check", action="store_true") + parser.add_argument("spec", nargs="*", type=Path) + args = parser.parse_args(argv) + specs = args.spec or sorted(SPECS.glob("*.toml")) + try: + headers = generate(specs) + except SymbolError as error: + print(f"gen_guest_symbols: {error}", file=sys.stderr) + return 1 + stale = [] + for name, text in sorted(headers.items()): + path = args.out / name + current = path.read_bytes() if path.is_file() else None + if current == text.encode("utf-8"): + continue + if args.check: + stale.append(name) + continue + args.out.mkdir(parents=True, exist_ok=True) + path.write_bytes(text.encode("utf-8")) + for name in stale: + print(f"gen_guest_symbols: {args.out / name} differs from its request", file=sys.stderr) + return 1 if stale else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 1fe1f177020ba1d3e28d638d5c46988b482d9d30 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:37:00 -0500 Subject: [PATCH 16/62] Note the intermittent Quick Join lifecycle wait for the maintainer --- docs/fold-in/open-items.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 041b9e47..0e5e134f 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -14,6 +14,16 @@ 2. **Launcher copy.** 71 reviewed strings are superseded by v1.0.11; see [launcher-copy-review.md](launcher-copy-review.md). +3. **`DKRQuickJoinLifecycle` is intermittent** on the Linux host presets + (amd64 under emulation on the devops Mac). Alone it fails about half its + runs, at `quick_join_lifecycle_tests.cpp:126`: after the rejected rekey + the client never takes the host's Quick Join bootstrap, and the 20 s wait + expires (passing runs take about 3 s). Nothing it compiles changed after + the test triage, and it passed then. That points to a race in the shipped + Quick Join path or in the test, not the fold. `DKRExperimentalQuickJoin` + aborted once under parallel load the same way, and it was the one failure + before the fold too. Behaviour fixes belong after B10. + ## For B6 (native macOS) - `netplay/online_directory.cpp` (online-directory layer) holds a From fd8e3b036812f814a244ab670f4f27e08757d586 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:39:45 -0500 Subject: [PATCH 17/62] Record configure-time generation, wire-format re-baselines, open items --- docs/fold-in/REFERENCE.md | 9 +++++++++ docs/fold-in/open-items.md | 20 +++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/fold-in/REFERENCE.md b/docs/fold-in/REFERENCE.md index 8dc3cc3f..2449ff43 100644 --- a/docs/fold-in/REFERENCE.md +++ b/docs/fold-in/REFERENCE.md @@ -40,3 +40,12 @@ Shipped closure: 1368 TUs behind `DKR-R`, `DKR-R-ModWorker`, `DKR-R-Updater` and `DKR-R-InputHost`. Of these, 837 first-party files, 40 guest transforms, 6 guest-derived files and 11 dependency transforms. The first-party files are written back by `scripts/fold/apply_closure.py` (step B1). + +## Configure-time generation (deliberate) + +Guest hooks (`cmake/GuestHooks.cmake`), the guest symbol headers +(`cmake/GuestSymbols.cmake`) and the Camera++ hook stubs (`cmake/Camera.cmake`) +run at configure time, like the existing code-mod dispatch staging. The plan +had build-time `add_custom_command`s; keep them at configure time. The gate's +header scan and preprocessing read the configured tree without building it, +so build-time outputs would not exist when it compares them. diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 0e5e134f..33d00f5d 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -24,8 +24,24 @@ aborted once under parallel load the same way, and it was the one failure before the fold too. Behaviour fixes belong after B10. +## Open questions + +- **Android release transforms aren't covered.** The gate and the stage + patches are checked on the Linux chain only; the Android-only stages and + the release layers' Android transforms are not reproduced or verified. + Android releases are discontinued, so this stays open rather than blocking. + +## For C5 (tests) + +- The layers' remaining check targets and the "Before" negative controls + move to C5 with the other promoted fixtures; the fold doesn't port them. + ## For B6 (native macOS) +- `apply_closure.py` respelled absolute staged includes inside `#if` branches + no Linux TU takes (Apple, Windows, Android) by path alone. No configure has + compiled them, so B6 and the Windows configure are their first check. + - `netplay/online_directory.cpp` (online-directory layer) holds a `std::atomic>`. libstdc++ supports that C++20 specialization; Apple's libc++ does not (`std::atomic` requires a @@ -74,7 +90,9 @@ (achievement, polish, vehicle, render heap) into `/generated/symbols`. `title_text_rt64.hpp` and `credits_sky_rt64.hpp` keep their tables inline, among hand-written code; `gen_guest_symbols_tests.py` checks the tables - against the pinned decomp table. + against the pinned decomp table. Splitting those tables into generated + `.inc` files is cleanup for after equivalence (it would cost a level 1 + delta now). - The polish header reads `gRareCopyrightString`, which only the decomp symbol table has, so a `host-full-nogen` configure needs `extern/dkr-decomp/ver/symbols`. That's the sparse "symbols" dependency From 78f07249054af4ba584fe0534dec17614d7cbfd9 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:41:17 -0500 Subject: [PATCH 18/62] Track owned guest inputs, prune stale guest-hook dirs --- runtime-recomp/cmake/GuestHooks.cmake | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/runtime-recomp/cmake/GuestHooks.cmake b/runtime-recomp/cmake/GuestHooks.cmake index 5ef0e481..aac7adcc 100644 --- a/runtime-recomp/cmake/GuestHooks.cmake +++ b/runtime-recomp/cmake/GuestHooks.cmake @@ -2,7 +2,9 @@ # guest code by scripts/guest_hooks.py, at configure time like the code-mod # dispatch staging. Protected generated input is never edited: hooked copies # go to a content-addressed build directory and replace their originals in the -# payload's source list. +# payload's source list. generated/guest-hooks--v.dir names +# the current directory (the equivalence gate reads it); older directories for +# the same payload are removed. # # dkr_apply_guest_hooks( ) include_guard(GLOBAL) @@ -23,6 +25,7 @@ function(dkr_apply_guest_hooks sources_var source revision payload) file(SHA256 "${input}" digest) get_filename_component(name "${input}" NAME) string(APPEND parts "${name}:${digest}|") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${input}") endforeach() string(SHA256 identity "${payload}|${revision}|${parts}") set(out "${CMAKE_BINARY_DIR}/generated/guest-hooks-${payload}-v${revision}-${identity}") @@ -35,6 +38,18 @@ function(dkr_apply_guest_hooks sources_var source revision payload) message(FATAL_ERROR "Guest hooks for US v${revision} ${payload} did not apply; see above") endif() endif() + file(GLOB previous LIST_DIRECTORIES true "${CMAKE_BINARY_DIR}/generated/guest-hooks-${payload}-v${revision}-*") + list(REMOVE_ITEM previous "${out}") + if(previous) + file(REMOVE_RECURSE ${previous}) + endif() + set(pointer "${CMAKE_BINARY_DIR}/generated/guest-hooks-${payload}-v${revision}.dir") + if(EXISTS "${pointer}") + file(READ "${pointer}" current) + endif() + if(NOT EXISTS "${pointer}" OR NOT current STREQUAL "${out}\n") + file(WRITE "${pointer}" "${out}\n") + endif() file(STRINGS "${out}/hooked.txt" hooked) set(sources ${${sources_var}}) foreach(name IN LISTS hooked) From 5826b081a7d0465fc1c6f9cdca17ff206d06430f Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:41:17 -0500 Subject: [PATCH 19/62] Skip comments and literals in return fences, refuse unbraced returns --- runtime-recomp/tests/guest_hooks_tests.py | 37 ++++++++++++++++++++++- scripts/guest_hooks.py | 34 ++++++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/runtime-recomp/tests/guest_hooks_tests.py b/runtime-recomp/tests/guest_hooks_tests.py index ac40afed..a63c349f 100644 --- a/runtime-recomp/tests/guest_hooks_tests.py +++ b/runtime-recomp/tests/guest_hooks_tests.py @@ -166,7 +166,7 @@ def test_exactly_once(self): self.assertTrue(any(message in problem for problem in problems), problems) def test_edit_must_match_once(self): - for match, count in (("not there", 0), ("ctx->r", None)): + for match in ("not there", "ctx->r"): with self.subTest(match=match): hooks = self.hooks(f""" [[hook]] @@ -180,6 +180,41 @@ def test_edit_must_match_once(self): _, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": GUEST}), hooks) self.assertTrue(any("not once" in problem for problem in problems), problems) + def test_returns_skip_comments_and_literals(self): + hooks = self.hooks( + """ + [[hook]] + id = "fence" + function = "plain" + payloads = ["funcs"] + returns = 'x(); ' + """ + ) + head, tail = GUEST.split(" ctx->r2 = 0;\n") + text = head + " // return; in a comment\n log(\"return;\"); log('r');\n ctx->r2 = 0;\n" + tail + out, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": text}), hooks) + self.assertEqual(problems, []) + hooked = out["funcs_1.c"] + self.assertIn(' // return; in a comment\n log("return;");', hooked) + self.assertEqual(hooked.count("x(); return;"), 1) + + def test_unbraced_return_is_refused(self): + hooks = self.hooks( + """ + [[hook]] + id = "fence" + function = "plain" + payloads = ["funcs"] + returns = 'x(); ' + """ + ) + head, tail = GUEST.split(" ctx->r2 = 0;\n") + for statement in ("if (ctx->r4 == 0) return;", "if (ctx->r4 == 0)\n return;", "else return;"): + with self.subTest(statement=statement): + text = head + " " + statement + "\n ctx->r2 = 0;\n" + tail + _, problems = guest_hooks.apply(write(self.guest, {"funcs_1.c": text}), hooks) + self.assertTrue(any("unbraced return" in problem for problem in problems), problems) + def test_returns_needs_a_return(self): hooks = self.hooks( """ diff --git a/scripts/guest_hooks.py b/scripts/guest_hooks.py index a59e1a7c..0f2a1b55 100644 --- a/scripts/guest_hooks.py +++ b/scripts/guest_hooks.py @@ -124,15 +124,43 @@ def bodies(text: str, function: str) -> list[tuple[int, int]]: return found +# Comments and string/char literals, which no anchor may look inside. +OPAQUE = r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'' +# What precedes a return statement that is the unbraced body of a statement. +UNBRACED = re.compile(r"(?:\)|\belse|\bdo)$") + + def closing_brace(text: str, begin: int, function: str) -> int: depth = 1 - for token in re.finditer(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\{|\}', text[begin:]): + for token in re.finditer(OPAQUE + r"|\{|\}", text[begin:]): depth += {"{": 1, "}": -1}.get(token.group(), 0) if depth == 0: return begin + token.start() raise HookError(f"{function}: unbalanced braces") +def fenced_returns(body: str, hook: Hook) -> str: + """Put hook.returns before every return statement of the body. + + The text is inserted, not braced, because that is what v1.0.11 shipped. A + return that is the unbraced body of an if/else/loop would make the inserted + text conditional and the return not, so it is refused instead.""" + pieces: list[str] = [] + cursor = 0 + count = 0 + for token in re.finditer(OPAQUE + r"|\breturn;", body): + if token.group() != "return;": + continue + if UNBRACED.search(body[max(0, token.start() - 200) : token.start()].rstrip()): + raise HookError(f"{hook.id}: {hook.function} has an unbraced return; brace it in the anchor instead") + pieces += [body[cursor : token.start()], hook.returns, "return;"] + cursor = token.end() + count += 1 + if count == 0: + raise HookError(f"{hook.id}: {hook.function} has no return") + return "".join(pieces) + body[cursor:] + + def hooked_body(body: str, hook: Hook) -> str: for old, new in hook.edits: count = body.count(old) @@ -140,9 +168,7 @@ def hooked_body(body: str, hook: Hook) -> str: raise HookError(f"{hook.id}: edit matches {count} times in {hook.function}, not once: {old[:60]!r}") body = body.replace(old, new) if hook.returns: - body, count = re.subn(r"\breturn;", lambda _: hook.returns + "return;", body) - if count == 0: - raise HookError(f"{hook.id}: {hook.function} has no return") + body = fenced_returns(body, hook) if hook.entry: body = "\n" + hook.entry + body return body From e261638278c21c32c807ead9244e4509109ee417 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:41:17 -0500 Subject: [PATCH 20/62] Remove generated symbol headers that no request names --- runtime-recomp/tests/gen_guest_symbols_tests.py | 9 +++++++++ scripts/gen_guest_symbols.py | 14 +++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/runtime-recomp/tests/gen_guest_symbols_tests.py b/runtime-recomp/tests/gen_guest_symbols_tests.py index 96106187..b1c7afcc 100644 --- a/runtime-recomp/tests/gen_guest_symbols_tests.py +++ b/runtime-recomp/tests/gen_guest_symbols_tests.py @@ -124,6 +124,15 @@ def test_check_mode_reports_a_stale_header(self): (out / "render_heap_addresses.hpp").write_text("stale\n", encoding="utf-8") self.assertEqual(gen_guest_symbols.main(["--out", str(out), "--check", str(spec)]), 1) + def test_headers_without_a_request_are_removed(self): + out = self.root / "out" + out.mkdir() + (out / "retired_symbols.hpp").write_text("#pragma once\n", encoding="utf-8") + spec = gen_guest_symbols.SPECS / "render_heap_addresses.toml" + self.assertEqual(gen_guest_symbols.main(["--out", str(out), "--check", str(spec)]), 1) + self.assertEqual(gen_guest_symbols.main(["--out", str(out), str(spec)]), 0) + self.assertEqual(sorted(path.name for path in out.iterdir()), ["render_heap_addresses.hpp"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/gen_guest_symbols.py b/scripts/gen_guest_symbols.py index ebab007e..e4e9a678 100644 --- a/scripts/gen_guest_symbols.py +++ b/scripts/gen_guest_symbols.py @@ -18,7 +18,8 @@ gen_guest_symbols.py --out DIR --check [SPEC...] fail unless DIR holds them With no SPEC every runtime-recomp/symbols/*.toml is generated. Headers are -written only when their bytes change. +written only when their bytes change, and a *.hpp in DIR that no SPEC names +is removed (reported, with --check): DIR holds the requested headers only. Exit status: 0 on success, 1 on a missing/ambiguous symbol or a --check difference, 2 on usage errors. @@ -124,7 +125,7 @@ def main(argv: list[str] | None = None) -> int: except SymbolError as error: print(f"gen_guest_symbols: {error}", file=sys.stderr) return 1 - stale = [] + stale: list[str] = [] for name, text in sorted(headers.items()): path = args.out / name current = path.read_bytes() if path.is_file() else None @@ -135,9 +136,16 @@ def main(argv: list[str] | None = None) -> int: continue args.out.mkdir(parents=True, exist_ok=True) path.write_bytes(text.encode("utf-8")) + # A header whose request was deleted must not linger for includers to find. + orphans = sorted(path for path in args.out.glob("*.hpp") if path.name not in headers) if args.out.is_dir() else [] + for path in orphans: + if args.check: + print(f"gen_guest_symbols: {path} has no request", file=sys.stderr) + else: + path.unlink() for name in stale: print(f"gen_guest_symbols: {args.out / name} differs from its request", file=sys.stderr) - return 1 if stale else 0 + return 1 if stale or (args.check and orphans) else 0 if __name__ == "__main__": From 8deb524f67828fc2743aa56114798cb449e33959 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:46:38 -0500 Subject: [PATCH 21/62] Raise the Python floor to 3.11, run strict pyright in the lint job --- .github/workflows/tooling.yml | 4 ++++ docs/DEVELOPMENT.md | 2 +- docs/NIGHTLY-CHECKS.md | 2 +- pyproject.toml | 2 +- scripts/README.md | 2 +- scripts/fold/dependency_stages.py | 10 +++++++--- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tooling.yml b/.github/workflows/tooling.yml index 6759fd4c..ab484690 100644 --- a/.github/workflows/tooling.yml +++ b/.github/workflows/tooling.yml @@ -47,6 +47,10 @@ jobs: run: git ls-files -z '*.sh' ':(exclude)patches/' ':(exclude)extern/' ':(exclude)tests/fixtures/' | xargs -0 shfmt -d - name: Pattern guard and tooling tests run: python3 scripts/check.py --suite guard,tooling + # Strict typing for the tooling scripts listed in pyproject.toml + # ([tool.pyright]); the version is exact so results don't drift. + - name: Pyright (strict tooling scripts) + run: npx --yes pyright@1.1.414 --project pyproject.toml # The tree check above can't tell new baseline entries from old ones, so # PRs also run the guard against their base. Baseline growth is only # allowed with the transition-layer label, until fold-in-base is tagged. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 106f295b..2b2c7465 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -15,7 +15,7 @@ Keep the changes focused and reproducible. Project-owned runtime code lives in ## First contribution: tests without a ROM -Start with Git and Python 3.10 or newer. From the repository root: +Start with Git and Python 3.11 or newer. From the repository root: ```sh python3 scripts/check.py diff --git a/docs/NIGHTLY-CHECKS.md b/docs/NIGHTLY-CHECKS.md index 4a8c75c6..b83ce5f4 100644 --- a/docs/NIGHTLY-CHECKS.md +++ b/docs/NIGHTLY-CHECKS.md @@ -8,7 +8,7 @@ covers Linux, macOS, and Windows. ## Prepare once -Use Python 3.10+, Ninja, the platform's C++20 compiler, and the +Use Python 3.11+, Ninja, the platform's C++20 compiler, and the [prepared host dependencies](DEVELOPMENT.md#first-contribution-tests-without-a-rom). The stress runner requires CTest 4.1+ for explicit random seeds. If your current CMake is older, install the same checksum-verified tools used by the workflow: diff --git a/pyproject.toml b/pyproject.toml index beee5d95..7f092a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,4 +117,4 @@ strict = [ ] exclude = ["build", "extern", "patches", "tools/blender"] extraPaths = ["scripts"] -pythonVersion = "3.10" +pythonVersion = "3.11" diff --git a/scripts/README.md b/scripts/README.md index 867da15f..f3e9ee12 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,7 +4,7 @@ Use patches/release-prep/v1.0.11/release.py and BUILDING.txt for stable composition. Packaging uses explicit runtime/legal allowlists; no offline Player Guide or old playtest handoff notes. Both US revisions, all owned payloads and exact staged inputs are required. Run only the named synthetic fixtures and ROM-free helper/updater checks; gameplay acceptance remains separate. -Run these commands from the repository root with Python 3.10+ (`python` on +Run these commands from the repository root with Python 3.11+ (`python` on Windows). Commands with options describe them with `--help`. | Task | Command | Inputs | diff --git a/scripts/fold/dependency_stages.py b/scripts/fold/dependency_stages.py index 5d9a664d..ec06721e 100644 --- a/scripts/fold/dependency_stages.py +++ b/scripts/fold/dependency_stages.py @@ -51,7 +51,7 @@ class Stage: name: str # dkr_stage_patch stage name: generated// source: str # chain output the patch applies to: generated/ or extern/ files: dict[str, str] # path inside the stage -> reference original_path (sources) - headers: dict[str, str] = dataclasses.field(default_factory=dict) # same, staged headers + headers: dict[str, str] = dataclasses.field(default_factory=dict[str, str]) # same, staged headers STAGES = ( @@ -122,7 +122,9 @@ def folded_paths(build: Path) -> dict[str, Path]: def reference_rows(reference: apply_closure.Reference) -> dict[str, apply_closure.Row]: - rows = {row.original: row for row in reference.rows if row.kind == "dependency-transform"} + rows = { + row.original: row for row in reference.rows if row.kind == "dependency-transform" and row.original is not None + } covered = {original for stage in STAGES for original in entries(stage).values()} if set(rows) != covered: raise SystemExit( @@ -168,7 +170,9 @@ def respelled( for line, quoted, spelling, (start, end) in apply_closure.directives(data): if "/" not in spelling or spelling in kept: continue - resolved = {resolver(spelling, quoted, row.staged.parent, search) for search in searches} - {None} + resolved = { + found for search in searches if (found := resolver(spelling, quoted, row.staged.parent, search)) is not None + } if len(resolved) != 1: problems.append(f"{stage.patch}: {relative}:{line}: {spelling} resolves to {sorted(map(str, resolved))}") continue From 989ab443e21c990be25effc7990efb4c9db9f8a0 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:58:55 -0500 Subject: [PATCH 22/62] Harden the equivalence gate against silent passes Unpaired TUs fail anywhere; guest hooks resolve through GuestHooks.cmake's pointer; only identity digests are masked; literals stay whole; shader pending TUs are listed; the reference is verified and read-only; level 3 always runs. --- scripts/fold/allowed-deltas.json | 16 +-- scripts/fold/equivalence.py | 213 +++++++++++++++++++++---------- scripts/fold/run_equivalence.sh | 57 +++++---- scripts/fold/shader-pending.json | 6 + 4 files changed, 190 insertions(+), 102 deletions(-) create mode 100644 scripts/fold/shader-pending.json diff --git a/scripts/fold/allowed-deltas.json b/scripts/fold/allowed-deltas.json index a0412071..a707052c 100644 --- a/scripts/fold/allowed-deltas.json +++ b/scripts/fold/allowed-deltas.json @@ -23,35 +23,35 @@ "reason": "The game's TUs compile in the DKRPortGameCore object library (so host builds without a payload compile them), and DKR-R links the core's libraries LINK_ONLY. CMake now places DKRCameraPresentation's private dependencies (the two camera scene kernels and libm) at their first transitive occurrence instead of the end. They still follow DKRCameraPresentation, their only user, and every other library keeps its position." }, { - "difference": "DKRRecompiledFuncsV77: build/fold-equivalence/generated/guest-hooks-funcs-v77/funcs_12.c: include dirs lost ['/generated'] extra []", + "difference": "DKRRecompiledFuncsV77: /generated/guest-hooks-funcs-v77/funcs_12.c: include dirs lost ['/generated'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRRecompiledFuncsV77: build/fold-equivalence/generated/guest-hooks-funcs-v77/funcs_14.c: include dirs lost ['/generated'] extra []", + "difference": "DKRRecompiledFuncsV77: /generated/guest-hooks-funcs-v77/funcs_14.c: include dirs lost ['/generated'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRRecompiledFuncsV77: build/fold-equivalence/generated/guest-hooks-funcs-v77/funcs_31.c: include dirs lost ['/generated'] extra []", + "difference": "DKRRecompiledFuncsV77: /generated/guest-hooks-funcs-v77/funcs_31.c: include dirs lost ['/generated'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRRecompiledFuncsV80: build/fold-equivalence/generated/guest-hooks-funcs-v80/funcs_12.c: include dirs lost ['/generated'] extra []", + "difference": "DKRRecompiledFuncsV80: /generated/guest-hooks-funcs-v80/funcs_12.c: include dirs lost ['/generated'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRRecompiledFuncsV80: build/fold-equivalence/generated/guest-hooks-funcs-v80/funcs_14.c: include dirs lost ['/generated'] extra []", + "difference": "DKRRecompiledFuncsV80: /generated/guest-hooks-funcs-v80/funcs_14.c: include dirs lost ['/generated'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRRecompiledFuncsV80: build/fold-equivalence/generated/guest-hooks-funcs-v80/funcs_31.c: include dirs lost ['/generated'] extra []", + "difference": "DKRRecompiledFuncsV80: /generated/guest-hooks-funcs-v80/funcs_31.c: include dirs lost ['/generated'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRExperimentalOwnedGameV77: build/fold-equivalence/generated/guest-hooks-owned-v77/guest_26.c: include dirs lost ['build/owned-id/final'] extra []", + "difference": "DKRExperimentalOwnedGameV77: /generated/guest-hooks-owned-v77/guest_26.c: include dirs lost ['build/owned-id/final'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." }, { - "difference": "DKRExperimentalOwnedGameV80: build/fold-equivalence/generated/guest-hooks-owned-v80/guest_26.c: include dirs lost ['build/owned-id/final'] extra []", + "difference": "DKRExperimentalOwnedGameV80: /generated/guest-hooks-owned-v80/guest_26.c: include dirs lost ['build/owned-id/final'] extra []", "reason": "The achievements and all-player-detail layers gave their hooked copy the payload directory and its parent (prior/..) as per-source include directories, which only let their absolute funcs.h/imports.h includes resolve. The guest-hooks copy reaches the same headers through the payload target's own include path; level 2 finds the preprocessed TU token-identical." } ] diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index da88aba8..d25ecf79 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -51,6 +51,7 @@ from pathlib import Path import re import shlex +import shutil import subprocess import sys from typing import cast @@ -61,6 +62,9 @@ ROOT = apply_closure.ROOT ALLOWED = ROOT / "scripts/fold/allowed-deltas.json" +# TUs that include shader headers the build writes later, so they cannot be +# preprocessed after a configure. Only these may skip level 2. +SHADER_PENDING = ROOT / "scripts/fold/shader-pending.json" # Folded targets that compile what one reference target compiled: the game's # TUs moved into an object library so host builds without a generated payload # compile them too. Compile units and dependencies are compared under the @@ -199,10 +203,16 @@ def guest_hooked(self, row: apply_closure.Row) -> Path | None: revision = match.group(2) if payload is None: return None - dirs = sorted((self.folded_build / "generated").glob(f"guest-hooks-{payload}-v{revision}-*")) - if len(dirs) != 1 or not (dirs[0] / row.staged.name).is_file(): + # GuestHooks.cmake names the current directory; hooked.txt lists what + # it hooked. A shipped file the spec no longer hooks has no pair. + pointer = self.folded_build / "generated" / f"guest-hooks-{payload}-v{revision}.dir" + if not pointer.is_file(): return None - return dirs[0] / row.staged.name + directory = Path(pointer.read_text(encoding="utf-8").strip()) + listed = directory / "hooked.txt" + if not listed.is_file() or row.staged.name not in listed.read_text(encoding="utf-8").split(): + return None + return directory / row.staged.name def build_path(self, path: Path) -> Path: parts = list(path.relative_to(self.reference.build).parts) @@ -232,18 +242,18 @@ def text(self, value: str) -> str: out = out.replace(str(self.reference.build) + "/", str(self.folded_build) + "/") return out.replace(str(self.reference.probe / "repo") + "/", str(ROOT) + "/") - def deferred(self, path: Path) -> str | None: - """Why a reference TU has no folded counterpart yet, or None.""" - layer = self.folder.layer_of(path) - rows = {row.staged: row for row in self.reference.rows} - row = rows.get(path) - if row is not None and row.kind == "dependency-transform" and path not in self.dependency: - return "dependency transform (fold step B3)" - if row is not None and row.kind in ("guest-transform", "guest-derived") and path not in self.dependency: - return "guest code (fold step B4)" - if layer is not None: - return f"staged by layer {layer} (fold step B3 or B4)" - return None + +def portable(mapping: Mapping, path: Path) -> str: + """A folded path as reports and allowed-deltas.json spell it: the build + directory as /, the work tree relative, content-addressed guest-hook + directories by payload and revision (so a recorded delta survives a spec + edit).""" + text = str(path) + if path.is_relative_to(mapping.folded_build): + text = "/" + str(path.relative_to(mapping.folded_build)) + elif path.is_relative_to(ROOT): + text = str(path.relative_to(ROOT)) + return re.sub(r"/generated/(guest-hooks-[a-z]+-v\d+)-[0-9a-f]{64}/", r"/generated/\1/", text) def normalized_includes(mapping: Mapping, includes: Iterable[str]) -> tuple[str, ...]: @@ -294,17 +304,15 @@ def compare_targets( continue target = mapping.file(path) if target is None: - reason = mapping.deferred(path) - if reason is None: - yield f"{name}: {path}: no folded counterpart" + yield f"{name}: {path}: no folded counterpart" continue mapped[target] = (path, group) for target, (path, ref) in sorted(mapped.items()): new = new_sources.get(target) + label = f"{name}: {portable(mapping, target)}" if new is None: - yield f"{name}: {target.relative_to(ROOT) if target.is_relative_to(ROOT) else target}: not built" + yield f"{label}: not built" continue - label = f"{name}: {target.relative_to(ROOT) if target.is_relative_to(ROOT) else target}" if ref.language != new.language: yield f"{label}: language {ref.language} -> {new.language}" ref_defines = {mapping.text(d) for d in ref.defines if not IDENTITY_DEFINES.match(d)} @@ -337,15 +345,16 @@ def compare_targets( ref_fragments = tuple(mapping.text(f) for f in ref.fragments) if ref_fragments != new.fragments: yield f"{label}: flags {list(ref_fragments)} -> {list(new.fragments)}" + # A folded source the reference target doesn't have (shipped or not, + # such as a precompiled-header stub) has no reference counterpart. + known = {mapping.file(path) for path in ref_sources} extra_sources = { path for path in new_sources - if path not in mapped - and path.is_relative_to(ROOT / "runtime-recomp") - and new_sources[path].language in ("C", "CXX") + if path not in mapped and path not in known and new_sources[path].language in ("C", "CXX") } for path in sorted(extra_sources): - yield f"{name}: {path.relative_to(ROOT)}: built, but not in the reference" + yield f"{name}: {portable(mapping, path)}: built, but not in the reference" def shipped_pairs(compdb: Path) -> dict[str, set[Path]]: @@ -410,16 +419,13 @@ def run_targets(args: argparse.Namespace) -> int: found += compare_links(mapping, reference, folded, shipped) problems: list[str] = [] for problem in found: - portable = problem.replace(str(args.folded_build), "").replace(str(ROOT) + "/", "") - # Guest-hook copies live in a content-addressed directory; name it by - # payload and revision so a recorded delta survives a spec edit. - portable = re.sub(r"/generated/(guest-hooks-[a-z]+-v\d+)-[0-9a-f]{64}/", r"/generated/\1/", portable) - reason = explained(portable, allowed) + text = problem.replace(str(args.folded_build), "").replace(str(ROOT) + "/", "") + reason = explained(text, allowed) if reason is None: - problems.append(portable) - print(portable) + problems.append(text) + print(text) elif args.verbose: - print(f"allowed: {portable} ({reason})") + print(f"allowed: {text} ({reason})") print( f"targets: {len(shipped)} compared, {len(found) - len(problems)} allowed, " f"{len(problems)} unexplained differences", @@ -508,8 +514,9 @@ def scan(entry: dict[str, str]) -> dict[str, object]: with ThreadPoolExecutor(args.jobs) as pool: results = list(pool.map(scan, entries)) - args.out.write_text(json.dumps(results) + "\n", encoding="utf-8") errors = [result for result in results if result["error"]] + if not errors: + atomic_write(args.out, json.dumps(results) + "\n") for result in errors: print(f"{result['source']}: {result['error']}", file=sys.stderr) print(f"depscan: {len(results)} TUs, {len(errors)} errors", file=sys.stderr) @@ -523,7 +530,12 @@ def run_preprocess(args: argparse.Namespace) -> int: candidates = [e for e in load_entries(args.compdb).values() if first_party_or_prepared(e, args.build)] entries = [e for e in candidates if tu_key(e)[1].is_file()] print(f"preprocess: skipping {len(candidates) - len(entries)} build-time generated TUs", file=sys.stderr) - args.out.mkdir(parents=True, exist_ok=True) + # Output appears under --out only when every TU preprocessed, so an + # interrupted run never leaves a cache a later run would accept. + final = args.out + args.out = final.with_name(final.name + ".partial") + shutil.rmtree(args.out, ignore_errors=True) + args.out.mkdir(parents=True) def preprocess(entry: dict[str, str]) -> tuple[TU, str | None]: key = tu_key(entry) @@ -547,7 +559,11 @@ def preprocess(entry: dict[str, str]) -> tuple[TU, str | None]: f"preprocess: {len(results)} TUs, {len(pending)} need build-time shader headers, {len(errors)} errors", file=sys.stderr, ) - return 1 if errors else 0 + if errors: + return 1 + shutil.rmtree(final, ignore_errors=True) + os.replace(args.out, final) + return 0 INCLUDE_SPELLING = re.compile(rb'^([ \t]*#[ \t]*include(?:_next)?[ \t]*)[<"][^>"\r\n]*[>"]', re.M) @@ -590,26 +606,28 @@ class Pairing: """Reference TUs and their folded counterparts, keyed by (target, source).""" pairs: dict[TU, TU] # reference -> folded - notes: list[str] + unmapped: list[str] # reference TUs with no folded counterpart unpaired_folded: set[TU] def pair_tus(mapping: Mapping, reference: dict[TU, dict[str, str]], folded: dict[TU, dict[str, str]]) -> Pairing: first_party = {row.staged for row in mapping.reference.rows if row.kind == "first-party"} pairs: dict[TU, TU] = {} - notes: list[str] = [] + unmapped: list[str] = [] for target, source in sorted(reference): deps = mapping.reference.deps.get(source, frozenset()) if source not in first_party and not deps & first_party: continue folded_source = mapping.file(source) if folded_source is None: - notes.append(f"{target}: {source}: {mapping.deferred(source) or 'no folded counterpart'}") + unmapped.append(f"{target}: {source}: no folded counterpart") continue pairs[(target, source)] = (target, folded_source) + # Every folded TU must have a reference counterpart, wherever it lives + # (runtime-recomp/, extern/ or a build-tree stage). every = {(target, mapping.file(source)) for target, source in reference} - unpaired = {key for key in folded if key[1].is_relative_to(ROOT / "runtime-recomp")} - every - return Pairing(pairs, notes, unpaired) + unpaired = set(folded) - every + return Pairing(pairs, unmapped, unpaired) def run_level1(args: argparse.Namespace) -> int: @@ -617,17 +635,16 @@ def run_level1(args: argparse.Namespace) -> int: first_party = {row.staged for row in mapping.reference.rows if row.kind == "first-party"} reference_entries = load_entries(args.reference / "shipped-compdb.json") folded_entries = load_entries(args.folded_compdb) - reference_deps = load_deps(args.reference / "depscan-by-target.json") + reference_deps = load_deps(args.reference_deps) folded_deps = load_deps(args.folded_deps) pairing = pair_tus(mapping, reference_entries, folded_entries) allow = variant_tus(ROOT, forwarders=True) allowed = load_allowed(ALLOWED) - problems: list[str] = [] - notes = list(pairing.notes) + problems: list[str] = list(pairing.unmapped) same_bytes: dict[tuple[Path, Path], bool] = {} def name(path: Path) -> str: - return str(path).replace(str(args.folded_build), "").replace(str(ROOT) + "/", "") + return portable(mapping, path) for old_key, new_key in sorted(pairing.pairs.items()): label = f"{new_key[0]}: {name(new_key[1])}" @@ -643,11 +660,7 @@ def name(path: Path) -> str: for dep in old: folded = mapping.file(dep) if folded is None: - reason = mapping.deferred(dep) - if reason is None: - problems.append(f"{label}: read {name(dep)}, which the fold drops") - else: - notes.append(f"{label}: read {name(dep)}: {reason}") + problems.append(f"{label}: read {name(dep)}, which the fold drops") continue expected.add(folded) if dep not in first_party: @@ -666,12 +679,9 @@ def name(path: Path) -> str: unexplained = [problem for problem in problems if explained(problem, allowed) is None] for problem in unexplained: print(problem) - if args.verbose: - for note in notes: - print(f"note: {note}") print( - f"level1: {len(pairing.pairs)} TUs, {len(same_bytes)} files byte-checked, {len(notes)} notes " - f"(fold steps B3/B4), {len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", + f"level1: {len(pairing.pairs)} TUs, {len(same_bytes)} files byte-checked, " + f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", file=sys.stderr, ) return 1 if unexplained else 0 @@ -679,6 +689,9 @@ def name(path: Path) -> str: QUOTED_PATH = re.compile(r'"(/[^"\n]+)"') DIGEST = re.compile(r"[0-9a-f]{64}") +# Preprocessed tokens for level 2: string and character literals whole (their +# spaces are content), everything else split at whitespace. +TOKEN = re.compile(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'|[^\s"\']+|["\']') def path_string(match: re.Match[str], mapping: Mapping | None) -> str: @@ -689,12 +702,12 @@ def path_string(match: re.Match[str], mapping: Mapping | None) -> str: def tokens(text: str, mapping: Mapping | None) -> list[str]: - """Whitespace-separated tokens. File path strings (__FILE__, assert + """Tokens (literals kept whole, see TOKEN). File path strings (__FILE__, assert messages) are normalized, and with a mapping, reference paths are rewritten to their folded paths: a respelled include changes the spelling __FILE__ reports, not the file.""" - return QUOTED_PATH.sub(lambda match: path_string(match, mapping), text).split() + return TOKEN.findall(QUOTED_PATH.sub(lambda match: path_string(match, mapping), text)) def normalized_command(mapping: Mapping | None, entry: dict[str, str]) -> list[str]: @@ -734,15 +747,17 @@ def run_level2(args: argparse.Namespace) -> int: problems: list[str] = [] level3: list[str] = [] identity_only: list[str] = [] - pending: set[str] = set() - for directory in (args.reference_pp, args.folded_pp): - if (directory / "pending.json").exists(): - pending |= set(json.loads((directory / "pending.json").read_text(encoding="utf-8"))) + pending = { + side: set(json.loads((directory / "pending.json").read_text(encoding="utf-8"))) + for side, directory in (("reference", args.reference_pp), ("folded", args.folded_pp)) + } + expected_pending = set(json.loads(SHADER_PENDING.read_text(encoding="utf-8"))["tus"]) + skipped: set[str] = set() same = 0 for old_key, new_key in sorted(pairing.pairs.items()): if new_key not in folded_entries: continue - label = f"{new_key[0]}: {str(new_key[1]).replace(str(ROOT) + '/', '')}" + label = f"{new_key[0]}: {portable(mapping, new_key[1])}" old_cmd = normalized_command(mapping, reference_entries[old_key]) new_cmd = normalized_command(None, folded_entries[new_key]) old_flags = [a for a in old_cmd if not IDENTITY_DEFINE_FLAG.match(a)] @@ -752,8 +767,15 @@ def run_level2(args: argparse.Namespace) -> int: identity_changed = old_cmd != new_cmd old_file = args.reference_pp / f"{output_name(old_key)}.i" new_file = args.folded_pp / f"{output_name(new_key)}.i" - if old_file.name in pending or new_file.name in pending: - continue # third-party TU waiting for build-time shader headers + waiting = [ + side for side, name in (("reference", old_file.name), ("folded", new_file.name)) if name in pending[side] + ] + if waiting: + if label in expected_pending: + skipped.add(label) + else: + problems.append(f"{label}: needs build-time shader headers ({' and '.join(waiting)}), not listed") + continue if not old_file.is_file() or not new_file.is_file(): missing = "reference" if not old_file.is_file() else "folded" problems.append(f"{label}: no {missing} preprocessed output") @@ -767,13 +789,23 @@ def run_level2(args: argparse.Namespace) -> int: level3.append(label) continue # Fold step B7 freezes the online identity; until then a TU whose - # identity defines changed may differ in those 64-hex digests only. - if identity_changed and [DIGEST.sub("", t) for t in old] == [DIGEST.sub("", t) for t in new]: - identity_only.append(label) - continue + # identity defines changed may differ in those defines' own digests + # only. Any other 64-hex literal is still compared. + if identity_changed: + ids = {d for a in old_cmd + new_cmd if IDENTITY_DEFINE_FLAG.match(a) for d in DIGEST.findall(a)} + + def masked(stream: list[str]) -> list[str]: + return [DIGEST.sub(lambda m: "" if m.group() in ids else m.group(), t) for t in stream] + + if ids and masked(old) == masked(new): + identity_only.append(label) + continue first = next((i for i, (a, b) in enumerate(zip(old, new)) if a != b), min(len(old), len(new))) was, now = " ".join(old[first : first + 8]), " ".join(new[first : first + 8]) problems.append(f"{label}: tokens differ at {first}: {was!r} vs {now!r}") + problems += [ + f"{label}: listed in {SHADER_PENDING.name} but preprocessed" for label in sorted(expected_pending - skipped) + ] unexplained = [problem for problem in problems if explained(problem, allowed) is None] for problem in unexplained: print(problem) @@ -783,7 +815,7 @@ def run_level2(args: argparse.Namespace) -> int: print(f"identity only (B7): {label}") print( f"level2: {same} TUs token-identical, {len(level3)} header-variant TUs left for level 3, " - f"{len(identity_only)} identity-only (B7), {len(pending)} waiting for build-time shaders, " + f"{len(identity_only)} identity-only (B7), {len(skipped)} waiting for build-time shaders, " f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", file=sys.stderr, ) @@ -860,7 +892,7 @@ def run_level3(args: argparse.Namespace) -> int: for old_key, new_key in sorted(pairing.pairs.items()): if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) not in split or new_key not in folded_entries: continue - label = f"{new_key[0]}: {str(new_key[1]).replace(str(ROOT) + '/', '')}" + label = f"{new_key[0]}: {portable(mapping, new_key[1])}" old_text = (args.reference_pp / f"{output_name(old_key)}.i").read_text(errors="surrogateescape") new_text = (args.folded_pp / f"{output_name(new_key)}.i").read_text(errors="surrogateescape") if tokens(old_text, mapping) == tokens(new_text, None): @@ -955,6 +987,45 @@ def run_options(args: argparse.Namespace) -> int: return 0 +REFERENCE_DOC = ROOT / "docs/fold-in/REFERENCE.md" +DOC_ROW = re.compile(r"^\| `([^`]+)`[^|]*\| `([0-9a-f]{64})` \|$", re.M) + + +def run_verify_reference(args: argparse.Namespace) -> int: + """The reference is what REFERENCE.md records, and every staged file the + gate compares against still has the bytes final-sources.json pinned.""" + problems: list[str] = [] + recorded = DOC_ROW.findall(REFERENCE_DOC.read_text(encoding="utf-8")) + if not recorded: + problems.append(f"{REFERENCE_DOC.name}: no sha256 table") + for name, sha in recorded: + path = args.reference / name + if not path.is_file(): + problems.append(f"{name}: missing from the reference") + elif hashlib.sha256(path.read_bytes()).hexdigest() != sha: + problems.append(f"{name}: sha256 differs from {REFERENCE_DOC.name}") + reference = apply_closure.load_reference(args.reference, ROOT) + for row in reference.rows: + if not row.staged.is_file(): + problems.append(f"{row.staged}: staged file missing") + elif hashlib.sha256(row.staged.read_bytes()).hexdigest() != row.sha256: + problems.append(f"{row.staged}: bytes differ from final-sources.json") + for problem in problems: + print(problem) + print( + f"reference: {len(recorded)} recorded files, {len(reference.rows)} staged files, {len(problems)} problems", + file=sys.stderr, + ) + return 1 if problems else 0 + + +def atomic_write(path: Path, text: str) -> None: + """Write via a temporary file so an interrupted run leaves no partial file.""" + scratch = path.with_name(path.name + ".partial") + scratch.write_text(text, encoding="utf-8") + os.replace(scratch, path) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) @@ -971,10 +1042,13 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: level1 = compared("level1", "compare TU sets, header closures and file bytes") level1.add_argument("--folded-compdb", type=Path, required=True) level1.add_argument("--folded-deps", type=Path, required=True) + level1.add_argument("--reference-deps", type=Path, required=True) level2 = compared("level2", "compare preprocessed token streams and compile flags") level2.add_argument("--folded-compdb", type=Path, required=True) level2.add_argument("--reference-pp", type=Path, required=True) level2.add_argument("--folded-pp", type=Path, required=True) + verify = sub.add_parser("verify-reference", help="check the reference against REFERENCE.md and its own pins") + verify.add_argument("--reference", type=Path, required=True) options = sub.add_parser("options", help="print the folded configure options, one per line") options.add_argument("--reference", type=Path, required=True) options.add_argument("--mounts", action="store_true", help="print the paths outside the tree they name instead") @@ -1002,6 +1076,7 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: if getattr(args, key, None) is not None: setattr(args, key, getattr(args, key).resolve()) handlers = { + "verify-reference": run_verify_reference, "targets": run_targets, "level1": run_level1, "level2": run_level2, diff --git a/scripts/fold/run_equivalence.sh b/scripts/fold/run_equivalence.sh index ccc4c7e1..d884eccb 100755 --- a/scripts/fold/run_equivalence.sh +++ b/scripts/fold/run_equivalence.sh @@ -11,16 +11,17 @@ # (DKR_EQUIVALENCE_IMAGE, default: the exact image the v1.0.11 reference was # built with, by image ID; docker/ci-linux-full/Dockerfile is its recipe). # -# Steps: configure this tree with the reference's options minus the release -# chain's, check the dependency stage patches reproduce the shipped -# third-party files (dependency_stages.py), then compare per-source compile -# settings and link lines (targets), -# TU sets, header closures and file bytes (level 1), preprocessed tokens plus -# compile flags (level 2), and objects for the TUs level 2 lets differ because -# of a header variant (level 3; DKR_EQUIVALENCE_LEVEL3=0 skips it, it compiles -# them twice). Exit status is non-zero on any unexplained -# difference; scripts/fold/allowed-deltas.json and header-variants.json list -# the explained ones. +# Steps: check the reference against docs/fold-in/REFERENCE.md and its own +# pins (it is mounted read-only; its scan and preprocess caches go to +# BUILD_DIR/reference-cache), configure this tree with the reference's options +# minus the release chain's, check the dependency stage patches reproduce the +# shipped third-party files (dependency_stages.py), then compare per-source +# compile settings and link lines (targets), TU sets, header closures and file +# bytes (level 1), preprocessed tokens plus compile flags (level 2), and +# objects for the TUs level 2 lets differ because of a header variant +# (level 3, always run). Exit status is non-zero on any unexplained +# difference; scripts/fold/allowed-deltas.json, header-variants.json and +# shader-pending.json list the explained ones. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" reference="$(cd "${1:?usage: run_equivalence.sh REFERENCE_DIR [BUILD_DIR]}" && pwd)" @@ -29,14 +30,18 @@ image="${DKR_EQUIVALENCE_IMAGE:-sha256:a40e107015464b92bff4e383545938f206590f73a jobs="${DKR_EQUIVALENCE_JOBS:-12}" tool="$root/scripts/fold/equivalence.py" out="$build/equivalence" -mkdir -p "$out" +# The reference is mounted read-only; its scan and preprocess caches live here. +cache="$build/reference-cache" +mkdir -p "$out" "$cache" # A worktree's .git points into the main checkout; the configure reads it. git_common="$(cd "$root" && cd "$(git rev-parse --git-common-dir)" && pwd)" -mounts=(-v "$root:$root" -v "$reference:$reference" -v "$git_common:$git_common:ro") +mounts=(-v "$root:$root" -v "$reference:$reference:ro" -v "$git_common:$git_common:ro") while IFS= read -r path; do [ -n "$path" ] && mounts+=(-v "$path:$path:ro") done < <(python3 "$tool" options --reference "$reference" --mounts) +# The pinned image lacks libcurl's headers; the reference configure installed +# them the same way. in_image() { docker run --rm --platform linux/amd64 "${mounts[@]}" -w "$root" "$image" bash -c \ "set -e; dpkg -s libcurl4-openssl-dev >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq libcurl4-openssl-dev) >/dev/null 2>&1; git config --global --add safe.directory '*'; $1" @@ -48,6 +53,9 @@ while IFS= read -r option; do done < <(python3 "$tool" options --reference "$reference") printf -v configure '%q ' cmake -S "$root/runtime-recomp" -B "$build" -G Ninja "${options[@]}" +echo "== reference" +python3 "$tool" verify-reference --reference "$reference" + echo "== configure (no overlay, no project include)" mkdir -p "$build/.cmake/api/v1/query" touch "$build/.cmake/api/v1/query/codemodel-v2" @@ -57,8 +65,8 @@ echo "== shipped compile commands and header scan" in_image "python3 -B '$tool' shipped --build '$build' --out '$out/shipped-compdb.json' \ && python3 -B '$tool' depscan --compdb '$out/shipped-compdb.json' --build '$build' --out '$out/depscan.json' --jobs $jobs" ref_build="$(python3 "$tool" options --reference "$reference" --reference-build)" -if [ ! -f "$reference/depscan-by-target.json" ]; then - in_image "python3 -B '$tool' depscan --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$reference/depscan-by-target.json' --jobs $jobs" +if [ ! -f "$cache/depscan-by-target.json" ]; then + in_image "python3 -B '$tool' depscan --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$cache/depscan-by-target.json' --jobs $jobs" fi status=0 @@ -68,22 +76,21 @@ echo "== targets" python3 "$tool" targets --reference "$reference" --folded-build "$build" || status=1 echo "== level 1" python3 "$tool" level1 --reference "$reference" --folded-build "$build" \ - --folded-compdb "$out/shipped-compdb.json" --folded-deps "$out/depscan.json" || status=1 + --folded-compdb "$out/shipped-compdb.json" --folded-deps "$out/depscan.json" \ + --reference-deps "$cache/depscan-by-target.json" || status=1 echo "== level 2" rm -rf "$out/pp" in_image "python3 -B '$tool' preprocess --compdb '$out/shipped-compdb.json' --build '$build' --out '$out/pp' --jobs $jobs" -if [ ! -d "$reference/pp" ]; then - in_image "python3 -B '$tool' preprocess --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$reference/pp' --jobs $jobs" +if [ ! -d "$cache/pp" ]; then + in_image "python3 -B '$tool' preprocess --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$cache/pp' --jobs $jobs" fi python3 "$tool" level2 --reference "$reference" --folded-build "$build" \ - --folded-compdb "$out/shipped-compdb.json" --reference-pp "$reference/pp" --folded-pp "$out/pp" || status=1 + --folded-compdb "$out/shipped-compdb.json" --reference-pp "$cache/pp" --folded-pp "$out/pp" || status=1 -if [ "${DKR_EQUIVALENCE_LEVEL3:-1}" != 0 ]; then - echo "== level 3" - rm -rf "$out/level3" - in_image "python3 -B '$tool' level3 --reference '$reference' --folded-build '$build' \ - --folded-compdb '$out/shipped-compdb.json' --reference-pp '$reference/pp' --folded-pp '$out/pp' \ - --out '$out/level3'" || status=1 -fi +echo "== level 3" +rm -rf "$out/level3" +in_image "python3 -B '$tool' level3 --reference '$reference' --folded-build '$build' \ + --folded-compdb '$out/shipped-compdb.json' --reference-pp '$cache/pp' --folded-pp '$out/pp' \ + --out '$out/level3'" || status=1 exit "$status" diff --git a/scripts/fold/shader-pending.json b/scripts/fold/shader-pending.json new file mode 100644 index 00000000..1bcc9dab --- /dev/null +++ b/scripts/fold/shader-pending.json @@ -0,0 +1,6 @@ +{ + "about": "Shipped TUs that include shader headers the build writes later (rt64's water renderer), so neither side can be preprocessed after a configure. Level 2 skips exactly these; any other TU that waits on a shader header fails.", + "tus": [ + "rt64: /generated/hle-command-guard/src/render/rt64_water_renderer.cpp" + ] +} From ee39f63ed30dffb70a4c9ab1a62b815f2a4f0562 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 21:59:01 -0500 Subject: [PATCH 23/62] List non-Linux PR-2 blockers, record the Quick Join flake on main --- docs/fold-in/open-items.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 33d00f5d..bbc85f0b 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -18,11 +18,29 @@ (amd64 under emulation on the devops Mac). Alone it fails about half its runs, at `quick_join_lifecycle_tests.cpp:126`: after the rejected rekey the client never takes the host's Quick Join bootstrap, and the 20 s wait - expires (passing runs take about 3 s). Nothing it compiles changed after - the test triage, and it passed then. That points to a race in the shipped - Quick Join path or in the test, not the fold. `DKRExperimentalQuickJoin` - aborted once under parallel load the same way, and it was the one failure - before the fold too. Behaviour fixes belong after B10. + expires (passing runs take about 3 s). It predates the fold: built from + `origin/main` (`1d46835`, no fold) it passed 10 of 20 back-to-back runs, + and from the fold branch 8 of 20, in the same image. So it's a race in the + Quick Join path or the test. `DKRExperimentalQuickJoin` aborted once under + parallel load the same way, and it was the one failure before the fold + too. Behaviour fixes belong after B10. + +## PR-2 merge blockers (non-Linux coverage) + +The gate is Linux x86_64 only. These close only when the `windows` and +`android` CI jobs build the folded tree and pass on the PR: + +- MSVC compile flags of the shipped TUs (`/bigobj`, `/EHs /EHc-`, + `/fp:strict`, `/FI` forced includes) and the Windows stage chain. +- The stage patches on the Android chain (five files take `android-*` stages + first) and the `DKRPortGameCore` object library inside the Android + `SHARED` `DKRPortGame`. + +Today neither job does that. `build-windows.yml` configures the `host` +preset (no RT64, so no stage patches and no game TUs), and +`build-android.yml` compiles `runtime-recomp/tests/android_compile` only. +They need a `host-full-nogen`-style Windows configure and an Android +configure of `runtime-recomp/` before they can close these. ## Open questions From 13c783a83aa69c377992d96760baacaf683db997 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 22:27:35 -0500 Subject: [PATCH 24/62] Freeze the owned rollback contracts at the v1.0.11 values (B7) legacy_identity.hpp holds them and CMake reads them; no source hash chain feeds identity. The gate no longer exempts identity defines. --- docs/contracts/ONLINE_IDENTITY.md | 36 +++++++++ docs/fold-in/open-items.md | 12 +++ runtime-recomp/CMakeLists.txt | 81 +++---------------- runtime-recomp/cmake/OnlineIdentity.cmake | 17 ++++ .../src/game/netplay/legacy_identity.hpp | 9 +++ runtime-recomp/tests/online_identity_tests.py | 61 ++++++++++++++ scripts/fold/equivalence.py | 37 ++------- 7 files changed, 152 insertions(+), 101 deletions(-) create mode 100644 docs/contracts/ONLINE_IDENTITY.md create mode 100644 runtime-recomp/cmake/OnlineIdentity.cmake create mode 100644 runtime-recomp/src/game/netplay/legacy_identity.hpp create mode 100644 runtime-recomp/tests/online_identity_tests.py diff --git a/docs/contracts/ONLINE_IDENTITY.md b/docs/contracts/ONLINE_IDENTITY.md new file mode 100644 index 00000000..db67e485 --- /dev/null +++ b/docs/contracts/ONLINE_IDENTITY.md @@ -0,0 +1,36 @@ +# Online identity + +Before two players' engines run a rollback match, they compare the owned +rollback contract of the ROM revision they share. If the contracts differ, +the lobby refuses the peer. The contracts are frozen at the values v1.0.11 +shipped, so a build from this tree plays with v1.0.11 players. + +| Revision | Define | Contract | +|---|---|---| +| US v1.0 (v77) | `DKR_OWNED_BUILD_ID_V77` | `5e22da8e6095b362ede17c69a2341aae2df24cbf8561c3af57fc86566d11784e` | +| US v1.1 (v80) | `DKR_OWNED_BUILD_ID_V80` | `32c82fea3111440434926d5782e174c9f2d036d198f039c5424393a835f948b0` | + +## Where they live + +`runtime-recomp/src/game/netplay/legacy_identity.hpp` is the only place they +change. `cmake/OnlineIdentity.cmake` reads them into the compile definitions +`netplay/experimental_owned_registry.cpp` returns. A build without the Rev A +engine offers no v80 contract. + +The rollback test frontend (`DKR-R-RollbackTest`, not shipped) negotiates +`sha256("v77:|v80:")` of the same two values. + +## Provenance + +v1.0.11 derived each value at configure time from a sha256 chain over the +owned engine's generated and authored sources (`cmake/OwnedContentIdentity.cmake`, +with the release layers' ledgers). The fold reference's configure of tag +`Version1.0.11` produced exactly these values +(`generated/memory-stability/contracts.json`, docs/fold-in/REFERENCE.md), +using the owned, generated and code-policy inputs that reproduce the shipped +binaries. The values were also recomputed from `6bdbcde` and `5cab242` with +`base_id.py` and `chain.py`, with the same result. + +The chain no longer runs. Sources now change without changing the contract. +Change the contract on purpose, by editing `legacy_identity.hpp` and this +table together, when a change breaks compatibility with older peers. diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index bbc85f0b..6921112c 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -116,6 +116,18 @@ configure of `runtime-recomp/` before they can close these. `extern/dkr-decomp/ver/symbols`. That's the sparse "symbols" dependency profile the plan describes for the `linux` job. +## Online identity (B7) + +- The owned rollback contracts are frozen in + `runtime-recomp/src/game/netplay/legacy_identity.hpp` + (`docs/contracts/ONLINE_IDENTITY.md`). The gate compares identity defines + exactly, and `online_identity_tests.py` pins the values. +- `cmake/OwnedContentIdentity.cmake` no longer feeds any identity; only + `owned_content_identity_tests.py` still runs it. B8 deletes both. +- `DKR_LOBBY_STABILITY_CONTRACT` was already a committed literal + (`src/game/build_contract.hpp`). Moving it into `legacy_identity.hpp` + would cost a level 1 delta now, so that waits for Phase C. + ## `patches/` content the build still reads (before B8) Everything else under `patches/` that the base CMake reads is a dependency diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index fa30a58f..57e823e0 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -1174,7 +1174,7 @@ if(DKR_RUNTIME_BUILD_GENERATED AND DKR_RUNTIME_BUILD_RT64 AND DKR_VISUAL_MODEL_G endif() if(DKR_EXPERIMENTAL_OWNED_GENERATED) include(cmake/ExperimentalOwnedGame.cmake) - include(cmake/OwnedContentIdentity.cmake) + include(cmake/OnlineIdentity.cmake) file(READ "${DKR_EXPERIMENTAL_OWNED_GENERATED}/manifest.json" primary_manifest) string(JSON primary_revision GET "${primary_manifest}" revision) if(NOT primary_revision EQUAL 77) @@ -1204,35 +1204,12 @@ if(DKR_EXPERIMENTAL_OWNED_GENERATED) endif() add_library(DKRExperimentalOwnedGame INTERFACE) target_link_libraries(DKRExperimentalOwnedGame INTERFACE DKRExperimentalOwnedRegistry) - # Revision IDs include both private generated and authored closures. Hash - # contents only so identical Windows/Linux engines negotiate identically. - foreach(owned_revision 77 80) - if(owned_revision EQUAL 77) - set(owned_directory "${DKR_EXPERIMENTAL_OWNED_GENERATED}") - elseif(DKR_OWNED_HAS_V80) - set(owned_directory "${DKR_EXPERIMENTAL_OWNED_GENERATED_V80}") - else() - set(DKR_OWNED_BUILD_ID_V80 "") - continue() - endif() - file(GLOB owned_identity_files CONFIGURE_DEPENDS - "${CMAKE_CURRENT_LIST_DIR}/src/game/audio_event_queue*.h" - "${CMAKE_CURRENT_LIST_DIR}/src/game/runtime_legacy_mods.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/runtime_legacy_mods.hpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/runtime_code_mods.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/runtime_code_mods.hpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/replay_probe_capture.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/replay_probe_capture.hpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/mods/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/mods/*.hpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/mods/*.h" - "${owned_directory}/*.c" "${owned_directory}/*.cpp" "${owned_directory}/*.h" "${owned_directory}/*.hpp" - "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.c" "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.cpp" - "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.h" "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.hpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.c" "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.cpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.h" "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.hpp") - dkr_owned_content_identity(owned_content_parts "${CMAKE_CURRENT_LIST_DIR}" "${owned_directory}" - "${owned_directory}/manifest.json" "${CMAKE_CURRENT_LIST_DIR}/src/game/track_select_draw_guest.h" - "${DKRPORT_ROOT}/modding/reference-us-v77/game_symbols.inc" "${DKRPORT_ROOT}/modding/reference-us-v80/game_symbols.inc" ${owned_identity_files}) - set(owned_identity_parts "owned-revision:${owned_revision}|identity-policy:text-lf-v1|numeric-policy:separate-fp-rounding-v1|${owned_content_parts}") - string(SHA256 DKR_OWNED_BUILD_ID_V${owned_revision} "${owned_identity_parts}") - message(STATUS "Owned v${owned_revision} rollback contract: ${DKR_OWNED_BUILD_ID_V${owned_revision}}") - endforeach() + # The revision contracts are frozen (cmake/OnlineIdentity.cmake); a build + # without the Rev A engine offers v80 to nobody. + if(NOT DKR_OWNED_HAS_V80) + set(DKR_OWNED_BUILD_ID_V80 "") + endif() + message(STATUS "Owned rollback contracts: v77 ${DKR_OWNED_BUILD_ID_V77} v80 ${DKR_OWNED_BUILD_ID_V80}") target_compile_definitions(DKRExperimentalOwnedRegistry PRIVATE DKR_OWNED_BUILD_ID_V77="${DKR_OWNED_BUILD_ID_V77}" DKR_OWNED_BUILD_ID_V80="${DKR_OWNED_BUILD_ID_V80}") add_executable(DKROwnedRevisionRegistryTests EXCLUDE_FROM_ALL tests/owned_revision_registry_tests.cpp @@ -1362,49 +1339,11 @@ if(TARGET DKRPortGame AND DKR_RUNTIME_BUILD_RT64 AND NOT APPLE) SDL_MAIN_HANDLED=1 DKR_OWNED_TEST_REVISION=${DKR_OWNED_REVISION} DKR_OWNED_FULL_SCENES=${DKR_OWNED_FULL_SCENES}) - set(DKR_OWNED_ID_PARTS "") - # Bind the negotiated identity to the COMPLETE linked owned closure, - # not just the driver. Audio/input/Pak/policy changes must reject mixed - # packages too. Content hashes (not absolute paths) remain cross-OS. - file(GLOB DKR_OWNED_CLOSURE_ID_FILES CONFIGURE_DEPENDS - "${CMAKE_CURRENT_LIST_DIR}/src/game/runtime_legacy_mods.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/runtime_legacy_mods.hpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/mods/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/mods/*.hpp" "${CMAKE_CURRENT_LIST_DIR}/src/game/mods/*.h" - "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.c" - "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.cpp" - "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.h" - "${CMAKE_CURRENT_LIST_DIR}/tests/replay_probe/*.hpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.cpp" - "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.h" - "${CMAKE_CURRENT_LIST_DIR}/src/game/netplay/*.hpp" - "${DKR_EXPERIMENTAL_OWNED_GENERATED}/*.c" - "${DKR_EXPERIMENTAL_OWNED_GENERATED}/*.cpp" - "${DKR_EXPERIMENTAL_OWNED_GENERATED}/*.h") - set(DKR_OWNED_CONTRACT_FILES - "${DKRPORT_ROOT}/modding/reference-us-v77/game_symbols.inc" "${DKRPORT_ROOT}/modding/reference-us-v80/game_symbols.inc" - "${DKR_EXPERIMENTAL_OWNED_GENERATED}/manifest.json" - ${DKR_INTERACTIVE_SOURCE} tests/replay_probe/probe_driver.cpp - tests/replay_probe/probe_bridge.h tests/replay_probe/probe_native.c - tests/replay_probe/probe_presentation.c tests/replay_probe/probe_presentation.h - tests/replay_probe/probe_policies.cpp - src/game/camera_clearance_metadata.h src/game/camera_clearance_policy.hpp - src/game/camera_obstruction_guest.h src/game/camera_obstruction_math.h - src/game/track_select_draw_guest.h - tests/replay_probe/probe_audio_rsp.cpp src/game/netplay/runtime_state.hpp - src/game/netplay/experimental_rollback.cpp src/game/netplay/experimental_launch.cpp - src/game/netplay/experimental_timeline.cpp src/game/netplay/experimental_network.cpp - src/game/netplay/experimental_session.cpp src/game/netplay/experimental_pump.cpp - src/game/netplay/experimental_pcm.hpp - src/game/save_manager.cpp src/game/save_manager.hpp - src/game/save_storage.hpp src/game/runtime_save_routing.cpp src/game/runtime_save_routing.hpp - ${DKR_OWNED_CLOSURE_ID_FILES}) - dkr_owned_content_identity(DKR_OWNED_ID_PARTS "${CMAKE_CURRENT_LIST_DIR}" - "${DKR_EXPERIMENTAL_OWNED_GENERATED}" ${DKR_OWNED_CONTRACT_FILES}) - string(SHA256 DKR_OWNED_BUILD_ID "${DKR_OWNED_ID_PARTS}|v77:${DKR_OWNED_BUILD_ID_V77}|v80:${DKR_OWNED_BUILD_ID_V80}") - message(STATUS "Owned rollback contract: ${DKR_OWNED_BUILD_ID}") + # The rollback test frontend negotiates the pair of frozen revision + # contracts, not a hash of the sources that built it. + string(SHA256 DKR_OWNED_BUILD_ID "v77:${DKR_OWNED_BUILD_ID_V77}|v80:${DKR_OWNED_BUILD_ID_V80}") if(DKR_EXPERIMENTAL_RACE_TEST) target_link_libraries(DKRPortGameCore PRIVATE DKRExperimentalOwnedGame) - set_property(SOURCE src/game/netplay/experimental_runtime.cpp APPEND PROPERTY - COMPILE_DEFINITIONS DKR_OWNED_BUILD_ID="${DKR_OWNED_BUILD_ID}") endif() # Only the frontend consumes this manifest. A protocol-identity change # must not recompile every unrelated launcher/renderer translation unit. diff --git a/runtime-recomp/cmake/OnlineIdentity.cmake b/runtime-recomp/cmake/OnlineIdentity.cmake new file mode 100644 index 00000000..39680df1 --- /dev/null +++ b/runtime-recomp/cmake/OnlineIdentity.cmake @@ -0,0 +1,17 @@ +# The owned rollback contracts, read from src/game/netplay/legacy_identity.hpp +# (the only place they change) into DKR_OWNED_BUILD_ID_V77/V80. +set(dkr_identity_header "${CMAKE_CURRENT_LIST_DIR}/../src/game/netplay/legacy_identity.hpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dkr_identity_header}") +foreach(revision IN ITEMS 77 80) + file(STRINGS "${dkr_identity_header}" line + REGEX "^#define DKR_LEGACY_OWNED_BUILD_ID_V${revision} \"[0-9a-f]+\"$") + list(LENGTH line count) + if(NOT count EQUAL 1 OR NOT line MATCHES "\"([0-9a-f]+)\"$") + message(FATAL_ERROR "legacy_identity.hpp must define DKR_LEGACY_OWNED_BUILD_ID_V${revision} once") + endif() + string(LENGTH "${CMAKE_MATCH_1}" length) + if(NOT length EQUAL 64) + message(FATAL_ERROR "DKR_LEGACY_OWNED_BUILD_ID_V${revision} is not a sha256") + endif() + set(DKR_OWNED_BUILD_ID_V${revision} "${CMAKE_MATCH_1}") +endforeach() diff --git a/runtime-recomp/src/game/netplay/legacy_identity.hpp b/runtime-recomp/src/game/netplay/legacy_identity.hpp new file mode 100644 index 00000000..51f8f63d --- /dev/null +++ b/runtime-recomp/src/game/netplay/legacy_identity.hpp @@ -0,0 +1,9 @@ +#pragma once +// Online identity: the owned rollback contracts peers compare before they +// admit each other, frozen at the v1.0.11 values. docs/contracts/ONLINE_IDENTITY.md +// records them and where they came from. They change only by a deliberate +// edit here, never because a source file changed, and a change makes this +// build refuse every v1.0.11 peer. cmake/OnlineIdentity.cmake reads them into +// the DKR_OWNED_BUILD_ID_V77/V80 compile definitions. +#define DKR_LEGACY_OWNED_BUILD_ID_V77 "5e22da8e6095b362ede17c69a2341aae2df24cbf8561c3af57fc86566d11784e" +#define DKR_LEGACY_OWNED_BUILD_ID_V80 "32c82fea3111440434926d5782e174c9f2d036d198f039c5424393a835f948b0" diff --git a/runtime-recomp/tests/online_identity_tests.py b/runtime-recomp/tests/online_identity_tests.py new file mode 100644 index 00000000..fac9ecc8 --- /dev/null +++ b/runtime-recomp/tests/online_identity_tests.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""The owned rollback contracts stay frozen at the v1.0.11 values: the +configure emits exactly those, the contract doc records them, and no source +hash chain feeds them again.""" + +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +RUNTIME = ROOT / "runtime-recomp" +HEADER = RUNTIME / "src/game/netplay/legacy_identity.hpp" +DOC = ROOT / "docs/contracts/ONLINE_IDENTITY.md" +# What v1.0.11 shipped (docs/fold-in/REFERENCE.md). Changing these is a +# protocol break with every v1.0.11 peer, never a refactor. +SHIPPED = { + "77": "5e22da8e6095b362ede17c69a2341aae2df24cbf8561c3af57fc86566d11784e", + "80": "32c82fea3111440434926d5782e174c9f2d036d198f039c5424393a835f948b0", +} + + +class OnlineIdentityTests(unittest.TestCase): + def test_header_holds_the_shipped_contracts(self): + text = HEADER.read_text(encoding="utf-8") + for revision, value in SHIPPED.items(): + with self.subTest(revision=revision): + found = re.findall(rf'^#define DKR_LEGACY_OWNED_BUILD_ID_V{revision} "([0-9a-f]+)"$', text, re.M) + self.assertEqual(found, [value]) + + @unittest.skipUnless(shutil.which("cmake"), "needs cmake") + def test_configure_emits_the_shipped_contracts(self): + with tempfile.TemporaryDirectory() as scratch: + script = Path(scratch) / "emit.cmake" + script.write_text( + f'set(CMAKE_CURRENT_LIST_DIR "{(RUNTIME / "cmake").as_posix()}")\n' + f'include("{(RUNTIME / "cmake/OnlineIdentity.cmake").as_posix()}")\n' + 'message("V77=${DKR_OWNED_BUILD_ID_V77}")\nmessage("V80=${DKR_OWNED_BUILD_ID_V80}")\n', + encoding="utf-8", + ) + done = subprocess.run(["cmake", "-P", str(script)], capture_output=True, text=True, check=True) + emitted = dict(re.findall(r"^V(77|80)=(\S*)$", done.stderr, re.M)) + self.assertEqual(emitted, SHIPPED) + + def test_contract_doc_matches(self): + text = DOC.read_text(encoding="utf-8") + for revision, value in SHIPPED.items(): + with self.subTest(revision=revision): + self.assertRegex(text, rf"\| `DKR_OWNED_BUILD_ID_V{revision}` \| `{value}` \|") + + def test_no_source_hash_feeds_the_contracts(self): + cmake = (RUNTIME / "CMakeLists.txt").read_text(encoding="utf-8") + self.assertNotIn("string(SHA256 DKR_OWNED_BUILD_ID_V", cmake) + self.assertNotIn("dkr_owned_content_identity(", cmake) + self.assertIn("include(cmake/OnlineIdentity.cmake)", cmake) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index d25ecf79..c5d5dc8e 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -28,9 +28,9 @@ Differences that are expected are listed with their reason and do not fail: the TUs scripts/fold/header-variants.json names (which then need level 3), -the online identity values fold step B7 freezes, the TUs that belong to later -fold steps (dependency patches B3, guest hooks B4), and the entries of -scripts/fold/allowed-deltas.json. Anything else fails. +the TUs scripts/fold/shader-pending.json names, and the entries of +scripts/fold/allowed-deltas.json. Anything else fails, including any change +to the online identity (fold step B7 froze it at the shipped values). Exit status: 0 no unexplained differences, 1 unexplained differences, 2 usage errors. @@ -76,10 +76,6 @@ "bin/Release/libexec/dkr-r/DKR-R-Updater", "bin/Release/libexec/dkr-r/DKR-R-InputHost", ) -# Online identity: fold step B7 replaces these computed values with the -# v1.0.11 constants, so until then they differ by design. -IDENTITY_DEFINES = re.compile(r"^(DKR_OWNED_BUILD_ID(_V\d+)?|DKR_ENGINE_IDENTITY|DKR_LOBBY_STABILITY_CONTRACT)=") -IDENTITY_DEFINE_FLAG = re.compile(r"^-D(DKR_OWNED_BUILD_ID(_V\d+)?|DKR_ENGINE_IDENTITY|DKR_LOBBY_STABILITY_CONTRACT)=") @dataclasses.dataclass(frozen=True) @@ -315,8 +311,8 @@ def compare_targets( continue if ref.language != new.language: yield f"{label}: language {ref.language} -> {new.language}" - ref_defines = {mapping.text(d) for d in ref.defines if not IDENTITY_DEFINES.match(d)} - new_defines = {d for d in new.defines if not IDENTITY_DEFINES.match(d)} + ref_defines = {mapping.text(d) for d in ref.defines} + new_defines = set(new.defines) for define in sorted(ref_defines - new_defines): yield f"{label}: lost define {define}" for define in sorted(new_defines - ref_defines): @@ -688,7 +684,6 @@ def name(path: Path) -> str: QUOTED_PATH = re.compile(r'"(/[^"\n]+)"') -DIGEST = re.compile(r"[0-9a-f]{64}") # Preprocessed tokens for level 2: string and character literals whole (their # spaces are content), everything else split at whitespace. TOKEN = re.compile(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'|[^\s"\']+|["\']') @@ -746,7 +741,6 @@ def run_level2(args: argparse.Namespace) -> int: allowed = load_allowed(ALLOWED) problems: list[str] = [] level3: list[str] = [] - identity_only: list[str] = [] pending = { side: set(json.loads((directory / "pending.json").read_text(encoding="utf-8"))) for side, directory in (("reference", args.reference_pp), ("folded", args.folded_pp)) @@ -760,11 +754,8 @@ def run_level2(args: argparse.Namespace) -> int: label = f"{new_key[0]}: {portable(mapping, new_key[1])}" old_cmd = normalized_command(mapping, reference_entries[old_key]) new_cmd = normalized_command(None, folded_entries[new_key]) - old_flags = [a for a in old_cmd if not IDENTITY_DEFINE_FLAG.match(a)] - new_flags = [a for a in new_cmd if not IDENTITY_DEFINE_FLAG.match(a)] - if old_flags != new_flags: + if old_cmd != new_cmd: problems.append(f"{label}: compile flags {old_cmd} -> {new_cmd}") - identity_changed = old_cmd != new_cmd old_file = args.reference_pp / f"{output_name(old_key)}.i" new_file = args.folded_pp / f"{output_name(new_key)}.i" waiting = [ @@ -788,18 +779,6 @@ def run_level2(args: argparse.Namespace) -> int: if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) in level3_names: level3.append(label) continue - # Fold step B7 freezes the online identity; until then a TU whose - # identity defines changed may differ in those defines' own digests - # only. Any other 64-hex literal is still compared. - if identity_changed: - ids = {d for a in old_cmd + new_cmd if IDENTITY_DEFINE_FLAG.match(a) for d in DIGEST.findall(a)} - - def masked(stream: list[str]) -> list[str]: - return [DIGEST.sub(lambda m: "" if m.group() in ids else m.group(), t) for t in stream] - - if ids and masked(old) == masked(new): - identity_only.append(label) - continue first = next((i for i, (a, b) in enumerate(zip(old, new)) if a != b), min(len(old), len(new))) was, now = " ".join(old[first : first + 8]), " ".join(new[first : first + 8]) problems.append(f"{label}: tokens differ at {first}: {was!r} vs {now!r}") @@ -811,11 +790,9 @@ def masked(stream: list[str]) -> list[str]: print(problem) for label in level3: print(f"level 3 required: {label}") - for label in identity_only: - print(f"identity only (B7): {label}") print( f"level2: {same} TUs token-identical, {len(level3)} header-variant TUs left for level 3, " - f"{len(identity_only)} identity-only (B7), {len(skipped)} waiting for build-time shaders, " + f"{len(skipped)} waiting for build-time shaders, " f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", file=sys.stderr, ) From b27eeae9c8f3bf0bc30a19578d48d1c8b8fa8e0e Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 22:28:50 -0500 Subject: [PATCH 25/62] Fail if any CMake outside OnlineIdentity.cmake writes an owned ID --- runtime-recomp/tests/online_identity_tests.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/runtime-recomp/tests/online_identity_tests.py b/runtime-recomp/tests/online_identity_tests.py index fac9ecc8..744bfef0 100644 --- a/runtime-recomp/tests/online_identity_tests.py +++ b/runtime-recomp/tests/online_identity_tests.py @@ -50,6 +50,26 @@ def test_contract_doc_matches(self): with self.subTest(revision=revision): self.assertRegex(text, rf"\| `DKR_OWNED_BUILD_ID_V{revision}` \| `{value}` \|") + def test_only_online_identity_cmake_writes_the_contracts(self): + # A bare DKR_OWNED_BUILD_ID_V77/V80 word in a CMake command is the + # variable being written (set, string(SHA256 ...), file(SHA256 ...), + # list, ...); reads are ${...} and compile definitions are NAME=. + # Only OnlineIdentity.cmake may write them, so no glob or file hash + # can feed them again. Clearing v80 without the Rev A engine is the + # one exception. + written = re.compile(r"(? Date: Sun, 25 Oct 2026 22:41:34 -0500 Subject: [PATCH 26/62] Build the folded tree on Windows and Android in CI windows-full-nogen and android-folded build RT64 with the stage patches and the game TUs. prepare-dependencies gains sparse decomp symbols and an android-full profile. Neither job has run yet. --- .../actions/prepare-dependencies/action.yml | 32 ++++++- .github/workflows/build-android.yml | 50 +++++++++++ .github/workflows/build-windows.yml | 89 +++++++++++++++++++ docs/fold-in/open-items.md | 38 ++++++-- 4 files changed, 202 insertions(+), 7 deletions(-) diff --git a/.github/actions/prepare-dependencies/action.yml b/.github/actions/prepare-dependencies/action.yml index f05984be..a0a40bd4 100644 --- a/.github/actions/prepare-dependencies/action.yml +++ b/.github/actions/prepare-dependencies/action.yml @@ -2,8 +2,11 @@ name: Prepare reviewed dependencies description: Cache pinned source checkouts and verify them before every build inputs: profile: - description: Dependency set (host, ios, or android) + description: Dependency set (host, ios, android, or android-full for the whole runtime) default: host + decomp-symbols: + description: Also check out extern/dkr-decomp/ver/symbols (sparse), which scripts/gen_guest_symbols.py reads + default: "false" runs: using: composite steps: @@ -11,7 +14,7 @@ runs: id: sources uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - key: sources-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.profile }}-${{ hashFiles('dependencies.lock.json', 'patches/**', 'scripts/bootstrap_dependencies.py', 'scripts/apply_dependency_patches.py', '.github/actions/prepare-dependencies/action.yml') }} + key: sources-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.profile }}-symbols-${{ inputs.decomp-symbols }}-${{ hashFiles('dependencies.lock.json', 'patches/**', 'scripts/bootstrap_dependencies.py', 'scripts/apply_dependency_patches.py', '.github/actions/prepare-dependencies/action.yml') }} path: | extern/n64-modern-runtime extern/rt64 @@ -21,6 +24,7 @@ runs: extern/mbedtls extern/sdl3 extern/sdl2 + extern/dkr-decomp - name: Verify pinned sources and apply reviewed patches shell: bash env: @@ -31,6 +35,7 @@ runs: host) dependencies=(n64-modern-runtime rt64 gekkonet monocypher libdatachannel mbedtls sdl3) ;; ios) dependencies=(rt64 sdl2) ;; android) dependencies=(n64-modern-runtime rt64 sdl2) ;; + android-full) dependencies=(n64-modern-runtime rt64 gekkonet monocypher libdatachannel mbedtls sdl2) ;; *) echo "Unknown dependency profile: $DEPENDENCY_PROFILE" >&2 exit 1 @@ -42,6 +47,28 @@ runs: done "$PYTHON_EXECUTABLE" scripts/bootstrap_dependencies.py --shallow "${arguments[@]}" "$PYTHON_EXECUTABLE" scripts/apply_dependency_patches.py + # The guest symbol headers need the decomp's symbol tables only, not the + # 563 MB tree: a sparse, blob-filtered checkout of ver/symbols at the + # locked commit. dkr-decomp carries no reviewed patches. + - name: Check out the decomp symbol tables + if: inputs.decomp-symbols == 'true' + shell: bash + env: + PYTHON_EXECUTABLE: ${{ runner.os == 'Windows' && 'python' || 'python3' }} + run: | + read -r repository commit < <("$PYTHON_EXECUTABLE" -c "import json; e = next(d for d in json.load(open('dependencies.lock.json'))['dependencies'] if d['name'] == 'dkr-decomp'); print(e['repository'], e['commit'])") + if [[ ! -d extern/dkr-decomp/.git ]]; then + git init --quiet extern/dkr-decomp + git -C extern/dkr-decomp remote add origin "$repository" + git -C extern/dkr-decomp sparse-checkout set ver/symbols + fi + if [[ "$(git -C extern/dkr-decomp rev-parse --verify --quiet HEAD || true)" != "$commit" ]]; then + git -C extern/dkr-decomp fetch --quiet --depth 1 --filter=blob:none origin "$commit" + git -C extern/dkr-decomp checkout --quiet --detach "$commit" + fi + test "$(git -C extern/dkr-decomp rev-parse HEAD)" = "$commit" + test -f extern/dkr-decomp/ver/symbols/symbol_addrs.us.v77.txt + test -f extern/dkr-decomp/ver/symbols/symbol_addrs.us.v80.txt # Save before configuration: the cache contains reviewed source checkouts, # never build outputs, ROMs, generated retail code, or local game data. - name: Save verified dependency sources @@ -58,3 +85,4 @@ runs: extern/mbedtls extern/sdl3 extern/sdl2 + extern/dkr-decomp diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 443f8f7e..cb36e091 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -55,3 +55,53 @@ jobs: build/android-compile/compile_commands.json build/android-compile/device-objects.json build/android-compile/CMakeFiles/CMakeConfigureLog.yaml + # The folded runtime for Android: RT64 with the Android stage chain and the + # stage patches on top, and every first-party game TU (DKRPortGameCore), + # without ROM-derived payloads. Fold PR-2 merge blocker + # (docs/fold-in/open-items.md). + android-folded: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + defaults: + run: + shell: bash + env: + NDK: ndk/29.0.14206865 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/prepare-dependencies # zizmor: ignore[self-repository] -- actionlint 1.7.12 compatibility; checkout uses this workflow revision. + with: + profile: android-full + decomp-symbols: "true" + - name: Install exact device toolchain + run: | + if [[ ! -f "$ANDROID_HOME/$NDK/source.properties" ]]; then + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" 'ndk;29.0.14206865' + fi + - name: Build the host file_to_c the Android renderer embeds shaders with + run: | + mkdir -p build/android-host-tools + c++ -std=c++17 -O2 -o build/android-host-tools/file_to_c extern/rt64/src/tools/file_to_c/file_to_c.cpp + - name: Configure the folded runtime (RT64 on, no generated payload) + run: | + cmake -S runtime-recomp -B build/android-folded -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_HOME/$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DANDROID_STL=c++_shared \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DDKR_RUNTIME_BUILD_GENERATED=OFF -DDKR_RUNTIME_BUILD_RT64=ON \ + -DDKR_ANDROID_SDL2_SOURCE="$GITHUB_WORKSPACE/extern/sdl2" \ + -DDKR_HOST_FILE_TO_C="$GITHUB_WORKSPACE/build/android-host-tools/file_to_c" + - name: Build RT64, the staged dependency sources and the game TUs + run: cmake --build build/android-folded --parallel 4 + - name: Preserve failure diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: android-folded + retention-days: 7 + if-no-files-found: ignore + path: | + build/android-folded/CMakeFiles/CMakeConfigureLog.yaml + build/android-folded/compile_commands.json diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index c7223ec6..58feced0 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -122,3 +122,92 @@ jobs: build/host/CMakeFiles/CMakeOutput.log build/host/Testing/Temporary/LastTest.log build/host/Testing/Temporary/LastTestsFailed.log + # The folded tree under MSVC: the host-full-nogen preset builds RT64 with the + # stage chain and stage patches, and every first-party game TU + # (DKRPortGameCore), without ROM-derived payloads. Fold PR-2 merge blocker + # (docs/fold-in/open-items.md). + windows-full-nogen: + runs-on: windows-2022 + timeout-minutes: 90 + env: + SCCACHE_DIR: ${{ github.workspace }}/build/sccache + SCCACHE_CACHE_SIZE: 2G + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The configure runs guest_hooks.py, gen_guest_symbols.py and + # stage_magic_code_payload.py, which need tomllib. + - name: Require Python 3.11 or newer + run: python -c "import sys; assert sys.version_info >= (3, 11), sys.version" + - name: Prepare pinned runtime dependencies + uses: ./.github/actions/prepare-dependencies # zizmor: ignore[self-repository] -- actionlint 1.7.12 compatibility; checkout uses this workflow revision. + with: + decomp-symbols: "true" + - name: Install pinned compiler cache + uses: mozilla-actions/sccache-action@fd02668681acd5f960e1372061bee5e3e987195c # v0.0.11 + with: + version: v0.18.0 + disable_annotations: true + - name: Identify MSVC and SDK + id: compiler + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installation = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (!$installation) { throw 'Visual Studio C++ tools are missing' } + "VS_INSTALLATION=$installation" >> $env:GITHUB_ENV + & "$installation\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch amd64 -SkipAutomaticLocation + if (!$?) { throw 'Could not initialize the x64 MSVC environment' } + New-Item -ItemType Directory -Force build | Out-Null + $compiler = (Get-Command cl.exe).Source + @( + "MSVC: $((Get-Item $compiler).VersionInfo.FileVersion)" + "Compiler SHA256: $((Get-FileHash $compiler -Algorithm SHA256).Hash)" + "Windows SDK: $env:WindowsSDKVersion" + "sccache: 0.18.0" + ) | Set-Content build/compiler-toolchain.txt + $fingerprint = (Get-FileHash build/compiler-toolchain.txt -Algorithm SHA256).Hash.ToLowerInvariant() + "fingerprint=$fingerprint" >> $env:GITHUB_OUTPUT + - name: Restore compiler cache + id: objects + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: build/sccache + key: sccache-v1-windows-2022-full-nogen-${{ steps.compiler.outputs.fingerprint }}-${{ github.sha }} + restore-keys: sccache-v1-windows-2022-full-nogen-${{ steps.compiler.outputs.fingerprint }}- + - name: Configure (RT64 on, no generated payload) + working-directory: runtime-recomp + run: | + & "$env:VS_INSTALLATION\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch amd64 -SkipAutomaticLocation + if (!$?) { throw 'Could not initialize the x64 MSVC environment' } + sccache --zero-stats + cmake --preset host-full-nogen -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + - name: Build RT64, the staged dependency sources and the game TUs + working-directory: runtime-recomp + run: | + & "$env:VS_INSTALLATION\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch amd64 -SkipAutomaticLocation + if (!$?) { throw 'Could not initialize the x64 MSVC environment' } + cmake --build --preset host-full-nogen --parallel $env:NUMBER_OF_PROCESSORS + - name: Report compiler cache statistics + if: ${{ always() && steps.compiler.outcome == 'success' }} + run: sccache --stop-server + - name: Save compiler cache + if: ${{ success() && steps.objects.outputs.cache-hit != 'true' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: build/sccache + key: ${{ steps.objects.outputs.cache-primary-key }} + - name: Preserve failure diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: windows-full-nogen + retention-days: 7 + if-no-files-found: ignore + path: | + build/compiler-toolchain.txt + build/host-full-nogen/CMakeFiles/CMakeConfigureLog.yaml + build/host-full-nogen/compile_commands.json diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 6921112c..493639ed 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -36,11 +36,39 @@ The gate is Linux x86_64 only. These close only when the `windows` and first) and the `DKRPortGameCore` object library inside the Android `SHARED` `DKRPortGame`. -Today neither job does that. `build-windows.yml` configures the `host` -preset (no RT64, so no stage patches and no game TUs), and -`build-android.yml` compiles `runtime-recomp/tests/android_compile` only. -They need a `host-full-nogen`-style Windows configure and an Android -configure of `runtime-recomp/` before they can close these. +The jobs that close them are `windows-full-nogen` (`build-windows.yml`: the +`host-full-nogen` preset under MSVC) and `android-folded` (`build-android.yml`: +an NDK r29 arm64 configure and build of `runtime-recomp/` with RT64 on). Both +build RT64 with its stage chain and stage patches and every `DKRPortGameCore` +TU, without payloads. They need the sparse `extern/dkr-decomp/ver/symbols` +checkout (`prepare-dependencies` `decomp-symbols`) and, on Android, the new +`android-full` dependency profile. actionlint and zizmor are clean locally. +**Neither job has run yet**; they first run when PR 2 opens. + +Known before they run: + +- `android-folded` will fail until the libc++ gaps are fixed. A local run in + the CI image (NDK r29, same flags) configures cleanly and applies every + stage patch the Android chain takes. Everything else compiles, but 7 + first-party TUs don't: + - `std::atomic>`, which libc++ lacks (the B6 Apple gap): + `online_directory.cpp`, `vehicle_binding.cpp`, `achievements.cpp`, + `mod_store_install.cpp`; + - ambiguous `std::to_string` calls: `mod_store_names.cpp`, + `mod_store_install.cpp`, `texture_dump.cpp` (`texture_gallery.inl`); + - `curl/curl.h` in `mod_store.cpp` (the store links CURL only off Android, + Apple and Windows, but still compiles there). + v1.0.11 never compiled these for Android or Apple, so they fail closed. + The fixes are platform-guarded source changes that leave the Linux level 2 + stream unchanged, which is B6/C12 work. +- Android doesn't configure `DKR_EXPERIMENTAL_RACE_TEST`, so it doesn't take + `0001-render-stability` and `0003-present-queue-pacer` (they sit on the + rollback chain). Android releases are discontinued, so that stays open. +- `windows-full-nogen` assumes the runner's `python` is 3.11 or newer; a + first step checks it. If `windows-2022` defaults to an older Python, it + needs a pinned `actions/setup-python`. +- Adding `decomp-symbols` to the source cache key invalidates the existing + dependency caches once. ## Open questions From b20599333a14661b60ae59a04c9c3aebb4a4ce16 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 22:47:55 -0500 Subject: [PATCH 27/62] Record fold evidence, add the ROM-free closure check (B5) run_equivalence.sh writes fold-equivalence.json for signing; level 3 fails if a header-variant TU goes uncompared; the fold-equivalence workflow checks closure.json. --- .github/workflows/fold-equivalence.yml | 27 + docs/fold-in/REFERENCE.md | 27 + scripts/fold/closure.json | 715 +++++++++++++++++++++++++ scripts/fold/equivalence.py | 136 ++++- scripts/fold/run_equivalence.sh | 19 +- 5 files changed, 917 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/fold-equivalence.yml create mode 100644 scripts/fold/closure.json diff --git a/.github/workflows/fold-equivalence.yml b/.github/workflows/fold-equivalence.yml new file mode 100644 index 00000000..8cdeebab --- /dev/null +++ b/.github/workflows/fold-equivalence.yml @@ -0,0 +1,27 @@ +name: Fold equivalence +# The ROM-free subset of the fold gate's level 1: every first-party file the +# shipped v1.0.11 binaries compile still holds its folded bytes (whitespace +# aside). Levels 0-3 need ROM-derived payloads and run on the devops machine +# (scripts/fold/run_equivalence.sh), whose signed fold-equivalence.json goes +# on the PR. Required on the fold branch and fold-in-base only; retired with +# scripts/fold/ after the first release from the folded tree. +on: + push: + branches: [refactor/patch-fold-in] + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + fold-equivalence: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: First-party closure matches v1.0.11 + run: python3 scripts/fold/equivalence.py closure --check diff --git a/docs/fold-in/REFERENCE.md b/docs/fold-in/REFERENCE.md index 2449ff43..8e6df38f 100644 --- a/docs/fold-in/REFERENCE.md +++ b/docs/fold-in/REFERENCE.md @@ -49,3 +49,30 @@ run at configure time, like the existing code-mod dispatch staging. The plan had build-time `add_custom_command`s; keep them at configure time. The gate's header scan and preprocessing read the configured tree without building it, so build-time outputs would not exist when it compares them. + +## Evidence and signing (B5) + +`scripts/fold/run_equivalence.sh` ends by writing +`build/fold-equivalence/equivalence/fold-equivalence.json`. It records the +commit, whether the work tree was clean, the image ID, this page's reference +sha256 table, the sha256 of every input the gate trusts (allowed deltas, +header variants, shader-pending list, closure record, patch manifest, guest +hooks, online identity), and each level's counts, allowed entries and +unexplained differences. The script exits non-zero unless every level passed +on a clean tree. + +Steve signs it on the devops machine and attaches both files to the PR: + + ssh-keygen -Y sign -n fold-equivalence -f ~/.ssh/id_ed25519 fold-equivalence.json + +Reviewers verify with the allowed-signers entry for Steve's key: + + ssh-keygen -Y verify -n fold-equivalence -f allowed_signers -I \ + -s fold-equivalence.json.sig < fold-equivalence.json + +CI can't run levels 0-3 (they need ROM-derived payloads). The +`fold-equivalence` workflow runs the ROM-free part of level 1 instead: +`equivalence.py closure --check` compares every first-party closure file with +`scripts/fold/closure.json`, whitespace aside so the format-only commit (B9) +keeps it green. `closure --reference DIR --write` re-records it, and only a +gate run on the reference may justify that. diff --git a/scripts/fold/closure.json b/scripts/fold/closure.json new file mode 100644 index 00000000..5402b6ce --- /dev/null +++ b/scripts/fold/closure.json @@ -0,0 +1,715 @@ +{ + "about": "Whitespace-insensitive sha256 (scripts/fold/equivalence.py closure_digest) of every first-party file the shipped v1.0.11 binaries compile, as folded. Written from the reference with `equivalence.py closure --reference DIR --write`; CI runs `closure --check`.", + "files": { + "runtime-recomp/include/dkr/presentation.h": "f3980c0004d2bb1a6d4d66cd5302fa74f04f5ce77036e9fd54948ff9ebe97ae6", + "runtime-recomp/src/game/account_bind.inl": "913fd434ca0912040730d38ae455592e9ad0afa7634e9668eac2ba0f3ffc5c5b", + "runtime-recomp/src/game/achievement_catalogue.cpp": "e57ba84610ed741bd1c2d9701acca15e465922155a55975d17c1ec342aefa57c", + "runtime-recomp/src/game/achievement_ui.inl": "f0f63a69e35859b797e9d7671f2631402e749785bbd7cb9e31268ae69e77d80e", + "runtime-recomp/src/game/achievements.cpp": "f441da67b7f6db08f90b24de7e2f0fe81f9fa9a792ed89fba0b2a8f1f204917e", + "runtime-recomp/src/game/achievements.hpp": "bf6ae13f00a274f93bd0cde9cd1b5ad90d2d76faf69ec8c7e3fc6bc6681224ad", + "runtime-recomp/src/game/admission_contract.hpp": "ee0936aff35b41da9402a50a3201e16cbb7773a67d96c3352e4668963fc11d37", + "runtime-recomp/src/game/admission_retry.hpp": "16c6ea317714ea4bffd9453f55d7d7af85207d30836db3b8887f87da79e1f6d7", + "runtime-recomp/src/game/application_layout.hpp": "d94ec74847792649cd99d87960915231070cd40d2dbdbe146c69477b7eb269d4", + "runtime-recomp/src/game/assessment.hpp": "de234dad97ba040a8ba8282e6cafe9ae5b90f6decca69ade4e8485bdc6f4acf4", + "runtime-recomp/src/game/atomic_snapshot.hpp": "6a2b40abc1de08f64ee53182bb1d29e016244029d751a9c6b55d998e9633a2c8", + "runtime-recomp/src/game/audio_equalizer.cpp": "012f7666d6030c643a6b073ba070288aca9432b9c8b8da0fdf38111f878af831", + "runtime-recomp/src/game/audio_equalizer.hpp": "a615f0a4214fe0c91e5ec8fedd064517fae20f91111ebf380109fed8686330ec", + "runtime-recomp/src/game/audio_event_queue.h": "1d5351850f2a50955a111041575aae4c89fa70e74558e287c2c524bd99afb9d2", + "runtime-recomp/src/game/audio_event_queue_state.h": "3c9143e729393522c2c5fda7447db595aa7881fd50cdc615dbd234e503b54e17", + "runtime-recomp/src/game/audio_mix_policy.hpp": "2c384691c328f843292af9569a0eee85fcc81188f4c00f717ccdbe7e505b74bd", + "runtime-recomp/src/game/authored_state_contract.hpp": "d60a1c77c1e7f332d6d4c9d94774e1a942a35c9b90a98e4b9e27894cbb4fcadd", + "runtime-recomp/src/game/build_contract.hpp": "8c3735e6ad3ac38febab8f3c0058d75db01f0de895b67852bb074861618afb5a", + "runtime-recomp/src/game/cache_repair.hpp": "cdb3ebb3ba1b3a660069daae827fbe119f41d01db5008e95b03fbfe8aa781d9d", + "runtime-recomp/src/game/camera_actions.cpp": "32f4df0d6800fae81e730437b0143a1a019c12885ac39c8c69d126eb04c6f8b6", + "runtime-recomp/src/game/camera_actions.hpp": "1d39ed9640a20c09f6f08afc1fa5b5f2e993e8f612cea0cfc91cfa786f978299", + "runtime-recomp/src/game/camera_clearance_metadata.h": "e291ee7d6356d5486b5efbd4392ac514e20cef9501186b8d2303f91bf1c38623", + "runtime-recomp/src/game/camera_clearance_policy.hpp": "052231c929319ba3a79b0cf45ffb6394fe1b13f8801e70fe671bab361d78d218", + "runtime-recomp/src/game/camera_kernel.c": "2f3ef714773e3da1c50fdbbc2eecc307c51f2957044f89975bce64bfceb64171", + "runtime-recomp/src/game/camera_kernel.h": "e0d55cbca20a531a1b1f2c6b2066296933847a11f5237ad8a92c2a98c5fe68dc", + "runtime-recomp/src/game/camera_kernel_guest.h": "179d3bb07dce11a8b9d80efbf13775106cf9eed7072c837d607a12d5c205c225", + "runtime-recomp/src/game/camera_obstruction.cpp": "e164354c49df43643f5b6991a700bf4ad1f8d3705e2ca5ca5dd11a77af6cf97e", + "runtime-recomp/src/game/camera_obstruction_guest.h": "a73f45a3f4c30760be1d6804442d14d1199d3e2c67bacb1ce914a90f8b357ce1", + "runtime-recomp/src/game/camera_obstruction_math.h": "3243a4ff2ad637660af8552a8d4d1edcdf4eb3d932b85078d019c3ecb9608874", + "runtime-recomp/src/game/camera_presentation.cpp": "2283953d26561463e2df63bf62fce9a0005b44b6900b346173e337a01a0c1698", + "runtime-recomp/src/game/camera_presentation.hpp": "b1121a0caf0eb14c8f8b4836da8ff4fbbfc562679eb259bb5f8193aefa4489a2", + "runtime-recomp/src/game/camera_rt64.cpp": "f96e6d3cb04003acbd4868cb1003e597a0edb2986c68995b92e59977f61162aa", + "runtime-recomp/src/game/camera_rt64.hpp": "637a391625b53b76cab0e0d82f1d4296c9d79fb21aef38ae7508b946f575d089", + "runtime-recomp/src/game/camera_runtime.cpp": "eb82e6eb6d8e0113eb7db0e1aa4733a41f90e9b0d29b301dab70e603d6ea95f0", + "runtime-recomp/src/game/camera_runtime.hpp": "17d1cb714a2ddabf305af01ff36f4bbdceaa6f46831230c7254b4a49c12d9eaf", + "runtime-recomp/src/game/camera_scene.cpp": "0a8a289f6c632df925f1a45d00506c2403fa111b5ad24a1b5657b143c39a663e", + "runtime-recomp/src/game/camera_scene.hpp": "1fd06fb61805c36d1acb18f8fac05d8ae53063926834ec75db5f0db33bdb6458", + "runtime-recomp/src/game/camera_session.cpp": "b7864c7de66dba7100f3ebb93b1610d3e5efdf764b04949f0edc345d76abec97", + "runtime-recomp/src/game/camera_session.hpp": "67a45b4d3686fa14a25098529ce636e257fbc62dde3e1c7d9be4d6f6d5c99ef9", + "runtime-recomp/src/game/camera_settings.cpp": "8b4fe1e87fe4acb0eba30eee4338e886072cdaa0a832952a202e34cab5e89c26", + "runtime-recomp/src/game/camera_settings.hpp": "57010043a81b886bd1c4fae7a3aa0228f24d0c4d7030641a94b0f8005441c18c", + "runtime-recomp/src/game/camera_shadow_kernel.c": "8b969bc3726ee8aa12c7cbfd3964e4f1b29502ff423af81a5fb124354a0d8b37", + "runtime-recomp/src/game/camera_shadow_kernel.h": "2af3dfd9aded4c0eae43925b19904dd7338f17b7fb54374d8691c9ac0ee85120", + "runtime-recomp/src/game/camera_snapshot.cpp": "97b825a09d9d4a0fc78b8bd641045232ff6154379ed03714c84298b0a4457877", + "runtime-recomp/src/game/camera_snapshot.hpp": "6492e7c2d13222d648b10bda3d3d3059fda7d627d9195013197a96594aca7ba8", + "runtime-recomp/src/game/character_select_animation_policy.hpp": "a1ff8649c4b95455590bb8bc6512f6e812010748596a3ba27cf12444a485d3b7", + "runtime-recomp/src/game/character_select_music_policy.hpp": "087e396f66f35754101f7c6d0192d962b71a950b58febab3c187cbe0be50ce8b", + "runtime-recomp/src/game/checkpoint_transfer.cpp": "64ff854a8e380e85de795a103e76961f71f230660949db93b116a97811ead7cf", + "runtime-recomp/src/game/checkpoint_transfer.hpp": "046e2bed3adb394410578852e345cbadd190c7b2058e6f596a9c680188d8ce38", + "runtime-recomp/src/game/confirmed_feed.cpp": "5adfad2f5e5c8bbc8cad0f5598b9ecb6c962c4273c667b0d2f2e079ddb6cf771", + "runtime-recomp/src/game/confirmed_feed.hpp": "29bbd1cab43c6efd8db09caf9b5a0a2c70aafa7341f4228f3113444c31b8f501", + "runtime-recomp/src/game/confirmed_observer.cpp": "4468e21ae034511ccabfbc00ec0873db1c357e8229abdc9a0fb24ca79b16ffbf", + "runtime-recomp/src/game/confirmed_observer.hpp": "29e4940f33ce834ab474292f3a72c6c356009755be54302392dd3b2e0b08cc47", + "runtime-recomp/src/game/connection_ui.inl": "799f0037fd3734ed80d4122c69fcf45e88a3fc8c85361f905c2bd02662aecffe", + "runtime-recomp/src/game/controller_assignment_policy.hpp": "d64b83c83dd17db23d4b91bb5fb035ad68f27fb345cf9a4097a2fbed5874fb89", + "runtime-recomp/src/game/controller_mapping_policy.hpp": "d9b10b15f948aeec9396e1f203e17f4f90dbe55c5bd575ec58bdcd2f1734e551", + "runtime-recomp/src/game/controller_snapshot.hpp": "a8423dc380caeaec502e585385d32071856015c62d0bead386190abf28c73f1a", + "runtime-recomp/src/game/countdown_tone_policy.hpp": "d49b31432c6f64a5b2567949c57c3c867787c9a8f86075f144f14caafc09d766", + "runtime-recomp/src/game/credits_sky_rt64.hpp": "17b567614ceab2d6917382bc78d5a27cb6c691f4e85cbd33fa42b75f0a519c08", + "runtime-recomp/src/game/credits_viewport_rt64.hpp": "b6f768872f80e024697ab16fb85bdac9afe205f81640773e21632f46616634b7", + "runtime-recomp/src/game/custom_music.cpp": "b47a0fd823d7387171d6e09e721cfe24726ddef016c6c73f1a877125fe1ad7ab", + "runtime-recomp/src/game/custom_music.hpp": "ada43f99f1f7a4b58e37686f0d34ac1c19d77ee6da56e67cee737c85d70db46f", + "runtime-recomp/src/game/custom_music_decode.cpp": "41abba6123b2920500b151962e3a1affdf40ff8ba964e1be5b51452a622ebfe4", + "runtime-recomp/src/game/custom_music_decode.hpp": "61ce5c5994bbcfa6fdb6a5ae1022ef8349d99f9d50b10d63bec8c1c693da0667", + "runtime-recomp/src/game/custom_music_policy.hpp": "a6f6556a3f29f3f6125abfbfef44c6928ebca092ee0dc45dc356d89ade659f5f", + "runtime-recomp/src/game/custom_music_sequence.cpp": "e81e87bfd95cebc302417910cf2ae8517ff12134793869279d36824837fea2d5", + "runtime-recomp/src/game/custom_music_sequence.hpp": "ee78da813f5b690775876ba6dfcd94e7b47575e72b274ecdf71b9062f2e0a465", + "runtime-recomp/src/game/custom_records.hpp": "5115a5b56cfb9b1f09504053d02ceea5ceed7b42f98397f9cddcd3f5f70b9793", + "runtime-recomp/src/game/custom_track_files.hpp": "8cff033a92389fe5e79ec39d51543f7dc71de963c1973966067a5e74479be181", + "runtime-recomp/src/game/custom_tracks.cpp": "9b4f62e2101338f53f6c12482c9b7e1dd10e6b49250fe39f355e782410c3befe", + "runtime-recomp/src/game/custom_tracks.hpp": "d70dc0f22c2516d971a815e0f0d1a4e5a5b1f01b3a6cd37423dc6c1641f1f5fe", + "runtime-recomp/src/game/custom_tracks_hooks.cpp": "1e1ade5f4a4c8fed72dcf531b7c5687f7c65169dc6e915533388725ad1d149f1", + "runtime-recomp/src/game/deferred_event_queue.hpp": "f545877de7848cabb81e531f54951680d377f5bfa4af9d647b73556542d1f536", + "runtime-recomp/src/game/departure.hpp": "866cacfa77ef4071af6ef5976acccce05a69a1b82f17aa951439935938bbbcc9", + "runtime-recomp/src/game/detail_batch.inl": "7eaa25c635e88f1e86c04d6714aa2d45c15a6d9d28aca2ff883dd2f5759bf1d8", + "runtime-recomp/src/game/detail_contract.hpp": "52bc38c397539c0dd9ecbf24e99c9c7ce0e8a71b961dcdda2ee641800881e931", + "runtime-recomp/src/game/detail_metrics.hpp": "842387f723b2292d6a1ff1bb950035ff539cdd85112d56b91e37eeb562587bc4", + "runtime-recomp/src/game/detail_observer.inl": "c13fda018ad4f1d71c9cb9f2522662edaca94a3f3be9e2de82a95327aea1a4bf", + "runtime-recomp/src/game/detail_texture_cache.inl": "4b4d63db70d0e91e3411ca589ce6e4dcca790f4828db057ff6702cf83178d58e", + "runtime-recomp/src/game/determinism_hash_policy.hpp": "68a00d03ab833ded2516f01fede925bd37c1bee145c85bfee3ed0defca707ac9", + "runtime-recomp/src/game/determinism_state_hash.cpp": "5020e6b0a6212c440510ebe44e627a1cf65e09d30711db8d0127f2f2c6f2f57e", + "runtime-recomp/src/game/determinism_state_hash.hpp": "fa2f4d2e6f01b2225fced75052fc06bd4c5e4c8d40fe7332d87cb362b8d16aa5", + "runtime-recomp/src/game/diagnostics.hpp": "5b66e11a68b773b5f34bca746333cb203d0f17a281ed460d89367e36b49d543b", + "runtime-recomp/src/game/directory_tls.hpp": "dda699879e796653eb268f9d372329f7ad874957cd90421457dd32721f741641", + "runtime-recomp/src/game/directory_ui.inl": "4df41344635a7a3c7079f8ef294228bee8db1e68fb1196349ce05f41fb812bc5", + "runtime-recomp/src/game/dkr_save_codec.cpp": "d3388c79c496ea2d47e5fde145a710a4d349addb738aaad9b0b51435356c0ac3", + "runtime-recomp/src/game/dkr_save_codec.hpp": "65575c1446984508cd102e27ad8e0291c19be9863f728c69e74d4185d5ebdbf5", + "runtime-recomp/src/game/driver_anchor_units.hpp": "85fbc5c43ef7e356653b34eee6da0f726d372bcc53cbcbc59fdc0c4b1d22d046", + "runtime-recomp/src/game/enhanced_water_metadata.hpp": "e57872f90d595cf41c7ec72e7702f31e57870b5ccd0dc6ef3bfc0170dc9a5e4e", + "runtime-recomp/src/game/enhanced_water_policy.hpp": "1f799cbf1b666096a5fadc44d729fe21d4af7dbfc524602156f34a79ffdaf058", + "runtime-recomp/src/game/enhanced_water_ray_geometry.hpp": "03e5cefff4690c47807da10fbeae7d6e178d824c3fcfa750914b9de0d2b1e910", + "runtime-recomp/src/game/enhanced_water_registry.hpp": "cafcd01237a445e187e28801e51cbd5bd875178e2d519712e47b1a1ef684baf7", + "runtime-recomp/src/game/enhanced_water_rt64.hpp": "96bf1c755f5a50e5dd303dffbe82e714436bc27c5aca6b3cb2dc879f9a020146", + "runtime-recomp/src/game/enhanced_water_settings.hpp": "ab60a300c94223d8928aa5ed75c700ca81fe0d50a7c1cbcf286a7b3e9f821b45", + "runtime-recomp/src/game/f3ddkr_execution_policy.hpp": "5843e71844757602e43a8ccef829787a31c902980606ca93ee5471cc203ccc5e", + "runtime-recomp/src/game/f3ddkr_rt64.cpp": "ef6f4a13f57e8eb99b7fb8f64f08d5cd6f9270b0ce83c35d00707497511aaf1a", + "runtime-recomp/src/game/f3ddkr_rt64.hpp": "de49c8d26dc78d5901b3c1e4db6912a0f9148cc8f022056ebb4634b7759a4f17", + "runtime-recomp/src/game/finish_presentation_policy.hpp": "32c6bbdd374afc1922c1274114ceb2e7efaee6e02a96a92000c6b5051938af87", + "runtime-recomp/src/game/flow_cadence_rt64.hpp": "5589def3a57d845eadd34f322377d7d7c64dd8306c3d8f01ebb81305e85ac6a0", + "runtime-recomp/src/game/flow_history_rt64.hpp": "205b2fc72a32f2f81b749e0f2f2fd6efdd1752d5f26ed470e721043fb7676c0b", + "runtime-recomp/src/game/flow_phase_rt64.hpp": "a80057b7ea96958a9591cce4486b1b6b83a4467cf866eeb5f2012f20083c480c", + "runtime-recomp/src/game/flow_vertex_scope_rt64.hpp": "df0e1fba964a8917d24a6ffb6f599f4e249143c1c179ba569438a9b2d2ba900b", + "runtime-recomp/src/game/flowing_water_mapping.hpp": "d7fae5d27eabe59dbad8f99b6afb9c95c1da1d2c6edcfc5ee81636574f3f3a02", + "runtime-recomp/src/game/game_main.cpp": "0e6cf1acdfe01d72569bb47c9eccb412566afd801c1fc5038643ebc4c5b00b6f", + "runtime-recomp/src/game/game_payload.cpp": "6dcd698eed97d907452e590a46e5f52a8d962d4de576d0695eae1e2b82407807", + "runtime-recomp/src/game/game_payload.hpp": "1bf07d60471c74ebcefac4df8bfe4eba2acb2534a062635dc544f93df9d622dc", + "runtime-recomp/src/game/game_payload_v77.cpp": "c25b41ff01acb28cf2c49c2b0e4917daa4a93acaa7511839a4452607164a176d", + "runtime-recomp/src/game/game_payload_v80.cpp": "0b437acef3cf3d64dc1c6a0bb6c6386daae2614eda454d32211528dca3bb6c9f", + "runtime-recomp/src/game/game_registration.cpp": "acedab83d7c4630f28dc3142b29d0d5ed73a43a599869a4f42cb6e3a454c7c13", + "runtime-recomp/src/game/game_registration.hpp": "4df51b90eec8c9ca1cd248ab6bdfd3d16c9d6dbacf6b3eb8a8d30a2a3f22c7bd", + "runtime-recomp/src/game/generated/jumpman_font.c": "ce2db22b100c429f42f5fc80560328b5fed80331a122e333c29aa94d35879562", + "runtime-recomp/src/game/generated/jumpman_font.h": "42d066950b101f6f81fe9010e603f3e69e9c4e6fbaf9ba6f142ad77842e8f57d", + "runtime-recomp/src/game/generated/racing_banana_font.c": "2125f60940c355d572f67ca1a6c6c2253d4fe86d805e57069af5ce38c26a7a90", + "runtime-recomp/src/game/generated/racing_banana_font.h": "4ee4df44717d280c032d4984c7e617540049ee529550647b18d383be533e4603", + "runtime-recomp/src/game/generated/selawik_font.c": "9eb2027932f7f32c2074f51eb47092ee4e7d18b36563e0cb09cd1637ef846164", + "runtime-recomp/src/game/generated/selawik_font.h": "ac894755cf8172a67eafc91f0477a78b06acd0f693261f13f85dcdc6be7029bb", + "runtime-recomp/src/game/graphics_health.hpp": "6d3f458a7ff39e2a830880294a1f81c10de02d15c5dd02565b65810fa752dd64", + "runtime-recomp/src/game/graphics_snapshot_pool.hpp": "93dcf51313938523f96c99818715934cdbc6e40ef47793344de2e07ed62669b6", + "runtime-recomp/src/game/host_task_lifetime.hpp": "d8b4d74e3a48776cb3d85ed9924c6e0f1884ca44e61ecd98c204f3e190cb68c2", + "runtime-recomp/src/game/hud_basic_settings.cpp": "bc3115418cfa671e9dc6d684313655d825a13cd4336dcdaeb8ddfcd596af5335", + "runtime-recomp/src/game/hud_basic_settings.hpp": "fe7937b02da7344ba0184df982a8de872c9df95c9c578d054467216255b6b657", + "runtime-recomp/src/game/hud_group_layout.hpp": "a2cc8b9cf53ce59cd45578b694a26e25359c3f2a7b9dd117bd34d648cdab0935", + "runtime-recomp/src/game/hud_group_rt64.hpp": "a2715eb70ec2c39adfc1b99e55e622473b24889387c99af76399a26fae9b0357", + "runtime-recomp/src/game/hud_layout_config.hpp": "0639fbab89b4e42665e976f855c499a33453673c5389cf6d5d0126946685cd2b", + "runtime-recomp/src/game/hud_layout_editor.hpp": "8b3fb26e0dd265c0b826136db6ad5400dd2b1248d7c7e4dcf2b97bf4fc6a5e64", + "runtime-recomp/src/game/hud_layout_policy.hpp": "2cb898eba4f786f90d050701e8d5ac5ee40eef49798fbc69daf42d9781f9b5db", + "runtime-recomp/src/game/hud_projection_rt64.hpp": "73e652b61216cad768107a42385afbee1dc0fcda9b59bc885b65d161b0cc3142", + "runtime-recomp/src/game/hud_rectangle_layout.hpp": "f689f611a773ff1b55f857266f4934de786143aed1fc198e30b14906c338b1de", + "runtime-recomp/src/game/hud_reference_layout.hpp": "2f9407c8b5e97bed8937427f9107989b51e85de1abbfaa857b4027e92e82368c", + "runtime-recomp/src/game/input_backend_policy.hpp": "fd4cc735cd101e1857c2940fe5e0a0061b2f3e8bfe0025288f20c288295cc581", + "runtime-recomp/src/game/interpolation_state_policy.hpp": "7cfd70b689980e6b0add86ae32f3e50fcb63628842dde20238420a07d24a4b48", + "runtime-recomp/src/game/intro_caption_rt64.hpp": "99952403aad7b6adda2bb4deb1d1c96cb5639ed6ee070fd60d3ac08e2b531acb", + "runtime-recomp/src/game/intro_sky_rt64.hpp": "1997633516f2e02706136f5ee58d55a80ffcdb1797c82010accddbdb8bc42f48", + "runtime-recomp/src/game/intro_tail_policy.hpp": "5ce04a7547118fa72da1956f200efadf373eb8430ec4165e9d63e085c5c4db83", + "runtime-recomp/src/game/intro_viewport_rt64.hpp": "1ebead2a9e83ab744fb56817eec7437e042bdc21540ffe12706486d70c7ca25c", + "runtime-recomp/src/game/invite_handoff.cpp": "009e7b8dba3ad27f49f2da0f69ffb8b8ece251593bfb95e5288ff1559b9ff77b", + "runtime-recomp/src/game/invite_handoff.hpp": "ab5a420d161598e52e36fbad7dffbe71f30ab0da79c00385605d22ef98bcc56f", + "runtime-recomp/src/game/invite_ui.inl": "c01abc0cc8d9ddfa7fdb7137d47767b0be0ecbcb53f6c2562ba0664bfbfd056d", + "runtime-recomp/src/game/launcher_copy_policy.hpp": "b6653c96e8f481f3117aeea6a4233c948538cf1d610e7d17e3400e6e58cea1b9", + "runtime-recomp/src/game/launcher_frame_policy.hpp": "89a2e0a07ad350d43887d857c5a867c50b1ade248773e1e51f74b61e42f5be7f", + "runtime-recomp/src/game/launcher_panel_cache.hpp": "cb1cc063c1e58b78b4b6f9296c1391cd3ac9d59f42866a5efe461fccdfc32ee1", + "runtime-recomp/src/game/launcher_performance.hpp": "3109c6c8745cbb1e93f2d09d206f4de28e2eac35be2bee546c270b679e987846", + "runtime-recomp/src/game/launcher_recovery_policy.hpp": "7e0174a0367e5189214f07e8343a7ed808bd6ae9f60d08218a42e99c8574a9c0", + "runtime-recomp/src/game/launcher_render_policy.hpp": "8227afc20b612b841702494400a44bd05e0772efc5d95da9962b4dbf09de132f", + "runtime-recomp/src/game/launcher_solid_geometry.hpp": "cbdea34da0f12d1f75f28f11ab8529fcd263ba80329fced1e2cf990b7a970a25", + "runtime-recomp/src/game/lava_glow_policy.hpp": "d426d365bd9fdb7217fbc12b4345a17bd7170b3381fef4bb59a85b8129ee9fcf", + "runtime-recomp/src/game/lava_lighting_policy.hpp": "06856a33506dcd3dff651de6b86b336529785bff4c4d151f434c6ee81a8ac8ac", + "runtime-recomp/src/game/linux_keyring.hpp": "de0efed1f989802f9ebed5a6ef567d8e4a0af191b4a1ce3d51c50fb3543cb280", + "runtime-recomp/src/game/liquid_replacement_gate.hpp": "006a765fa5b8f0253d4b136279eb8c65848bc1c42af03d41c0b11ec7a444af43", + "runtime-recomp/src/game/lobby_adapter.cpp": "640de106817625d415951b02e82b5531bb840edc62d5f82e7acc66d8dfa1f110", + "runtime-recomp/src/game/lobby_adapter.hpp": "f2a82aabe0c062ab9aee68281241d250f03cf0947907271ebbf69125bbb57a27", + "runtime-recomp/src/game/lobby_context.inl": "894b39e38c2a0f904905ba7614f9ae31f95580a421d4a464bc710439ce2afc88", + "runtime-recomp/src/game/lobby_controls.inl": "61db545b85f5eaaf734ee2bd71a4e4547e842c36e962cddc828a9124acbb803f", + "runtime-recomp/src/game/lobby_privacy_policy.hpp": "774727e9eafa39f30c83bdf68f72846153ce1017ff2dae1dab54c8e061cb61ff", + "runtime-recomp/src/game/lobby_sidebar.inl": "9e2a867fe0082c4993fd1713ab732228b5f95f257443c5b8813a3a3908a6d78d", + "runtime-recomp/src/game/local_model_rt64.cpp": "e60e69689f31204f716adbdda4010850637a1cd3f936bc258620cee35dbe64a3", + "runtime-recomp/src/game/local_model_rt64.hpp": "b2f16300265991f4c401bbcc5dd2eb231257042c4cf775d9cbb3671ff2db8f0e", + "runtime-recomp/src/game/local_scenery_arena.hpp": "b8ecbecbc83679758e8a65156e9d78f803cf8a4b1a088a00e3e989f7620e89b8", + "runtime-recomp/src/game/local_scenery_policy.hpp": "ff64920c79ded6eac4467241a123da0b24f4099a06dbdf54795e491772d697a3", + "runtime-recomp/src/game/local_scenery_rt64.cpp": "b9c7f4fc81a6c8aecdcc19ca1380cc791b2065e11e1db56780d7c7441df9db35", + "runtime-recomp/src/game/local_scenery_rt64.hpp": "daf363f88b2e947039fe938905938e3549bc4f17940a02906b2a70b0b24b5624", + "runtime-recomp/src/game/local_scenery_texture_scope.hpp": "1d1c3940647e9898dfe9bc73dfb0119efb2862e7cb47bf7decfe242065a356f7", + "runtime-recomp/src/game/local_scenery_vertex_state.hpp": "a766f0a01d1885dcfeced849cb99c0581b950b6478f99b9a5375b907c675fc54", + "runtime-recomp/src/game/local_scenery_workload.hpp": "d917689ac6e8aeea3dfd0bd842028b040e22034257f7eb262c7f8deeec8b9d38", + "runtime-recomp/src/game/magic_code_policy.hpp": "7c18eb5a43fb110d9201ffa71fee210ebda3d9a141fb1c8dcd8031da016e2690", + "runtime-recomp/src/game/magic_code_runtime_policy.hpp": "e606707e5a1e05a01785a016aa6b53bd5064712520cb1fa2bc8d461e8323aa50", + "runtime-recomp/src/game/mobile_graphics_preset.hpp": "59469e5525321bf749ea3b532c7142f9df7c313ffc0280da407987835aa695a3", + "runtime-recomp/src/game/mod_store.cpp": "9de1b0be15b637d7408a47c1ecf2632f7f0aa58530d70645b3153fe69f1424b7", + "runtime-recomp/src/game/mod_store.hpp": "79ecd35cdd94c399147318447148618d23026e46b2357d43181cf81afddf86f5", + "runtime-recomp/src/game/mod_store_install.cpp": "0b763838b0b8ebd9573607e141a9ee7d523ee831673977070dda7c12f8299d71", + "runtime-recomp/src/game/mod_store_install.hpp": "287ff12bc66b06b2dd63fa51707cb789cfefb8c40d5472f81668dfe6ecdc236c", + "runtime-recomp/src/game/mod_store_management_ui.inl": "e72bbe0ea5e3b5638c90ed60bfb9e27af175315e0c2858fdffc27a95fc260e06", + "runtime-recomp/src/game/mod_store_names.cpp": "85d7fe1905b373479501ec4556d7dc594fc2e3a7932e7a5f316cbb655bc8a755", + "runtime-recomp/src/game/mod_store_style.inl": "cd3adc364d24ebaf8bc233b3367983fc1eadd8323139bcc5abfc185aa8911854", + "runtime-recomp/src/game/mod_store_ui.inl": "b15fb9ba88c08329c25e47570a10549500f07782584d7c7e461764d91731b21e", + "runtime-recomp/src/game/modern_cam/modern_cam.c": "5abdc070d0f5eb57b416cb69431c9241bb92b957c634e051efda6f7f7abf2931", + "runtime-recomp/src/game/modern_cam/modern_cam.h": "3bada341209c12b335c0ff36426017d04c7e0da869010bfb09228b283d7902dc", + "runtime-recomp/src/game/modern_camera.cpp": "8acbad1fc96c2203e91e74ac27eca293531670d6e426f60bcf284e4b77963391", + "runtime-recomp/src/game/modern_camera.hpp": "ae13f9f52b732aade91ba3cde0d9ce6b598502ed01c0ea24fda127bf7f9a5bbf", + "runtime-recomp/src/game/modern_camera_dkr.hpp": "7605fa602d75a39e0b6559d481a9c8ce8a03f058fee3f8e1f854035278711592", + "runtime-recomp/src/game/modern_camera_policy.hpp": "b4612de3cb506064fa14066432e0fa193ebce7a49a6705f80477c077650adb86", + "runtime-recomp/src/game/mods/legacy_asset_bank.cpp": "13005e73fea7ffdded03803661683a05943fa5aaee4992364be3cb12ee66491f", + "runtime-recomp/src/game/mods/legacy_asset_bank.hpp": "1b97766d15e49dcbc1e27b078284e10b67d9bf0ff454f39d313946410e89244a", + "runtime-recomp/src/game/mods/legacy_asset_bus.cpp": "48a04a315aaa36a0ed71792a5c903eb554fe2879c95355ae35b177a1b17f1542", + "runtime-recomp/src/game/mods/legacy_asset_bus.hpp": "ed91e587ce3b4fb6a617af724484748af4aa17496a6c44e8c763597d01cccdf9", + "runtime-recomp/src/game/mods/legacy_asset_capacity.hpp": "3e458108856a967d5ca2e6812b126dca9ba33b581a542b0c2233859d373916cb", + "runtime-recomp/src/game/mods/legacy_asset_directory.cpp": "2ce35b421010740739379dbf75ab772325b0bdabb5a849534a7b5535c9f74dbb", + "runtime-recomp/src/game/mods/legacy_asset_directory.hpp": "e28f0bf5ebf0145b6bfc07f16e19c51f2930eec3bc53390a6c30426f043e1111", + "runtime-recomp/src/game/mods/legacy_asset_io.cpp": "03afbb6f255feeb354b02f34fb1b7c05403bb33b7d197bba76748ff071906a8c", + "runtime-recomp/src/game/mods/legacy_asset_io.hpp": "427ce3f9ea60247a04b2e680d4c01dc3353bb21843bd2b8899761edeca79dc80", + "runtime-recomp/src/game/mods/legacy_audio_bank.cpp": "8d66f5d11642c9daff8aeb9ee9b552b319fbe275dadfb02c6727d6244d27d433", + "runtime-recomp/src/game/mods/legacy_audio_bank.hpp": "ec49f0e9f9e9340ee07e2eaca99761f0540c018a94aad7fee3625bf1deee67ab", + "runtime-recomp/src/game/mods/legacy_behaviour.cpp": "6cebf4b2f0fbe8004e71032d33471ef9b9727c97d8c35cf52bd168c045b3a4ab", + "runtime-recomp/src/game/mods/legacy_behaviour.hpp": "c5c6aa82a3b8df5c5dc1b99eb508791c5bc8f731ece02ec776c1f922c7f223cf", + "runtime-recomp/src/game/mods/legacy_cache_namespace.cpp": "db9dd05e479567d810d66352b84b3ffdad53510d375340086e9f253ddb1e7cc7", + "runtime-recomp/src/game/mods/legacy_cache_namespace.hpp": "371ec61300581a7fe429a764895949caa0b57baee30ce389fd13e78d78431e61", + "runtime-recomp/src/game/mods/legacy_character_artifact.cpp": "d3bb36ad9d3159951fecbc15251b4fce7badadae73acb71f6fc92133587b2af3", + "runtime-recomp/src/game/mods/legacy_character_artifact.hpp": "6ae29893c07ed3b766c8947c59a3b13eb58b798bdc686db416b3501ca61bc5ed", + "runtime-recomp/src/game/mods/legacy_character_audio.cpp": "a140bb797ef45f235a99db019f911b14c747f1f2d7fea47afce12d6bcb9a782b", + "runtime-recomp/src/game/mods/legacy_character_audio.hpp": "ccdac31f79aaa14db748e9074bb280741c001e076f7c6b318c1e7a72b96ab539", + "runtime-recomp/src/game/mods/legacy_character_limits.hpp": "ba31f1df77e68473e0f4a8ed42028f924df62c1cc17d00bc12b55d0aaedff4a5", + "runtime-recomp/src/game/mods/legacy_character_materialize.cpp": "c002b364953a8e79c18f50d4e04d5b43c85e85cd6b299778d3c9c7041b2bba56", + "runtime-recomp/src/game/mods/legacy_character_materialize.hpp": "422b10f0585fc4c364e524561a10cb315faa4bf5ac37602c2c1ba92ceec7b374", + "runtime-recomp/src/game/mods/legacy_character_menu.cpp": "600b247ee3627f4a5bcee1d3dcc3ba47469edc7aa6bb366e7d77c3ac0286dd7f", + "runtime-recomp/src/game/mods/legacy_character_menu.hpp": "6a5dc5add2b2725ff1dc3e3775aa183f0b132f5c7ed95ee8aacff669910c83f8", + "runtime-recomp/src/game/mods/legacy_character_menu_render.cpp": "a87b37eaadff8eefd5b0443fea2aeba963621e2d05f7ad73d1c3e528d6975274", + "runtime-recomp/src/game/mods/legacy_character_menu_render.hpp": "a2b4b12ce68bda8a874c0a1647e59524e2c34c70e2f428f3d4260f2a13f2dd50", + "runtime-recomp/src/game/mods/legacy_character_presentation.cpp": "1d1da51da440969ddcf6645b692d4053c6d7d03070fa6cebc689af209eb86537", + "runtime-recomp/src/game/mods/legacy_character_presentation.hpp": "b1680edb5b261e3135ac68e41fa78c71249eb6c96a89dc774d1f086a785f39aa", + "runtime-recomp/src/game/mods/legacy_character_roster.cpp": "7fa761598ac5473aec674f23419e5c6ae8a79a2fc9c85ade5f98c10ea214a47d", + "runtime-recomp/src/game/mods/legacy_character_roster.hpp": "234668707bd2a2f55c927c1bc6889f5100c0e5f85b679b8257d7199f7d77719e", + "runtime-recomp/src/game/mods/legacy_character_stage.cpp": "d106361dcbd64600e836e71b331bf41b925e964c20b6c0c5ddabce315fe115da", + "runtime-recomp/src/game/mods/legacy_character_stage.hpp": "4260cde00dff76974e959c30f10efcd4fee08a74d9328f98ceab28545395a2b0", + "runtime-recomp/src/game/mods/legacy_checkpoint.hpp": "8a102495c38c73aef39586b27b2e2d0a8ee0de30480508f39cfd7f56fcd29fc3", + "runtime-recomp/src/game/mods/legacy_guest_checkpoint.cpp": "e4b0b860139c766c0a6ec6a5f1ff37fde1710e3f309893bc6d5c125ae832e20f", + "runtime-recomp/src/game/mods/legacy_heap_policy.hpp": "2c0684b53614b7fbb5c590e156ed4816f3b3e8ee68fd146a488167b90718253c", + "runtime-recomp/src/game/mods/legacy_import_library.cpp": "67cdfc2bd9094587364580d9a39a65d989310d7bcc4edabfaf015e3a75f808c8", + "runtime-recomp/src/game/mods/legacy_import_library.hpp": "18cc8ce7572e29cf4923f878ff0de708c7fdb2e0e5b2d4f3924932c18e41d527", + "runtime-recomp/src/game/mods/legacy_memory_safety.h": "ccdc150758329711c9c109a6aa976ddf1bbaa330265a9d1819528582708c7426", + "runtime-recomp/src/game/mods/legacy_mod_browser.hpp": "5826e792f2f8089d8b8a1a3eae4d013dfa50bef70a6d58f1ace9696b5a1d892a", + "runtime-recomp/src/game/mods/legacy_mod_dependencies.cpp": "aa26342239f76827d45731d5d7ab59201685f0b2f3ec923f5792b2523b004805", + "runtime-recomp/src/game/mods/legacy_mod_dependencies.hpp": "47cca32b3564b54eca2177c00411162503841c62d8ae3ef16f5ee0ce26f7c01a", + "runtime-recomp/src/game/mods/legacy_mod_format.cpp": "db2e49233bda3ac79c3f23f629600e25b7ee8c10640b99544ed528113cb9ffd1", + "runtime-recomp/src/game/mods/legacy_mod_format.hpp": "a2c06ed6ed8488fb650e72daa83ff19a2646cc0e13cda0e03395adcb07a80c34", + "runtime-recomp/src/game/mods/legacy_mod_geometry.cpp": "6fcd83f4ea7817e1c8330f30105f5b8123b41fa21b47da37064f231debd7d812", + "runtime-recomp/src/game/mods/legacy_mod_geometry.hpp": "a1697b987b6150a29b5158813216f20bdda4ed0415cd0fe9eea4abe3343e36d5", + "runtime-recomp/src/game/mods/legacy_mod_import.cpp": "dd1d8f64bc9ea98e9d1e5d5aecb7d518b6ad4be402dcfb469444f3636dda9b3b", + "runtime-recomp/src/game/mods/legacy_mod_launch.cpp": "4b91aa2b3113a6c7c3b0ca6efdf109565f23504bd496c99dbcd66cb01e073508", + "runtime-recomp/src/game/mods/legacy_mod_launch.hpp": "34b0af7d3efd17f4350d7cc5ea2a969d621435fe0dda04afdbe7db196cb2244b", + "runtime-recomp/src/game/mods/legacy_mod_library.cpp": "14813e60e84ece120f8c2b75f44d2777deeac2228598e546b3f5d71915c4198a", + "runtime-recomp/src/game/mods/legacy_mod_library.hpp": "a4f24914832af93ec385a0ed439448f5752a3afa6e83ada93b4fe7593c7046eb", + "runtime-recomp/src/game/mods/legacy_mod_lzma_boundary.c": "87b27c2def077da4cfb9c1b45bdf70d65ade64a8fbc8a78ed57d482d2b0da928", + "runtime-recomp/src/game/mods/legacy_mod_process.cpp": "99d9d8ac8884e4a6169477223dab42fc3cfbb1d385664d9283a1bd9ce766f92f", + "runtime-recomp/src/game/mods/legacy_mod_process.hpp": "16f7cca5203fd6ea743e5c5255764ebf8e8ff3418121de52761ac4f8d4aba0bc", + "runtime-recomp/src/game/mods/legacy_mod_stage.cpp": "4ade300187a6f73ed29c7aa9c098fc15b0d91b3cf1e5405fc0521a88ab0d8e74", + "runtime-recomp/src/game/mods/legacy_mod_stage.hpp": "4ad218013209e38561a4e6d0d425543445db4e1640455692eff22888fcd3b2ec", + "runtime-recomp/src/game/mods/legacy_mod_worker.cpp": "deb876a6304be77055d5e25574b9b4ed0ab4495f6bb3e606b03c77ef462c3fc7", + "runtime-recomp/src/game/mods/legacy_model_safety.hpp": "05a4843b83aac816700eef2c5b6d5c9a905925f61b4ceff562d5d238cdcbb82a", + "runtime-recomp/src/game/mods/legacy_resident_assets.cpp": "9a60be8bb1e0f52a04b407f9b99717323a5ac1a9eae83898b4fc5da576240a99", + "runtime-recomp/src/game/mods/legacy_resident_assets.hpp": "c0cffd2ca69b13299ef85aee3b2f959f6cb8fc6f0920a0a20815bdd4b3bafb08", + "runtime-recomp/src/game/mods/legacy_runtime_assets.cpp": "e9b5afc60a89225cb7642aef10dd95ba01d773079e2e123c78941e66318db62e", + "runtime-recomp/src/game/mods/legacy_runtime_assets.hpp": "5ae74faa97ca9aecb367b65a9990e961cf8944c992ee52ec5baab4262b354a98", + "runtime-recomp/src/game/mods/legacy_runtime_character.cpp": "472c2094718edf9d556395a8de79945ef900d7b5b486d0083ecdb026aef526c3", + "runtime-recomp/src/game/mods/legacy_runtime_character.hpp": "25626429403ebde0dd3ff038a0f5ac360102359674cdc0a52a0762bcb91d25e2", + "runtime-recomp/src/game/mods/legacy_runtime_io.cpp": "d56245d00a3a22e4f475123b229e9e66be987dc563efada82fb29293cf38d741", + "runtime-recomp/src/game/mods/legacy_runtime_io.hpp": "194de3bbfe000f6e45ba039713c42eb7ef7360652410693fd4d2e24d00f8c86d", + "runtime-recomp/src/game/mods/legacy_runtime_session.cpp": "03c47036b6de1498a33f043bacd5879d49cc4e67f13aea3c33db3ee2789621da", + "runtime-recomp/src/game/mods/legacy_runtime_session.hpp": "620e48ae3ca029c57d26a18d0cae5b4912f3ea8fa09fe86713effad11deb078c", + "runtime-recomp/src/game/mods/legacy_track_artifact.cpp": "98ce8d1f2c9bb8f947b064718151e2b56600132a1dede2180470a1b49ef91062", + "runtime-recomp/src/game/mods/legacy_track_artifact.hpp": "365f62b6e181826fc5f18fdd33c6a9c4b6911e6407c47ca834cbd3c106b0de3a", + "runtime-recomp/src/game/mods/legacy_track_catalog.cpp": "bbf1ee5298251c4b10063fb97265c32a7efe57bfa4adae2b4177acbe09ff9f42", + "runtime-recomp/src/game/mods/legacy_track_catalog.hpp": "7a58c8387d7204c3a6f8e3b478c781598910077c90847c238f145902f42c2336", + "runtime-recomp/src/game/mods/legacy_track_materialize.cpp": "6bcde3d1783fae447c40da17c15e1af2cee540edc64763ebf972e37035053d86", + "runtime-recomp/src/game/mods/legacy_track_materialize.hpp": "a608cb68cd6c67cd343185b0a147b31bdbaf2242a97e1d5ff486f9207ed1aa79", + "runtime-recomp/src/game/mods/legacy_track_menu.cpp": "4a6d02adb8310f594dd4e604eb402f60acc3d38b1d33e1f1e5648607cc86e98a", + "runtime-recomp/src/game/mods/legacy_track_menu.hpp": "6e17c49a310bfccfeb98ebd36db1e2e31f766536d7530848ac910e9c8033ba28", + "runtime-recomp/src/game/mods/legacy_track_menu_adapter.cpp": "286bb5be995d13377b4b0bbd849f12fee5adf213807917ad9cb431d1923aa212", + "runtime-recomp/src/game/mods/legacy_track_menu_adapter.hpp": "26aac5d4ad87007bb17cd3a5c3844d5c520556fcde15a1aceb2b6a3283ac3e63", + "runtime-recomp/src/game/mods/legacy_track_prepare_job.cpp": "04015ae5cad7f86c7ec48fdb6cc84a0aaf4880a5a0cf5dc1f0a4e5acda5f83c3", + "runtime-recomp/src/game/mods/legacy_track_prepare_job.hpp": "1a1a16b4c841e695e69ccc39a8927a15c624137f30d00ca5b9e89fcce1d1c8a5", + "runtime-recomp/src/game/mods/magic_code_dispatch.h": "cd21a7f616af157f0b9d9b602e680614a81784ea48f325eb9bd6be9bf085be44", + "runtime-recomp/src/game/mods/magic_code_effects.cpp": "1dc24502c0a090a1b6f3ea0b42c9830051a10ce55603b75b327ca9bfc38b80c5", + "runtime-recomp/src/game/mods/magic_code_effects.hpp": "66274dd8fc798d2b35d5e320795c6daaec2f4ccb0443af6145dd766803b35ad7", + "runtime-recomp/src/game/mods/magic_code_execution_policy.hpp": "62baf463df36e7318f571bf8f723dc11c19b10c6167fa0d56ff4d40dbef600ef", + "runtime-recomp/src/game/mods/magic_code_game_services.cpp": "3d612e5fe111229a8fdf5aacbed19c0df941d130dfda4f481e08f33d717133df", + "runtime-recomp/src/game/mods/magic_code_game_services.hpp": "dad331d0d63d4c2edcb7a87f4e5f9f980bac7b016332465e52425e9aa8009ce5", + "runtime-recomp/src/game/mods/magic_code_game_world.cpp": "b906f7432d112cddcd70ddb72fc15bd67537ad777ffc8f4f8b95f7b1f4c9f58d", + "runtime-recomp/src/game/mods/magic_code_game_world.hpp": "7e2d2cf853494f89734a423d5c00ec4c1ffdae2007a877678d5cad4bf4c79227", + "runtime-recomp/src/game/mods/magic_code_library.cpp": "83dcbe5e220caf59c43ef7831561749f253e599581bd23c32dbd42ed4b895029", + "runtime-recomp/src/game/mods/magic_code_library.hpp": "a0b13826c7d371d435efe3247269cf56ec5e11b3640960e52fdc8bfde3f08cd9", + "runtime-recomp/src/game/mods/magic_code_library_jobs.cpp": "a87b8b4ee9d0ba03f1d414fa845a03e36d723a53a19e22b32385e62c7e2d5b44", + "runtime-recomp/src/game/mods/magic_code_library_jobs.hpp": "0b64a75304820f6f91ace73d6ad3dc3cc30af04b597c2409ce476574b93c9e38", + "runtime-recomp/src/game/mods/magic_code_link.cpp": "a6e1aaa146f21f9caf873239e429493bc5014becce8fbd0b0332be8255fac294", + "runtime-recomp/src/game/mods/magic_code_link.hpp": "5c0280c2a918802bdb7419d714c3950715844c9700fb0801b5240bd9d367125e", + "runtime-recomp/src/game/mods/magic_code_native_arguments.hpp": "9e00fe7d50e7f65b94919db4ba3772eced5a7624824e6029e548b31bfc61beed", + "runtime-recomp/src/game/mods/magic_code_package.cpp": "b2ea1992a61b199c35725b1370fd96add5c2bef26268451caf84ec5ca736ba05", + "runtime-recomp/src/game/mods/magic_code_package.hpp": "e5812362732e12bedc95bec2e00295f965f5efadeef8857f168db9d22f66b5ee", + "runtime-recomp/src/game/mods/magic_code_trophy_catalog.cpp": "4fc5e6d592b33ec9801882bfd25761b4750a02d0e3643b851d96ffc0b550c468", + "runtime-recomp/src/game/mods/magic_code_trophy_catalog.hpp": "b7c15050b8ca7d4f5f34b53844983423bcca90f81e37f70bdb77abca79b4bd4b", + "runtime-recomp/src/game/mods/magic_code_trophy_definition.cpp": "770aa946d09c3f8d4a04305437680f79981e57c30b6c36dec206a240d62a38f2", + "runtime-recomp/src/game/mods/magic_code_trophy_definition.hpp": "e10e96569a2128b47ce37dd08338c6b8361f68934026584fee5ee6fef47d46c0", + "runtime-recomp/src/game/mods/magic_code_vm.cpp": "4b720c662f651855709b80fd1480b25c11c67366bcada23452d09d3b5d989cf2", + "runtime-recomp/src/game/mods/magic_code_vm.hpp": "e3ad05274fbae73e383d8d1c185692eb331bf8c4751b677e46e77ea67429c431", + "runtime-recomp/src/game/mods/magic_code_world.cpp": "ce77efab4659ae75e739b51e0c9c8f3c78ff09a2c0709f8de58113fd9136f657", + "runtime-recomp/src/game/mods/magic_code_world.hpp": "23f96ce88f5e91021e73158cbd30fa85ffa016d739e94dc65c388fde46e38465", + "runtime-recomp/src/game/mods/online_course_policy.hpp": "fa68e8a27b651d88a70b4e0020929a99ef9befc1f33199d7cb9febbd16895820", + "runtime-recomp/src/game/mods/online_game_pak.hpp": "44c50cff73864f7951fce3150cff270bf1dd3cb8084b8194fa621af072b09eed", + "runtime-recomp/src/game/mods/online_mod_bundle.cpp": "0f2a75d9204683a35ba074060ecdde898b9289d684a4398e8146ef8c5acd204d", + "runtime-recomp/src/game/mods/online_mod_bundle.hpp": "eca770e39555bcb4df6ab9a816d3ffd8b1ac4a186a3c270207b4121da59e9e05", + "runtime-recomp/src/game/mods/online_mod_cache.cpp": "e81f915df5fc3ec64c921eec40107cee8fa361efc32549e8a237a684f39fc9d8", + "runtime-recomp/src/game/mods/online_mod_cache.hpp": "c1fe64a3cfe4d58a133ddb93eaeba1d39f02be43527485d6c175b1fc34b53a01", + "runtime-recomp/src/game/mods/online_mod_cache_policy.hpp": "999f062b34970520b89745c0537c273357f314abe6ad8a253f4efa7bdad62383", + "runtime-recomp/src/game/mods/online_mod_manifest.cpp": "70805cee67662b412c2ae2823100a11bbd7b46f61b710e4539a63d4d34f9fbdb", + "runtime-recomp/src/game/mods/online_mod_manifest.hpp": "9bf450ca9c8298eecd89e9164c63c149475c886de05c7f8d8073cfd9862cd992", + "runtime-recomp/src/game/mods/online_mod_music.cpp": "246739b7c0cf2b1abf303f59f63aae09512a9e729c746517377e9b765cbd84e6", + "runtime-recomp/src/game/mods/online_mod_music.hpp": "e228a1cacb3248583fb7b6fccf83b94f65d6a7b47da345a3d603b3b6b3398a48", + "runtime-recomp/src/game/mods/online_mod_native_prepare.cpp": "15d3941dea4727d7fe5df252d5a57792753ee4c532a7510002faa1486c2f3ba7", + "runtime-recomp/src/game/mods/online_mod_package.cpp": "78ee3c964981480a3390e60469308c9a4c2c1f238971205b68f9f15cd5e59352", + "runtime-recomp/src/game/mods/online_mod_prepare.cpp": "02e9c36f20f90e81045dcc6475071994b38d2c2a3c830d669427a1be65bffa33", + "runtime-recomp/src/game/mods/online_mod_prepare.hpp": "43f72f3ec2a6dceea7d5b1e7042b42120b708a24d3b274e750b43aceace02110", + "runtime-recomp/src/game/mods/online_mod_profile.cpp": "51848288a9718421cb84e99a4e4a1b619f3a77dcffc6279a7ccf45b58111891c", + "runtime-recomp/src/game/mods/online_mod_profile.hpp": "e592f33b62c45a7b3535c05ad72ed19baa9b12817e1aadd5cc48fcb17a2ee4ac", + "runtime-recomp/src/game/mods/online_mod_runtime.cpp": "9d62aa5e7a52b8251f033c55d5529c9c3985011444c9ef4f1d07f24d1cd234ba", + "runtime-recomp/src/game/mods/online_mod_runtime.hpp": "a8ee3e96988e107ee779a09b96029d9327e7cdc5228b0b1b15efb31e83852076", + "runtime-recomp/src/game/mods/online_mod_sync.cpp": "8d5b9ad4fae829deccf7b3840e450bcf8c406e808880ec59a90230006ae5a6c6", + "runtime-recomp/src/game/mods/online_mod_sync.hpp": "82162728247b62d764daa6cbf1fef765628e1052fef786ab52e6fc03dd825455", + "runtime-recomp/src/game/mods/online_mod_transfer.cpp": "ba69acf36a4d7ef3fa9f99d452a236b38de6780ace161768e819560671edff7f", + "runtime-recomp/src/game/mods/online_mod_transfer.hpp": "aba070114aa35dde4d989440ed4054377d1dc14dfe8d4441543e9663a0a948d6", + "runtime-recomp/src/game/mods/online_mod_wire_version.hpp": "e016feb85eb24db8d9e44e5076ed7e2358f6199c455cf39fd22eda0ffd1857c0", + "runtime-recomp/src/game/mods/online_preparation_notice.hpp": "8f2e1117c6debbfb45f190fbe14f5721b8a79726c15070d6f136f7d116b7e571", + "runtime-recomp/src/game/mods/sdk2_asset_inventory.inl": "709d6b909440f4051262a809855f4143bb19a5c89dcc0c45d77a22e12a35101d", + "runtime-recomp/src/game/mods/sdk2_bridge.cpp": "a71f9603fbead4283877e542e0ef29dc9f17a27d4c9982d1f946913499720588", + "runtime-recomp/src/game/mods/sdk2_bridge.hpp": "b7c141ecd88ced5624a09e1e5b885780f2c4919b936318192d01060facc191a0", + "runtime-recomp/src/game/mods/sdk2_broker.inl": "73fedbaa11e799f13c3067b87bbf1f35cbaa65e79df781948c9178575e430915", + "runtime-recomp/src/game/mods/sdk2_camera.inl": "defe4f3451cef4397746adbadd06a1d6da153c6dfd2ae416fb25ede1c9fa3815", + "runtime-recomp/src/game/mods/sdk2_collision.inl": "5076ca5759a78e56d3466b8bacee64e3183b586def342df56600f24829bb7b0c", + "runtime-recomp/src/game/mods/sdk2_command.hpp": "72d5eea4a4eae5fc9d37bd2ed5a4b77635fd1525c78acc963afaedae7367b7f1", + "runtime-recomp/src/game/mods/sdk2_debug.inl": "92c9b2a7bc227697ec102f7fd9ddc56e7f7d22cd035590e4aec2e8281979d7c6", + "runtime-recomp/src/game/mods/sdk2_documents.inl": "48257ec5dbb15d761b9b46ac0a48b60efb11385f7e177da3d35ed28c8c288904", + "runtime-recomp/src/game/mods/sdk2_operations.inl": "9eab0487126ca305cca4834c954d472d936c02d4f2fb3c51c15f7382d1552534", + "runtime-recomp/src/game/mods/sdk2_overrides.inl": "4bfc374159802915882005f70a638d1e2db6d5e0de9e837a861dd35af0131322", + "runtime-recomp/src/game/mods/sdk2_properties.inl": "1783eadb7f083ee331077ba5e143f0e3bba925e3a47b76165e4383238a6a0da8", + "runtime-recomp/src/game/mods/sdk2_reload.inl": "02d647ac2366ef842519b51860bde356c4ecdc212aae59ee68eb22a1323c8569", + "runtime-recomp/src/game/mods/sdk2_schema.hpp": "2e0fa717f4d6ace5c44bbdabab36c152b9f8bc4214973ab538561a9fb6802c65", + "runtime-recomp/src/game/mods/sdk2_settings.inl": "af6130a19de320cb60f84522f53631f51777c377f9267bc6a49f98f2955c1844", + "runtime-recomp/src/game/mods/sdk2_simulation.inl": "ff010062dd7f26d66731e153afb1a563d72209ea0ad607fc6a5880e327250010", + "runtime-recomp/src/game/mods/sdk2_tools_camera.inl": "9264ed3dc9dfc945700b75b75ef3e0cbc88b88da758075e3cc2c14f2651f0b71", + "runtime-recomp/src/game/mods/sdk2_tools_console.inl": "34b57927974be52a541a9b696cb1c4d6f7ac3b26f888571b4f0c44dea88483de", + "runtime-recomp/src/game/mods/sdk2_tools_documents.inl": "a0e7bcbe6b5d37eac899f1190d4fe77ba1b62ea551afeb8f7e9a0ab830d41df1", + "runtime-recomp/src/game/mods/sdk2_tools_reload.inl": "e3670a97b074a52ce9f38ed9e61bacc215cf8fdcb332eb9ee119c9d98343d4aa", + "runtime-recomp/src/game/mods/sdk2_tools_ui.inl": "33e2f27b3e4d79aa402e81e3f92f688d69d57a617ac7afd48a485d08f4841200", + "runtime-recomp/src/game/mods/sdk2_tools_workspace.inl": "77021097d17f70274a95573731664cf24a1c434afc113b901fe0b3897962aac6", + "runtime-recomp/src/game/mods/sdk2_track_catalogue.inl": "221ca9d2004f37f2938e6ef7ef1e647c4e133e4688ffbf1fd25452908f7af21a", + "runtime-recomp/src/game/mods/sdk2_tuning.inl": "18e306459c23bf1fc5934ee191be880955f29641a3717a2d3ccff614d3e68cc3", + "runtime-recomp/src/game/mods/sdk2_world.inl": "26d2fe6349143d15d6c1a465ac425da1cdebfd852d8d5730129ab045047e6ebe", + "runtime-recomp/src/game/motion_steering_policy.hpp": "0118ce93404f40617491edb0e5469a8a2967fcf849414096e984dc4909c3ce65", + "runtime-recomp/src/game/native_draw_scope_rt64.hpp": "5bcc56c0eba7a71e1db4f98c94c8c9c2b0f753dcb64131bc95674675f6d5d5b7", + "runtime-recomp/src/game/native_guest_lifetime.hpp": "16796bcfcdb77e1feafefe0d0d4d4e3ef501c0970e4b7d15dd6856ca55ff9a58", + "runtime-recomp/src/game/native_resource_safety.hpp": "86ca7f160332df64fbc860be576a749841e2a4e77b031672f88fb7c9d857a117", + "runtime-recomp/src/game/netplay/authoritative_state.cpp": "6d8d6601694cd93a6d87062bb5666221722924f7c55265832912ef534c08c661", + "runtime-recomp/src/game/netplay/authoritative_state.hpp": "a1691165cbfa32be39fe15457662e08162986857dfc12918726dea5c8d432a89", + "runtime-recomp/src/game/netplay/authoritative_state_codec.cpp": "c953b45ac5dda671eb202aab1abdae81a4bfb992ebebb39d03e6e363e33469fd", + "runtime-recomp/src/game/netplay/authoritative_state_codec.hpp": "0d5d7f7cc103a24cb257d3bc36a3c4fe7e3e412665d57fa8debff702c3662f66", + "runtime-recomp/src/game/netplay/callback_lifetime.hpp": "f36b897250f87643dccc3849ffd549de1b8bd48d92155ac9eeaaddbd6343f6e1", + "runtime-recomp/src/game/netplay/datagram_socket.cpp": "316d611f326295d9bc7ee81649b163ded86d3125be05fc29bfbb535a28187a9f", + "runtime-recomp/src/game/netplay/datagram_socket.hpp": "c27d19a46496b495c3bc52a5ddddda735e7cd4cf0e47417f7c1a445d4c690100", + "runtime-recomp/src/game/netplay/direct_session.cpp": "f1da74f0e14d22866aee58a82b29de9b5d8cc2345b2377bebf9ad26fc02d2926", + "runtime-recomp/src/game/netplay/direct_session.hpp": "4bea0ea21cc4567e201e8e66835d6a91e789a275a0543b9322735e2acc2cdfe0", + "runtime-recomp/src/game/netplay/experimental_admission_contract.hpp": "daa329ba03ba0ab27ad5bfa9905589c9f96008bb525fb97e3114f81849b7092d", + "runtime-recomp/src/game/netplay/experimental_checkpoint_hash.cpp": "f342b9c739d3a1551e9d2941ff3342d1d672c9b387a2d18f6c61fadb02cba15e", + "runtime-recomp/src/game/netplay/experimental_checkpoint_hash.hpp": "5cb7ef5c75581a3e3e5b01a35faab6ef7168e7a26f254aa1d306c6db63e87f65", + "runtime-recomp/src/game/netplay/experimental_confirmed_output.hpp": "5eeea8a28f2441452d13b0b45c09de15dcf0b250a411f9e5bcae942b3e1ad8f3", + "runtime-recomp/src/game/netplay/experimental_draw_events.h": "ca2257b810a9a05ac0290205841427341ea16ffd331ad45d71cb8c6bc679bd92", + "runtime-recomp/src/game/netplay/experimental_eeprom.cpp": "568093460a74a90b9452a18fc9997907656d2ca568176eda971d0f1c7d2adc3b", + "runtime-recomp/src/game/netplay/experimental_eeprom.hpp": "a97049857e4ed3850f502f65bd37c1d7a1da505a83cfd61cbcdc81a2824eb7a1", + "runtime-recomp/src/game/netplay/experimental_effect_envelope.hpp": "2de1b370e2a7febd893c5db04b80e7e4bfe0d6f18f56e84099ef144758c79f14", + "runtime-recomp/src/game/netplay/experimental_epoch.cpp": "1ab173a1a45faee763e2fb78be06b9cc1b526806a234d3e8f1d0d56d38bf6616", + "runtime-recomp/src/game/netplay/experimental_epoch.hpp": "4eaa05c02f9cacb5c36602e57c8792287f78537a0d4e81583781f67d000ec166", + "runtime-recomp/src/game/netplay/experimental_guest_memory.h": "bcbff909668d745b00d5798b4adeab283c0d9f20e1627db18aa24e2a2532ea53", + "runtime-recomp/src/game/netplay/experimental_latency.hpp": "a7a5fa9da04f44ebba18031e45e4fc3642276ee8f9028641c90af0f7aba74dd0", + "runtime-recomp/src/game/netplay/experimental_launch.cpp": "bfd6d8c53bfb111a4c9cb6d5fe744329dcb3a256444ac6dda9d64215583e6eaa", + "runtime-recomp/src/game/netplay/experimental_launch.hpp": "8f166026f339cf34bf994c48dd896dbedf5ce2830b4a65a2ab9f9d0c1343c625", + "runtime-recomp/src/game/netplay/experimental_lobby_admission.cpp": "2e519276f5081584c693cd4967024f41c49e23c3ec43f3a50716df342caad47d", + "runtime-recomp/src/game/netplay/experimental_lobby_admission.hpp": "31d8868d97254741f4ac5280a1aa0b529fee8e3d1c3944d39312e207c210a1ef", + "runtime-recomp/src/game/netplay/experimental_magic_codes.cpp": "48548f4bd796375e1bfa599f3d7e6f796951c70ef19ebd06b67f0385df32a36d", + "runtime-recomp/src/game/netplay/experimental_magic_codes.hpp": "446176940b1b25fb492d81df92cdcec03cda3f3f61de8416e10c1f1013c89a4b", + "runtime-recomp/src/game/netplay/experimental_network.cpp": "c7f1226d4b0468cb6f8a53ba3462a76b84fbc3d7e09d42bd72ac00d1286aeb88", + "runtime-recomp/src/game/netplay/experimental_network.hpp": "6bc6d86b1a4174594aa8ade2bdc232314dc8e78403379f8f625346e297adb50e", + "runtime-recomp/src/game/netplay/experimental_owned_registry.cpp": "8dae2fa02fbd9f3936e2109a16132a3aa54426a54e4f5717f4d5e97e1e5f9156", + "runtime-recomp/src/game/netplay/experimental_owner_inputs.cpp": "e3d16746ffe180f2548224a4d57287c503a4b9f1febe22a18e5c17a819dfe9a0", + "runtime-recomp/src/game/netplay/experimental_owner_inputs.hpp": "f8c98743bcdfdc99f65f5712b4ad28649f45c39390a813612ad348c8f3b28b61", + "runtime-recomp/src/game/netplay/experimental_pak.cpp": "10e4f5bd8ebc048a2fd71bcc16cd6df30659f04a3155bd6a46c82024b7b4aef8", + "runtime-recomp/src/game/netplay/experimental_pak.hpp": "4aa72108ee87a714df116e3e526d8729f6d575fb539be7bcc8bc37e5a511c208", + "runtime-recomp/src/game/netplay/experimental_pcm.hpp": "820c914a7c39c615afe90073ba0844758608ecf35241babc6838ecd6c7e4cd82", + "runtime-recomp/src/game/netplay/experimental_performance.hpp": "292515288c047a84d80e0acd42f0b581a836e42e603fceb7d98988fd989c8abc", + "runtime-recomp/src/game/netplay/experimental_present_gate.cpp": "c99cf207fcb71d146d6481536ea20a51e70355cf49a3ba7f9c5f3ab517c2a47f", + "runtime-recomp/src/game/netplay/experimental_present_gate.hpp": "2898eb6375dd53f05a6cedc2a9117307f7fd783d7b0e23a127d73de7ca1a147d", + "runtime-recomp/src/game/netplay/experimental_presentation.cpp": "65722b065823f5b87e231328e5c5a34d97b5a7307021cd163f22e8a25722027f", + "runtime-recomp/src/game/netplay/experimental_presentation.hpp": "94ff268e229417d965f05252d05c7c6211bf080152ddffaea7ade31768f92340", + "runtime-recomp/src/game/netplay/experimental_pump.cpp": "d6d150c2c487a41535385e7b9e538110756c3b019d8898630c7f88eae5383a46", + "runtime-recomp/src/game/netplay/experimental_pump.hpp": "33dcf6cfd8c7317a11f36e20dca221466cc6003f804d2f937923958303396f33", + "runtime-recomp/src/game/netplay/experimental_rollback.cpp": "485328203a01bae453b8a2b73b45bededb5635cff966df4e0c7737e7fa25aa2e", + "runtime-recomp/src/game/netplay/experimental_rollback.hpp": "8ffaa134c5c64533907c16092ce21fc8eebbc504c1fe60863c5637ba5ea57364", + "runtime-recomp/src/game/netplay/experimental_runtime.cpp": "213fc04b7eed1cb7bf713730a956143e9bb9bcfecb89707e75f5b9a11f9baf6f", + "runtime-recomp/src/game/netplay/experimental_runtime.hpp": "10cdd157db773e9003ba3e01f7e9fa60245075b79d1aa3a6d4f9897298fda897", + "runtime-recomp/src/game/netplay/experimental_runtime_admission.hpp": "d85abe6839cd337c1552ee34cb6217f4d0068e730543149dcf2702392f5aa76f", + "runtime-recomp/src/game/netplay/experimental_runtime_presence.hpp": "52376b405c85d0df34896e1ca8b2c86e2216738314d06bc1f399d6b3328ab859", + "runtime-recomp/src/game/netplay/experimental_session.cpp": "11165b12077b1b81a4f88b99104be29abd5d30b6bf018e133a0aa0da753356c0", + "runtime-recomp/src/game/netplay/experimental_session.hpp": "66b267671d3397956d99dcd760e473770bb8e18d0c3a9155262ae19d12d35e56", + "runtime-recomp/src/game/netplay/experimental_timeline.cpp": "47d166d245726b40e4177f7d122119fd2a1aa303e02405faf6ed99fb1001a05f", + "runtime-recomp/src/game/netplay/experimental_timeline.hpp": "20f8a422bfb2176ede16f0a9dce79f60c23aed0604a1f9dc6eb5fa1451582496", + "runtime-recomp/src/game/netplay/failure_recorder.cpp": "9070d1deaf71cd325a8fbb22b5cd2a8097cc769d689881323d834ede3a181af3", + "runtime-recomp/src/game/netplay/failure_recorder.hpp": "7ecb9143b7765d06962bd3c72e4f1bf1533ffb439b3190d845bc3e5f328ab80e", + "runtime-recomp/src/game/netplay/friend_service.cpp": "28cd5e02c55903d6f70ec617f08dd6148f14a4c5090cdc4bc548df7e71e4a466", + "runtime-recomp/src/game/netplay/friend_service.hpp": "818a0111f9463ca787995e2ebbffcd015656ae2b72cfe696e3d9693b3cb212e6", + "runtime-recomp/src/game/netplay/gekko_direct_adapter.cpp": "b8a301dfbd3ed758abe4d7d79d9101c315247c180466b4792c7a84aaa22332be", + "runtime-recomp/src/game/netplay/gekko_direct_adapter.hpp": "d59e4c252efc783ec2bf6ffa3e095dbe63e90c0f13b00cac4fd6e6c2bbc84614", + "runtime-recomp/src/game/netplay/lan_discovery.cpp": "df4191a74ce83fa5c3668ea8d985385f757306a1cee0e2980af5ed54d4aac494", + "runtime-recomp/src/game/netplay/lan_discovery.hpp": "9743fadf698567e02389efab3e0de02f5e5256f7f8effa4697a72630e1c44cd0", + "runtime-recomp/src/game/netplay/latency_metrics.hpp": "d73f0cec3ddcaaf278394036e0bebdf8f40b4b753e29d5a502b5e77413ffe30e", + "runtime-recomp/src/game/netplay/local_scenery.cpp": "fa15032fe4e2d0fae1b3f3044cb172a73006e3cffd7872e00bbe751f06b90129", + "runtime-recomp/src/game/netplay/local_scenery.hpp": "4afe4b5163fa395c6731c567a5035d03e7a0f5b128b9ea1e1eaaf311cd696f30", + "runtime-recomp/src/game/netplay/mbedtls_dkrr_user_config.h": "54218e74b7416beddffe88d3f7c11979c056b52051eacc272d7cfedd7300d01b", + "runtime-recomp/src/game/netplay/netplay_build_identity.hpp": "91c88eb4e490df6b3397b1c81df14115142925dbf9ed15e564c5016b2066259b", + "runtime-recomp/src/game/netplay/netplay_lobby.cpp": "3e8ce3a2d73ecd745b076fe5ecdcdce333a14b4a41c36f3cdf486045c2f62a4d", + "runtime-recomp/src/game/netplay/netplay_lobby.hpp": "e67ec7b280df9e93ca7deb2fd8bb164ba78538dc3c51754f869e40332bfae5f9", + "runtime-recomp/src/game/netplay/netplay_pacing_policy.hpp": "e1017e1e578dee1d87ef19bf3bf66f389a97ee10dac997ee3e722bf749aac52f", + "runtime-recomp/src/game/netplay/netplay_protocol.cpp": "d7eef04630efabc08f7adef475b507f33131945e2d1cbe08c5483d0634bf2d2f", + "runtime-recomp/src/game/netplay/netplay_protocol.hpp": "5ad5cab64c3ddde1152b34bb703f3ba30c1d5efcebd8e2d1fe1fcf7bd5bd4adf", + "runtime-recomp/src/game/netplay/netplay_timeline.cpp": "dfedd5299051d8c7060df25d33bd8b15dd78791648f89734df1fcb7127444355", + "runtime-recomp/src/game/netplay/netplay_timeline.hpp": "c6b305f65207cf817e0395c825bf2816f4065f5d3c8d57debdafc96ac4c02f3b", + "runtime-recomp/src/game/netplay/netplay_types.cpp": "08aed263d24e14c6875968a936fa26500e1213b3af63c5c6de51236e90ce7755", + "runtime-recomp/src/game/netplay/netplay_types.hpp": "91f93bdc4c0509f95c3e1778b75175f0855f555286cc01c37e07a42dd17fa813", + "runtime-recomp/src/game/netplay/network_interfaces.cpp": "6f8321001cd6b35748b525bfc8f13364c9f2cbb01df37418506cfc634a4df88f", + "runtime-recomp/src/game/netplay/network_interfaces.hpp": "8e8696172cb82e6217779cfcf3ba688e960491e404b72746a3706cd5be044577", + "runtime-recomp/src/game/netplay/online_input_broker.cpp": "06b1d45e4cbb7c66fb445e28cca7f7991f19a0a9a6ba4b18c6814b5df9eaff46", + "runtime-recomp/src/game/netplay/online_input_broker.hpp": "d30931c25af3a1143fb699946379e804b2f51876dcc069e463e4741d03bd8659", + "runtime-recomp/src/game/netplay/online_lobby_policy.hpp": "a088e820ac9379f57cdf7e1c90d0e458e4088ecd4b1072dcb13c678df336271d", + "runtime-recomp/src/game/netplay/online_mod_admission.cpp": "9cec91131fe715b45574dc65292f4ff9c47f73a98756aa5cdcc7005a957e6943", + "runtime-recomp/src/game/netplay/online_mod_sync_link.hpp": "55d832857029f9181392f5979ce74c0c6699c8eaaeff92a1a753aa59bf6348ff", + "runtime-recomp/src/game/netplay/port_mapping.cpp": "dfa8cc1cb803fa81d7b1ac243eb9989bc0ad9869ff5d2d26bcc77a9351858f70", + "runtime-recomp/src/game/netplay/port_mapping.hpp": "f0b8578240847d0aa00e5b33c22907f97c6cad53f229399b5ffe16a551bbb524", + "runtime-recomp/src/game/netplay/progress_budget.hpp": "ba6b0c1a1cac7a0a70084c36efea1be34257462ed2d310ca3fdab34325b7b995", + "runtime-recomp/src/game/netplay/quick_join_transport.cpp": "9c293897e1c08c6efeac16030645e0c67080045dc2a6dde8ae31046f4538fb14", + "runtime-recomp/src/game/netplay/replay_qualification.cpp": "28e30972e59d5306fc830db08db99e7d99321e1ca3b158726f28cf0bbc1bf635", + "runtime-recomp/src/game/netplay/replay_qualification.hpp": "1b7fae4cb1d39907ec78c5ddab59f102b62cb94553431db4a22c2849af8e1a43", + "runtime-recomp/src/game/netplay/replay_recorder.cpp": "e895519135cf7ea0788de3fc0c04d5d2a709cb77b62b738d443baaddce0da073", + "runtime-recomp/src/game/netplay/replay_recorder.hpp": "8e877fd49827d22460089c71035af27aed02728830717a3eceda9962062eaa9f", + "runtime-recomp/src/game/netplay/rollback_ring.cpp": "545a545f3746e481371c23bcc31ddf60fe74022e68a9c1da4812c0e96732845b", + "runtime-recomp/src/game/netplay/rollback_ring.hpp": "7464975119caf587ff100cfd96b6ce5ff813aad1e354d562d85e22e7469e501a", + "runtime-recomp/src/game/netplay/rollback_simulation_state.cpp": "511bd3e9c68a6a120d879e81ae83234bd457e4ca5b20970bc5d42b0aa5303a78", + "runtime-recomp/src/game/netplay/rollback_simulation_state.hpp": "69cdddfb33ecf636d7154757db1af2d02c7f2ce50eebbf01e47b59d7fb99e6e3", + "runtime-recomp/src/game/netplay/rollback_state_store.cpp": "87cc6ddd942ff58e2bc0db6d71d188496821600be1735a458e2126cea45c9f33", + "runtime-recomp/src/game/netplay/rollback_state_store.hpp": "ecc9a58ab3d6e0c69067052cc62f3309c9538ccf4157efa57779a0af32f6c53d", + "runtime-recomp/src/game/netplay/runtime_state.cpp": "4c087b58580dbf4d143bae3a56d508dfdcd2ae98c0d0e292be872ae1a38520f1", + "runtime-recomp/src/game/netplay/runtime_state.hpp": "f73058a285029dae0c4e69b74468dc6d23b1873cfe1ad0c2e5c389de448908c1", + "runtime-recomp/src/game/netplay/secure_channel.cpp": "ddb4677a2404cff9719e40d30c45535f7f90a6eb486fe1bdac1481d94328db31", + "runtime-recomp/src/game/netplay/secure_channel.hpp": "b2143bfa7f887a650bdb8f3cefecc0d45b63c85b583251c85800a96740e505a5", + "runtime-recomp/src/game/netplay/sequence_window.hpp": "f347e703e1fb05137a1946024a4058aad3ee2685644f53da7718e84c2cc6f964", + "runtime-recomp/src/game/netplay/session_transport.cpp": "81a0279f7e87dc78aaeb3049faad13b6c0d1ee771074d733eb6a5d0bc71ebe7f", + "runtime-recomp/src/game/netplay/session_transport.hpp": "0489b207cea8bc4f9adcb84022d96e8bd1a9c7741c04bbb1950054a4aa74c277", + "runtime-recomp/src/game/netplay/social_executor.hpp": "9fd527906cfc397c58dffc44d80dcbb382b6789ebfbe9fce3f464c2fefeb0ef0", + "runtime-recomp/src/game/netplay/social_json_policy.hpp": "ca4c9fdbec1d3e23318c073e7190f8bc2f751612a75efca4d78a7925d50d0a84", + "runtime-recomp/src/game/netplay/transport_send_policy.hpp": "738b1e824287efdf459c2aa8da6bb5b2886c81aefb214fc4d2a90860bd01d77d", + "runtime-recomp/src/game/netplay/two_player_adventure_policy.hpp": "a84aa1d29be7f6698f83bcfde25b10e0bc3e29eec38f052243cb2719ca016cd7", + "runtime-recomp/src/game/netplay/windows_firewall.cpp": "fb648f6b6283bae38305c33c2d586bd41a7452fc5c297d523d8c3edc00454f6f", + "runtime-recomp/src/game/netplay/windows_firewall.hpp": "bfe6034b1e08ae96eff7cb0722cddeda34745800a2b5103e98d73bb2a35db3e4", + "runtime-recomp/src/game/null_renderer.cpp": "ff68771e2915b3da26879bc257a573cc34b8c24ae5081363cac13d7a625d5cff", + "runtime-recomp/src/game/null_renderer.hpp": "bc03f0c7e330352841b6fa53194f421980fb47f309d933ff256c669eae2f9902", + "runtime-recomp/src/game/observer_music.hpp": "e04a68d6fa4fb3753f0fdd9b1bb1c7ecea2d0ce45ede0c858ba8b6446f2674d5", + "runtime-recomp/src/game/observer_playback.cpp": "bafc7cdac4d0158f69087e00f007d19f9e9bde394d7ec492d0471209f8decf63", + "runtime-recomp/src/game/observer_playback.hpp": "ddc6279188c101e2c84ea0916e47180c985bdc790df82aa9d2047cd025c4086b", + "runtime-recomp/src/game/offline_keep.cpp": "0d8a07c776f3055a1e18750a8deeb781dc712b7d896b62e3eb08be8847506035", + "runtime-recomp/src/game/offline_keep.hpp": "29fba07d937296ddb6d6cc107e4fbe0bd3a7852b046c6b043a759319e2d7ed48", + "runtime-recomp/src/game/online_directory.cpp": "be2c8453a59462ff3146d0ff594195fae3b12e346b8798e60f8f279293505218", + "runtime-recomp/src/game/online_directory.hpp": "323b57f8d39802af8d3d31302edfbdaaee502763e5af9bfa528ba774bf3e2990", + "runtime-recomp/src/game/online_input_policy.hpp": "64806f55323622c5275d0f0c9bbda55da91c891d85222b0833ba6fa1fc75e717", + "runtime-recomp/src/game/online_nameplates_rt64.cpp": "2256bbbda66a14bb7e0722bd0b4cf71de13a50dbbcaf24f17ee7204d2beb1fd1", + "runtime-recomp/src/game/online_nameplates_rt64.hpp": "4561021e2d28720cd9d380f79f8d9ef7638122456b0ac5d96d3b83877bd9d867", + "runtime-recomp/src/game/online_roster_policy.hpp": "2cc43f2c476b05df4c5294100de9cb10a80b81b7ac3d57461b7e2ed05d25651d", + "runtime-recomp/src/game/online_view_policy.hpp": "6cce166182761c5df66d1ac787c8c228e27b522871ec06b323e8289e0d37da4b", + "runtime-recomp/src/game/online_view_rt64.cpp": "2427f31cd247f14dabbef64c52c746534364b57de42a06eb4010d8cd620a356f", + "runtime-recomp/src/game/online_view_rt64.hpp": "f5a426c07e29573dfb0456ebe627e9f7339e8a998cb663943f390b75ce9c62f3", + "runtime-recomp/src/game/online_wait_state.hpp": "743f8092f16fbde93cdad39a22fd4764d3e855e34f648384223fb50f113941ff", + "runtime-recomp/src/game/owned_observer.cpp": "1e0809fecde1def695f548533dd3fe9517ef4880f2904a5355bf3f881acdf982", + "runtime-recomp/src/game/owned_observer.hpp": "f0438c62b83aa19e354c56aec91f560dac125c71e61c1fe389b5bffaaea1ad31", + "runtime-recomp/src/game/owned_vehicle_calls.inl": "ee2325797651d222a6a744d6bd8f26caeb7f031f14bffa41d8c8b8cee20851f8", + "runtime-recomp/src/game/owned_vehicle_menu.hpp": "fbc398bf3203393d5b4ae210cf4c3c5d86b935ba8135e08b3bdde55bc72c14b0", + "runtime-recomp/src/game/owner_context.inl": "26cac109ae7e197e2e0a444b95692b83a8a1627c1c3ba80b11b6c5698564e5cb", + "runtime-recomp/src/game/owner_export.cpp": "ab9f2b0be0ec52bbcbf9c77a0d321f4195ca9d6ec741e85912fad1df49dacf7a", + "runtime-recomp/src/game/owner_export.hpp": "3c8d26471a36dbdfb0001919778528a1696c74b17e450952643dcc6efd51aa0f", + "runtime-recomp/src/game/owner_pump.inl": "29ccec0f4be1a2381a4080d1f1f242c10fc0847fa863bf55da1b15976cb4eec8", + "runtime-recomp/src/game/owner_send.inl": "cfb1aff3579094986646e7c4be5a30ef075b06327166f891b3963f824d8c43b6", + "runtime-recomp/src/game/owner_state.hpp": "c52408d752c34265fb8051fe87ea888d23015ebc576211d396dba96c60bec405", + "runtime-recomp/src/game/package_compatibility.hpp": "74392bcb965015290c8cea88f12a748e96e83cae9c0096e29192d39084e1359d", + "runtime-recomp/src/game/party.cpp": "696a5620df30bf2167227fb3931b82226f5c18c9fc04d5c412b6b454889d4bf9", + "runtime-recomp/src/game/party.hpp": "75fea75ac802d201290d81af07a3a023f1edfc95ef94d341e799b3a1aedc0b85", + "runtime-recomp/src/game/party_camera.hpp": "782522798cbebff33b372643fee2c76e49adf066497e1fe173feaa6e0bbba3c3", + "runtime-recomp/src/game/party_codec.cpp": "7574707bcd8a5b82e517e8391b22acb35e8f329e81fcd21a773ab2378fb3a68f", + "runtime-recomp/src/game/party_codec.hpp": "fa345a22ee3c73d5b6c195ccf0cf3944a694e258e929cdc3a71c9c5751310ed1", + "runtime-recomp/src/game/party_link.cpp": "bcf57c6ba1e37ce9c52668ce4db57e000155fc1c5deb6e209ac1ab45bf94303b", + "runtime-recomp/src/game/party_link.hpp": "df4e8aebf234037ad89453a45ab8ca86c6a69ec98d25fa1c6bd26c7454e30f70", + "runtime-recomp/src/game/party_runtime.cpp": "d73eca78e530b5e7ee2f4058c3735ff8af118db9c7d2fdeba714e6e5453da95b", + "runtime-recomp/src/game/party_runtime.hpp": "3d05251a38a7cea95b9118b4812976f1820e3b0035928df21017b2e596346fb3", + "runtime-recomp/src/game/performance_capture.hpp": "9a749e6fa86fccb61e78b71c4b2b517c07cf820e05df263442f0e79dcdc72ec2", + "runtime-recomp/src/game/performance_observation.hpp": "ac6c99566a1a3f829d5196d943bc7ff73b5bece841e57ddf7748031086257df8", + "runtime-recomp/src/game/performance_switches.hpp": "474d5079d8d75d74e9d15e2df620d35ac0f0634eb6ac78d1e22983d037f5e67d", + "runtime-recomp/src/game/performance_trace.hpp": "94f039c6be4123da619fd5f47da50efc4d7fd14e75e01fc6a9b6c45b8021c52b", + "runtime-recomp/src/game/policy.hpp": "5f9c0d2c3a8ed89fbba2f4c6260d7caa94ec5fa6715dbec6753d662c089d6eca", + "runtime-recomp/src/game/postrace_viewport_rt64.hpp": "d974efc8df9dcd99416b8c439b9d9e7540741aac1100f71e350275ff05cc15ec", + "runtime-recomp/src/game/presentation_counter_policy.hpp": "4ce4bd5833314e770f7f210eee35fc72726ace0175186458ba487e0cd4aa857a", + "runtime-recomp/src/game/presentation_identity.cpp": "a249169f7356038adf3f0b4fd1e7c64fd8f960233461ad9bb47188cc4f3f8584", + "runtime-recomp/src/game/presentation_identity.hpp": "8108a5919693e8b1ec9bd55dca8d4209cebc99b6cd01cdbb04fa3874375aa260", + "runtime-recomp/src/game/presentation_marker_policy.hpp": "a79fd491c4e428fe82032a82a2dd245ae2bf36a09c82ee7b742c0ce1d2f5b5fe", + "runtime-recomp/src/game/presentation_pacer.hpp": "ded80bda2b0e9da5387a0fdbc91c13bbc66aa01244a80f69b4d3442bd2c25275", + "runtime-recomp/src/game/presentation_policy.hpp": "52277f92b0799c10b88af17f5cf8cb2f67e2842cf9b45471821839aab7809106", + "runtime-recomp/src/game/presentation_random_policy.hpp": "0049df9ae2bd97a65c1d822ac56100d191c604e5a2d700fcf6f5ed3be40423d8", + "runtime-recomp/src/game/presentation_wait.hpp": "908f0423891cc35aff05eb801326d79d993f73337428d5bd36f9858206d9f5cb", + "runtime-recomp/src/game/primary_heap.h": "f90c2036dcf6eff31876badf2b0ca2a54df3e25b006d7df44de73ec43c6e2d1f", + "runtime-recomp/src/game/producer_snapshot.cpp": "7970e4c9d5ea02d01d17743bbce03f67ad63dbf0e3f232ce32a9a38d77008e23", + "runtime-recomp/src/game/producer_snapshot.hpp": "15e5544d0047f48f35eab694da821e2b9d308649230319ad3126077f89c4756d", + "runtime-recomp/src/game/quick_restart_policy.hpp": "5010861fba8256c6eea29f8335e15423c2f1d829ae8c72881aa9df4c327558f9", + "runtime-recomp/src/game/record_store.inl": "e395f8886cacb1d42e434bca682dfcd891f501f1ce3613391cc0386045e9bba3", + "runtime-recomp/src/game/records_guest.inl": "e2487b32046c6544bf7cdce0f79a30a823d805efc4479695ebd0b344d8917f6b", + "runtime-recomp/src/game/remove_member.inl": "32ff2da5243662fdc5b8aa359e70efb38abe304737c9b5cb678be566db18857b", + "runtime-recomp/src/game/render_heap_support.cpp": "ed4c631063068728da8f24e67172813bf88c9945628299db820c2c11cea0989d", + "runtime-recomp/src/game/render_power_policy.hpp": "02368e28edbff235e46370fb2fe813e8a16cda4e985d1110273fa2fd5532fc93", + "runtime-recomp/src/game/render_provenance.hpp": "960727a6b4c72a065a19d6a26a5b1a70e99e76f2fe1b8a040724c95caae89933", + "runtime-recomp/src/game/render_resource_policy.hpp": "25e2fc735ea7f1d0aa63a5e13fad6ccd024318d1455c282df2df6c3d234a391c", + "runtime-recomp/src/game/renderer_snapshot.cpp": "2cdf834dd1511b6e2803191001d555e351bddf66fbbbe3836c80b99f5bb533f6", + "runtime-recomp/src/game/renderer_snapshot.hpp": "740b66c84f400b2dcd42b886804c8c377b34faed93d67c8b1eaa46565d8b5f24", + "runtime-recomp/src/game/replay_probe_capture.cpp": "7a38a5f2f14b6da015d4b9d5d48d687b0787131d39eb1e16634672f5a76815e3", + "runtime-recomp/src/game/replay_probe_capture.hpp": "4df3c3b6022618e91abe28078b28b3b7cd6f833cf0174d96a58c39a9d923ad3f", + "runtime-recomp/src/game/report_ui.inl": "70c0f43dfee272896de8f2ed65b932929257da004e8d2bd0f52e99446d89f2b1", + "runtime-recomp/src/game/resident_music.cpp": "a68cbd6293676e92b5ad0c523f2ceb4df076f62a3a2ecdf68452b0a3675857fc", + "runtime-recomp/src/game/resident_music.hpp": "e2473c0d3d938c4d0e8ae4984e1cae6fc5187dc24cd88f4324ab230ac78d3f30", + "runtime-recomp/src/game/retained_metadata_table.hpp": "7bf598d4169334a5a4cf85a946701e3cbffb67524ec60f9bd25df10cd7ee020c", + "runtime-recomp/src/game/reusable_flow_map.hpp": "841fc6f4b924ffa2a5236808282391a06cd3f954a9ad6c80d0d2610fcf87e1a3", + "runtime-recomp/src/game/rev_a_asset_mutex.cpp": "b502f7aa8c235dae74643eb3c6c11be6716d2e455bcdbc255748bf8be4db2af7", + "runtime-recomp/src/game/rev_a_asset_mutex.hpp": "d523832a967afd503ac9da2e7598b2ce43bb30463da220b7047acecc935a79d2", + "runtime-recomp/src/game/revision_addresses.hpp": "2d3fc56d244b7b69ae6802f4d483f208ddccadf5cdf136c023c705c4dff99ae3", + "runtime-recomp/src/game/rice_texture_pack_policy.hpp": "55193299cff03dcd0248ff70e78187b0c760d41f17675b4cc5598a4a348ac8c0", + "runtime-recomp/src/game/rollback_check.cpp": "4f50fcdc5d5827c4efc65693b00c578c8695feff15619b0c1eb499db1e99f7ee", + "runtime-recomp/src/game/rollback_check.hpp": "15e261c6db3e1806100cbac36334e441cc2ccba31ad3b34940aad42700d3587c", + "runtime-recomp/src/game/rollback_policy.hpp": "19305ea0c6437dc646260418bbe09b56d87796b71f9a2538f35dca4779a66964", + "runtime-recomp/src/game/rom_revision.cpp": "709f5e5f81555bf52a1051ec288333985b3e55ba8e25b4096b6663b0bd503825", + "runtime-recomp/src/game/rom_revision.hpp": "ba731193879bd69caadfc49367d795fa737e32175350f570a4b2e1571a44d3c4", + "runtime-recomp/src/game/rt64_renderer.cpp": "2c86e8f6befa98e500bf3c20542a92bd400262748aa014a0bedb51c0546b29f4", + "runtime-recomp/src/game/rt64_renderer.hpp": "84ee395971f068ba2e74ec4d41262265d1e3f5ff3d5d9ebb53be0829961eb538", + "runtime-recomp/src/game/runtime_audio_controls.cpp": "e761c1ac9ceacb78e354506f9ca7b38ebdac3174074a9aac80bb897e2501fb64", + "runtime-recomp/src/game/runtime_audio_controls.hpp": "7ee2dec22bb4b613fbd916622999490a438f38fb81a72defe120dd4965d47017", + "runtime-recomp/src/game/runtime_camera_tuning_ui.inl": "65fce6b5e0a93a2cc7bec8050d149a905572233b69e21dba151c23c58c21a216", + "runtime-recomp/src/game/runtime_code_mods.cpp": "84092b8f3e6268298719e1cac705e897e2c8bac75b9f94ebc89f4a5079f0525a", + "runtime-recomp/src/game/runtime_code_mods.hpp": "82348e27c93bc3635dd88312c293eaa04450302a39d8adb86dbb8b46afbf6a9c", + "runtime-recomp/src/game/runtime_code_mods_ui.inl": "262a2b2c363914be8b717ab0af812f4ddeebc88ad73e645ad9039b0efd83a5cc", + "runtime-recomp/src/game/runtime_controls_ui.inl": "df5f05aa7f6d7eaceb62c2812d9d2f3a9e732ec9cfe0490151c02eb3b1f9c643", + "runtime-recomp/src/game/runtime_crt_overlay.cpp": "e69243c06a25d4f00150a5ad6697c2c3426e34ae55b4a46b644624def0996a86", + "runtime-recomp/src/game/runtime_crt_overlay.hpp": "158f5c42de685c3c0222ee59262817cf4b849deb525a1d6419d54f34c930903c", + "runtime-recomp/src/game/runtime_details_ui.inl": "a17850ab5ef32172d09f3712435a9f1a61fcd3a9510708187b3a6a80112510dc", + "runtime-recomp/src/game/runtime_enhancements.cpp": "2c939b99407dc6774e421a077de32461f84d9b1e2886562669f61f8b87586ad4", + "runtime-recomp/src/game/runtime_enhancements.hpp": "d7193a3d314b9fbdeb79cadf7ce8f72a225f49cb085e52f91a61e7d48cf77238", + "runtime-recomp/src/game/runtime_graphics_ui.inl": "ff93249722495a4ca3950dc47874ccd335cd3e58acd0c896a8decaad9bd8a5aa", + "runtime-recomp/src/game/runtime_hud_layout.cpp": "f1c2a47879ee3f5e03306d0f294f154db1042fdcb61b6256bd8b0bb86b6b55e4", + "runtime-recomp/src/game/runtime_hud_layout.hpp": "4bbfe8718f2c3dbaed0d07bc423e25fc330282b8adfb7a29399253aa7a8fbdbc", + "runtime-recomp/src/game/runtime_input.cpp": "78796991a9bf0af8162a2c3a706d868f101f1b8c449b0fd4465f0d714d3710d1", + "runtime-recomp/src/game/runtime_input.hpp": "44f60a19f71d470e5a83434f7aaf200896bdb465d543549822cc8c2780fa6970", + "runtime-recomp/src/game/runtime_legacy_mods.cpp": "aa57ca057010c45fb25673408d0c744945891ba93d40838d348a6e13e5ce24c3", + "runtime-recomp/src/game/runtime_legacy_mods.hpp": "512db1eda96dd0af6f3755712ddfaa4cb1f61d9464045bc8d5f30559010b922b", + "runtime-recomp/src/game/runtime_log_archive.hpp": "652a293c9f58ffd3f48626ecda61185391c192dcf435069d0a4d47ac252d6bfe", + "runtime-recomp/src/game/runtime_magic_codes.cpp": "3677872b7ed147325bb969b1465c20d11b99d4d33456c13f2c19606f7c6f4f06", + "runtime-recomp/src/game/runtime_magic_codes.hpp": "2a697c3e0b5657539384b5ca39139fcdcfebc93153027b02e209607c4cb4567f", + "runtime-recomp/src/game/runtime_messages_ui.inl": "57b39f182291af1d96349f9814349243890a55c4bafb8e232a7379eff68151fa", + "runtime-recomp/src/game/runtime_mipmap_loading.hpp": "122c0a2cb31b0a8254124c15aaf5bcc0752669ae2a7340562bd37bce3f142acb", + "runtime-recomp/src/game/runtime_mipmap_modal.hpp": "28bf4e3dab8ed945be617f73cc6928d2333e4cb02782d17aeb9a9283ff0e8130", + "runtime-recomp/src/game/runtime_mod_library_ui.inl": "4b20f0a9d60d73a235346bbcca9dd99776dab68213d8e0be94c203695b3c5b3a", + "runtime-recomp/src/game/runtime_netplay.cpp": "da65c535c8d441be2452a676a1754d0bb8d38b70bc5563111a4785f2c1b6b07e", + "runtime-recomp/src/game/runtime_netplay.hpp": "2f366ad57546595a67e7ca55eca4fa84384860e0d629959849b6fbeba2c5f51a", + "runtime-recomp/src/game/runtime_online_mods_ui.inl": "9a6ffc69f1ca82f1fa1102070d5831efab76c12e6489d805aa5a9e4edd4f759e", + "runtime-recomp/src/game/runtime_online_ui.inl": "dc0aee90538b0eafa5e610a86b37b026c338fb7ca9d845521c5a80c06606f11b", + "runtime-recomp/src/game/runtime_platform.cpp": "701f578c83e1e65f37e7044b7fff76bf3ba81ec84bfac56dc22c58580187bb13", + "runtime-recomp/src/game/runtime_platform.hpp": "360339d0d43661da9df093021bfab247337bbf1135870d8f796abed6f441acb3", + "runtime-recomp/src/game/runtime_play_ui.inl": "c6faf4338f4781f8bd80d6b645d05f48dd51d6f4a0838c5f0ee877755eb169aa", + "runtime-recomp/src/game/runtime_portable.cpp": "2ac7926528990120ef0964d0b1c42fb3f4d15e36ced2b0efce18e562592f12b8", + "runtime-recomp/src/game/runtime_portable.hpp": "9cb6628a403038f66b4fea79f4386ee74c1fb90028e641285e9a55e243d852cf", + "runtime-recomp/src/game/runtime_quick_restart.cpp": "7baaf12163578d46b2964a7666a69f430360028f67803d9fe351e226c536a3ce", + "runtime-recomp/src/game/runtime_rice_texture_import.cpp": "fd7c198efbdfacdbc903e0f814fa950dbba1fcf7157bef879b3c8c6db2f002b0", + "runtime-recomp/src/game/runtime_rice_texture_import.hpp": "7a8a30f6214b5a3212c1d767b72255cc917a20b8b9552cf0e73c055d6171acb9", + "runtime-recomp/src/game/runtime_save_routing.cpp": "3940f532c123c765c48f42f1c8c4c55f7a7bd6d329712d4ba966ec87e6d55bda", + "runtime-recomp/src/game/runtime_save_routing.hpp": "ca804927f62f264c94b881e2ffb46c741ca25827c98fa727e7ce1cd179b6592f", + "runtime-recomp/src/game/runtime_saves_ui.inl": "9d5967975f85363e8f97372e689755c61f6f38bc6c95da55cec81720229311db", + "runtime-recomp/src/game/runtime_settings_ui.inl": "cba332e9d07a4d3a2c5ef1c91f8c4eb2a1890d24f990d6e4f164cbff980d65ff", + "runtime-recomp/src/game/runtime_sound_ui.inl": "41bd9641a6582b02366d79246a973ad303efb93dc2358c5d275d7b322ee74253", + "runtime-recomp/src/game/runtime_stubs.cpp": "57c382f8d0196f945ac7bbd16f48ce8dc3f34b2c8b4a77bea51d15241d2ce5b5", + "runtime-recomp/src/game/runtime_support.cpp": "4790c5abd5120d37f6c837b453d8cd51b3d97398df5e1b35819985221f08865c", + "runtime-recomp/src/game/runtime_support.hpp": "8399997da133be821ae2a88adad9989ce6b6ededdf6e60e2445e2159e906b3dc", + "runtime-recomp/src/game/runtime_telemetry.cpp": "c750d7c7627c720852f8bc39c66cda8e5fb7fb6e1ed1152252f243cade8b8b4a", + "runtime-recomp/src/game/runtime_telemetry.hpp": "865c8e698e359a19b2bcad6d8d5e6016a2691b7fe91fdcdbd840d3360185b028", + "runtime-recomp/src/game/runtime_texture_packs.cpp": "5dcd5d5ee22aacae9d78e31848b1e61ddcbb44b1f921ac13a3f4bc482157b5b2", + "runtime-recomp/src/game/runtime_texture_packs.hpp": "c53a3962a7940590c3e9491bff8fac7fe6fc3ddc3fa5a7a4953ba98a6f32ef6d", + "runtime-recomp/src/game/runtime_trophy_editor_ui.inl": "49bc04b02fb6335c2c49375649633c16ed50ec36bb09828e4ccfe89167973b7e", + "runtime-recomp/src/game/runtime_ui.cpp": "5741f3f36d636559f4c1164fec8c5203e8d781b2ad2ea348c26b6ebae69250bb", + "runtime-recomp/src/game/runtime_ui.hpp": "da6d21693a4969772fc29e734fb80e29c68c1917f4c8e10f67c6273b47fdaae4", + "runtime-recomp/src/game/runtime_ui_paddock.inl": "8f999997d4450d5a573af5a48e5939f9629dbadde4da358e7ec34ae940324023", + "runtime-recomp/src/game/runtime_updates_ui.inl": "e45d32f8dcd56d6148983a09e3d484210214d9162b8193f3fc97d06a87ff1f69", + "runtime-recomp/src/game/save_manager.cpp": "910805998b38303198a63a74235d5382ad1a9fcacfda20f6c26d99dedfbd37b1", + "runtime-recomp/src/game/save_manager.hpp": "921b0f2976114be6ac5937a048664a17c46092e49f25abd6a03b7cb5b4cee095", + "runtime-recomp/src/game/save_storage.hpp": "a1e9a75c7c364a07a08351c558f8ae2c161bc4fcca2defd9dea9fdbf81201f4c", + "runtime-recomp/src/game/scene_load_work.hpp": "fa007372151f93363f6f4921523254728dfa0ddd65b4edbea57db38c46d8059d", + "runtime-recomp/src/game/scheduler_event_policy.hpp": "42830ded37fe0bc2d6eea6a092902304f94ab045e1d257c72a8a504f43b2e219", + "runtime-recomp/src/game/scoreboard_wire.inl": "fc17c3229936579211a855cbdc45002a944fffffcda5f464c9bb7160caad5af5", + "runtime-recomp/src/game/sdk_task_source.hpp": "e61bdcc3be01093b2fb5d20388bbe1e967f75dd5a2548fa783c6aa53f0ab27cd", + "runtime-recomp/src/game/sdl3_input_client.cpp": "4cffa1d541ef323bf703d9031fc98a5268dbccdcdc34ca732e60970a79ee88b6", + "runtime-recomp/src/game/sdl3_input_client.hpp": "d2eb3b8102b110f705dc89e81de3b5f38d6a354491d3977aadd9da00ca55f8de", + "runtime-recomp/src/game/sdl3_input_protocol.hpp": "6fc70dc7d3ba2c5f8323e0c8dabf00d3eefdca5e6b70d6bb8de4e1f78b956707", + "runtime-recomp/src/game/shared_save_backend.inl": "ccb4addeb916b77cd2371cd7ab7dffd86686aa24970ac56a9b5595242b7ac97e", + "runtime-recomp/src/game/shared_save_jobs.inl": "9303249628fcf46ee0d964824c933ab1b55c907aaa4ab47f69b2c053ca9f865c", + "runtime-recomp/src/game/shared_saves.hpp": "6b608927dc3ad5168423da85cf8bce2ff8342e4d689f52a798628c4d55845dc5", + "runtime-recomp/src/game/split_screen_rt64.hpp": "88d1b94ff52fe3e23ed3e72ba81214791a8050edaea4e9048adb6f82e618d243", + "runtime-recomp/src/game/start_gate.hpp": "6dd9d8ef2ad4ede6e0e432aef0cbeb1866c22fceefd13678868a3c9525deee70", + "runtime-recomp/src/game/startup_flow.cpp": "bb6a36bdcd0187f5aa773aab5ca4eb5f8030e018b18068999a964001fe8bc613", + "runtime-recomp/src/game/startup_flow.hpp": "a6d11d3ab43d65251d22062b277e303b114f24b09fa4a6165e132664b9d91fa6", + "runtime-recomp/src/game/startup_performance.cpp": "bb93f25cab8c39ae39652b42945ffaaa24c320fcb0caf8bc4396853ac1ef97ab", + "runtime-recomp/src/game/startup_performance.hpp": "fc8657106f2f58742c8bbf756e0feac4044037593ede1b6008b3d45611bbed17", + "runtime-recomp/src/game/steering_wheel_policy.hpp": "bb5e3b0c83705521640115c0f703ceb35eff9fb82bb43d1506d32827fa36acb8", + "runtime-recomp/src/game/store_account_page.inl": "2f8d34a4a9441f57bf752a84fe63eb3ba75effef4f15225c4ce2b768648ed689", + "runtime-recomp/src/game/store_page_shell.inl": "c8280d08435706e5e7d5559c1ec4b73536f2b9b5b15f4997ec3b95f6bcf57423", + "runtime-recomp/src/game/store_player_activity.inl": "ea38235e1d84f4088df9dbcdc46de8f9b0d837455539629af537949bc688b7b0", + "runtime-recomp/src/game/store_player_details.inl": "3f465273ae089ff80da2411ca8cb963f7584a81dc41ac37d72543d047aa7d1f2", + "runtime-recomp/src/game/store_player_library.inl": "fc33561ab75b231fca11344951d6ed8bd5f15294d34ff85f47545cf4a8792a7b", + "runtime-recomp/src/game/store_polish_controls.inl": "ec0a315c38fdb51a4de121cd7aa6d8f6ef61f7079e31e4961f4dac4b9113019f", + "runtime-recomp/src/game/store_polish_modals.inl": "59a634fb5c35c02f4f1746ea1128acecd877a8ac5dfac6330e866a1ffddd62dd", + "runtime-recomp/src/game/store_staff_pages.inl": "dff2233dfeec23fb50a60e6f09ab85c6c3db54527d3386c5072a5cb41398f25b", + "runtime-recomp/src/game/store_texture_adapter.inl": "b778a2c3c9ad5a9dbd61518877122044771504ec54a979bea5e8c21e81a84483", + "runtime-recomp/src/game/store_track_adapter.inl": "6130b7ed8d829ddfeafccbd1f42e8978b6075534cd3492c657cf913cb69d119c", + "runtime-recomp/src/game/store_track_locator.inl": "dab05941810b5279baba8db756c7b8ba205ffc6dc99cee9050ccb54d8e3e5bdd", + "runtime-recomp/src/game/store_track_zip.inl": "4d54e33aa62a099f7239b7e478c2813a6afc2952a2e6fffbc44dae689e2c8ad9", + "runtime-recomp/src/game/store_vehicle_adapter.cpp": "24740f7a721c8412e928236a1ae80205c0076e68bb8aafff752cfe52832b03f9", + "runtime-recomp/src/game/store_vehicle_adapter.hpp": "031751984f70c257b1a735e931eb7db5fbc1d7a85adaece749a5f60ee535bb27", + "runtime-recomp/src/game/task_submission.cpp": "b9ee03aff27fdddf6d12ae53c7bc900d32c70e412d981deda1e8b19240283282", + "runtime-recomp/src/game/texture_binding_index.hpp": "dcdda7228b5bf31548ca816e89eaf3b0a8c4d218f0d1800b3e53df879ec01e3c", + "runtime-recomp/src/game/texture_capture_bridge.hpp": "cf77bfecf802ced4434f48442f85271816d6db62e49ca28649297bcd49e44c15", + "runtime-recomp/src/game/texture_decode.hpp": "30fcb659ada72d95d935d7f1ee7ebed3aad169cbdea1c75b014b3318951e6293", + "runtime-recomp/src/game/texture_dump.cpp": "7b63377311947d24e9243bc4c92d1b6aa13f56939d0b307440edeb9aa8e6da66", + "runtime-recomp/src/game/texture_dump.hpp": "950b719c8d6bd394865af964960116a355ab4a22e9617c5983a9f9f0931a103d", + "runtime-recomp/src/game/texture_dump_ui.inl": "e8b967c5bbff55819232446dbb9837c4e0c48a123ef19243b23b78b00904eaf8", + "runtime-recomp/src/game/texture_gallery.inl": "f86b86bb83eed17fe34f846e2ef708c53a6cd3cbf6691da216baee06b0045cb4", + "runtime-recomp/src/game/texture_pack_browser_policy.hpp": "518f58811f4ef3baaf8fa0efc196c453455c8df0ed7ce704d1a18ac06a13043e", + "runtime-recomp/src/game/texture_pack_priority_policy.hpp": "0ff39e634b552ee24a186054c8971a44d8bcc863079cf48bc73d6c1bfeef2165", + "runtime-recomp/src/game/title_text_rt64.hpp": "02d434918bb11b590c8df0901c47852076e781296cdd6f2d033b52278eedb1c3", + "runtime-recomp/src/game/track_performance.hpp": "5a8a3c3e91911f12acd835238846fbd7150fb4d927139acc4588ca4e85431af0", + "runtime-recomp/src/game/track_select_draw_guest.h": "2809d5c318818938f2cd95166e400ec93101a5f92d929e9152558c271eb8e1c7", + "runtime-recomp/src/game/track_select_draw_policy.hpp": "6da32bf95ecb508d5bf3123a923dd630a8eeea2f3f3e936c62fc192946e40c9d", + "runtime-recomp/src/game/ui_notification_policy.hpp": "c21227c579a15c39356bfa4ce3298505663b9e00e426aeae03e7ec7097edd064", + "runtime-recomp/src/game/updates/update_core.cpp": "8c1cf7b324387082f3147369eca3844894c7f113a4bf4fd2299a2f27bb0ab1f7", + "runtime-recomp/src/game/updates/update_core.hpp": "c9c72e484ca0848a08ce4da6a5aabd1335300795e08fc8feba2d3e65f33b401a", + "runtime-recomp/src/game/updates/update_helper_main.cpp": "ad9e4010d31d7c353b76629f6c0048c76314b3c0a21cb7b17466b2d5b8093039", + "runtime-recomp/src/game/updates/update_http.cpp": "2c6cc7f50921be2093b8f09cac2f9a0ea836503f4e169cf2b735b31e463185c3", + "runtime-recomp/src/game/updates/update_http.hpp": "90a92e48c4c7ee05fa0947894d242c5056a5d8779c1dba76c495c9a38d32b388", + "runtime-recomp/src/game/updates/update_install.cpp": "16f12b911835873ef451afe0a57078435425baac9c4b43e9c89786d0283b68bc", + "runtime-recomp/src/game/updates/update_install.hpp": "67b7cd7f6c37fb6aef51e618599ee1567e868893a3e00c10fb886b86cf0e0230", + "runtime-recomp/src/game/updates/update_process.cpp": "ece1cc686c443d61d9830ad3ea63bd6767d441929e6e96eb945394bc8acd5995", + "runtime-recomp/src/game/updates/update_process.hpp": "89cb301a3f7bd85fb1fa6338fc8b15b6be2161c05184f078328ba33b2854cafb", + "runtime-recomp/src/game/updates/update_service.cpp": "bd1f170283a9fd44e647aa9d193f26b54abf05054cbd6779dbb383fa235dbef7", + "runtime-recomp/src/game/updates/update_service.hpp": "1f675644eacddda82eb0b327f4f992b959fd0fb161c4fd877cca7a0a8394c959", + "runtime-recomp/src/game/vehicle_base_hashes.hpp": "0fb3b31b2f31be4f5ae02b3d52c10555223e6d510cdf0851743a665ad1fe91a9", + "runtime-recomp/src/game/vehicle_binding.cpp": "2fef7176444e85c7e8aa53dadddad2fcde1d741288b486731e9448f02b0bc71c", + "runtime-recomp/src/game/vehicle_binding.hpp": "643d59a3dbfd9d7bf84f8477be0a5fd49d6217ecaa4b171b482fe98dc379f945", + "runtime-recomp/src/game/vehicle_character_masks.hpp": "fef4e9aecf76d4c26168340668aa0cc2985314f2743d79a2514855866a4c2ceb", + "runtime-recomp/src/game/vehicle_context_policy.hpp": "0b545e3f40e6bf1556a74f67fbdf6e3303b699be5f64601b536ae6e55198ec17", + "runtime-recomp/src/game/vehicle_library.cpp": "5f9b908284afd26d3f4179dafa97c8aace9a0aaa391a745555ce57bdcbb0c7dc", + "runtime-recomp/src/game/vehicle_library.hpp": "7fbce2f86b894750f0a485b57cc24fdce6c1f36e80f4fa7a1895073889d2f4cf", + "runtime-recomp/src/game/vehicle_online.hpp": "8c09ace7172c1f8fbd9ba21eae665e7b12eb392caf9a4fd84e530c2fa6bbaeb6", + "runtime-recomp/src/game/vehicle_package.cpp": "f5510ebc24c55f7cfd8add1ba67ba0403e4c1cd14f8768b8afed0b2fbc6a81a4", + "runtime-recomp/src/game/vehicle_package.hpp": "c2b5ffa9687b57e83a98140e8c140409af1bc25cb705c01f35e0fa7c8d231c5a", + "runtime-recomp/src/game/vehicle_protocol.hpp": "0a64c682b45caa81bf4d6c33d5707e4b123b09d7194ed1134746bdc70aa1eb31", + "runtime-recomp/src/game/vehicle_resources.cpp": "75fb7abea982d85dba6b4ba2aac291feaf5d27543331d1a8f8f8138eb1943a02", + "runtime-recomp/src/game/vehicle_resources.hpp": "fc4b343b6d41df8b92eedf34caa172c35395365768f4a012cd699652c9d17ee3", + "runtime-recomp/src/game/vehicle_rt64.cpp": "219dc460ec3de3d0874ab1c0bbf9a7500eb460e9b2384cb143afeea779e84f7b", + "runtime-recomp/src/game/vehicle_rt64.hpp": "a73145fcb532ce20e2755044ca939efc51b8bf55e800128cfdebdc0fc334d72f", + "runtime-recomp/src/game/vehicle_seat_alignment.hpp": "614877b75709d293b7b8b3e00eb10f9f2bd61e4fdf7d6728087fe4084a0d5f24", + "runtime-recomp/src/game/vehicle_store_ui.inl": "21b67f14bae1c4ba831b271ea8d49be69858240af7706d192f12636ecdcc571b", + "runtime-recomp/src/game/vehicle_texture_scope.hpp": "ba04edaeb6cdb7b85126e366235eab044b08ee4c37258733ab170481f6fc7e7c", + "runtime-recomp/src/game/vehicle_ui.inl": "f72d9fd2191a61a6e28c1bc59a91f1ceee59b03755d7b43d03c903ec17af76e7", + "runtime-recomp/src/game/vehicle_wheel_interpolation.hpp": "4c893e1ca665df97eb33f7acea01fc80f44860e6a8495d0618e5b75e73a54b97", + "runtime-recomp/src/game/vehicle_world.cpp": "6c01ab03ff8b6ebf2b62718bd5e410754d98f9a5e8fec6332b3aa789e4497ea8", + "runtime-recomp/src/game/vehicle_world.hpp": "07159b23dac97e43ae6ab9337426d13eec100d2b7e3c75b0963f16aa7575c758", + "runtime-recomp/src/game/vi_presentation_policy.hpp": "c9012e484e0b96cb27b34dd01ca8f2eefd0cd63c03fdc97e818018bc0695bf62", + "runtime-recomp/src/game/virtual_pak.cpp": "2c470eb010a35f6b2f547487bc7fb8a53152a03e87411af546d7d8a2dbf36e44", + "runtime-recomp/src/game/virtual_pak.hpp": "a3d23164848e7f2e95cd872956917cf4274db88af7ddd6f7dc884207887fde62", + "runtime-recomp/src/game/virtual_pak_policy.hpp": "9f95d2fc3817f8b79991ac74df12db8cd4461342774762b44c1aec702a2df320", + "runtime-recomp/src/game/visual_model_kernel.c": "e1bc434c090515e792c5546a378eabd735be098a96e1813fb1f3ce0edf42b428", + "runtime-recomp/src/game/visual_model_kernel.h": "1e0e77b44f13cc2b69fc89297534b29d227cb5fe2799e40ab9a8ea10d782c00a", + "runtime-recomp/src/game/visual_model_kernel_guest.h": "1e5edacbe63a6811a5a5e1e54e96f1b3fae3b2f1c02cc4bcee4ce47730f8e371", + "runtime-recomp/src/game/water_performance.hpp": "ced1cdfe967e4d01ba5ebad7210fa44046f5686c880ccb4e420c8418afbe8011", + "runtime-recomp/src/game/water_profile.cpp": "76789c0f3ff05898c98ff419e644f8f1a316d675e2745a3dc5f799c9b69a7fe2", + "runtime-recomp/src/game/water_residency_rt64.hpp": "25a09c73e2c2aa0a57a91ce2d55644dbe56aec75eb1858ab56a999a209099f50", + "runtime-recomp/src/game/water_scroll_policy.hpp": "959b4e7e0475520e09c9fe97d33cc5b318c2d8adca92c472514270f4968e0914", + "runtime-recomp/src/game/water_uv_rt64.hpp": "44cca5f8b5565ef18241dd4e2c455b5cd5596cb46dce86ce9d0f64bb6fddad89", + "runtime-recomp/src/game/waterfall_scroll_policy.hpp": "a215f3661b20421f2e4707ca1a212a19f5670c56645b81ff66b29214df23cfcc", + "runtime-recomp/src/game/widescreen_policy.hpp": "4406f0b929be89eeb04cd8d4edd914a12288f6c792dae630056d675209a0bee9", + "runtime-recomp/src/game/wrapped_input.inl": "080388e14a3030725bb7f8bbdec74594d992dcb877a61c100e5bb7e8a480fdf3", + "runtime-recomp/src/input/sdl3_input_host.cpp": "ea6b7e7f59f95cbfdc10a3fe0919477fa33dd1eee864dd3ff29ebff573286235", + "runtime-recomp/tests/replay_probe/owned_game.hpp": "75e0efffabf66605c88b220a104f4649032e9ff7121f3566d780913056ae76d3", + "runtime-recomp/tests/replay_probe/probe_audio.c": "e3d00e269a8afbd8fb9f38aff9d4e660305fd68e461be85838d4edfb4b7a7fe9", + "runtime-recomp/tests/replay_probe/probe_audio.h": "13ac6ba2f60278ab69490c049c307ddf6df3c08a38559d3b1d731b5582c5e59b", + "runtime-recomp/tests/replay_probe/probe_audio_events.c": "757cf75052ff9b268e8bc6960109471ce60d56a94ecbd0a1ad69ed4ea73deea1", + "runtime-recomp/tests/replay_probe/probe_audio_rsp.cpp": "3d3c4c50d92ab72d2e81586450d6d66d67078e886cc643f784a57719b32a31ba", + "runtime-recomp/tests/replay_probe/probe_bridge.c": "a3c9cbb355f2706bd978400cc39bc90b6a04064ac9c4c6dd281f8bf05bcc8b30", + "runtime-recomp/tests/replay_probe/probe_bridge.h": "0ba2441e641ef07c315e49abc8175042907c5a1cf943f956435d946bce2cf685", + "runtime-recomp/tests/replay_probe/probe_component.c": "b48de1147765dd6804c924b36c3b8578981581712d5cca4409171bb54ac883e5", + "runtime-recomp/tests/replay_probe/probe_component.h": "791cb99d7c01250e22e0a85b5effdd24aae0ef74ea75b3190cd6024ac967d54e", + "runtime-recomp/tests/replay_probe/probe_driver.cpp": "29bc0aa892099643740f326d0d9b16f56e8e7d3ba53d7e753935256a19cd6c05", + "runtime-recomp/tests/replay_probe/probe_driver.hpp": "86ed78d34c707b3d89829ad2c39ff8bdc6d3ddfcf0c834d440171f252e58388d", + "runtime-recomp/tests/replay_probe/probe_eeprom.cpp": "0dd039c66cae3a132c2bb726cb31a7a9d0f63d3b18810963d7f1aaf1c4c11c26", + "runtime-recomp/tests/replay_probe/probe_input.c": "10f405249182b05183c078ff4f615a807445c897e0dbf54424850e1174f717a6", + "runtime-recomp/tests/replay_probe/probe_input.cpp": "177a2100d78fc4fee21f0c1131fea6b125fd5b1bdb193bc625ea253e8b062571", + "runtime-recomp/tests/replay_probe/probe_input.h": "f5b0d835a2ddde4f21034bc9e5f363ec4f5bef059811f8c9a2575f46cb73834e", + "runtime-recomp/tests/replay_probe/probe_magic.c": "51bc563ab7149c9d28e19fb6e814a7f6939543ed794c196331548eec4837fadd", + "runtime-recomp/tests/replay_probe/probe_magic.cpp": "291fc25734200bf58e294dedd3024d801c3aeba87bbc3d49a9088ff5a4abb84b", + "runtime-recomp/tests/replay_probe/probe_magic.h": "be86ddb13c520c8c762f8b87cab5df9b1a187810bf72911f7a1cadfcbb873e3a", + "runtime-recomp/tests/replay_probe/probe_memory_inline.h": "336ed7ff823b9f78ac3bd8d4a5ddc9504e03cbbe46967d094f86063e711a3729", + "runtime-recomp/tests/replay_probe/probe_native.c": "1873f875499be9c07d634767765c84f2d5fc38feb403e7a6cdacf2eabc2c1cf7", + "runtime-recomp/tests/replay_probe/probe_pak.c": "e5399d482ae56d7c20d5db7b0224a6e7b4c39926357e5bb5276c9a89b833d14d", + "runtime-recomp/tests/replay_probe/probe_pak.cpp": "f11a075dd49cede0eaeaf224a8c4d737fedce6c50a95bc8a3e0c94e5de5655e1", + "runtime-recomp/tests/replay_probe/probe_pak.h": "2822ed655e48ed3cea4ee77dd1519d55093dbdd3df7254558f2bec3f8c2c9233", + "runtime-recomp/tests/replay_probe/probe_policies.cpp": "a3bbc43819631dd64a3acfbc06ed988e08151bc56728c7dbc7743fbb2a76f72b", + "runtime-recomp/tests/replay_probe/probe_presentation.c": "92f6393f1654b4ed9ad4a4fe8a91a4e3e043326fe516dba0103c497fdf02646f", + "runtime-recomp/tests/replay_probe/probe_presentation.h": "05a4d76018f8a477b69858dad5faf125ea72d967bf0ccfda8d8211d1f0d3fa41", + "runtime-recomp/third_party/dr_libs/dr_mp3.h": "b28f9cacb7aa3b33c3d70b5ed0ab6783f073dffc80396bb49917984f68945aa8", + "runtime-recomp/third_party/dr_libs/dr_wav.h": "c15c0a6dfa02240b762ed013cdc0872720a9d13027520bbf93c21677bcc3aa9d" + } +} diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index c5d5dc8e..0fc3f5b0 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -403,6 +403,12 @@ def explained(problem: str, allowed: list[dict[str, str]]) -> str | None: return None +def write_record(args: argparse.Namespace, level: str, **counts: object) -> None: + """One level's result for the evidence record (run_equivalence.sh).""" + if getattr(args, "record", None): + atomic_write(args.record, json.dumps({"level": level, **counts}, indent=1) + "\n") + + def run_targets(args: argparse.Namespace) -> int: mapping = Mapping(args.reference, args.folded_build) reference = load_codemodel(args.reference / "codemodel") @@ -427,6 +433,7 @@ def run_targets(args: argparse.Namespace) -> int: f"{len(problems)} unexplained differences", file=sys.stderr, ) + write_record(args, "targets", compared=len(shipped), allowed=len(found) - len(problems), unexplained=problems) return 1 if problems else 0 @@ -680,6 +687,14 @@ def name(path: Path) -> str: f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", file=sys.stderr, ) + write_record( + args, + "level1", + tus=len(pairing.pairs), + files=len(same_bytes), + allowed=len(problems) - len(unexplained), + unexplained=unexplained, + ) return 1 if unexplained else 0 @@ -796,6 +811,15 @@ def run_level2(args: argparse.Namespace) -> int: f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", file=sys.stderr, ) + write_record( + args, + "level2", + token_identical=same, + level3=level3, + shader_pending=sorted(skipped), + allowed=len(problems) - len(unexplained), + unexplained=unexplained, + ) return 1 if unexplained else 0 @@ -866,9 +890,12 @@ def run_level3(args: argparse.Namespace) -> int: args.out.mkdir(parents=True, exist_ok=True) problems: list[str] = [] checked: list[str] = [] + covered: set[str] = set() for old_key, new_key in sorted(pairing.pairs.items()): - if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) not in split or new_key not in folded_entries: + name = apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) + if name not in split or new_key not in folded_entries: continue + covered.add(name) label = f"{new_key[0]}: {portable(mapping, new_key[1])}" old_text = (args.reference_pp / f"{output_name(old_key)}.i").read_text(errors="surrogateescape") new_text = (args.folded_pp / f"{output_name(new_key)}.i").read_text(errors="surrogateescape") @@ -903,6 +930,9 @@ def run_level3(args: argparse.Namespace) -> int: "".join(difflib.unified_diff(old_text.splitlines(True), new_text.splitlines(True))), encoding="utf-8" ) problems.append(f"{label}: object sections differ: {', '.join(differ)}") + # Every TU header-variants.json says compiled against another header + # version must have been compared here (or found token-identical). + problems += [f"{name}: header-variant TU not compared" for name in sorted(split - covered)] allowed = load_allowed(ALLOWED) unexplained = [problem for problem in problems if explained(problem, allowed) is None] for label in checked: @@ -912,10 +942,19 @@ def run_level3(args: argparse.Namespace) -> int: reason = explained(problem, allowed) print(problem if reason is None else f"allowed: {problem} ({reason})") print( - f"level3: {len(checked)} header-variant TUs compiled, {len(problems) - len(unexplained)} allowed, " + f"level3: {len(covered)} header-variant TUs covered, {len(checked)} compiled, " + f"{len(problems) - len(unexplained)} allowed, " f"{len(unexplained)} unexplained (diffs in {args.out})", file=sys.stderr, ) + write_record( + args, + "level3", + covered=len(covered), + compiled=checked, + allowed=[problem for problem in problems if problem not in unexplained], + unexplained=unexplained, + ) return 1 if unexplained else 0 @@ -964,6 +1003,87 @@ def run_options(args: argparse.Namespace) -> int: return 0 +CLOSURE = ROOT / "scripts/fold/closure.json" +WHITESPACE = re.compile(rb"\s+") + + +def closure_digest(data: bytes) -> str: + """sha256 of a closure file as level 1 compares it, with whitespace + dropped as well, so the record survives the format-only commit (B9).""" + return hashlib.sha256(WHITESPACE.sub(b"", comparable(data))).hexdigest() + + +def run_closure(args: argparse.Namespace) -> int: + """The ROM-free part of level 1: every first-party file of the shipped + closure still holds the v1.0.11 bytes. --write records them from the + reference (devops machine); --check needs only the work tree (CI).""" + if args.write: + mapping = Mapping(args.reference, ROOT / "build" / "unused") + files = { + dest: closure_digest(data) + for dest, data in mapping.result.contents.items() + if dest not in apply_closure.GENERATED + } + # Guest-derived but first-party since B4. + for staged, folded in mapping.dependency.items(): + if folded.is_relative_to(ROOT / "runtime-recomp"): + files[folded.relative_to(ROOT).as_posix()] = closure_digest(staged.read_bytes()) + record = { + "about": "Whitespace-insensitive sha256 (scripts/fold/equivalence.py closure_digest) of every " + "first-party file the shipped v1.0.11 binaries compile, as folded. Written from the reference " + "with `equivalence.py closure --reference DIR --write`; CI runs `closure --check`.", + "files": dict(sorted(files.items())), + } + atomic_write(CLOSURE, json.dumps(record, indent=1) + "\n") + print(f"closure: {len(files)} files recorded", file=sys.stderr) + return 0 + files = json.loads(CLOSURE.read_text(encoding="utf-8"))["files"] + problems: list[str] = [] + for dest, digest in sorted(files.items()): + path = ROOT / dest + if not path.is_file(): + problems.append(f"{dest}: missing") + elif closure_digest(path.read_bytes()) != digest: + problems.append(f"{dest}: differs from the shipped v1.0.11 file (whitespace aside)") + for problem in problems: + print(problem) + print(f"closure: {len(files)} files checked, {len(problems)} differ", file=sys.stderr) + return 1 if problems else 0 + + +EVIDENCE_INPUTS = ( + "scripts/fold/allowed-deltas.json", + "scripts/fold/header-variants.json", + "scripts/fold/shader-pending.json", + "scripts/fold/closure.json", + "patches/manifest.json", + "runtime-recomp/guest-hooks/hooks.toml", + "runtime-recomp/src/game/netplay/legacy_identity.hpp", +) + + +def run_evidence(args: argparse.Namespace) -> int: + """fold-equivalence.json: what was compared, against what, and the result + of every level. Steve signs it (docs/fold-in/REFERENCE.md) and attaches it + to the PR.""" + levels = [json.loads(path.read_text(encoding="utf-8")) for path in args.records] + commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip() + dirty = subprocess.run(["git", "status", "--porcelain"], cwd=ROOT, capture_output=True, text=True).stdout + evidence = { + "schema": 1, + "commit": commit, + "work_tree_clean": not dirty.strip(), + "image": args.image, + "reference": {name: sha for name, sha in DOC_ROW.findall(REFERENCE_DOC.read_text(encoding="utf-8"))}, + "inputs": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in EVIDENCE_INPUTS}, + "levels": {level.pop("level"): level for level in levels}, + "passed": all(not level["unexplained"] for level in levels) and len(levels) == 4, + } + atomic_write(args.out, json.dumps(evidence, indent=1) + "\n") + print(f"evidence: {args.out} ({'passed' if evidence['passed'] else 'FAILED'})", file=sys.stderr) + return 0 if evidence["passed"] and evidence["work_tree_clean"] else 1 + + REFERENCE_DOC = ROOT / "docs/fold-in/REFERENCE.md" DOC_ROW = re.compile(r"^\| `([^`]+)`[^|]*\| `([0-9a-f]{64})` \|$", re.M) @@ -1012,6 +1132,7 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: command.add_argument("--reference", type=Path, required=True, help="the reference directory") command.add_argument("--folded-build", type=Path, required=True, help="the folded build directory") command.add_argument("--verbose", action="store_true", help="also list allowed differences and notes") + command.add_argument("--record", type=Path, help="write this level's result for the evidence record") return command targets = compared("targets", "compare per-source compile settings and link lines") @@ -1024,6 +1145,15 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: level2.add_argument("--folded-compdb", type=Path, required=True) level2.add_argument("--reference-pp", type=Path, required=True) level2.add_argument("--folded-pp", type=Path, required=True) + evidence = sub.add_parser("evidence", help="assemble fold-equivalence.json from the level records") + evidence.add_argument("--out", type=Path, required=True) + evidence.add_argument("--image", required=True) + evidence.add_argument("records", nargs="+", type=Path) + closure = sub.add_parser("closure", help="record or check the first-party closure bytes (ROM-free level 1)") + closure.add_argument("--reference", type=Path, help="the reference directory (with --write)") + closure_mode = closure.add_mutually_exclusive_group(required=True) + closure_mode.add_argument("--write", action="store_true") + closure_mode.add_argument("--check", action="store_true") verify = sub.add_parser("verify-reference", help="check the reference against REFERENCE.md and its own pins") verify.add_argument("--reference", type=Path, required=True) options = sub.add_parser("options", help="print the folded configure options, one per line") @@ -1054,6 +1184,8 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: setattr(args, key, getattr(args, key).resolve()) handlers = { "verify-reference": run_verify_reference, + "closure": run_closure, + "evidence": run_evidence, "targets": run_targets, "level1": run_level1, "level2": run_level2, diff --git a/scripts/fold/run_equivalence.sh b/scripts/fold/run_equivalence.sh index d884eccb..9156c8b0 100755 --- a/scripts/fold/run_equivalence.sh +++ b/scripts/fold/run_equivalence.sh @@ -21,7 +21,8 @@ # objects for the TUs level 2 lets differ because of a header variant # (level 3, always run). Exit status is non-zero on any unexplained # difference; scripts/fold/allowed-deltas.json, header-variants.json and -# shader-pending.json list the explained ones. +# shader-pending.json list the explained ones. BUILD_DIR/equivalence/ +# fold-equivalence.json records the run for signing (docs/fold-in/REFERENCE.md). set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" reference="$(cd "${1:?usage: run_equivalence.sh REFERENCE_DIR [BUILD_DIR]}" && pwd)" @@ -70,14 +71,17 @@ if [ ! -f "$cache/depscan-by-target.json" ]; then fi status=0 +rm -f "$out"/record-*.json "$out/fold-equivalence.json" +echo "== closure (ROM-free level 1 subset, as CI runs it)" +python3 "$tool" closure --check || status=1 echo "== dependency stages" python3 "$root/scripts/fold/dependency_stages.py" --reference "$reference" --build "$build" --check || status=1 echo "== targets" -python3 "$tool" targets --reference "$reference" --folded-build "$build" || status=1 +python3 "$tool" targets --reference "$reference" --folded-build "$build" --record "$out/record-targets.json" || status=1 echo "== level 1" python3 "$tool" level1 --reference "$reference" --folded-build "$build" \ --folded-compdb "$out/shipped-compdb.json" --folded-deps "$out/depscan.json" \ - --reference-deps "$cache/depscan-by-target.json" || status=1 + --reference-deps "$cache/depscan-by-target.json" --record "$out/record-level1.json" || status=1 echo "== level 2" rm -rf "$out/pp" @@ -86,11 +90,16 @@ if [ ! -d "$cache/pp" ]; then in_image "python3 -B '$tool' preprocess --compdb '$reference/shipped-compdb.json' --build '$ref_build' --out '$cache/pp' --jobs $jobs" fi python3 "$tool" level2 --reference "$reference" --folded-build "$build" \ - --folded-compdb "$out/shipped-compdb.json" --reference-pp "$cache/pp" --folded-pp "$out/pp" || status=1 + --folded-compdb "$out/shipped-compdb.json" --reference-pp "$cache/pp" --folded-pp "$out/pp" \ + --record "$out/record-level2.json" || status=1 echo "== level 3" rm -rf "$out/level3" in_image "python3 -B '$tool' level3 --reference '$reference' --folded-build '$build' \ --folded-compdb '$out/shipped-compdb.json' --reference-pp '$cache/pp' --folded-pp '$out/pp' \ - --out '$out/level3'" || status=1 + --out '$out/level3' --record '$out/record-level3.json'" || status=1 + +echo "== evidence" +records=("$out"/record-*.json) +python3 "$tool" evidence --out "$out/fold-equivalence.json" --image "$image" "${records[@]}" || status=1 exit "$status" From d5d007132018b418e7ec848a2147ea74e061d437 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 23:08:57 -0500 Subject: [PATCH 28/62] macOS build of the folded tree: libc++ and Metal fixes (B6) Five sites use AtomicSnapshot, file-clock counts go through int64_t, the water residency include is skipped on Apple. portability.json pins the files; their Linux TUs must compile to identical objects (level 3). --- runtime-recomp/cmake/LegacyMods.cmake | 5 ++ runtime-recomp/src/game/achievements.cpp | 3 +- runtime-recomp/src/game/f3ddkr_rt64.cpp | 3 ++ runtime-recomp/src/game/mod_store.cpp | 3 +- runtime-recomp/src/game/mod_store_install.cpp | 9 ++-- runtime-recomp/src/game/mod_store_names.cpp | 4 +- runtime-recomp/src/game/online_directory.cpp | 3 +- runtime-recomp/src/game/texture_gallery.inl | 2 +- runtime-recomp/src/game/vehicle_binding.cpp | 3 +- scripts/fold/equivalence.py | 30 ++++++++++-- scripts/fold/portability.json | 46 +++++++++++++++++++ 11 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 scripts/fold/portability.json diff --git a/runtime-recomp/cmake/LegacyMods.cmake b/runtime-recomp/cmake/LegacyMods.cmake index 07ba620c..f39ddff4 100644 --- a/runtime-recomp/cmake/LegacyMods.cmake +++ b/runtime-recomp/cmake/LegacyMods.cmake @@ -128,6 +128,11 @@ if(TARGET rt64) # The game gets miniz from RT64's existing archive, exactly once. The child # worker gets a separate small provider, never the renderer or window stack. target_link_libraries(DKRLegacyModCore PRIVATE rt64) + if(APPLE) + # Metal builds skip the enhanced-water stage, whose RT64 interface + # carries src/game (the vehicle protocol headers) elsewhere. + target_include_directories(DKRLegacyModCore PRIVATE "${_dkr_mod_src}/..") + endif() else() target_link_libraries(DKRLegacyModCore PRIVATE DKRLegacyModMiniz) # RT64's interface carries src/game (the vehicle protocol headers) otherwise. diff --git a/runtime-recomp/src/game/achievements.cpp b/runtime-recomp/src/game/achievements.cpp index 5162d76b..7f4c768b 100644 --- a/runtime-recomp/src/game/achievements.cpp +++ b/runtime-recomp/src/game/achievements.cpp @@ -1,4 +1,5 @@ #include "achievements.hpp" +#include "atomic_snapshot.hpp" #include "achievement_symbols.hpp" #include #include @@ -100,7 +101,7 @@ struct Event {Sample sample;bool reset=false,is_online=false,modded=false;unsign class Service { std::mutex mutex_;std::condition_variable cv_;bool stopping_=false; std::deque> jobs_;std::deque toasts_; - std::atomic> published_{std::make_shared()}; + dkr::runtime::AtomicSnapshot published_{std::make_shared()}; std::thread worker_;std::filesystem::path path_;View state_;Sample previous_,latest_; bool baseline_=false;Racer adventure_human_;std::array driver_{};std::array,10> earned_balloon_totals_; std::uint64_t last_epoch_=~0ULL;std::uint32_t last_frame_=~0U;Json retained_=Json::object(); diff --git a/runtime-recomp/src/game/f3ddkr_rt64.cpp b/runtime-recomp/src/game/f3ddkr_rt64.cpp index 904e8c74..975319ce 100644 --- a/runtime-recomp/src/game/f3ddkr_rt64.cpp +++ b/runtime-recomp/src/game/f3ddkr_rt64.cpp @@ -26,7 +26,10 @@ #include "credits_sky_rt64.hpp" #include "intro_caption_rt64.hpp" #include "intro_sky_rt64.hpp" +#if !defined(__APPLE__) +// Metal builds have no enhanced-water renderer (cmake/EnhancedWater.cmake). #include "water_residency_rt64.hpp" +#endif #include "presentation_pacer.hpp" #include "title_text_rt64.hpp" #include "water_performance.hpp" diff --git a/runtime-recomp/src/game/mod_store.cpp b/runtime-recomp/src/game/mod_store.cpp index 6c8e8895..9a375f59 100644 --- a/runtime-recomp/src/game/mod_store.cpp +++ b/runtime-recomp/src/game/mod_store.cpp @@ -1,4 +1,5 @@ #include "online_directory.hpp" +#include "atomic_snapshot.hpp" #include "linux_keyring.hpp" #include "store_vehicle_adapter.hpp" #include "mod_store.hpp" @@ -616,7 +617,7 @@ struct Service::Impl { std::mutex download_persist_mutex; std::condition_variable_any cv; Snapshot state; - std::atomic> published{std::make_shared()}; + dkr::runtime::AtomicSnapshot published{std::make_shared()}; std::filesystem::path root; std::string version, token, device, browser; unsigned account = 0; diff --git a/runtime-recomp/src/game/mod_store_install.cpp b/runtime-recomp/src/game/mod_store_install.cpp index acd0114e..7af09f46 100644 --- a/runtime-recomp/src/game/mod_store_install.cpp +++ b/runtime-recomp/src/game/mod_store_install.cpp @@ -1,4 +1,5 @@ #include "store_vehicle_adapter.hpp" +#include "atomic_snapshot.hpp" #include "mod_store_install.hpp" #include "custom_tracks.hpp" #include "mods/legacy_mod_library.hpp" @@ -283,14 +284,14 @@ std::string name_stamp(const fs::path& input) { ordinary(path, fs::is_directory(path)); if (fs::is_regular_file(path)) entries["file"] = std::to_string(fs::file_size(path)) + ":" + - std::to_string(fs::last_write_time(path).time_since_epoch().count()); + std::to_string(static_cast(fs::last_write_time(path).time_since_epoch().count())); else for (const auto& e : fs::directory_iterator(path)) { ordinary(e.path(), false); if (entries.size() >= 200005) return {}; entries[path_text(e.path().filename())] = - std::to_string(e.file_size()) + ":" + std::to_string(e.last_write_time().time_since_epoch().count()); + std::to_string(e.file_size()) + ":" + std::to_string(static_cast(e.last_write_time().time_since_epoch().count())); } const auto bytes = Json(entries).dump(); return dkr::updates::hash(std::span(reinterpret_cast(bytes.data()), bytes.size())); @@ -302,7 +303,7 @@ std::string track_stamp(const fs::path& input) { for (const auto& e : fs::recursive_directory_iterator(path)) if (e.is_regular_file()) entries[path_text(e.path().lexically_relative(path))] = - std::to_string(e.file_size()) + ":" + std::to_string(e.last_write_time().time_since_epoch().count()); + std::to_string(e.file_size()) + ":" + std::to_string(static_cast(e.last_write_time().time_since_epoch().count())); const auto bytes = Json(entries).dump(); return dkr::updates::hash(std::span(reinterpret_cast(bytes.data()), bytes.size())); } @@ -482,7 +483,7 @@ bool recover_cached_package(const fs::path& folder, const fs::path& target, cons struct InstallManager::Impl { mutable std::mutex mutex; std::condition_variable_any cv; - std::atomic> published{std::make_shared()}; + dkr::runtime::AtomicSnapshot published{std::make_shared()}; InstallView state; fs::path config, root, worker_path; std::string version; diff --git a/runtime-recomp/src/game/mod_store_names.cpp b/runtime-recomp/src/game/mod_store_names.cpp index 2fef08b6..424ee031 100644 --- a/runtime-recomp/src/game/mod_store_names.cpp +++ b/runtime-recomp/src/game/mod_store_names.cpp @@ -53,7 +53,7 @@ std::string stamp(const std::filesystem::path& path, std::stop_token stop) { return {}; if (std::filesystem::is_regular_file(path)) { entries["file"] = std::to_string(std::filesystem::file_size(path)) + ":" + - std::to_string(std::filesystem::last_write_time(path).time_since_epoch().count()); + std::to_string(static_cast(std::filesystem::last_write_time(path).time_since_epoch().count())); } else { for (const auto& entry : std::filesystem::directory_iterator(path)) { if (stop.stop_requested() || entries.size() >= 200005 || entry.is_symlink()) @@ -61,7 +61,7 @@ std::string stamp(const std::filesystem::path& path, std::stop_token stop) { if (!entry.is_regular_file()) return {}; entries[utf8(entry.path().filename())] = std::to_string(entry.file_size()) + ":" + - std::to_string(entry.last_write_time().time_since_epoch().count()); + std::to_string(static_cast(entry.last_write_time().time_since_epoch().count())); } } const auto value = Json(entries).dump(); diff --git a/runtime-recomp/src/game/online_directory.cpp b/runtime-recomp/src/game/online_directory.cpp index 91bc3d3f..f6275488 100644 --- a/runtime-recomp/src/game/online_directory.cpp +++ b/runtime-recomp/src/game/online_directory.cpp @@ -1,4 +1,5 @@ #include "online_directory.hpp" +#include "atomic_snapshot.hpp" #include "directory_tls.hpp" #include "monocypher.h" #include @@ -20,7 +21,7 @@ struct State { std::condition_variable_any cv; AccountView account; AccountRequest request; View ui; - std::atomic> published{std::make_shared()}; + dkr::runtime::AtomicSnapshot published{std::make_shared()}; std::map> bridges; struct JoinKey {secure::KeyPair pair;secure::Key admission;}; std::map keys; diff --git a/runtime-recomp/src/game/texture_gallery.inl b/runtime-recomp/src/game/texture_gallery.inl index 756b163c..3e4d3f21 100644 --- a/runtime-recomp/src/game/texture_gallery.inl +++ b/runtime-recomp/src/game/texture_gallery.inl @@ -113,7 +113,7 @@ public: }else ++hits; auto& preview=found->second;preview.used=++clock_; item.preview_width=preview.width;item.preview_height=preview.height;item.rgba=preview.rgba; - item.preview_key=text_path(root_)+"/"+item.path+":"+std::to_string(index_generation_)+":"+std::to_string(stamp.bytes)+":"+std::to_string(stamp.modified.time_since_epoch().count()); + item.preview_key=text_path(root_)+"/"+item.path+":"+std::to_string(index_generation_)+":"+std::to_string(stamp.bytes)+":"+std::to_string(static_cast(stamp.modified.time_since_epoch().count())); }catch(const std::exception& e) { // One damaged PNG must not prevent navigation through the dump. item.preview_error=e.what(); diff --git a/runtime-recomp/src/game/vehicle_binding.cpp b/runtime-recomp/src/game/vehicle_binding.cpp index 008a7db6..e7b2c72a 100644 --- a/runtime-recomp/src/game/vehicle_binding.cpp +++ b/runtime-recomp/src/game/vehicle_binding.cpp @@ -1,4 +1,5 @@ #include "vehicle_binding.hpp" +#include "atomic_snapshot.hpp" #include #include @@ -7,7 +8,7 @@ namespace { struct Binding { std::weak_ptr owner;std::shared_ptr resources; }; std::mutex mutex; std::map bindings; -std::atomic> renderer; +dkr::runtime::AtomicSnapshot renderer; } void bind_resources(std::shared_ptr owner,std::shared_ptr resources) { if(!owner||!resources||resources->library.packages.empty())return; diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 0fc3f5b0..8929b520 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -65,6 +65,17 @@ # TUs that include shader headers the build writes later, so they cannot be # preprocessed after a configure. Only these may skip level 2. SHADER_PENDING = ROOT / "scripts/fold/shader-pending.json" +# Portability fixes for platforms v1.0.11 never compiled (B6): the files they +# change, pinned to their reviewed bytes, and the Linux TUs whose tokens they +# change, which must then compile to identical objects (level 3). +PORTABILITY = ROOT / "scripts/fold/portability.json" + + +def load_portability() -> tuple[dict[str, str], set[str]]: + data = json.loads(PORTABILITY.read_text(encoding="utf-8")) + return {path: entry["sha256"] for path, entry in data["files"].items()}, set(data["tus"]) + + # Folded targets that compile what one reference target compiled: the game's # TUs moved into an object library so host builds without a generated payload # compile them too. Compile units and dependencies are compared under the @@ -644,6 +655,7 @@ def run_level1(args: argparse.Namespace) -> int: allow = variant_tus(ROOT, forwarders=True) allowed = load_allowed(ALLOWED) problems: list[str] = list(pairing.unmapped) + ported, _ = load_portability() same_bytes: dict[tuple[Path, Path], bool] = {} def name(path: Path) -> str: @@ -668,6 +680,10 @@ def name(path: Path) -> str: expected.add(folded) if dep not in first_party: continue + if name(folded) in ported: + if closure_digest(folded.read_bytes()) != ported[name(folded)]: + problems.append(f"{label}: {name(folded)} differs from its reviewed portability fix") + continue if (dep, folded) not in same_bytes: same_bytes[(dep, folded)] = folded.is_file() and comparable(dep.read_bytes()) == comparable( folded.read_bytes() @@ -761,6 +777,7 @@ def run_level2(args: argparse.Namespace) -> int: for side, directory in (("reference", args.reference_pp), ("folded", args.folded_pp)) } expected_pending = set(json.loads(SHADER_PENDING.read_text(encoding="utf-8"))["tus"]) + _, ported_tus = load_portability() skipped: set[str] = set() same = 0 for old_key, new_key in sorted(pairing.pairs.items()): @@ -791,7 +808,7 @@ def run_level2(args: argparse.Namespace) -> int: if old == new: same += 1 continue - if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) in level3_names: + if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) in level3_names or label in ported_tus: level3.append(label) continue first = next((i for i, (a, b) in enumerate(zip(old, new)) if a != b), min(len(old), len(new))) @@ -891,12 +908,13 @@ def run_level3(args: argparse.Namespace) -> int: problems: list[str] = [] checked: list[str] = [] covered: set[str] = set() + _, ported_tus = load_portability() for old_key, new_key in sorted(pairing.pairs.items()): name = apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) - if name not in split or new_key not in folded_entries: - continue - covered.add(name) label = f"{new_key[0]}: {portable(mapping, new_key[1])}" + if (name not in split and label not in ported_tus) or new_key not in folded_entries: + continue + covered.add(name if name in split else label) old_text = (args.reference_pp / f"{output_name(old_key)}.i").read_text(errors="surrogateescape") new_text = (args.folded_pp / f"{output_name(new_key)}.i").read_text(errors="surrogateescape") if tokens(old_text, mapping) == tokens(new_text, None): @@ -933,6 +951,7 @@ def run_level3(args: argparse.Namespace) -> int: # Every TU header-variants.json says compiled against another header # version must have been compared here (or found token-identical). problems += [f"{name}: header-variant TU not compared" for name in sorted(split - covered)] + problems += [f"{label}: portability TU not compared" for label in sorted(ported_tus - covered)] allowed = load_allowed(ALLOWED) unexplained = [problem for problem in problems if explained(problem, allowed) is None] for label in checked: @@ -1038,6 +1057,8 @@ def run_closure(args: argparse.Namespace) -> int: print(f"closure: {len(files)} files recorded", file=sys.stderr) return 0 files = json.loads(CLOSURE.read_text(encoding="utf-8"))["files"] + ported, _ = load_portability() + files.update({dest: digest for dest, digest in ported.items() if dest in files}) problems: list[str] = [] for dest, digest in sorted(files.items()): path = ROOT / dest @@ -1056,6 +1077,7 @@ def run_closure(args: argparse.Namespace) -> int: "scripts/fold/header-variants.json", "scripts/fold/shader-pending.json", "scripts/fold/closure.json", + "scripts/fold/portability.json", "patches/manifest.json", "runtime-recomp/guest-hooks/hooks.toml", "runtime-recomp/src/game/netplay/legacy_identity.hpp", diff --git a/scripts/fold/portability.json b/scripts/fold/portability.json new file mode 100644 index 00000000..fbf86858 --- /dev/null +++ b/scripts/fold/portability.json @@ -0,0 +1,46 @@ +{ + "about": "Portability fixes for platforms v1.0.11 never compiled (fold step B6). Each file is pinned to its reviewed bytes (whitespace-insensitive sha256, equivalence.py closure_digest); level 1 and the closure check compare against that instead of the shipped bytes. Each listed TU's Linux tokens change, so level 3 must find its objects identical.", + "files": { + "runtime-recomp/src/game/achievements.cpp": { + "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", + "sha256": "f17da14cff56cee42d8d3d6a2e9b6e3fc37c9371747e3cd005238c3c5dab124f" + }, + "runtime-recomp/src/game/f3ddkr_rt64.cpp": { + "reason": "Metal builds have no enhanced-water renderer; the water_residency_rt64.hpp include is skipped under __APPLE__ (token-identical elsewhere).", + "sha256": "6a669be2a612362d7f67a9e3df3faa1b48166637ca1d0179d5414f4d7e9690d4" + }, + "runtime-recomp/src/game/mod_store.cpp": { + "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", + "sha256": "195b07d395a55bc9580abfb13fb7406ebe25cc9394d7d0c7d7eac6fc7e2adfd2" + }, + "runtime-recomp/src/game/mod_store_install.cpp": { + "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC. libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", + "sha256": "2a26f704b2e2b8699c1cb1ab98f5491aba6454d8660c13e91e052f56fc697f30" + }, + "runtime-recomp/src/game/mod_store_names.cpp": { + "reason": "libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", + "sha256": "b32f11a1484b0a649d7cc5962e926fdec663f126e7c9363bcc967ca687b7eafe" + }, + "runtime-recomp/src/game/online_directory.cpp": { + "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", + "sha256": "74547dc4930c827598f1a6fd62cbd64c3575d455df952581393acec3a04d1d71" + }, + "runtime-recomp/src/game/texture_gallery.inl": { + "reason": "libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", + "sha256": "a4b3f753ac48d5299094fd48cdc31a2a65afa645e50ccc5f548007b77ebc01c7" + }, + "runtime-recomp/src/game/vehicle_binding.cpp": { + "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", + "sha256": "823af3eab2ff306d0c112e22bceb39dac0885b4249165aa4935319215934e574" + } + }, + "tus": [ + "DKRCustomVehicles: runtime-recomp/src/game/vehicle_binding.cpp", + "DKRNetplayCore: runtime-recomp/src/game/online_directory.cpp", + "DKRPortGame: runtime-recomp/src/game/achievements.cpp", + "DKRPortGame: runtime-recomp/src/game/mod_store.cpp", + "DKRPortGame: runtime-recomp/src/game/mod_store_install.cpp", + "DKRPortGame: runtime-recomp/src/game/mod_store_names.cpp", + "DKRPortGame: runtime-recomp/src/game/texture_dump.cpp" + ] +} From 30bfe32c58d09fd79355fe8a3ab8743ee85c3c64 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 23:19:39 -0500 Subject: [PATCH 29/62] Let portability TUs change their header set; count them in level 3 --- scripts/fold/equivalence.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 8929b520..10a763e2 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -655,7 +655,7 @@ def run_level1(args: argparse.Namespace) -> int: allow = variant_tus(ROOT, forwarders=True) allowed = load_allowed(ALLOWED) problems: list[str] = list(pairing.unmapped) - ported, _ = load_portability() + ported, ported_tus = load_portability() same_bytes: dict[tuple[Path, Path], bool] = {} def name(path: Path) -> str: @@ -690,6 +690,8 @@ def name(path: Path) -> str: ) if not same_bytes[(dep, folded)] and tu_name not in allow.get(name(folded), set()): problems.append(f"{label}: {name(folded)} differs from what the reference TU read") + if label in ported_tus: + continue # its header set changed with the fix; level 3 compares its objects problems += [f"{label}: no longer reads {name(dep)}" for dep in sorted(expected - new) if dep.exists()] problems += [f"{label}: now reads {name(dep)}" for dep in sorted(new - expected)] problems += [ @@ -908,13 +910,15 @@ def run_level3(args: argparse.Namespace) -> int: problems: list[str] = [] checked: list[str] = [] covered: set[str] = set() + compared = 0 _, ported_tus = load_portability() for old_key, new_key in sorted(pairing.pairs.items()): name = apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) label = f"{new_key[0]}: {portable(mapping, new_key[1])}" if (name not in split and label not in ported_tus) or new_key not in folded_entries: continue - covered.add(name if name in split else label) + covered |= {name, label} + compared += 1 old_text = (args.reference_pp / f"{output_name(old_key)}.i").read_text(errors="surrogateescape") new_text = (args.folded_pp / f"{output_name(new_key)}.i").read_text(errors="surrogateescape") if tokens(old_text, mapping) == tokens(new_text, None): @@ -961,7 +965,7 @@ def run_level3(args: argparse.Namespace) -> int: reason = explained(problem, allowed) print(problem if reason is None else f"allowed: {problem} ({reason})") print( - f"level3: {len(covered)} header-variant TUs covered, {len(checked)} compiled, " + f"level3: {compared} header-variant and portability TUs covered, {len(checked)} compiled, " f"{len(problems) - len(unexplained)} allowed, " f"{len(unexplained)} unexplained (diffs in {args.out})", file=sys.stderr, @@ -969,7 +973,7 @@ def run_level3(args: argparse.Namespace) -> int: write_record( args, "level3", - covered=len(covered), + covered=compared, compiled=checked, allowed=[problem for problem in problems if problem not in unexplained], unexplained=unexplained, From 2f3db6672467da2481ee1e50e1b2e4892475baf7 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 23:48:40 -0500 Subject: [PATCH 30/62] Install pinned Python 3.12 in the new fold CI jobs --- .github/workflows/build-android.yml | 3 +++ .github/workflows/build-windows.yml | 3 +++ .github/workflows/fold-equivalence.yml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index cb36e091..4fa050b0 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -71,6 +71,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" - uses: ./.github/actions/prepare-dependencies # zizmor: ignore[self-repository] -- actionlint 1.7.12 compatibility; checkout uses this workflow revision. with: profile: android-full diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 58feced0..f2603d68 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -139,6 +139,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" # The configure runs guest_hooks.py, gen_guest_symbols.py and # stage_magic_code_payload.py, which need tomllib. - name: Require Python 3.11 or newer diff --git a/.github/workflows/fold-equivalence.yml b/.github/workflows/fold-equivalence.yml index 8cdeebab..f76e3121 100644 --- a/.github/workflows/fold-equivalence.yml +++ b/.github/workflows/fold-equivalence.yml @@ -23,5 +23,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" - name: First-party closure matches v1.0.11 run: python3 scripts/fold/equivalence.py closure --check From e017fbaa2811259a87f0f51d8001670166be83bb Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Sun, 25 Oct 2026 23:48:41 -0500 Subject: [PATCH 31/62] Use one mutex-guarded snapshot holder, skip curl on Android (B6) SharedSnapshot replaces std::atomic on every platform. Android has no store backend. portability.json names the five Linux TUs whose tokens change as allowed level 2 deltas. --- docs/fold-in/open-items.md | 25 +++------ runtime-recomp/CMakeLists.txt | 3 +- runtime-recomp/src/game/achievements.cpp | 4 +- runtime-recomp/src/game/mod_store.cpp | 11 +++- runtime-recomp/src/game/mod_store_install.cpp | 4 +- runtime-recomp/src/game/online_directory.cpp | 4 +- runtime-recomp/src/game/shared_snapshot.hpp | 34 ++++++++++++ runtime-recomp/src/game/vehicle_binding.cpp | 4 +- scripts/fold/equivalence.py | 54 ++++++++++++++----- scripts/fold/portability.json | 38 ++++++------- 10 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 runtime-recomp/src/game/shared_snapshot.hpp diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 493639ed..fa0b5aea 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -47,26 +47,17 @@ checkout (`prepare-dependencies` `decomp-symbols`) and, on Android, the new Known before they run: -- `android-folded` will fail until the libc++ gaps are fixed. A local run in - the CI image (NDK r29, same flags) configures cleanly and applies every - stage patch the Android chain takes. Everything else compiles, but 7 - first-party TUs don't: - - `std::atomic>`, which libc++ lacks (the B6 Apple gap): - `online_directory.cpp`, `vehicle_binding.cpp`, `achievements.cpp`, - `mod_store_install.cpp`; - - ambiguous `std::to_string` calls: `mod_store_names.cpp`, - `mod_store_install.cpp`, `texture_dump.cpp` (`texture_gallery.inl`); - - `curl/curl.h` in `mod_store.cpp` (the store links CURL only off Android, - Apple and Windows, but still compiles there). - v1.0.11 never compiled these for Android or Apple, so they fail closed. - The fixes are platform-guarded source changes that leave the Linux level 2 - stream unchanged, which is B6/C12 work. +- `android-folded` was red before B6. A local run in the CI image (NDK r29, + same flags) applied every stage patch the Android chain takes. Then 7 + first-party TUs failed on libc++ gaps v1.0.11 never hit: missing + `std::atomic`, `std::to_string` of a 128-bit file-clock + count, and `curl/curl.h`. B6 fixes them portably (`portability.json`), and + the same local run now builds. - Android doesn't configure `DKR_EXPERIMENTAL_RACE_TEST`, so it doesn't take `0001-render-stability` and `0003-present-queue-pacer` (they sit on the rollback chain). Android releases are discontinued, so that stays open. -- `windows-full-nogen` assumes the runner's `python` is 3.11 or newer; a - first step checks it. If `windows-2022` defaults to an older Python, it - needs a pinned `actions/setup-python`. +- The new jobs install Python 3.12 with the pinned `actions/setup-python`; + `windows-full-nogen` also checks for 3.11 or newer as a backstop. - Adding `decomp-symbols` to the source cache key invalidates the existing dependency caches once. diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index 57e823e0..d94cc1bc 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -1380,7 +1380,8 @@ if(DKR_RUNTIME_BUILD_GENERATED) target_link_libraries(DKRPortGameCore PRIVATE DKRCameraPresentation) if(WIN32) target_link_libraries(DKRPortGameCore PRIVATE winhttp crypt32 bcrypt) - elseif(NOT ANDROID AND NOT APPLE) + elseif(NOT ANDROID AND NOT IOS) + # macOS uses the system libcurl; Android and iOS have no store backend. find_package(CURL REQUIRED) target_link_libraries(DKRPortGameCore PRIVATE CURL::libcurl) endif() diff --git a/runtime-recomp/src/game/achievements.cpp b/runtime-recomp/src/game/achievements.cpp index 7f4c768b..05c25da6 100644 --- a/runtime-recomp/src/game/achievements.cpp +++ b/runtime-recomp/src/game/achievements.cpp @@ -1,5 +1,5 @@ #include "achievements.hpp" -#include "atomic_snapshot.hpp" +#include "shared_snapshot.hpp" #include "achievement_symbols.hpp" #include #include @@ -101,7 +101,7 @@ struct Event {Sample sample;bool reset=false,is_online=false,modded=false;unsign class Service { std::mutex mutex_;std::condition_variable cv_;bool stopping_=false; std::deque> jobs_;std::deque toasts_; - dkr::runtime::AtomicSnapshot published_{std::make_shared()}; + dkr::runtime::SharedSnapshot published_{std::make_shared()}; std::thread worker_;std::filesystem::path path_;View state_;Sample previous_,latest_; bool baseline_=false;Racer adventure_human_;std::array driver_{};std::array,10> earned_balloon_totals_; std::uint64_t last_epoch_=~0ULL;std::uint32_t last_frame_=~0U;Json retained_=Json::object(); diff --git a/runtime-recomp/src/game/mod_store.cpp b/runtime-recomp/src/game/mod_store.cpp index 9a375f59..e36e5673 100644 --- a/runtime-recomp/src/game/mod_store.cpp +++ b/runtime-recomp/src/game/mod_store.cpp @@ -1,5 +1,5 @@ #include "online_directory.hpp" -#include "atomic_snapshot.hpp" +#include "shared_snapshot.hpp" #include "linux_keyring.hpp" #include "store_vehicle_adapter.hpp" #include "mod_store.hpp" @@ -31,7 +31,10 @@ #include #include #else +// Android links no HTTPS backend (the CURL link in CMakeLists.txt). +#if !defined(__ANDROID__) #include +#endif #include #include #endif @@ -322,6 +325,10 @@ Response http_once(const Request& r, std::stop_token stop, const Sink& sink) { } return response; } +#elif defined(__ANDROID__) +Response http_once(const Request&, std::stop_token, const Sink&) { + throw Error("The Mod Store isn't available in this build."); +} #else Response http_once(const Request& r, std::stop_token stop, const Sink& sink) { if (!trusted_url(r.url, prefix(r.url, Api))) @@ -617,7 +624,7 @@ struct Service::Impl { std::mutex download_persist_mutex; std::condition_variable_any cv; Snapshot state; - dkr::runtime::AtomicSnapshot published{std::make_shared()}; + dkr::runtime::SharedSnapshot published{std::make_shared()}; std::filesystem::path root; std::string version, token, device, browser; unsigned account = 0; diff --git a/runtime-recomp/src/game/mod_store_install.cpp b/runtime-recomp/src/game/mod_store_install.cpp index 7af09f46..0981b306 100644 --- a/runtime-recomp/src/game/mod_store_install.cpp +++ b/runtime-recomp/src/game/mod_store_install.cpp @@ -1,5 +1,5 @@ #include "store_vehicle_adapter.hpp" -#include "atomic_snapshot.hpp" +#include "shared_snapshot.hpp" #include "mod_store_install.hpp" #include "custom_tracks.hpp" #include "mods/legacy_mod_library.hpp" @@ -483,7 +483,7 @@ bool recover_cached_package(const fs::path& folder, const fs::path& target, cons struct InstallManager::Impl { mutable std::mutex mutex; std::condition_variable_any cv; - dkr::runtime::AtomicSnapshot published{std::make_shared()}; + dkr::runtime::SharedSnapshot published{std::make_shared()}; InstallView state; fs::path config, root, worker_path; std::string version; diff --git a/runtime-recomp/src/game/online_directory.cpp b/runtime-recomp/src/game/online_directory.cpp index f6275488..e2a271e8 100644 --- a/runtime-recomp/src/game/online_directory.cpp +++ b/runtime-recomp/src/game/online_directory.cpp @@ -1,5 +1,5 @@ #include "online_directory.hpp" -#include "atomic_snapshot.hpp" +#include "shared_snapshot.hpp" #include "directory_tls.hpp" #include "monocypher.h" #include @@ -21,7 +21,7 @@ struct State { std::condition_variable_any cv; AccountView account; AccountRequest request; View ui; - dkr::runtime::AtomicSnapshot published{std::make_shared()}; + dkr::runtime::SharedSnapshot published{std::make_shared()}; std::map> bridges; struct JoinKey {secure::KeyPair pair;secure::Key admission;}; std::map keys; diff --git a/runtime-recomp/src/game/shared_snapshot.hpp b/runtime-recomp/src/game/shared_snapshot.hpp new file mode 100644 index 00000000..e8f4932b --- /dev/null +++ b/runtime-recomp/src/game/shared_snapshot.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +namespace dkr::runtime { +// A shared_ptr one thread publishes and others read. A mutex guards the +// pointer itself, so every standard library behaves the same: libc++ (Apple, +// Android) has no std::atomic. The memory_order arguments +// mirror that interface; the lock already orders the accesses at least as +// strongly. A replaced value is released outside the lock. +template class SharedSnapshot { + mutable std::mutex mutex_; + std::shared_ptr value_; +public: + SharedSnapshot() = default; + explicit SharedSnapshot(std::shared_ptr value) : value_(std::move(value)) {} + SharedSnapshot(const SharedSnapshot&) = delete; + SharedSnapshot& operator=(const SharedSnapshot&) = delete; + std::shared_ptr load(std::memory_order = std::memory_order_seq_cst) const { + std::scoped_lock lock(mutex_); + return value_; + } + void store(std::shared_ptr value, std::memory_order = std::memory_order_seq_cst) { + std::shared_ptr previous; + { + std::scoped_lock lock(mutex_); + previous = std::exchange(value_, std::move(value)); + } + } +}; +} diff --git a/runtime-recomp/src/game/vehicle_binding.cpp b/runtime-recomp/src/game/vehicle_binding.cpp index e7b2c72a..32b7160b 100644 --- a/runtime-recomp/src/game/vehicle_binding.cpp +++ b/runtime-recomp/src/game/vehicle_binding.cpp @@ -1,5 +1,5 @@ #include "vehicle_binding.hpp" -#include "atomic_snapshot.hpp" +#include "shared_snapshot.hpp" #include #include @@ -8,7 +8,7 @@ namespace { struct Binding { std::weak_ptr owner;std::shared_ptr resources; }; std::mutex mutex; std::map bindings; -dkr::runtime::AtomicSnapshot renderer; +dkr::runtime::SharedSnapshot renderer; } void bind_resources(std::shared_ptr owner,std::shared_ptr resources) { if(!owner||!resources||resources->library.packages.empty())return; diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index 10a763e2..c1f86698 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -66,14 +66,30 @@ # preprocessed after a configure. Only these may skip level 2. SHADER_PENDING = ROOT / "scripts/fold/shader-pending.json" # Portability fixes for platforms v1.0.11 never compiled (B6): the files they -# change, pinned to their reviewed bytes, and the Linux TUs whose tokens they -# change, which must then compile to identical objects (level 3). +# change, pinned to their reviewed bytes; the Linux TUs whose tokens change +# but whose objects must not (level 3); and the named TUs whose portable +# replacement changes Linux code on purpose (allowed level 2 deltas). PORTABILITY = ROOT / "scripts/fold/portability.json" -def load_portability() -> tuple[dict[str, str], set[str]]: +@dataclasses.dataclass(frozen=True) +class Portability: + files: dict[str, str] # repo path -> reviewed closure_digest + identical: frozenset[str] # TU labels: tokens differ, objects identical + deltas: dict[str, str] # TU label -> reason its Linux tokens may differ + + @property + def tus(self) -> frozenset[str]: + return self.identical | frozenset(self.deltas) + + +def load_portability() -> Portability: data = json.loads(PORTABILITY.read_text(encoding="utf-8")) - return {path: entry["sha256"] for path, entry in data["files"].items()}, set(data["tus"]) + return Portability( + {path: entry["sha256"] for path, entry in data["files"].items()}, + frozenset(data["objects_identical"]), + dict(data["token_deltas"]), + ) # Folded targets that compile what one reference target compiled: the game's @@ -655,7 +671,8 @@ def run_level1(args: argparse.Namespace) -> int: allow = variant_tus(ROOT, forwarders=True) allowed = load_allowed(ALLOWED) problems: list[str] = list(pairing.unmapped) - ported, ported_tus = load_portability() + portability = load_portability() + ported, ported_tus = portability.files, portability.tus same_bytes: dict[tuple[Path, Path], bool] = {} def name(path: Path) -> str: @@ -779,7 +796,8 @@ def run_level2(args: argparse.Namespace) -> int: for side, directory in (("reference", args.reference_pp), ("folded", args.folded_pp)) } expected_pending = set(json.loads(SHADER_PENDING.read_text(encoding="utf-8"))["tus"]) - _, ported_tus = load_portability() + portability = load_portability() + portable_deltas: list[str] = [] skipped: set[str] = set() same = 0 for old_key, new_key in sorted(pairing.pairs.items()): @@ -809,8 +827,13 @@ def run_level2(args: argparse.Namespace) -> int: new = tokens(new_file.read_text(errors="surrogateescape"), None) if old == new: same += 1 + if label in portability.tus: + problems.append(f"{label}: listed in {PORTABILITY.name} but token-identical") + continue + if label in portability.deltas: + portable_deltas.append(label) # portable replacement for a libc++ gap continue - if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) in level3_names or label in ported_tus: + if apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) in level3_names or label in portability.identical: level3.append(label) continue first = next((i for i, (a, b) in enumerate(zip(old, new)) if a != b), min(len(old), len(new))) @@ -826,7 +849,7 @@ def run_level2(args: argparse.Namespace) -> int: print(f"level 3 required: {label}") print( f"level2: {same} TUs token-identical, {len(level3)} header-variant TUs left for level 3, " - f"{len(skipped)} waiting for build-time shaders, " + f"{len(skipped)} waiting for build-time shaders, {len(portable_deltas)} portability deltas, " f"{len(problems) - len(unexplained)} allowed, {len(unexplained)} unexplained", file=sys.stderr, ) @@ -836,6 +859,7 @@ def run_level2(args: argparse.Namespace) -> int: token_identical=same, level3=level3, shader_pending=sorted(skipped), + portability_deltas={label: portability.deltas[label] for label in portable_deltas}, allowed=len(problems) - len(unexplained), unexplained=unexplained, ) @@ -911,11 +935,17 @@ def run_level3(args: argparse.Namespace) -> int: checked: list[str] = [] covered: set[str] = set() compared = 0 - _, ported_tus = load_portability() + portability = load_portability() for old_key, new_key in sorted(pairing.pairs.items()): name = apply_closure.tu_name(ROOT, mapping.folder, old_key[1]) label = f"{new_key[0]}: {portable(mapping, new_key[1])}" - if (name not in split and label not in ported_tus) or new_key not in folded_entries: + if label in portability.deltas and new_key in folded_entries: + # A portable replacement changes this TU's code on purpose, which + # also hides any header-variant effect; level 2 names the reason. + covered |= {name, label} + compared += 1 + continue + if (name not in split and label not in portability.identical) or new_key not in folded_entries: continue covered |= {name, label} compared += 1 @@ -955,7 +985,7 @@ def run_level3(args: argparse.Namespace) -> int: # Every TU header-variants.json says compiled against another header # version must have been compared here (or found token-identical). problems += [f"{name}: header-variant TU not compared" for name in sorted(split - covered)] - problems += [f"{label}: portability TU not compared" for label in sorted(ported_tus - covered)] + problems += [f"{label}: portability TU not compared" for label in sorted(portability.tus - covered)] allowed = load_allowed(ALLOWED) unexplained = [problem for problem in problems if explained(problem, allowed) is None] for label in checked: @@ -1061,7 +1091,7 @@ def run_closure(args: argparse.Namespace) -> int: print(f"closure: {len(files)} files recorded", file=sys.stderr) return 0 files = json.loads(CLOSURE.read_text(encoding="utf-8"))["files"] - ported, _ = load_portability() + ported = load_portability().files files.update({dest: digest for dest, digest in ported.items() if dest in files}) problems: list[str] = [] for dest, digest in sorted(files.items()): diff --git a/scripts/fold/portability.json b/scripts/fold/portability.json index fbf86858..704b3fe2 100644 --- a/scripts/fold/portability.json +++ b/scripts/fold/portability.json @@ -1,46 +1,48 @@ { - "about": "Portability fixes for platforms v1.0.11 never compiled (fold step B6). Each file is pinned to its reviewed bytes (whitespace-insensitive sha256, equivalence.py closure_digest); level 1 and the closure check compare against that instead of the shipped bytes. Each listed TU's Linux tokens change, so level 3 must find its objects identical.", + "about": "Portability fixes for platforms v1.0.11 never compiled (fold step B6). files: each pinned to its reviewed bytes (whitespace-insensitive sha256, equivalence.py closure_digest); level 1 and the closure check compare against that instead of the shipped bytes. objects_identical: Linux TUs whose tokens change but whose objects level 3 must find identical. token_deltas: the named TUs whose portable replacement changes Linux code on purpose, allowed at level 2.", "files": { "runtime-recomp/src/game/achievements.cpp": { - "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", - "sha256": "f17da14cff56cee42d8d3d6a2e9b6e3fc37c9371747e3cd005238c3c5dab124f" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform.", + "sha256": "ad83afc0e4a8d4e77f9ceb29dda472330a2cae36c194c1bd4ba64d0bc945657e" }, "runtime-recomp/src/game/f3ddkr_rt64.cpp": { "reason": "Metal builds have no enhanced-water renderer; the water_residency_rt64.hpp include is skipped under __APPLE__ (token-identical elsewhere).", "sha256": "6a669be2a612362d7f67a9e3df3faa1b48166637ca1d0179d5414f4d7e9690d4" }, "runtime-recomp/src/game/mod_store.cpp": { - "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", - "sha256": "195b07d395a55bc9580abfb13fb7406ebe25cc9394d7d0c7d7eac6fc7e2adfd2" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform. Android links no HTTPS backend, so its curl include and backend are skipped there (the store reports itself unavailable).", + "sha256": "0cb2aaa38265ad82b9da45ca448b1b12e6fe99b316c942acf73aed653f0aa7e0" }, "runtime-recomp/src/game/mod_store_install.cpp": { - "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC. libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", - "sha256": "2a26f704b2e2b8699c1cb1ab98f5491aba6454d8660c13e91e052f56fc697f30" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform. libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", + "sha256": "921cb38792d1182766e4d0c22408490fbf829429c6db780ec95b319cb6210ecd" }, "runtime-recomp/src/game/mod_store_names.cpp": { "reason": "libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", "sha256": "b32f11a1484b0a649d7cc5962e926fdec663f126e7c9363bcc967ca687b7eafe" }, "runtime-recomp/src/game/online_directory.cpp": { - "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", - "sha256": "74547dc4930c827598f1a6fd62cbd64c3575d455df952581393acec3a04d1d71" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform.", + "sha256": "bae7c9b705aa9298c3d70676cfab7dd88b34d595770d43b6fcc88d042f470b85" }, "runtime-recomp/src/game/texture_gallery.inl": { "reason": "libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", "sha256": "a4b3f753ac48d5299094fd48cdc31a2a65afa645e50ccc5f548007b77ebc01c7" }, "runtime-recomp/src/game/vehicle_binding.cpp": { - "reason": "libc++ (Apple, Android) lacks std::atomic; uses dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), the same type on libstdc++ and MSVC.", - "sha256": "823af3eab2ff306d0c112e22bceb39dac0885b4249165aa4935319215934e574" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform.", + "sha256": "744f24276aaab75d62e368f7730bf5634cc25c734e5b58aece04b00ecd37a7e2" } }, - "tus": [ - "DKRCustomVehicles: runtime-recomp/src/game/vehicle_binding.cpp", - "DKRNetplayCore: runtime-recomp/src/game/online_directory.cpp", - "DKRPortGame: runtime-recomp/src/game/achievements.cpp", - "DKRPortGame: runtime-recomp/src/game/mod_store.cpp", - "DKRPortGame: runtime-recomp/src/game/mod_store_install.cpp", + "objects_identical": [ "DKRPortGame: runtime-recomp/src/game/mod_store_names.cpp", "DKRPortGame: runtime-recomp/src/game/texture_dump.cpp" - ] + ], + "token_deltas": { + "DKRCustomVehicles: runtime-recomp/src/game/vehicle_binding.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", + "DKRNetplayCore: runtime-recomp/src/game/online_directory.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", + "DKRPortGame: runtime-recomp/src/game/achievements.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", + "DKRPortGame: runtime-recomp/src/game/mod_store.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", + "DKRPortGame: runtime-recomp/src/game/mod_store_install.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform (plus the file-clock casts)" + } } From 277cdacb2987971e8593b9be15507144faaa0afe Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 26 Oct 2026 00:21:43 -0500 Subject: [PATCH 32/62] Promote the prepared-input generators to prepare steps (B8a) prepare_payloads.py derives the staged payloads, owned engines and Camera++ and model kernels from the N64Recomp output into build/prepared, which PreparedInputs.cmake makes the default. Prepare-DKR-Runtime-macOS.sh runs it. - Scene kernel generator moves to scripts/, guest inputs to runtime-recomp/. - Portrait stage pins the seven folded mods sources instead of a native stage. - Probe audits pin the folded sources; self-isolated probe names are skipped. - Model kernel sorts its alias defines (it iterated a set). - Generators are strict-typed; the release-chain PS1 wrappers are gone. --- Build-macOS.sh | 19 +- architecture/pattern-guard-baseline.txt | 4 - docs/BUILDING.md | 53 ++- docs/fold-in/open-items.md | 37 +- patches/camera-plus/scene_kernel_pipeline.py | 188 ---------- pyproject.toml | 12 + runtime-recomp/CMakeLists.txt | 1 + runtime-recomp/cmake/Camera.cmake | 3 +- runtime-recomp/cmake/PreparedInputs.cmake | 33 ++ .../guest/save_layout_guest.h | 0 .../guest}/world_portrait.inc | 0 scripts/Prepare-CharacterPortraitPayload.ps1 | 42 --- scripts/Prepare-DKR-Runtime-macOS.sh | 16 +- scripts/Prepare-SaveLayoutPayload.ps1 | 47 --- scripts/camera_scene_kernel.py | 319 +++++++++++++++++ scripts/character_portrait_pipeline.py | 336 +++--------------- scripts/generate_replay_probe.py | 24 +- scripts/generate_visual_model_kernel.py | 29 +- scripts/kernel_inputs.py | 34 ++ scripts/pattern_guard.py | 1 + scripts/prepare_payloads.py | 221 ++++++++++++ scripts/save_layout_pipeline.py | 20 +- 22 files changed, 806 insertions(+), 633 deletions(-) delete mode 100644 patches/camera-plus/scene_kernel_pipeline.py create mode 100644 runtime-recomp/cmake/PreparedInputs.cmake rename patches/save-layout/guest_save_layout.h => runtime-recomp/guest/save_layout_guest.h (100%) rename {patches/character-portraits => runtime-recomp/guest}/world_portrait.inc (100%) delete mode 100644 scripts/Prepare-CharacterPortraitPayload.ps1 delete mode 100644 scripts/Prepare-SaveLayoutPayload.ps1 create mode 100644 scripts/camera_scene_kernel.py create mode 100644 scripts/kernel_inputs.py create mode 100644 scripts/prepare_payloads.py diff --git a/Build-macOS.sh b/Build-macOS.sh index 9fb45402..b7937020 100755 --- a/Build-macOS.sh +++ b/Build-macOS.sh @@ -79,9 +79,10 @@ if [[ -z "${DKR_MAC_GENERATED_SOURCE:-}" || -z "${DKR_MAC_V80_GENERATED_SOURCE:- else "${project_root}/scripts/Prepare-DKR-Runtime-macOS.sh" fi - generated_root="${DKR_GENERATED_ROOT:-${project_root}/build/generated}" - DKR_MAC_GENERATED_SOURCE="${generated_root}/v77/RecompiledFuncs" - DKR_MAC_V80_GENERATED_SOURCE="${generated_root}/v80/RecompiledFuncs" + # It finishes with scripts/prepare_payloads.py; CMake defaults the owned + # engine and kernels to the same directory. + DKR_MAC_GENERATED_SOURCE="${project_root}/build/prepared/generated-v77" + DKR_MAC_V80_GENERATED_SOURCE="${project_root}/build/prepared/generated-v80" fi dkr_require_tools cmake ninja @@ -136,6 +137,14 @@ revision_80_generated="${DKR_MAC_V80_GENERATED_SOURCE}" exit 1 } +# prepare_payloads.py names its configs v.toml; N64Recomp's sit beside +# RecompiledFuncs as dkr.us.v.toml. +code_policy() { + local parent + parent="$(dirname "$1")" + if [[ -f "${parent}/v$2.toml" ]]; then echo "${parent}/v$2.toml"; else echo "${parent}/dkr.us.v$2.toml"; fi +} + cmake -S "${project_root}/runtime-recomp" -B "${build_dir}" -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${DKR_MACOS_DEPLOYMENT_TARGET:-12.0}" \ @@ -146,8 +155,8 @@ cmake -S "${project_root}/runtime-recomp" -B "${build_dir}" -G Ninja \ -DDKR_RUNTIME_BUILD_RT64=ON \ -DDKR_GENERATED_SOURCE_V77="${generated_source}" \ -DDKR_GENERATED_SOURCE_V80="${revision_80_generated}" \ - -DDKR_CODE_POLICY_V77="$(dirname "${generated_source}")/dkr.us.v77.toml" \ - -DDKR_CODE_POLICY_V80="$(dirname "${revision_80_generated}")/dkr.us.v80.toml" + -DDKR_CODE_POLICY_V77="$(code_policy "${generated_source}" 77)" \ + -DDKR_CODE_POLICY_V80="$(code_policy "${revision_80_generated}" 80)" cmake --build "${build_dir}" --parallel "${jobs}" ctest --test-dir "${build_dir}" --output-on-failure --no-tests=error -R '^DKR' diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index c80e3b9d..0b30e0a1 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -70,7 +70,6 @@ patches/camera-plus/native/camera_snapshot.hpp PG006 patches/camera-plus/native/camera_xr.cpp PG006 patches/camera-plus/native/camera_xr.hpp PG006 patches/camera-plus/package_playtest.py PG013 -patches/camera-plus/scene_kernel_pipeline.py PG004 patches/camera-plus/sdk/dkr/presentation.h PG006 patches/camera-plus/vr_device_patch.py PG013 patches/camera-shadow/NativeOverlay.cmake PG001 @@ -101,7 +100,6 @@ patches/character-portraits/inspect_packages.py PG013 patches/character-portraits/inspect_sources.py PG013 patches/character-portraits/load_reviewed_portrait.inc PG006 patches/character-portraits/package-windows.ps1 PG013 -patches/character-portraits/world_portrait.inc PG006 patches/checkpoint-clock-repair/IMPLEMENTATION.txt PG013 patches/checkpoint-clock-repair/PLAYTEST.txt PG013 patches/checkpoint-clock-repair/ReleaseOverlay.cmake PG001 @@ -952,7 +950,6 @@ patches/rollback-scoreboard/package.py PG013 patches/rollback-scoreboard/pipeline.py PG004 patches/rollback-scoreboard/transforms.py PG013 patches/save-layout/README.md PG013 -patches/save-layout/guest_save_layout.h PG006 patches/save-layout/inspect_packages.py PG013 patches/save-layout/inspect_sources.py PG013 patches/save-layout/package-linux.sh PG013 @@ -1229,4 +1226,3 @@ scripts/compose-water-stability.py PG004 scripts/compose-water-views.py PG004 scripts/compose-waterfall.py PG004 scripts/normalize-water-adapter.py PG004 -scripts/save_layout_pipeline.py PG004 diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 17db6bb3..1aad60a4 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -78,44 +78,33 @@ Revision translation uses named functions and checked offsets, not a global address delta. If translating the base v80 policy, retain and validate its revision-specific entries; don't overwrite them with a blind translation. -## Generate the owned rollback payloads +## Prepare the build inputs -The complete Windows/Linux Public Build also needs two isolated full-scene owned -outputs. A normal payload or the old single-track probe is not sufficient. - -Run `scripts/generate_replay_probe.py --help` for the input contract. -It reads composed generated CPU inputs and the reviewed `recomp.h`, then emits -new isolated output; it does not edit the input tree. For each revision use: +The complete Public Build compiles more than the two normal payloads: each +revision's payload with its save-layout and in-world portrait stages, its +isolated full-scene owned rollback engine, its Camera++ scene kernel and its +local model kernel. `scripts/prepare_payloads.py` derives all of them from the +normal payloads and writes them to `build/prepared`, where CMake looks for them +by default (`runtime-recomp/cmake/PreparedInputs.cmake`). +`Prepare-DKR-Runtime-macOS.sh` runs it last. After the Windows composition +above: ```text -python scripts/generate_replay_probe.py - --revision <77-or-80> - --scene-cuts --audio-services --input-services --authored-cpu - --isolate-symbols --full-scenes --mod-services --inline-memory +python scripts/prepare_payloads.py + --input 77 /generated-v77 /menu-v77.toml /dkr.us.v77.elf + --input 80 /generated-v80 /menu-v80.toml /dkr.us.v80.elf ``` -Despite the historical “probe/test” names, these checked full-scene outputs -supply the integrated backend. Native callback signatures, header hashes, -service ownership and the manifest are mandatory. Don't loosen a failed check -or edit generated output to admit a new closure. - -## Prepared visual inputs and release configuration - -The retained visual-model/vehicle renderer also needs two fresh model-only -outputs. These are separate from both the normal and owned CPU directories: - -```text -python scripts/generate_visual_model_kernel.py --generated - --header --elf --revision 77 - --output -``` +Then configure without naming any payload: the defaults use +`build/prepared/generated-v77` and the rest of the set. A configure that names +its own `DKR_GENERATED_SOURCE_V77` gets none of the prepared set, since the +owned engine and kernels belong to the payload they were prepared from. -Repeat for revision 80 using its own ELF and normal input. The script checks -the five leaf routine hashes, scalar symbols and memory ABI. It emits only -the model closure, with checked reads, shadowed guest stores and an operation -budget; there is no game-world redraw or gameplay/native-import escape. -Supply both outputs using `DKR_VISUAL_MODEL_GENERATED_V77` and -`DKR_VISUAL_MODEL_GENERATED_V80`. Without them the local model feature is not +Each stage checks pinned function hashes, native callback signatures, header +hashes and service ownership, and writes new output only; it never edits its +input. Despite the historical "probe/test" names, the owned outputs supply the +integrated backend. Don't loosen a failed check or edit generated output to +admit a new closure. Without the model kernels the local model feature is not compiled and the live Online maximum-detail control remains unavailable. For an already prepared platform cache, configure through the current overlay: diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index fa0b5aea..6458d8ec 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -120,9 +120,8 @@ Known before they run: preprocessing see the hooked files without a build step. - The Camera++ shadow kernel is first-party (`src/game/camera_shadow_kernel.c`), and `Camera.cmake` derives its hook stubs from the prepared kernel, so - `DKR_CAMERA_SHADOW_KERNEL_V*` is gone. `DKR_CAMERA_KERNEL_V*` is still a - prepared input from `patches/camera-plus/scene_kernel_pipeline.py`, so moving - that pipeline out of `patches/` is B8's call. + `DKR_CAMERA_SHADOW_KERNEL_V*` is gone. `DKR_CAMERA_KERNEL_V*` is a + prepared input from `scripts/camera_scene_kernel.py` (B8a). - `scripts/gen_guest_symbols.py` generates the four pure symbol headers (achievement, polish, vehicle, render heap) into `/generated/symbols`. `title_text_rt64.hpp` and `credits_sky_rt64.hpp` keep their tables inline, @@ -157,9 +156,29 @@ Everything else under `patches/` that the base CMake reads is a dependency | `enhanced-water/native/` | `cmake/EnhancedWater.cmake` (copies the HLSL and headers into RT64's staged tree), `DKREnhancedWaterPolicyTests` include path, `scripts/compose-water-*.py` (write it) | first-party RT64 additions: water and lava shaders, `WaterRayScene.cpp`, policy headers | B3 or B8: either first-party source under `runtime-recomp/` staged into RT64 the same way, or new files inside a registered RT64 patch. Not moved yet: the compose scripts still write into this directory | | `startup-memory/` | `RuntimeMemory.cmake` was included by `cmake/PerformancePatches.cmake` | one `dkr_stage_patch` call plus `runtime-memory.patch` | call folded into `PerformancePatches.cmake`; the patch is registered in B3, the directory's other files go in B8 | | `custom-magic-codes/` | `cmake/PerformancePatches.cmake` | `runtime-code-heap.patch` only | B3 registers it | -| `save-layout/` | `scripts/save_layout_pipeline.py` (`guest_save_layout.h`) | guest header for the owned-payload pipeline | B4, with the guest hooks | -| `character-portraits/` | `scripts/character_portrait_pipeline.py` (`*.inc`) | guest inputs for the portrait payload | B4, with the guest hooks | - -`camera-plus/` is also still read, by `scripts/camera_plus_pipeline.py` and -the scene-kernel pipeline that produce the Camera++ kernels; B4 replaces -them with the generator. +`camera-plus/` is also still read, by `scripts/camera_plus_pipeline.py` +(the release chain's Camera++ stage, B8 deletes it). The scene kernel's +generator is `scripts/camera_scene_kernel.py` (B8a). + +## Prepared inputs (B8a) + +`scripts/prepare_payloads.py` replaces the release chain's prepared inputs: +the staged payload (save layout, then the in-world portrait), the owned +engine regenerated after each stage, and the Camera++ and model kernels. +Their authored guest inputs moved from `patches/save-layout/` and +`patches/character-portraits/` to `runtime-recomp/guest/`. The gate's +`prepared` level checks them against the reference +(`scripts/fold/prepared.py` names the allowed differences). + +- The save-layout and portrait stages still rewrite copies of + `funcs_*.c`, which AGENTS.md reserves for `guest-hooks/hooks.toml`. They + stay stages because the owned engine is generated from their output and + has to match the shipped one byte for byte. Moving them into hooks.toml + (payloads `funcs` and `owned`) changes the owned manifest, so it's a + separate, deliberate step after the fold. +- The portrait stage's native half edited seven first-party mods sources in + a staged copy. B1 folded those edits into `runtime-recomp/src`, so the + receipt records the folded files' digests instead of a native stage. +- `generate_replay_probe.py`'s native audits pin the folded sources. The old + pins were v1.0.11's base sources, before the release layers changed them; + the folded tree holds what shipped. diff --git a/patches/camera-plus/scene_kernel_pipeline.py b/patches/camera-plus/scene_kernel_pipeline.py deleted file mode 100644 index cfbafbaf..00000000 --- a/patches/camera-plus/scene_kernel_pipeline.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Compile a closed draw-only routine set into NEW private pipeline output. - -No routine in this output links to the live game, renderer globals or native -mod services. All guest accesses are checked against the supplied DECODE COPY. -The approved roots prepare/draw ONE view, never render_scene or a logic tick. -""" -import argparse -import hashlib -import json -from pathlib import Path -import re -import sys - -ROOT=Path(__file__).resolve().parents[2] -sys.path.insert(0,str(ROOT/'scripts')) -from camera_plus_pipeline import new_output,replace,digest -from generate_replay_probe import DEF,CALL,strip,HEADER_HASH -from compose_legacy_mod_policy import elf_functions - -ROOTS=('initialise_player_viewport_vars','skydome_render','trackbg_render_gradient', - 'trackbg_render_flashy','viewport_main','mtx_world_origin','func_8002A31C', - 'apply_fog','set_active_camera','rendermode_reset','snow_vertices','snow_render', - 'rain_render','rain_render_splashes','lensflare_render','lensflare_override', - 'weather_clip_planes','get_camera_matrix','mtx_ortho','viewport_reset','mtx_perspective') -# The only transitive root set admitted by this generator. A code update -# adding another routine requires an explicit review, not a wider catch-all. -NAMES=set('''add_segment_to_order apply_fog arctan2_f atan2s block_visible -calc_dynamic_lighting_for_object_1 calc_dynamic_lighting_for_object_2 calc_env_mapping_for_object -cam_get_active_camera cam_get_viewport_layout check_if_in_draw_range check_if_showing_cutscene_camera -compute_grid_overlap_mask compute_scene_camera_transform_matrix coss_f coss_s16 directional_lighting_off -directional_lighting_on fb_size func_80012C30 func_80012C3C func_80012C98 func_80012CE8 func_800135B8 -func_80026070 func_80026430 func_80026C14 func_80026E54 func_80027568 func_8002A31C func_80067D3C -func_800B92F4 func_800B97A8 func_800BA288 generate_collision_candidates get_current_viewport -get_distance_to_active_camera get_distance_to_camera get_filtered_cheats get_first_active_object get_misc_asset -get_object get_projection_matrix_f32 get_racer_objects get_settings gfx_init_basic_xlu -initialise_player_viewport_vars is_player_two_in_control is_time_trial_enabled level_header level_is_race -leveltable_type material_load_simple material_set material_set_blinking_lights material_set_no_tex_offset -mtx_cam_push mtx_head_push mtx_pop mtx_shear_push mtx_world_origin mtxf_billboard mtxf_from_inverse_transform -mtxf_from_transform mtxf_from_translation mtxf_mul mtxf_scale_y mtxf_to_mtx mtxf_to_mtxs -mtxf_transform_dir mtxf_transform_point mtxf_translate_y mtxs_transform_dir obj_animate obj_door_number -obj_shade_fancy obj_shade_fast obj_tex_animate object_do_player_tumble object_undo_player_tumble rand_range -regenerate_point_particles_mesh render_3d_billboard render_3d_misc render_3d_model render_bubble_trap -render_level_geometry_and_objects render_level_segment render_mesh render_misc_model render_object -render_object_parts render_particle render_racer_magnet render_racer_shield render_sprite_billboard -rendermode_reset set_active_camera set_animated_texture_header set_temp_model_transforms shadow_render sins_f -sins_s16 skydome_render sort_objects_by_dist sqrtf_recomp tex_animate_texture trackbg_render_flashy -trackbg_render_gradient traverse_segments_bsp_tree unset_temp_model_transforms update_envmap_position -update_object_stack_trace vec3f_rotate vec3f_rotate_ypr vec3s_reflect viewport_main viewport_rsp_set void_check -void_generate_primitive watereffect_render wave_load_material waves_block_hq waves_get_y waves_render -waves_visibility waves_visibility_reset func_800BB2F4 get_camera_matrix get_inside_segment_count_xz -get_level_segment_index_from_position get_level_segment_waves get_projection_matrix_s16 get_racer_object_by_port -lensflare_override lensflare_render mtx_ortho normalise_time rain_render rain_render_splashes -snow_render snow_vertices viewport_reset viewport_scissor weather_clip_planes mtx_perspective'''.split()) -HOOKS=set('''dkr_anchor_persistent_water_to_camera dkr_apply_maximum_racer_detail -dkr_billboard_interpolation_begin dkr_extend_hub_segment_bitfield dkr_extend_object_draw_distance -dkr_extended_frustum_begin dkr_extended_frustum_end dkr_fix_car_steering_wheel_material -dkr_keep_hub_segment_visible dkr_legacy_texture_animation dkr_level_segment_interpolation_begin -dkr_level_segment_interpolation_end dkr_netplay_presentation_random_begin dkr_netplay_presentation_random_end -dkr_netplay_presentation_random_range dkr_presentation_object_begin dkr_presentation_object_end -dkr_presentation_wave_begin dkr_presentation_wave_block dkr_presentation_wave_end dkr_presentation_wave_matrix -dkr_presentation_wave_selection dkr_presentation_world_origin_matrix dkr_shadow_interpolation_begin -dkr_shadow_interpolation_end dkr_skybox_cover_begin dkr_skybox_cover_end dkr_stabilise_persistent_water_transition -dkr_surface_interpolation_begin dkr_surface_interpolation_end dkr_track_select_draw -dkr_vehicle_part_interpolation_end dkr_vehicle_part_matrix_identity dkr_widen_gradient_sky -dkr_widen_void_primitive dkr_track_select_lens_flare_tint_begin dkr_track_select_lens_flare_tint_end -dkr_presentation_perspective_matrix'''.split()) -MATH={'__f_to_ll_recomp','__ll_to_f_recomp'} -GLOBALS='''gTrackDL gTrackMtxPtr gTrackVtxPtr gTrackTriPtr gSceneActiveCamera gSceneCurrentPlayerID -gSceneRenderSkyDome gDrawLevelSegments gDisableShadows gCurrentLevelHeader2 gCurrentLevelModel -gCameras gActiveCameraID gPerspectiveMatrixF gPerspectiveMatrix gViewportLayout gScenePlayerViewports -gCutsceneCameraActive gModelMatrixStackPos gMtxOriginID gObjectCurrDisplayList gObjectCurrMatrix perspNorm -gViewProjMatrixF gWaveController gCurrWeatherDisplayList gCurrWeatherMatrix gCurrWeatherVertexList -gCurrWeatherTriList gWeatherCamera gWeatherCameraMatrix gWeatherType gSnowParticleCount gSnowVerts -gSnowVertexData gSnowVertexFlip gRainSplashDelay gRainGfx gLightningFrequency D_800DC8AC -D_8011D4A0 D_8011D4A4 D_8011D4AC D_8011D4B0'''.split() - -def generate(source,header,elf,revision,output): - source=source.resolve(strict=True);output=new_output(output,source,header.parent) - raw=header.read_text() - if digest(header)!=HEADER_HASH:raise ValueError('Unreviewed scene-kernel register ABI') - functions={} - for path in sorted(source.glob('*.c')): - text=path.read_text();matches=list(DEF.finditer(text)) - for i,m in enumerate(matches): - if m[1] in functions:raise ValueError('Duplicate scene routine') - functions[m[1]]=text[m.start():matches[i+1].start() if i+1>32); \\ -}''',header_text,flags=re.S) - if count!=1:raise ValueError('Scene doubleword store changed') - header_text=header_text.replace('assert(val == val)','((val)==(val)?(void)0:dkr_camera_fault("non-finite scene value"))') - header_text=header_text.replace('assert(((idx) & 1) == 0 || (ctx)->mips3_float_mode)', - '((((idx)&1)==0||(ctx)->mips3_float_mode)?(void)0:dkr_camera_fault("scene floating ABI"))') - macros=set(re.findall(r'^#define\s+(\w+)\s*\(',raw,re.M)) - macros|=set(re.findall(r'static\s+inline\s+\w+\s+(\w+)\s*\(',raw)) - allowed=NAMES|HOOKS|MATH|macros|{'if','while','for','switch','return','sizeof','void','sqrtf','do_break','switch_error'} - symbols=elf_functions(elf,(0,1,2));addresses={} - for name in GLOBALS: - entries=symbols.get(name,set()) - if len(entries)!=1:raise ValueError('Unknown scene global: '+name) - addresses[name]=next(iter(entries))[0] - emitted=[] - for name,body in sorted(selected.items()): - extra=set(CALL.findall(strip(body)))-allowed - if extra or re.search(r'\brdram\s*(?:\[|\+)',strip(body)): - raise ValueError('Scene escaped its closed boundary: '+name+' '+str(extra)) - body=body.replace('{','{\n dkr_camera_step();',1) - body=re.sub(r'^(L_[0-9A-Fa-f]+:)\s*$',r'\1 dkr_camera_step();',body,flags=re.M) - # Uniform C prototypes for observation-only injected hooks. No process- - # wide observer may be called from this private renderer. - body=re.sub(r'extern\s+(?:unsigned|int|void)\s+(dkr_\w+)\([^;]*\);',lambda m:'' if m[1] in HOOKS else m[0],body) - for n in sorted(NAMES|HOOKS|MATH|{'switch_error','do_break'},key=len,reverse=True): - body=re.sub(r'\b'+n+r'\b',prefix+n,body) - emitted.append(body) - output.mkdir(parents=True) - (output/'recomp.h').write_text(header_text) - prototypes=''.join(f'void {prefix}{n}(uint8_t*,recomp_context*);\n' for n in sorted(NAMES|MATH)) - prototypes+=''.join(f'unsigned {prefix}{n}(uint8_t*,recomp_context*,...);\n' for n in sorted(HOOKS)) - prototypes+=f'void {prefix}do_break(uint32_t);\nvoid {prefix}switch_error(const char*,uint32_t,uint32_t);\n' - (output/'funcs.h').write_text('#pragma once\n#include "recomp.h"\n'+prototypes) - (output/'camera_scene_functions.c').write_text('#include "funcs.h"\n'+''.join(emitted)) - special={'dkr_presentation_world_origin_matrix','dkr_presentation_object_begin', - 'dkr_presentation_object_end','dkr_extend_hub_segment_bitfield','dkr_legacy_texture_animation', - 'dkr_fix_car_steering_wheel_material','dkr_skybox_cover_begin','dkr_skybox_cover_end', - 'dkr_presentation_wave_begin','dkr_presentation_wave_end','dkr_presentation_wave_block', - 'dkr_presentation_wave_selection','dkr_presentation_wave_matrix', - 'dkr_widen_gradient_sky','dkr_widen_void_primitive'} - # Ignored hooks are renderer observation/quality overrides only. Their - # live host implementations must never be reached from the private scene. - stubs=''.join(f'unsigned {prefix}{n}(uint8_t* ram,recomp_context* ctx,...)' - '{(void)ram;(void)ctx;return 0;}\n' for n in sorted(HOOKS-special)) - (output/'camera_scene_hooks.c').write_text('#include "funcs.h"\n'+stubs) - definitions='\n'.join(f'#define {n} {prefix}{n}' for n in sorted(NAMES|HOOKS|MATH|{'do_break','switch_error'})) - definitions+='\n'+'\n'.join(f'#define CAMERA_{n} 0x{a:08X}U' for n,a in addresses.items()) - (output/'camera_scene_addresses.h').write_text('#pragma once\n'+definitions+f'\n#define CAMERA_ENTRY {prefix}draw\n') - (output/'manifest.json').write_text(json.dumps({'schema':1,'revision':revision,'roots':ROOTS, - 'functions':{n:hashlib.sha256(body.encode()).hexdigest() for n,body in sorted(selected.items())}, - 'source':source.relative_to(ROOT).as_posix(),'recipe':digest(Path(__file__)), - 'bridge':{name:digest(Path(__file__).parent/'native'/name) for name in ('camera_kernel.c','camera_kernel.h','camera_kernel_guest.h')}, - 'files':{p.name:digest(p) for p in sorted(output.iterdir()) if p.is_file()}, - 'guest_writes':False,'logic_ticks':False,'device_calls':False},indent=2)+'\n') - print('Private draw-only closure staged:',revision,len(selected),output) - -def verify(output): - manifest=json.loads((output/'manifest.json').read_text()) - if manifest.get('schema')!=1 or manifest.get('recipe')!=digest(Path(__file__)): - raise ValueError('Regenerate the private camera draw closure after recipe changes') - if {p.name:digest(p) for p in output.iterdir() if p.is_file() and p.name!='manifest.json'}!=manifest['files']: - raise ValueError('Camera draw output contains unreviewed edits') - for name,sha in manifest['bridge'].items(): - if digest(Path(__file__).parent/'native'/name)!=sha:raise ValueError('Camera draw bridge changed') - source=(ROOT/manifest['source']).resolve(strict=True) - if ROOT/'build' not in source.parents:raise ValueError('Camera draw input escaped the pipeline') - found={} - for path in sorted(source.glob('*.c')): - text=path.read_text();matches=list(DEF.finditer(text)) - for i,m in enumerate(matches): - if m[1] in NAMES: - if m[1] in found:raise ValueError('Duplicate camera draw input') - body=text[m.start():matches[i+1].start() if i+1 holds # camera_scene_functions.c and camera_scene_hooks.c from -# patches/camera-plus/scene_kernel_pipeline.py. Neither input is modified. +# scripts/camera_scene_kernel.py (scripts/prepare_payloads.py runs it; +# cmake/PreparedInputs.cmake defaults the paths). Neither input is modified. set(DKR_CAMERA_KERNEL_V77 "" CACHE PATH "Prepared Camera++ scene kernel for revision 77") set(DKR_CAMERA_KERNEL_V80 "" CACHE PATH "Prepared Camera++ scene kernel for revision 80") diff --git a/runtime-recomp/cmake/PreparedInputs.cmake b/runtime-recomp/cmake/PreparedInputs.cmake new file mode 100644 index 00000000..66e5d6f4 --- /dev/null +++ b/runtime-recomp/cmake/PreparedInputs.cmake @@ -0,0 +1,33 @@ +# Default the ROM-derived build inputs to what scripts/prepare_payloads.py +# prepared (Prepare-DKR-Runtime-macOS.sh runs it), so a configure after +# preparing needs no paths. Each is a cache default: anything set on the +# command line wins. The set is all or nothing: a configure that names its own +# US v1.0 payload gets none of it, since the owned engine and kernels belong to +# the payload they were prepared from. Without a prepared directory nothing is +# set, and the options keep their old defaults (no owned engine, no Camera++ +# or local model kernels). +set(DKR_PREPARED_DIR "${DKRPORT_ROOT}/build/prepared" CACHE PATH + "Inputs scripts/prepare_payloads.py prepares from the N64Recomp payload") +if(DKR_RUNTIME_BUILD_GENERATED AND EXISTS "${DKR_PREPARED_DIR}/prepared.json" + AND (NOT DEFINED CACHE{DKR_GENERATED_SOURCE_V77} + OR DKR_GENERATED_SOURCE_V77 STREQUAL "${DKR_PREPARED_DIR}/generated-v77")) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${DKR_PREPARED_DIR}/prepared.json") + set(DKR_GENERATED_SOURCE_V77 "${DKR_PREPARED_DIR}/generated-v77" CACHE PATH + "N64Recomp-generated CPU source for DKR US v1.0") + set(DKR_EXPERIMENTAL_OWNED_GENERATED "${DKR_PREPARED_DIR}/owned-v77" CACHE PATH + "NEW isolated owned CPU Patch Pipeline output; integration targets only") + set(DKR_VISUAL_MODEL_GENERATED_V77 "${DKR_PREPARED_DIR}/model-v77" CACHE PATH + "NEW renderer-only model kernel output for US v1.0") + set(DKR_CAMERA_KERNEL_V77 "${DKR_PREPARED_DIR}/camera-kernel-v77" CACHE PATH + "Prepared Camera++ scene kernel for revision 77") + if(EXISTS "${DKR_PREPARED_DIR}/generated-v80") + set(DKR_GENERATED_SOURCE_V80 "${DKR_PREPARED_DIR}/generated-v80" CACHE PATH + "N64Recomp-generated CPU source for DKR US Rev A/v1.1") + set(DKR_EXPERIMENTAL_OWNED_GENERATED_V80 "${DKR_PREPARED_DIR}/owned-v80" CACHE PATH + "NEW isolated US Rev A full-scene owned CPU Patch Pipeline output") + set(DKR_VISUAL_MODEL_GENERATED_V80 "${DKR_PREPARED_DIR}/model-v80" CACHE PATH + "NEW renderer-only model kernel output for US v1.1") + set(DKR_CAMERA_KERNEL_V80 "${DKR_PREPARED_DIR}/camera-kernel-v80" CACHE PATH + "Prepared Camera++ scene kernel for revision 80") + endif() +endif() diff --git a/patches/save-layout/guest_save_layout.h b/runtime-recomp/guest/save_layout_guest.h similarity index 100% rename from patches/save-layout/guest_save_layout.h rename to runtime-recomp/guest/save_layout_guest.h diff --git a/patches/character-portraits/world_portrait.inc b/runtime-recomp/guest/world_portrait.inc similarity index 100% rename from patches/character-portraits/world_portrait.inc rename to runtime-recomp/guest/world_portrait.inc diff --git a/scripts/Prepare-CharacterPortraitPayload.ps1 b/scripts/Prepare-CharacterPortraitPayload.ps1 deleted file mode 100644 index 1ce95a05..00000000 --- a/scripts/Prepare-CharacterPortraitPayload.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory=$true)][string]$BaselinePipeline, - [Parameter(Mandatory=$true)][string]$OutputRoot, - [string]$Python='python' -) -$ErrorActionPreference='Stop' -Set-StrictMode -Version Latest -$root=Split-Path -Parent $PSScriptRoot -$baseline=(Resolve-Path -LiteralPath $BaselinePipeline).Path -$output=[IO.Path]::GetFullPath($OutputRoot) -if(Test-Path -LiteralPath $output){throw 'Choose a NEW portrait pipeline output directory.'} -if($output.StartsWith($baseline+[IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { - throw 'Portrait output cannot be inside its read-only baseline.' -} -# The Python stager validates protected/input paths before creating parents. -$native=Join-Path $output 'native' -& $Python (Join-Path $PSScriptRoot 'character_portrait_pipeline.py') native --output $native -if($LASTEXITCODE -ne 0){throw 'Native portrait staging failed.'} -$header=Join-Path $root 'extern/n64-modern-runtime/N64Recomp/include/recomp.h' -foreach($revision in @(77,80)) { - $generated=Join-Path $output "generated-v$revision" - $config=Join-Path $output "v$revision.toml" - & $Python (Join-Path $PSScriptRoot 'character_portrait_pipeline.py') guest ` - --source (Join-Path $baseline "generated-v$revision") --output $generated --revision $revision ` - --config (Join-Path $baseline "v$revision.toml") --output-config $config --native-stage $native - if($LASTEXITCODE -ne 0){throw "Guest portrait staging failed for v$revision."} - $manifest=Get-Content -LiteralPath (Join-Path $baseline "owned-v$revision/manifest.json") -Raw | ConvertFrom-Json - if($manifest.revision -ne $revision -or -not $manifest.runtime_symbol_isolation){throw 'Invalid baseline rollback manifest.'} - $arguments=@((Join-Path $PSScriptRoot 'generate_replay_probe.py'),$generated,$header, - (Join-Path $output "owned-v$revision"),'--revision',"$revision",'--isolate-symbols','--code-policy-config',$config) - $options=@{ - scene_cuts='--scene-cuts';full_scenes='--full-scenes';audio_services='--audio-services' - input_services='--input-services';authored_cpu='--authored-cpu';owned_mod_services='--mod-services' - checked_inline_memory='--inline-memory';boss_finish_diagnostics='--boss-finish-diagnostics' - } - foreach($option in $options.GetEnumerator()){if($manifest.($option.Key)){$arguments+=$option.Value}} - $arguments+='--roots';$arguments+=@($manifest.roots) - & $Python @arguments - if($LASTEXITCODE -ne 0){throw "Owned portrait staging failed for v$revision."} -} -Write-Host 'Fresh portrait payloads generated. No game, mods or tests were executed.' diff --git a/scripts/Prepare-DKR-Runtime-macOS.sh b/scripts/Prepare-DKR-Runtime-macOS.sh index 3b71b261..d17632da 100755 --- a/scripts/Prepare-DKR-Runtime-macOS.sh +++ b/scripts/Prepare-DKR-Runtime-macOS.sh @@ -32,7 +32,9 @@ Usage: $(basename "$0") --rom-v77 --rom-v80 [options --skip-deps Don't install Homebrew packages --force Rebuild the decomp ELFs and payloads even if their inputs are unchanged --jobs N Parallel jobs (default: host CPU count) -Writes /v77/RecompiledFuncs and /v80/RecompiledFuncs. +Writes /v77/RecompiledFuncs and /v80/RecompiledFuncs, then +the build inputs scripts/prepare_payloads.py derives from them to +build/prepared. EOF } @@ -320,6 +322,16 @@ PY prepare_revision 77 "$ROM_V77" 0cb115d8716dbbc2922fda38e533b9fe63bb9670 0x80065D40 prepare_revision 80 "$ROM_V80" 6d96743d46f8c0cd0edb0ec5600b003c89b93755 0x80065F80 +# The build inputs derived from both payloads (save layout and portrait +# stages, owned engine, Camera++ and model kernels); CMake reads them by default. +step "Prepared build inputs" +prepared_inputs=() +for revision in 77 80; do + prepared_inputs+=(--input "$revision" "$OUTPUT/v$revision/RecompiledFuncs" "$OUTPUT/v$revision/dkr.us.v$revision.toml" + "$PROJECT_ROOT/build/decomp/v$revision/build/dkr.us.v$revision.elf") +done +python3 -B "$SCRIPT_DIR/prepare_payloads.py" "${prepared_inputs[@]}" || fail "Preparing the build inputs failed." + echo -echo "[OK] Payloads ready in $OUTPUT" +echo "[OK] Payloads ready in $OUTPUT, build inputs in $PROJECT_ROOT/build/prepared" echo "Full log: $LOG_PATH" diff --git a/scripts/Prepare-SaveLayoutPayload.ps1 b/scripts/Prepare-SaveLayoutPayload.ps1 deleted file mode 100644 index 3e6d6c7d..00000000 --- a/scripts/Prepare-SaveLayoutPayload.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory=$true)][string]$BaselinePipeline, - [Parameter(Mandatory=$true)][string]$OutputRoot, - [string]$Python = 'python' -) -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -$root = Split-Path -Parent $PSScriptRoot -$baseline = (Resolve-Path -LiteralPath $BaselinePipeline).Path -$output = [System.IO.Path]::GetFullPath($OutputRoot) -if (Test-Path -LiteralPath $output) { throw 'Choose a NEW save-layout pipeline output directory.' } -if ($output.StartsWith($baseline + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { - throw 'Save-layout output cannot be inside its read-only input.' -} -New-Item -ItemType Directory -Path $output | Out-Null -$header = Join-Path $root 'extern/n64-modern-runtime/N64Recomp/include/recomp.h' -foreach ($revision in @(77,80)) { - $source = Join-Path $baseline "generated-v$revision" - $generated = Join-Path $output "generated-v$revision" - $config = Join-Path $output "v$revision.toml" - & $Python (Join-Path $PSScriptRoot 'save_layout_pipeline.py') --source $source --output $generated ` - --revision $revision --config (Join-Path $baseline "v$revision.toml") --output-config $config - if ($LASTEXITCODE -ne 0) { throw "Save-layout staging failed for v$revision." } - $manifest = Get-Content -LiteralPath (Join-Path $baseline "owned-v$revision/manifest.json") -Raw | ConvertFrom-Json - if ($manifest.revision -ne $revision -or -not $manifest.runtime_symbol_isolation) { - throw 'Baseline replay manifest must match its revision and isolate runtime symbols.' - } - $arguments = @((Join-Path $PSScriptRoot 'generate_replay_probe.py'),$generated,$header, - (Join-Path $output "owned-v$revision"),'--revision',"$revision",'--isolate-symbols', - '--code-policy-config',$config) - $options = @{ - scene_cuts='--scene-cuts'; full_scenes='--full-scenes'; audio_services='--audio-services' - input_services='--input-services'; authored_cpu='--authored-cpu' - owned_mod_services='--mod-services'; checked_inline_memory='--inline-memory' - boss_finish_diagnostics='--boss-finish-diagnostics' - } - foreach ($option in $options.GetEnumerator()) { - if ($manifest.($option.Key)) { $arguments += $option.Value } - } - $arguments += '--roots' - $arguments += @($manifest.roots) - & $Python @arguments - if ($LASTEXITCODE -ne 0) { throw "Owned save-layout staging failed for v$revision." } -} -Write-Host 'New normal and rollback CPU payloads prepared. No game or tests were run.' -Write-Host "Configure DKR_GENERATED_SOURCE_V77/V80, DKR_EXPERIMENTAL_OWNED_GENERATED(_V80), and DKR_CODE_POLICY_V77/V80 to the matching paths in $output." diff --git a/scripts/camera_scene_kernel.py b/scripts/camera_scene_kernel.py new file mode 100644 index 00000000..b2e24ba2 --- /dev/null +++ b/scripts/camera_scene_kernel.py @@ -0,0 +1,319 @@ +"""Compile a closed draw-only routine set into NEW private pipeline output. + +No routine in this output links to the live game, renderer globals or native +mod services. All guest accesses are checked against the supplied DECODE COPY. +The approved roots prepare/draw ONE view, never render_scene or a logic tick. +This is the Camera++ scene kernel (DKR_CAMERA_KERNEL_V77/V80), a supported +prepare step: scripts/prepare_payloads.py runs it. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +from typing import cast + +from kernel_inputs import CALL, DEF, HEADER_HASH, elf_symbols, strip + +ROOT = Path(__file__).resolve().parents[1] +# The kernel's native bridge (DKRCameraScene compiles these beside it). +BRIDGE = ROOT / "runtime-recomp/src/game" + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def new_output(path: Path, *inputs: Path) -> Path: + path = path.resolve() + if path.exists() or any( + path == p.resolve() or p.resolve() in path.parents or path in p.resolve().parents for p in inputs + ): + raise ValueError("Choose a NEW Camera++ output, outside and not above all inputs") + if any(part.lower() in ("extern", "recompiledfuncs", "recompiledpatches") for part in path.parts): + raise ValueError("Camera++ output cannot enter a protected tree") + if (ROOT / "build").resolve() not in path.parents: + raise ValueError("Camera++ generated output must stay inside the workspace build directory") + return path + + +ROOTS = ( + "initialise_player_viewport_vars", + "skydome_render", + "trackbg_render_gradient", + "trackbg_render_flashy", + "viewport_main", + "mtx_world_origin", + "func_8002A31C", + "apply_fog", + "set_active_camera", + "rendermode_reset", + "snow_vertices", + "snow_render", + "rain_render", + "rain_render_splashes", + "lensflare_render", + "lensflare_override", + "weather_clip_planes", + "get_camera_matrix", + "mtx_ortho", + "viewport_reset", + "mtx_perspective", +) +# The only transitive root set admitted by this generator. A code update +# adding another routine requires an explicit review, not a wider catch-all. +NAMES = set( + """add_segment_to_order apply_fog arctan2_f atan2s block_visible +calc_dynamic_lighting_for_object_1 calc_dynamic_lighting_for_object_2 calc_env_mapping_for_object +cam_get_active_camera cam_get_viewport_layout check_if_in_draw_range check_if_showing_cutscene_camera +compute_grid_overlap_mask compute_scene_camera_transform_matrix coss_f coss_s16 directional_lighting_off +directional_lighting_on fb_size func_80012C30 func_80012C3C func_80012C98 func_80012CE8 func_800135B8 +func_80026070 func_80026430 func_80026C14 func_80026E54 func_80027568 func_8002A31C func_80067D3C +func_800B92F4 func_800B97A8 func_800BA288 generate_collision_candidates get_current_viewport +get_distance_to_active_camera get_distance_to_camera get_filtered_cheats get_first_active_object get_misc_asset +get_object get_projection_matrix_f32 get_racer_objects get_settings gfx_init_basic_xlu +initialise_player_viewport_vars is_player_two_in_control is_time_trial_enabled level_header level_is_race +leveltable_type material_load_simple material_set material_set_blinking_lights material_set_no_tex_offset +mtx_cam_push mtx_head_push mtx_pop mtx_shear_push mtx_world_origin mtxf_billboard mtxf_from_inverse_transform +mtxf_from_transform mtxf_from_translation mtxf_mul mtxf_scale_y mtxf_to_mtx mtxf_to_mtxs +mtxf_transform_dir mtxf_transform_point mtxf_translate_y mtxs_transform_dir obj_animate obj_door_number +obj_shade_fancy obj_shade_fast obj_tex_animate object_do_player_tumble object_undo_player_tumble rand_range +regenerate_point_particles_mesh render_3d_billboard render_3d_misc render_3d_model render_bubble_trap +render_level_geometry_and_objects render_level_segment render_mesh render_misc_model render_object +render_object_parts render_particle render_racer_magnet render_racer_shield render_sprite_billboard +rendermode_reset set_active_camera set_animated_texture_header set_temp_model_transforms shadow_render sins_f +sins_s16 skydome_render sort_objects_by_dist sqrtf_recomp tex_animate_texture trackbg_render_flashy +trackbg_render_gradient traverse_segments_bsp_tree unset_temp_model_transforms update_envmap_position +update_object_stack_trace vec3f_rotate vec3f_rotate_ypr vec3s_reflect viewport_main viewport_rsp_set void_check +void_generate_primitive watereffect_render wave_load_material waves_block_hq waves_get_y waves_render +waves_visibility waves_visibility_reset func_800BB2F4 get_camera_matrix get_inside_segment_count_xz +get_level_segment_index_from_position get_level_segment_waves get_projection_matrix_s16 get_racer_object_by_port +lensflare_override lensflare_render mtx_ortho normalise_time rain_render rain_render_splashes +snow_render snow_vertices viewport_reset viewport_scissor weather_clip_planes mtx_perspective""".split() +) +HOOKS = set( + """dkr_anchor_persistent_water_to_camera dkr_apply_maximum_racer_detail +dkr_billboard_interpolation_begin dkr_extend_hub_segment_bitfield dkr_extend_object_draw_distance +dkr_extended_frustum_begin dkr_extended_frustum_end dkr_fix_car_steering_wheel_material +dkr_keep_hub_segment_visible dkr_legacy_texture_animation dkr_level_segment_interpolation_begin +dkr_level_segment_interpolation_end dkr_netplay_presentation_random_begin dkr_netplay_presentation_random_end +dkr_netplay_presentation_random_range dkr_presentation_object_begin dkr_presentation_object_end +dkr_presentation_wave_begin dkr_presentation_wave_block dkr_presentation_wave_end dkr_presentation_wave_matrix +dkr_presentation_wave_selection dkr_presentation_world_origin_matrix dkr_shadow_interpolation_begin +dkr_shadow_interpolation_end dkr_skybox_cover_begin dkr_skybox_cover_end dkr_stabilise_persistent_water_transition +dkr_surface_interpolation_begin dkr_surface_interpolation_end dkr_track_select_draw +dkr_vehicle_part_interpolation_end dkr_vehicle_part_matrix_identity dkr_widen_gradient_sky +dkr_widen_void_primitive dkr_track_select_lens_flare_tint_begin dkr_track_select_lens_flare_tint_end +dkr_presentation_perspective_matrix""".split() +) +MATH = {"__f_to_ll_recomp", "__ll_to_f_recomp"} +GLOBALS = """gTrackDL gTrackMtxPtr gTrackVtxPtr gTrackTriPtr gSceneActiveCamera gSceneCurrentPlayerID +gSceneRenderSkyDome gDrawLevelSegments gDisableShadows gCurrentLevelHeader2 gCurrentLevelModel +gCameras gActiveCameraID gPerspectiveMatrixF gPerspectiveMatrix gViewportLayout gScenePlayerViewports +gCutsceneCameraActive gModelMatrixStackPos gMtxOriginID gObjectCurrDisplayList gObjectCurrMatrix perspNorm +gViewProjMatrixF gWaveController gCurrWeatherDisplayList gCurrWeatherMatrix gCurrWeatherVertexList +gCurrWeatherTriList gWeatherCamera gWeatherCameraMatrix gWeatherType gSnowParticleCount gSnowVerts +gSnowVertexData gSnowVertexFlip gRainSplashDelay gRainGfx gLightningFrequency D_800DC8AC +D_8011D4A0 D_8011D4A4 D_8011D4AC D_8011D4B0""".split() + + +def generate(source: Path, header: Path, elf: Path, revision: int, output: Path) -> None: + source = source.resolve(strict=True) + output = new_output(output, source, header.parent) + raw = header.read_text() + if digest(header) != HEADER_HASH: + raise ValueError("Unreviewed scene-kernel register ABI") + functions: dict[str, str] = {} + for path in sorted(source.glob("*.c")): + text = path.read_text() + matches = list(DEF.finditer(text)) + for i, m in enumerate(matches): + if m[1] in functions: + raise ValueError("Duplicate scene routine") + functions[m[1]] = text[m.start() : matches[i + 1].start() if i + 1 < len(matches) else len(text)] + work = list(ROOTS) + selected: dict[str, str] = {} + while work: + name = work.pop() + if name in selected: + continue + if name not in NAMES or name not in functions: + raise ValueError("Unreviewed scene dependency: " + name) + body = functions[name] + selected[name] = body + work.extend((set(CALL.findall(strip(body))) & set(functions)) - {name}) + if set(selected) != NAMES: + raise ValueError("Draw-only closure changed: " + str(set(selected) ^ NAMES)) + prefix = f"dkr_camera_v{revision}_" + header_text = raw.replace("// Compiler definition", '#include "camera_kernel_guest.h"\n// Compiler definition', 1) + for name, typ, width in ( + ("W", "int32_t", 4), + ("H", "int16_t", 2), + ("B", "int8_t", 1), + ("HU", "uint16_t", 2), + ("BU", "uint8_t", 1), + ): + header_text, count = re.subn( + r"#define MEM_" + name + r"\(offset, reg\) \\\n[^\n]*", + f"#define MEM_{name}(offset, reg) (*({typ}*)dkr_camera_access((uint64_t)(reg)+(uint64_t)(offset),{width}))", + header_text, + ) + if count != 1: + raise ValueError("Scene memory macro changed") + header_text, count = re.subn( + r"#define SD\(val, offset, reg\) \{.*?\n\}", + r"""#define SD(val, offset, reg) { \\ + *(uint32_t*)dkr_camera_access((uint64_t)(reg)+(uint64_t)(offset)+4,4)=(uint32_t)(val); \\ + *(uint32_t*)dkr_camera_access((uint64_t)(reg)+(uint64_t)(offset),4)=(uint32_t)((uint64_t)(val)>>32); \\ +}""", + header_text, + flags=re.S, + ) + if count != 1: + raise ValueError("Scene doubleword store changed") + header_text = header_text.replace( + "assert(val == val)", '((val)==(val)?(void)0:dkr_camera_fault("non-finite scene value"))' + ) + header_text = header_text.replace( + "assert(((idx) & 1) == 0 || (ctx)->mips3_float_mode)", + '((((idx)&1)==0||(ctx)->mips3_float_mode)?(void)0:dkr_camera_fault("scene floating ABI"))', + ) + macros = set(re.findall(r"^#define\s+(\w+)\s*\(", raw, re.M)) + macros |= set(re.findall(r"static\s+inline\s+\w+\s+(\w+)\s*\(", raw)) + allowed = ( + NAMES + | HOOKS + | MATH + | macros + | {"if", "while", "for", "switch", "return", "sizeof", "void", "sqrtf", "do_break", "switch_error"} + ) + symbols = elf_symbols(elf, (0, 1, 2)) + addresses: dict[str, int] = {} + for name in GLOBALS: + entries = symbols.get(name, set()) + if len(entries) != 1: + raise ValueError("Unknown scene global: " + name) + addresses[name] = next(iter(entries))[0] + emitted: list[str] = [] + for name, body in sorted(selected.items()): + extra = set(CALL.findall(strip(body))) - allowed + if extra or re.search(r"\brdram\s*(?:\[|\+)", strip(body)): + raise ValueError("Scene escaped its closed boundary: " + name + " " + str(extra)) + body = body.replace("{", "{\n dkr_camera_step();", 1) + body = re.sub(r"^(L_[0-9A-Fa-f]+:)\s*$", r"\1 dkr_camera_step();", body, flags=re.M) + # Uniform C prototypes for observation-only injected hooks. No process- + # wide observer may be called from this private renderer. + body = re.sub( + r"extern\s+(?:unsigned|int|void)\s+(dkr_\w+)\([^;]*\);", lambda m: "" if m[1] in HOOKS else m[0], body + ) + for n in sorted(NAMES | HOOKS | MATH | {"switch_error", "do_break"}, key=len, reverse=True): + body = re.sub(r"\b" + n + r"\b", prefix + n, body) + emitted.append(body) + output.mkdir(parents=True) + (output / "recomp.h").write_text(header_text) + prototypes = "".join(f"void {prefix}{n}(uint8_t*,recomp_context*);\n" for n in sorted(NAMES | MATH)) + prototypes += "".join(f"unsigned {prefix}{n}(uint8_t*,recomp_context*,...);\n" for n in sorted(HOOKS)) + prototypes += f"void {prefix}do_break(uint32_t);\nvoid {prefix}switch_error(const char*,uint32_t,uint32_t);\n" + (output / "funcs.h").write_text('#pragma once\n#include "recomp.h"\n' + prototypes) + (output / "camera_scene_functions.c").write_text('#include "funcs.h"\n' + "".join(emitted)) + special = { + "dkr_presentation_world_origin_matrix", + "dkr_presentation_object_begin", + "dkr_presentation_object_end", + "dkr_extend_hub_segment_bitfield", + "dkr_legacy_texture_animation", + "dkr_fix_car_steering_wheel_material", + "dkr_skybox_cover_begin", + "dkr_skybox_cover_end", + "dkr_presentation_wave_begin", + "dkr_presentation_wave_end", + "dkr_presentation_wave_block", + "dkr_presentation_wave_selection", + "dkr_presentation_wave_matrix", + "dkr_widen_gradient_sky", + "dkr_widen_void_primitive", + } + # Ignored hooks are renderer observation/quality overrides only. Their + # live host implementations must never be reached from the private scene. + stubs = "".join( + f"unsigned {prefix}{n}(uint8_t* ram,recomp_context* ctx,...){{(void)ram;(void)ctx;return 0;}}\n" + for n in sorted(HOOKS - special) + ) + (output / "camera_scene_hooks.c").write_text('#include "funcs.h"\n' + stubs) + definitions = "\n".join( + f"#define {n} {prefix}{n}" for n in sorted(NAMES | HOOKS | MATH | {"do_break", "switch_error"}) + ) + definitions += "\n" + "\n".join(f"#define CAMERA_{n} 0x{a:08X}U" for n, a in addresses.items()) + (output / "camera_scene_addresses.h").write_text( + "#pragma once\n" + definitions + f"\n#define CAMERA_ENTRY {prefix}draw\n" + ) + (output / "manifest.json").write_text( + json.dumps( + { + "schema": 1, + "revision": revision, + "roots": ROOTS, + "functions": {n: hashlib.sha256(body.encode()).hexdigest() for n, body in sorted(selected.items())}, + "source": source.relative_to(ROOT).as_posix(), + "recipe": digest(Path(__file__)), + "bridge": { + name: digest(BRIDGE / name) + for name in ("camera_kernel.c", "camera_kernel.h", "camera_kernel_guest.h") + }, + "files": {p.name: digest(p) for p in sorted(output.iterdir()) if p.is_file()}, + "guest_writes": False, + "logic_ticks": False, + "device_calls": False, + }, + indent=2, + ) + + "\n" + ) + print("Private draw-only closure staged:", revision, len(selected), output) + + +def verify(output: Path) -> None: + manifest = cast(dict[str, object], json.loads((output / "manifest.json").read_text())) + if manifest.get("schema") != 1 or manifest.get("recipe") != digest(Path(__file__)): + raise ValueError("Regenerate the private camera draw closure after recipe changes") + if {p.name: digest(p) for p in output.iterdir() if p.is_file() and p.name != "manifest.json"} != manifest["files"]: + raise ValueError("Camera draw output contains unreviewed edits") + for name, sha in cast(dict[str, str], manifest["bridge"]).items(): + if digest(BRIDGE / name) != sha: + raise ValueError("Camera draw bridge changed") + source = (ROOT / cast(str, manifest["source"])).resolve(strict=True) + if ROOT / "build" not in source.parents: + raise ValueError("Camera draw input escaped the pipeline") + found: dict[str, str] = {} + for path in sorted(source.glob("*.c")): + text = path.read_text() + matches = list(DEF.finditer(text)) + for i, m in enumerate(matches): + if m[1] in NAMES: + if m[1] in found: + raise ValueError("Duplicate camera draw input") + body = text[m.start() : matches[i + 1].start() if i + 1 < len(matches) else len(text)] + found[m[1]] = hashlib.sha256(body.encode()).hexdigest() + if found != manifest["functions"] or set(found) != NAMES: + raise ValueError("Camera draw source provenance changed") + print("Private camera draw composition verified:", manifest["revision"]) + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--verify", action="store_true") + p.add_argument("--source", type=Path) + p.add_argument("--header", type=Path) + p.add_argument("--elf", type=Path) + p.add_argument("--revision", type=int, choices=(77, 80)) + p.add_argument("--output", type=Path, required=True) + a = p.parse_args() + if a.verify: + verify(a.output.resolve(strict=True)) + elif not all((a.source, a.header, a.elf, a.revision)): + p.error("generation requires source, header, ELF and revision") + else: + generate(a.source, a.header, a.elf, a.revision, a.output) diff --git a/scripts/character_portrait_pipeline.py b/scripts/character_portrait_pipeline.py index f3e678a0..b1c8d9c7 100644 --- a/scripts/character_portrait_pipeline.py +++ b/scripts/character_portrait_pipeline.py @@ -1,36 +1,51 @@ """Portrait correction stage: read-only pinned inputs, NEW outputs only. -The native overlay preserves v2 artifact/save identities and authenticates the -missing portrait from its retained source review. New imports use the correct -table. An explicit legacy preparation mode is solely for frozen online v2 IDs. +The guest stage hooks the in-world character flag so custom participants get +their portrait, and protects the function from code-mod replacement in the +code policy. A supported prepare step (scripts/prepare_payloads.py runs it). + +The native half of the original stage edited seven first-party sources in a +staged copy of runtime-recomp/src; the fold (step B1) wrote those edits into +runtime-recomp/src itself. The receipt therefore records those seven files' +folded digests, and the owned payload checks them, instead of a native stage. """ +from __future__ import annotations + import argparse import hashlib import json from pathlib import Path import shutil +from typing import Any, cast + +from save_layout_pipeline import DEF ROOT = Path(__file__).resolve().parents[1] -AUTH = ROOT / "patches/character-portraits" +WORLD_PORTRAIT = ROOT / "runtime-recomp/guest/world_portrait.inc" MODS = ROOT / "runtime-recomp/src/game/mods" -PINS = { - "legacy_mod_format.cpp": "74adeea776dc34cb2516fd14b4578c4f81f048f61f4fd452faa1e1e5d66642b3", - "legacy_character_materialize.hpp": "bd4b2f1104257de526bbe05cbfa1aac0ec0f3b528f908de55d7ac16e5e992397", - "legacy_character_materialize.cpp": "6d9657e9dabb748a13f9733e201411b02cb8f94cb1b33f14bca64fa90f4db203", - "legacy_track_catalog.cpp": "37cf82e6987a5cc06743fc86144eabb5e299f797c37a8ab7e959771c68bd3875", - "legacy_track_prepare_job.hpp": "2672949857d0bf106ba702a47d153725878f10058934c11ba3d2cf83429e542a", - "legacy_track_prepare_job.cpp": "716b637ca843b7f1fd87d95bd3dbaaf37df80828fa3adb43e0b7f742cc2a771d", - "online_mod_prepare.cpp": "73921b793921da7bb0bab3763eb1c42dc40fafa067a01c57155a38533d22cf06", +# The seven native portrait edits as v1.0.11 shipped them (runtime-recomp/src). +NATIVE_FILES = { + "legacy_mod_format.cpp": "b3cdd9d7bcab6f54355592f90eed0e5c9ccf98e92d331746a8e186b06982cf4d", + "legacy_character_materialize.hpp": "7fa418bb9fd4da9b79eab21dd98876f2b05b4496330b809e17c9359bf4eef4fb", + "legacy_character_materialize.cpp": "2270d3efce0dcfcef2f5364e946fe70a5d5401f11b1960917ba5449678423563", + "legacy_track_catalog.cpp": "ad6d37cb7196738dfe9d2a1c07af28e94e69040ebc9909ffbed30aae7055796f", + "legacy_track_prepare_job.hpp": "810b3cfa3c62e43c557f9d3c5d134888b7b9e3b9ad4ac7643badfd3c1e75d14c", + "legacy_track_prepare_job.cpp": "d1a0f6a906c261f9d8104d167da5ebc3fcf1aef686ec6142efd39c0da3f5d82d", + "online_mod_prepare.cpp": "e0ec53916dfd0f75582835f2198b2934164b9e5080d3766e7a2423c24063c9bb", } GUEST_PINS = { 77: "006cc572caf0097392aeccac974dc4574813eeea81c09b6eabc5b1b7a9199842", 80: "9fc26cc8bb1762db7a78f5979cea65835ccd7c84e6ed325d411ad149f98df070", } -sha = lambda b: hashlib.sha256(b).hexdigest() +ADDRESSES = {77: "80035FE4", 80: "80036024"} + +def sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() -def new_output(path, *inputs): + +def new_output(path: Path, *inputs: Path) -> Path: path = path.resolve() if path.exists() or any(path == p.resolve() or p.resolve() in path.parents for p in inputs): raise ValueError("Portrait pipeline output must be NEW and outside every read-only input") @@ -39,247 +54,30 @@ def new_output(path, *inputs): return path -def replace(text, old, new, count=1): +def replace(text: str, old: str, new: str, count: int = 1) -> str: if text.count(old) != count: raise ValueError("Unreviewed portrait edit site: " + old[:100]) return text.replace(old, new) -def transform(name, text): - if name == "legacy_mod_format.cpp": - return replace( - text, - "portraits[]{122,123,125,126,124,127,129,128,130,131}", - "portraits[]{122,123,124,126,125,127,128,129,130,131}", - ) - if name == "legacy_character_materialize.hpp": - # Only PreparedCharacter grows; AllocatedCharacter's ABI stays intact. - at = text.index("struct AllocatedCharacter") - first = replace( - text[:at], - " CharacterRaceAudio race_audio;", - """ CharacterRaceAudio race_audio; - // Recovered only by the authenticated source-review loader. - Bytes source_portrait; - unsigned source_portrait_id=0;""", - ) - text = first + text[at:] - return replace( - text, "bool behaviour_enabled=true);", "bool behaviour_enabled=true,bool legacy_portrait_layout=false);" - ) - if name == "legacy_character_materialize.cpp": - text = replace( - text, - "unsigned base_character,bool behaviour_enabled) {", - "unsigned base_character,bool behaviour_enabled,bool legacy_portrait_layout) {", - ) - text = replace( - text, - " result.base_fingerprint=sha256(original);", - """ result.base_fingerprint=sha256(original); - if(legacy_portrait_layout) { - constexpr unsigned previous[]{122,123,125,126,124,127,129,128,130,131}; - result.root.portrait=previous[base_character]; - }""", - ) - text = replace( - text, - " const auto& portrait=character.records.at({4,character.root.portrait});", - """ constexpr unsigned portrait_ids[]{122,123,124,126,125,127,128,129,130,131}; - const bool recovered=!character.source_portrait.empty(); - if(recovered && (character.source_portrait_id!=portrait_ids[character.root.base_character] || character.source_portrait.size()>4*MiB)) - throw Error("Invalid recovered source portrait."); - const auto portrait_id=recovered?character.source_portrait_id:character.root.portrait; - const auto& portrait=recovered?character.source_portrait:character.records.at({4,character.root.portrait}); - validate_texture_record(portrait);""", - ) - text = replace( - text, - " for(unsigned i=0;i<3;++i)slot.headers[i]=remap.at({34,character.root.headers[i]});", - """ if(recovered) { - if(next[4]>=32767)throw Error("Recovered portrait namespace is full."); - slot.portrait=next[4]++; - result.additions.emplace(AssetKey{4,slot.portrait},portrait); - } - for(unsigned i=0;i<3;++i)slot.headers[i]=remap.at({34,character.root.headers[i]});""", - ) - text = replace( - text, - "slot.custom_portrait=character_portrait_changed(portrait,stock->record(4,character.root.portrait));", - "slot.custom_portrait=character_portrait_changed(portrait,stock->record(4,portrait_id));", - ) - return replace( - text, - "for(const auto& c:result.characters)roster_ids+=c.id;", - 'for(const auto& c:result.characters)roster_ids+=c.id+":portrait-source-v1:"+c.portrait_identity;', - ) - if name == "legacy_track_catalog.cpp": - text = replace( - text, - " std::size_t cached_audio_bytes=0;", - " std::size_t cached_audio_bytes=0,source_portrait_bytes=0;", - ) - anchor = " std::array audio;" - text = replace(text, anchor, (AUTH / "load_reviewed_portrait.inc").read_text() + "\n" + anchor) - return replace( - text, - " result.push_back(std::move(character));", - """ constexpr unsigned correct_portraits[]{122,123,124,126,125,127,128,129,130,131}; - const auto correct_portrait=correct_portraits[character.root.base_character]; - if(character.root.portrait!=correct_portrait && character.source_portrait.empty()) { - const auto bytes=stock->record(4,correct_portrait); - character.source_portrait.assign(bytes.begin(),bytes.end()); - character.source_portrait_id=correct_portrait; - } - if(character.source_portrait.size()>32*MiB-source_portrait_bytes) - throw Error("Recovered character portraits exceed the presentation memory budget."); - source_portrait_bytes+=character.source_portrait.size(); - result.push_back(std::move(character));""", - ) - if name == "legacy_track_prepare_job.hpp": - at = text.index("void prepare_imported_characters") - return text[:at] + replace( - text[at:], - "bool behaviour_enabled=true);", - "bool behaviour_enabled=true,bool legacy_portrait_layout=false);", - ) - if name == "legacy_track_prepare_job.cpp": - text = replace( - text, - "bool characters,const ImportPreparation* preparation,bool behaviour_enabled) {", - "bool characters,const ImportPreparation* preparation,bool behaviour_enabled,bool legacy_portrait_layout=false) {", - ) - text = replace( - text, - "prepare_character(base,target,patch_id,root.base_character,behaviour_enabled);", - "prepare_character(base,target,patch_id,root.base_character,behaviour_enabled,legacy_portrait_layout);", - ) - at = text.index("void prepare_imported_characters(") - tail = replace(text[at:], "bool behaviour_enabled) {", "bool behaviour_enabled,bool legacy_portrait_layout) {") - tail = replace( - tail, - "progress,true,preparation,behaviour_enabled);", - "progress,true,preparation,behaviour_enabled,legacy_portrait_layout);", - ) - return text[:at] + tail - if name == "online_mod_prepare.cpp": - text = replace( - text, - " const auto prepared=job/(std::string(kind==Kind::Track?", - """ // Old host selections retain v2 IDs/save associations. Rebuild - // that exact edition only when requested, never accept a mismatch. - for(const bool previous_portraits:{false,true}) { - if(previous_portraits && kind!=Kind::Character)continue; - bool pending=false; - for(const auto& content:manifest.content) - if(content.payload==payload.digest && content.kind==kind && (content.behaviour_abi!=0)==enabled && - !found.contains({kind,content.id}))pending=true; - if(!pending)continue; - const auto prepared=job/(std::string(previous_portraits?"previous-portraits-":"")+std::string(kind==Kind::Track?""", - ) - text = replace( - text, - "prepare_imported_characters(review,owned_roms,prepared,progress,&preparation,enabled);", - "prepare_imported_characters(review,owned_roms,prepared,progress,&preparation,enabled,previous_portraits);", - ) - text = replace( - text, - " if(entry==expected.end() || entry->second.payload!=payload.digest)continue;", - " if(entry==expected.end() || entry->second.payload!=payload.digest || found.contains(key))continue;", - ) - text = replace( - text, - " receipt[entries]=std::move(selected);", - """ // A corrected pass may have no roots matching an old host ID. - // Leave that scratch unpublished; empty groups are not catalogues. - if(selected.empty())continue; - receipt[entries]=std::move(selected);""", - ) - return replace( - text, - " }\n }\n storage_budget(destination);", - " }\n }\n }\n storage_budget(destination);", - ) - raise ValueError(name) - - -def native(output): - sources = ROOT / "runtime-recomp/src" - output = new_output(output, sources) - for name, pin in PINS.items(): - if sha((MODS / name).read_bytes()) != pin: - raise ValueError("Portrait source pin changed: " + name) - for p in sources.rglob("*"): - if p.is_symlink(): - raise ValueError("Native source input must not contain links") - shutil.copytree(sources, output / "src") - for name in PINS: - dest = output / "src/game/mods" / name - dest.write_text(transform(name, (MODS / name).read_text()), newline="\n") - # Every staged consumer must use the same struct definition. The complete - # first-party src tree is copied unchanged except the seven pinned files. - files = { - p.relative_to(output).as_posix(): sha(p.read_bytes()) - for p in sorted((output / "src").rglob("*")) - if p.is_file() - } - receipt = dict(schema=1, original_pins=PINS, files=files) - (output / "native-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") - print("Native portrait stage:", output, len(files), "first-party inputs, seven pinned edits") - symbols(output.parent / "modding") +def verify_native_files() -> None: + """The native half of the portrait fix is present in runtime-recomp/src.""" + for name, pin in NATIVE_FILES.items(): + path = MODS / name + if path.is_symlink() or sha(path.read_bytes()) != pin: + raise ValueError("Native portrait source changed: " + name) -def symbols(output): - output = new_output(output, ROOT / "modding") - for revision in (77, 80): - rel = Path("reference-us-v" + str(revision)) / "game_symbols.inc" - source = ROOT / "modding" / rel - if source.is_symlink() or not source.is_file(): - raise ValueError("Invalid symbol dependency") - dest = output / rel - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, dest) - print("Read-only source-relative symbol dependencies copied to", output) - - -def verify_native(output): - receipt = json.loads((output / "native-receipt.json").read_text()) - if receipt["schema"] != 1 or receipt["original_pins"] != PINS: - raise ValueError("Unknown portrait stage") - originals = ROOT / "runtime-recomp/src" - expected = {p.relative_to(output).as_posix() for p in (output / "src").rglob("*") if p.is_file()} - if expected != set(receipt["files"]): - raise ValueError("Portrait stage file set changed") - for rel, digest in receipt["files"].items(): - file = output / rel - if file.is_symlink() or sha(file.read_bytes()) != digest: - raise ValueError("Portrait staged file changed: " + rel) - source = originals / Path(rel).relative_to("src") - name = source.name - if source.parent == MODS and name in PINS: - if sha(source.read_bytes()) != PINS[name] or file.read_text() != transform(name, source.read_text()): - raise ValueError("Unreviewed portrait source difference: " + rel) - elif file.read_bytes() != source.read_bytes(): - raise ValueError("Unrelated native source changed: " + rel) - for revision in (77, 80): - rel = Path("reference-us-v" + str(revision)) / "game_symbols.inc" - copy = output.parent / "modding" / rel - if copy.is_symlink() or copy.read_bytes() != (ROOT / "modding" / rel).read_bytes(): - raise ValueError("Source-relative game symbols differ from the checked online contract") - print("Portrait native stage matches authored pipeline and current first-party inputs") - - -def guest(source, output, revision, config, output_config, native_stage): - from save_layout_pipeline import DEF - +def guest(source: Path, output: Path, revision: int, config: Path, output_config: Path) -> None: + verify_native_files() source = source.resolve() output = new_output(output, source) output_config = new_output(output_config, source, config) for path in source.rglob("*"): if path.is_symlink(): raise ValueError("Guest portrait input cannot contain links") - edits = {} - found = None + edits: dict[str, str] = {} + found: dict[str, str] | None = None for file in source.glob("*.c"): text = file.read_text() matches = list(DEF.finditer(text)) @@ -295,7 +93,7 @@ def guest(source, output, revision, config, output_config, native_stage): replacement = replace( body, " MEM_W(0X24, ctx->r3) = ctx->r11;", - (AUTH / "world_portrait.inc").read_text() + "\n MEM_W(0X24, ctx->r3) = ctx->r11;", + WORLD_PORTRAIT.read_text() + "\n MEM_W(0X24, ctx->r3) = ctx->r11;", ) edits[file.name] = text[: m.start()] + replacement + text[end:] found = dict(original_sha256=sha(body.encode()), patched_sha256=sha(replacement.encode())) @@ -308,42 +106,35 @@ def guest(source, output, revision, config, output_config, native_stage): schema=1, revision=revision, function=found, - native_sha256=sha((native_stage / "native-receipt.json").read_bytes()), - world_source_sha256=sha((AUTH / "world_portrait.inc").read_bytes()), + native_files=NATIVE_FILES, + world_source_sha256=sha(WORLD_PORTRAIT.read_bytes()), ) (output / "character-portrait-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") content = config.read_text().replace(source.resolve().as_posix(), output.resolve().as_posix()) - address = {77: "80035FE4", 80: "80036024"}[revision] # Protect the entire function from code-mod replacement, like other port hooks. content += ( '\n# In-world custom participant portraits\n[[patches.hook]]\nfunc = "obj_loop_characterflag"\nbefore_vram = 0x' - + address + + ADDRESSES[revision] + '\ntext = "/* dkr_character_portrait_pipeline: protected source hook */"\n' ) output_config.write_text(content, newline="\n") print("World portrait guest stage v" + str(revision) + ":", output) -def owned_receipt(source, revision): +def owned_receipt(source: Path, revision: int) -> dict[str, Any]: path = source / "character-portrait-receipt.json" if not path.exists(): return {} - receipt = json.loads(path.read_text()) - if ( - receipt["schema"] != 1 - or receipt["revision"] != revision - or receipt["function"]["original_sha256"] != GUEST_PINS[revision] - ): + receipt = cast("dict[str, Any]", json.loads(path.read_text())) + function = cast("dict[str, str]", receipt["function"]) + if receipt["schema"] != 1 or receipt["revision"] != revision or function["original_sha256"] != GUEST_PINS[revision]: raise ValueError("Unreviewed portrait guest provenance") - if receipt["world_source_sha256"] != sha((AUTH / "world_portrait.inc").read_bytes()): + if receipt["world_source_sha256"] != sha(WORLD_PORTRAIT.read_bytes()): raise ValueError("World portrait source has changed") - native_stage = source.parent / "native" - verify_native(native_stage) - if receipt["native_sha256"] != sha((native_stage / "native-receipt.json").read_bytes()): - raise ValueError("Portrait guest/native stages disagree") - from save_layout_pipeline import DEF - - found = [] + if receipt.get("native_files") != NATIVE_FILES: + raise ValueError("Portrait guest stage and native sources disagree") + verify_native_files() + found: list[str] = [] for file in source.glob("*.c"): text = file.read_text() matches = list(DEF.finditer(text)) @@ -351,26 +142,17 @@ def owned_receipt(source, revision): if m[1] == "obj_loop_characterflag": end = matches[i + 1].start() if i + 1 < len(matches) else len(text) found.append(sha(text[m.start() : end].encode())) - if found != [receipt["function"]["patched_sha256"]]: + if found != [function["patched_sha256"]]: raise ValueError("World portrait guest body changed") return receipt if __name__ == "__main__": p = argparse.ArgumentParser(description=__doc__) - p.add_argument("mode", choices=("native", "verify-native", "guest", "symbols")) p.add_argument("--output", type=Path, required=True) - p.add_argument("--source", type=Path) - p.add_argument("--revision", type=int, choices=(77, 80)) - p.add_argument("--config", type=Path) - p.add_argument("--output-config", type=Path) - p.add_argument("--native-stage", type=Path) + p.add_argument("--source", type=Path, required=True) + p.add_argument("--revision", type=int, choices=(77, 80), required=True) + p.add_argument("--config", type=Path, required=True) + p.add_argument("--output-config", type=Path, required=True) a = p.parse_args() - if a.mode == "native": - native(a.output) - elif a.mode == "verify-native": - verify_native(a.output) - elif a.mode == "symbols": - symbols(a.output) - else: - guest(a.source, a.output, a.revision, a.config, a.output_config, a.native_stage) + guest(a.source, a.output, a.revision, a.config, a.output_config) diff --git a/scripts/generate_replay_probe.py b/scripts/generate_replay_probe.py index 232e81b9..7545a2b8 100644 --- a/scripts/generate_replay_probe.py +++ b/scripts/generate_replay_probe.py @@ -566,7 +566,7 @@ def authored_main_cpu(body, revision, full_scenes=False): # Pak path continues to use its separate checkpointed in-memory services. "runtime-recomp/src/game/virtual_pak.cpp": "0dd5e4b81817f4d50db6e701d0d1ad517b47a94e721bf29d4ee94ae65d121747", # Oct 1 reviewed opt-in capture hook; stable drive/input branches unchanged. - "runtime-recomp/src/game/runtime_netplay.cpp": "aaeee3894a0087e9512cf426a65f044cfd76f7aee82dac713b2969f12126160c", + "runtime-recomp/src/game/runtime_netplay.cpp": "4cb001dd32e158978221f1536ff6c8695b5ab0c330e7fcaa9dc06686c3376709", "runtime-recomp/src/game/runtime_magic_codes.cpp": "e7d661472bb041991b3e8cfc6373c1d647b0fa300281d21448ed74efdb01021f", # Frozen online course sizing/ID routing bypasses the offline catalogue. # The no-session path remains the prior offline/vanilla implementation. @@ -582,7 +582,7 @@ def authored_main_cpu(body, revision, full_scenes=False): # Oct 12: reviewed merge with PR56's frozen-course guard BEFORE any live # transport read. Retains caller-owned background lists and offline vehicle # selection; neither changes owned checkpoint semantics or asset routing. - "runtime-recomp/src/game/runtime_legacy_mods.cpp": "f9af686c2f30ce6f875041f74b694640565d6cef783c18bcdeaf752f298d0988", + "runtime-recomp/src/game/runtime_legacy_mods.cpp": "4ee51ab618aeaaa661b6cd97c73a3b04d9952298f95f761bba065c86ddef095b", # Oct 9: guest-owned deferred queue/reserve; no shortened borrower lifetime. "runtime-recomp/src/game/mods/legacy_memory_safety.h": "17b293ec170dd7d8d25377a01a73b9c5cdfca9636b2c959d06930c178d02cd85", "runtime-recomp/src/game/mods/legacy_model_safety.hpp": "64520d21f6721b9caf40ee51f88548266ffaf5bc8821a7c17e6bdc72fa244bb7", @@ -590,11 +590,11 @@ def authored_main_cpu(body, revision, full_scenes=False): # immutable sample mount are shared by offline and replay-owned sessions. "runtime-recomp/src/game/mods/legacy_character_limits.hpp": "d74576f3bcd7aa61283ac4e8e68d3a234e9182dca5f09f6398f285ca9353c55a", "runtime-recomp/src/game/mods/legacy_character_audio.cpp": "00fdd1fa4dbae442ee471cd4ef71631db95c4df6055ea092c9b368f1cf12d124", - "runtime-recomp/src/game/mods/legacy_character_materialize.cpp": "6d9657e9dabb748a13f9733e201411b02cb8f94cb1b33f14bca64fa90f4db203", + "runtime-recomp/src/game/mods/legacy_character_materialize.cpp": "2270d3efce0dcfcef2f5364e946fe70a5d5401f11b1960917ba5449678423563", "runtime-recomp/src/game/mods/legacy_character_presentation.cpp": "311f633c84b103a63d5e2538af1caa377c07e311df3aae1cf52a3fb77aa208b7", # Preserve the already-shipped stock-preview restore correction: empty # custom identity is allowed only for a pending retail preview, not races. - "runtime-recomp/src/game/mods/legacy_guest_checkpoint.cpp": "4748e2ef4b83b4772373e0cf6719945fb3c420a7e60f716cef00834114cde789", + "runtime-recomp/src/game/mods/legacy_guest_checkpoint.cpp": "e204c7cc1dfac8205d8078b359f911e799fbdd33c4b94a351c0a37508b562094", # Prepared immutable-template instantiation preserves mount/allocation # order and owns a separate mutable bus for every replay world. # Scene identity diagnostics flush before the existing prepare/commit; @@ -626,7 +626,7 @@ def authored_main_cpu(body, revision, full_scenes=False): # CPU coordinates/visible neighbourhood are canonical on every peer. # PR56 additionally moves the same TailGate/RdramAddress definitions outside # the RT64 guard for headless audio/input. Rendered/owned hooks are unchanged. - "runtime-recomp/src/game/runtime_stubs.cpp": "3bd058b1674d9fa12d282f99cfdf0771406dd5da7ba816d81b32225485ff9e51", + "runtime-recomp/src/game/runtime_stubs.cpp": "26671b332bf72c27eeef253a784abc5f438ad84ddd51f7f72e15fc2988e43bd8", "runtime-recomp/src/game/track_select_draw_guest.h": "6bc51e7168679d32c9602988793cc47e983b116343981db00f3feac6fbc13912", "runtime-recomp/src/game/audio_event_queue.h": "efb6c0cdadbc5a1fbab30ee7ff524f558ea54523a43a3424727bba4a997535c2", "runtime-recomp/src/game/audio_event_queue_state.h": "ca400d843e9018f5d69c2aa75ec40889a303574c7789a7297185369dcde79b18", @@ -644,7 +644,7 @@ def authored_main_cpu(body, revision, full_scenes=False): # Beta 5: four-player range compensation and a normal-runtime-only # noninteractive sprite retention hook. Both remain explicit identities # in the owned CPU; per-player preferences are consumed by the decoder. - "runtime-recomp/src/game/runtime_enhancements.cpp": "79d59b06477366169e41d213310385819e9f902441e18fe46d372a7ff789f8a9", + "runtime-recomp/src/game/runtime_enhancements.cpp": "14b77607f65e926c3a18c7cee9a9a073f23f83a70726c11f4aef58815cd8edb8", "runtime-recomp/src/game/character_select_animation_policy.hpp": "ab8a0f4220c00ce1b5726ff75953f0c4944b5b4e24eba942b442e07268ad04da", "runtime-recomp/src/game/character_select_music_policy.hpp": "e2e308c4821e2b11cba32ecafd7195cdf7ed9cc4c4166c8b4078ec513441da6c", } @@ -680,7 +680,7 @@ def full_scene_native_audit(dependencies_dir=None): # transient semantic cursor scopes cannot cross a checkpoint. Track Lab # requests use their admitted appended IDs, not legacy retail carriers. # DMA/save/character dispatch signatures and ownership remain unchanged. - "runtime-recomp/src/game/runtime_legacy_mods.cpp": "4dce7b77f8b86ca30a8429d14f700e060c08f134456d1a9931df77d2549842ed", + "runtime-recomp/src/game/runtime_legacy_mods.cpp": "4ee51ab618aeaaa661b6cd97c73a3b04d9952298f95f761bba065c86ddef095b", "runtime-recomp/src/game/mods/legacy_guest_checkpoint.cpp": "e204c7cc1dfac8205d8078b359f911e799fbdd33c4b94a351c0a37508b562094", } @@ -1289,10 +1289,16 @@ def runtime_isolation_header(guest_names, import_names, revision): """ root = Path(__file__).resolve().parents[1] / "runtime-recomp/tests/replay_probe" names = {"make_owned_game", "dkr_owned_mod_calls"} + # A probe source that #defines a dkr_probe_ name to its own per-revision + # symbol (probe_component.h's dkr_probe_sdk2_pulse) isolates it already; + # a second mapping here would clash with it. + self_isolated = set() for path in sorted(root.iterdir()): if path.suffix in (".c", ".cpp", ".h", ".hpp"): - names.update(re.findall(r"\bdkr_probe_[A-Za-z_0-9]+\b", path.read_text(encoding="utf-8"))) - names.difference_update(set(guest_names) | set(import_names)) + text = path.read_text(encoding="utf-8") + names.update(re.findall(r"\bdkr_probe_[A-Za-z_0-9]+\b", text)) + self_isolated.update(re.findall(r"^#define (dkr_probe_[A-Za-z_0-9]+) ", text, re.M)) + names.difference_update(set(guest_names) | set(import_names) | self_isolated) return isolation_header(names, (), revision) diff --git a/scripts/generate_visual_model_kernel.py b/scripts/generate_visual_model_kernel.py index 4b4e50ea..f3295428 100644 --- a/scripts/generate_visual_model_kernel.py +++ b/scripts/generate_visual_model_kernel.py @@ -5,16 +5,20 @@ renderer, behaviour, allocator, code-mod callback, audio or network entry here. The five reviewed leaf routines run against a decoder image with copy-on-write scalar globals and a separate vertex/command arena; they cannot write guest RAM. +This is the local model kernel (DKR_VISUAL_MODEL_GENERATED_V77/V80), a +supported prepare step: scripts/prepare_payloads.py runs it. """ +from __future__ import annotations + import argparse import hashlib import json from pathlib import Path import re +from typing import cast -from compose_legacy_mod_policy import elf_functions -from generate_replay_probe import CALL, DEF, HEADER_HASH, strip +from kernel_inputs import CALL, DEF, HEADER_HASH, elf_symbols, strip PINS = { 77: { @@ -45,7 +49,7 @@ ) -def generate(source, header_path, elf, revision, output): +def generate(source: Path, header_path: Path, elf: Path, revision: int, output: Path) -> None: source, output = source.resolve(), output.resolve() if ( output.exists() @@ -57,7 +61,7 @@ def generate(source, header_path, elf, revision, output): header = header_path.read_bytes() if hashlib.sha256(header).hexdigest() != HEADER_HASH: raise ValueError("Unreviewed visual kernel register/memory ABI") - functions = {} + functions: dict[str, str] = {} for path in sorted(source.glob("*.c")): text = path.read_text(encoding="utf-8") matches = list(DEF.finditer(text)) @@ -71,8 +75,8 @@ def generate(source, header_path, elf, revision, output): functions[match[1]] = body if set(functions) != set(PINS[revision]): raise ValueError("Incomplete model-only closure") - symbols = elf_functions(elf, (0, 1, 2)) - addresses = {} + symbols = elf_symbols(elf, (0, 1, 2)) + addresses: dict[str, int] = {} for name in GLOBALS: entries = symbols.get(name, set()) if len(entries) != 1 or next(iter(entries))[1] not in (2, 4): @@ -102,7 +106,7 @@ def generate(source, header_path, elf, revision, output): allowed = set(re.findall(r"^#define\s+(\w+)\s*\(", header.decode(), re.M)) allowed.update(re.findall(r"static\s+inline\s+\w+\s+(\w+)\s*\(", header.decode())) allowed |= {"if", "while", "switch", "for", "return", "sizeof", "void"} | names - emitted = [] + emitted: list[str] = [] for name, body in sorted(functions.items()): extra = set(CALL.findall(strip(body))) - allowed if extra or re.search(r"\brdram\s*(?:\[|\+)", strip(body)): @@ -124,7 +128,6 @@ def generate(source, header_path, elf, revision, output): for symbol in sorted(names, key=len, reverse=True): body = re.sub(r"\b" + symbol + r"\b", prefix + symbol, body) emitted.append(body) - root = Path(__file__).resolve().parents[1] output.mkdir(parents=True) (output / "recomp.h").write_text(h, encoding="utf-8") (output / "funcs.h").write_text( @@ -138,7 +141,7 @@ def generate(source, header_path, elf, revision, output): "#pragma once\n" + constants + "\n" - + "\n".join(f"#define {n} {prefix}{n}" for n in names) + + "\n".join(f"#define {n} {prefix}{n}" for n in sorted(names)) + "\n" + f"#define DKR_VISUAL_ENTRY {prefix}model\n", encoding="utf-8", @@ -169,4 +172,10 @@ def generate(source, header_path, elf, revision, output): parser.add_argument("--revision", type=int, choices=(77, 80), required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() - generate(args.generated, args.header, args.elf, args.revision, args.output) + generate( + cast(Path, args.generated), + cast(Path, args.header), + cast(Path, args.elf), + cast(int, args.revision), + cast(Path, args.output), + ) diff --git a/scripts/kernel_inputs.py b/scripts/kernel_inputs.py new file mode 100644 index 00000000..6f472836 --- /dev/null +++ b/scripts/kernel_inputs.py @@ -0,0 +1,34 @@ +"""Typed views of the guest-code parsers the private kernel generators share. + +scripts/camera_scene_kernel.py and scripts/generate_visual_model_kernel.py +read N64Recomp output with generate_replay_probe.py's function parser and the +decomp ELF with compose_legacy_mod_policy.py's symbol reader. Those modules +aren't typed; this gives the kernels' strict typing one place to rely on. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re +from typing import cast + +import compose_legacy_mod_policy +import generate_replay_probe + +# One generated function definition (group 1 is its name). +DEF: re.Pattern[str] = generate_replay_probe.DEF +# A call site (group 1 is the callee). +CALL: re.Pattern[str] = generate_replay_probe.CALL +# Source without comments and string literals. +strip = cast(Callable[[str], str], generate_replay_probe.strip) # pyright: ignore[reportUnknownMemberType] +# The reviewed N64Recomp recomp.h. +HEADER_HASH = cast(str, generate_replay_probe.HEADER_HASH) + + +def elf_symbols(path: Path, symbol_types: tuple[int, ...]) -> dict[str, set[tuple[int, int]]]: + """Symbol name to its (address, size) entries, for the given ELF symbol types.""" + return cast( + "dict[str, set[tuple[int, int]]]", + compose_legacy_mod_policy.elf_functions(path, symbol_types), # pyright: ignore[reportUnknownMemberType] + ) diff --git a/scripts/pattern_guard.py b/scripts/pattern_guard.py index d2d17a4a..3756b35d 100644 --- a/scripts/pattern_guard.py +++ b/scripts/pattern_guard.py @@ -183,6 +183,7 @@ class Rule: "scripts/gen_guest_symbols.py": "writes new symbol headers", "scripts/guest_hooks.py": "applies declared hooks to generated guest code", "scripts/generate_replay_probe.py": "reads first-party headers only to collect probe symbol names", + "scripts/camera_scene_kernel.py": "reads the first-party camera bridge only to record its digests", # Fold-only (step B1): writes the shipped closure into runtime-recomp/ once, # respelling staged #include paths. Deleted with scripts/fold/ after C11. "scripts/fold/apply_closure.py": "writes the shipped v1.0.11 closure back into the tree once", diff --git a/scripts/prepare_payloads.py b/scripts/prepare_payloads.py new file mode 100644 index 00000000..2d7b1d99 --- /dev/null +++ b/scripts/prepare_payloads.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Prepare the build inputs the runtime derives from the recompiled guest code. + +Each revision's inputs are the N64Recomp output of the composed production +policy, the N64Recomp config that produced it, and the decomp ELF. By default +they are where scripts/Prepare-DKR-Runtime-macOS.sh puts them (build/generated/ +v/RecompiledFuncs, build/generated/v/dkr.us.v.toml, build/decomp/ +v/build/dkr.us.v.elf); --input names them for one revision instead +(the Windows composition, docs/BUILDING.md). From those, this prepares, under +OUT (default build/prepared): + +* generated-v/ and v.toml: the CPU payload and code policy the game + compiles, with the save-layout and in-world portrait stages applied + (scripts/save_layout_pipeline.py, scripts/character_portrait_pipeline.py); +* owned-v/: the owned rollback engine (scripts/generate_replay_probe.py), + regenerated after each stage with the roots of the one before, as the + release process did; +* camera-kernel-v/: the Camera++ draw-only scene kernel + (scripts/camera_scene_kernel.py); +* model-v/: the local model detail kernel + (scripts/generate_visual_model_kernel.py). + +runtime-recomp's CMake reads these paths by default (cmake/PreparedInputs.cmake). +Intermediate stages stay in OUT/stages/. OUT is rebuilt from scratch on every +run; it must lie under the work tree's build/ (the kernels refuse anything +else) and is only removed when this script made it. + + prepare_payloads.py [--output DIR] [--input REV FUNCS CONFIG ELF ...] + +Exit status: 0 on success, 1 when a stage refuses its input. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import json +from pathlib import Path +import shutil +import subprocess +import sys +from typing import cast + +import camera_scene_kernel +import character_portrait_pipeline +import generate_visual_model_kernel +import save_layout_pipeline + +ROOT = Path(__file__).resolve().parents[1] +HEADER = ROOT / "extern/n64-modern-runtime/N64Recomp/include/recomp.h" +MARKER = "prepared.json" +REVISIONS = (77, 80) +# The owned engine's options, read back from a manifest for the next stage. +OWNED_OPTIONS = { + "scene_cuts": "--scene-cuts", + "full_scenes": "--full-scenes", + "audio_services": "--audio-services", + "input_services": "--input-services", + "authored_cpu": "--authored-cpu", + "owned_mod_services": "--mod-services", + "checked_inline_memory": "--inline-memory", + "boss_finish_diagnostics": "--boss-finish-diagnostics", +} +BASELINE_OPTIONS = ( + "--scene-cuts", + "--audio-services", + "--input-services", + "--authored-cpu", + "--isolate-symbols", + "--full-scenes", + "--mod-services", + "--inline-memory", +) + + +class PrepareError(Exception): + pass + + +def owned(source: Path, config: Path, output: Path, revision: int, options: Sequence[str]) -> None: + """Run the owned-engine generator the way the release process did.""" + command = [ + sys.executable, + "-B", + str(ROOT / "scripts/generate_replay_probe.py"), + str(source), + str(HEADER), + str(output), + "--revision", + str(revision), + *options, + "--code-policy-config", + str(config), + ] + done = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) + if done.returncode: + raise PrepareError(f"owned engine v{revision} ({output.name}): {done.stderr.strip()[-600:]}") + + +def options_from(manifest: Path, revision: int) -> list[str]: + """The previous owned engine's options and roots, for the next stage.""" + data = cast("dict[str, object]", json.loads(manifest.read_text(encoding="utf-8"))) + if data.get("revision") != revision or not data.get("runtime_symbol_isolation"): + raise PrepareError(f"{manifest}: not an isolated v{revision} owned engine") + options = ["--isolate-symbols", *(flag for key, flag in OWNED_OPTIONS.items() if data.get(key))] + return [*options, "--roots", *cast("list[str]", data["roots"])] + + +def policy_copy(config: Path, source: Path, target: Path, output: Path) -> None: + """The N64Recomp code policy, naming the copied CPU payload.""" + text = config.read_text(encoding="utf-8") + if text.count(source.as_posix()) != 1: + raise PrepareError(f"{config}: does not name {source} exactly once") + output.write_text(text.replace(source.as_posix(), target.as_posix()), encoding="utf-8", newline="\n") + + +def default_inputs(revision: int) -> tuple[Path, Path, Path]: + generated = ROOT / f"build/generated/v{revision}" + elf = ROOT / f"build/decomp/v{revision}/build/dkr.us.v{revision}.elf" + return generated / "RecompiledFuncs", generated / f"dkr.us.v{revision}.toml", elf + + +def prepare_revision(out: Path, revision: int, raw: Path, config: Path, elf: Path) -> None: + for needed in (raw, config, elf): + if not needed.exists(): + raise PrepareError(f"missing {needed}; run scripts/Prepare-DKR-Runtime-macOS.sh first") + raw, config = raw.resolve(), config.resolve() + baseline, save = out / "stages/baseline", out / "stages/save-layout" + for stage in (baseline, save): + stage.mkdir(parents=True, exist_ok=True) + + # Baseline: the N64Recomp payload as it is, and its full owned engine. + shutil.copytree(raw, baseline / f"generated-v{revision}") + policy_copy(config, raw, (baseline / f"generated-v{revision}").resolve(), baseline / f"v{revision}.toml") + owned( + baseline / f"generated-v{revision}", + baseline / f"v{revision}.toml", + baseline / f"owned-v{revision}", + revision, + BASELINE_OPTIONS, + ) + + # Save layout, then the in-world portrait; each reruns the owned engine. + save_layout_pipeline.stage( + baseline / f"generated-v{revision}", + save / f"generated-v{revision}", + revision, + baseline / f"v{revision}.toml", + save / f"v{revision}.toml", + ) + owned( + save / f"generated-v{revision}", + save / f"v{revision}.toml", + save / f"owned-v{revision}", + revision, + options_from(baseline / f"owned-v{revision}/manifest.json", revision), + ) + character_portrait_pipeline.guest( + save / f"generated-v{revision}", + out / f"generated-v{revision}", + revision, + save / f"v{revision}.toml", + out / f"v{revision}.toml", + ) + owned( + out / f"generated-v{revision}", + out / f"v{revision}.toml", + out / f"owned-v{revision}", + revision, + options_from(save / f"owned-v{revision}/manifest.json", revision), + ) + + # The kernels read the N64Recomp payload, not the staged one. + camera_scene_kernel.generate(raw, HEADER, elf, revision, out / f"camera-kernel-v{revision}") + generate_visual_model_kernel.generate(raw, HEADER, elf, revision, out / f"model-v{revision}") + + +def fresh_output(out: Path) -> None: + if (ROOT / "build").resolve() not in out.parents: + raise PrepareError(f"{out} must lie inside {ROOT / 'build'}") + if out.exists(): + if not (out / MARKER).is_file(): + raise PrepareError(f"{out} exists and was not made by prepare_payloads.py; remove it yourself") + shutil.rmtree(out) + out.mkdir(parents=True) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--output", type=Path, default=ROOT / "build/prepared", help="default: build/prepared") + parser.add_argument( + "--input", + nargs=4, + action="append", + metavar=("REV", "FUNCS", "CONFIG", "ELF"), + help="one revision's N64Recomp output, its config and decomp ELF (repeat per revision)", + ) + args = parser.parse_args(argv) + out = cast(Path, args.output).resolve() + inputs: dict[int, tuple[Path, Path, Path]] = {} + for rev, funcs, config, elf in cast("list[list[str]] | None", args.input) or []: + if not rev.isdigit() or int(rev) not in REVISIONS or int(rev) in inputs: + parser.error(f"--input REV must be 77 or 80, once each (got {rev})") + inputs[int(rev)] = (Path(funcs), Path(config), Path(elf)) + if not inputs: + inputs = {revision: default_inputs(revision) for revision in REVISIONS} + revisions = sorted(inputs) + try: + fresh_output(out) + for revision in revisions: + prepare_revision(out, revision, *inputs[revision]) + print(f"prepare_payloads: US v{revision} prepared in {out}") + except (PrepareError, ValueError) as error: + print(f"prepare_payloads: {error}", file=sys.stderr) + return 1 + (out / MARKER).write_text(json.dumps({"schema": 1, "revisions": revisions}) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/save_layout_pipeline.py b/scripts/save_layout_pipeline.py index b865fd80..a071bb91 100644 --- a/scripts/save_layout_pipeline.py +++ b/scripts/save_layout_pipeline.py @@ -2,8 +2,11 @@ Reuses the complete composed CPU pipeline (characters, tracks, code SDK, graphics fixes) rather than regenerating from an incomplete base policy. +A supported prepare step (scripts/prepare_payloads.py runs it). """ +from __future__ import annotations + import argparse import hashlib import json @@ -36,11 +39,14 @@ }, } ROOT = Path(__file__).resolve().parents[1] -HEADER = ROOT / "patches/save-layout/guest_save_layout.h" -sha = lambda text: hashlib.sha256(text.encode()).hexdigest() +HEADER = ROOT / "runtime-recomp/guest/save_layout_guest.h" + + +def sha(text: str) -> str: + return hashlib.sha256(text.encode()).hexdigest() -def owned_support(source): +def owned_support(source: Path) -> str: receipt = source / "save-layout-receipt.json" if not receipt.exists(): return "" @@ -51,7 +57,7 @@ def owned_support(source): return header -def stage(source, output, revision, config, output_config): +def stage(source: Path, output: Path, revision: int, config: Path, output_config: Path) -> None: source, output = source.resolve(), output.resolve() config, output_config = config.resolve(), output_config.resolve() if revision not in PINS or output.exists() or source == output or source in output.parents: @@ -67,9 +73,9 @@ def stage(source, output, revision, config, output_config): scheduler = (ROOT / "extern/n64-modern-runtime/ultramodern/include/ultramodern/ultramodern.hpp").read_text() if "struct thread_terminated : std::exception {};" not in scheduler or "void quit();" not in scheduler: raise ValueError("Unreviewed scheduler termination ABI; inspect before regenerating") - edits = {} - found = {} - hooks = [] + edits: dict[str, str] = {} + found: dict[str, dict[str, str]] = {} + hooks: list[str] = [] for file in sorted(source.glob("*.c")): text = file.read_text() matches = list(DEF.finditer(text)) From 638293435203a876d1791831b6b8f33884e535e4 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 26 Oct 2026 00:21:51 -0500 Subject: [PATCH 33/62] Gate prepare_payloads.py against the reference's prepared inputs run_equivalence.sh runs it from this tree and equivalence.py prepared compares every file with the paths the reference configure names. Compiled files compare byte for byte; fold/prepared.py names the only allowed kinds (kernel CRLF, model define order, policy machine paths, JSON provenance). The evidence record now needs five levels. --- .../tests/prepare_payloads_tests.py | 233 ++++++++++++++++++ scripts/fold/equivalence.py | 40 ++- scripts/fold/prepared.py | 179 ++++++++++++++ scripts/fold/run_equivalence.sh | 13 +- 4 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 runtime-recomp/tests/prepare_payloads_tests.py create mode 100644 scripts/fold/prepared.py diff --git a/runtime-recomp/tests/prepare_payloads_tests.py b/runtime-recomp/tests/prepare_payloads_tests.py new file mode 100644 index 00000000..3ccbfd42 --- /dev/null +++ b/runtime-recomp/tests/prepare_payloads_tests.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""scripts/prepare_payloads.py and the gate level that checks it +(scripts/fold/prepared.py): the allowed differences are exactly the named ones, +the portrait stage pins the folded native sources, and the owned engine's +runtime isolation leaves self-isolated probe names alone. The full chain needs +ROM-derived payloads; scripts/fold/run_equivalence.sh runs it.""" + +import contextlib +import io +import json +from pathlib import Path +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "scripts/fold")) +import prepared # noqa: E402 + +import camera_scene_kernel # noqa: E402 +import character_portrait_pipeline # noqa: E402 +import generate_replay_probe # noqa: E402 +import prepare_payloads # noqa: E402 + +TOML = 'elf_path = "{root}/a.elf"\nrom_file_path = "{root}/a.z64"\noutput_func_path = "{root}/funcs"\nentrypoint = 1\n' + + +def setUpModule(): + # Prepared output and the kernels must lie under the work tree's build/. + (ROOT / "build").mkdir(exist_ok=True) + + +def write(path: Path, data: str | bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(data, bytes): + path.write_bytes(data) + else: + path.write_bytes(data.encode()) + + +class PreparedComparisonTests(unittest.TestCase): + """A synthetic reference and prepared tree, then one change at a time.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + base = Path(self.temp.name) + self.ours, self.theirs = base / "prepared", base / "reference" + self.options = {} + for rev in (77, 80): + suffix = "" if rev == 77 else "_V80" + for side, root in ((self.ours, "/ours"), (self.theirs, "/theirs")): + prefix = "" if side is self.ours else "ref-" + write(side / f"{prefix}generated-v{rev}/funcs_0.c", "int f;\n") + write( + side / f"{prefix}generated-v{rev}/character-portrait-receipt.json", + json.dumps({"schema": 1, "native_sha256" if side is self.theirs else "native_files": "x"}), + ) + write(side / f"{prefix}v{rev}.toml", TOML.format(root=root)) + owned = {"revision": rev, "full_scenes": True, "a": {"file": root + "/funcs_0.c"}} + owned["input_native_inputs"] = {"src/x.cpp": root} + write(side / f"{prefix}owned-v{rev}/manifest.json", json.dumps(owned)) + write(side / f"{prefix}owned-v{rev}/owned_0.c", "int g;\n") + lines = "#define A 1\n#define B 2\n" if side is self.ours else "#define B 2\n#define A 1\n" + write(side / f"{prefix}model-v{rev}/visual_model_addresses.h", lines) + kernel = side / f"{prefix}camera-kernel-v{rev}" + body = b"int k;\n" if side is self.ours else b"int k;\r\n" + write(kernel / "camera_scene_functions.c", body) + self.kernel_manifest(kernel, recipe=root) + ref = self.theirs + self.options |= { + f"DKR_GENERATED_SOURCE_V{rev}": str(ref / f"ref-generated-v{rev}"), + f"DKR_CODE_POLICY_V{rev}": str(ref / f"ref-v{rev}.toml"), + f"DKR_EXPERIMENTAL_OWNED_GENERATED{suffix}": str(ref / f"ref-owned-v{rev}"), + f"DKR_VISUAL_MODEL_GENERATED_V{rev}": str(ref / f"ref-model-v{rev}"), + f"DKR_CAMERA_KERNEL_V{rev}": str(ref / f"ref-camera-kernel-v{rev}"), + } + + @staticmethod + def kernel_manifest(kernel: Path, recipe: str) -> None: + files = { + p.name: prepared.sha(p.read_bytes()) for p in kernel.iterdir() if p.is_file() and p.name != "manifest.json" + } + (kernel / "manifest.json").write_text(json.dumps({"schema": 1, "recipe": recipe, "files": files})) + + def compare(self): + return prepared.compare(self.options, self.ours) + + def test_only_named_differences_remain(self): + counts, unexplained, allowed = self.compare() + self.assertEqual(unexplained, []) + reasons = {line.split(": ", 1)[1] for line in allowed} + self.assertEqual(reasons, {"line endings", "line order", "machine paths", "provenance"}) + self.assertEqual(counts["identical"], 4) + + def test_compiled_bytes_must_match(self): + write(self.ours / "owned-v80/owned_0.c", "int h;\n") + self.assertEqual(self.compare()[1], ["owned-v80/owned_0.c: bytes"]) + + def test_line_endings_only_for_kernels(self): + write(self.ours / "generated-v77/funcs_0.c", "int f;\r\n") + self.assertEqual(self.compare()[1], ["generated-v77/funcs_0.c: bytes"]) + + def test_kernel_manifest_files_must_describe_the_directory(self): + write(self.ours / "camera-kernel-v77/camera_scene_functions.c", "int k2;\n") + unexplained = self.compare()[1] + self.assertIn("camera-kernel-v77/camera_scene_functions.c: bytes", unexplained) + self.assertTrue(any(line.startswith("camera-kernel-v77/manifest.json: ") for line in unexplained)) + with self.assertRaises(ValueError): + prepared.kernel_manifest(self.ours / "camera-kernel-v77") + + def test_policy_paths_only(self): + write(self.ours / "v77.toml", TOML.format(root="/ours").replace("entrypoint = 1", "entrypoint = 2")) + self.assertEqual(self.compare()[1], ["v77.toml: bytes"]) + + def test_manifest_fields_cmake_reads_compare_exactly(self): + path = self.ours / "owned-v77/manifest.json" + manifest = json.loads(path.read_text()) + manifest["full_scenes"] = False + path.write_text(json.dumps(manifest)) + self.assertEqual(self.compare()[1], ["owned-v77/manifest.json: fields .full_scenes"]) + + def test_native_audits_keep_their_file_set(self): + path = self.ours / "owned-v77/manifest.json" + manifest = json.loads(path.read_text()) + manifest["input_native_inputs"] = {"src/y.cpp": "/ours"} + path.write_text(json.dumps(manifest)) + self.assertEqual(len(self.compare()[1]), 1) + + def test_line_order_only_for_the_model_addresses(self): + write(self.ours / "model-v77/visual_model_addresses.h", "#define A 1\n#define B 3\n") + self.assertEqual(self.compare()[1], ["model-v77/visual_model_addresses.h: bytes"]) + + def test_missing_and_extra_files_fail(self): + (self.ours / "owned-v77/owned_0.c").unlink() + write(self.ours / "model-v80/extra.h", "") + unexplained = self.compare()[1] + self.assertIn("owned-v77/owned_0.c: missing from the prepared inputs", unexplained) + self.assertIn("model-v80/extra.h: missing from DKR_VISUAL_MODEL_GENERATED_V80", unexplained) + + def test_reference_must_name_every_input(self): + del self.options["DKR_CAMERA_KERNEL_V80"] + with self.assertRaises(ValueError): + self.compare() + + +class PreparePayloadsTests(unittest.TestCase): + def run_main(self, *argv: str) -> tuple[int, str]: + err = io.StringIO() + with contextlib.redirect_stderr(err): + try: + status = prepare_payloads.main(list(argv)) + except SystemExit as exit_: + status = int(exit_.code or 0) + return status, err.getvalue() + + def test_input_revisions_are_checked(self): + status, err = self.run_main("--input", "78", "a", "b", "c") + self.assertEqual(status, 2) + self.assertIn("77 or 80", err) + args = ("--input", "77", "a", "b", "c") + self.assertEqual(self.run_main(*args, *args)[0], 2) + + def test_output_outside_build_is_refused(self): + with tempfile.TemporaryDirectory() as temp: + status, err = self.run_main("--output", temp, "--input", "77", "a", "b", "c") + self.assertEqual(status, 1) + self.assertIn("must lie inside", err) + + def test_an_unmarked_output_is_never_removed(self): + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temp: + keep = Path(temp) / "keep.txt" + keep.write_text("mine") + status, err = self.run_main("--output", temp, "--input", "77", "a", "b", "c") + self.assertEqual(status, 1) + self.assertIn("was not made by prepare_payloads.py", err) + self.assertTrue(keep.exists()) + + def test_missing_inputs_name_the_prepare_script(self): + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temp: + out = Path(temp) / "prepared" + status, err = self.run_main("--output", str(out), "--input", "77", temp + "/no", "b", "c") + self.assertEqual(status, 1) + self.assertIn("Prepare-DKR-Runtime-macOS.sh", err) + + def test_options_round_trip_through_a_manifest(self): + with tempfile.TemporaryDirectory() as temp: + manifest = Path(temp) / "manifest.json" + manifest.write_text( + json.dumps({"revision": 77, "runtime_symbol_isolation": True, "full_scenes": True, "roots": ["a", "b"]}) + ) + options = prepare_payloads.options_from(manifest, 77) + self.assertEqual(options, ["--isolate-symbols", "--full-scenes", "--roots", "a", "b"]) + with self.assertRaises(prepare_payloads.PrepareError): + prepare_payloads.options_from(manifest, 80) + + +class StageTests(unittest.TestCase): + def test_portrait_stage_pins_the_folded_native_sources(self): + character_portrait_pipeline.verify_native_files() + + def test_portrait_receipt_must_name_the_folded_sources(self): + with tempfile.TemporaryDirectory() as temp: + receipt = { + "schema": 1, + "revision": 77, + "function": {"original_sha256": character_portrait_pipeline.GUEST_PINS[77], "patched_sha256": "x"}, + "world_source_sha256": character_portrait_pipeline.sha( + character_portrait_pipeline.WORLD_PORTRAIT.read_bytes() + ), + "native_sha256": "the old native stage", + } + (Path(temp) / "character-portrait-receipt.json").write_text(json.dumps(receipt)) + with self.assertRaisesRegex(ValueError, "native sources disagree"): + character_portrait_pipeline.owned_receipt(Path(temp), 77) + + def test_kernel_output_stays_in_the_build_directory(self): + with tempfile.TemporaryDirectory() as temp: + with self.assertRaises(ValueError): + camera_scene_kernel.new_output(Path(temp) / "kernel") + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temp: + with self.assertRaises(ValueError): + camera_scene_kernel.new_output(Path(temp), Path(temp) / "input") + + def test_self_isolated_probe_names_are_not_renamed_twice(self): + header = generate_replay_probe.runtime_isolation_header((), (), 77) + self.assertNotIn("define dkr_probe_sdk2_pulse ", header) + self.assertIn("make_owned_game", header) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/fold/equivalence.py b/scripts/fold/equivalence.py index c1f86698..5911505c 100644 --- a/scripts/fold/equivalence.py +++ b/scripts/fold/equivalence.py @@ -25,6 +25,10 @@ flags of every shipped first-party TU level3 objects of the TUs level 2 lets differ for a header variant, compiled from path-normalized preprocessed output + prepared the inputs scripts/prepare_payloads.py prepares from the + N64Recomp payload against the ones the reference was + configured with (scripts/fold/prepared.py lists the allowed + differences) Differences that are expected are listed with their reason and do not fail: the TUs scripts/fold/header-variants.json names (which then need level 3), @@ -59,6 +63,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import apply_closure # noqa: E402 import dependency_stages # noqa: E402 +import prepared # noqa: E402 ROOT = apply_closure.ROOT ALLOWED = ROOT / "scripts/fold/allowed-deltas.json" @@ -1018,6 +1023,33 @@ def run_level3(args: argparse.Namespace) -> int: CHAIN_ONLY = re.compile(r"^DKR_(MATERIALIZE_\w+|RELEASE_EVIDENCE|RELEASE_CAPSULE|\w+_STAGE)$") +def reference_options(reference: Path) -> dict[str, str]: + """Every -D option of the reference configure, name to value.""" + script = (reference / "configure-linux.sh").read_text(encoding="utf-8") + command = next(line for line in script.splitlines() if line.startswith("cmake ")) + options: dict[str, str] = {} + for arg in shlex.split(command): + if arg.startswith("-D"): + name, _, value = arg[2:].partition("=") + options[name.split(":")[0]] = value + return options + + +def run_prepared(args: argparse.Namespace) -> int: + counts, unexplained, allowed = prepared.compare(reference_options(args.reference), args.prepared) + for line in unexplained: + print(f"prepared: UNEXPLAINED {line}", file=sys.stderr) + if args.verbose: + for line in allowed: + print(f"prepared: allowed {line}", file=sys.stderr) + print( + f"prepared: {counts['identical']} identical, {counts['allowed']} allowed, {counts['unexplained']} unexplained", + file=sys.stderr, + ) + write_record(args, "prepared", identical=counts["identical"], allowed=counts["allowed"], unexplained=unexplained) + return 1 if unexplained else 0 + + def folded_options(reference: Path) -> tuple[list[str], set[Path]]: """The reference's -D options for a folded configure, and the paths outside the work tree they name (to mount into the build image).""" @@ -1133,7 +1165,7 @@ def run_evidence(args: argparse.Namespace) -> int: "reference": {name: sha for name, sha in DOC_ROW.findall(REFERENCE_DOC.read_text(encoding="utf-8"))}, "inputs": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in EVIDENCE_INPUTS}, "levels": {level.pop("level"): level for level in levels}, - "passed": all(not level["unexplained"] for level in levels) and len(levels) == 4, + "passed": all(not level["unexplained"] for level in levels) and len(levels) == 5, } atomic_write(args.out, json.dumps(evidence, indent=1) + "\n") print(f"evidence: {args.out} ({'passed' if evidence['passed'] else 'FAILED'})", file=sys.stderr) @@ -1216,6 +1248,11 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: options.add_argument("--reference", type=Path, required=True) options.add_argument("--mounts", action="store_true", help="print the paths outside the tree they name instead") options.add_argument("--reference-build", action="store_true", help="print the reference build directory instead") + prepared_inputs = sub.add_parser("prepared", help="compare prepare_payloads.py output with the reference inputs") + prepared_inputs.add_argument("--reference", type=Path, required=True) + prepared_inputs.add_argument("--prepared", type=Path, required=True, help="what prepare_payloads.py wrote") + prepared_inputs.add_argument("--verbose", action="store_true", help="also list allowed differences") + prepared_inputs.add_argument("--record", type=Path, help="write the result for the evidence record") level3 = compared("level3", "object equality for the header-variant TUs (in the build image)") level3.add_argument("--folded-compdb", type=Path, required=True) level3.add_argument("--reference-pp", type=Path, required=True) @@ -1246,6 +1283,7 @@ def compared(name: str, help_text: str) -> argparse.ArgumentParser: "level1": run_level1, "level2": run_level2, "level3": run_level3, + "prepared": run_prepared, "shipped": write_shipped, "options": run_options, "depscan": run_depscan, diff --git a/scripts/fold/prepared.py b/scripts/fold/prepared.py new file mode 100644 index 00000000..a4ee2f43 --- /dev/null +++ b/scripts/fold/prepared.py @@ -0,0 +1,179 @@ +"""The prepared-inputs level of the fold gate (equivalence.py prepared). + +scripts/prepare_payloads.py, run from the folded tree with no capsule and no +release chain, must reproduce the ROM-derived inputs the v1.0.11 reference +was configured with (the paths its configure-linux.sh names). Compiled files +compare byte for byte. The exceptions are named here, one per kind: + +* line endings of the Camera++ kernel files: the reference took them from the + release capsule, which stored them CRLF; +* the order of visual_model_addresses.h's lines: the shipped generator + iterated a set (it sorts now); +* the three machine paths in the code policy (elf_path, rom_file_path, + output_func_path), and the "file" paths in the owned manifest; +* provenance in the JSON records that names a pre-fold source rather than + the folded tree: the portrait receipt's native stage digest, the owned + manifest's digests of the native sources it audits (same files, folded + bytes), and the kernel manifest's recipe and file digests (recomputed + instead: each must match its own directory). + +Everything CMake reads from a manifest (revision, full_scenes, the +observation schemas) is outside these exceptions and compares exactly. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import hashlib +import json +from pathlib import Path +import re +from typing import cast + +# Reference configure option -> prepared path, per revision. +INPUTS = { + "DKR_GENERATED_SOURCE_V{rev}": "generated-v{rev}", + "DKR_CODE_POLICY_V{rev}": "v{rev}.toml", + "DKR_EXPERIMENTAL_OWNED_GENERATED{suffix}": "owned-v{rev}", + "DKR_VISUAL_MODEL_GENERATED_V{rev}": "model-v{rev}", + "DKR_CAMERA_KERNEL_V{rev}": "camera-kernel-v{rev}", +} +POLICY_PATHS = re.compile(r'^(elf_path|rom_file_path|output_func_path) = ".*"$', re.M) +# Owned-manifest keys whose values are digests of first-party native sources. +NATIVE_AUDITS = ("full_scene_native_inputs", "input_native_inputs") +Json = dict[str, object] + + +def expected_pairs(options: dict[str, str]) -> Iterator[tuple[str, Path, str]]: + """(option, reference path, prepared name) for every prepared input.""" + for rev in (77, 80): + for option, name in INPUTS.items(): + key = option.format(rev=rev, suffix="" if rev == 77 else "_V80") + if key not in options: + raise ValueError(f"the reference configure names no {key}") + yield key, Path(options[key]), name.format(rev=rev) + + +def sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def load(path: Path) -> Json: + return cast(Json, json.loads(path.read_text(encoding="utf-8"))) + + +def json_diff(ours: object, theirs: object, path: str = "") -> Iterator[str]: + if isinstance(ours, dict) and isinstance(theirs, dict): + a, b = cast(Json, ours), cast(Json, theirs) + for key in sorted(set(a) | set(b)): + yield from json_diff(a.get(key), b.get(key), f"{path}.{key}") + elif isinstance(ours, list) and isinstance(theirs, list): + a_list, b_list = cast(list[object], ours), cast(list[object], theirs) + if len(a_list) != len(b_list): + yield path or "." + return + for i, (x, y) in enumerate(zip(a_list, b_list)): + yield from json_diff(x, y, f"{path}[{i}]") + elif ours != theirs: + yield path or "." + + +def masked_owned(manifest: Json) -> Json: + """The owned manifest without its machine paths and native digests (the + keys of the native audits stay, so the audited file set still compares).""" + + def walk(value: object) -> object: + if isinstance(value, dict): + return {k: walk(v) for k, v in cast(Json, value).items() if k != "file"} + if isinstance(value, list): + return [walk(v) for v in cast(list[object], value)] + return value + + masked = cast(Json, walk(manifest)) + for key in NATIVE_AUDITS: + if key in masked: + masked[key] = sorted(cast(Json, masked[key])) + portrait = masked.get("character_portrait_pipeline") + if isinstance(portrait, dict): + masked["character_portrait_pipeline"] = { + k: v for k, v in cast(Json, portrait).items() if k not in ("native_sha256", "native_files") + } + return masked + + +def masked_receipt(receipt: Json) -> Json: + return {k: v for k, v in receipt.items() if k not in ("native_sha256", "native_files")} + + +def kernel_manifest(directory: Path) -> Json: + """A kernel manifest without recipe and files, once files is checked + against the directory it describes.""" + manifest = load(directory / "manifest.json") + files = {p.name: sha(p.read_bytes()) for p in directory.iterdir() if p.is_file() and p.name != "manifest.json"} + if manifest.get("files") != files: + raise ValueError(f"{directory}/manifest.json: files do not describe the directory") + return {k: v for k, v in manifest.items() if k not in ("recipe", "files")} + + +def compare_file(ours: Path, theirs: Path, label: str) -> str | None: + """None when equal, else the reason. Names the allowed kind in the + reason it returns when only an allowed difference remains.""" + a, b = ours.read_bytes(), theirs.read_bytes() + if a == b: + return None + name = ours.name + if label.startswith("camera-kernel") and name != "manifest.json": + return "line endings" if a.replace(b"\r\n", b"\n") == b.replace(b"\r\n", b"\n") else "bytes" + if name == "visual_model_addresses.h": + return "line order" if sorted(a.splitlines()) == sorted(b.splitlines()) else "bytes" + if name.endswith(".toml"): + same = POLICY_PATHS.sub(r"\1", a.decode()) == POLICY_PATHS.sub(r"\1", b.decode()) + return "machine paths" if same else "bytes" + if name == "character-portrait-receipt.json": + same = masked_receipt(load(ours)) == masked_receipt(load(theirs)) + return "provenance" if same else "fields " + ", ".join(json_diff(load(ours), load(theirs))) + if name == "manifest.json" and label.startswith("owned"): + a_json, b_json = masked_owned(load(ours)), masked_owned(load(theirs)) + return "provenance" if a_json == b_json else "fields " + ", ".join(list(json_diff(a_json, b_json))[:8]) + if name == "manifest.json" and label.startswith("camera-kernel"): + try: + a_json, b_json = kernel_manifest(ours.parent), kernel_manifest(theirs.parent) + except ValueError as error: + return str(error) + return "provenance" if a_json == b_json else "fields " + ", ".join(json_diff(a_json, b_json)) + return "bytes" + + +ALLOWED = {"line endings", "line order", "machine paths", "provenance"} + + +def compare(options: dict[str, str], prepared: Path) -> tuple[dict[str, int], list[str], list[str]]: + """(counts, unexplained, allowed) for the prepared inputs against the + reference's.""" + counts = {"identical": 0, "allowed": 0, "unexplained": 0} + unexplained: list[str] = [] + allowed: list[str] = [] + for option, theirs, name in expected_pairs(options): + ours = prepared / name + if theirs.is_file(): + pairs = [(ours, theirs, name)] + else: + names = {p.relative_to(theirs).as_posix() for p in theirs.rglob("*") if p.is_file()} + names |= ( + {p.relative_to(ours).as_posix() for p in ours.rglob("*") if p.is_file()} if ours.is_dir() else set() + ) + pairs = [(ours / n, theirs / n, f"{name}/{n}") for n in sorted(names)] + for a, b, label in pairs: + if not a.is_file() or not b.is_file(): + reason = "missing from " + ("the prepared inputs" if not a.is_file() else f"{option}") + else: + reason = compare_file(a, b, label) + if reason is None: + counts["identical"] += 1 + elif reason in ALLOWED: + counts["allowed"] += 1 + allowed.append(f"{label}: {reason}") + else: + counts["unexplained"] += 1 + unexplained.append(f"{label}: {reason}") + return counts, unexplained, allowed diff --git a/scripts/fold/run_equivalence.sh b/scripts/fold/run_equivalence.sh index 9156c8b0..b8107262 100755 --- a/scripts/fold/run_equivalence.sh +++ b/scripts/fold/run_equivalence.sh @@ -15,7 +15,10 @@ # pins (it is mounted read-only; its scan and preprocess caches go to # BUILD_DIR/reference-cache), configure this tree with the reference's options # minus the release chain's, check the dependency stage patches reproduce the -# shipped third-party files (dependency_stages.py), then compare per-source +# shipped third-party files (dependency_stages.py), check +# scripts/prepare_payloads.py reproduces the ROM-derived inputs the reference +# was configured with (prepared; it reads this tree's build/generated and +# build/decomp, so BUILD_DIR must lie under build/), then compare per-source # compile settings and link lines (targets), TU sets, header closures and file # bytes (level 1), preprocessed tokens plus compile flags (level 2), and # objects for the TUs level 2 lets differ because of a header variant @@ -74,6 +77,14 @@ status=0 rm -f "$out"/record-*.json "$out/fold-equivalence.json" echo "== closure (ROM-free level 1 subset, as CI runs it)" python3 "$tool" closure --check || status=1 +echo "== prepared inputs (scripts/prepare_payloads.py from this tree)" +if python3 -B "$root/scripts/prepare_payloads.py" --output "$build/prepared" >"$out/prepare.log" 2>&1; then + python3 "$tool" prepared --reference "$reference" --prepared "$build/prepared" \ + --record "$out/record-prepared.json" || status=1 +else + tail -20 "$out/prepare.log" + status=1 +fi echo "== dependency stages" python3 "$root/scripts/fold/dependency_stages.py" --reference "$reference" --build "$build" --check || status=1 echo "== targets" From d587e07186714c4a1f94dd13a8ac12b835af6147 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 26 Oct 2026 00:23:40 -0500 Subject: [PATCH 34/62] Go back to AtomicSnapshot at the five snapshot sites AtomicSnapshot is main's abstraction for this and is std::atomic itself on libstdc++ and MSVC, so the five Linux TUs keep the shipped type and move from token_deltas to objects_identical. shared_snapshot.hpp is gone; open-items records the libc++ branch's C++26 removal for Phase C. --- docs/fold-in/open-items.md | 20 +++++++---- runtime-recomp/src/game/achievements.cpp | 4 +-- runtime-recomp/src/game/mod_store.cpp | 4 +-- runtime-recomp/src/game/mod_store_install.cpp | 4 +-- runtime-recomp/src/game/online_directory.cpp | 4 +-- runtime-recomp/src/game/shared_snapshot.hpp | 34 ------------------- runtime-recomp/src/game/vehicle_binding.cpp | 4 +-- scripts/fold/portability.json | 33 +++++++++--------- 8 files changed, 39 insertions(+), 68 deletions(-) delete mode 100644 runtime-recomp/src/game/shared_snapshot.hpp diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index 6458d8ec..ecc66034 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -79,13 +79,11 @@ Known before they run: no Linux TU takes (Apple, Windows, Android) by path alone. No configure has compiled them, so B6 and the Windows configure are their first check. -- `netplay/online_directory.cpp` (online-directory layer) holds a - `std::atomic>`. libstdc++ supports that C++20 - specialization; Apple's libc++ does not (`std::atomic` requires a - trivially copyable `T`), so the macOS host build stops there. v1.0.11 was - Linux and Windows only, so no shipped build hit it. It needs an - `__APPLE__`-guarded alternative (a mutex-guarded `shared_ptr`, or - `std::atomic_load`/`std::atomic_store` on a plain one). +- Five TUs held a `std::atomic>`, which libc++ + (Apple, Android) doesn't implement. They use main's + `dkr::runtime::AtomicSnapshot` (`atomic_snapshot.hpp`): on libstdc++ and + MSVC it's an alias for that same type, so their Linux objects must be + identical (`portability.json` `objects_identical`, level 3). - The Mod Store links CURL only off Windows, Android and Apple, as the release did; plan B2 lists CURL on Apple for B6. @@ -134,6 +132,14 @@ Known before they run: `extern/dkr-decomp/ver/symbols`. That's the sparse "symbols" dependency profile the plan describes for the `linux` job. +## For Phase C + +- `atomic_snapshot.hpp`'s libc++ branch uses the free `std::atomic_load`/ + `std::atomic_store` on `shared_ptr`, deprecated in C++20 and removed in + C++26. Replace it with a mutex-guarded holder (a `shared_ptr` under a + `std::mutex`, releasing the previous value outside the lock), and drop the + alias once every target toolchain has libc++'s `atomic`. + ## Online identity (B7) - The owned rollback contracts are frozen in diff --git a/runtime-recomp/src/game/achievements.cpp b/runtime-recomp/src/game/achievements.cpp index 05c25da6..7f4c768b 100644 --- a/runtime-recomp/src/game/achievements.cpp +++ b/runtime-recomp/src/game/achievements.cpp @@ -1,5 +1,5 @@ #include "achievements.hpp" -#include "shared_snapshot.hpp" +#include "atomic_snapshot.hpp" #include "achievement_symbols.hpp" #include #include @@ -101,7 +101,7 @@ struct Event {Sample sample;bool reset=false,is_online=false,modded=false;unsign class Service { std::mutex mutex_;std::condition_variable cv_;bool stopping_=false; std::deque> jobs_;std::deque toasts_; - dkr::runtime::SharedSnapshot published_{std::make_shared()}; + dkr::runtime::AtomicSnapshot published_{std::make_shared()}; std::thread worker_;std::filesystem::path path_;View state_;Sample previous_,latest_; bool baseline_=false;Racer adventure_human_;std::array driver_{};std::array,10> earned_balloon_totals_; std::uint64_t last_epoch_=~0ULL;std::uint32_t last_frame_=~0U;Json retained_=Json::object(); diff --git a/runtime-recomp/src/game/mod_store.cpp b/runtime-recomp/src/game/mod_store.cpp index e36e5673..348c18ca 100644 --- a/runtime-recomp/src/game/mod_store.cpp +++ b/runtime-recomp/src/game/mod_store.cpp @@ -1,5 +1,5 @@ #include "online_directory.hpp" -#include "shared_snapshot.hpp" +#include "atomic_snapshot.hpp" #include "linux_keyring.hpp" #include "store_vehicle_adapter.hpp" #include "mod_store.hpp" @@ -624,7 +624,7 @@ struct Service::Impl { std::mutex download_persist_mutex; std::condition_variable_any cv; Snapshot state; - dkr::runtime::SharedSnapshot published{std::make_shared()}; + dkr::runtime::AtomicSnapshot published{std::make_shared()}; std::filesystem::path root; std::string version, token, device, browser; unsigned account = 0; diff --git a/runtime-recomp/src/game/mod_store_install.cpp b/runtime-recomp/src/game/mod_store_install.cpp index 0981b306..7af09f46 100644 --- a/runtime-recomp/src/game/mod_store_install.cpp +++ b/runtime-recomp/src/game/mod_store_install.cpp @@ -1,5 +1,5 @@ #include "store_vehicle_adapter.hpp" -#include "shared_snapshot.hpp" +#include "atomic_snapshot.hpp" #include "mod_store_install.hpp" #include "custom_tracks.hpp" #include "mods/legacy_mod_library.hpp" @@ -483,7 +483,7 @@ bool recover_cached_package(const fs::path& folder, const fs::path& target, cons struct InstallManager::Impl { mutable std::mutex mutex; std::condition_variable_any cv; - dkr::runtime::SharedSnapshot published{std::make_shared()}; + dkr::runtime::AtomicSnapshot published{std::make_shared()}; InstallView state; fs::path config, root, worker_path; std::string version; diff --git a/runtime-recomp/src/game/online_directory.cpp b/runtime-recomp/src/game/online_directory.cpp index e2a271e8..f6275488 100644 --- a/runtime-recomp/src/game/online_directory.cpp +++ b/runtime-recomp/src/game/online_directory.cpp @@ -1,5 +1,5 @@ #include "online_directory.hpp" -#include "shared_snapshot.hpp" +#include "atomic_snapshot.hpp" #include "directory_tls.hpp" #include "monocypher.h" #include @@ -21,7 +21,7 @@ struct State { std::condition_variable_any cv; AccountView account; AccountRequest request; View ui; - dkr::runtime::SharedSnapshot published{std::make_shared()}; + dkr::runtime::AtomicSnapshot published{std::make_shared()}; std::map> bridges; struct JoinKey {secure::KeyPair pair;secure::Key admission;}; std::map keys; diff --git a/runtime-recomp/src/game/shared_snapshot.hpp b/runtime-recomp/src/game/shared_snapshot.hpp deleted file mode 100644 index e8f4932b..00000000 --- a/runtime-recomp/src/game/shared_snapshot.hpp +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace dkr::runtime { -// A shared_ptr one thread publishes and others read. A mutex guards the -// pointer itself, so every standard library behaves the same: libc++ (Apple, -// Android) has no std::atomic. The memory_order arguments -// mirror that interface; the lock already orders the accesses at least as -// strongly. A replaced value is released outside the lock. -template class SharedSnapshot { - mutable std::mutex mutex_; - std::shared_ptr value_; -public: - SharedSnapshot() = default; - explicit SharedSnapshot(std::shared_ptr value) : value_(std::move(value)) {} - SharedSnapshot(const SharedSnapshot&) = delete; - SharedSnapshot& operator=(const SharedSnapshot&) = delete; - std::shared_ptr load(std::memory_order = std::memory_order_seq_cst) const { - std::scoped_lock lock(mutex_); - return value_; - } - void store(std::shared_ptr value, std::memory_order = std::memory_order_seq_cst) { - std::shared_ptr previous; - { - std::scoped_lock lock(mutex_); - previous = std::exchange(value_, std::move(value)); - } - } -}; -} diff --git a/runtime-recomp/src/game/vehicle_binding.cpp b/runtime-recomp/src/game/vehicle_binding.cpp index 32b7160b..e7b2c72a 100644 --- a/runtime-recomp/src/game/vehicle_binding.cpp +++ b/runtime-recomp/src/game/vehicle_binding.cpp @@ -1,5 +1,5 @@ #include "vehicle_binding.hpp" -#include "shared_snapshot.hpp" +#include "atomic_snapshot.hpp" #include #include @@ -8,7 +8,7 @@ namespace { struct Binding { std::weak_ptr owner;std::shared_ptr resources; }; std::mutex mutex; std::map bindings; -dkr::runtime::SharedSnapshot renderer; +dkr::runtime::AtomicSnapshot renderer; } void bind_resources(std::shared_ptr owner,std::shared_ptr resources) { if(!owner||!resources||resources->library.packages.empty())return; diff --git a/scripts/fold/portability.json b/scripts/fold/portability.json index 704b3fe2..da1e63e9 100644 --- a/scripts/fold/portability.json +++ b/scripts/fold/portability.json @@ -2,47 +2,46 @@ "about": "Portability fixes for platforms v1.0.11 never compiled (fold step B6). files: each pinned to its reviewed bytes (whitespace-insensitive sha256, equivalence.py closure_digest); level 1 and the closure check compare against that instead of the shipped bytes. objects_identical: Linux TUs whose tokens change but whose objects level 3 must find identical. token_deltas: the named TUs whose portable replacement changes Linux code on purpose, allowed at level 2.", "files": { "runtime-recomp/src/game/achievements.cpp": { - "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform.", - "sha256": "ad83afc0e4a8d4e77f9ceb29dda472330a2cae36c194c1bd4ba64d0bc945657e" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), which is that type wherever the standard library has it (libstdc++, MSVC) and falls back to the shared_ptr atomic free functions on libc++.", + "sha256": "f17da14cff56cee42d8d3d6a2e9b6e3fc37c9371747e3cd005238c3c5dab124f" }, "runtime-recomp/src/game/f3ddkr_rt64.cpp": { "reason": "Metal builds have no enhanced-water renderer; the water_residency_rt64.hpp include is skipped under __APPLE__ (token-identical elsewhere).", "sha256": "6a669be2a612362d7f67a9e3df3faa1b48166637ca1d0179d5414f4d7e9690d4" }, "runtime-recomp/src/game/mod_store.cpp": { - "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform. Android links no HTTPS backend, so its curl include and backend are skipped there (the store reports itself unavailable).", - "sha256": "0cb2aaa38265ad82b9da45ca448b1b12e6fe99b316c942acf73aed653f0aa7e0" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), which is that type wherever the standard library has it (libstdc++, MSVC) and falls back to the shared_ptr atomic free functions on libc++. Android links no HTTPS backend, so its curl include and backend are skipped there (the store reports itself unavailable).", + "sha256": "17eeb41f6343d41a008774585a3f1fccd196c79b9063a0853aac8062d0ef622e" }, "runtime-recomp/src/game/mod_store_install.cpp": { - "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform. libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", - "sha256": "921cb38792d1182766e4d0c22408490fbf829429c6db780ec95b319cb6210ecd" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), which is that type wherever the standard library has it (libstdc++, MSVC) and falls back to the shared_ptr atomic free functions on libc++. libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", + "sha256": "2a26f704b2e2b8699c1cb1ab98f5491aba6454d8660c13e91e052f56fc697f30" }, "runtime-recomp/src/game/mod_store_names.cpp": { "reason": "libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", "sha256": "b32f11a1484b0a649d7cc5962e926fdec663f126e7c9363bcc967ca687b7eafe" }, "runtime-recomp/src/game/online_directory.cpp": { - "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform.", - "sha256": "bae7c9b705aa9298c3d70676cfab7dd88b34d595770d43b6fcc88d042f470b85" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), which is that type wherever the standard library has it (libstdc++, MSVC) and falls back to the shared_ptr atomic free functions on libc++.", + "sha256": "74547dc4930c827598f1a6fd62cbd64c3575d455df952581393acec3a04d1d71" }, "runtime-recomp/src/game/texture_gallery.inl": { "reason": "libc++ file_clock counts are __int128, which std::to_string has no overload for; the count goes through std::int64_t, the overload libstdc++ already picked.", "sha256": "a4b3f753ac48d5299094fd48cdc31a2a65afa645e50ccc5f548007b77ebc01c7" }, "runtime-recomp/src/game/vehicle_binding.cpp": { - "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::SharedSnapshot (shared_snapshot.hpp), one mutex-guarded holder on every platform.", - "sha256": "744f24276aaab75d62e368f7730bf5634cc25c734e5b58aece04b00ecd37a7e2" + "reason": "libc++ (Apple, Android) has no std::atomic; the published snapshot is a dkr::runtime::AtomicSnapshot (atomic_snapshot.hpp), which is that type wherever the standard library has it (libstdc++, MSVC) and falls back to the shared_ptr atomic free functions on libc++.", + "sha256": "823af3eab2ff306d0c112e22bceb39dac0885b4249165aa4935319215934e574" } }, "objects_identical": [ + "DKRCustomVehicles: runtime-recomp/src/game/vehicle_binding.cpp", + "DKRNetplayCore: runtime-recomp/src/game/online_directory.cpp", + "DKRPortGame: runtime-recomp/src/game/achievements.cpp", + "DKRPortGame: runtime-recomp/src/game/mod_store.cpp", + "DKRPortGame: runtime-recomp/src/game/mod_store_install.cpp", "DKRPortGame: runtime-recomp/src/game/mod_store_names.cpp", "DKRPortGame: runtime-recomp/src/game/texture_dump.cpp" ], - "token_deltas": { - "DKRCustomVehicles: runtime-recomp/src/game/vehicle_binding.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", - "DKRNetplayCore: runtime-recomp/src/game/online_directory.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", - "DKRPortGame: runtime-recomp/src/game/achievements.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", - "DKRPortGame: runtime-recomp/src/game/mod_store.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform", - "DKRPortGame: runtime-recomp/src/game/mod_store_install.cpp": "portable replacement for libc++ gap: std::atomic becomes the mutex-guarded dkr::runtime::SharedSnapshot on every platform (plus the file-clock casts)" - } + "token_deltas": {} } From 337696b86677bcd6edc02bfbb2806b34171d2dae Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 26 Oct 2026 06:52:28 -0500 Subject: [PATCH 35/62] Name the two owned-engine input stages as the guest-code exception AGENTS.md and CONTRIBUTING.md reserve guest-code writes for hooks.toml except save_layout_pipeline.py and character_portrait_pipeline.py, which feed the owned payload byte for byte. The guard names exactly those two (OWNED_INPUT_STAGES), and a test keeps the set from growing. --- AGENTS.md | 5 ++++- CONTRIBUTING.md | 4 +++- architecture/pattern-guard-baseline.txt | 1 - runtime-recomp/tests/pattern_guard_tests.py | 13 +++++++++++++ scripts/pattern_guard.py | 10 +++++++++- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad325d74..2ab52808 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,10 @@ strict version. - Never stage, copy, string-replace or swap sources at configure or build time. If you find yourself writing `pipeline.py`, `transforms.py`, `once()`, `region()` or `set_target_properties(... SOURCES ...)`, stop. - Generated guest code is hooked only through `runtime-recomp/guest-hooks/hooks.toml` (applied by - `scripts/guest_hooks.py`). Never rewrite `RecompiledFuncs`, `funcs_*.c` or `guest_*.c` from a script. + `scripts/guest_hooks.py`), except the two named owned-engine input stages, `scripts/save_layout_pipeline.py` + (save-layout) and `scripts/character_portrait_pipeline.py` (portrait), which feed the owned payload byte for byte + (`scripts/prepare_payloads.py` runs them). Never rewrite `RecompiledFuncs`, `funcs_*.c` or `guest_*.c` from any + other script. - Symbol tables come from `runtime-recomp/symbols/*.toml` (generated by `scripts/gen_guest_symbols.py`). Don't hard-code addresses. - Online or simulation changes that break cross-play need a deliberate edit to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fc7413e..c263d602 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,9 @@ First-party code lives in `runtime-recomp/`, `modding/` and `scripts/` and is edited in place. Third-party code is changed only through `patches/manifest.json` patch sets against `extern/`. Nothing stages, copies, string-replaces or swaps first-party sources at configure time. Guest hooks go in -`guest-hooks/hooks.toml`, symbol tables in `symbols/*.toml`, and online +`guest-hooks/hooks.toml` (the save-layout and portrait stages that feed the +owned payload are the only scripts that write guest code), symbol tables in +`symbols/*.toml`, and online identity lives in `netplay/legacy_identity.hpp` and changes only by deliberate edit. diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index 0b30e0a1..677ddbfa 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -1213,7 +1213,6 @@ patches/viewport-flow/pipeline.py PG004 runtime-recomp/src/game/netplay/experimental_runtime.cpp PG010 scripts/adapt-water-reference.py PG004 scripts/camera_plus_pipeline.py PG004 -scripts/character_portrait_pipeline.py PG004 scripts/compose-lava-glow.py PG004 scripts/compose-lava-lighting.py PG004 scripts/compose-lava.py PG004 diff --git a/runtime-recomp/tests/pattern_guard_tests.py b/runtime-recomp/tests/pattern_guard_tests.py index 83f9cfb6..0de36959 100644 --- a/runtime-recomp/tests/pattern_guard_tests.py +++ b/runtime-recomp/tests/pattern_guard_tests.py @@ -69,6 +69,19 @@ def test_every_rule_explains_itself(self): self.assertEqual(run_guard(ROOT, "--explain", "PG999")[0], 2) +class AllowlistTests(unittest.TestCase): + def test_only_the_two_named_stages_may_write_guest_code(self): + # AGENTS.md names exactly these; anything else goes in hooks.toml. + self.assertEqual( + set(pattern_guard.OWNED_INPUT_STAGES), + {"scripts/save_layout_pipeline.py", "scripts/character_portrait_pipeline.py"}, + ) + self.assertEqual( + pattern_guard.GUEST_REWRITE_ALLOWED - set(pattern_guard.OWNED_INPUT_STAGES), + {"scripts/guest_hooks.py", "runtime-recomp/cmake/GuestHooks.cmake"}, + ) + + class CheckContentTests(unittest.TestCase): def test_a_patch_registered_before_it_is_written_is_still_checked(self): # Registering first must not hide PG008: PG007 hashes the planned diff --git a/scripts/pattern_guard.py b/scripts/pattern_guard.py index 3756b35d..6c442736 100644 --- a/scripts/pattern_guard.py +++ b/scripts/pattern_guard.py @@ -179,7 +179,15 @@ class Rule: "runtime-recomp/cmake/StagedPatch.cmake", # dependency-patch staging "runtime-recomp/cmake/GuestHooks.cmake", } +# The two owned-engine input stages (AGENTS.md): they write new copies of +# recompiled guest code that the owned payload is generated from byte for +# byte, so they can't be hooks.toml entries yet. No other script joins them. +OWNED_INPUT_STAGES = { + "scripts/save_layout_pipeline.py": "save-layout stage of the owned-engine inputs", + "scripts/character_portrait_pipeline.py": "portrait stage of the owned-engine inputs", +} PIPELINE_ALLOWED = { + **OWNED_INPUT_STAGES, "scripts/gen_guest_symbols.py": "writes new symbol headers", "scripts/guest_hooks.py": "applies declared hooks to generated guest code", "scripts/generate_replay_probe.py": "reads first-party headers only to collect probe symbol names", @@ -194,7 +202,7 @@ class Rule: # reports and scratch objects, never a source file. "scripts/fold/equivalence.py": "reads the folded tree to compare it, writes only reports", } -GUEST_REWRITE_ALLOWED = {"scripts/guest_hooks.py", "runtime-recomp/cmake/GuestHooks.cmake"} +GUEST_REWRITE_ALLOWED = {"scripts/guest_hooks.py", "runtime-recomp/cmake/GuestHooks.cmake", *OWNED_INPUT_STAGES} IDENTITY_ALLOWED_SUFFIX = "netplay/legacy_identity.hpp" CMAKE_SUFFIXES = (".cmake",) From a0628fb0e1e57090ddc74a0ebeab3c7a8f96f49d Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 26 Oct 2026 07:00:54 -0500 Subject: [PATCH 36/62] Give Windows the same prepare path as macOS compose_payloads.py checks a ROM and composes and recompiles one revision's production payload; both prepare scripts use it, then run prepare_payloads.py. Prepare-DKR-Runtime.ps1 now builds both revisions (one WSL decomp tree each) and Build-DKR-R-Windows.ps1 defaults to build\prepared. The hand-built v77 config and the diagnose script that reran it are gone. The PS1 parses under pwsh 7.4 but hasn't run on Windows; the macOS payloads are byte-identical. --- Diagnose-DKR-Recompile.cmd | 17 - docs/BUILDING.md | 87 ++-- docs/fold-in/open-items.md | 7 + pyproject.toml | 2 + runtime-recomp/cmake/PreparedInputs.cmake | 14 +- .../tests/prepare_payloads_tests.py | 40 ++ scripts/Build-DKR-R-Windows.ps1 | 11 +- scripts/Diagnose-DKR-Recompile.ps1 | 249 --------- scripts/Prepare-DKR-Runtime-macOS.sh | 49 +- scripts/Prepare-DKR-Runtime.ps1 | 481 +++++------------- scripts/compose_payloads.py | 158 ++++++ 11 files changed, 386 insertions(+), 729 deletions(-) delete mode 100644 Diagnose-DKR-Recompile.cmd delete mode 100644 scripts/Diagnose-DKR-Recompile.ps1 create mode 100644 scripts/compose_payloads.py diff --git a/Diagnose-DKR-Recompile.cmd b/Diagnose-DKR-Recompile.cmd deleted file mode 100644 index 7d7d30ac..00000000 --- a/Diagnose-DKR-Recompile.cmd +++ /dev/null @@ -1,17 +0,0 @@ -@echo off -setlocal -cd /d "%~dp0" - -echo DKR-R - regenerate the prepared N64Recomp boundary -powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Diagnose-DKR-Recompile.ps1" %* -set "EXIT_CODE=%ERRORLEVEL%" - -echo. -if not "%EXIT_CODE%"=="0" ( - echo [FAILED] N64Recomp did not complete. - echo Upload the newest n64recomp-*.stdout.log and n64recomp-*.stderr.log from build-logs. -) else ( - echo [OK] N64Recomp completed. -) -if not "%DKRPORT_NO_PAUSE%"=="1" pause -exit /b %EXIT_CODE% diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 1aad60a4..5e30a8e8 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -47,65 +47,42 @@ Supply your own clean US v1.0/v77 and US Rev A/v1.1/v80 ROMs locally. A complete release needs both native CPU payloads and the matching Rare RSP output. Ordinary players only need the ROM they want to play. -`Build-DKR-Runtime.cmd` invokes `scripts/Prepare-DKR-Runtime.ps1`. -It prepares the decomp ELF, pinned dependencies and generated/probe inputs. -It is not, by itself, the complete mod-enabled/owned-rollback Public Build. -`Diagnose-DKR-Recompile.cmd` is a diagnostic regeneration entry point; it -does not replace composing both production policies. +## Prepare (Windows and macOS) -Use fresh build/output directories. Keep ROMs, generated output and local -profiles out of source control. - -## Compose both normal payloads - -The versioned policies are only part of the policy. Production legacy scene, -track/character menu, presentation and behaviour fragments must be composed -before N64Recomp runs: - -```text -python scripts/generate_legacy_menu_qualification.py --characters --character-menu - --v77-build - --v80-build - --recompiler --output -``` - -The displayed arguments are one command; join the lines using your shell's -continuation syntax. Paths in angle brackets are placeholders. -Use the resulting `generated-v77` and `generated-v80` directories separately. -CMake checks required hooks and rejects v80 pointed at the protected v77 output. - -Revision translation uses named functions and checked offsets, not a global -address delta. If translating the base v80 policy, retain and validate its -revision-specific entries; don't overwrite them with a blind translation. - -## Prepare the build inputs - -The complete Public Build compiles more than the two normal payloads: each -revision's payload with its save-layout and in-world portrait stages, its -isolated full-scene owned rollback engine, its Camera++ scene kernel and its -local model kernel. `scripts/prepare_payloads.py` derives all of them from the -normal payloads and writes them to `build/prepared`, where CMake looks for them -by default (`runtime-recomp/cmake/PreparedInputs.cmake`). -`Prepare-DKR-Runtime-macOS.sh` runs it last. After the Windows composition -above: +One prepare script per platform does the whole job, the same way on both: ```text -python scripts/prepare_payloads.py - --input 77 /generated-v77 /menu-v77.toml /dkr.us.v77.elf - --input 80 /generated-v80 /menu-v80.toml /dkr.us.v80.elf +Prepare-DKR-Runtime.cmd -RomV77 "" -RomV80 "" +bash scripts/Prepare-DKR-Runtime-macOS.sh --rom-v77 "" --rom-v80 "" ``` -Then configure without naming any payload: the defaults use -`build/prepared/generated-v77` and the rest of the set. A configure that names -its own `DKR_GENERATED_SOURCE_V77` gets none of the prepared set, since the -owned engine and kernels belong to the payload they were prepared from. +It checks both ROMs against their retail SHA-1, builds one decomp ELF per +revision (`build/decomp/v`; under WSL on Windows), builds N64Recomp, and +runs `scripts/compose_payloads.py` for each revision. That composes the +production policy (the versioned base policy plus the legacy mod, track menu, +character and character menu fragments, with the scene runtime) and runs +N64Recomp into `build/generated/v`. Last, `scripts/prepare_payloads.py` +derives everything else the Public Build compiles into `build/prepared`: each +payload with its save-layout and in-world portrait stages, the isolated +full-scene owned rollback engines, the Camera++ scene kernels and the local +model kernels. `Build-Windows.cmd` and `Build-macOS.sh` build from there, and a +plain configure finds the whole set by default +(`runtime-recomp/cmake/PreparedInputs.cmake`). A configure that names its own +`DKR_GENERATED_SOURCE_V77` gets none of it, since the owned engines and kernels +belong to the payload they were prepared from. Each stage checks pinned function hashes, native callback signatures, header hashes and service ownership, and writes new output only; it never edits its input. Despite the historical "probe/test" names, the owned outputs supply the integrated backend. Don't loosen a failed check or edit generated output to -admit a new closure. Without the model kernels the local model feature is not -compiled and the live Online maximum-detail control remains unavailable. +admit a new closure. Revision translation uses named functions and checked +offsets, not a global address delta. + +`scripts/generate_legacy_menu_qualification.py` builds menu qualification +payloads; it isn't part of a release build. To rerun only N64Recomp for one +revision, run `scripts/compose_payloads.py payload` (`--help` lists its +inputs). Keep ROMs, generated output and +local profiles out of source control. For an already prepared platform cache, configure through the current overlay: @@ -142,14 +119,14 @@ The Linux release toolchain uses glibc 2.38 or newer. AppImages bundle application libraries, not the system glibc or GPU driver. Record the ELF architecture and required symbol versions when using a different build host. -The simpler `Build-Windows.cmd`/`Build-Linux.sh` wrappers consume prepared -normal sources. Supply `-GeneratedSource`/`-Revision80GeneratedSource` -on the Windows script, or `DKR_LINUX_GENERATED_SOURCE`/ -`DKR_LINUX_V80_GENERATED_SOURCE` on Linux. +`Build-Windows.cmd` builds from `build\prepared` by default; +`-GeneratedSource`/`-Revision80GeneratedSource` select other payloads (which +then build without the prepared owned engines and kernels). `Build-Linux.sh` +takes `DKR_LINUX_GENERATED_SOURCE`/`DKR_LINUX_V80_GENERATED_SOURCE`. `./Build-Linux.sh` builds and tests; add `--package` only when you want an AppImage. `./Build-Linux.sh --help` lists the input and output overrides. -They do not configure the extra owned outputs automatically; don't describe -that basic build as reproduction of the full experimental Public Build. +`Build-Linux.sh` doesn't configure the extra owned outputs automatically; +don't describe that basic build as reproduction of the full Public Build. ## macOS diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index ecc66034..ee8a0334 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -176,6 +176,13 @@ Their authored guest inputs moved from `patches/save-layout/` and `prepared` level checks them against the reference (`scripts/fold/prepared.py` names the allowed differences). +- One prepare path per platform: `Prepare-DKR-Runtime.ps1` and + `Prepare-DKR-Runtime-macOS.sh` both check the ROMs and compose the payloads + through `scripts/compose_payloads.py`, then run `prepare_payloads.py`; the + Windows build defaults to `build\prepared`. **The PS1 is unverified**: no + Windows host here, and no CI job runs it (it needs ROMs and WSL). It parses + under pwsh 7.4; `windows-full-nogen` covers the CMake side only. The macOS + script's payloads came out byte-identical to the previous ones. - The save-layout and portrait stages still rewrite copies of `funcs_*.c`, which AGENTS.md reserves for `guest-hooks/hooks.toml`. They stay stages because the owned engine is generated from their output and diff --git a/pyproject.toml b/pyproject.toml index 319afeaa..1cc9cfb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ force-sort-within-sections = true # Strict only for new tooling scripts; everything else is ratcheted in per path. include = [ "scripts/agent_hooks", + "scripts/compose_payloads.py", "scripts/kernel_inputs.py", "scripts/prepare_payloads.py", "scripts/camera_scene_kernel.py", @@ -111,6 +112,7 @@ include = [ ] strict = [ "scripts/agent_hooks", + "scripts/compose_payloads.py", "scripts/kernel_inputs.py", "scripts/prepare_payloads.py", "scripts/camera_scene_kernel.py", diff --git a/runtime-recomp/cmake/PreparedInputs.cmake b/runtime-recomp/cmake/PreparedInputs.cmake index 66e5d6f4..e951aae3 100644 --- a/runtime-recomp/cmake/PreparedInputs.cmake +++ b/runtime-recomp/cmake/PreparedInputs.cmake @@ -8,9 +8,17 @@ # or local model kernels). set(DKR_PREPARED_DIR "${DKRPORT_ROOT}/build/prepared" CACHE PATH "Inputs scripts/prepare_payloads.py prepares from the N64Recomp payload") -if(DKR_RUNTIME_BUILD_GENERATED AND EXISTS "${DKR_PREPARED_DIR}/prepared.json" - AND (NOT DEFINED CACHE{DKR_GENERATED_SOURCE_V77} - OR DKR_GENERATED_SOURCE_V77 STREQUAL "${DKR_PREPARED_DIR}/generated-v77")) +# Compared as real paths: Windows callers pass backslashed paths. +file(TO_CMAKE_PATH "${DKR_PREPARED_DIR}" dkr_prepared_dir) +get_filename_component(dkr_prepared_dir "${dkr_prepared_dir}" REALPATH) +set(dkr_named_payload "") +if(DEFINED CACHE{DKR_GENERATED_SOURCE_V77}) + file(TO_CMAKE_PATH "${DKR_GENERATED_SOURCE_V77}" dkr_named_payload) + get_filename_component(dkr_named_payload "${dkr_named_payload}" REALPATH) +endif() +if(DKR_RUNTIME_BUILD_GENERATED AND EXISTS "${dkr_prepared_dir}/prepared.json" + AND (dkr_named_payload STREQUAL "" OR dkr_named_payload STREQUAL "${dkr_prepared_dir}/generated-v77")) + set(DKR_PREPARED_DIR "${dkr_prepared_dir}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${DKR_PREPARED_DIR}/prepared.json") set(DKR_GENERATED_SOURCE_V77 "${DKR_PREPARED_DIR}/generated-v77" CACHE PATH "N64Recomp-generated CPU source for DKR US v1.0") diff --git a/runtime-recomp/tests/prepare_payloads_tests.py b/runtime-recomp/tests/prepare_payloads_tests.py index 3ccbfd42..60c919dc 100644 --- a/runtime-recomp/tests/prepare_payloads_tests.py +++ b/runtime-recomp/tests/prepare_payloads_tests.py @@ -6,6 +6,7 @@ ROM-derived payloads; scripts/fold/run_equivalence.sh runs it.""" import contextlib +import hashlib import io import json from pathlib import Path @@ -20,9 +21,11 @@ import camera_scene_kernel # noqa: E402 import character_portrait_pipeline # noqa: E402 +import compose_payloads # noqa: E402 import generate_replay_probe # noqa: E402 import prepare_payloads # noqa: E402 +ORIGINAL_SHA1 = compose_payloads.RETAIL_SHA1[77] TOML = 'elf_path = "{root}/a.elf"\nrom_file_path = "{root}/a.z64"\noutput_func_path = "{root}/funcs"\nentrypoint = 1\n' @@ -196,6 +199,43 @@ def test_options_round_trip_through_a_manifest(self): prepare_payloads.options_from(manifest, 80) +class ComposeRomTests(unittest.TestCase): + """compose_payloads.py rom: byte order is normalised before the SHA-1 check.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.z64 = bytes.fromhex("80371240") + bytes(range(256)) * 4 + compose_payloads.RETAIL_SHA1[77] = hashlib.sha1(self.z64).hexdigest() + self.addCleanup(compose_payloads.RETAIL_SHA1.__setitem__, 77, ORIGINAL_SHA1) + + def test_every_byte_order_normalises_to_z64(self): + v64 = bytearray(self.z64) + v64[0::2], v64[1::2] = v64[1::2], v64[0::2] + n64 = bytearray(self.z64) + for i in range(0, len(n64), 4): + n64[i : i + 4] = n64[i : i + 4][::-1] + for name, data in (("z64", self.z64), ("v64", bytes(v64)), ("n64", bytes(n64))): + with self.subTest(order=name): + source, target = self.base / f"rom.{name}", self.base / f"out-{name}.z64" + source.write_bytes(data) + compose_payloads.normalise_rom(source, target, 77) + self.assertEqual(target.read_bytes(), self.z64) + + def test_a_different_dump_or_revision_is_refused(self): + source = self.base / "rom.z64" + source.write_bytes(self.z64[:-1] + b"\x00") + with self.assertRaisesRegex(compose_payloads.ComposeError, "SHA-1"): + compose_payloads.normalise_rom(source, self.base / "out.z64", 77) + source.write_bytes(self.z64) + with self.assertRaisesRegex(compose_payloads.ComposeError, "US v80"): + compose_payloads.normalise_rom(source, self.base / "out.z64", 80) + source.write_bytes(b"\x00" * 16) + with self.assertRaisesRegex(compose_payloads.ComposeError, "not an N64 ROM"): + compose_payloads.normalise_rom(source, self.base / "out.z64", 77) + + class StageTests(unittest.TestCase): def test_portrait_stage_pins_the_folded_native_sources(self): character_portrait_pipeline.verify_native_files() diff --git a/scripts/Build-DKR-R-Windows.ps1 b/scripts/Build-DKR-R-Windows.ps1 index 2133d38b..ae0d1f9e 100644 --- a/scripts/Build-DKR-R-Windows.ps1 +++ b/scripts/Build-DKR-R-Windows.ps1 @@ -14,8 +14,11 @@ Set-StrictMode -Version Latest $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $buildDirectory = Join-Path $projectRoot 'build\dkr-runtime-rt64' +# Prepare-DKR-Runtime.cmd ends with scripts\prepare_payloads.py; CMake defaults +# the owned engines, kernels and code policies to the same build\prepared. +$prepared = Join-Path $projectRoot 'build\prepared' $generatedSource = if ([string]::IsNullOrWhiteSpace($GeneratedSource)) { - Join-Path $projectRoot 'runtime-recomp\RecompiledFuncs' + Join-Path $prepared 'generated-v77' } else { [IO.Path]::GetFullPath($GeneratedSource) } @@ -24,7 +27,7 @@ $revision80Generated = if (-not [string]::IsNullOrWhiteSpace($Revision80Generate } elseif (-not [string]::IsNullOrWhiteSpace($env:DKR_WINDOWS_V80_GENERATED_SOURCE)) { [IO.Path]::GetFullPath($env:DKR_WINDOWS_V80_GENERATED_SOURCE) } else { - throw 'Set -Revision80GeneratedSource or DKR_WINDOWS_V80_GENERATED_SOURCE to the US Rev A/v1.1 Patch Pipeline output.' + Join-Path $prepared 'generated-v80' } $version = (Get-Content -LiteralPath (Join-Path $projectRoot 'VERSION') -Raw).Trim() $cmakeCandidates = @( @@ -56,10 +59,10 @@ foreach ($required in @( } } if (-not (Test-Path -LiteralPath $generatedSource -PathType Container)) { - throw "US v1.0/v77 Patch Pipeline output is missing: $generatedSource" + throw "US v1.0/v77 payload is missing: $generatedSource. Run Prepare-DKR-Runtime.cmd first." } if (-not (Test-Path -LiteralPath $revision80Generated -PathType Container)) { - throw "US Rev A/v1.1 Patch Pipeline output is missing: $revision80Generated" + throw "US Rev A/v1.1 payload is missing: $revision80Generated. Run Prepare-DKR-Runtime.cmd first." } if ($Clean -and (Test-Path -LiteralPath $buildDirectory)) { diff --git a/scripts/Diagnose-DKR-Recompile.ps1 b/scripts/Diagnose-DKR-Recompile.ps1 deleted file mode 100644 index 13c2fce1..00000000 --- a/scripts/Diagnose-DKR-Recompile.ps1 +++ /dev/null @@ -1,249 +0,0 @@ -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -$ProjectRoot = Split-Path -Parent $PSScriptRoot -$LogDirectory = Join-Path $ProjectRoot 'build-logs' -$RuntimeRoot = Join-Path $ProjectRoot 'runtime-recomp' -$TomlPath = Join-Path $RuntimeRoot 'dkr.us.v77.generated.toml' -$Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' -$StdoutLog = Join-Path $LogDirectory "n64recomp-$Timestamp.stdout.log" -$StderrLog = Join-Path $LogDirectory "n64recomp-$Timestamp.stderr.log" - -New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null - -function Fail([string]$Message) { - throw $Message -} - -function Convert-ToWslPath([string]$WindowsPath) { - $resolvedWindowsPath = (Resolve-Path -LiteralPath $WindowsPath).Path - $previousErrorActionPreference = $ErrorActionPreference - try { - $ErrorActionPreference = 'Continue' - $output = @(& wsl.exe --exec wslpath -a -u $resolvedWindowsPath 2>&1) - $exitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previousErrorActionPreference - } - $translatedPath = ($output | Out-String).Trim() - if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($translatedPath)) { - Fail "WSL could not translate '$resolvedWindowsPath': $translatedPath" - } - return $translatedPath -} - -function Refresh-DkrEntrypoint([string]$ElfPath, [string]$ConfigPath) { - if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { - Fail 'WSL is required to inspect the matching DKR ELF.' - } - if (-not (Test-Path -LiteralPath $ElfPath -PathType Leaf)) { - Fail "The matching DKR ELF is missing: $ElfPath" - } - - $wslElf = Convert-ToWslPath $ElfPath - $previousErrorActionPreference = $ErrorActionPreference - try { - $ErrorActionPreference = 'Continue' - $symbolOutput = @(& wsl.exe --exec mips-linux-gnu-nm -n --defined-only $wslElf 2>&1) - $symbolExitCode = $LASTEXITCODE - $sectionOutput = @(& wsl.exe --exec mips-linux-gnu-readelf -S -W $wslElf 2>&1) - $sectionExitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previousErrorActionPreference - } - - if ($symbolExitCode -ne 0) { - Fail "Could not inspect DKR ELF symbols: $(($symbolOutput | Out-String).Trim())" - } - if ($sectionExitCode -ne 0) { - Fail "Could not inspect DKR ELF sections: $(($sectionOutput | Out-String).Trim())" - } - - $mainprocAddress = $null - foreach ($rawLine in $symbolOutput) { - $line = [string]$rawLine - if ($line -match '^\s*([0-9A-Fa-f]+)\s+[A-Za-z]\s+mainproc\s*$') { - $mainprocAddress = [Convert]::ToUInt64($Matches[1], 16) - break - } - } - if ($null -eq $mainprocAddress) { - Fail 'The matching DKR ELF does not expose the expected mainproc symbol.' - } - - $entrypointText = '0x{0:X8}' -f ([uint32]$mainprocAddress) - $useMdebugText = (($sectionOutput | Out-String) -match '(?m)\s\.mdebug\s').ToString().ToLowerInvariant() - $configText = [System.IO.File]::ReadAllText($ConfigPath) - - $policyPath = Join-Path $ProjectRoot 'runtime-recomp\dkr.us.v77.recomp-policy.json' - if (-not (Test-Path -LiteralPath $policyPath -PathType Leaf)) { - Fail "DKR recompilation policy is missing: $policyPath" - } - $policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json - if ([int]$policy.schemaVersion -ne 1) { Fail 'Unsupported DKR recompilation policy schema.' } - $stubToml = (@($policy.stubs | ForEach-Object { '"' + ([string]$_.name).Replace('"', '\"') + '"' }) -join ', ') - $renamedToml = (@($policy.renamed | ForEach-Object { '"' + ([string]$_.name).Replace('"', '\"') + '"' }) -join ', ') - $ignoredToml = (@($policy.ignored | ForEach-Object { '"' + ([string]$_.name).Replace('"', '\"') + '"' }) -join ', ') - $functionSizesToml = (@($policy.functionSizes | ForEach-Object { - "{ name = `"$([string]$_.name)`", size = $([string]$_.size) }" - }) -join ', ') - $instructionPatchToml = (@($policy.instructionPatches | ForEach-Object { - "[[patches.instruction]]`nfunc = `"$([string]$_.function)`"`nvram = $([string]$_.vram)`nvalue = $([string]$_.value)" - }) -join "`n`n") - $manualFunctionsToml = (@($policy.manualFunctions | ForEach-Object { - "{ name = `"$([string]$_.name)`", section = `"$([string]$_.section)`", vram = $([string]$_.vram), size = $([string]$_.size) }" - }) -join ', ') - $functionHookToml = (@($policy.functionHooks | ForEach-Object { - $hookText = ([string]$_.text).Replace('\', '\\').Replace('"', '\"') - "[[patches.hook]]`nfunc = `"$([string]$_.function)`"`nbefore_vram = $([string]$_.beforeVram)`ntext = `"$hookText`"" - }) -join "`n`n") - - if ($configText -notmatch '(?m)^entrypoint\s*=') { - Fail "The generated config does not contain an entrypoint key: $ConfigPath" - } - $configText = [regex]::Replace($configText, '(?m)^entrypoint\s*=.*$', "entrypoint = $entrypointText") - if ($configText -match '(?m)^use_mdebug\s*=') { - $configText = [regex]::Replace($configText, '(?m)^use_mdebug\s*=.*$', "use_mdebug = $useMdebugText") - } else { - $configText = [regex]::Replace($configText, '(?m)^(entrypoint\s*=.*)$', "`$1`nuse_mdebug = $useMdebugText") - } - if ($configText -match '(?m)^manual_funcs\s*=') { - $configText = [regex]::Replace($configText, '(?m)^manual_funcs\s*=.*$', "manual_funcs = [$manualFunctionsToml]") - } else { - $configText = [regex]::Replace($configText, '(?m)^(output_func_path\s*=.*)$', "`$1`nmanual_funcs = [$manualFunctionsToml]") - } - if ($configText -match '(?m)^function_sizes\s*=') { - $configText = [regex]::Replace($configText, '(?m)^function_sizes\s*=.*$', "function_sizes = [$functionSizesToml]") - } else { - $configText = [regex]::Replace($configText, '(?m)^(manual_funcs\s*=.*)$', "`$1`nfunction_sizes = [$functionSizesToml]") - } - if ($configText -match '(?m)^stubs\s*=') { - $configText = [regex]::Replace($configText, '(?m)^stubs\s*=.*$', "stubs = [$stubToml]") - } else { - Fail 'The generated config does not contain a stubs array.' - } - if ($configText -match '(?m)^ignored\s*=') { - $configText = [regex]::Replace($configText, '(?m)^ignored\s*=.*$', "ignored = [$ignoredToml]") - } else { - Fail 'The generated config does not contain an ignored array.' - } - if ($configText -match '(?m)^renamed\s*=') { - $configText = [regex]::Replace($configText, '(?m)^renamed\s*=.*$', "renamed = [$renamedToml]") - } else { - $configText = [regex]::Replace($configText, '(?m)^(stubs\s*=.*)$', "`$1`nrenamed = [$renamedToml]") - } - $instructionPatchBlock = "# BEGIN DKR_INSTRUCTION_PATCHES`n$instructionPatchToml`n# END DKR_INSTRUCTION_PATCHES" - if ($configText -match '(?s)# BEGIN DKR_INSTRUCTION_PATCHES.*?# END DKR_INSTRUCTION_PATCHES') { - $configText = [regex]::Replace( - $configText, - '(?s)# BEGIN DKR_INSTRUCTION_PATCHES.*?# END DKR_INSTRUCTION_PATCHES', - $instructionPatchBlock) - } else { - $configText = $configText.TrimEnd() + "`n`n$instructionPatchBlock`n" - } - $functionHookBlock = "# BEGIN DKR_FUNCTION_HOOKS`n$functionHookToml`n# END DKR_FUNCTION_HOOKS" - if ($configText -match '(?s)# BEGIN DKR_FUNCTION_HOOKS.*?# END DKR_FUNCTION_HOOKS') { - $configText = [regex]::Replace( - $configText, - '(?s)# BEGIN DKR_FUNCTION_HOOKS.*?# END DKR_FUNCTION_HOOKS', - $functionHookBlock) - } else { - $configText = $configText.TrimEnd() + "`n`n$functionHookBlock`n" - } - - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($ConfigPath, $configText, $utf8NoBom) - Write-Host "[OK] Static recompilation entry function: mainproc at $entrypointText" -ForegroundColor Green - Write-Host "[OK] use_mdebug: $useMdebugText" -ForegroundColor Green -} - -try { - Write-Host 'DKR-R - regenerate the prepared N64Recomp boundary' - Write-Host "Project: $ProjectRoot" - Write-Host '' - - if (-not (Test-Path -LiteralPath $TomlPath -PathType Leaf)) { - Fail "The generated N64Recomp configuration is missing: $TomlPath Run Build-DKR-Runtime.cmd first." - } - - $Recompiler = Get-ChildItem ` - -LiteralPath (Join-Path $ProjectRoot 'build\runtime-tools\n64recomp') ` - -Filter 'N64Recomp.exe' ` - -File ` - -Recurse ` - -ErrorAction SilentlyContinue | - Select-Object -First 1 - - if (-not $Recompiler) { - Fail 'N64Recomp.exe is missing. Run Build-DKR-Runtime.cmd first.' - } - - $ElfPath = Join-Path $ProjectRoot 'extern\dkr-decomp\build\dkr.us.v77.elf' - Refresh-DkrEntrypoint $ElfPath $TomlPath - - Write-Host "[OK] N64Recomp: $($Recompiler.FullName)" -ForegroundColor Green - Write-Host "[OK] Configuration: $TomlPath" -ForegroundColor Green - Write-Host '' - Write-Host 'Running N64Recomp. This command reuses the already-built DKR ELF and does not rerun WSL setup.' -ForegroundColor Cyan - - $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) - $StartInfo = New-Object System.Diagnostics.ProcessStartInfo - $StartInfo.FileName = $Recompiler.FullName - $StartInfo.Arguments = '"' + $TomlPath + '"' - $StartInfo.WorkingDirectory = $RuntimeRoot - $StartInfo.UseShellExecute = $false - $StartInfo.CreateNoWindow = $true - $StartInfo.RedirectStandardOutput = $true - $StartInfo.RedirectStandardError = $true - - $Process = New-Object System.Diagnostics.Process - $Process.StartInfo = $StartInfo - if (-not $Process.Start()) { - Fail 'N64Recomp could not be started.' - } - - $StdoutTask = $Process.StandardOutput.ReadToEndAsync() - $StderrTask = $Process.StandardError.ReadToEndAsync() - $Process.WaitForExit() - $ExitCode = $Process.ExitCode - $Stdout = $StdoutTask.Result - $Stderr = $StderrTask.Result - $Process.Dispose() - - [System.IO.File]::WriteAllText($StdoutLog, $Stdout, $Utf8NoBom) - [System.IO.File]::WriteAllText($StderrLog, $Stderr, $Utf8NoBom) - - if (-not [string]::IsNullOrWhiteSpace($Stdout)) { - Write-Host '' - Write-Host '==> N64Recomp standard output' - Write-Host $Stdout.TrimEnd() - } - if (-not [string]::IsNullOrWhiteSpace($Stderr)) { - Write-Host '' - Write-Host '==> N64Recomp diagnostic output' -ForegroundColor Yellow - Write-Host $Stderr.TrimEnd() -ForegroundColor Yellow - } - - Write-Host '' - Write-Host "stdout log: $StdoutLog" - Write-Host "stderr log: $StderrLog" - - if ($ExitCode -ne 0) { - Write-Host '' - Write-Host "[FAILED] N64Recomp returned exit code $ExitCode." -ForegroundColor Red - exit $ExitCode - } - - Write-Host '' - Write-Host '[OK] N64Recomp completed successfully.' -ForegroundColor Green - exit 0 -} catch { - Write-Host '' - Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red - Write-Host "stdout log: $StdoutLog" - Write-Host "stderr log: $StderrLog" - exit 1 -} diff --git a/scripts/Prepare-DKR-Runtime-macOS.sh b/scripts/Prepare-DKR-Runtime-macOS.sh index d17632da..8023eb3e 100755 --- a/scripts/Prepare-DKR-Runtime-macOS.sh +++ b/scripts/Prepare-DKR-Runtime-macOS.sh @@ -230,33 +230,15 @@ decomp_ready() { stamp_matches() { [[ "$FORCE" -eq 0 && -f "$1" && "$(cat "$1")" == "$2" ]]; } prepare_revision() { - local revision="$1" rom="$2" sha1="$3" entrypoint="$4" + local revision="$1" rom="$2" sha1="$3" local tree="$PROJECT_ROOT/build/decomp/v$revision" local elf="$tree/build/dkr.us.v$revision.elf" built_rom="$tree/build/dkr.us.v$revision.z64" local work="$OUTPUT/v$revision" step "US v$revision: ROM" # Normalise byte order and verify the exact retail dump. - mkdir -p "$tree/baseroms" - python3 - "$rom" "$tree/baseroms/baserom.us.v$revision.z64" "$sha1" <<'PY' -import hashlib, pathlib, sys -source, target, expected = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3] -data = bytearray(source.read_bytes()) -magic = bytes(data[:4]).hex() -if magic == "37804012": - data[0::2], data[1::2] = data[1::2], data[0::2] -elif magic == "40123780": - for i in range(0, len(data) - 3, 4): - data[i:i + 4] = data[i:i + 4][::-1] -elif magic != "80371240": - raise SystemExit(f"{source}: not an N64 ROM (magic {magic})") -digest = hashlib.sha1(data).hexdigest() -if digest != expected: - raise SystemExit(f"{source}: SHA-1 {digest}, expected {expected}") -if not target.exists() or target.read_bytes() != data: - target.write_bytes(data) -print(f"[OK] {source.name} verified ({digest})") -PY + python3 "$SCRIPT_DIR/compose_payloads.py" rom --revision "$revision" "$rom" "$tree/baseroms/baserom.us.v$revision.z64" || + fail "The US v$revision ROM was rejected." step "US v$revision: decomp ELF" # Each revision gets its own tree: the decomp extracts every version into @@ -297,30 +279,15 @@ PY mv "$work" "$previous" echo "[OK] Previous generated output preserved: $previous" fi - mkdir -p "$work/RecompiledFuncs" - python3 "$SCRIPT_DIR/compose_legacy_mod_policy.py" \ - --policy "$PROJECT_ROOT/runtime-recomp/dkr.us.v$revision.recomp-policy.json" \ - --fragment "$PROJECT_ROOT/runtime-recomp/legacy-mods.v$revision.recomp-fragment.json" \ - --track-menu "$PROJECT_ROOT/runtime-recomp/legacy-track-menu.v$revision.recomp-fragment.json" \ - --characters "$PROJECT_ROOT/runtime-recomp/legacy-characters.v$revision.recomp-fragment.json" \ - --character-menu "$PROJECT_ROOT/runtime-recomp/legacy-character-menu.v$revision.recomp-fragment.json" \ - --scene-runtime --elf "$elf" --output "$work/policy.json" - python3 "$SCRIPT_DIR/generate_recomp_config.py" \ - --policy "$work/policy.json" --elf "$elf" --rom "$built_rom" \ - --output-functions "$work/RecompiledFuncs" --entrypoint "$entrypoint" \ - --output "$work/dkr.us.v$revision.toml" - (cd "$PROJECT_ROOT/runtime-recomp" && "$RECOMPILER" "$work/dkr.us.v$revision.toml") \ - >"$work/n64recomp.log" 2>&1 || { - tail -20 "$work/n64recomp.log" - fail "N64Recomp failed for v$revision." - } + python3 "$SCRIPT_DIR/compose_payloads.py" payload --revision "$revision" --elf "$elf" --rom "$built_rom" \ + --recompiler "$RECOMPILER" --output "$work" || fail "The US v$revision payload failed (log: $work/n64recomp.log)." echo "$inputs" >"$work/.dkr-payload-stamp" echo "[OK] $work/RecompiledFuncs" } -# Entrypoints are mainproc in each retail image. -prepare_revision 77 "$ROM_V77" 0cb115d8716dbbc2922fda38e533b9fe63bb9670 0x80065D40 -prepare_revision 80 "$ROM_V80" 6d96743d46f8c0cd0edb0ec5600b003c89b93755 0x80065F80 +# The retail SHA-1s (compose_payloads.py checks the ROMs against the same). +prepare_revision 77 "$ROM_V77" 0cb115d8716dbbc2922fda38e533b9fe63bb9670 +prepare_revision 80 "$ROM_V80" 6d96743d46f8c0cd0edb0ec5600b003c89b93755 # The build inputs derived from both payloads (save layout and portrait # stages, owned engine, Camera++ and model kernels); CMake reads them by default. diff --git a/scripts/Prepare-DKR-Runtime.ps1 b/scripts/Prepare-DKR-Runtime.ps1 index fbbc15b0..b266544b 100644 --- a/scripts/Prepare-DKR-Runtime.ps1 +++ b/scripts/Prepare-DKR-Runtime.ps1 @@ -1,13 +1,24 @@ [CmdletBinding()] param( + [string]$RomV77 = '', + [string]$RomV80 = '', [switch]$SkipApt, [switch]$Force, [switch]$SkipRecompile, [switch]$SkipProbe, [switch]$BuildRenderer, + [string]$Python = '', [int]$Jobs = 0 ) +# Prepare everything Build-Windows.cmd compiles, the same way +# Prepare-DKR-Runtime-macOS.sh does on macOS: pinned dependencies with their +# patches, N64Recomp, one decomp ELF per supported ROM revision (US v1.0 = v77, +# US Rev A/v1.1 = v80, each in its own build\decomp\v tree, built under +# WSL), the production payloads (scripts\compose_payloads.py, into +# build\generated\v), and the build inputs scripts\prepare_payloads.py +# derives from them (build\prepared, which CMake reads by default). + $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest @@ -18,11 +29,12 @@ $Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $LogPath = Join-Path $LogDirectory "prepare-dkr-runtime-$Timestamp.log" Start-Transcript -Path $LogPath -Force | Out-Null -$ExpectedSha1 = '0cb115d8716dbbc2922fda38e533b9fe63bb9670' $DkrSource = Join-Path $ProjectRoot 'extern\dkr-decomp' $RuntimeRoot = Join-Path $ProjectRoot 'runtime-recomp' -$GeneratedFunctions = Join-Path $RuntimeRoot 'RecompiledFuncs' +$GeneratedRoot = Join-Path $ProjectRoot 'build\generated' +$PreparedRoot = Join-Path $ProjectRoot 'build\prepared' $ResolvedPath = Join-Path $RuntimeRoot 'resolved-runtime-dependencies.json' +$Revisions = @(77, 80) function Fail([string]$Message) { throw $Message @@ -63,99 +75,6 @@ function Convert-ToWslPath([string]$WindowsPath) { return $translatedPath } - -function Resolve-DkrRecompEntrypoint([string]$ElfPath, [string]$RomHeaderEntrypointText) { - if (-not (Test-Path -LiteralPath $ElfPath -PathType Leaf)) { - Fail "Cannot inspect the DKR ELF because it does not exist: $ElfPath" - } - - $wslElf = Convert-ToWslPath $ElfPath - $symbolsLog = Join-Path $LogDirectory "dkr-elf-symbols-$Timestamp.log" - $sectionsLog = Join-Path $LogDirectory "dkr-elf-sections-$Timestamp.log" - - $previousErrorActionPreference = $ErrorActionPreference - try { - $ErrorActionPreference = 'Continue' - $symbolOutput = @(& wsl.exe --exec mips-linux-gnu-nm -n --defined-only $wslElf 2>&1) - $symbolExitCode = $LASTEXITCODE - $sectionOutput = @(& wsl.exe --exec mips-linux-gnu-readelf -S -W $wslElf 2>&1) - $sectionExitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previousErrorActionPreference - } - - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($symbolsLog, (($symbolOutput | Out-String).TrimEnd() + "`n"), $utf8NoBom) - [System.IO.File]::WriteAllText($sectionsLog, (($sectionOutput | Out-String).TrimEnd() + "`n"), $utf8NoBom) - - if ($symbolExitCode -ne 0) { - Fail "Could not inspect DKR ELF symbols with mips-linux-gnu-nm. Full output: $symbolsLog" - } - if ($sectionExitCode -ne 0) { - Fail "Could not inspect DKR ELF sections with mips-linux-gnu-readelf. Full output: $sectionsLog" - } - - [uint64]$romHeaderAddress = [Convert]::ToUInt64($RomHeaderEntrypointText.Substring(2), 16) - $mainprocAddress = $null - $headerSymbolAddress = $null - $headerSymbolName = $null - - foreach ($rawLine in $symbolOutput) { - $line = [string]$rawLine - if ($line -notmatch '^\s*([0-9A-Fa-f]+)\s+([A-Za-z])\s+(\S+)\s*$') { - continue - } - - [uint64]$address = [Convert]::ToUInt64($Matches[1], 16) - $symbolType = $Matches[2] - $symbolName = $Matches[3] - - if ($symbolName -eq 'mainproc') { - $mainprocAddress = $address - } - if ($address -eq $romHeaderAddress -and $symbolType -match '^[TtWw]$') { - $headerSymbolAddress = $address - $headerSymbolName = $symbolName - } - } - - # DKR's decomp documents mainproc as the function run after IPL3 has - # completed. Prefer that real function symbol over the raw ROM header PC, - # which may point at bootstrap code that is not represented as an ELF - # function and therefore cannot be selected by N64Recomp. - $selectedAddress = $null - $selectedSymbol = $null - if ($null -ne $mainprocAddress) { - $selectedAddress = [uint64]$mainprocAddress - $selectedSymbol = 'mainproc' - } elseif ($null -ne $headerSymbolAddress) { - $selectedAddress = [uint64]$headerSymbolAddress - $selectedSymbol = [string]$headerSymbolName - } else { - Fail ("The DKR ELF contains neither the mainproc symbol nor a text symbol at the ROM header entry point " + - "$RomHeaderEntrypointText. ELF symbols: $symbolsLog") - } - - if (($selectedAddress -band 3) -ne 0) { - Fail ("Resolved DKR entrypoint $selectedSymbol at 0x{0:X8}, but it is not word-aligned." -f $selectedAddress) - } - - $hasMdebug = (($sectionOutput | Out-String) -match '(?m)\s\.mdebug\s') - $addressText = '0x{0:X8}' -f ([uint32]$selectedAddress) - - Write-Host "[OK] Static recompilation entry function: $selectedSymbol at $addressText" -ForegroundColor Green - Write-Host "[OK] ELF .mdebug metadata available: $hasMdebug" -ForegroundColor Green - Write-Host "[OK] ELF symbol report: $symbolsLog" -ForegroundColor Green - - return [PSCustomObject]@{ - AddressText = $addressText - SymbolName = $selectedSymbol - UseMdebug = [bool]$hasMdebug - SymbolsLog = $symbolsLog - SectionsLog = $sectionsLog - } -} - function Get-NativeCMake { $candidates = New-Object System.Collections.Generic.List[string] @@ -190,79 +109,60 @@ function Invoke-Checked([string]$Description, [scriptblock]$Command) { } } -function Resolve-RomPath { - $candidates = @( - (Join-Path $ProjectRoot 'runtime\config\rom-source.json'), - (Join-Path $ProjectRoot 'dist\DKRPort-Windows-x64\runtime\config\rom-source.json'), - (Join-Path $ProjectRoot 'build\windows-x64\bin\Release\runtime\config\rom-source.json'), - (Join-Path $ProjectRoot 'build\windows-x64\bin\Debug\runtime\config\rom-source.json') - ) - - foreach ($candidate in $candidates) { - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - try { - $config = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json - $path = [string]$config.path - if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) { - Write-Host "[OK] Using the ROM already connected by the launcher: $path" -ForegroundColor Green - return (Resolve-Path -LiteralPath $path).Path +function Resolve-RomPath([int]$Revision, [string]$Explicit) { + if (-not [string]::IsNullOrWhiteSpace($Explicit)) { + if (-not (Test-Path -LiteralPath $Explicit -PathType Leaf)) { Fail "ROM not found: $Explicit" } + return (Resolve-Path -LiteralPath $Explicit).Path + } + if ($Revision -eq 77) { + # The launcher remembers the US v1.0 ROM it validated. + $candidates = @( + (Join-Path $ProjectRoot 'runtime\config\rom-source.json'), + (Join-Path $ProjectRoot 'dist\DKRPort-Windows-x64\runtime\config\rom-source.json'), + (Join-Path $ProjectRoot 'build\windows-x64\bin\Release\runtime\config\rom-source.json'), + (Join-Path $ProjectRoot 'build\windows-x64\bin\Debug\runtime\config\rom-source.json') + ) + foreach ($candidate in $candidates) { + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + try { + $config = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json + $path = [string]$config.path + if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Host "[OK] Using the ROM already connected by the launcher: $path" -ForegroundColor Green + return (Resolve-Path -LiteralPath $path).Path + } + } catch { + Write-Warning "Could not read ROM source configuration '$candidate': $($_.Exception.Message)" } - } catch { - Write-Warning "Could not read ROM source configuration '$candidate': $($_.Exception.Message)" } } + $label = if ($Revision -eq 77) { 'US 1.0' } else { 'US Rev A (1.1)' } Add-Type -AssemblyName System.Windows.Forms $dialog = New-Object System.Windows.Forms.OpenFileDialog - $dialog.Title = 'Select your validated Diddy Kong Racing US 1.0 ROM' + $dialog.Title = "Select your Diddy Kong Racing $label ROM" $dialog.Filter = 'Nintendo 64 ROM (*.z64;*.v64;*.n64)|*.z64;*.v64;*.n64|All files (*.*)|*.*' $dialog.Multiselect = $false if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { - Fail 'No ROM was selected. Validate the ROM in the launcher or rerun this command and choose it.' + Fail "No $label ROM was selected. Pass -RomV$Revision or rerun this command and choose it." } return $dialog.FileName } -function Convert-ToCanonicalRom([string]$SourcePath, [string]$DestinationPath) { - [byte[]]$bytes = [System.IO.File]::ReadAllBytes($SourcePath) - if ($bytes.Length -lt 4) { Fail 'The selected ROM is too small to contain an N64 header.' } - - $magic = '{0:X2}{1:X2}{2:X2}{3:X2}' -f $bytes[0], $bytes[1], $bytes[2], $bytes[3] - switch ($magic) { - '80371240' { Write-Host '[OK] ROM byte order: z64 / big-endian' -ForegroundColor Green } - '37804012' { - Write-Host '[OK] ROM byte order: v64 / byte-swapped; normalising locally' -ForegroundColor Green - for ($i = 0; $i + 1 -lt $bytes.Length; $i += 2) { - $tmp = $bytes[$i]; $bytes[$i] = $bytes[$i + 1]; $bytes[$i + 1] = $tmp - } - } - '40123780' { - Write-Host '[OK] ROM byte order: n64 / little-endian; normalising locally' -ForegroundColor Green - for ($i = 0; $i + 3 -lt $bytes.Length; $i += 4) { - $a = $bytes[$i]; $b = $bytes[$i + 1] - $bytes[$i] = $bytes[$i + 3] - $bytes[$i + 1] = $bytes[$i + 2] - $bytes[$i + 2] = $b - $bytes[$i + 3] = $a - } - } - default { Fail "The selected file does not have a recognised N64 ROM byte order (magic $magic)." } - } - - $sha = [System.Security.Cryptography.SHA1]::Create() - try { - $hash = ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' - } finally { - $sha.Dispose() - } - if ($hash -ne $ExpectedSha1) { - Fail "The normalised ROM SHA-1 is $hash, but DKR US 1.0 requires $ExpectedSha1." - } +function Resolve-Python { + if (-not [string]::IsNullOrWhiteSpace($Python)) { return @($Python) } + if (Get-Command py.exe -ErrorAction SilentlyContinue) { return @('py.exe', '-3') } + $command = Get-Command python.exe -ErrorAction SilentlyContinue + if ($command) { return @($command.Source) } + Fail 'Python 3.11 or newer was not found. Install it from python.org, or pass -Python .' +} - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $DestinationPath) | Out-Null - [System.IO.File]::WriteAllBytes($DestinationPath, $bytes) - Write-Host "[OK] Canonical local build ROM written to: $DestinationPath" -ForegroundColor Green - return $bytes +function Invoke-Python([string]$Description, [string[]]$Arguments) { + $python = @(Resolve-Python) + $rest = @() + if ($python.Count -gt 1) { $rest = $python[1..($python.Count - 1)] } + & $python[0] @rest @Arguments + if ($LASTEXITCODE -ne 0) { Fail "$Description failed with exit code $LASTEXITCODE." } } function Clone-Or-Update([string]$Name, [string]$Repository, [string]$Destination, @@ -301,11 +201,10 @@ function Clone-Or-Update([string]$Name, [string]$Repository, [string]$Destinatio } try { - Write-Host 'DKR-R - decomp ELF and static-recompilation preparation' + Write-Host 'DKR-R - runtime preparation (decomp ELFs, payloads, prepared build inputs)' Write-Host "Project: $ProjectRoot" Write-Host "Log: $LogPath" Write-Host '' - Write-Host 'This command prepares the first real game-code boundary. It builds the matching DKR ELF and attempts to generate native C output. It does not yet promise a playable executable.' -ForegroundColor Yellow if (-not (Get-Command git.exe -ErrorAction SilentlyContinue) -and -not (Get-Command git -ErrorAction SilentlyContinue)) { Fail 'Git is missing. Run Build-Windows.cmd first.' @@ -328,76 +227,66 @@ try { & powershell.exe @sourcePreparationArgs if ($LASTEXITCODE -ne 0) { Fail 'The pinned DKR decomp source could not be prepared.' } - Write-Step 'Locating and normalising the user-owned ROM' - $sourceRom = Resolve-RomPath - $canonicalRom = Join-Path $DkrSource 'baseroms\dkr.us.v77.z64' - [byte[]]$romBytes = Convert-ToCanonicalRom $sourceRom $canonicalRom - [uint32]$romHeaderEntrypoint = ([uint32]$romBytes[8] -shl 24) -bor ([uint32]$romBytes[9] -shl 16) -bor ([uint32]$romBytes[10] -shl 8) -bor [uint32]$romBytes[11] - $romHeaderEntrypointText = '0x{0:X8}' -f $romHeaderEntrypoint - Write-Host "[OK] ROM header entry point: $romHeaderEntrypointText" -ForegroundColor Green - - Write-Step 'Building the completed DKR decomp ELF under WSL' - $wslDkr = Convert-ToWslPath $DkrSource - if (-not $wslDkr) { Fail 'WSL could not translate the DKR source path.' } - Write-Host "[OK] WSL source path: $wslDkr" -ForegroundColor Green + $roms = @{ 77 = (Resolve-RomPath 77 $RomV77); 80 = (Resolve-RomPath 80 $RomV80) } + Write-Step 'Building the decomp ELFs under WSL' if (-not $SkipApt) { Write-Host 'Ubuntu may ask for your Linux sudo password while installing the documented decomp dependencies.' -ForegroundColor Yellow - & wsl.exe --exec bash -lc "sudo apt-get update && sudo apt-get install -y build-essential pkg-config git python3 python3-pip binutils-mips-linux-gnu python3-venv libpcre2-dev libpcre2-8-0 cmake ninja-build clang lld libcurl4-openssl-dev" + & wsl.exe --exec bash -lc "sudo apt-get update && sudo apt-get install -y build-essential pkg-config git python3 python3-pip binutils-mips-linux-gnu python3-venv libpcre2-dev libpcre2-8-0 cmake ninja-build clang lld libcurl4-openssl-dev rsync" if ($LASTEXITCODE -ne 0) { Fail 'WSL dependency installation failed. Rerun with -SkipApt only after installing the listed packages manually.' } } - # Do not pass a Windows CRLF here-string directly to `bash -lc`. Bash + # Each revision gets its own tree: the decomp extracts every version into + # the same asm/, assets/ and build/ directories. The live checkout is only + # read. Do not pass a Windows CRLF here-string directly to `bash -lc`: Bash # treats the hidden carriage return in `pipefail\r` as part of the option - # name and aborts before running the build. Generate an explicit LF-only, - # BOM-free script and execute that file directly through WSL instead. + # name. Write an explicit LF-only, BOM-free script and run that through WSL. $buildScriptPath = Join-Path $LogDirectory "build-dkr-decomp-$Timestamp.sh" $buildLines = @( 'set -euo pipefail', - 'cd "$1"', - 'git submodule update --init --recursive', - '', - 'needs_setup=0', - 'if [ ! -x .venv/bin/python3 ]; then', - ' needs_setup=1', - "elif ! .venv/bin/python3 -c 'import splat' >/dev/null 2>&1; then", - ' needs_setup=1', - 'fi', - '', - 'if [ "$needs_setup" -eq 1 ]; then', - ' echo "[INFO] The DKR Python/tool environment is incomplete; running make setup."', + 'source="$1" tree="$2" revision="$3" jobs="$4"', + '(cd "$source" && git submodule update --init --recursive)', + 'mkdir -p "$tree"', + 'rsync -a --delete --exclude /.git --exclude /build --exclude /asm --exclude /assets \', + ' --exclude /baseroms --exclude /.venv "$source/" "$tree/"', + 'cd "$tree"', + "if [ ! -x .venv/bin/python3 ] || ! .venv/bin/python3 -c 'import splat' >/dev/null 2>&1; then", + ' echo "[INFO] The decomp Python/tool environment is incomplete; running make setup."', ' make setup', 'fi', - '', "if ! .venv/bin/python3 -c 'import splat' >/dev/null 2>&1; then", - ' echo "[ERROR] DKR make setup completed, but the splat module is still unavailable in .venv." >&2', - ' echo "[ERROR] Remove .venv and rerun Build-DKR-Runtime.cmd if this persists." >&2', + ' echo "[ERROR] make setup completed, but splat is still unavailable in $tree/.venv." >&2', ' exit 92', 'fi', - '', - 'make extract', - 'make -j"$2"' + 'make VERSION="v$revision" extract', + 'make VERSION="v$revision" -j"$jobs"' ) - $buildCommand = [string]::Join("`n", $buildLines) + "`n" - $utf8NoBomShell = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($buildScriptPath, $buildCommand, $utf8NoBomShell) - + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($buildScriptPath, ([string]::Join("`n", $buildLines) + "`n"), $utf8NoBom) $wslBuildScript = Convert-ToWslPath $buildScriptPath - Write-Host "[OK] WSL build script: $wslBuildScript" -ForegroundColor Green - & wsl.exe --exec bash $wslBuildScript $wslDkr ([string]$Jobs) - if ($LASTEXITCODE -ne 0) { Fail 'The matching DKR decomp build failed. The log above contains the first real decomp/toolchain error.' } - - $elfPath = Join-Path $DkrSource 'build\dkr.us.v77.elf' - $builtRomPath = Join-Path $DkrSource 'build\dkr.us.v77.z64' - if (-not (Test-Path -LiteralPath $elfPath -PathType Leaf)) { Fail "The DKR build completed without producing the expected ELF: $elfPath" } - if (-not (Test-Path -LiteralPath $builtRomPath -PathType Leaf)) { Fail "The DKR build completed without producing the expected ROM image: $builtRomPath" } - Write-Host "[OK] Matching DKR ELF: $elfPath" -ForegroundColor Green - - Write-Step 'Resolving the DKR static-recompilation entry function' - $entrypointInfo = Resolve-DkrRecompEntrypoint $elfPath $romHeaderEntrypointText - $entrypointText = [string]$entrypointInfo.AddressText - $entrypointSymbol = [string]$entrypointInfo.SymbolName - $useMdebugText = ([bool]$entrypointInfo.UseMdebug).ToString().ToLowerInvariant() + $wslDkr = Convert-ToWslPath $DkrSource + + $elfs = @{} + $builtRoms = @{} + foreach ($revision in $Revisions) { + $tree = Join-Path $ProjectRoot "build\decomp\v$revision" + Write-Step "US v${revision}: ROM" + Invoke-Python "Checking the US v$revision ROM" @( + (Join-Path $PSScriptRoot 'compose_payloads.py'), 'rom', '--revision', "$revision", + $roms[$revision], (Join-Path $tree "baseroms\baserom.us.v$revision.z64")) + + Write-Step "US v${revision}: decomp ELF" + New-Item -ItemType Directory -Force -Path $tree | Out-Null + $wslTree = Convert-ToWslPath $tree + & wsl.exe --exec bash $wslBuildScript $wslDkr $wslTree ([string]$revision) ([string]$Jobs) + if ($LASTEXITCODE -ne 0) { Fail "The US v$revision decomp build failed. The log above contains the first real decomp/toolchain error." } + $elfs[$revision] = Join-Path $tree "build\dkr.us.v$revision.elf" + $builtRoms[$revision] = Join-Path $tree "build\dkr.us.v$revision.z64" + foreach ($output in @($elfs[$revision], $builtRoms[$revision])) { + if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { Fail "The US v$revision decomp build did not produce $output" } + } + Write-Host "[OK] US v$revision ELF: $($elfs[$revision])" -ForegroundColor Green + } Write-Step 'Preparing the static recompilation tool and runtime sources' $modernRuntimePath = Join-Path $ProjectRoot 'extern\n64-modern-runtime' @@ -482,152 +371,29 @@ try { Write-Host "[OK] N64Recomp executable: $($recompiler.FullName)" -ForegroundColor Green Write-Host "[OK] RSPRecomp executable: $($rspRecompiler.FullName)" -ForegroundColor Green - New-Item -ItemType Directory -Force -Path $RuntimeRoot | Out-Null - if ($Force -and (Test-Path -LiteralPath $GeneratedFunctions)) { - Remove-Item -LiteralPath $GeneratedFunctions -Recurse -Force - } - New-Item -ItemType Directory -Force -Path $GeneratedFunctions | Out-Null - - $tomlPath = Join-Path $RuntimeRoot 'dkr.us.v77.generated.toml' - $recompPolicyPath = Join-Path $RuntimeRoot 'dkr.us.v77.recomp-policy.json' - if (-not (Test-Path -LiteralPath $recompPolicyPath -PathType Leaf)) { - Fail "DKR recompilation policy is missing: $recompPolicyPath" - } - $recompPolicy = Get-Content -LiteralPath $recompPolicyPath -Raw | ConvertFrom-Json - if ([int]$recompPolicy.schemaVersion -ne 1) { Fail 'Unsupported DKR recompilation policy schema.' } - $stubToml = (@($recompPolicy.stubs | ForEach-Object { '"' + ([string]$_.name).Replace('"', '\"') + '"' }) -join ', ') - $renamedToml = (@($recompPolicy.renamed | ForEach-Object { '"' + ([string]$_.name).Replace('"', '\"') + '"' }) -join ', ') - $ignoredToml = (@($recompPolicy.ignored | ForEach-Object { '"' + ([string]$_.name).Replace('"', '\"') + '"' }) -join ', ') - $functionSizesToml = (@($recompPolicy.functionSizes | ForEach-Object { - "{ name = `"$([string]$_.name)`", size = $([string]$_.size) }" - }) -join ', ') - $instructionPatchToml = (@($recompPolicy.instructionPatches | ForEach-Object { - "[[patches.instruction]]`nfunc = `"$([string]$_.function)`"`nvram = $([string]$_.vram)`nvalue = $([string]$_.value)" - }) -join "`n`n") - $manualFunctionsToml = (@($recompPolicy.manualFunctions | ForEach-Object { - "{ name = `"$([string]$_.name)`", section = `"$([string]$_.section)`", vram = $([string]$_.vram), size = $([string]$_.size) }" - }) -join ', ') - $functionHookToml = (@($recompPolicy.functionHooks | ForEach-Object { - $hookText = ([string]$_.text).Replace('\', '\\').Replace('"', '\"') - "[[patches.hook]]`nfunc = `"$([string]$_.function)`"`nbefore_vram = $([string]$_.beforeVram)`ntext = `"$hookText`"" - }) -join "`n`n") - $elfToml = $elfPath.Replace('\', '/') - $romToml = $builtRomPath.Replace('\', '/') - $outputToml = $GeneratedFunctions.Replace('\', '/') - $tomlContent = @" -# Generated by Prepare-DKR-Runtime.ps1. -# This is the first-pass DKR CPU recompilation configuration. DKR-specific -# stubs, manual boundaries and instruction patches will be added from the -# emitted diagnostics. -[input] -entrypoint = $entrypointText -use_mdebug = $useMdebugText -elf_path = "$elfToml" -rom_file_path = "$romToml" -output_func_path = "$outputToml" -manual_funcs = [$manualFunctionsToml] -function_sizes = [$functionSizesToml] - -[patches] -stubs = [$stubToml] -renamed = [$renamedToml] -ignored = [$ignoredToml] - -# BEGIN DKR_INSTRUCTION_PATCHES -$instructionPatchToml -# END DKR_INSTRUCTION_PATCHES - -# BEGIN DKR_FUNCTION_HOOKS -$functionHookToml -# END DKR_FUNCTION_HOOKS -"@ - # Windows PowerShell 5.1 writes a UTF-8 BOM when Set-Content -Encoding UTF8 - # is used. Keep the TOML BOM-free because command-line parsers do not all - # accept a BOM before the first key. - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($tomlPath, $tomlContent, $utf8NoBom) - + $payloads = @{} if (-not $SkipRecompile) { - Write-Step 'Generating the first native C translation of DKR' - - # PowerShell transcripts do not reliably capture stdout/stderr from - # native console programs. Run N64Recomp through ProcessStartInfo and - # capture both streams explicitly so the first real game-specific - # blocker always includes its actual diagnostic. - $recompStdoutLog = Join-Path $LogDirectory "n64recomp-$Timestamp.stdout.log" - $recompStderrLog = Join-Path $LogDirectory "n64recomp-$Timestamp.stderr.log" - Remove-Item -LiteralPath $recompStdoutLog, $recompStderrLog -Force -ErrorAction SilentlyContinue - - $recompStartInfo = New-Object System.Diagnostics.ProcessStartInfo - $recompStartInfo.FileName = $recompiler.FullName - $recompStartInfo.Arguments = '"' + $tomlPath + '"' - $recompStartInfo.WorkingDirectory = $RuntimeRoot - $recompStartInfo.UseShellExecute = $false - $recompStartInfo.CreateNoWindow = $true - $recompStartInfo.RedirectStandardOutput = $true - $recompStartInfo.RedirectStandardError = $true - - $recompProcess = New-Object System.Diagnostics.Process - $recompProcess.StartInfo = $recompStartInfo - if (-not $recompProcess.Start()) { - Fail "N64Recomp could not be started: $($recompiler.FullName)" - } - - # Read both streams asynchronously before waiting. This avoids a - # deadlock when a tool writes enough data to fill either pipe. - $recompStdoutTask = $recompProcess.StandardOutput.ReadToEndAsync() - $recompStderrTask = $recompProcess.StandardError.ReadToEndAsync() - $recompProcess.WaitForExit() - $recompExitCode = $recompProcess.ExitCode - $recompStdout = $recompStdoutTask.Result - $recompStderr = $recompStderrTask.Result - $recompProcess.Dispose() - - [System.IO.File]::WriteAllText($recompStdoutLog, $recompStdout, $utf8NoBom) - [System.IO.File]::WriteAllText($recompStderrLog, $recompStderr, $utf8NoBom) - - if (-not [string]::IsNullOrWhiteSpace($recompStdout)) { - Write-Host '' - Write-Host '==> N64Recomp standard output' - Write-Host $recompStdout.TrimEnd() - } - if (-not [string]::IsNullOrWhiteSpace($recompStderr)) { - Write-Host '' - Write-Host '==> N64Recomp diagnostic output' -ForegroundColor Yellow - Write-Host $recompStderr.TrimEnd() -ForegroundColor Yellow + foreach ($revision in $Revisions) { + Write-Step "US v${revision}: recompiled payload" + $work = Join-Path $GeneratedRoot "v$revision" + # Generated output only: never reuse a previous run's files. + if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } + Invoke-Python "The US v$revision payload (log: $work\n64recomp.log)" @( + (Join-Path $PSScriptRoot 'compose_payloads.py'), 'payload', '--revision', "$revision", + '--elf', $elfs[$revision], '--rom', $builtRoms[$revision], + '--recompiler', $recompiler.FullName, '--output', $work) } - if ($recompExitCode -ne 0) { - $diagnosticLines = New-Object System.Collections.Generic.List[string] - foreach ($line in ($recompStderr -split "[`r`n]+")) { - if (-not [string]::IsNullOrWhiteSpace($line)) { - $diagnosticLines.Add($line.Trim()) - } - } - foreach ($line in ($recompStdout -split "[`r`n]+")) { - if (-not [string]::IsNullOrWhiteSpace($line)) { - $diagnosticLines.Add($line.Trim()) - } - } - - $firstDiagnostic = 'N64Recomp returned no diagnostic text.' - if ($diagnosticLines.Count -gt 0) { - $firstDiagnostic = $diagnosticLines[0] - } - - Fail (("N64Recomp failed with exit code {0}. First diagnostic: {1} " + - "Full stdout: {2} Full stderr: {3}") -f ` - $recompExitCode, ` - $firstDiagnostic, ` - $recompStdoutLog, ` - $recompStderrLog) - } - - Write-Host "[OK] N64Recomp completed. stdout: $recompStdoutLog" -ForegroundColor Green - Write-Host "[OK] N64Recomp completed. stderr: $recompStderrLog" -ForegroundColor Green + # The build inputs derived from both payloads (save layout and portrait + # stages, owned engine, Camera++ and model kernels). + Write-Step 'Prepared build inputs' + Invoke-Python 'Preparing the build inputs' @((Join-Path $PSScriptRoot 'prepare_payloads.py')) + } + foreach ($revision in $Revisions) { + $payloads[$revision] = Join-Path $PreparedRoot "generated-v$revision" } - $functionFiles = @(Get-ChildItem -LiteralPath $GeneratedFunctions -File -Recurse -Include '*.c','*.cpp' -ErrorAction SilentlyContinue) + $functionFiles = @(Get-ChildItem -LiteralPath $payloads[77] -File -Include '*.c' -Recurse -ErrorAction SilentlyContinue) $probeExecutable = $null $probeBuilt = $false @@ -682,12 +448,8 @@ $functionHookToml $state = [ordered]@{ schemaVersion = 2 preparedUtc = [DateTime]::UtcNow.ToString('o') - sourceRomSha1 = $ExpectedSha1 - romHeaderEntrypoint = $romHeaderEntrypointText - recompEntrypoint = $entrypointText - recompEntrypointSymbol = $entrypointSymbol - useMdebug = [bool]$entrypointInfo.UseMdebug - dkrElf = $elfPath + dkrElf = [ordered]@{ v77 = $elfs[77]; v80 = $elfs[80] } + preparedPayloads = [ordered]@{ v77 = $payloads[77]; v80 = $payloads[80] } generatedFunctionFiles = $functionFiles.Count n64RecompExecutable = $recompiler.FullName rspRecompExecutable = $rspRecompiler.FullName @@ -695,16 +457,15 @@ $functionHookToml runtimeProbeExecutable = $probeExecutablePath rt64BuildRequested = [bool]$BuildRenderer dependencies = $resolved - nextBoundary = 'Register DKR with librecomp, implement SDL audio/input callbacks, recompile F3DDKR and audio RSP microcode, then bind the RT64 render context.' } $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ResolvedPath -Encoding UTF8 Write-Host '' - Write-Host '[OK] DKR runtime preparation reached the generated-code and N64ModernRuntime compilation boundary.' -ForegroundColor Green - Write-Host "Generated function files: $($functionFiles.Count)" + Write-Host "[OK] Payloads ready in $GeneratedRoot, build inputs in $PreparedRoot" -ForegroundColor Green + Write-Host "Generated function files (US v1.0): $($functionFiles.Count)" Write-Host "State report: $ResolvedPath" Write-Host 'No ROM or extracted game asset is added to source control or the distributable ZIP.' - Write-Host 'The remaining boot work is DKR game registration, callbacks, F3DDKR/audio RSP recompilation and RT64 rendering.' + Write-Host 'Next: Build-Windows.cmd builds from build\prepared.' Stop-Transcript | Out-Null exit 0 } catch { diff --git a/scripts/compose_payloads.py b/scripts/compose_payloads.py new file mode 100644 index 00000000..9118c77f --- /dev/null +++ b/scripts/compose_payloads.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Compose and recompile one revision's production CPU payload. + +The step both platform prepare scripts (Prepare-DKR-Runtime-macOS.sh and +Prepare-DKR-Runtime.ps1) share between building the decomp and running +scripts/prepare_payloads.py: + + compose_payloads.py rom --revision REV SOURCE TARGET + normalise a user's ROM to big-endian and check it is the retail dump + compose_payloads.py payload --revision REV --elf ELF --rom Z64 + --recompiler N64RECOMP --output DIR + compose the production policy (the versioned base policy plus the + legacy mod, track menu, character and character menu fragments, with + the scene runtime), write the N64Recomp config and run N64Recomp: + DIR/policy.json, DIR/dkr.us.v.toml, DIR/RecompiledFuncs and + DIR/n64recomp.log + +prepare_payloads.py reads DIR/RecompiledFuncs and DIR/dkr.us.v.toml. +Exit status: 0 on success, 1 when a step fails, 2 on usage errors. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import hashlib +from pathlib import Path +import subprocess +import sys +from typing import cast + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME = ROOT / "runtime-recomp" +# Retail SHA-1 of each supported US dump, and its entry point (mainproc). +RETAIL_SHA1 = { + 77: "0cb115d8716dbbc2922fda38e533b9fe63bb9670", + 80: "6d96743d46f8c0cd0edb0ec5600b003c89b93755", +} +ENTRYPOINT = {77: "0x80065D40", 80: "0x80065F80"} + + +class ComposeError(Exception): + pass + + +def normalise_rom(source: Path, target: Path, revision: int) -> str: + """Write SOURCE to TARGET in z64 byte order once its SHA-1 is the retail + dump's; returns the SHA-1. TARGET is only rewritten when it differs.""" + data = bytearray(source.read_bytes()) + magic = bytes(data[:4]).hex() + if magic == "37804012": + data[0::2], data[1::2] = data[1::2], data[0::2] + elif magic == "40123780": + for i in range(0, len(data) - 3, 4): + data[i : i + 4] = data[i : i + 4][::-1] + elif magic != "80371240": + raise ComposeError(f"{source}: not an N64 ROM (magic {magic})") + digest = hashlib.sha1(data).hexdigest() + if digest != RETAIL_SHA1[revision]: + raise ComposeError(f"{source}: SHA-1 {digest}, expected {RETAIL_SHA1[revision]} (US v{revision})") + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists() or target.read_bytes() != bytes(data): + target.write_bytes(data) + return digest + + +def run(command: Sequence[str], what: str, log: Path | None = None) -> None: + done = subprocess.run(list(command), cwd=RUNTIME, capture_output=True, text=True) + if log is not None: + log.write_text(done.stdout + done.stderr, encoding="utf-8") + if done.returncode: + detail = (done.stderr or done.stdout).strip().splitlines()[-20:] + raise ComposeError(f"{what} failed ({done.returncode}):\n" + "\n".join(detail)) + + +def compose(revision: int, elf: Path, rom: Path, recompiler: Path, output: Path) -> None: + for needed in (elf, rom, recompiler): + if not needed.is_file(): + raise ComposeError(f"missing {needed}") + functions = output / "RecompiledFuncs" + functions.mkdir(parents=True, exist_ok=True) + policy, config = output / "policy.json", output / f"dkr.us.v{revision}.toml" + fragments = ( + ("--fragment", "legacy-mods"), + ("--track-menu", "legacy-track-menu"), + ("--characters", "legacy-characters"), + ("--character-menu", "legacy-character-menu"), + ) + command = [ + sys.executable, + str(ROOT / "scripts/compose_legacy_mod_policy.py"), + "--policy", + str(RUNTIME / f"dkr.us.v{revision}.recomp-policy.json"), + ] + for flag, name in fragments: + command += [flag, str(RUNTIME / f"{name}.v{revision}.recomp-fragment.json")] + command += ["--scene-runtime", "--elf", str(elf), "--output", str(policy)] + run(command, f"v{revision} policy composition") + run( + [ + sys.executable, + str(ROOT / "scripts/generate_recomp_config.py"), + "--policy", + str(policy), + "--elf", + str(elf), + "--rom", + str(rom), + "--output-functions", + str(functions), + "--entrypoint", + ENTRYPOINT[revision], + "--output", + str(config), + ], + f"v{revision} N64Recomp config", + ) + run([str(recompiler), str(config)], f"v{revision} N64Recomp", output / "n64recomp.log") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + rom = sub.add_parser("rom", help="normalise and check a retail ROM") + rom.add_argument("--revision", type=int, choices=(77, 80), required=True) + rom.add_argument("source", type=Path) + rom.add_argument("target", type=Path) + payload = sub.add_parser("payload", help="compose the policy and run N64Recomp") + payload.add_argument("--revision", type=int, choices=(77, 80), required=True) + payload.add_argument("--elf", type=Path, required=True) + payload.add_argument("--rom", type=Path, required=True, help="the decomp's built z64") + payload.add_argument("--recompiler", type=Path, required=True) + payload.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + revision = cast(int, args.revision) + try: + if args.command == "rom": + source = cast(Path, args.source) + digest = normalise_rom(source, cast(Path, args.target), revision) + print(f"[OK] {source.name} verified ({digest})") + else: + output = cast(Path, args.output).resolve() + compose( + revision, + cast(Path, args.elf).resolve(), + cast(Path, args.rom).resolve(), + cast(Path, args.recompiler).resolve(), + output, + ) + print(f"[OK] {output / 'RecompiledFuncs'}") + except (ComposeError, OSError) as error: + print(f"compose_payloads: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From e556cb0d32fc943f381f1d965136ea402d5734a3 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 26 Oct 2026 07:30:44 -0500 Subject: [PATCH 37/62] Delete the layer pipelines, move the service backends to services/ (B8) patches/ keeps the manifest and registered patches (release-prep aside). Water natives move to runtime-recomp/src/water; the engine identity digests to legacy_identity.hpp. services/ holds the directory, Mod Store and library backends, each with a build.py over its external base, tests and a CI job. open-items lists every removed or renamed entrypoint. --- .../workflows/services-library-workflow.yml | 39 + .github/workflows/services-mod-store.yml | 28 + .../workflows/services-online-directory.yml | 50 + .gitignore | 3 + .pre-commit-config.yaml | 2 + architecture/pattern-guard-baseline.txt | 859 ---- docs/ARM-PLATFORM-VALIDATION.md | 6 +- docs/BUILDING.md | 6 +- docs/GRAPHICS.md | 2 +- docs/MOD_LIBRARY_ADMIN.md | 8 +- docs/PUBLIC_LOBBIES.md | 6 +- docs/TEXTURE_PACKS.md | 5 +- docs/contracts/ONLINE_IDENTITY.md | 5 + docs/fold-in/open-items.md | 44 +- docs/history/RELEASE-VALIDATION-1.0.8.md | 4 +- patches/achievements/ACHIEVEMENTS.txt | 70 - patches/achievements/PIPELINE.txt | 79 - patches/achievements/ReleaseOverlay.cmake | 68 - patches/achievements/audit.py | 67 - patches/achievements/configure.py | 16 - patches/achievements/inspect_rom.py | 57 - .../native/achievement_catalogue.cpp | 41 - .../achievements/native/achievement_ui.inl | 182 - patches/achievements/native/achievements.cpp | 228 -- patches/achievements/native/achievements.hpp | 54 - patches/achievements/package.py | 37 - patches/achievements/pipeline.py | 143 - patches/all-player-detail/PIPELINE.txt | 56 - patches/all-player-detail/PLAYTEST.txt | 53 - .../all-player-detail/ReleaseOverlay.cmake | 112 - patches/all-player-detail/audit.py | 95 - patches/all-player-detail/configure.py | 15 - .../native/detail_contract.hpp | 45 - .../native/detail_observer.inl | 46 - patches/all-player-detail/package.py | 31 - patches/all-player-detail/pipeline.py | 230 -- patches/android/README.md | 61 - patches/camera-plus/CMakeLists.txt | 57 - patches/camera-plus/IMPLEMENTATION.md | 44 - patches/camera-plus/NativeOverlay.cmake | 95 - patches/camera-plus/OpenXR.cmake | 54 - patches/camera-plus/README.md | 87 - patches/camera-plus/SceneKernel.cmake | 29 - patches/camera-plus/camera_editor_ui.inl | 126 - patches/camera-plus/inspect_scene_closure.py | 77 - patches/camera-plus/integration_pipeline.py | 270 -- patches/camera-plus/mod/dkr.json | 10 - patches/camera-plus/mod/main.c | 207 - patches/camera-plus/mod/settings.json | 77 - patches/camera-plus/native/camera_actions.cpp | 55 - patches/camera-plus/native/camera_actions.hpp | 30 - patches/camera-plus/native/camera_kernel.c | 289 -- patches/camera-plus/native/camera_kernel.h | 24 - .../camera-plus/native/camera_kernel_guest.h | 7 - .../native/camera_presentation.cpp | 180 - .../native/camera_presentation.hpp | 42 - patches/camera-plus/native/camera_rt64.cpp | 124 - patches/camera-plus/native/camera_rt64.hpp | 37 - patches/camera-plus/native/camera_runtime.cpp | 51 - patches/camera-plus/native/camera_runtime.hpp | 23 - patches/camera-plus/native/camera_scene.cpp | 103 - patches/camera-plus/native/camera_scene.hpp | 24 - patches/camera-plus/native/camera_session.cpp | 84 - patches/camera-plus/native/camera_session.hpp | 43 - .../camera-plus/native/camera_settings.cpp | 111 - .../camera-plus/native/camera_settings.hpp | 62 - .../camera-plus/native/camera_snapshot.cpp | 89 - .../camera-plus/native/camera_snapshot.hpp | 24 - patches/camera-plus/native/camera_xr.cpp | 419 -- patches/camera-plus/native/camera_xr.hpp | 40 - patches/camera-plus/package_playtest.py | 81 - patches/camera-plus/sdk/dkr/presentation.h | 122 - patches/camera-plus/vr_device_patch.py | 175 - patches/camera-shadow/NativeOverlay.cmake | 65 - patches/camera-shadow/RELEASE-NOTES.txt | 13 - patches/camera-shadow/camera_shadow_kernel.c | 53 - patches/camera-shadow/camera_shadow_kernel.h | 20 - patches/camera-shadow/package.py | 24 - patches/camera-shadow/pipeline.py | 180 - patches/camera-shadow/shadow_pass.inl | 74 - .../character-library-portraits/PIPELINE.txt | 19 - .../ReleaseOverlay.cmake | 51 - .../character-library-portraits/configure.py | 12 - .../native/character_portraits.cpp | 113 - .../native/character_portraits.hpp | 24 - .../native/character_portraits_ui.inl | 41 - .../character-library-portraits/pipeline.py | 41 - .../character-library-portraits/run_checks.py | 16 - .../tests/inspect_installed.py | 21 - .../tests/portrait_checks.cpp | 55 - .../character-portraits/NativeOverlay.cmake | 71 - patches/character-portraits/README.md | 91 - .../character-portraits/inspect_packages.py | 27 - .../character-portraits/inspect_sources.py | 63 - .../load_reviewed_portrait.inc | 33 - .../character-portraits/package-windows.ps1 | 17 - .../IMPLEMENTATION.txt | 59 - patches/checkpoint-clock-repair/PLAYTEST.txt | 33 - .../ReleaseOverlay.cmake | 45 - patches/checkpoint-clock-repair/audit.py | 95 - patches/checkpoint-clock-repair/configure.py | 13 - patches/checkpoint-clock-repair/package.py | 29 - patches/checkpoint-clock-repair/pipeline.py | 34 - patches/checkpoint-clock-repair/transforms.py | 52 - patches/credits-viewport/NativeOverlay.cmake | 37 - patches/credits-viewport/README.md | 78 - patches/credits-viewport/credit_fade.inl | 34 - patches/credits-viewport/credit_match.inl | 73 - patches/credits-viewport/credits_sky_rt64.hpp | 70 - patches/credits-viewport/package.py | 76 - patches/credits-viewport/pipeline.py | 201 - patches/custom-vehicles/PLAYTEST.txt | 153 - patches/custom-vehicles/REPAIR-NOTES.txt | 384 -- patches/custom-vehicles/ReleaseOverlay.cmake | 236 -- patches/custom-vehicles/audit_characters.py | 187 - .../character_ownership_review.py | 92 - patches/custom-vehicles/configure109.py | 23 - patches/custom-vehicles/guest_pipeline.py | 130 - patches/custom-vehicles/inspect_base.py | 91 - .../inspect_character_components.py | 65 - patches/custom-vehicles/inspect_inputs.py | 42 - patches/custom-vehicles/inspect_placement.py | 36 - patches/custom-vehicles/inspect_scale.py | 55 - patches/custom-vehicles/inspect_seating.py | 82 - patches/custom-vehicles/music_pipeline.py | 39 - .../native/asset_bus_mount.inl | 69 - .../custom-vehicles/native/resident_music.cpp | 103 - .../custom-vehicles/native/resident_music.hpp | 47 - .../native/resident_music_prepare.inl | 34 - .../native/vehicle_base_hashes.hpp | 67 - .../native/vehicle_binding.cpp | 31 - .../native/vehicle_binding.hpp | 12 - .../native/vehicle_character_masks.hpp | 78 - .../native/vehicle_library.cpp | 270 -- .../native/vehicle_library.hpp | 46 - .../custom-vehicles/native/vehicle_online.hpp | 14 - .../native/vehicle_package.cpp | 345 -- .../native/vehicle_package.hpp | 61 - .../native/vehicle_protocol.hpp | 15 - .../native/vehicle_resources.cpp | 280 -- .../native/vehicle_resources.hpp | 38 - .../custom-vehicles/native/vehicle_rt64.cpp | 335 -- .../custom-vehicles/native/vehicle_rt64.hpp | 43 - .../native/vehicle_seat_alignment.hpp | 36 - patches/custom-vehicles/native/vehicle_ui.inl | 206 - .../native/vehicle_wheel_interpolation.hpp | 170 - .../custom-vehicles/native/vehicle_world.cpp | 436 --- .../custom-vehicles/native/vehicle_world.hpp | 64 - patches/custom-vehicles/online_pipeline.py | 111 - patches/custom-vehicles/package_playtest.py | 86 - patches/custom-vehicles/pipeline.py | 337 -- patches/detail-records-repair/PLAYTEST.txt | 56 - .../ReleaseOverlay.cmake | 77 - patches/detail-records-repair/audit.py | 67 - patches/detail-records-repair/configure.py | 15 - .../native/detail_batch.inl | 34 - .../native/detail_metrics.hpp | 26 - .../native/detail_texture_cache.inl | 49 - .../native/records_guest.inl | 68 - patches/detail-records-repair/package.py | 31 - patches/detail-records-repair/pipeline.py | 107 - patches/graphics-handoff/PIPELINE.txt | 46 - patches/graphics-handoff/PLAYTEST.txt | 31 - patches/graphics-handoff/ReleaseOverlay.cmake | 62 - patches/graphics-handoff/audit.py | 72 - patches/graphics-handoff/configure.py | 12 - patches/graphics-handoff/finalize.py | 71 - .../native/producer_snapshot.cpp | 37 - .../native/producer_snapshot.hpp | 97 - patches/graphics-handoff/package.py | 29 - patches/graphics-handoff/pipeline.py | 76 - patches/graphics-handoff/run_checks.py | 21 - patches/graphics-handoff/tests/copy_cost.cpp | 29 - .../graphics-handoff/tests/handoff_tests.cpp | 107 - patches/invite-handoff/IMPLEMENTATION.txt | 43 - patches/invite-handoff/PLAYTEST.txt | 78 - patches/invite-handoff/ReleaseOverlay.cmake | 48 - patches/invite-handoff/audit.py | 55 - patches/invite-handoff/configure.py | 15 - .../invite-handoff/native/invite_handoff.cpp | 39 - .../invite-handoff/native/invite_handoff.hpp | 29 - patches/invite-handoff/native/invite_ui.inl | 247 -- .../invite-handoff/native/release_inputs.inl | 18 - patches/invite-handoff/package.py | 30 - patches/invite-handoff/pipeline.py | 158 - patches/launcher-polish/NativeOverlay.cmake | 41 - patches/launcher-polish/pipeline.py | 97 - patches/launcher-polish/support_actions.inl | 67 - patches/library-workflow/RELEASE-NOTES.txt | 31 - patches/library-workflow/pipeline.py | 157 - patches/library-workflow/transforms.py | 180 - patches/liquid-streaming/NativeOverlay.cmake | 47 - patches/liquid-streaming/OWNER-NOTES.txt | 48 - .../liquid_replacement_gate.hpp | 119 - patches/liquid-streaming/package.py | 47 - patches/liquid-streaming/pipeline.py | 91 - .../liquid-streaming/water_residency_rt64.hpp | 111 - patches/lobby-link-repair/IMPLEMENTATION.txt | 94 - patches/lobby-link-repair/PLAYTEST.txt | 35 - .../lobby-link-repair/ReleaseOverlay.cmake | 49 - patches/lobby-link-repair/audit.py | 127 - patches/lobby-link-repair/configure.py | 13 - patches/lobby-link-repair/package.py | 29 - patches/lobby-link-repair/pipeline.py | 48 - patches/lobby-link-repair/transforms.py | 140 - patches/lobby-restoration/IMPLEMENTATION.txt | 66 - patches/lobby-restoration/PLAYTEST.txt | 48 - .../lobby-restoration/ReleaseOverlay.cmake | 52 - patches/lobby-restoration/audit.py | 123 - patches/lobby-restoration/configure.py | 13 - .../native/lobby_adapter.cpp | 38 - .../native/lobby_adapter.hpp | 23 - .../native/lobby_context.inl | 90 - .../native/lobby_controls.inl | 231 -- .../native/remove_member.inl | 30 - patches/lobby-restoration/package.py | 33 - patches/lobby-restoration/pipeline.py | 45 - patches/lobby-restoration/transforms.py | 111 - patches/lobby-stability/IMPLEMENTATION.txt | 126 - patches/lobby-stability/PLAYTEST.txt | 54 - patches/lobby-stability/ReleaseOverlay.cmake | 112 - patches/lobby-stability/audit.py | 171 - patches/lobby-stability/configure.py | 22 - .../lobby-stability/native/cache_repair.hpp | 32 - .../lobby-stability/native/diagnostics.hpp | 78 - patches/lobby-stability/native/policy.hpp | 86 - .../native/task_submission.cpp | 23 - patches/lobby-stability/package.py | 48 - patches/lobby-stability/pipeline.py | 136 - patches/lobby-stability/probe_worker.py | 32 - patches/lobby-stability/tests/CMakeLists.txt | 19 - .../lobby-stability/tests/policy_tests.cpp | 56 - patches/lobby-stability/tests/sp_fixture.cpp | 15 - patches/lobby-stability/tests/sp_fixture.hpp | 9 - .../tests/task_submission_tests.cpp | 31 - patches/lobby-stability/transforms.py | 276 -- patches/memory-stability/DIAGNOSIS.txt | 69 - patches/memory-stability/PIPELINE.txt | 53 - patches/memory-stability/PLAYTEST.txt | 28 - patches/memory-stability/ReleaseOverlay.cmake | 126 - patches/memory-stability/audit.py | 86 - patches/memory-stability/configure.py | 12 - patches/memory-stability/finalize.py | 71 - .../memory-stability/native/primary_heap.h | 19 - patches/memory-stability/package.py | 27 - patches/memory-stability/pipeline.py | 107 - patches/memory-stability/run_checks.py | 33 - .../tests/allocator_tests.cpp | 121 - .../tests/owned_entry_tests.cpp | 28 - patches/memory-stability/verify_archive.py | 35 - patches/mod-runtime-repair/PLAYTEST.txt | 32 - .../mod-runtime-repair/ReleaseOverlay.cmake | 160 - .../mod-runtime-repair/SDK-WORK-POLICY.txt | 34 - patches/mod-runtime-repair/WORK-NOTES.txt | 36 - patches/mod-runtime-repair/configure.py | 20 - patches/mod-runtime-repair/guest_pipeline.py | 70 - patches/mod-runtime-repair/inspect_sources.py | 87 - .../native/magic_code_execution_policy.hpp | 181 - .../native/render_heap_support.cpp | 64 - patches/mod-runtime-repair/package.py | 47 - patches/mod-runtime-repair/pipeline.py | 219 -- patches/mod-store/INSTALLATION-MANAGER.md | 195 - patches/mod-store/NativeOverlay.cmake | 58 - patches/mod-store/RELEASE-NOTES.txt | 170 - patches/mod-store/backend/backfill_library.py | 96 - patches/mod-store/backend/release_manifest.py | 57 - patches/mod-store/frontend_pipeline.py | 42 - patches/mod-store/native/mod_store.cpp | 2139 ---------- patches/mod-store/native/mod_store.hpp | 87 - .../mod-store/native/mod_store_install.cpp | 2232 ----------- .../mod-store/native/mod_store_install.hpp | 70 - .../native/mod_store_management_ui.inl | 585 --- patches/mod-store/native/mod_store_names.cpp | 272 -- patches/mod-store/native/mod_store_style.inl | 186 - patches/mod-store/native/mod_store_ui.inl | 1956 --------- .../mod-store/native/store_account_page.inl | 102 - patches/mod-store/native/store_page_shell.inl | 172 - .../native/store_player_activity.inl | 269 -- .../mod-store/native/store_player_details.inl | 185 - .../mod-store/native/store_player_library.inl | 361 -- .../native/store_polish_controls.inl | 186 - .../mod-store/native/store_polish_modals.inl | 163 - .../mod-store/native/store_staff_pages.inl | 637 --- .../native/store_texture_adapter.inl | 79 - .../mod-store/native/store_track_adapter.inl | 47 - .../mod-store/native/store_track_locator.inl | 107 - patches/mod-store/native/store_track_zip.inl | 178 - patches/mod-store/package.py | 154 - patches/mod-store/package_linux.py | 122 - patches/mod-store/pipeline.py | 283 -- patches/mod-store/texture_names_pipeline.py | 82 - patches/music-preview-repair/PLAYTEST.txt | 30 - patches/music-preview-repair/REPAIR.txt | 58 - .../music-preview-repair/ReleaseOverlay.cmake | 83 - patches/music-preview-repair/audit.py | 106 - patches/music-preview-repair/configure.py | 23 - patches/music-preview-repair/package.py | 48 - patches/music-preview-repair/pipeline.py | 132 - .../IMPLEMENTATION.txt | 56 - patches/offline-retention-repair/PLAYTEST.txt | 55 - .../ReleaseOverlay.cmake | 70 - patches/offline-retention-repair/audit.py | 90 - patches/offline-retention-repair/configure.py | 15 - patches/offline-retention-repair/handoff.py | 88 - .../offline-retention-repair/lobby_layout.py | 49 - .../native/lobby_sidebar.inl | 64 - .../native/offline_keep.hpp | 6 - .../native/offline_keep.inl | 183 - patches/offline-retention-repair/package.py | 27 - patches/offline-retention-repair/pipeline.py | 80 - .../offline-retention-repair/run_checks.py | 22 - .../tests/retention_tests.cpp | 144 - patches/online-directory/ReleaseOverlay.cmake | 58 - patches/online-directory/audit.py | 92 - patches/online-directory/backend_pipeline.py | 61 - patches/online-directory/configure.py | 13 - patches/online-directory/delivery.py | 116 - .../online-directory/native/account_bind.inl | 32 - .../native/admission_contract.hpp | 43 - .../online-directory/native/directory_tls.hpp | 61 - .../online-directory/native/directory_ui.inl | 73 - .../online-directory/native/linux_keyring.hpp | 56 - .../native/online_directory.cpp | 283 -- .../native/online_directory.hpp | 70 - .../online-directory/native/owner_pump.inl | 125 - .../online-directory/native/owner_state.hpp | 43 - patches/online-directory/package.py | 28 - patches/online-directory/pipeline.py | 61 - patches/online-directory/transforms.py | 226 -- patches/online-performance/FINDINGS.txt | 62 - patches/online-performance/PIPELINE.txt | 40 - patches/online-performance/PLAYTEST.txt | 24 - .../online-performance/ReleaseOverlay.cmake | 121 - patches/online-performance/audit.py | 86 - patches/online-performance/build.py | 17 - patches/online-performance/configure.py | 12 - patches/online-performance/finalize.py | 61 - .../native/menu_notifications.h | 6 - .../online-performance/native/runtime_log.hpp | 39 - .../native/setup_policy.hpp | 38 - patches/online-performance/package.py | 30 - patches/online-performance/pipeline.py | 138 - patches/online-performance/run_checks.py | 22 - .../tests/menu_fixture_main.c | 15 - .../tests/menu_fixture_prefix.c | 17 - .../online-performance/tests/party_checks.inl | 45 - .../online-performance/tests/setup_checks.cpp | 84 - patches/online-vehicle-menu/PLAYTEST.txt | 37 - patches/online-vehicle-menu/REPAIR.txt | 49 - .../online-vehicle-menu/ReleaseOverlay.cmake | 77 - patches/online-vehicle-menu/audit.py | 62 - patches/online-vehicle-menu/configure.py | 15 - .../native/owned_vehicle_calls.inl | 16 - .../native/owned_vehicle_menu.hpp | 10 - patches/online-vehicle-menu/package.py | 31 - patches/online-vehicle-menu/pipeline.py | 100 - .../party-departure-repair/IMPLEMENTATION.txt | 75 - patches/party-departure-repair/PLAYTEST.txt | 34 - .../ReleaseOverlay.cmake | 50 - patches/party-departure-repair/audit.py | 103 - patches/party-departure-repair/configure.py | 13 - .../native/close_notice.inl | 21 - .../native/departure.hpp | 31 - .../native/retire_member.inl | 36 - patches/party-departure-repair/package.py | 29 - patches/party-departure-repair/pipeline.py | 34 - patches/party-departure-repair/transforms.py | 101 - patches/party-start-repair/IMPLEMENTATION.txt | 73 - patches/party-start-repair/PLAYTEST.txt | 40 - .../party-start-repair/ReleaseOverlay.cmake | 50 - patches/party-start-repair/audit.py | 127 - patches/party-start-repair/configure.py | 13 - .../native/client_start.inl | 54 - .../party-start-repair/native/host_start.inl | 88 - .../party-start-repair/native/start_gate.hpp | 43 - patches/party-start-repair/package.py | 29 - patches/party-start-repair/pipeline.py | 34 - patches/party-start-repair/transforms.py | 155 - .../performance-surgical/NativeOverlay.cmake | 51 - patches/performance-surgical/OWNER-CHECKS.txt | 102 - .../Record-Performance.cmd | 9 - .../performance-surgical/inspect_sources.py | 60 - patches/performance-surgical/mip_scratch.inl | 19 - patches/performance-surgical/package.py | 69 - .../performance_observation.hpp | 65 - .../performance_switches.hpp | 17 - patches/performance-surgical/pipeline.py | 189 - .../presentation_pacer.hpp | 82 - .../reusable_flow_map.hpp | 66 - .../texture_binding_index.hpp | 31 - .../presentation-polish/NativeOverlay.cmake | 67 - patches/presentation-polish/README.md | 119 - .../intro_caption_rt64.hpp | 217 - .../presentation-polish/intro_sky_rt64.hpp | 62 - .../native_resource_safety.hpp | 32 - patches/presentation-polish/package.py | 130 - patches/presentation-polish/pipeline.py | 261 -- .../water_residency_rt64.hpp | 110 - .../race-release-repair/IMPLEMENTATION.txt | 63 - patches/race-release-repair/PLAYTEST.txt | 51 - .../race-release-repair/ReleaseOverlay.cmake | 90 - patches/race-release-repair/audit.py | 110 - patches/race-release-repair/configure.py | 22 - patches/race-release-repair/handoff.py | 121 - patches/race-release-repair/package.py | 48 - patches/race-release-repair/pipeline.py | 57 - patches/race-release-repair/run_checks.py | 38 - .../tests/runtime_tests.cpp | 202 - patches/race-release-repair/transforms.py | 45 - patches/render-stability/PIPELINE.txt | 54 - patches/render-stability/PLAYTEST.txt | 40 - patches/render-stability/ReleaseOverlay.cmake | 82 - patches/render-stability/audit.py | 64 - patches/render-stability/configure.py | 12 - patches/render-stability/finalize.py | 73 - .../native/liquid_replacement_gate.hpp | 151 - .../native/render_provenance.hpp | 57 - .../native/water_residency_rt64.hpp | 138 - patches/render-stability/package.py | 32 - patches/render-stability/pipeline.py | 105 - patches/render-stability/run_checks.py | 24 - .../tests/residency_tests.cpp | 75 - .../tests/selection_tests.cpp | 91 - .../tests/stubs/enhanced_water_rt64.hpp | 22 - .../tests/stubs/performance_observation.hpp | 4 - .../tests/stubs/polish_symbols.hpp | 6 - .../tests/stubs/render/rt64_texture_cache.h | 34 - .../tests/stubs/rice_texture_pack_policy.hpp | 2 - patches/review-editor/BUILDING.txt | 43 - patches/review-editor/ReleaseOverlay.cmake | 61 - patches/review-editor/configure109.py | 24 - patches/review-editor/native/review_form.inl | 47 - .../review-editor/native/wrapped_input.inl | 198 - patches/review-editor/pipeline.py | 192 - .../rollback-connection/IMPLEMENTATION.txt | 106 - patches/rollback-connection/PLAYTEST.txt | 42 - .../rollback-connection/ReleaseOverlay.cmake | 52 - patches/rollback-connection/audit.py | 123 - patches/rollback-connection/configure.py | 13 - .../native/connection_ui.inl | 97 - .../native/owner_context.inl | 41 - .../rollback-connection/native/owner_send.inl | 22 - .../native/rollback_check.cpp | 225 -- .../native/rollback_check.hpp | 48 - patches/rollback-connection/package.py | 29 - patches/rollback-connection/pipeline.py | 54 - patches/rollback-connection/transforms.py | 77 - patches/rollback-only/IMPLEMENTATION.txt | 40 - patches/rollback-only/PLAYTEST.txt | 65 - patches/rollback-only/ReleaseOverlay.cmake | 62 - patches/rollback-only/audit.py | 85 - patches/rollback-only/configure.py | 15 - .../rollback-only/native/host_settings.inl | 38 - .../rollback-only/native/rollback_policy.hpp | 50 - patches/rollback-only/package.py | 32 - patches/rollback-only/pipeline.py | 239 -- .../rollback-scoreboard/IMPLEMENTATION.txt | 96 - patches/rollback-scoreboard/PLAYTEST.txt | 44 - .../rollback-scoreboard/ReleaseOverlay.cmake | 50 - patches/rollback-scoreboard/audit.py | 119 - patches/rollback-scoreboard/configure.py | 13 - .../rollback-scoreboard/native/assessment.hpp | 59 - .../rollback-scoreboard/native/report_ui.inl | 135 - .../native/scoreboard_wire.inl | 69 - patches/rollback-scoreboard/package.py | 28 - patches/rollback-scoreboard/pipeline.py | 52 - patches/rollback-scoreboard/transforms.py | 74 - patches/save-layout/README.md | 66 - patches/save-layout/inspect_packages.py | 97 - patches/save-layout/inspect_sources.py | 95 - patches/save-layout/package-linux.sh | 14 - patches/save-layout/package-windows.ps1 | 30 - patches/scene-preparation/PLAYTEST.txt | 41 - patches/scene-preparation/REPAIR.txt | 44 - .../scene-preparation/ReleaseOverlay.cmake | 76 - patches/scene-preparation/audit.py | 65 - patches/scene-preparation/configure.py | 15 - .../native/scene_load_work.hpp | 16 - patches/scene-preparation/package.py | 31 - patches/scene-preparation/pipeline.py | 54 - patches/sdk2/BIBLE.txt | 1299 ------ patches/sdk2/DEV-MENU-IMPORT-FIX.txt | 27 - patches/sdk2/IMPLEMENTATION-PLAN.txt | 385 -- patches/sdk2/NativeOverlay.cmake | 129 - patches/sdk2/PLAYTEST-README.txt | 53 - patches/sdk2/RELEASE-NOTES.txt | 69 - patches/sdk2/SDK-README.txt | 49 - patches/sdk2/STARTUP-FIX-README.txt | 31 - patches/sdk2/WORK-STATUS.txt | 79 - patches/sdk2/android_startup_init.gradle | 10 - patches/sdk2/catalogue_pipeline.py | 66 - patches/sdk2/compatibility.py | 19 - patches/sdk2/control_pipeline.py | 74 - patches/sdk2/distribution.py | 182 - patches/sdk2/documentation.py | 86 - patches/sdk2/documentation_qa.py | 23 - patches/sdk2/finalize_delivery.py | 74 - patches/sdk2/inspect_delivery.py | 89 - patches/sdk2/lobby_pipeline.py | 144 - patches/sdk2/mod/dkr.json | 14 - patches/sdk2/mod/documents.inl | 58 - patches/sdk2/mod/main.c | 257 -- patches/sdk2/mod/mod.toml | 12 - patches/sdk2/mod/tools.py | 89 - patches/sdk2/mod/track.inl | 48 - patches/sdk2/native/sdk2_asset_inventory.inl | 16 - patches/sdk2/native/sdk2_bridge.cpp | 136 - patches/sdk2/native/sdk2_bridge.hpp | 79 - patches/sdk2/native/sdk2_broker.inl | 73 - patches/sdk2/native/sdk2_camera.inl | 42 - patches/sdk2/native/sdk2_collision.inl | 123 - patches/sdk2/native/sdk2_command.hpp | 17 - patches/sdk2/native/sdk2_debug.inl | 46 - patches/sdk2/native/sdk2_documents.inl | 75 - patches/sdk2/native/sdk2_operations.inl | 197 - patches/sdk2/native/sdk2_overrides.inl | 26 - patches/sdk2/native/sdk2_properties.inl | 49 - patches/sdk2/native/sdk2_reload.inl | 40 - patches/sdk2/native/sdk2_schema.hpp | 48 - patches/sdk2/native/sdk2_settings.inl | 19 - patches/sdk2/native/sdk2_simulation.inl | 55 - patches/sdk2/native/sdk2_tools_camera.inl | 41 - patches/sdk2/native/sdk2_tools_console.inl | 85 - patches/sdk2/native/sdk2_tools_documents.inl | 67 - patches/sdk2/native/sdk2_tools_reload.inl | 24 - patches/sdk2/native/sdk2_tools_ui.inl | 297 -- patches/sdk2/native/sdk2_tools_workspace.inl | 100 - patches/sdk2/native/sdk2_track_catalogue.inl | 64 - patches/sdk2/native/sdk2_tuning.inl | 62 - patches/sdk2/native/sdk2_world.inl | 482 --- patches/sdk2/network_pipeline.py | 132 - patches/sdk2/package_import_fix.py | 80 - patches/sdk2/package_startup_fix.py | 146 - patches/sdk2/pipeline.py | 433 -- patches/sdk2/presentation_pipeline.py | 44 - patches/sdk2/reload_pipeline.py | 61 - patches/sdk2/reuse_stage.py | 33 - patches/sdk2/sdk/development.h | 209 - patches/sdk2/sdk/development.hpp | 69 - patches/sdk2/sdk/json.h | 33 - patches/sdk2/sdk/json_read.h | 73 - patches/sdk2/sdk/relocations.py | 252 -- patches/sdk2/sdk/tools.py | 112 - patches/sdk2/settings_pipeline.py | 30 - patches/shared-saves/NativeOverlay.cmake | 122 - patches/shared-saves/README.md | 72 - patches/shared-saves/RELEASE-NOTES.txt | 87 - patches/shared-saves/bundle_dialog.inl | 9 - patches/shared-saves/cache_ui.inl | 23 - patches/shared-saves/custom_records.hpp | 57 - patches/shared-saves/import_dialog.inl | 9 - patches/shared-saves/inspect_build.py | 58 - patches/shared-saves/online_seed.inl | 25 - patches/shared-saves/overview_ui.inl | 44 - patches/shared-saves/package.py | 177 - patches/shared-saves/page_ui.inl | 232 -- patches/shared-saves/pipeline.py | 379 -- patches/shared-saves/record_guest.inl | 35 - patches/shared-saves/record_store.inl | 57 - patches/shared-saves/shared_save_backend.inl | 319 -- patches/shared-saves/shared_save_jobs.inl | 26 - patches/shared-saves/shared_saves.hpp | 23 - .../IMPLEMENTATION.txt | 53 - .../spectator-admission-repair/PLAYTEST.txt | 55 - .../ReleaseOverlay.cmake | 82 - patches/spectator-admission-repair/audit.py | 113 - .../spectator-admission-repair/configure.py | 14 - patches/spectator-admission-repair/handoff.py | 89 - .../native/admission_retry.hpp | 21 - .../native/observer_music.hpp | 21 - patches/spectator-admission-repair/package.py | 25 - .../spectator-admission-repair/pipeline.py | 63 - .../spectator-admission-repair/run_checks.py | 35 - .../tests/handler_tests.cpp | 51 - .../tests/music_tests.cpp | 120 - .../spectator-admission-repair/transforms.py | 75 - patches/spectators/IMPLEMENTATION.txt | 122 - patches/spectators/PLAYTEST.txt | 101 - patches/spectators/ReleaseOverlay.cmake | 64 - patches/spectators/audit.py | 89 - patches/spectators/configure.py | 15 - patches/spectators/core/CMakeLists.txt | 15 - patches/spectators/link_pipeline.py | 165 - patches/spectators/native/admit_component.inl | 56 - patches/spectators/native/admit_eeprom.inl | 11 - patches/spectators/native/admit_magic.inl | 7 - patches/spectators/native/admit_paks.inl | 11 - .../spectators/native/checkpoint_transfer.cpp | 96 - .../spectators/native/checkpoint_transfer.hpp | 71 - patches/spectators/native/confirmed_feed.cpp | 50 - patches/spectators/native/confirmed_feed.hpp | 51 - .../spectators/native/confirmed_observer.cpp | 79 - .../spectators/native/confirmed_observer.hpp | 63 - patches/spectators/native/director_pose.inl | 35 - patches/spectators/native/link_approve.inl | 22 - patches/spectators/native/link_receive.inl | 115 - patches/spectators/native/link_service.inl | 131 - .../spectators/native/observer_playback.cpp | 105 - .../spectators/native/observer_playback.hpp | 58 - patches/spectators/native/owned_observer.cpp | 23 - patches/spectators/native/owned_observer.hpp | 20 - patches/spectators/native/owner_export.cpp | 6 - patches/spectators/native/owner_export.hpp | 47 - patches/spectators/native/party.cpp | 171 - patches/spectators/native/party.hpp | 72 - patches/spectators/native/party_camera.hpp | 6 - patches/spectators/native/party_codec.cpp | 91 - patches/spectators/native/party_codec.hpp | 33 - patches/spectators/native/party_link.cpp | 98 - patches/spectators/native/party_link.hpp | 119 - patches/spectators/native/party_runtime.cpp | 641 --- patches/spectators/native/party_runtime.hpp | 27 - patches/spectators/native/party_ui.inl | 83 - patches/spectators/native/runtime_runner.inl | 145 - patches/spectators/owned_pipeline.py | 47 - patches/spectators/package.py | 31 - patches/spectators/pipeline.py | 105 - patches/spectators/runtime_pipeline.py | 93 - patches/spectators/stage_core.py | 25 - patches/spectators/ui_pipeline.py | 90 - patches/startup-memory/BUILDING.txt | 25 - patches/startup-memory/DIAGNOSIS.txt | 30 - patches/startup-memory/RuntimeMemory.cmake | 4 - .../history/RELEASE-VALIDATION-1.0.7.txt | 59 - patches/startup-memory/inspect108.py | 177 - patches/startup-memory/pipeline.py | 13 - patches/startup-memory/release108.py | 157 - patches/store-vehicles/IMPLEMENTATION.txt | 40 - patches/store-vehicles/PLAYTEST.txt | 64 - patches/store-vehicles/ReleaseOverlay.cmake | 61 - patches/store-vehicles/audit.py | 75 - patches/store-vehicles/configure.py | 15 - .../native/store_vehicle_adapter.cpp | 163 - .../native/store_vehicle_adapter.hpp | 14 - .../native/vehicle_store_ui.inl | 16 - patches/store-vehicles/package.py | 32 - patches/store-vehicles/pipeline.py | 75 - patches/store-vehicles/transforms.py | 139 - patches/texture-dumping/ReleaseOverlay.cmake | 57 - patches/texture-dumping/TEXTURE-DUMPING.txt | 152 - patches/texture-dumping/configure.py | 16 - patches/texture-dumping/inspect_dump.py | 64 - .../native/texture_capture_bridge.hpp | 19 - .../texture-dumping/native/texture_decode.hpp | 91 - .../texture-dumping/native/texture_dump.cpp | 544 --- .../texture-dumping/native/texture_dump.hpp | 50 - .../native/texture_dump_ui.inl | 373 -- .../native/texture_gallery.inl | 129 - patches/texture-dumping/package.py | 44 - patches/texture-dumping/pipeline.py | 63 - patches/title-text/NativeOverlay.cmake | 37 - patches/title-text/README.md | 71 - patches/title-text/package.py | 75 - patches/title-text/pipeline.py | 125 - patches/title-text/title_text_rt64.hpp | 235 -- patches/upstream-sync/IMPLEMENTATION.txt | 72 - patches/upstream-sync/PLAYTEST.txt | 36 - patches/upstream-sync/ReleaseOverlay.cmake | 63 - patches/upstream-sync/audit.py | 102 - patches/upstream-sync/configure.py | 13 - patches/upstream-sync/package.py | 29 - patches/upstream-sync/pins.json | 17 - patches/upstream-sync/pipeline.py | 65 - patches/upstream-sync/restore_capsule.py | 36 - patches/upstream-sync/transforms.py | 44 - patches/vanilla-water/NativeOverlay.cmake | 39 - patches/vanilla-water/README.md | 33 - patches/vanilla-water/package.py | 39 - patches/vanilla-water/pipeline.py | 153 - .../vehicle-original-textures/PIPELINE.txt | 54 - .../vehicle-original-textures/PLAYTEST.txt | 42 - .../ReleaseOverlay.cmake | 86 - patches/vehicle-original-textures/audit.py | 58 - .../collect_checks.py | 23 - .../vehicle-original-textures/configure.py | 12 - patches/vehicle-original-textures/finalize.py | 70 - .../native/driver_anchor_units.hpp | 15 - .../native/package_compatibility.hpp | 214 - patches/vehicle-original-textures/package.py | 29 - patches/vehicle-original-textures/pipeline.py | 64 - .../vehicle-original-textures/run_checks.py | 63 - .../tests/package_checks.cpp | 112 - patches/vehicle-seating/FORMAT.txt | 113 - patches/vehicle-seating/PLAYTEST.txt | 123 - patches/vehicle-seating/ReleaseOverlay.cmake | 98 - patches/vehicle-seating/configure.py | 20 - .../vehicle-seating/driver_detail_pipeline.py | 163 - patches/vehicle-seating/inspect_sources.py | 221 -- patches/vehicle-seating/package.py | 48 - patches/viewport-flow/NativeOverlay.cmake | 37 - patches/viewport-flow/README.md | 120 - patches/viewport-flow/flow_cadence_rt64.hpp | 47 - patches/viewport-flow/flow_history_rt64.hpp | 56 - patches/viewport-flow/flow_phase_rt64.hpp | 32 - .../viewport-flow/flow_vertex_scope_rt64.hpp | 43 - patches/viewport-flow/intro_viewport_rt64.hpp | 78 - patches/viewport-flow/package.py | 72 - patches/viewport-flow/pipeline.py | 233 -- pyproject.toml | 19 +- runtime-recomp/CMakeLists.txt | 2 +- runtime-recomp/cmake/EnhancedWater.cmake | 12 +- runtime-recomp/cmake/PreparedInputs.cmake | 32 +- .../src/game/netplay/experimental_runtime.cpp | 3 +- .../src/game/netplay/legacy_identity.hpp | 8 + .../src/water}/LICENSE-WAVE-RACE.txt | 0 .../src/water}/LavaFlow.hlsli | 0 .../src/water}/LavaLighting.hlsli | 0 .../src/water}/LavaLightingBounds.h | 0 .../src/water}/LavaLightingCS.hlsl | 0 .../src/water}/LavaLightingPS.hlsl | 0 .../src/water}/LavaLightingVS.hlsl | 0 .../src/water}/WaterBounds.h | 0 .../src/water}/WaterCaptureProbeCS.hlsl | 0 .../src/water}/WaterFieldCompareCS.hlsl | 0 .../src/water}/WaterFieldReferenceCS.hlsl | 0 .../src/water}/WaterPipelineSafety.h | 0 .../src/water}/WaterProjection.h | 0 .../src/water}/WaterProjection.hlsli | 0 .../src/water}/WaterProjectionProbeCS.hlsl | 0 .../src/water}/WaterRayProbeCS.hlsl | 0 .../src/water}/WaterRayScene.cpp | 0 .../src/water}/WaterRayScene.h | 0 .../src/water}/WaterRays.hlsli | 0 .../src/water}/WaterResourcePolicy.h | 0 .../src/water}/WaterWorkPolicy.h | 0 .../src/water}/WaterfallFlow.hlsli | 0 .../src/water}/WaterfallPS.hlsl | 0 .../src/water}/WaterfallVS.hlsl | 0 runtime-recomp/tests/build_tooling_tests.py | 4 +- runtime-recomp/tests/online_identity_tests.py | 27 + runtime-recomp/tests/pattern_guard_tests.py | 21 + runtime-recomp/tests/pinned_bytes_tests.py | 11 + scripts/Package-Android-Preview.ps1 | 2 +- scripts/Package-Linux-AppImage.sh | 2 +- scripts/Package-Windows.ps1 | 2 +- scripts/Refresh-Water-Shaders.cmake | 38 - scripts/adapt-water-reference.py | 86 - scripts/camera_plus_pipeline.py | 275 -- scripts/check_pinned_bytes.py | 9 + scripts/compose-lava-glow.py | 259 -- scripts/compose-lava-lighting.py | 341 -- scripts/compose-lava.py | 87 - scripts/compose-water-performance.py | 388 -- scripts/compose-water-projection.py | 161 - scripts/compose-water-rays.py | 438 --- scripts/compose-water-recovery.py | 216 - scripts/compose-water-safety.py | 307 -- scripts/compose-water-stability.py | 484 --- scripts/compose-water-views.py | 185 - scripts/compose-waterfall.py | 452 --- scripts/fold/portability.json | 7 +- scripts/normalize-water-adapter.py | 43 - scripts/pattern_guard.py | 13 +- services/README.md | 16 + .../library-workflow/DEPLOYMENT.md | 17 +- {patches => services}/library-workflow/QA.md | 0 services/library-workflow/README.md | 22 + .../library-workflow/backend.inl | 0 .../PRIVATE-API/worker-single-file.mjs | 3479 +++++++++++++++++ services/library-workflow/baseline/SHA256SUMS | 6 + .../baseline/assets/api-store.js | 258 ++ .../library-workflow/baseline/assets/app.js | 840 ++++ .../baseline/assets/hosted-files.js | 0 .../baseline/assets/uploads-client.js | 353 ++ .../library-workflow/baseline/index.html | 0 .../library-workflow/batch-client.js | 0 services/library-workflow/build.py | 186 + .../library-workflow/tests/build_tests.py | 71 + .../library-workflow/tests/d1-runtime.cjs | 0 .../library-workflow/tests/preview.cjs | 0 .../library-workflow/tests/run.cjs | 2 +- .../library-workflow/tests/support.cjs | 0 .../library-workflow/tests/typecheck.cjs | 0 services/library-workflow/transforms.py | 342 ++ {patches => services}/library-workflow/ui.inl | 0 .../library-workflow/workflow-schema.sql | 0 .../library-workflow/workflow.css | 0 .../mod-store}/OWNER-SETUP.txt | 0 services/mod-store/README.md | 19 + services/mod-store/backfill_library.py | 141 + services/mod-store/build.py | 245 ++ services/mod-store/frontend.py | 89 + .../mod-store}/launcher-auth.inl | 0 .../mod-store}/launcher-schema.sql | 0 .../mod-store}/launcher-service.inl | 0 services/mod-store/release_manifest.py | 91 + services/mod-store/tests/build_tests.py | 95 + .../tests/fixture/assets/api-store.js | 3 + .../mod-store/tests/fixture/assets/app.js | 18 + .../tests/fixture/assets/uploads-client.js | 5 + .../tests/fixture/worker-single-file.mjs | 18 + .../mod-store}/upload-hash.inl | 0 .../mod-store}/upload-integrity.inl | 0 .../backend => services/mod-store}/web.config | 0 .../online-directory/DEPLOYMENT.md | 0 .../online-directory/IMPLEMENTATION.txt | 0 .../online-directory/PLAYTEST.txt | 0 {patches => services}/online-directory/QA.md | 0 services/online-directory/README.md | 19 + services/online-directory/build.py | 150 + .../online-directory}/directory.mjs | 0 .../online-directory}/gateway.inl | 0 .../online-directory/tests/build_tests.py | 83 + .../online-directory/tests/runtime.cjs | 0 .../online-directory}/wrangler-online.jsonc | 0 services/test-tools/package-lock.json | 996 +++++ services/test-tools/package.json | 8 + 807 files changed, 7875 insertions(+), 68009 deletions(-) create mode 100644 .github/workflows/services-library-workflow.yml create mode 100644 .github/workflows/services-mod-store.yml create mode 100644 .github/workflows/services-online-directory.yml delete mode 100644 patches/achievements/ACHIEVEMENTS.txt delete mode 100644 patches/achievements/PIPELINE.txt delete mode 100644 patches/achievements/ReleaseOverlay.cmake delete mode 100644 patches/achievements/audit.py delete mode 100644 patches/achievements/configure.py delete mode 100644 patches/achievements/inspect_rom.py delete mode 100644 patches/achievements/native/achievement_catalogue.cpp delete mode 100644 patches/achievements/native/achievement_ui.inl delete mode 100644 patches/achievements/native/achievements.cpp delete mode 100644 patches/achievements/native/achievements.hpp delete mode 100644 patches/achievements/package.py delete mode 100644 patches/achievements/pipeline.py delete mode 100644 patches/all-player-detail/PIPELINE.txt delete mode 100644 patches/all-player-detail/PLAYTEST.txt delete mode 100644 patches/all-player-detail/ReleaseOverlay.cmake delete mode 100644 patches/all-player-detail/audit.py delete mode 100644 patches/all-player-detail/configure.py delete mode 100644 patches/all-player-detail/native/detail_contract.hpp delete mode 100644 patches/all-player-detail/native/detail_observer.inl delete mode 100644 patches/all-player-detail/package.py delete mode 100644 patches/all-player-detail/pipeline.py delete mode 100644 patches/android/README.md delete mode 100644 patches/camera-plus/CMakeLists.txt delete mode 100644 patches/camera-plus/IMPLEMENTATION.md delete mode 100644 patches/camera-plus/NativeOverlay.cmake delete mode 100644 patches/camera-plus/OpenXR.cmake delete mode 100644 patches/camera-plus/README.md delete mode 100644 patches/camera-plus/SceneKernel.cmake delete mode 100644 patches/camera-plus/camera_editor_ui.inl delete mode 100644 patches/camera-plus/inspect_scene_closure.py delete mode 100644 patches/camera-plus/integration_pipeline.py delete mode 100644 patches/camera-plus/mod/dkr.json delete mode 100644 patches/camera-plus/mod/main.c delete mode 100644 patches/camera-plus/mod/settings.json delete mode 100644 patches/camera-plus/native/camera_actions.cpp delete mode 100644 patches/camera-plus/native/camera_actions.hpp delete mode 100644 patches/camera-plus/native/camera_kernel.c delete mode 100644 patches/camera-plus/native/camera_kernel.h delete mode 100644 patches/camera-plus/native/camera_kernel_guest.h delete mode 100644 patches/camera-plus/native/camera_presentation.cpp delete mode 100644 patches/camera-plus/native/camera_presentation.hpp delete mode 100644 patches/camera-plus/native/camera_rt64.cpp delete mode 100644 patches/camera-plus/native/camera_rt64.hpp delete mode 100644 patches/camera-plus/native/camera_runtime.cpp delete mode 100644 patches/camera-plus/native/camera_runtime.hpp delete mode 100644 patches/camera-plus/native/camera_scene.cpp delete mode 100644 patches/camera-plus/native/camera_scene.hpp delete mode 100644 patches/camera-plus/native/camera_session.cpp delete mode 100644 patches/camera-plus/native/camera_session.hpp delete mode 100644 patches/camera-plus/native/camera_settings.cpp delete mode 100644 patches/camera-plus/native/camera_settings.hpp delete mode 100644 patches/camera-plus/native/camera_snapshot.cpp delete mode 100644 patches/camera-plus/native/camera_snapshot.hpp delete mode 100644 patches/camera-plus/native/camera_xr.cpp delete mode 100644 patches/camera-plus/native/camera_xr.hpp delete mode 100644 patches/camera-plus/package_playtest.py delete mode 100644 patches/camera-plus/sdk/dkr/presentation.h delete mode 100644 patches/camera-plus/vr_device_patch.py delete mode 100644 patches/camera-shadow/NativeOverlay.cmake delete mode 100644 patches/camera-shadow/RELEASE-NOTES.txt delete mode 100644 patches/camera-shadow/camera_shadow_kernel.c delete mode 100644 patches/camera-shadow/camera_shadow_kernel.h delete mode 100644 patches/camera-shadow/package.py delete mode 100644 patches/camera-shadow/pipeline.py delete mode 100644 patches/camera-shadow/shadow_pass.inl delete mode 100644 patches/character-library-portraits/PIPELINE.txt delete mode 100644 patches/character-library-portraits/ReleaseOverlay.cmake delete mode 100644 patches/character-library-portraits/configure.py delete mode 100644 patches/character-library-portraits/native/character_portraits.cpp delete mode 100644 patches/character-library-portraits/native/character_portraits.hpp delete mode 100644 patches/character-library-portraits/native/character_portraits_ui.inl delete mode 100644 patches/character-library-portraits/pipeline.py delete mode 100644 patches/character-library-portraits/run_checks.py delete mode 100644 patches/character-library-portraits/tests/inspect_installed.py delete mode 100644 patches/character-library-portraits/tests/portrait_checks.cpp delete mode 100644 patches/character-portraits/NativeOverlay.cmake delete mode 100644 patches/character-portraits/README.md delete mode 100644 patches/character-portraits/inspect_packages.py delete mode 100644 patches/character-portraits/inspect_sources.py delete mode 100644 patches/character-portraits/load_reviewed_portrait.inc delete mode 100644 patches/character-portraits/package-windows.ps1 delete mode 100644 patches/checkpoint-clock-repair/IMPLEMENTATION.txt delete mode 100644 patches/checkpoint-clock-repair/PLAYTEST.txt delete mode 100644 patches/checkpoint-clock-repair/ReleaseOverlay.cmake delete mode 100644 patches/checkpoint-clock-repair/audit.py delete mode 100644 patches/checkpoint-clock-repair/configure.py delete mode 100644 patches/checkpoint-clock-repair/package.py delete mode 100644 patches/checkpoint-clock-repair/pipeline.py delete mode 100644 patches/checkpoint-clock-repair/transforms.py delete mode 100644 patches/credits-viewport/NativeOverlay.cmake delete mode 100644 patches/credits-viewport/README.md delete mode 100644 patches/credits-viewport/credit_fade.inl delete mode 100644 patches/credits-viewport/credit_match.inl delete mode 100644 patches/credits-viewport/credits_sky_rt64.hpp delete mode 100644 patches/credits-viewport/package.py delete mode 100644 patches/credits-viewport/pipeline.py delete mode 100644 patches/custom-vehicles/PLAYTEST.txt delete mode 100644 patches/custom-vehicles/REPAIR-NOTES.txt delete mode 100644 patches/custom-vehicles/ReleaseOverlay.cmake delete mode 100644 patches/custom-vehicles/audit_characters.py delete mode 100644 patches/custom-vehicles/character_ownership_review.py delete mode 100644 patches/custom-vehicles/configure109.py delete mode 100644 patches/custom-vehicles/guest_pipeline.py delete mode 100644 patches/custom-vehicles/inspect_base.py delete mode 100644 patches/custom-vehicles/inspect_character_components.py delete mode 100644 patches/custom-vehicles/inspect_inputs.py delete mode 100644 patches/custom-vehicles/inspect_placement.py delete mode 100644 patches/custom-vehicles/inspect_scale.py delete mode 100644 patches/custom-vehicles/inspect_seating.py delete mode 100644 patches/custom-vehicles/music_pipeline.py delete mode 100644 patches/custom-vehicles/native/asset_bus_mount.inl delete mode 100644 patches/custom-vehicles/native/resident_music.cpp delete mode 100644 patches/custom-vehicles/native/resident_music.hpp delete mode 100644 patches/custom-vehicles/native/resident_music_prepare.inl delete mode 100644 patches/custom-vehicles/native/vehicle_base_hashes.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_binding.cpp delete mode 100644 patches/custom-vehicles/native/vehicle_binding.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_character_masks.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_library.cpp delete mode 100644 patches/custom-vehicles/native/vehicle_library.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_online.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_package.cpp delete mode 100644 patches/custom-vehicles/native/vehicle_package.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_protocol.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_resources.cpp delete mode 100644 patches/custom-vehicles/native/vehicle_resources.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_rt64.cpp delete mode 100644 patches/custom-vehicles/native/vehicle_rt64.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_seat_alignment.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_ui.inl delete mode 100644 patches/custom-vehicles/native/vehicle_wheel_interpolation.hpp delete mode 100644 patches/custom-vehicles/native/vehicle_world.cpp delete mode 100644 patches/custom-vehicles/native/vehicle_world.hpp delete mode 100644 patches/custom-vehicles/online_pipeline.py delete mode 100644 patches/custom-vehicles/package_playtest.py delete mode 100644 patches/custom-vehicles/pipeline.py delete mode 100644 patches/detail-records-repair/PLAYTEST.txt delete mode 100644 patches/detail-records-repair/ReleaseOverlay.cmake delete mode 100644 patches/detail-records-repair/audit.py delete mode 100644 patches/detail-records-repair/configure.py delete mode 100644 patches/detail-records-repair/native/detail_batch.inl delete mode 100644 patches/detail-records-repair/native/detail_metrics.hpp delete mode 100644 patches/detail-records-repair/native/detail_texture_cache.inl delete mode 100644 patches/detail-records-repair/native/records_guest.inl delete mode 100644 patches/detail-records-repair/package.py delete mode 100644 patches/detail-records-repair/pipeline.py delete mode 100644 patches/graphics-handoff/PIPELINE.txt delete mode 100644 patches/graphics-handoff/PLAYTEST.txt delete mode 100644 patches/graphics-handoff/ReleaseOverlay.cmake delete mode 100644 patches/graphics-handoff/audit.py delete mode 100644 patches/graphics-handoff/configure.py delete mode 100644 patches/graphics-handoff/finalize.py delete mode 100644 patches/graphics-handoff/native/producer_snapshot.cpp delete mode 100644 patches/graphics-handoff/native/producer_snapshot.hpp delete mode 100644 patches/graphics-handoff/package.py delete mode 100644 patches/graphics-handoff/pipeline.py delete mode 100644 patches/graphics-handoff/run_checks.py delete mode 100644 patches/graphics-handoff/tests/copy_cost.cpp delete mode 100644 patches/graphics-handoff/tests/handoff_tests.cpp delete mode 100644 patches/invite-handoff/IMPLEMENTATION.txt delete mode 100644 patches/invite-handoff/PLAYTEST.txt delete mode 100644 patches/invite-handoff/ReleaseOverlay.cmake delete mode 100644 patches/invite-handoff/audit.py delete mode 100644 patches/invite-handoff/configure.py delete mode 100644 patches/invite-handoff/native/invite_handoff.cpp delete mode 100644 patches/invite-handoff/native/invite_handoff.hpp delete mode 100644 patches/invite-handoff/native/invite_ui.inl delete mode 100644 patches/invite-handoff/native/release_inputs.inl delete mode 100644 patches/invite-handoff/package.py delete mode 100644 patches/invite-handoff/pipeline.py delete mode 100644 patches/launcher-polish/NativeOverlay.cmake delete mode 100644 patches/launcher-polish/pipeline.py delete mode 100644 patches/launcher-polish/support_actions.inl delete mode 100644 patches/library-workflow/RELEASE-NOTES.txt delete mode 100644 patches/library-workflow/pipeline.py delete mode 100644 patches/library-workflow/transforms.py delete mode 100644 patches/liquid-streaming/NativeOverlay.cmake delete mode 100644 patches/liquid-streaming/OWNER-NOTES.txt delete mode 100644 patches/liquid-streaming/liquid_replacement_gate.hpp delete mode 100644 patches/liquid-streaming/package.py delete mode 100644 patches/liquid-streaming/pipeline.py delete mode 100644 patches/liquid-streaming/water_residency_rt64.hpp delete mode 100644 patches/lobby-link-repair/IMPLEMENTATION.txt delete mode 100644 patches/lobby-link-repair/PLAYTEST.txt delete mode 100644 patches/lobby-link-repair/ReleaseOverlay.cmake delete mode 100644 patches/lobby-link-repair/audit.py delete mode 100644 patches/lobby-link-repair/configure.py delete mode 100644 patches/lobby-link-repair/package.py delete mode 100644 patches/lobby-link-repair/pipeline.py delete mode 100644 patches/lobby-link-repair/transforms.py delete mode 100644 patches/lobby-restoration/IMPLEMENTATION.txt delete mode 100644 patches/lobby-restoration/PLAYTEST.txt delete mode 100644 patches/lobby-restoration/ReleaseOverlay.cmake delete mode 100644 patches/lobby-restoration/audit.py delete mode 100644 patches/lobby-restoration/configure.py delete mode 100644 patches/lobby-restoration/native/lobby_adapter.cpp delete mode 100644 patches/lobby-restoration/native/lobby_adapter.hpp delete mode 100644 patches/lobby-restoration/native/lobby_context.inl delete mode 100644 patches/lobby-restoration/native/lobby_controls.inl delete mode 100644 patches/lobby-restoration/native/remove_member.inl delete mode 100644 patches/lobby-restoration/package.py delete mode 100644 patches/lobby-restoration/pipeline.py delete mode 100644 patches/lobby-restoration/transforms.py delete mode 100644 patches/lobby-stability/IMPLEMENTATION.txt delete mode 100644 patches/lobby-stability/PLAYTEST.txt delete mode 100644 patches/lobby-stability/ReleaseOverlay.cmake delete mode 100644 patches/lobby-stability/audit.py delete mode 100644 patches/lobby-stability/configure.py delete mode 100644 patches/lobby-stability/native/cache_repair.hpp delete mode 100644 patches/lobby-stability/native/diagnostics.hpp delete mode 100644 patches/lobby-stability/native/policy.hpp delete mode 100644 patches/lobby-stability/native/task_submission.cpp delete mode 100644 patches/lobby-stability/package.py delete mode 100644 patches/lobby-stability/pipeline.py delete mode 100644 patches/lobby-stability/probe_worker.py delete mode 100644 patches/lobby-stability/tests/CMakeLists.txt delete mode 100644 patches/lobby-stability/tests/policy_tests.cpp delete mode 100644 patches/lobby-stability/tests/sp_fixture.cpp delete mode 100644 patches/lobby-stability/tests/sp_fixture.hpp delete mode 100644 patches/lobby-stability/tests/task_submission_tests.cpp delete mode 100644 patches/lobby-stability/transforms.py delete mode 100644 patches/memory-stability/DIAGNOSIS.txt delete mode 100644 patches/memory-stability/PIPELINE.txt delete mode 100644 patches/memory-stability/PLAYTEST.txt delete mode 100644 patches/memory-stability/ReleaseOverlay.cmake delete mode 100644 patches/memory-stability/audit.py delete mode 100644 patches/memory-stability/configure.py delete mode 100644 patches/memory-stability/finalize.py delete mode 100644 patches/memory-stability/native/primary_heap.h delete mode 100644 patches/memory-stability/package.py delete mode 100644 patches/memory-stability/pipeline.py delete mode 100644 patches/memory-stability/run_checks.py delete mode 100644 patches/memory-stability/tests/allocator_tests.cpp delete mode 100644 patches/memory-stability/tests/owned_entry_tests.cpp delete mode 100644 patches/memory-stability/verify_archive.py delete mode 100644 patches/mod-runtime-repair/PLAYTEST.txt delete mode 100644 patches/mod-runtime-repair/ReleaseOverlay.cmake delete mode 100644 patches/mod-runtime-repair/SDK-WORK-POLICY.txt delete mode 100644 patches/mod-runtime-repair/WORK-NOTES.txt delete mode 100644 patches/mod-runtime-repair/configure.py delete mode 100644 patches/mod-runtime-repair/guest_pipeline.py delete mode 100644 patches/mod-runtime-repair/inspect_sources.py delete mode 100644 patches/mod-runtime-repair/native/magic_code_execution_policy.hpp delete mode 100644 patches/mod-runtime-repair/native/render_heap_support.cpp delete mode 100644 patches/mod-runtime-repair/package.py delete mode 100644 patches/mod-runtime-repair/pipeline.py delete mode 100644 patches/mod-store/INSTALLATION-MANAGER.md delete mode 100644 patches/mod-store/NativeOverlay.cmake delete mode 100644 patches/mod-store/RELEASE-NOTES.txt delete mode 100644 patches/mod-store/backend/backfill_library.py delete mode 100644 patches/mod-store/backend/release_manifest.py delete mode 100644 patches/mod-store/frontend_pipeline.py delete mode 100644 patches/mod-store/native/mod_store.cpp delete mode 100644 patches/mod-store/native/mod_store.hpp delete mode 100644 patches/mod-store/native/mod_store_install.cpp delete mode 100644 patches/mod-store/native/mod_store_install.hpp delete mode 100644 patches/mod-store/native/mod_store_management_ui.inl delete mode 100644 patches/mod-store/native/mod_store_names.cpp delete mode 100644 patches/mod-store/native/mod_store_style.inl delete mode 100644 patches/mod-store/native/mod_store_ui.inl delete mode 100644 patches/mod-store/native/store_account_page.inl delete mode 100644 patches/mod-store/native/store_page_shell.inl delete mode 100644 patches/mod-store/native/store_player_activity.inl delete mode 100644 patches/mod-store/native/store_player_details.inl delete mode 100644 patches/mod-store/native/store_player_library.inl delete mode 100644 patches/mod-store/native/store_polish_controls.inl delete mode 100644 patches/mod-store/native/store_polish_modals.inl delete mode 100644 patches/mod-store/native/store_staff_pages.inl delete mode 100644 patches/mod-store/native/store_texture_adapter.inl delete mode 100644 patches/mod-store/native/store_track_adapter.inl delete mode 100644 patches/mod-store/native/store_track_locator.inl delete mode 100644 patches/mod-store/native/store_track_zip.inl delete mode 100644 patches/mod-store/package.py delete mode 100644 patches/mod-store/package_linux.py delete mode 100644 patches/mod-store/pipeline.py delete mode 100644 patches/mod-store/texture_names_pipeline.py delete mode 100644 patches/music-preview-repair/PLAYTEST.txt delete mode 100644 patches/music-preview-repair/REPAIR.txt delete mode 100644 patches/music-preview-repair/ReleaseOverlay.cmake delete mode 100644 patches/music-preview-repair/audit.py delete mode 100644 patches/music-preview-repair/configure.py delete mode 100644 patches/music-preview-repair/package.py delete mode 100644 patches/music-preview-repair/pipeline.py delete mode 100644 patches/offline-retention-repair/IMPLEMENTATION.txt delete mode 100644 patches/offline-retention-repair/PLAYTEST.txt delete mode 100644 patches/offline-retention-repair/ReleaseOverlay.cmake delete mode 100644 patches/offline-retention-repair/audit.py delete mode 100644 patches/offline-retention-repair/configure.py delete mode 100644 patches/offline-retention-repair/handoff.py delete mode 100644 patches/offline-retention-repair/lobby_layout.py delete mode 100644 patches/offline-retention-repair/native/lobby_sidebar.inl delete mode 100644 patches/offline-retention-repair/native/offline_keep.hpp delete mode 100644 patches/offline-retention-repair/native/offline_keep.inl delete mode 100644 patches/offline-retention-repair/package.py delete mode 100644 patches/offline-retention-repair/pipeline.py delete mode 100644 patches/offline-retention-repair/run_checks.py delete mode 100644 patches/offline-retention-repair/tests/retention_tests.cpp delete mode 100644 patches/online-directory/ReleaseOverlay.cmake delete mode 100644 patches/online-directory/audit.py delete mode 100644 patches/online-directory/backend_pipeline.py delete mode 100644 patches/online-directory/configure.py delete mode 100644 patches/online-directory/delivery.py delete mode 100644 patches/online-directory/native/account_bind.inl delete mode 100644 patches/online-directory/native/admission_contract.hpp delete mode 100644 patches/online-directory/native/directory_tls.hpp delete mode 100644 patches/online-directory/native/directory_ui.inl delete mode 100644 patches/online-directory/native/linux_keyring.hpp delete mode 100644 patches/online-directory/native/online_directory.cpp delete mode 100644 patches/online-directory/native/online_directory.hpp delete mode 100644 patches/online-directory/native/owner_pump.inl delete mode 100644 patches/online-directory/native/owner_state.hpp delete mode 100644 patches/online-directory/package.py delete mode 100644 patches/online-directory/pipeline.py delete mode 100644 patches/online-directory/transforms.py delete mode 100644 patches/online-performance/FINDINGS.txt delete mode 100644 patches/online-performance/PIPELINE.txt delete mode 100644 patches/online-performance/PLAYTEST.txt delete mode 100644 patches/online-performance/ReleaseOverlay.cmake delete mode 100644 patches/online-performance/audit.py delete mode 100644 patches/online-performance/build.py delete mode 100644 patches/online-performance/configure.py delete mode 100644 patches/online-performance/finalize.py delete mode 100644 patches/online-performance/native/menu_notifications.h delete mode 100644 patches/online-performance/native/runtime_log.hpp delete mode 100644 patches/online-performance/native/setup_policy.hpp delete mode 100644 patches/online-performance/package.py delete mode 100644 patches/online-performance/pipeline.py delete mode 100644 patches/online-performance/run_checks.py delete mode 100644 patches/online-performance/tests/menu_fixture_main.c delete mode 100644 patches/online-performance/tests/menu_fixture_prefix.c delete mode 100644 patches/online-performance/tests/party_checks.inl delete mode 100644 patches/online-performance/tests/setup_checks.cpp delete mode 100644 patches/online-vehicle-menu/PLAYTEST.txt delete mode 100644 patches/online-vehicle-menu/REPAIR.txt delete mode 100644 patches/online-vehicle-menu/ReleaseOverlay.cmake delete mode 100644 patches/online-vehicle-menu/audit.py delete mode 100644 patches/online-vehicle-menu/configure.py delete mode 100644 patches/online-vehicle-menu/native/owned_vehicle_calls.inl delete mode 100644 patches/online-vehicle-menu/native/owned_vehicle_menu.hpp delete mode 100644 patches/online-vehicle-menu/package.py delete mode 100644 patches/online-vehicle-menu/pipeline.py delete mode 100644 patches/party-departure-repair/IMPLEMENTATION.txt delete mode 100644 patches/party-departure-repair/PLAYTEST.txt delete mode 100644 patches/party-departure-repair/ReleaseOverlay.cmake delete mode 100644 patches/party-departure-repair/audit.py delete mode 100644 patches/party-departure-repair/configure.py delete mode 100644 patches/party-departure-repair/native/close_notice.inl delete mode 100644 patches/party-departure-repair/native/departure.hpp delete mode 100644 patches/party-departure-repair/native/retire_member.inl delete mode 100644 patches/party-departure-repair/package.py delete mode 100644 patches/party-departure-repair/pipeline.py delete mode 100644 patches/party-departure-repair/transforms.py delete mode 100644 patches/party-start-repair/IMPLEMENTATION.txt delete mode 100644 patches/party-start-repair/PLAYTEST.txt delete mode 100644 patches/party-start-repair/ReleaseOverlay.cmake delete mode 100644 patches/party-start-repair/audit.py delete mode 100644 patches/party-start-repair/configure.py delete mode 100644 patches/party-start-repair/native/client_start.inl delete mode 100644 patches/party-start-repair/native/host_start.inl delete mode 100644 patches/party-start-repair/native/start_gate.hpp delete mode 100644 patches/party-start-repair/package.py delete mode 100644 patches/party-start-repair/pipeline.py delete mode 100644 patches/party-start-repair/transforms.py delete mode 100644 patches/performance-surgical/NativeOverlay.cmake delete mode 100644 patches/performance-surgical/OWNER-CHECKS.txt delete mode 100644 patches/performance-surgical/Record-Performance.cmd delete mode 100644 patches/performance-surgical/inspect_sources.py delete mode 100644 patches/performance-surgical/mip_scratch.inl delete mode 100644 patches/performance-surgical/package.py delete mode 100644 patches/performance-surgical/performance_observation.hpp delete mode 100644 patches/performance-surgical/performance_switches.hpp delete mode 100644 patches/performance-surgical/pipeline.py delete mode 100644 patches/performance-surgical/presentation_pacer.hpp delete mode 100644 patches/performance-surgical/reusable_flow_map.hpp delete mode 100644 patches/performance-surgical/texture_binding_index.hpp delete mode 100644 patches/presentation-polish/NativeOverlay.cmake delete mode 100644 patches/presentation-polish/README.md delete mode 100644 patches/presentation-polish/intro_caption_rt64.hpp delete mode 100644 patches/presentation-polish/intro_sky_rt64.hpp delete mode 100644 patches/presentation-polish/native_resource_safety.hpp delete mode 100644 patches/presentation-polish/package.py delete mode 100644 patches/presentation-polish/pipeline.py delete mode 100644 patches/presentation-polish/water_residency_rt64.hpp delete mode 100644 patches/race-release-repair/IMPLEMENTATION.txt delete mode 100644 patches/race-release-repair/PLAYTEST.txt delete mode 100644 patches/race-release-repair/ReleaseOverlay.cmake delete mode 100644 patches/race-release-repair/audit.py delete mode 100644 patches/race-release-repair/configure.py delete mode 100644 patches/race-release-repair/handoff.py delete mode 100644 patches/race-release-repair/package.py delete mode 100644 patches/race-release-repair/pipeline.py delete mode 100644 patches/race-release-repair/run_checks.py delete mode 100644 patches/race-release-repair/tests/runtime_tests.cpp delete mode 100644 patches/race-release-repair/transforms.py delete mode 100644 patches/render-stability/PIPELINE.txt delete mode 100644 patches/render-stability/PLAYTEST.txt delete mode 100644 patches/render-stability/ReleaseOverlay.cmake delete mode 100644 patches/render-stability/audit.py delete mode 100644 patches/render-stability/configure.py delete mode 100644 patches/render-stability/finalize.py delete mode 100644 patches/render-stability/native/liquid_replacement_gate.hpp delete mode 100644 patches/render-stability/native/render_provenance.hpp delete mode 100644 patches/render-stability/native/water_residency_rt64.hpp delete mode 100644 patches/render-stability/package.py delete mode 100644 patches/render-stability/pipeline.py delete mode 100644 patches/render-stability/run_checks.py delete mode 100644 patches/render-stability/tests/residency_tests.cpp delete mode 100644 patches/render-stability/tests/selection_tests.cpp delete mode 100644 patches/render-stability/tests/stubs/enhanced_water_rt64.hpp delete mode 100644 patches/render-stability/tests/stubs/performance_observation.hpp delete mode 100644 patches/render-stability/tests/stubs/polish_symbols.hpp delete mode 100644 patches/render-stability/tests/stubs/render/rt64_texture_cache.h delete mode 100644 patches/render-stability/tests/stubs/rice_texture_pack_policy.hpp delete mode 100644 patches/review-editor/BUILDING.txt delete mode 100644 patches/review-editor/ReleaseOverlay.cmake delete mode 100644 patches/review-editor/configure109.py delete mode 100644 patches/review-editor/native/review_form.inl delete mode 100644 patches/review-editor/native/wrapped_input.inl delete mode 100644 patches/review-editor/pipeline.py delete mode 100644 patches/rollback-connection/IMPLEMENTATION.txt delete mode 100644 patches/rollback-connection/PLAYTEST.txt delete mode 100644 patches/rollback-connection/ReleaseOverlay.cmake delete mode 100644 patches/rollback-connection/audit.py delete mode 100644 patches/rollback-connection/configure.py delete mode 100644 patches/rollback-connection/native/connection_ui.inl delete mode 100644 patches/rollback-connection/native/owner_context.inl delete mode 100644 patches/rollback-connection/native/owner_send.inl delete mode 100644 patches/rollback-connection/native/rollback_check.cpp delete mode 100644 patches/rollback-connection/native/rollback_check.hpp delete mode 100644 patches/rollback-connection/package.py delete mode 100644 patches/rollback-connection/pipeline.py delete mode 100644 patches/rollback-connection/transforms.py delete mode 100644 patches/rollback-only/IMPLEMENTATION.txt delete mode 100644 patches/rollback-only/PLAYTEST.txt delete mode 100644 patches/rollback-only/ReleaseOverlay.cmake delete mode 100644 patches/rollback-only/audit.py delete mode 100644 patches/rollback-only/configure.py delete mode 100644 patches/rollback-only/native/host_settings.inl delete mode 100644 patches/rollback-only/native/rollback_policy.hpp delete mode 100644 patches/rollback-only/package.py delete mode 100644 patches/rollback-only/pipeline.py delete mode 100644 patches/rollback-scoreboard/IMPLEMENTATION.txt delete mode 100644 patches/rollback-scoreboard/PLAYTEST.txt delete mode 100644 patches/rollback-scoreboard/ReleaseOverlay.cmake delete mode 100644 patches/rollback-scoreboard/audit.py delete mode 100644 patches/rollback-scoreboard/configure.py delete mode 100644 patches/rollback-scoreboard/native/assessment.hpp delete mode 100644 patches/rollback-scoreboard/native/report_ui.inl delete mode 100644 patches/rollback-scoreboard/native/scoreboard_wire.inl delete mode 100644 patches/rollback-scoreboard/package.py delete mode 100644 patches/rollback-scoreboard/pipeline.py delete mode 100644 patches/rollback-scoreboard/transforms.py delete mode 100644 patches/save-layout/README.md delete mode 100644 patches/save-layout/inspect_packages.py delete mode 100644 patches/save-layout/inspect_sources.py delete mode 100644 patches/save-layout/package-linux.sh delete mode 100644 patches/save-layout/package-windows.ps1 delete mode 100644 patches/scene-preparation/PLAYTEST.txt delete mode 100644 patches/scene-preparation/REPAIR.txt delete mode 100644 patches/scene-preparation/ReleaseOverlay.cmake delete mode 100644 patches/scene-preparation/audit.py delete mode 100644 patches/scene-preparation/configure.py delete mode 100644 patches/scene-preparation/native/scene_load_work.hpp delete mode 100644 patches/scene-preparation/package.py delete mode 100644 patches/scene-preparation/pipeline.py delete mode 100644 patches/sdk2/BIBLE.txt delete mode 100644 patches/sdk2/DEV-MENU-IMPORT-FIX.txt delete mode 100644 patches/sdk2/IMPLEMENTATION-PLAN.txt delete mode 100644 patches/sdk2/NativeOverlay.cmake delete mode 100644 patches/sdk2/PLAYTEST-README.txt delete mode 100644 patches/sdk2/RELEASE-NOTES.txt delete mode 100644 patches/sdk2/SDK-README.txt delete mode 100644 patches/sdk2/STARTUP-FIX-README.txt delete mode 100644 patches/sdk2/WORK-STATUS.txt delete mode 100644 patches/sdk2/android_startup_init.gradle delete mode 100644 patches/sdk2/catalogue_pipeline.py delete mode 100644 patches/sdk2/compatibility.py delete mode 100644 patches/sdk2/control_pipeline.py delete mode 100644 patches/sdk2/distribution.py delete mode 100644 patches/sdk2/documentation.py delete mode 100644 patches/sdk2/documentation_qa.py delete mode 100644 patches/sdk2/finalize_delivery.py delete mode 100644 patches/sdk2/inspect_delivery.py delete mode 100644 patches/sdk2/lobby_pipeline.py delete mode 100644 patches/sdk2/mod/dkr.json delete mode 100644 patches/sdk2/mod/documents.inl delete mode 100644 patches/sdk2/mod/main.c delete mode 100644 patches/sdk2/mod/mod.toml delete mode 100644 patches/sdk2/mod/tools.py delete mode 100644 patches/sdk2/mod/track.inl delete mode 100644 patches/sdk2/native/sdk2_asset_inventory.inl delete mode 100644 patches/sdk2/native/sdk2_bridge.cpp delete mode 100644 patches/sdk2/native/sdk2_bridge.hpp delete mode 100644 patches/sdk2/native/sdk2_broker.inl delete mode 100644 patches/sdk2/native/sdk2_camera.inl delete mode 100644 patches/sdk2/native/sdk2_collision.inl delete mode 100644 patches/sdk2/native/sdk2_command.hpp delete mode 100644 patches/sdk2/native/sdk2_debug.inl delete mode 100644 patches/sdk2/native/sdk2_documents.inl delete mode 100644 patches/sdk2/native/sdk2_operations.inl delete mode 100644 patches/sdk2/native/sdk2_overrides.inl delete mode 100644 patches/sdk2/native/sdk2_properties.inl delete mode 100644 patches/sdk2/native/sdk2_reload.inl delete mode 100644 patches/sdk2/native/sdk2_schema.hpp delete mode 100644 patches/sdk2/native/sdk2_settings.inl delete mode 100644 patches/sdk2/native/sdk2_simulation.inl delete mode 100644 patches/sdk2/native/sdk2_tools_camera.inl delete mode 100644 patches/sdk2/native/sdk2_tools_console.inl delete mode 100644 patches/sdk2/native/sdk2_tools_documents.inl delete mode 100644 patches/sdk2/native/sdk2_tools_reload.inl delete mode 100644 patches/sdk2/native/sdk2_tools_ui.inl delete mode 100644 patches/sdk2/native/sdk2_tools_workspace.inl delete mode 100644 patches/sdk2/native/sdk2_track_catalogue.inl delete mode 100644 patches/sdk2/native/sdk2_tuning.inl delete mode 100644 patches/sdk2/native/sdk2_world.inl delete mode 100644 patches/sdk2/network_pipeline.py delete mode 100644 patches/sdk2/package_import_fix.py delete mode 100644 patches/sdk2/package_startup_fix.py delete mode 100644 patches/sdk2/pipeline.py delete mode 100644 patches/sdk2/presentation_pipeline.py delete mode 100644 patches/sdk2/reload_pipeline.py delete mode 100644 patches/sdk2/reuse_stage.py delete mode 100644 patches/sdk2/sdk/development.h delete mode 100644 patches/sdk2/sdk/development.hpp delete mode 100644 patches/sdk2/sdk/json.h delete mode 100644 patches/sdk2/sdk/json_read.h delete mode 100644 patches/sdk2/sdk/relocations.py delete mode 100644 patches/sdk2/sdk/tools.py delete mode 100644 patches/sdk2/settings_pipeline.py delete mode 100644 patches/shared-saves/NativeOverlay.cmake delete mode 100644 patches/shared-saves/README.md delete mode 100644 patches/shared-saves/RELEASE-NOTES.txt delete mode 100644 patches/shared-saves/bundle_dialog.inl delete mode 100644 patches/shared-saves/cache_ui.inl delete mode 100644 patches/shared-saves/custom_records.hpp delete mode 100644 patches/shared-saves/import_dialog.inl delete mode 100644 patches/shared-saves/inspect_build.py delete mode 100644 patches/shared-saves/online_seed.inl delete mode 100644 patches/shared-saves/overview_ui.inl delete mode 100644 patches/shared-saves/package.py delete mode 100644 patches/shared-saves/page_ui.inl delete mode 100644 patches/shared-saves/pipeline.py delete mode 100644 patches/shared-saves/record_guest.inl delete mode 100644 patches/shared-saves/record_store.inl delete mode 100644 patches/shared-saves/shared_save_backend.inl delete mode 100644 patches/shared-saves/shared_save_jobs.inl delete mode 100644 patches/shared-saves/shared_saves.hpp delete mode 100644 patches/spectator-admission-repair/IMPLEMENTATION.txt delete mode 100644 patches/spectator-admission-repair/PLAYTEST.txt delete mode 100644 patches/spectator-admission-repair/ReleaseOverlay.cmake delete mode 100644 patches/spectator-admission-repair/audit.py delete mode 100644 patches/spectator-admission-repair/configure.py delete mode 100644 patches/spectator-admission-repair/handoff.py delete mode 100644 patches/spectator-admission-repair/native/admission_retry.hpp delete mode 100644 patches/spectator-admission-repair/native/observer_music.hpp delete mode 100644 patches/spectator-admission-repair/package.py delete mode 100644 patches/spectator-admission-repair/pipeline.py delete mode 100644 patches/spectator-admission-repair/run_checks.py delete mode 100644 patches/spectator-admission-repair/tests/handler_tests.cpp delete mode 100644 patches/spectator-admission-repair/tests/music_tests.cpp delete mode 100644 patches/spectator-admission-repair/transforms.py delete mode 100644 patches/spectators/IMPLEMENTATION.txt delete mode 100644 patches/spectators/PLAYTEST.txt delete mode 100644 patches/spectators/ReleaseOverlay.cmake delete mode 100644 patches/spectators/audit.py delete mode 100644 patches/spectators/configure.py delete mode 100644 patches/spectators/core/CMakeLists.txt delete mode 100644 patches/spectators/link_pipeline.py delete mode 100644 patches/spectators/native/admit_component.inl delete mode 100644 patches/spectators/native/admit_eeprom.inl delete mode 100644 patches/spectators/native/admit_magic.inl delete mode 100644 patches/spectators/native/admit_paks.inl delete mode 100644 patches/spectators/native/checkpoint_transfer.cpp delete mode 100644 patches/spectators/native/checkpoint_transfer.hpp delete mode 100644 patches/spectators/native/confirmed_feed.cpp delete mode 100644 patches/spectators/native/confirmed_feed.hpp delete mode 100644 patches/spectators/native/confirmed_observer.cpp delete mode 100644 patches/spectators/native/confirmed_observer.hpp delete mode 100644 patches/spectators/native/director_pose.inl delete mode 100644 patches/spectators/native/link_approve.inl delete mode 100644 patches/spectators/native/link_receive.inl delete mode 100644 patches/spectators/native/link_service.inl delete mode 100644 patches/spectators/native/observer_playback.cpp delete mode 100644 patches/spectators/native/observer_playback.hpp delete mode 100644 patches/spectators/native/owned_observer.cpp delete mode 100644 patches/spectators/native/owned_observer.hpp delete mode 100644 patches/spectators/native/owner_export.cpp delete mode 100644 patches/spectators/native/owner_export.hpp delete mode 100644 patches/spectators/native/party.cpp delete mode 100644 patches/spectators/native/party.hpp delete mode 100644 patches/spectators/native/party_camera.hpp delete mode 100644 patches/spectators/native/party_codec.cpp delete mode 100644 patches/spectators/native/party_codec.hpp delete mode 100644 patches/spectators/native/party_link.cpp delete mode 100644 patches/spectators/native/party_link.hpp delete mode 100644 patches/spectators/native/party_runtime.cpp delete mode 100644 patches/spectators/native/party_runtime.hpp delete mode 100644 patches/spectators/native/party_ui.inl delete mode 100644 patches/spectators/native/runtime_runner.inl delete mode 100644 patches/spectators/owned_pipeline.py delete mode 100644 patches/spectators/package.py delete mode 100644 patches/spectators/pipeline.py delete mode 100644 patches/spectators/runtime_pipeline.py delete mode 100644 patches/spectators/stage_core.py delete mode 100644 patches/spectators/ui_pipeline.py delete mode 100644 patches/startup-memory/BUILDING.txt delete mode 100644 patches/startup-memory/DIAGNOSIS.txt delete mode 100644 patches/startup-memory/RuntimeMemory.cmake delete mode 100644 patches/startup-memory/history/RELEASE-VALIDATION-1.0.7.txt delete mode 100644 patches/startup-memory/inspect108.py delete mode 100644 patches/startup-memory/pipeline.py delete mode 100644 patches/startup-memory/release108.py delete mode 100644 patches/store-vehicles/IMPLEMENTATION.txt delete mode 100644 patches/store-vehicles/PLAYTEST.txt delete mode 100644 patches/store-vehicles/ReleaseOverlay.cmake delete mode 100644 patches/store-vehicles/audit.py delete mode 100644 patches/store-vehicles/configure.py delete mode 100644 patches/store-vehicles/native/store_vehicle_adapter.cpp delete mode 100644 patches/store-vehicles/native/store_vehicle_adapter.hpp delete mode 100644 patches/store-vehicles/native/vehicle_store_ui.inl delete mode 100644 patches/store-vehicles/package.py delete mode 100644 patches/store-vehicles/pipeline.py delete mode 100644 patches/store-vehicles/transforms.py delete mode 100644 patches/texture-dumping/ReleaseOverlay.cmake delete mode 100644 patches/texture-dumping/TEXTURE-DUMPING.txt delete mode 100644 patches/texture-dumping/configure.py delete mode 100644 patches/texture-dumping/inspect_dump.py delete mode 100644 patches/texture-dumping/native/texture_capture_bridge.hpp delete mode 100644 patches/texture-dumping/native/texture_decode.hpp delete mode 100644 patches/texture-dumping/native/texture_dump.cpp delete mode 100644 patches/texture-dumping/native/texture_dump.hpp delete mode 100644 patches/texture-dumping/native/texture_dump_ui.inl delete mode 100644 patches/texture-dumping/native/texture_gallery.inl delete mode 100644 patches/texture-dumping/package.py delete mode 100644 patches/texture-dumping/pipeline.py delete mode 100644 patches/title-text/NativeOverlay.cmake delete mode 100644 patches/title-text/README.md delete mode 100644 patches/title-text/package.py delete mode 100644 patches/title-text/pipeline.py delete mode 100644 patches/title-text/title_text_rt64.hpp delete mode 100644 patches/upstream-sync/IMPLEMENTATION.txt delete mode 100644 patches/upstream-sync/PLAYTEST.txt delete mode 100644 patches/upstream-sync/ReleaseOverlay.cmake delete mode 100644 patches/upstream-sync/audit.py delete mode 100644 patches/upstream-sync/configure.py delete mode 100644 patches/upstream-sync/package.py delete mode 100644 patches/upstream-sync/pins.json delete mode 100644 patches/upstream-sync/pipeline.py delete mode 100644 patches/upstream-sync/restore_capsule.py delete mode 100644 patches/upstream-sync/transforms.py delete mode 100644 patches/vanilla-water/NativeOverlay.cmake delete mode 100644 patches/vanilla-water/README.md delete mode 100644 patches/vanilla-water/package.py delete mode 100644 patches/vanilla-water/pipeline.py delete mode 100644 patches/vehicle-original-textures/PIPELINE.txt delete mode 100644 patches/vehicle-original-textures/PLAYTEST.txt delete mode 100644 patches/vehicle-original-textures/ReleaseOverlay.cmake delete mode 100644 patches/vehicle-original-textures/audit.py delete mode 100644 patches/vehicle-original-textures/collect_checks.py delete mode 100644 patches/vehicle-original-textures/configure.py delete mode 100644 patches/vehicle-original-textures/finalize.py delete mode 100644 patches/vehicle-original-textures/native/driver_anchor_units.hpp delete mode 100644 patches/vehicle-original-textures/native/package_compatibility.hpp delete mode 100644 patches/vehicle-original-textures/package.py delete mode 100644 patches/vehicle-original-textures/pipeline.py delete mode 100644 patches/vehicle-original-textures/run_checks.py delete mode 100644 patches/vehicle-original-textures/tests/package_checks.cpp delete mode 100644 patches/vehicle-seating/FORMAT.txt delete mode 100644 patches/vehicle-seating/PLAYTEST.txt delete mode 100644 patches/vehicle-seating/ReleaseOverlay.cmake delete mode 100644 patches/vehicle-seating/configure.py delete mode 100644 patches/vehicle-seating/driver_detail_pipeline.py delete mode 100644 patches/vehicle-seating/inspect_sources.py delete mode 100644 patches/vehicle-seating/package.py delete mode 100644 patches/viewport-flow/NativeOverlay.cmake delete mode 100644 patches/viewport-flow/README.md delete mode 100644 patches/viewport-flow/flow_cadence_rt64.hpp delete mode 100644 patches/viewport-flow/flow_history_rt64.hpp delete mode 100644 patches/viewport-flow/flow_phase_rt64.hpp delete mode 100644 patches/viewport-flow/flow_vertex_scope_rt64.hpp delete mode 100644 patches/viewport-flow/intro_viewport_rt64.hpp delete mode 100644 patches/viewport-flow/package.py delete mode 100644 patches/viewport-flow/pipeline.py rename {patches/enhanced-water => runtime-recomp/src/water}/LICENSE-WAVE-RACE.txt (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/LavaFlow.hlsli (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/LavaLighting.hlsli (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/LavaLightingBounds.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/LavaLightingCS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/LavaLightingPS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/LavaLightingVS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterBounds.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterCaptureProbeCS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterFieldCompareCS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterFieldReferenceCS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterPipelineSafety.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterProjection.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterProjection.hlsli (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterProjectionProbeCS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterRayProbeCS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterRayScene.cpp (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterRayScene.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterRays.hlsli (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterResourcePolicy.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterWorkPolicy.h (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterfallFlow.hlsli (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterfallPS.hlsl (100%) rename {patches/enhanced-water/native => runtime-recomp/src/water}/WaterfallVS.hlsl (100%) delete mode 100644 scripts/Refresh-Water-Shaders.cmake delete mode 100644 scripts/adapt-water-reference.py delete mode 100644 scripts/camera_plus_pipeline.py delete mode 100644 scripts/compose-lava-glow.py delete mode 100644 scripts/compose-lava-lighting.py delete mode 100644 scripts/compose-lava.py delete mode 100644 scripts/compose-water-performance.py delete mode 100644 scripts/compose-water-projection.py delete mode 100644 scripts/compose-water-rays.py delete mode 100644 scripts/compose-water-recovery.py delete mode 100644 scripts/compose-water-safety.py delete mode 100644 scripts/compose-water-stability.py delete mode 100644 scripts/compose-water-views.py delete mode 100644 scripts/compose-waterfall.py delete mode 100644 scripts/normalize-water-adapter.py create mode 100644 services/README.md rename {patches => services}/library-workflow/DEPLOYMENT.md (83%) rename {patches => services}/library-workflow/QA.md (100%) create mode 100644 services/library-workflow/README.md rename {patches => services}/library-workflow/backend.inl (100%) create mode 100644 services/library-workflow/baseline/PRIVATE-API/worker-single-file.mjs create mode 100644 services/library-workflow/baseline/SHA256SUMS create mode 100644 services/library-workflow/baseline/assets/api-store.js create mode 100644 services/library-workflow/baseline/assets/app.js rename {patches => services}/library-workflow/baseline/assets/hosted-files.js (100%) create mode 100644 services/library-workflow/baseline/assets/uploads-client.js rename {patches => services}/library-workflow/baseline/index.html (100%) rename {patches => services}/library-workflow/batch-client.js (100%) create mode 100644 services/library-workflow/build.py create mode 100644 services/library-workflow/tests/build_tests.py rename {patches => services}/library-workflow/tests/d1-runtime.cjs (100%) rename {patches => services}/library-workflow/tests/preview.cjs (100%) rename {patches => services}/library-workflow/tests/run.cjs (99%) rename {patches => services}/library-workflow/tests/support.cjs (100%) rename {patches => services}/library-workflow/tests/typecheck.cjs (100%) create mode 100644 services/library-workflow/transforms.py rename {patches => services}/library-workflow/ui.inl (100%) rename {patches => services}/library-workflow/workflow-schema.sql (100%) rename {patches => services}/library-workflow/workflow.css (100%) rename {patches/mod-store/backend => services/mod-store}/OWNER-SETUP.txt (100%) create mode 100644 services/mod-store/README.md create mode 100644 services/mod-store/backfill_library.py create mode 100644 services/mod-store/build.py create mode 100644 services/mod-store/frontend.py rename {patches/mod-store/backend => services/mod-store}/launcher-auth.inl (100%) rename {patches/mod-store/backend => services/mod-store}/launcher-schema.sql (100%) rename {patches/mod-store/backend => services/mod-store}/launcher-service.inl (100%) create mode 100644 services/mod-store/release_manifest.py create mode 100644 services/mod-store/tests/build_tests.py create mode 100644 services/mod-store/tests/fixture/assets/api-store.js create mode 100644 services/mod-store/tests/fixture/assets/app.js create mode 100644 services/mod-store/tests/fixture/assets/uploads-client.js create mode 100644 services/mod-store/tests/fixture/worker-single-file.mjs rename {patches/mod-store/backend => services/mod-store}/upload-hash.inl (100%) rename {patches/mod-store/backend => services/mod-store}/upload-integrity.inl (100%) rename {patches/mod-store/backend => services/mod-store}/web.config (100%) rename {patches => services}/online-directory/DEPLOYMENT.md (100%) rename {patches => services}/online-directory/IMPLEMENTATION.txt (100%) rename {patches => services}/online-directory/PLAYTEST.txt (100%) rename {patches => services}/online-directory/QA.md (100%) create mode 100644 services/online-directory/README.md create mode 100644 services/online-directory/build.py rename {patches/online-directory/backend => services/online-directory}/directory.mjs (100%) rename {patches/online-directory/backend => services/online-directory}/gateway.inl (100%) create mode 100644 services/online-directory/tests/build_tests.py rename {patches => services}/online-directory/tests/runtime.cjs (100%) rename {patches/online-directory/backend => services/online-directory}/wrangler-online.jsonc (100%) create mode 100644 services/test-tools/package-lock.json create mode 100644 services/test-tools/package.json diff --git a/.github/workflows/services-library-workflow.yml b/.github/workflows/services-library-workflow.yml new file mode 100644 index 00000000..ac98af5c --- /dev/null +++ b/.github/workflows/services-library-workflow.yml @@ -0,0 +1,39 @@ +name: Service - library workflow +on: + push: + branches: [main] + paths: ["services/library-workflow/**", "services/test-tools/**", ".github/workflows/services-library-workflow.yml"] + pull_request: + paths: ["services/library-workflow/**", "services/test-tools/**", ".github/workflows/services-library-workflow.yml"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + # Stages the website/API update over services/library-workflow/baseline and + # runs its tests on the staged bundle (services/library-workflow/README.md). + library-workflow: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Stage tests + run: python3 -B services/library-workflow/tests/build_tests.py + - name: Stage the bundle + run: python3 -B services/library-workflow/build.py stage --output "$RUNNER_TEMP/stage" + - name: Workflow checks + run: node services/library-workflow/tests/run.cjs "$RUNNER_TEMP/stage" + - name: Cloudflare runtime checks (Miniflare) + run: | + npm ci --prefix services/test-tools --ignore-scripts + node services/library-workflow/tests/d1-runtime.cjs "$RUNNER_TEMP/stage" services/test-tools/node_modules/miniflare diff --git a/.github/workflows/services-mod-store.yml b/.github/workflows/services-mod-store.yml new file mode 100644 index 00000000..8584d909 --- /dev/null +++ b/.github/workflows/services-mod-store.yml @@ -0,0 +1,28 @@ +name: Service - Mod Store backend +on: + push: + branches: [main] + paths: ["services/mod-store/**", ".github/workflows/services-mod-store.yml"] + pull_request: + paths: ["services/mod-store/**", ".github/workflows/services-mod-store.yml"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + # Stages the backend over tests/fixture, a minimal base holding each splice + # anchor once (services/mod-store/README.md). + mod-store: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Stage tests + run: python3 -B services/mod-store/tests/build_tests.py diff --git a/.github/workflows/services-online-directory.yml b/.github/workflows/services-online-directory.yml new file mode 100644 index 00000000..9e0bbc9b --- /dev/null +++ b/.github/workflows/services-online-directory.yml @@ -0,0 +1,50 @@ +name: Service - online directory +on: + push: + branches: [main] + paths: + - "services/online-directory/**" + - "services/library-workflow/**" # the API Worker the gateway slots into + - "services/test-tools/**" + - ".github/workflows/services-online-directory.yml" + pull_request: + paths: + - "services/online-directory/**" + - "services/library-workflow/**" + - "services/test-tools/**" + - ".github/workflows/services-online-directory.yml" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + # Composes the directory bundle over the library workflow's staged API + # Worker and runs it in Miniflare (services/online-directory/README.md). + online-directory: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Compose tests + run: python3 -B services/online-directory/tests/build_tests.py + - name: Compose the bundle + run: | + python3 -B services/library-workflow/build.py stage --output "$RUNNER_TEMP/library" + python3 -B services/online-directory/build.py \ + --api-worker "$RUNNER_TEMP/library/PRIVATE-API/worker-single-file.mjs" --output "$RUNNER_TEMP/bundle" + - name: Directory runtime checks (Miniflare) + run: | + npm ci --prefix services/test-tools --ignore-scripts + miniflare=services/test-tools/node_modules/miniflare + node services/online-directory/tests/runtime.cjs "$RUNNER_TEMP/bundle" "$miniflare" + node services/online-directory/tests/runtime.cjs "$RUNNER_TEMP/bundle" "$miniflare" --running diff --git a/.gitignore b/.gitignore index 183b0e42..cc3d1340 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,6 @@ runtime-recomp/build/ /.claude/settings.local.json /.claude/session-base /.claude/session-base.*.tmp + +# Pinned Node tools for the services tests (npm ci in services/test-tools) +/services/test-tools/node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70bbd974..a49551eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,7 @@ default_stages: [pre-commit] # The 1.0.7 capsule also pins its first-party "base" files (all of them under # runtime-recomp/src/ and runtime-recomp/tests/replay_probe/); those two # entries go when the fold deletes the capsule, before C/C++ formatting starts. +# services/*/baseline/ are deployed bases pinned by their SHA256SUMS, # tests/fixtures/ are pattern-guard inputs and packaging/licenses/ are verbatim # third-party texts. exclude: | @@ -28,6 +29,7 @@ exclude: | build/| patches/| modding/reference-us-v| + services/[^/]+/baseline/| tests/fixtures/| packaging/licenses/| .*\.patch$ diff --git a/architecture/pattern-guard-baseline.txt b/architecture/pattern-guard-baseline.txt index 677ddbfa..dc6c4ae4 100644 --- a/architecture/pattern-guard-baseline.txt +++ b/architecture/pattern-guard-baseline.txt @@ -9,520 +9,6 @@ # Regenerate after removing hits: python3 scripts/pattern_guard.py --tree --baseline write # Explain a rule: python3 scripts/pattern_guard.py --explain PG001 -patches/achievements/ACHIEVEMENTS.txt PG013 -patches/achievements/PIPELINE.txt PG013 -patches/achievements/ReleaseOverlay.cmake PG001 -patches/achievements/ReleaseOverlay.cmake PG002 -patches/achievements/ReleaseOverlay.cmake PG003 -patches/achievements/audit.py PG013 -patches/achievements/configure.py PG013 -patches/achievements/inspect_rom.py PG004 -patches/achievements/native/achievement_catalogue.cpp PG006 -patches/achievements/native/achievement_ui.inl PG006 -patches/achievements/native/achievements.cpp PG006 -patches/achievements/native/achievements.hpp PG006 -patches/achievements/package.py PG013 -patches/achievements/pipeline.py PG004 -patches/all-player-detail/PIPELINE.txt PG013 -patches/all-player-detail/PLAYTEST.txt PG013 -patches/all-player-detail/ReleaseOverlay.cmake PG001 -patches/all-player-detail/ReleaseOverlay.cmake PG002 -patches/all-player-detail/ReleaseOverlay.cmake PG003 -patches/all-player-detail/ReleaseOverlay.cmake PG010 -patches/all-player-detail/audit.py PG013 -patches/all-player-detail/configure.py PG013 -patches/all-player-detail/native/detail_contract.hpp PG006 -patches/all-player-detail/native/detail_observer.inl PG006 -patches/all-player-detail/package.py PG013 -patches/all-player-detail/pipeline.py PG004 -patches/camera-plus/CMakeLists.txt PG001 -patches/camera-plus/IMPLEMENTATION.md PG013 -patches/camera-plus/NativeOverlay.cmake PG001 -patches/camera-plus/NativeOverlay.cmake PG002 -patches/camera-plus/OpenXR.cmake PG001 -patches/camera-plus/README.md PG013 -patches/camera-plus/SceneKernel.cmake PG001 -patches/camera-plus/camera_editor_ui.inl PG006 -patches/camera-plus/inspect_scene_closure.py PG013 -patches/camera-plus/integration_pipeline.py PG013 -patches/camera-plus/mod/dkr.json PG013 -patches/camera-plus/mod/main.c PG006 -patches/camera-plus/mod/settings.json PG013 -patches/camera-plus/native/camera_actions.cpp PG006 -patches/camera-plus/native/camera_actions.hpp PG006 -patches/camera-plus/native/camera_kernel.c PG006 -patches/camera-plus/native/camera_kernel.h PG006 -patches/camera-plus/native/camera_kernel_guest.h PG006 -patches/camera-plus/native/camera_presentation.cpp PG006 -patches/camera-plus/native/camera_presentation.hpp PG006 -patches/camera-plus/native/camera_rt64.cpp PG006 -patches/camera-plus/native/camera_rt64.hpp PG006 -patches/camera-plus/native/camera_runtime.cpp PG006 -patches/camera-plus/native/camera_runtime.hpp PG006 -patches/camera-plus/native/camera_scene.cpp PG006 -patches/camera-plus/native/camera_scene.hpp PG006 -patches/camera-plus/native/camera_session.cpp PG006 -patches/camera-plus/native/camera_session.hpp PG006 -patches/camera-plus/native/camera_settings.cpp PG006 -patches/camera-plus/native/camera_settings.hpp PG006 -patches/camera-plus/native/camera_snapshot.cpp PG006 -patches/camera-plus/native/camera_snapshot.hpp PG006 -patches/camera-plus/native/camera_xr.cpp PG006 -patches/camera-plus/native/camera_xr.hpp PG006 -patches/camera-plus/package_playtest.py PG013 -patches/camera-plus/sdk/dkr/presentation.h PG006 -patches/camera-plus/vr_device_patch.py PG013 -patches/camera-shadow/NativeOverlay.cmake PG001 -patches/camera-shadow/NativeOverlay.cmake PG002 -patches/camera-shadow/NativeOverlay.cmake PG003 -patches/camera-shadow/RELEASE-NOTES.txt PG013 -patches/camera-shadow/camera_shadow_kernel.c PG006 -patches/camera-shadow/camera_shadow_kernel.h PG006 -patches/camera-shadow/package.py PG013 -patches/camera-shadow/pipeline.py PG013 -patches/camera-shadow/shadow_pass.inl PG006 -patches/character-library-portraits/PIPELINE.txt PG013 -patches/character-library-portraits/ReleaseOverlay.cmake PG001 -patches/character-library-portraits/ReleaseOverlay.cmake PG002 -patches/character-library-portraits/ReleaseOverlay.cmake PG003 -patches/character-library-portraits/configure.py PG013 -patches/character-library-portraits/native/character_portraits.cpp PG006 -patches/character-library-portraits/native/character_portraits.hpp PG006 -patches/character-library-portraits/native/character_portraits_ui.inl PG006 -patches/character-library-portraits/pipeline.py PG004 -patches/character-library-portraits/run_checks.py PG013 -patches/character-library-portraits/tests/inspect_installed.py PG013 -patches/character-library-portraits/tests/portrait_checks.cpp PG006 -patches/character-portraits/NativeOverlay.cmake PG001 -patches/character-portraits/NativeOverlay.cmake PG002 -patches/character-portraits/README.md PG013 -patches/character-portraits/inspect_packages.py PG013 -patches/character-portraits/inspect_sources.py PG013 -patches/character-portraits/load_reviewed_portrait.inc PG006 -patches/character-portraits/package-windows.ps1 PG013 -patches/checkpoint-clock-repair/IMPLEMENTATION.txt PG013 -patches/checkpoint-clock-repair/PLAYTEST.txt PG013 -patches/checkpoint-clock-repair/ReleaseOverlay.cmake PG001 -patches/checkpoint-clock-repair/ReleaseOverlay.cmake PG002 -patches/checkpoint-clock-repair/ReleaseOverlay.cmake PG003 -patches/checkpoint-clock-repair/audit.py PG013 -patches/checkpoint-clock-repair/configure.py PG013 -patches/checkpoint-clock-repair/package.py PG013 -patches/checkpoint-clock-repair/pipeline.py PG004 -patches/checkpoint-clock-repair/transforms.py PG010 -patches/credits-viewport/NativeOverlay.cmake PG001 -patches/credits-viewport/NativeOverlay.cmake PG002 -patches/credits-viewport/NativeOverlay.cmake PG003 -patches/credits-viewport/README.md PG013 -patches/credits-viewport/credit_fade.inl PG006 -patches/credits-viewport/credit_match.inl PG006 -patches/credits-viewport/credits_sky_rt64.hpp PG006 -patches/credits-viewport/package.py PG013 -patches/credits-viewport/pipeline.py PG004 -patches/custom-vehicles/PLAYTEST.txt PG013 -patches/custom-vehicles/REPAIR-NOTES.txt PG013 -patches/custom-vehicles/ReleaseOverlay.cmake PG001 -patches/custom-vehicles/ReleaseOverlay.cmake PG002 -patches/custom-vehicles/ReleaseOverlay.cmake PG003 -patches/custom-vehicles/ReleaseOverlay.cmake PG010 -patches/custom-vehicles/audit_characters.py PG013 -patches/custom-vehicles/character_ownership_review.py PG013 -patches/custom-vehicles/configure109.py PG013 -patches/custom-vehicles/guest_pipeline.py PG013 -patches/custom-vehicles/inspect_base.py PG013 -patches/custom-vehicles/inspect_character_components.py PG013 -patches/custom-vehicles/inspect_inputs.py PG013 -patches/custom-vehicles/inspect_placement.py PG013 -patches/custom-vehicles/inspect_scale.py PG013 -patches/custom-vehicles/inspect_seating.py PG013 -patches/custom-vehicles/music_pipeline.py PG013 -patches/custom-vehicles/native/asset_bus_mount.inl PG006 -patches/custom-vehicles/native/resident_music.cpp PG006 -patches/custom-vehicles/native/resident_music.hpp PG006 -patches/custom-vehicles/native/resident_music_prepare.inl PG006 -patches/custom-vehicles/native/vehicle_base_hashes.hpp PG006 -patches/custom-vehicles/native/vehicle_binding.cpp PG006 -patches/custom-vehicles/native/vehicle_binding.hpp PG006 -patches/custom-vehicles/native/vehicle_character_masks.hpp PG006 -patches/custom-vehicles/native/vehicle_library.cpp PG006 -patches/custom-vehicles/native/vehicle_library.hpp PG006 -patches/custom-vehicles/native/vehicle_online.hpp PG006 -patches/custom-vehicles/native/vehicle_package.cpp PG006 -patches/custom-vehicles/native/vehicle_package.hpp PG006 -patches/custom-vehicles/native/vehicle_protocol.hpp PG006 -patches/custom-vehicles/native/vehicle_resources.cpp PG006 -patches/custom-vehicles/native/vehicle_resources.hpp PG006 -patches/custom-vehicles/native/vehicle_rt64.cpp PG006 -patches/custom-vehicles/native/vehicle_rt64.hpp PG006 -patches/custom-vehicles/native/vehicle_seat_alignment.hpp PG006 -patches/custom-vehicles/native/vehicle_ui.inl PG006 -patches/custom-vehicles/native/vehicle_wheel_interpolation.hpp PG006 -patches/custom-vehicles/native/vehicle_world.cpp PG006 -patches/custom-vehicles/native/vehicle_world.hpp PG006 -patches/custom-vehicles/online_pipeline.py PG013 -patches/custom-vehicles/package_playtest.py PG013 -patches/custom-vehicles/pipeline.py PG004 -patches/detail-records-repair/PLAYTEST.txt PG013 -patches/detail-records-repair/ReleaseOverlay.cmake PG001 -patches/detail-records-repair/ReleaseOverlay.cmake PG002 -patches/detail-records-repair/ReleaseOverlay.cmake PG003 -patches/detail-records-repair/ReleaseOverlay.cmake PG010 -patches/detail-records-repair/audit.py PG013 -patches/detail-records-repair/configure.py PG013 -patches/detail-records-repair/native/detail_batch.inl PG006 -patches/detail-records-repair/native/detail_metrics.hpp PG006 -patches/detail-records-repair/native/detail_texture_cache.inl PG006 -patches/detail-records-repair/native/records_guest.inl PG006 -patches/detail-records-repair/package.py PG013 -patches/detail-records-repair/pipeline.py PG004 -patches/enhanced-water/LICENSE-WAVE-RACE.txt PG013 -patches/enhanced-water/native/LavaFlow.hlsli PG006 -patches/enhanced-water/native/LavaLighting.hlsli PG006 -patches/enhanced-water/native/LavaLightingBounds.h PG006 -patches/enhanced-water/native/LavaLightingCS.hlsl PG006 -patches/enhanced-water/native/LavaLightingPS.hlsl PG006 -patches/enhanced-water/native/LavaLightingVS.hlsl PG006 -patches/enhanced-water/native/WaterBounds.h PG006 -patches/enhanced-water/native/WaterCaptureProbeCS.hlsl PG006 -patches/enhanced-water/native/WaterFieldCompareCS.hlsl PG006 -patches/enhanced-water/native/WaterFieldReferenceCS.hlsl PG006 -patches/enhanced-water/native/WaterPipelineSafety.h PG006 -patches/enhanced-water/native/WaterProjection.h PG006 -patches/enhanced-water/native/WaterProjection.hlsli PG006 -patches/enhanced-water/native/WaterProjectionProbeCS.hlsl PG006 -patches/enhanced-water/native/WaterRayProbeCS.hlsl PG006 -patches/enhanced-water/native/WaterRayScene.cpp PG006 -patches/enhanced-water/native/WaterRayScene.h PG006 -patches/enhanced-water/native/WaterRays.hlsli PG006 -patches/enhanced-water/native/WaterResourcePolicy.h PG006 -patches/enhanced-water/native/WaterWorkPolicy.h PG006 -patches/enhanced-water/native/WaterfallFlow.hlsli PG006 -patches/enhanced-water/native/WaterfallPS.hlsl PG006 -patches/enhanced-water/native/WaterfallVS.hlsl PG006 -patches/graphics-handoff/PIPELINE.txt PG013 -patches/graphics-handoff/PLAYTEST.txt PG013 -patches/graphics-handoff/ReleaseOverlay.cmake PG001 -patches/graphics-handoff/ReleaseOverlay.cmake PG002 -patches/graphics-handoff/ReleaseOverlay.cmake PG003 -patches/graphics-handoff/audit.py PG013 -patches/graphics-handoff/configure.py PG013 -patches/graphics-handoff/finalize.py PG013 -patches/graphics-handoff/native/producer_snapshot.cpp PG006 -patches/graphics-handoff/native/producer_snapshot.hpp PG006 -patches/graphics-handoff/package.py PG013 -patches/graphics-handoff/pipeline.py PG004 -patches/graphics-handoff/run_checks.py PG013 -patches/graphics-handoff/tests/copy_cost.cpp PG006 -patches/graphics-handoff/tests/handoff_tests.cpp PG006 -patches/invite-handoff/IMPLEMENTATION.txt PG013 -patches/invite-handoff/PLAYTEST.txt PG013 -patches/invite-handoff/ReleaseOverlay.cmake PG001 -patches/invite-handoff/ReleaseOverlay.cmake PG002 -patches/invite-handoff/ReleaseOverlay.cmake PG003 -patches/invite-handoff/audit.py PG004 -patches/invite-handoff/configure.py PG013 -patches/invite-handoff/native/invite_handoff.cpp PG006 -patches/invite-handoff/native/invite_handoff.hpp PG006 -patches/invite-handoff/native/invite_ui.inl PG006 -patches/invite-handoff/native/release_inputs.inl PG006 -patches/invite-handoff/package.py PG013 -patches/invite-handoff/pipeline.py PG004 -patches/launcher-polish/NativeOverlay.cmake PG001 -patches/launcher-polish/NativeOverlay.cmake PG002 -patches/launcher-polish/NativeOverlay.cmake PG003 -patches/launcher-polish/pipeline.py PG013 -patches/launcher-polish/support_actions.inl PG006 -patches/library-workflow/DEPLOYMENT.md PG013 -patches/library-workflow/QA.md PG013 -patches/library-workflow/RELEASE-NOTES.txt PG013 -patches/library-workflow/backend.inl PG006 -patches/library-workflow/baseline/assets/hosted-files.js PG013 -patches/library-workflow/baseline/index.html PG013 -patches/library-workflow/batch-client.js PG013 -patches/library-workflow/pipeline.py PG004 -patches/library-workflow/tests/d1-runtime.cjs PG013 -patches/library-workflow/tests/preview.cjs PG013 -patches/library-workflow/tests/run.cjs PG013 -patches/library-workflow/tests/support.cjs PG013 -patches/library-workflow/tests/typecheck.cjs PG013 -patches/library-workflow/transforms.py PG013 -patches/library-workflow/ui.inl PG006 -patches/library-workflow/workflow-schema.sql PG013 -patches/library-workflow/workflow.css PG013 -patches/liquid-streaming/NativeOverlay.cmake PG001 -patches/liquid-streaming/NativeOverlay.cmake PG002 -patches/liquid-streaming/NativeOverlay.cmake PG003 -patches/liquid-streaming/OWNER-NOTES.txt PG013 -patches/liquid-streaming/liquid_replacement_gate.hpp PG006 -patches/liquid-streaming/package.py PG013 -patches/liquid-streaming/pipeline.py PG004 -patches/liquid-streaming/water_residency_rt64.hpp PG006 -patches/lobby-link-repair/IMPLEMENTATION.txt PG013 -patches/lobby-link-repair/PLAYTEST.txt PG013 -patches/lobby-link-repair/ReleaseOverlay.cmake PG001 -patches/lobby-link-repair/ReleaseOverlay.cmake PG002 -patches/lobby-link-repair/ReleaseOverlay.cmake PG003 -patches/lobby-link-repair/audit.py PG004 -patches/lobby-link-repair/configure.py PG013 -patches/lobby-link-repair/package.py PG013 -patches/lobby-link-repair/pipeline.py PG004 -patches/lobby-link-repair/transforms.py PG013 -patches/lobby-restoration/IMPLEMENTATION.txt PG013 -patches/lobby-restoration/PLAYTEST.txt PG013 -patches/lobby-restoration/ReleaseOverlay.cmake PG001 -patches/lobby-restoration/ReleaseOverlay.cmake PG002 -patches/lobby-restoration/ReleaseOverlay.cmake PG003 -patches/lobby-restoration/audit.py PG004 -patches/lobby-restoration/configure.py PG013 -patches/lobby-restoration/native/lobby_adapter.cpp PG006 -patches/lobby-restoration/native/lobby_adapter.hpp PG006 -patches/lobby-restoration/native/lobby_context.inl PG006 -patches/lobby-restoration/native/lobby_controls.inl PG006 -patches/lobby-restoration/native/remove_member.inl PG006 -patches/lobby-restoration/package.py PG013 -patches/lobby-restoration/pipeline.py PG004 -patches/lobby-restoration/transforms.py PG013 -patches/lobby-stability/IMPLEMENTATION.txt PG013 -patches/lobby-stability/PLAYTEST.txt PG013 -patches/lobby-stability/ReleaseOverlay.cmake PG001 -patches/lobby-stability/ReleaseOverlay.cmake PG002 -patches/lobby-stability/ReleaseOverlay.cmake PG003 -patches/lobby-stability/ReleaseOverlay.cmake PG010 -patches/lobby-stability/audit.py PG004 -patches/lobby-stability/configure.py PG013 -patches/lobby-stability/native/cache_repair.hpp PG006 -patches/lobby-stability/native/diagnostics.hpp PG006 -patches/lobby-stability/native/policy.hpp PG006 -patches/lobby-stability/native/task_submission.cpp PG006 -patches/lobby-stability/package.py PG013 -patches/lobby-stability/pipeline.py PG004 -patches/lobby-stability/probe_worker.py PG013 -patches/lobby-stability/tests/CMakeLists.txt PG001 -patches/lobby-stability/tests/policy_tests.cpp PG006 -patches/lobby-stability/tests/sp_fixture.cpp PG006 -patches/lobby-stability/tests/sp_fixture.hpp PG006 -patches/lobby-stability/tests/task_submission_tests.cpp PG006 -patches/lobby-stability/transforms.py PG013 -patches/memory-stability/DIAGNOSIS.txt PG013 -patches/memory-stability/PIPELINE.txt PG013 -patches/memory-stability/PLAYTEST.txt PG013 -patches/memory-stability/ReleaseOverlay.cmake PG001 -patches/memory-stability/ReleaseOverlay.cmake PG002 -patches/memory-stability/ReleaseOverlay.cmake PG003 -patches/memory-stability/ReleaseOverlay.cmake PG010 -patches/memory-stability/audit.py PG013 -patches/memory-stability/configure.py PG013 -patches/memory-stability/finalize.py PG013 -patches/memory-stability/native/primary_heap.h PG006 -patches/memory-stability/package.py PG013 -patches/memory-stability/pipeline.py PG004 -patches/memory-stability/run_checks.py PG013 -patches/memory-stability/tests/allocator_tests.cpp PG006 -patches/memory-stability/tests/owned_entry_tests.cpp PG006 -patches/memory-stability/verify_archive.py PG013 -patches/mod-runtime-repair/PLAYTEST.txt PG013 -patches/mod-runtime-repair/ReleaseOverlay.cmake PG001 -patches/mod-runtime-repair/ReleaseOverlay.cmake PG002 -patches/mod-runtime-repair/ReleaseOverlay.cmake PG003 -patches/mod-runtime-repair/ReleaseOverlay.cmake PG010 -patches/mod-runtime-repair/SDK-WORK-POLICY.txt PG013 -patches/mod-runtime-repair/WORK-NOTES.txt PG013 -patches/mod-runtime-repair/configure.py PG013 -patches/mod-runtime-repair/guest_pipeline.py PG013 -patches/mod-runtime-repair/inspect_sources.py PG004 -patches/mod-runtime-repair/native/magic_code_execution_policy.hpp PG006 -patches/mod-runtime-repair/native/render_heap_support.cpp PG006 -patches/mod-runtime-repair/package.py PG013 -patches/mod-runtime-repair/pipeline.py PG004 -patches/mod-store/INSTALLATION-MANAGER.md PG013 -patches/mod-store/NativeOverlay.cmake PG001 -patches/mod-store/NativeOverlay.cmake PG002 -patches/mod-store/NativeOverlay.cmake PG003 -patches/mod-store/RELEASE-NOTES.txt PG013 -patches/mod-store/backend/OWNER-SETUP.txt PG013 -patches/mod-store/backend/backfill_library.py PG013 -patches/mod-store/backend/launcher-auth.inl PG006 -patches/mod-store/backend/launcher-schema.sql PG013 -patches/mod-store/backend/launcher-service.inl PG006 -patches/mod-store/backend/release_manifest.py PG013 -patches/mod-store/backend/upload-hash.inl PG006 -patches/mod-store/backend/upload-integrity.inl PG006 -patches/mod-store/backend/web.config PG013 -patches/mod-store/frontend_pipeline.py PG013 -patches/mod-store/native/mod_store.cpp PG006 -patches/mod-store/native/mod_store.hpp PG006 -patches/mod-store/native/mod_store_install.cpp PG006 -patches/mod-store/native/mod_store_install.hpp PG006 -patches/mod-store/native/mod_store_management_ui.inl PG006 -patches/mod-store/native/mod_store_names.cpp PG006 -patches/mod-store/native/mod_store_style.inl PG006 -patches/mod-store/native/mod_store_ui.inl PG006 -patches/mod-store/native/store_account_page.inl PG006 -patches/mod-store/native/store_page_shell.inl PG006 -patches/mod-store/native/store_player_activity.inl PG006 -patches/mod-store/native/store_player_details.inl PG006 -patches/mod-store/native/store_player_library.inl PG006 -patches/mod-store/native/store_polish_controls.inl PG006 -patches/mod-store/native/store_polish_modals.inl PG006 -patches/mod-store/native/store_staff_pages.inl PG006 -patches/mod-store/native/store_texture_adapter.inl PG006 -patches/mod-store/native/store_track_adapter.inl PG006 -patches/mod-store/native/store_track_locator.inl PG006 -patches/mod-store/native/store_track_zip.inl PG006 -patches/mod-store/package.py PG013 -patches/mod-store/package_linux.py PG013 -patches/mod-store/pipeline.py PG004 -patches/mod-store/texture_names_pipeline.py PG013 -patches/music-preview-repair/PLAYTEST.txt PG013 -patches/music-preview-repair/REPAIR.txt PG013 -patches/music-preview-repair/ReleaseOverlay.cmake PG001 -patches/music-preview-repair/ReleaseOverlay.cmake PG002 -patches/music-preview-repair/ReleaseOverlay.cmake PG003 -patches/music-preview-repair/ReleaseOverlay.cmake PG010 -patches/music-preview-repair/audit.py PG013 -patches/music-preview-repair/configure.py PG013 -patches/music-preview-repair/package.py PG013 -patches/music-preview-repair/pipeline.py PG004 -patches/offline-retention-repair/IMPLEMENTATION.txt PG013 -patches/offline-retention-repair/PLAYTEST.txt PG013 -patches/offline-retention-repair/ReleaseOverlay.cmake PG001 -patches/offline-retention-repair/ReleaseOverlay.cmake PG002 -patches/offline-retention-repair/ReleaseOverlay.cmake PG003 -patches/offline-retention-repair/audit.py PG004 -patches/offline-retention-repair/configure.py PG013 -patches/offline-retention-repair/handoff.py PG013 -patches/offline-retention-repair/lobby_layout.py PG013 -patches/offline-retention-repair/native/lobby_sidebar.inl PG006 -patches/offline-retention-repair/native/offline_keep.hpp PG006 -patches/offline-retention-repair/native/offline_keep.inl PG006 -patches/offline-retention-repair/package.py PG013 -patches/offline-retention-repair/pipeline.py PG004 -patches/offline-retention-repair/run_checks.py PG013 -patches/offline-retention-repair/tests/retention_tests.cpp PG006 -patches/online-directory/DEPLOYMENT.md PG013 -patches/online-directory/IMPLEMENTATION.txt PG013 -patches/online-directory/PLAYTEST.txt PG013 -patches/online-directory/QA.md PG013 -patches/online-directory/ReleaseOverlay.cmake PG001 -patches/online-directory/ReleaseOverlay.cmake PG002 -patches/online-directory/ReleaseOverlay.cmake PG003 -patches/online-directory/audit.py PG004 -patches/online-directory/backend/directory.mjs PG013 -patches/online-directory/backend/gateway.inl PG006 -patches/online-directory/backend/wrangler-online.jsonc PG013 -patches/online-directory/backend_pipeline.py PG013 -patches/online-directory/configure.py PG013 -patches/online-directory/delivery.py PG013 -patches/online-directory/native/account_bind.inl PG006 -patches/online-directory/native/admission_contract.hpp PG006 -patches/online-directory/native/directory_tls.hpp PG006 -patches/online-directory/native/directory_ui.inl PG006 -patches/online-directory/native/linux_keyring.hpp PG006 -patches/online-directory/native/online_directory.cpp PG006 -patches/online-directory/native/online_directory.hpp PG006 -patches/online-directory/native/owner_pump.inl PG006 -patches/online-directory/native/owner_state.hpp PG006 -patches/online-directory/package.py PG013 -patches/online-directory/pipeline.py PG004 -patches/online-directory/tests/runtime.cjs PG013 -patches/online-directory/transforms.py PG013 -patches/online-performance/FINDINGS.txt PG013 -patches/online-performance/PIPELINE.txt PG013 -patches/online-performance/PLAYTEST.txt PG013 -patches/online-performance/ReleaseOverlay.cmake PG001 -patches/online-performance/ReleaseOverlay.cmake PG002 -patches/online-performance/ReleaseOverlay.cmake PG003 -patches/online-performance/ReleaseOverlay.cmake PG010 -patches/online-performance/audit.py PG004 -patches/online-performance/build.py PG013 -patches/online-performance/configure.py PG013 -patches/online-performance/finalize.py PG013 -patches/online-performance/native/menu_notifications.h PG006 -patches/online-performance/native/runtime_log.hpp PG006 -patches/online-performance/native/setup_policy.hpp PG006 -patches/online-performance/package.py PG013 -patches/online-performance/pipeline.py PG004 -patches/online-performance/run_checks.py PG013 -patches/online-performance/tests/menu_fixture_main.c PG006 -patches/online-performance/tests/menu_fixture_prefix.c PG006 -patches/online-performance/tests/party_checks.inl PG006 -patches/online-performance/tests/setup_checks.cpp PG006 -patches/online-vehicle-menu/PLAYTEST.txt PG013 -patches/online-vehicle-menu/REPAIR.txt PG013 -patches/online-vehicle-menu/ReleaseOverlay.cmake PG001 -patches/online-vehicle-menu/ReleaseOverlay.cmake PG002 -patches/online-vehicle-menu/ReleaseOverlay.cmake PG003 -patches/online-vehicle-menu/ReleaseOverlay.cmake PG010 -patches/online-vehicle-menu/audit.py PG013 -patches/online-vehicle-menu/configure.py PG013 -patches/online-vehicle-menu/native/owned_vehicle_calls.inl PG006 -patches/online-vehicle-menu/native/owned_vehicle_menu.hpp PG006 -patches/online-vehicle-menu/package.py PG013 -patches/online-vehicle-menu/pipeline.py PG004 -patches/party-departure-repair/IMPLEMENTATION.txt PG013 -patches/party-departure-repair/PLAYTEST.txt PG013 -patches/party-departure-repair/ReleaseOverlay.cmake PG001 -patches/party-departure-repair/ReleaseOverlay.cmake PG002 -patches/party-departure-repair/ReleaseOverlay.cmake PG003 -patches/party-departure-repair/audit.py PG013 -patches/party-departure-repair/configure.py PG013 -patches/party-departure-repair/native/close_notice.inl PG006 -patches/party-departure-repair/native/departure.hpp PG006 -patches/party-departure-repair/native/retire_member.inl PG006 -patches/party-departure-repair/package.py PG013 -patches/party-departure-repair/pipeline.py PG004 -patches/party-departure-repair/transforms.py PG010 -patches/party-start-repair/IMPLEMENTATION.txt PG013 -patches/party-start-repair/PLAYTEST.txt PG013 -patches/party-start-repair/ReleaseOverlay.cmake PG001 -patches/party-start-repair/ReleaseOverlay.cmake PG002 -patches/party-start-repair/ReleaseOverlay.cmake PG003 -patches/party-start-repair/audit.py PG004 -patches/party-start-repair/configure.py PG013 -patches/party-start-repair/native/client_start.inl PG006 -patches/party-start-repair/native/host_start.inl PG006 -patches/party-start-repair/native/start_gate.hpp PG006 -patches/party-start-repair/package.py PG013 -patches/party-start-repair/pipeline.py PG004 -patches/party-start-repair/transforms.py PG010 -patches/performance-surgical/NativeOverlay.cmake PG001 -patches/performance-surgical/NativeOverlay.cmake PG002 -patches/performance-surgical/NativeOverlay.cmake PG003 -patches/performance-surgical/OWNER-CHECKS.txt PG013 -patches/performance-surgical/Record-Performance.cmd PG013 -patches/performance-surgical/inspect_sources.py PG013 -patches/performance-surgical/mip_scratch.inl PG006 -patches/performance-surgical/package.py PG013 -patches/performance-surgical/performance_observation.hpp PG006 -patches/performance-surgical/performance_switches.hpp PG006 -patches/performance-surgical/pipeline.py PG004 -patches/performance-surgical/presentation_pacer.hpp PG006 -patches/performance-surgical/reusable_flow_map.hpp PG006 -patches/performance-surgical/texture_binding_index.hpp PG006 -patches/presentation-polish/NativeOverlay.cmake PG001 -patches/presentation-polish/NativeOverlay.cmake PG002 -patches/presentation-polish/NativeOverlay.cmake PG003 -patches/presentation-polish/README.md PG013 -patches/presentation-polish/intro_caption_rt64.hpp PG006 -patches/presentation-polish/intro_sky_rt64.hpp PG006 -patches/presentation-polish/native_resource_safety.hpp PG006 -patches/presentation-polish/package.py PG013 -patches/presentation-polish/pipeline.py PG004 -patches/presentation-polish/water_residency_rt64.hpp PG006 -patches/race-release-repair/IMPLEMENTATION.txt PG013 -patches/race-release-repair/PLAYTEST.txt PG013 -patches/race-release-repair/ReleaseOverlay.cmake PG001 -patches/race-release-repair/ReleaseOverlay.cmake PG002 -patches/race-release-repair/ReleaseOverlay.cmake PG003 -patches/race-release-repair/ReleaseOverlay.cmake PG010 -patches/race-release-repair/audit.py PG004 -patches/race-release-repair/configure.py PG013 -patches/race-release-repair/handoff.py PG013 -patches/race-release-repair/package.py PG013 -patches/race-release-repair/pipeline.py PG004 -patches/race-release-repair/run_checks.py PG013 -patches/race-release-repair/tests/runtime_tests.cpp PG006 -patches/race-release-repair/transforms.py PG013 patches/release-prep/v1.0.10/BUILDING.txt PG013 patches/release-prep/v1.0.10/RELEASE-NOTES.txt PG013 patches/release-prep/v1.0.10/ReleaseOverlay.cmake PG001 @@ -880,348 +366,3 @@ patches/release-prep/v1.0.9/package.py PG013 patches/release-prep/v1.0.9/release.py PG002 patches/release-prep/v1.0.9/release.py PG004 patches/release-prep/v1.0.9/update_assets.py PG013 -patches/render-stability/PIPELINE.txt PG013 -patches/render-stability/PLAYTEST.txt PG013 -patches/render-stability/ReleaseOverlay.cmake PG001 -patches/render-stability/ReleaseOverlay.cmake PG002 -patches/render-stability/ReleaseOverlay.cmake PG003 -patches/render-stability/audit.py PG013 -patches/render-stability/configure.py PG013 -patches/render-stability/finalize.py PG013 -patches/render-stability/native/liquid_replacement_gate.hpp PG006 -patches/render-stability/native/render_provenance.hpp PG006 -patches/render-stability/native/water_residency_rt64.hpp PG006 -patches/render-stability/package.py PG013 -patches/render-stability/pipeline.py PG004 -patches/render-stability/run_checks.py PG013 -patches/render-stability/tests/residency_tests.cpp PG006 -patches/render-stability/tests/selection_tests.cpp PG006 -patches/render-stability/tests/stubs/enhanced_water_rt64.hpp PG006 -patches/render-stability/tests/stubs/performance_observation.hpp PG006 -patches/render-stability/tests/stubs/polish_symbols.hpp PG006 -patches/render-stability/tests/stubs/polish_symbols.hpp PG011 -patches/render-stability/tests/stubs/render/rt64_texture_cache.h PG006 -patches/render-stability/tests/stubs/rice_texture_pack_policy.hpp PG006 -patches/review-editor/BUILDING.txt PG013 -patches/review-editor/ReleaseOverlay.cmake PG001 -patches/review-editor/ReleaseOverlay.cmake PG002 -patches/review-editor/ReleaseOverlay.cmake PG003 -patches/review-editor/configure109.py PG013 -patches/review-editor/native/review_form.inl PG006 -patches/review-editor/native/wrapped_input.inl PG006 -patches/review-editor/pipeline.py PG004 -patches/rollback-connection/IMPLEMENTATION.txt PG013 -patches/rollback-connection/PLAYTEST.txt PG013 -patches/rollback-connection/ReleaseOverlay.cmake PG001 -patches/rollback-connection/ReleaseOverlay.cmake PG002 -patches/rollback-connection/ReleaseOverlay.cmake PG003 -patches/rollback-connection/audit.py PG004 -patches/rollback-connection/configure.py PG013 -patches/rollback-connection/native/connection_ui.inl PG006 -patches/rollback-connection/native/owner_context.inl PG006 -patches/rollback-connection/native/owner_send.inl PG006 -patches/rollback-connection/native/rollback_check.cpp PG006 -patches/rollback-connection/native/rollback_check.hpp PG006 -patches/rollback-connection/package.py PG013 -patches/rollback-connection/pipeline.py PG004 -patches/rollback-connection/transforms.py PG013 -patches/rollback-only/IMPLEMENTATION.txt PG013 -patches/rollback-only/PLAYTEST.txt PG013 -patches/rollback-only/ReleaseOverlay.cmake PG001 -patches/rollback-only/ReleaseOverlay.cmake PG002 -patches/rollback-only/ReleaseOverlay.cmake PG003 -patches/rollback-only/audit.py PG004 -patches/rollback-only/configure.py PG013 -patches/rollback-only/native/host_settings.inl PG006 -patches/rollback-only/native/rollback_policy.hpp PG006 -patches/rollback-only/package.py PG013 -patches/rollback-only/pipeline.py PG004 -patches/rollback-scoreboard/IMPLEMENTATION.txt PG013 -patches/rollback-scoreboard/PLAYTEST.txt PG013 -patches/rollback-scoreboard/ReleaseOverlay.cmake PG001 -patches/rollback-scoreboard/ReleaseOverlay.cmake PG002 -patches/rollback-scoreboard/ReleaseOverlay.cmake PG003 -patches/rollback-scoreboard/audit.py PG004 -patches/rollback-scoreboard/configure.py PG013 -patches/rollback-scoreboard/native/assessment.hpp PG006 -patches/rollback-scoreboard/native/report_ui.inl PG006 -patches/rollback-scoreboard/native/scoreboard_wire.inl PG006 -patches/rollback-scoreboard/package.py PG013 -patches/rollback-scoreboard/pipeline.py PG004 -patches/rollback-scoreboard/transforms.py PG013 -patches/save-layout/README.md PG013 -patches/save-layout/inspect_packages.py PG013 -patches/save-layout/inspect_sources.py PG013 -patches/save-layout/package-linux.sh PG013 -patches/save-layout/package-windows.ps1 PG013 -patches/scene-preparation/PLAYTEST.txt PG013 -patches/scene-preparation/REPAIR.txt PG013 -patches/scene-preparation/ReleaseOverlay.cmake PG001 -patches/scene-preparation/ReleaseOverlay.cmake PG002 -patches/scene-preparation/ReleaseOverlay.cmake PG003 -patches/scene-preparation/ReleaseOverlay.cmake PG010 -patches/scene-preparation/audit.py PG004 -patches/scene-preparation/configure.py PG013 -patches/scene-preparation/native/scene_load_work.hpp PG006 -patches/scene-preparation/package.py PG013 -patches/scene-preparation/pipeline.py PG013 -patches/sdk2/BIBLE.txt PG013 -patches/sdk2/DEV-MENU-IMPORT-FIX.txt PG013 -patches/sdk2/IMPLEMENTATION-PLAN.txt PG013 -patches/sdk2/NativeOverlay.cmake PG001 -patches/sdk2/NativeOverlay.cmake PG002 -patches/sdk2/NativeOverlay.cmake PG003 -patches/sdk2/NativeOverlay.cmake PG010 -patches/sdk2/PLAYTEST-README.txt PG013 -patches/sdk2/RELEASE-NOTES.txt PG013 -patches/sdk2/SDK-README.txt PG013 -patches/sdk2/STARTUP-FIX-README.txt PG013 -patches/sdk2/WORK-STATUS.txt PG013 -patches/sdk2/android_startup_init.gradle PG013 -patches/sdk2/catalogue_pipeline.py PG013 -patches/sdk2/compatibility.py PG013 -patches/sdk2/control_pipeline.py PG013 -patches/sdk2/distribution.py PG013 -patches/sdk2/documentation.py PG013 -patches/sdk2/documentation_qa.py PG013 -patches/sdk2/finalize_delivery.py PG013 -patches/sdk2/inspect_delivery.py PG013 -patches/sdk2/lobby_pipeline.py PG013 -patches/sdk2/mod/dkr.json PG013 -patches/sdk2/mod/documents.inl PG006 -patches/sdk2/mod/main.c PG006 -patches/sdk2/mod/mod.toml PG006 -patches/sdk2/mod/tools.py PG013 -patches/sdk2/mod/track.inl PG006 -patches/sdk2/native/sdk2_asset_inventory.inl PG006 -patches/sdk2/native/sdk2_bridge.cpp PG006 -patches/sdk2/native/sdk2_bridge.hpp PG006 -patches/sdk2/native/sdk2_broker.inl PG006 -patches/sdk2/native/sdk2_camera.inl PG006 -patches/sdk2/native/sdk2_collision.inl PG006 -patches/sdk2/native/sdk2_command.hpp PG006 -patches/sdk2/native/sdk2_debug.inl PG006 -patches/sdk2/native/sdk2_documents.inl PG006 -patches/sdk2/native/sdk2_operations.inl PG006 -patches/sdk2/native/sdk2_overrides.inl PG006 -patches/sdk2/native/sdk2_properties.inl PG006 -patches/sdk2/native/sdk2_reload.inl PG006 -patches/sdk2/native/sdk2_schema.hpp PG006 -patches/sdk2/native/sdk2_settings.inl PG006 -patches/sdk2/native/sdk2_simulation.inl PG006 -patches/sdk2/native/sdk2_tools_camera.inl PG006 -patches/sdk2/native/sdk2_tools_console.inl PG006 -patches/sdk2/native/sdk2_tools_documents.inl PG006 -patches/sdk2/native/sdk2_tools_reload.inl PG006 -patches/sdk2/native/sdk2_tools_ui.inl PG006 -patches/sdk2/native/sdk2_tools_workspace.inl PG006 -patches/sdk2/native/sdk2_track_catalogue.inl PG006 -patches/sdk2/native/sdk2_tuning.inl PG006 -patches/sdk2/native/sdk2_world.inl PG006 -patches/sdk2/network_pipeline.py PG013 -patches/sdk2/package_import_fix.py PG013 -patches/sdk2/package_startup_fix.py PG004 -patches/sdk2/pipeline.py PG004 -patches/sdk2/presentation_pipeline.py PG013 -patches/sdk2/reload_pipeline.py PG013 -patches/sdk2/reuse_stage.py PG013 -patches/sdk2/sdk/development.h PG006 -patches/sdk2/sdk/development.hpp PG006 -patches/sdk2/sdk/json.h PG006 -patches/sdk2/sdk/json_read.h PG006 -patches/sdk2/sdk/relocations.py PG013 -patches/sdk2/sdk/tools.py PG013 -patches/sdk2/settings_pipeline.py PG013 -patches/shared-saves/NativeOverlay.cmake PG001 -patches/shared-saves/NativeOverlay.cmake PG002 -patches/shared-saves/NativeOverlay.cmake PG003 -patches/shared-saves/NativeOverlay.cmake PG010 -patches/shared-saves/README.md PG013 -patches/shared-saves/RELEASE-NOTES.txt PG013 -patches/shared-saves/bundle_dialog.inl PG006 -patches/shared-saves/cache_ui.inl PG006 -patches/shared-saves/custom_records.hpp PG006 -patches/shared-saves/import_dialog.inl PG006 -patches/shared-saves/inspect_build.py PG013 -patches/shared-saves/online_seed.inl PG006 -patches/shared-saves/overview_ui.inl PG006 -patches/shared-saves/package.py PG013 -patches/shared-saves/page_ui.inl PG006 -patches/shared-saves/pipeline.py PG004 -patches/shared-saves/record_guest.inl PG006 -patches/shared-saves/record_store.inl PG006 -patches/shared-saves/shared_save_backend.inl PG006 -patches/shared-saves/shared_save_jobs.inl PG006 -patches/shared-saves/shared_saves.hpp PG006 -patches/spectator-admission-repair/IMPLEMENTATION.txt PG013 -patches/spectator-admission-repair/PLAYTEST.txt PG013 -patches/spectator-admission-repair/ReleaseOverlay.cmake PG001 -patches/spectator-admission-repair/ReleaseOverlay.cmake PG002 -patches/spectator-admission-repair/ReleaseOverlay.cmake PG003 -patches/spectator-admission-repair/audit.py PG004 -patches/spectator-admission-repair/configure.py PG013 -patches/spectator-admission-repair/handoff.py PG013 -patches/spectator-admission-repair/native/admission_retry.hpp PG006 -patches/spectator-admission-repair/native/observer_music.hpp PG006 -patches/spectator-admission-repair/package.py PG013 -patches/spectator-admission-repair/pipeline.py PG004 -patches/spectator-admission-repair/run_checks.py PG013 -patches/spectator-admission-repair/tests/handler_tests.cpp PG006 -patches/spectator-admission-repair/tests/music_tests.cpp PG006 -patches/spectator-admission-repair/transforms.py PG013 -patches/spectators/IMPLEMENTATION.txt PG013 -patches/spectators/PLAYTEST.txt PG013 -patches/spectators/ReleaseOverlay.cmake PG001 -patches/spectators/ReleaseOverlay.cmake PG002 -patches/spectators/ReleaseOverlay.cmake PG003 -patches/spectators/audit.py PG010 -patches/spectators/configure.py PG013 -patches/spectators/core/CMakeLists.txt PG001 -patches/spectators/link_pipeline.py PG013 -patches/spectators/native/admit_component.inl PG006 -patches/spectators/native/admit_eeprom.inl PG006 -patches/spectators/native/admit_magic.inl PG006 -patches/spectators/native/admit_paks.inl PG006 -patches/spectators/native/checkpoint_transfer.cpp PG006 -patches/spectators/native/checkpoint_transfer.hpp PG006 -patches/spectators/native/confirmed_feed.cpp PG006 -patches/spectators/native/confirmed_feed.hpp PG006 -patches/spectators/native/confirmed_observer.cpp PG006 -patches/spectators/native/confirmed_observer.hpp PG006 -patches/spectators/native/director_pose.inl PG006 -patches/spectators/native/link_approve.inl PG006 -patches/spectators/native/link_receive.inl PG006 -patches/spectators/native/link_service.inl PG006 -patches/spectators/native/observer_playback.cpp PG006 -patches/spectators/native/observer_playback.hpp PG006 -patches/spectators/native/owned_observer.cpp PG006 -patches/spectators/native/owned_observer.hpp PG006 -patches/spectators/native/owner_export.cpp PG006 -patches/spectators/native/owner_export.hpp PG006 -patches/spectators/native/party.cpp PG006 -patches/spectators/native/party.hpp PG006 -patches/spectators/native/party_camera.hpp PG006 -patches/spectators/native/party_codec.cpp PG006 -patches/spectators/native/party_codec.hpp PG006 -patches/spectators/native/party_link.cpp PG006 -patches/spectators/native/party_link.hpp PG006 -patches/spectators/native/party_runtime.cpp PG006 -patches/spectators/native/party_runtime.hpp PG006 -patches/spectators/native/party_ui.inl PG006 -patches/spectators/native/runtime_runner.inl PG006 -patches/spectators/owned_pipeline.py PG013 -patches/spectators/package.py PG013 -patches/spectators/pipeline.py PG004 -patches/spectators/runtime_pipeline.py PG010 -patches/spectators/stage_core.py PG013 -patches/spectators/ui_pipeline.py PG004 -patches/startup-memory/BUILDING.txt PG013 -patches/startup-memory/DIAGNOSIS.txt PG013 -patches/startup-memory/RuntimeMemory.cmake PG001 -patches/startup-memory/history/RELEASE-VALIDATION-1.0.7.txt PG013 -patches/startup-memory/inspect108.py PG004 -patches/startup-memory/pipeline.py PG004 -patches/startup-memory/release108.py PG004 -patches/store-vehicles/IMPLEMENTATION.txt PG013 -patches/store-vehicles/PLAYTEST.txt PG013 -patches/store-vehicles/ReleaseOverlay.cmake PG001 -patches/store-vehicles/ReleaseOverlay.cmake PG002 -patches/store-vehicles/ReleaseOverlay.cmake PG003 -patches/store-vehicles/audit.py PG004 -patches/store-vehicles/configure.py PG013 -patches/store-vehicles/native/store_vehicle_adapter.cpp PG006 -patches/store-vehicles/native/store_vehicle_adapter.hpp PG006 -patches/store-vehicles/native/vehicle_store_ui.inl PG006 -patches/store-vehicles/package.py PG013 -patches/store-vehicles/pipeline.py PG004 -patches/store-vehicles/transforms.py PG013 -patches/texture-dumping/ReleaseOverlay.cmake PG001 -patches/texture-dumping/ReleaseOverlay.cmake PG002 -patches/texture-dumping/ReleaseOverlay.cmake PG003 -patches/texture-dumping/TEXTURE-DUMPING.txt PG013 -patches/texture-dumping/configure.py PG013 -patches/texture-dumping/inspect_dump.py PG013 -patches/texture-dumping/native/texture_capture_bridge.hpp PG006 -patches/texture-dumping/native/texture_decode.hpp PG006 -patches/texture-dumping/native/texture_dump.cpp PG006 -patches/texture-dumping/native/texture_dump.hpp PG006 -patches/texture-dumping/native/texture_dump_ui.inl PG006 -patches/texture-dumping/native/texture_gallery.inl PG006 -patches/texture-dumping/package.py PG013 -patches/texture-dumping/pipeline.py PG004 -patches/title-text/NativeOverlay.cmake PG001 -patches/title-text/NativeOverlay.cmake PG002 -patches/title-text/NativeOverlay.cmake PG003 -patches/title-text/README.md PG013 -patches/title-text/package.py PG013 -patches/title-text/pipeline.py PG004 -patches/title-text/title_text_rt64.hpp PG006 -patches/upstream-sync/IMPLEMENTATION.txt PG013 -patches/upstream-sync/PLAYTEST.txt PG013 -patches/upstream-sync/ReleaseOverlay.cmake PG001 -patches/upstream-sync/ReleaseOverlay.cmake PG002 -patches/upstream-sync/ReleaseOverlay.cmake PG003 -patches/upstream-sync/audit.py PG013 -patches/upstream-sync/configure.py PG013 -patches/upstream-sync/package.py PG013 -patches/upstream-sync/pins.json PG013 -patches/upstream-sync/pipeline.py PG004 -patches/upstream-sync/restore_capsule.py PG005 -patches/upstream-sync/transforms.py PG013 -patches/vanilla-water/NativeOverlay.cmake PG001 -patches/vanilla-water/NativeOverlay.cmake PG002 -patches/vanilla-water/NativeOverlay.cmake PG003 -patches/vanilla-water/README.md PG013 -patches/vanilla-water/package.py PG013 -patches/vanilla-water/pipeline.py PG004 -patches/vehicle-original-textures/PIPELINE.txt PG013 -patches/vehicle-original-textures/PLAYTEST.txt PG013 -patches/vehicle-original-textures/ReleaseOverlay.cmake PG001 -patches/vehicle-original-textures/ReleaseOverlay.cmake PG002 -patches/vehicle-original-textures/ReleaseOverlay.cmake PG003 -patches/vehicle-original-textures/audit.py PG013 -patches/vehicle-original-textures/collect_checks.py PG013 -patches/vehicle-original-textures/configure.py PG013 -patches/vehicle-original-textures/finalize.py PG013 -patches/vehicle-original-textures/native/driver_anchor_units.hpp PG006 -patches/vehicle-original-textures/native/package_compatibility.hpp PG006 -patches/vehicle-original-textures/package.py PG013 -patches/vehicle-original-textures/pipeline.py PG004 -patches/vehicle-original-textures/run_checks.py PG013 -patches/vehicle-original-textures/tests/package_checks.cpp PG006 -patches/vehicle-seating/FORMAT.txt PG013 -patches/vehicle-seating/PLAYTEST.txt PG013 -patches/vehicle-seating/ReleaseOverlay.cmake PG001 -patches/vehicle-seating/ReleaseOverlay.cmake PG002 -patches/vehicle-seating/ReleaseOverlay.cmake PG003 -patches/vehicle-seating/ReleaseOverlay.cmake PG010 -patches/vehicle-seating/configure.py PG013 -patches/vehicle-seating/driver_detail_pipeline.py PG013 -patches/vehicle-seating/inspect_sources.py PG013 -patches/vehicle-seating/package.py PG013 -patches/viewport-flow/NativeOverlay.cmake PG001 -patches/viewport-flow/NativeOverlay.cmake PG002 -patches/viewport-flow/NativeOverlay.cmake PG003 -patches/viewport-flow/README.md PG013 -patches/viewport-flow/flow_cadence_rt64.hpp PG006 -patches/viewport-flow/flow_history_rt64.hpp PG006 -patches/viewport-flow/flow_phase_rt64.hpp PG006 -patches/viewport-flow/flow_vertex_scope_rt64.hpp PG006 -patches/viewport-flow/intro_viewport_rt64.hpp PG006 -patches/viewport-flow/package.py PG013 -patches/viewport-flow/pipeline.py PG004 -runtime-recomp/src/game/netplay/experimental_runtime.cpp PG010 -scripts/adapt-water-reference.py PG004 -scripts/camera_plus_pipeline.py PG004 -scripts/compose-lava-glow.py PG004 -scripts/compose-lava-lighting.py PG004 -scripts/compose-lava.py PG004 -scripts/compose-water-performance.py PG004 -scripts/compose-water-projection.py PG004 -scripts/compose-water-rays.py PG004 -scripts/compose-water-recovery.py PG004 -scripts/compose-water-safety.py PG004 -scripts/compose-water-stability.py PG004 -scripts/compose-water-views.py PG004 -scripts/compose-waterfall.py PG004 -scripts/normalize-water-adapter.py PG004 diff --git a/docs/ARM-PLATFORM-VALIDATION.md b/docs/ARM-PLATFORM-VALIDATION.md index 3e98984b..96960a2d 100644 --- a/docs/ARM-PLATFORM-VALIDATION.md +++ b/docs/ARM-PLATFORM-VALIDATION.md @@ -140,7 +140,7 @@ native crash traces. The first GPU failure and renderer setup were missing. - The pinned renderer requests `B8G8R8A8_UNORM` for both the swapchain and the final VI presentation pipelines. Its Vulkan swapchain constructor requires an exact supported surface format. Android guarantees RGBA8, not BGRA8; - see the primary reference in `patches/android/README.md`. + see the pre-fold `patches/android/README.md` (git history). - An Android-only build-stage patch now uses `R8G8B8A8_UNORM` at both sites. The inspector already takes its attachment format from the swapchain. - Only staged copies of those two source files change. Dependency checkout @@ -212,8 +212,8 @@ pass. Preview.3 is retained as the known-playing rollback build. for externally managed Vulkan; its surface-change callback also lacked an OpenGL-window guard, and surface destruction waited for EGL backup even on Vulkan. The scoped external-context hint and checked SDL build-stage patch - address these concrete defects. See `patches/android/README.md` for source - details and the SDL reference. No SDL/RT64 dependency checkout was edited. + address these concrete defects. The pre-fold `patches/android/README.md` (git + history) has source details and the SDL reference. No SDL/RT64 dependency checkout was edited. 4. **Diagnostics without screen capture:** Android now logs effective graphics configuration, five-second samples of existing presentation/simulation/VI counters, and relevant SDL lifecycle events. Metrics are observational and diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 5e30a8e8..ec15f369 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -277,8 +277,6 @@ outstanding device/gameplay checks in [release validation](../RELEASE-VALIDATION ## Retained v1.0.8 startup hotfix -The current release includes [the startup hotfix pipeline](../patches/startup-memory/BUILDING.txt). -That earlier layer used the frozen 1.0.7 inputs and an isolated runtime-memory -patch for 1.0.8 (Android code 1080001). The current outer release layer supplies -1.0.11 for Windows/Linux while retaining the startup correction. Android +The v1.0.8 startup correction (Android code 1080001) is the registered +dependency patch `patches/startup-memory/runtime-memory.patch`. Android release metadata is not changed by this release. diff --git a/docs/GRAPHICS.md b/docs/GRAPHICS.md index 11d819ab..b8703b2a 100644 --- a/docs/GRAPHICS.md +++ b/docs/GRAPHICS.md @@ -29,7 +29,7 @@ report reproducible gaps with the track, position, aspect ratio and FOV. The optional **ModernCamera++** code mod supplies camera presets, local profiles and live F7 tuning. Import/approve it like other Custom Magic Codes. Camera presentation and the shadow-interpolation correction are separate from -shared simulation. See [Camera++](../patches/camera-plus/README.md). +shared simulation (`runtime-recomp/cmake/Camera.cmake`). VR is unfinished and not a supported feature of this release. ## Vehicles and scenery diff --git a/docs/MOD_LIBRARY_ADMIN.md b/docs/MOD_LIBRARY_ADMIN.md index 10bf04a9..b1eb1d64 100644 --- a/docs/MOD_LIBRARY_ADMIN.md +++ b/docs/MOD_LIBRARY_ADMIN.md @@ -42,14 +42,14 @@ must not be advertised as supported imports. See [Vehicles](VEHICLES.md). ## Deployment and rollback -Build the library workflow bundle through -[`patches/library-workflow/pipeline.py`](../patches/library-workflow/pipeline.py) -and follow its [deployment instructions](../patches/library-workflow/DEPLOYMENT.md). +Build the library workflow bundle with +[`services/library-workflow/build.py`](../services/library-workflow/README.md) +and follow its [deployment instructions](../services/library-workflow/DEPLOYMENT.md). Preserve the live database, secrets, account bindings and upload configuration. Keep a source/configuration backup before replacing the website/API files. The Online directory composes over that API. Follow the separate -[directory deployment guide](../patches/online-directory/DEPLOYMENT.md) for its +[directory deployment guide](../services/online-directory/DEPLOYMENT.md) for its private Worker, binding and feature flags. Worker JavaScript is deployed to Cloudflare, not copied to the FTP site. Compare newer live API edits with the bundle baseline before replacing anything. No deployment is performed by the diff --git a/docs/PUBLIC_LOBBIES.md b/docs/PUBLIC_LOBBIES.md index 52b44ea2..f6170659 100644 --- a/docs/PUBLIC_LOBBIES.md +++ b/docs/PUBLIC_LOBBIES.md @@ -80,7 +80,7 @@ Worker first and preserve the live API's existing account/FTP/database bindings. The launcher and existing website API are the feature's integration points; FTP remains the mod-upload method. -See the [deployment and rollback instructions](../patches/online-directory/DEPLOYMENT.md), -[local backend QA record](../patches/online-directory/QA.md) and -[owner acceptance checklist](../patches/online-directory/PLAYTEST.txt). +See the [deployment and rollback instructions](../services/online-directory/DEPLOYMENT.md), +[local backend QA record](../services/online-directory/QA.md) and +[owner acceptance checklist](../services/online-directory/PLAYTEST.txt). Do not treat local synthetic load tests as proof of worldwide network behaviour. diff --git a/docs/TEXTURE_PACKS.md b/docs/TEXTURE_PACKS.md index 60efde75..406b5da5 100644 --- a/docs/TEXTURE_PACKS.md +++ b/docs/TEXTURE_PACKS.md @@ -221,5 +221,6 @@ Water replacements still need the existing complete-family rules. Keep original-art dumps private. The starter omits ROMs and source reports, but unedited Working images can still be original game art. Distribute only replacement artwork you have permission to share. The detailed original -[texture-tool implementation/checklist](../patches/texture-dumping/TEXTURE-DUMPING.txt) -is retained as engineering history, not v1.0.11 hardware qualification. +texture-tool implementation/checklist (`patches/texture-dumping/TEXTURE-DUMPING.txt` +before the fold, in git history) is engineering history, not v1.0.11 hardware +qualification. diff --git a/docs/contracts/ONLINE_IDENTITY.md b/docs/contracts/ONLINE_IDENTITY.md index db67e485..08cec864 100644 --- a/docs/contracts/ONLINE_IDENTITY.md +++ b/docs/contracts/ONLINE_IDENTITY.md @@ -17,6 +17,11 @@ change. `cmake/OnlineIdentity.cmake` reads them into the compile definitions `netplay/experimental_owned_registry.cpp` returns. A build without the Rev A engine offers no v80 contract. +The owned engine identity a lobby manifest carries (`owned_engine_hash`, +`experimental::runtime_engine_identity`) is sha256 of the owned adapter +identity followed by four frozen digests, `DKR_LEGACY_ENGINE_SALT_1` to `_4` +in the same header. + The rollback test frontend (`DKR-R-RollbackTest`, not shipped) negotiates `sha256("v77:|v80:")` of the same two values. diff --git a/docs/fold-in/open-items.md b/docs/fold-in/open-items.md index ee8a0334..f3d439b6 100644 --- a/docs/fold-in/open-items.md +++ b/docs/fold-in/open-items.md @@ -45,6 +45,10 @@ checkout (`prepare-dependencies` `decomp-symbols`) and, on Android, the new `android-full` dependency profile. actionlint and zizmor are clean locally. **Neither job has run yet**; they first run when PR 2 opens. +Also blocking: a real Windows run of `Prepare-DKR-Runtime.ps1` (both ROMs, +WSL) followed by `Build-DKR-R-Windows.ps1`, by ThatGuyMcd or Steve. Windows is +the platform that ships; the scripts only parse-checked here. + Known before they run: - `android-folded` was red before B6. A local run in the CI image (NDK r29, @@ -61,6 +65,29 @@ Known before they run: - Adding `decomp-symbols` to the source cache key invalidates the existing dependency caches once. +## Removed or renamed entrypoints + +For the PR-2 description. Each line is what a maintainer ran before and what +replaces it. + +| Before | Now | +|---|---| +| `Diagnose-DKR-Recompile.cmd` / `scripts/Diagnose-DKR-Recompile.ps1` | `python scripts/compose_payloads.py payload --revision ...` reruns N64Recomp for one revision | +| `scripts/Prepare-SaveLayoutPayload.ps1`, `scripts/Prepare-CharacterPortraitPayload.ps1` | `scripts/prepare_payloads.py`, which `Prepare-DKR-Runtime.cmd` and `Prepare-DKR-Runtime-macOS.sh` run last | +| `generate_legacy_menu_qualification.py --characters --character-menu` for release payloads | `Prepare-DKR-Runtime.cmd` (`scripts/compose_payloads.py`); the qualification script stays for menu qualification only | +| `generate_replay_probe.py` / `generate_visual_model_kernel.py` run by hand for the owned engine and model kernels | `scripts/prepare_payloads.py` (writes `build/prepared`; CMake reads it by default) | +| `patches/camera-plus/scene_kernel_pipeline.py` | `scripts/camera_scene_kernel.py` (run by `prepare_payloads.py`) | +| `scripts/camera_plus_pipeline.py` | none: Camera++ is source in `runtime-recomp/src/game` and `cmake/Camera.cmake` | +| `scripts/compose-water-*.py`, `compose-lava*.py`, `compose-waterfall.py`, `adapt-water-reference.py`, `normalize-water-adapter.py` | edit `patches/enhanced-water/*.patch` (registered RT64 patches) and `runtime-recomp/src/water/` directly | +| `scripts/Refresh-Water-Shaders.cmake` | none: the configure restages and rebuilds the water shaders | +| `patches/enhanced-water/native/*`, `LICENSE-WAVE-RACE.txt` | `runtime-recomp/src/water/` | +| `patches//pipeline.py`, `configure.py`, `audit.py`, `package*.py`, `package-*.sh/.ps1`, `ReleaseOverlay.cmake` | none: the fold is the source; build and package the tree normally | +| `patches/library-workflow/pipeline.py` (+ `transforms.py`) | `services/library-workflow/build.py stage` (base defaults to `services/library-workflow/baseline/`) | +| `patches/online-directory/backend_pipeline.py` | `services/online-directory/build.py --api-worker ` | +| `patches/online-directory/delivery.py` | none: it finalized the 1.0.9 public-lobbies playtest delivery only | +| `patches/mod-store/pipeline.py` (backend), `frontend_pipeline.py` | `services/mod-store/build.py --worker [--frontend ]`; the native half is in `runtime-recomp/src/game` | +| `-GeneratedSource` required by `Build-Windows.cmd` | optional; defaults to `build\prepared` | + ## Open questions - **Android release transforms aren't covered.** The gate and the stage @@ -152,19 +179,12 @@ Known before they run: (`src/game/build_contract.hpp`). Moving it into `legacy_identity.hpp` would cost a level 1 delta now, so that waits for Phase C. -## `patches/` content the build still reads (before B8) - -Everything else under `patches/` that the base CMake reads is a dependency -`*.patch` (fold step B3 registers them). +## `patches/` after B8 -| Directory | Read by | What it is | Plan | -|---|---|---|---| -| `enhanced-water/native/` | `cmake/EnhancedWater.cmake` (copies the HLSL and headers into RT64's staged tree), `DKREnhancedWaterPolicyTests` include path, `scripts/compose-water-*.py` (write it) | first-party RT64 additions: water and lava shaders, `WaterRayScene.cpp`, policy headers | B3 or B8: either first-party source under `runtime-recomp/` staged into RT64 the same way, or new files inside a registered RT64 patch. Not moved yet: the compose scripts still write into this directory | -| `startup-memory/` | `RuntimeMemory.cmake` was included by `cmake/PerformancePatches.cmake` | one `dkr_stage_patch` call plus `runtime-memory.patch` | call folded into `PerformancePatches.cmake`; the patch is registered in B3, the directory's other files go in B8 | -| `custom-magic-codes/` | `cmake/PerformancePatches.cmake` | `runtime-code-heap.patch` only | B3 registers it | -`camera-plus/` is also still read, by `scripts/camera_plus_pipeline.py` -(the release chain's Camera++ stage, B8 deletes it). The scene kernel's -generator is `scripts/camera_scene_kernel.py` (B8a). +`patches/` holds `manifest.json` and the registered dependency `*.patch` files +only (release-prep aside, see below). The enhanced-water RT64 additions are +first-party source in `runtime-recomp/src/water/`, staged into RT64 by +`cmake/EnhancedWater.cmake`; the service backends are in `services/`. ## Prepared inputs (B8a) diff --git a/docs/history/RELEASE-VALIDATION-1.0.8.md b/docs/history/RELEASE-VALIDATION-1.0.8.md index 6ab00a8a..dfedabf5 100644 --- a/docs/history/RELEASE-VALIDATION-1.0.8.md +++ b/docs/history/RELEASE-VALIDATION-1.0.8.md @@ -21,5 +21,5 @@ existing private release signing key; it was not replaced. No push/tag/publish was performed as an implicit part of this build pass. Private evidence: D:/DiddykongWorkFolder/release-v1.0.8-20261020/evidence. -The [preceding stable build record](patches/startup-memory/history/RELEASE-VALIDATION-1.0.7.txt) -is preserved as history, not reclassified as new gameplay acceptance. +The preceding stable build record (`patches/startup-memory/history/RELEASE-VALIDATION-1.0.7.txt` +before the fold, in git history) is preserved as history, not reclassified as new gameplay acceptance. diff --git a/patches/achievements/ACHIEVEMENTS.txt b/patches/achievements/ACHIEVEMENTS.txt deleted file mode 100644 index 46664dce..00000000 --- a/patches/achievements/ACHIEVEMENTS.txt +++ /dev/null @@ -1,70 +0,0 @@ -DKR-R 1.0.9 - Achievements Playtest 1 - -We've now got our own achievements built into DKR-R. No account needed and no -internet connection required. Open ACHIEVEMENTS, just below TEXTURES, to see -your collection. The 94 goals cover the same challenges as the original DKR -RetroAchievements base set, with our own implementation and presentation. - -WHAT'S IN HERE -20 T.T. times, 20 developer times, 20 Adventure Two silver-coin races, -five fully upgraded power-ups, ten five-balloon character challenges, -and 19 adventure, key, trophy, boss and collection milestones. - -Beat a goal and you'll get a rounded notification and a short unlock chime. -Settings lets you change the position, size, duration, sound and motion. -Pin an unfinished goal to keep it visible while you play. Controller users -can use A to open an entry, B to return, and LB/RB to switch status filters. -Search has an on-screen keyboard. Categories and sorting are available too. - -YOUR PROGRESS -Achievements have their own progress file in your DKR-R user-data folder: -achievements/progress-v1.json -They don't go inside your game save. Changing mods, deleting an adventure, -or updating DKR-R won't automatically reset your collection. Backups are -kept separately. Export/import uses DKR-R-Achievements.json in your chosen -folder. Imports merge earned achievements rather than deleting your own. - -Already got a save on the go? We don't pretend we watched you earn it. -Load the adventure, then use Recognise existing progress in achievement -settings if you want its provable milestones recognised. Those entries are -labelled. Time trials, power-ups and five-balloon session goals still need -gameplay. Max Out Save doesn't itself generate achievement unlocks. - -CHALLENGE RULES -T.T./developer challenges need an original, three-lap, non-mirrored Time -Trial with the specified car, hovercraft or plane. The recorded race timer -is used, not your screen's frame rate. Developer targets come from the -game's credits. T.T. challenges do not require the ghost to be beaten again -if the actual qualifying time is already achieved in that run. - -Silver-coin challenges need eight coins AND first place in the same original -Adventure Two race. Original trophy goals require a gold trophy. Adventure -goals belong to one adventure, not balloons added across multiple slots. - -Each character goal needs five NEW balloons with that base driver in one -Adventure session. Loading five existing balloons doesn't count. Leaving -Adventure, changing the save or closing the game restarts that attempt. -Custom character models retain their base driver's gameplay identity. - -Cosmetic mods work with applicable goals. Custom courses/cups cannot stand -in for original ones. Multiplayer item goals count local human racers only. -Experimental Rollback uses confirmed observations, not predicted frames. -Developer sessions pause tracking so debug commands don't award progress. -There is no Hardcore mode, global leaderboard, login or RetroAchievements -account synchronisation. These are personal, local DKR-R achievements. - -KEEPING THINGS SAFE -The observer is read-only towards guest memory and doesn't edit your game -saves. Progress writes use a worker and atomic replacement with a backup. -If storage fails, the achievement page reports it; the game should continue. - -BUILD CHECKS -Compilation, linking and static source/package inspection only. No gameplay, -mods, automated runtime tests or AppImages have been executed by the agent. -Please use the supplied 94-entry checklist for your gameplay checks. - -CREDIT -Challenge objectives inspired by Delmaru's Diddy Kong Racing achievement -set: https://retroachievements.org/game/10202 -Tracking code, wording, badges and notification sound are local DKR-R work. -This feature is not affiliated with or connected to RetroAchievements. diff --git a/patches/achievements/PIPELINE.txt b/patches/achievements/PIPELINE.txt deleted file mode 100644 index 25ea6a2a..00000000 --- a/patches/achievements/PIPELINE.txt +++ /dev/null @@ -1,79 +0,0 @@ -DKR-R ACHIEVEMENTS - PATCH PIPELINE - -This is our local achievement system. All 94 goals are wired to native game -state. There is no RetroAchievements login, API, runtime, cloud service or -downloaded achievement script. The challenge inventory follows the original -DKR base set; wording, badges, sound and tracking code are our own. - -The checked-out submodules, RecompiledFuncs and RecompiledPatches must stay -untouched. configure.py composes the accepted release, review editor, custom -vehicles and texture-dumping pipeline, then applies this final overlay only -to an isolated build outside the checkout. Nothing is patched in place. - -FILES -native/achievement_catalogue.cpp: stable IDs, descriptions and all 94 goals. -native/achievements.hpp: value-only snapshots, definitions and UI API. -native/achievements.cpp: bounded read-only RAM capture, evaluator, background -storage worker, atomic progress writes, backups and notification queue. -native/achievement_ui.inl: sidebar page, controller-first progress list, -detail view, filters, keyboard, preferences, pinning and unlock notifications. -pipeline.py: validated source transformations and source/hash receipts. -ReleaseOverlay.cmake: final source selection and include precedence. -audit.py: static coverage, source, protected-input and linked-byte checks. -inspect_rom.py: read-only extraction of numeric original staff ghost targets. -package.py: allowlisted Windows ZIP/Linux AppImage packaging and 94-goal -checklists. It never launches either binary or invokes a test target. - -RULES AND OWNERSHIP -Adventure milestones watch new progress in the currently loaded adventure. -Five-balloon goals watch newly earned balloons for each base driver in the -current Adventure session. Changing saves or leaving Adventure resets the -attempt, not the collection. Cosmetic character mods keep base identities. - -Time trials use all three original laps, original vehicle type, non-mirrored -mode and strict native-clock comparisons. Staff targets have been checked -against all 20 ghost headers in the original US 1.0 ROM. Developer targets -are paired with the ROM's MAIN_TRACKS_IDS order, exactly as the credits do, -not the different Track Select grid order. A custom course or trophy cannot stand in -for an original course. Silver goals require eight coins and first place -in the same original Adventure Two race. CPUs never earn player item goals. - -Offline captures happen after each main_game_loop boundary in both US -revisions. Experimental Rollback captures value-only observations in the -owning RAM instance; replay replaces its frame projection, and only a -successful confirmed commit submits it. Local ownership filters exclude -other peers' item collection. These projections do not enter guest memory, -checkpoints, hashes, effect journals or the wire protocol. Other online -backends do not submit predicted observations. Every goal can be earned -through offline play; applicable item goals also support local multiplayer -and confirmed Experimental Rollback. Developer sessions pause tracking. - -STORAGE -The one local profile is achievements/progress-v1.json under the existing -DKR-R user-data directory. It is separate from EEPROM and adventure files. -A worker merges existing unlocks under an OS file lease before atomic -replacement. A last-known-good backup is retained; a corrupt primary is -preserved during recovery. Unknown entry IDs survive normal writes. -Imports merge unlocks. Explicit recognition labels provable saved milestones -as recognised and does not manufacture session or time-trial history. - -BUILD -python -B patches/achievements/configure.py --build OUTSIDE_BUILD --capsule - ACCEPTED_SOURCE_CAPSULE --log CONFIGURE_LOG --cmake CMAKE_EXECUTABLE -python -B patches/release-prep/v1.0.7/release.py build --build OUTSIDE_BUILD - --log BUILD_LOG --cmake CMAKE_EXECUTABLE --jobs 4 -python -B patches/achievements/audit.py --stage OUTSIDE_BUILD/generated/achievements - --binary RUNTIME_BINARY --output STATIC_RECEIPT.json -python -B patches/achievements/package.py windows|linux --compiled BIN_DIRECTORY - --template PREVIOUS_PACKAGE_ROOT --output NEW_OUTPUT_DIRECTORY - --stage OUTSIDE_BUILD/generated/achievements --trophy PINNED_TROPHY_UPDATER - [--arch x86_64|aarch64] [--runtime-image PREVIOUS_LINUX_APPIMAGE] - -Windows uses native CMake, not the MSYS executable. Linux uses WSL/Linux -toolchains. AppImage creation inspects/reuses a known runtime header and -constructs the squashfs payload without executing the AppImage. - -Validation is compilation, linking, source and package inspection only. -The owner performs gameplay acceptance using the supplied 94-entry checklist. -Do not claim that compile success proves that all goals have been earned -or that platform/controller/hardware acceptance has already happened. diff --git a/patches/achievements/ReleaseOverlay.cmake b/patches/achievements/ReleaseOverlay.cmake deleted file mode 100644 index c7ae93f4..00000000 --- a/patches/achievements/ReleaseOverlay.cmake +++ /dev/null @@ -1,68 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../texture-dumping/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_achievements) - set(auth "${DKRPORT_ROOT}/patches/achievements") - set(stage "${CMAKE_BINARY_DIR}/generated/achievements") - file(MAKE_DIRECTORY "${stage}") - set(records "[") - set(separator "") - foreach(pair "DKRPortGame|runtime_ui.cpp" "DKRPortGame|runtime_legacy_mods.cpp" "DKRPortGame|game_main.cpp" "DKRPortGame|experimental_runtime.cpp" "DKRExperimentalOwnedGameV77|probe_driver.cpp" "DKRExperimentalOwnedGameV80|probe_driver.cpp") - string(REPLACE "|" ";" fields "${pair}") - list(GET fields 0 target) - list(GET fields 1 name) - get_target_property(paths ${target} SOURCES) - list(FILTER paths INCLUDE REGEX "[/\\\\]${name}$") - list(LENGTH paths count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Achievements need one final ${target}/${name}") - endif() - list(GET paths 0 path) - file(TO_CMAKE_PATH "${path}" path) - string(APPEND records "${separator}{\"target\":\"${target}\",\"path\":\"${path}\"}") - set(separator ",") - endforeach() - foreach(target DKRRecompiledFuncsV77 DKRRecompiledFuncsV80) - get_target_property(paths ${target} SOURCES) - foreach(path IN LISTS paths) - if(path MATCHES "\\.c$") - file(TO_CMAKE_PATH "${path}" path) - string(APPEND records "${separator}{\"target\":\"${target}\",\"path\":\"${path}\",\"guest\":true}") - set(separator ",") - endif() - endforeach() - endforeach() - string(APPEND records "]") - file(WRITE "${stage}/inputs.json" "${records}") - file(GLOB authored CONFIGURE_DEPENDS "${auth}/native/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${authored} "${auth}/pipeline.py") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" --inputs "${stage}/inputs.json" --output "${stage}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Achievement staging failed") - endif() - file(READ "${stage}/achievement-swaps.json" swaps) - string(JSON count LENGTH "${swaps}") - math(EXPR last "${count}-1") - foreach(index RANGE ${last}) - string(JSON target GET "${swaps}" ${index} target) - string(JSON old GET "${swaps}" ${index} path) - string(JSON next GET "${swaps}" ${index} new) - get_target_property(directory ${target} SOURCE_DIR) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage};${prior};${prior}/..;${includes}") - dkr_repair_replace_source(${target} "${old}" "${next}") - endforeach() - target_sources(DKRPortGame PRIVATE "${stage}/achievements.cpp" "${stage}/achievement_catalogue.cpp") - target_include_directories(DKRPortGame PRIVATE "${stage}") - # Host-only observations do not modify checkpoint schemas, effect journals, - # simulation state or peer IDs. Both original guest revisions stay read-only. - endfunction() - cmake_language(DEFER CALL dkr_apply_achievements) -endif() diff --git a/patches/achievements/audit.py b/patches/achievements/audit.py deleted file mode 100644 index b3a127ef..00000000 --- a/patches/achievements/audit.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Read-only catalogue, integration and linked-byte inspection. Never runs the game or tests.""" -from pathlib import Path -import argparse -from collections import Counter -import hashlib -import json -import re -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -def sha(path):return hashlib.sha256(path.read_bytes()).hexdigest() -def require(value,message): - if not value:raise ValueError(message) -def catalogue(): - text=(AUTH/'native/achievements.hpp').read_text(encoding='utf-8') - courses=[(int(a),int(b),c,int(d),int(e)) for a,b,c,d,e in re.findall(r'\{(\d+),(\d+),"([^"]+)",(\d+),(\d+)\}',text)] - require(len(courses)==20 and len({row[0] for row in courses})==20,'Need all 20 distinct original time-trial courses') - entries=[] - def add(id,rule,**values):entries.append({'id':id,'rule':rule,**values}) - add('first-balloon','New adventure balloon: total crosses 1') - add('banana-ten','Active human racer crosses 10 held bananas') - add('tt-amulet','Adventure T.T. amulet reaches four pieces') - add('wizpig-amulet','Adventure Wizpig amulet reaches four pieces') - for i,name in enumerate(('Ancient Lake','Crescent Island','Snowball Valley','Boulder Canyon')):add('key-'+str(i),'New key bit',course=name,mask=1<<(i+1)) - for total in (39,47):add('balloons-'+str(total),'New adventure balloon crosses threshold',target=total) - for i,name in enumerate(('Dino Domain','Sherbet Island','Snowflake Mountain','Dragon Forest','Future Fun Land')):add('trophy-'+str(i),'Original adventure trophy reaches gold',world=name,shift=i*2) - add('wizpig-one','New first Wizpig victory',mask=1) - add('wizpig-two','New second Wizpig victory',mask=32) - add('mirrored-wizpig','New second Wizpig victory in Adventure Two',mask=32) - add('mirrored-47','New Adventure Two balloon crosses 47',target=47) - for id,vehicle,name,tt,developer in courses: - add('tt-'+str(id),'Completed original three-lap non-mirrored time trial strictly under the original staff ghost time',course=name,course_id=id,vehicle=vehicle,native_ticks=tt) - add('rare-'+str(id),'Completed original three-lap non-mirrored time trial strictly under developer credits time',course=name,course_id=id,vehicle=vehicle,developer_centiseconds=developer) - add('silver-'+str(id),'Same original Adventure Two race: eight coins and first place',course=name,course_id=id) - for i,name in enumerate(('Boost','Rocket','Trap','Shield','Magnet')):add('power-'+str(i),'Active human obtains level two (zero-based) with positive ammunition',power=name,power_type=i) - for i,name in enumerate(('Krunch','Bumper','Tiptup','Conker','Timber','Banjo','Drumstick','Pipsy','T.T.','Diddy Kong')):add('driver-'+str(i),'Five newly earned adventure balloons without leaving Adventure, changing save, or closing the session',base_driver=name,base_driver_id=i) - require(len(entries)==94 and len({e['id'] for e in entries})==94,'Catalogue coverage is not exactly 94 unique goals') - return entries -def inspect(stage,binary=None): - entries=catalogue();swaps=json.loads((stage/'achievement-swaps.json').read_text(encoding='utf-8')) - require(Counter(row['target'] for row in swaps)==Counter({'DKRPortGame':4,'DKRExperimentalOwnedGameV77':1,'DKRExperimentalOwnedGameV80':1,'DKRRecompiledFuncsV77':1,'DKRRecompiledFuncsV80':1}),'Incomplete integration target coverage') - for row in swaps: - require(not Path(row['new']).resolve().is_relative_to(ROOT),'A staged output is inside the checkout') - require(sha(Path(row['path']))==row['input_sha256'],'An upstream input changed') - require(sha(Path(row['new']))==row['output_sha256'],'A staged output changed') - for path in (AUTH/'native').iterdir():require(sha(path)==sha(stage/path.name),'An authored achievement file was not staged: '+path.name) - core=(stage/'achievements.cpp').read_text(encoding='utf-8');ui=(stage/'achievement_ui.inl').read_text(encoding='utf-8');driver=(stage/'probe_driver.cpp').read_text(encoding='utf-8') - for kind in ('Balloons','Key','Amulet','Trophy','Boss','Silver','TimeTrial','Developer','Power','Driver'): - require('case Kind::'+kind+':' in core,'Missing evaluator '+kind) - for marker in ('class FileLease','MoveFileExW','::fsync','8192','r.address==old.address','r.player<0','r.coins==8&&r.position==1','std::int64_t(r.total_ticks)*100','previous_.balloonscharacter]','Tracking paused for developer tools','restart.exchange(false)'): - require(marker in core,'Missing tracking/storage guard: '+marker) - require('ram_[' in core and not re.search(r'ram_\[[^;]+\]\s*=(?!=)',core),'Observer writes guest memory') - require('capture(ram_.data(),ram_.size(),DKR_PROBE_REVISION)' in driver,'Owned snapshot is not from the correct RAM owner') - require(driver.index('if(scene_sessions_)dkr::runtime::achievements::submit')>driver.index('++confirmed_count_'),'Online awards are not confirmation-only') - for marker in ('DrawAchievementsPage','DrawAchievementToast','DrawPinnedAchievement','RequestCustomTextKeyboard("Find an achievement"','DrawAchievementBadge','AchievementChime','SetKeyboardFocusHere','ImGui::Button("##open"','BeginPaddockModalWindow'): - require(marker in ui,'Missing controller/presentation feature: '+marker) - for rev in (77,80): - rows=[r for r in swaps if r['target']=='DKRRecompiledFuncsV'+str(rev)] - require(len(rows)==1 and Path(rows[0]['new']).read_text(encoding='utf-8').count('dkr_achievements_observe(rdram,'+str(rev)+');')==1,'Missing unique original-frame observer') - if binary: - data=binary.read_bytes() - for marker in (b'1.0.9-achievements-playtest.1',b'collection=94 local-only v1',b'ACHIEVEMENTS',b'ACHIEVEMENT UNLOCKED',b'Recognise existing progress',b'Mirror Master',b'Challenge objectives inspired by Delmaru'): - require(marker in data,'Achievement feature missing from linked executable: '+repr(marker)) - return {'schema':1,'catalogue_version':'dkr-r-94-v1','count':94,'entries':entries,'swaps':swaps,'source_files':{p.name:sha(p) for p in sorted((AUTH/'native').iterdir())},'binary_sha256':sha(binary) if binary else None,'validation':'Compilation/static inspection only. No game, mod, test or AppImage executed. Owner gameplay acceptance pending.'} -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--stage',type=Path,required=True);p.add_argument('--binary',type=Path);p.add_argument('--output',type=Path);a=p.parse_args();report=inspect(a.stage,a.binary) - if a.output:a.output.write_text(json.dumps(report,indent=2)+'\n') - else:print(json.dumps(report,indent=2)) diff --git a/patches/achievements/configure.py b/patches/achievements/configure.py deleted file mode 100644 index 54d2320f..00000000 --- a/patches/achievements/configure.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Configure a Windows/Linux local-achievement playtest, without executing targets.""" -from pathlib import Path -import argparse -import importlib.util -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-achievements-playtest.1' -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','capsule','log'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--cmake',default='cmake');a=p.parse_args() - spec=importlib.util.spec_from_file_location('achievement_release',ROOT/'patches/release-prep/v1.0.7/release.py') - release=importlib.util.module_from_spec(spec);spec.loader.exec_module(release) - release.AUTH=AUTH;release.VERSION=VERSION;release.configure(a.build,a.log,a.cmake,a.capsule) diff --git a/patches/achievements/inspect_rom.py b/patches/achievements/inspect_rom.py deleted file mode 100644 index b7b3e20a..00000000 --- a/patches/achievements/inspect_rom.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Read original ROM metadata to check numeric achievement targets; no game execution or ROM export.""" -from pathlib import Path -import argparse -import hashlib -import json -import re -import struct -import tomllib - -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -def inspect(path): - data=path.read_bytes() - if data[:4]!=bytes.fromhex('80371240'):raise ValueError('Use the canonical big-endian ROM') - identity=hashlib.sha1(data).hexdigest() - starts={'0cb115d8716dbbc2922fda38e533b9fe63bb9670':969568,'6d96743d46f8c0cd0edb0ec5600b003c89b93755':970976} - start=starts.get(identity) - if start is None:raise ValueError('Not an original supported USA ROM') - count=struct.unpack_from('>I',data,start)[0] - if count!=50:raise ValueError('Unreviewed original asset LUT') - base=start+(count+2)*4 - offsets=struct.unpack_from('>'+str(count+1)+'I',data,start+4) - table=data[base+offsets[48]:base+offsets[49]] - ghost_base=base+offsets[49] - values=[] - for index in range(0,len(table),8): - map_id,vehicle,offset=struct.unpack_from('>BB2xI',table,index) - if map_id==255:break - ticks=struct.unpack_from('>H',data,ghost_base+offset+4)[0] - values.append({'course_id':map_id,'vehicle':vehicle,'tt_ticks':ticks}) - if len(values)!=20:raise ValueError('Need all 20 original staff ghosts') - revision='us.v77' if start==969568 else 'us.v80' - symbols=tomllib.loads((ROOT/'modding'/('reference-'+revision.replace('.','-'))/'data.toml').read_text(encoding='utf-8'))['section'] - def rom_address(vram): - for section in symbols: - if section['vram']<=vram2I',data,base+offsets[16]+28*4) - main_ids=data[base+offsets[15]+lo*4:base+offsets[15]+hi*4] - if len(main_ids)<21 or main_ids[20]!=255:raise ValueError('Unreviewed main-course order') - developer={} - for course,ptr in zip(main_ids[:20],struct.unpack_from('>20I',data,rom_address(credits))): - at=rom_address(ptr);stamp=data[at:at+8].split(b'\0',1)[0].decode('ascii') - if not re.fullmatch(r'\d:\d\d:\d\d',stamp):raise ValueError('Unreviewed credits time') - minutes,seconds,hundredths=map(int,stamp.split(':'));developer[course]=minutes*6000+seconds*100+hundredths - authored={int(id):(int(vehicle),int(tt),int(dev)) for id,vehicle,tt,dev in re.findall(r'\{(\d+),(\d+),"[^"]+",(\d+),(\d+)\}',(AUTH/'native/achievements.hpp').read_text(encoding='utf-8'))} - for row in values: - row['developer_centiseconds']=developer[row['course_id']] - if authored.get(row['course_id'])!=(row['vehicle'],row['tt_ticks'],row['developer_centiseconds']):raise ValueError('Catalogue differs from original game: '+str(row)) - return {'schema':1,'revision':revision,'staff_ghosts':values,'developer_source':'Original credits pointers paired with ASSET_MISC_MAIN_TRACKS_IDS, as menu_credits_loop does.','catalogue_matches':True,'validation':'Read-only original asset-header/credits metadata inspection. No game, mod or test execution; no ROM artwork or code exported.'} -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--rom',type=Path,required=True);p.add_argument('--output',type=Path);a=p.parse_args();result=inspect(a.rom) - if a.output:a.output.write_text(json.dumps(result,indent=2)+'\n') - print(json.dumps(result,indent=2)) diff --git a/patches/achievements/native/achievement_catalogue.cpp b/patches/achievements/native/achievement_catalogue.cpp deleted file mode 100644 index 805947a6..00000000 --- a/patches/achievements/native/achievement_catalogue.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include "achievements.hpp" -#include -#include -namespace dkr::runtime::achievements { -const std::vector& catalogue() { - static const auto result=[] { - std::vector out; - auto add=[&](std::string id,std::string title,std::string description,std::string group,Kind kind,int arg=0,int target=1,int vehicle=-1,bool mirrored=false,bool restorable=false) { - out.push_back({std::move(id),std::move(title),std::move(description),std::move(group),kind,arg,target,vehicle,mirrored,restorable}); - }; - add("first-balloon","First Balloon","Earn your first adventure balloon.","Adventure",Kind::Balloons,0,1,-1,false,true); - add("banana-ten","Banana Bunch","Hold 10 bananas at once during a race.","Racing",Kind::Power,5,10); - add("tt-amulet","Four Challenges","Complete all four adventure battle challenges.","Adventure",Kind::Amulet,0,4,-1,false,true); - add("wizpig-amulet","Ready for Wizpig","Collect all four Wizpig amulet pieces.","Adventure",Kind::Amulet,1,4,-1,false,true); - const char* key_names[]{"Ancient Lake","Crescent Island","Snowball Valley","Boulder Canyon"}; - for(int i=0;i<4;++i)add("key-"+std::to_string(i),std::string(key_names[i])+" Key","Find the hidden key in "+std::string(key_names[i])+".","Adventure",Kind::Key,i,1,-1,false,true); - add("balloons-39","Beyond the Island","Collect 39 balloons in one adventure.","Adventure",Kind::Balloons,0,39,-1,false,true); - add("balloons-47","Across the Galaxy","Collect all 47 balloons in one adventure.","Adventure",Kind::Balloons,0,47,-1,false,true); - const char* worlds[]{"Dino Domain","Sherbet Island","Snowflake Mountain","Dragon Forest","Future Fun Land"}; - for(int i=0;i<5;++i)add("trophy-"+std::to_string(i),std::string(worlds[i])+" Champion","Win the original "+std::string(worlds[i])+" trophy competition.","Trophies",Kind::Trophy,i,3,-1,false,true); - add("wizpig-one","Wizpig Defeated","Win the first Wizpig race.","Adventure",Kind::Boss,1,1,-1,false,true); - add("wizpig-two","One More for the Road","Win the second Wizpig race.","Adventure",Kind::Boss,32,1,-1,false,true); - add("mirrored-wizpig","Wizpig Strikes Twice","Win the second Wizpig race in Adventure Two.","Adventure Two",Kind::Boss,32,1,-1,true,true); - add("mirrored-47","Mirror Master","Collect all 47 balloons in Adventure Two.","Adventure Two",Kind::Balloons,0,47,-1,true,true); - const char* vehicles[]{"car","hovercraft","plane"}; - for(const auto& c:courses) { - const auto key=std::to_string(c.id);const std::string name=c.name; - add("tt-"+key,name+": T.T. Challenge","Beat T.T.'s full-race time on "+name+" using the "+vehicles[c.vehicle]+" in Time Trial mode.","T.T. Times",Kind::TimeTrial,c.id,c.tt_ticks,c.vehicle); - add("rare-"+key,name+": Developer Challenge","Beat the developer's full-race time on "+name+" using the "+vehicles[c.vehicle]+" in Time Trial mode.","Developer Times",Kind::Developer,c.id,c.developer_centiseconds,c.vehicle); - add("silver-"+key,name+": Mirror Coins","Collect all eight silver coins and finish first in the same Adventure Two race on "+name+".","Adventure Two",Kind::Silver,c.id,8,-1,true,true); - } - const char* powers[]{"Boost","Rocket","Trap","Shield","Magnet"}; - for(int i=0;i<5;++i)add("power-"+std::to_string(i),std::string(powers[i])+" Expert","Obtain the highest-level "+std::string(powers[i])+" power-up.","Racing",Kind::Power,i,2); - const char* drivers[]{"Krunch","Bumper","Tiptup","Conker","Timber","Banjo","Drumstick","Pipsy","T.T.","Diddy Kong"}; - for(int i=0;i<10;++i)add("driver-"+std::to_string(i),std::string(drivers[i])+": Five More","Earn five new balloons as "+std::string(drivers[i])+" without leaving Adventure mode, in one session.","Characters",Kind::Driver,i,5); - std::set ids;for(const auto& d:out)if(!ids.insert(d.id).second)throw std::logic_error("Duplicate achievement ID"); - if(out.size()!=94)throw std::logic_error("The built-in collection must contain all 94 achievements"); - return out; - }();return result; -} -} diff --git a/patches/achievements/native/achievement_ui.inl b/patches/achievements/native/achievement_ui.inl deleted file mode 100644 index 1301abe2..00000000 --- a/patches/achievements/native/achievement_ui.inl +++ /dev/null @@ -1,182 +0,0 @@ -namespace ach=dkr::runtime::achievements; -int g_achievement_filter=0,g_achievement_sort=0; -std::string g_achievement_category,g_achievement_detail; -std::array g_achievement_search{}; -bool g_achievement_settings=false,g_achievement_reset=false,g_achievement_recognise=false; -bool g_achievement_focus_detail=false; -std::string g_achievement_focus_row; -float g_achievement_return_scroll=0; -ach::Toast g_achievement_toast; -double g_achievement_toast_started=0; -SDL_AudioDeviceID g_achievement_audio=0; - -void AchievementChime() { - if(!g_achievement_audio) { - SDL_AudioSpec wanted{},actual{};wanted.freq=48000;wanted.format=AUDIO_F32SYS;wanted.channels=2;wanted.samples=512; - g_achievement_audio=SDL_OpenAudioDevice(nullptr,0,&wanted,&actual,0); - if(!g_achievement_audio)return; - SDL_PauseAudioDevice(g_achievement_audio,0); - } - std::vector samples(48000*2*3/5); - const float volume=.10F*dkr::runtime::audio::sound_effects_volume(); - for(unsigned i=0;i{523.25F,659.25F,783.99F})value+=std::sin(t*6.2831853F*tone); - samples[2*i]=samples[2*i+1]=value*envelope*volume/3.F; - } - SDL_ClearQueuedAudio(g_achievement_audio);SDL_QueueAudio(g_achievement_audio,samples.data(),Uint32(samples.size()*sizeof(float))); -} -void ReleaseAchievementAudio(){if(g_achievement_audio){SDL_CloseAudioDevice(g_achievement_audio);g_achievement_audio=0;}} -const char* AchievementBadge(const ach::Definition& d) { - switch(d.kind){case ach::Kind::TimeTrial:return "TT";case ach::Kind::Developer:return "DEV"; - case ach::Kind::Silver:return "SC";case ach::Kind::Driver:return "5";case ach::Kind::Key:return "K"; - case ach::Kind::Trophy:return "1";case ach::Kind::Boss:return "W";case ach::Kind::Amulet:return "4"; - case ach::Kind::Power:return "+";default:return "B";} -} -void DrawAchievementBadge(ImVec2 at,float size,const char* text,bool unlocked) { - auto* draw=ImGui::GetWindowDrawList();const ImVec2 center{at.x+size*.5F,at.y+size*.5F}; - draw->AddCircleFilled(center,size*.48F,PaddockRgb(unlocked?0x0CA68F:0x263E4C)); - draw->AddCircle(center,size*.38F,PaddockRgb(unlocked?0xFFCF68:0x718990),0,1.5F); - const auto type=PaddockReading(size*.27F,true,1.F); - const auto width=PaddockMeasure(type,text); - draw->AddText(type.font,type.size,{center.x-width*.5F,center.y-type.size*.5F},PaddockRgb(unlocked?0xFFF6DA:0xB0C9CC),text); -} -bool AchievementToastVisible(){return !g_achievement_toast.id.empty()||ach::notification_active()||!ach::view()->pinned.empty();} -void DrawPinnedAchievement() { - const auto state=ach::view();if(state->pinned.empty())return; - const auto& definitions=ach::catalogue();const auto found=std::find_if(definitions.begin(),definitions.end(),[&](auto& d){return d.id==state->pinned;});if(found==definitions.end())return; - const auto& entry=state->entries[std::size_t(found-definitions.begin())];const auto* viewport=ImGui::GetMainViewport(); - const float width=std::min(330.F,viewport->WorkSize.x-32.F);ImGui::SetNextWindowPos({viewport->WorkPos.x+16.F,viewport->WorkPos.y+16.F}); - ImGui::SetNextWindowSize({width,0});ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,12.F);ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,{14.F,12.F});ImGui::PushStyleColor(ImGuiCol_WindowBg,PaddockRgb(0x0A202D,238)); - ImGui::Begin("Pinned achievement##dkr",nullptr,ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_AlwaysAutoResize|ImGuiWindowFlags_NoInputs|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing|ImGuiWindowFlags_NoNav); - PaddockText(PaddockReading(15.F,true),PaddockRgb(0xFFCB66),found->title,width-28.F); - if(found->target>1&&found->kind!=ach::Kind::TimeTrial&&found->kind!=ach::Kind::Developer){const auto progress=std::to_string(entry.progress)+" / "+std::to_string(found->target);PaddockText(PaddockReading(14.F),PaddockRgb(0xFFF6DA),progress,width-28.F);} - else PaddockText(PaddockReading(13.F),PaddockRgb(0xB0C9CC),found->description,width-28.F); - ImGui::End();ImGui::PopStyleColor();ImGui::PopStyleVar(2); -} -void DrawAchievementToast() { - const auto state=ach::view();const auto& p=state->preferences; - if(!p.notifications){g_achievement_toast={};ach::Toast discard;while(ach::next_toast(discard)){}return;} - // Launcher and in-game overlays use separate ImGui contexts. A shared - // monotonic clock keeps a toast's lifetime correct across that handoff. - const auto time=double(SDL_GetTicks64())*.001; - if(!g_achievement_toast.id.empty()&&time-g_achievement_toast_started>=p.duration)g_achievement_toast={}; - if(g_achievement_toast.id.empty()){if(!ach::next_toast(g_achievement_toast))return;g_achievement_toast_started=time;if(p.sound)AchievementChime();} - const auto* viewport=ImGui::GetMainViewport();const auto origin=viewport->WorkPos;const auto extent=viewport->WorkSize; - const float scale=p.scale,width=std::min(420.F*scale,extent.x-32.F),padding=15.F*scale; - const auto title=PaddockReading(17.F*scale,true,1.25F);const auto label=PaddockReading(13.F*scale,false,1.1F); - const float text_width=std::max(50.F,width-88.F*scale); - const auto title_size=title.font->CalcTextSizeA(title.size,FLT_MAX,text_width,g_achievement_toast.title.c_str()); - const float height=std::max(80.F*scale,title_size.y+label.size+padding*2+5.F); - float x=p.position==0?origin.x+(extent.x-width)*.5F:origin.x+extent.x-width-20.F; - float y=p.position==2?origin.y+20.F:origin.y+extent.y-height-20.F; - const float elapsed=float(time-g_achievement_toast_started); - if(!p.reduced_motion)y+=(p.position==2?-1.F:1.F)*(1.F-PaddockEase(std::min(elapsed/.22F,1.F)))*16.F; - ImGui::SetNextWindowPos({x,y});ImGui::SetNextWindowSize({width,height}); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,height*.5F);ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,{padding,padding}); - ImGui::PushStyleColor(ImGuiCol_WindowBg,PaddockRgb(0x0A202D,244));ImGui::PushStyleColor(ImGuiCol_Border,PaddockRgb(0x2C6577)); - ImGui::Begin("Achievement unlocked##dkr-toast",nullptr,ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_NoInputs|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing|ImGuiWindowFlags_NoNav|ImGuiWindowFlags_NoBringToFrontOnFocus); - DrawAchievementBadge({x+padding,y+(height-50.F*scale)*.5F},50.F*scale,"!",true); - auto* draw=ImGui::GetWindowDrawList();const ImVec2 text{x+padding+64.F*scale,y+padding}; - draw->AddText(label.font,label.size,text,PaddockRgb(0xFFCB66),"ACHIEVEMENT UNLOCKED"); - draw->AddText(title.font,title.size,{text.x,text.y+label.size+6.F},PaddockRgb(0xFFF6DA),g_achievement_toast.title.c_str(),nullptr,text_width); - ImGui::End();ImGui::PopStyleColor(2);ImGui::PopStyleVar(2); -} -void AchievementFileDialog(bool importing) { - StartDialogJob([importing]()->std::function { - // The established native folder picker keeps Android/Linux/Windows - // path handling outside the ImGui frame. Backups have one fixed name. - std::filesystem::path folder;std::string error;const bool chosen=PickFolder(folder,error); - return [folder,chosen,error,importing]{if(chosen){const auto path=folder/"DKR-R-Achievements.json";if(importing)ach::import_progress(path);else ach::export_progress(path);}else if(!error.empty())g_support_action_status=error;}; - }); -} -void DrawAchievementPreferences(float width,const ach::View& state) { - if(SettingsButton("Back to achievements")){g_achievement_settings=false;return;} - PaddockGap(18);DetailHeading("Notifications",width);auto p=state.preferences;bool changed=false; - changed|=SettingsCheck("##achievement-popups","Show unlock notifications",&p.notifications,width); - changed|=SettingsCheck("##achievement-chime","Play unlock sound",&p.sound,width); - changed|=SettingsCheck("##achievement-motion","Reduce notification motion",&p.reduced_motion,width); - SettingsLabel("Position",width); - const char* positions[]{"Bottom centre","Bottom right","Top right"}; - ImGui::SetNextItemWidth(std::min(width,300.F));changed|=ImGui::Combo("##achievement-position",&p.position,positions,3); - SettingsLabel("Size",width);ImGui::SetNextItemWidth(std::min(width,360.F));changed|=ImGui::SliderFloat("##achievement-size",&p.scale,.7F,1.6F,"%.1fx"); - SettingsLabel("Duration",width);ImGui::SetNextItemWidth(std::min(width,360.F));changed|=ImGui::SliderFloat("##achievement-duration",&p.duration,2.F,10.F,"%.0f seconds"); - if(changed)ach::preferences(p); - if(SettingsButton("Preview notification"))ach::preview(); - PaddockGap(22);DetailHeading("Your collection",width); - DetailText("Achievements stay separate from your game saves. Starting another adventure will not erase them.",width); - DumpActionRow files(width); - if(files.Button("Export backup"))AchievementFileDialog(false); - if(files.Button("Import backup"))AchievementFileDialog(true); - if(files.Button("Recognise existing progress"))g_achievement_recognise=true; - PaddockGap(16);if(SettingsButton("Reset achievement collection"))g_achievement_reset=true; - PaddockGap(18);DetailText("Challenge objectives inspired by Delmaru's Diddy Kong Racing achievement set. Local DKR-R implementation, wording and badge designs. Not connected to RetroAchievements.",width,kSetMuted); -} -void DrawAchievementsPage(float width) { - const auto state=ach::view();const auto& definitions=ach::catalogue(); - DrawPageHeading("ACHIEVEMENTS");PaddockGap(8); - DetailText("Your challenges. Your collection.",width,kSetMuted,12); - DetailHeading(std::to_string(state->unlocked)+" / 94 unlocked",width); - ImGui::ProgressBar(float(state->unlocked)/94.F,{width,7.F},"");PaddockGap(14); - if(!state->error.empty())DetailText(state->error,width,0xFFAB72); - else if(state->status!="Saved on this device")DetailText(state->status,width,kSetMuted); - if(g_achievement_settings){DrawAchievementPreferences(width,*state);} - else if(!g_achievement_detail.empty()) { - const auto found=std::find_if(definitions.begin(),definitions.end(),[](const auto& d){return d.id==g_achievement_detail;}); - if(found==definitions.end()){g_achievement_detail.clear();return;} - const auto index=std::size_t(found-definitions.begin());const auto& entry=state->entries[index]; - if(g_achievement_focus_detail){ImGui::SetKeyboardFocusHere();g_achievement_focus_detail=false;} - if(SettingsButton("Back to achievements")||ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight,false)){g_achievement_focus_row=g_achievement_detail;g_achievement_detail.clear();ImGui::SetScrollY(g_achievement_return_scroll);} - PaddockGap(18);DetailHeading(found->title,width);DetailText(found->description,width); - DetailText(entry.unlocked?(entry.restored?"Recognised from existing progress":entry.modded?"Unlocked during modded play":"Unlocked") : "Not unlocked yet",width,entry.unlocked?0x42D6BC:kSetMuted); - if(entry.when){const auto time=std::time_t(entry.when);std::tm stamp{}; -#ifdef _WIN32 - gmtime_s(&stamp,&time); -#else - gmtime_r(&time,&stamp); -#endif - char when[80]{};std::strftime(when,sizeof(when),"%d %b %Y, %H:%M UTC",&stamp);DetailText(when,width,kSetMuted); - } - if(found->kind==ach::Kind::TimeTrial||found->kind==ach::Kind::Developer){const auto c=std::find_if(ach::courses.begin(),ach::courses.end(),[&](auto& c){return c.id==found->argument;});if(c!=ach::courses.end()){const auto hundredths=found->kind==ach::Kind::TimeTrial?c->tt_ticks*100/60:c->developer_centiseconds;char target[48];std::snprintf(target,sizeof(target),"Target: %d:%02d.%02d",hundredths/6000,(hundredths/100)%60,hundredths%100);DetailText(target,width);}} - else if(found->target>1){DetailText(std::to_string(entry.progress)+" / "+std::to_string(found->target),width);ImGui::ProgressBar(float(entry.progress)/float(found->target),{width,7.F},"");} - if(!entry.unlocked&&SettingsButton(state->pinned==found->id?"Unpin goal":"Pin goal"))ach::pin(state->pinned==found->id?"":found->id); - if(!entry.unlocked&&found->restorable&&entry.progress>=found->target)DetailText("This milestone is already present in your save. Settings lets you recognise existing progress explicitly.",width,kSetMuted); - if(found->kind==ach::Kind::Driver)DetailText("New balloons only. Leaving Adventure or closing the game restarts this challenge.",width,kSetMuted); - if(found->argument<65&&(found->kind==ach::Kind::Silver||found->kind==ach::Kind::Developer||found->kind==ach::Kind::TimeTrial))DetailText("Original course only. Custom courses and custom cups do not count for this goal.",width,kSetMuted); - } else { - if(ImGui::IsKeyPressed(ImGuiKey_GamepadL1,false))g_achievement_filter=(g_achievement_filter+2)%3; - if(ImGui::IsKeyPressed(ImGuiKey_GamepadR1,false))g_achievement_filter=(g_achievement_filter+1)%3; - DumpActionRow actions(width);for(int i=0;i<3;++i){const char* labels[]{"All","Locked","Unlocked"};if(actions.Button(labels[i],false,i==g_achievement_filter?SettingsButtonTone::Selected:SettingsButtonTone::Plain))g_achievement_filter=i;} - if(actions.Button("Settings"))g_achievement_settings=true; - PaddockGap(12);ImGui::SetNextItemWidth(width>=240.F?width-115.F:width);ImGui::InputTextWithHint("##achievement-search","Find an achievement...",g_achievement_search.data(),g_achievement_search.size());if(width>=240.F)ImGui::SameLine(); - if(SettingsButton("Keyboard",100.F))RequestCustomTextKeyboard("Find an achievement",g_achievement_search.data(),g_achievement_search.size(),[](const std::string& value){std::snprintf(g_achievement_search.data(),g_achievement_search.size(),"%s",value.c_str());}); - PaddockGap(10); - std::vector groups{"All categories"};for(const auto& d:definitions)if(std::find(groups.begin(),groups.end(),d.category)==groups.end())groups.push_back(d.category); - ImGui::SetNextItemWidth(std::min(width,260.F));if(ImGui::BeginCombo("##achievement-category",g_achievement_category.empty()?"All categories":g_achievement_category.c_str())){for(unsigned i=0;i entries;std::string search=g_achievement_search.data();std::transform(search.begin(),search.end(),search.begin(),[](unsigned char c){return char(std::tolower(c));}); - for(unsigned i=0;ientries[i];if((g_achievement_filter==1&&e.unlocked)||(g_achievement_filter==2&&!e.unlocked)||(!g_achievement_category.empty()&&d.category!=g_achievement_category))continue;auto text=d.title+" "+d.description;std::transform(text.begin(),text.end(),text.begin(),[](unsigned char c){return char(std::tolower(c));});if(!search.empty()&&text.find(search)==std::string::npos)continue;entries.push_back(i);} - if(g_achievement_sort)std::stable_sort(entries.begin(),entries.end(),[&](unsigned a,unsigned b){return g_achievement_sort==1?state->entries[a].when>state->entries[b].when:double(state->entries[a].progress)/definitions[a].target>double(state->entries[b].progress)/definitions[b].target;}); - PaddockGap(12);if(entries.empty())DetailText("No achievements match those filters.",width,kSetMuted); - const auto heading=PaddockReading(17.F,true,1.3F),body=PaddockReading(14.F,false,1.3F); - for(const auto index:entries){const auto& d=definitions[index];const auto& e=state->entries[index];ImGui::PushID(d.id.c_str()); - const float badge=48.F,text_width=std::max(50.F,width-88.F);const auto wrapped=body.font->CalcTextSizeA(body.size,FLT_MAX,text_width,d.description.c_str());const auto title_size=heading.font->CalcTextSizeA(heading.size,FLT_MAX,text_width,d.title.c_str());const float height=std::max(90.F,title_size.y+wrapped.y+43.F); - if(g_achievement_focus_row==d.id){ImGui::SetKeyboardFocusHere();g_achievement_focus_row.clear();} - const auto at=ImGui::GetCursorScreenPos();if(ImGui::Button("##open",{width,height})){g_achievement_detail=d.id;g_achievement_return_scroll=ImGui::GetScrollY();g_achievement_focus_detail=true;ImGui::SetScrollY(0);} - auto* draw=ImGui::GetWindowDrawList();draw->AddRectFilled(at,{at.x+width,at.y+height},PaddockRgb(0x10364A),12); - draw->AddRect(at,{at.x+width,at.y+height},PaddockRgb(ImGui::IsItemFocused()||ImGui::IsItemHovered()?0xFFCB66:0x296B70),12); - DrawAchievementBadge({at.x+12,at.y+14},badge,AchievementBadge(d),e.unlocked); - const ImVec2 text{at.x+74,at.y+12};draw->AddText(heading.font,heading.size,text,PaddockRgb(0xFFF6DA),d.title.c_str(),nullptr,text_width); - draw->AddText(body.font,body.size,{text.x,text.y+title_size.y+5},PaddockRgb(0xB0C9CC),d.description.c_str(),nullptr,text_width); - const std::string status=e.unlocked?(e.restored?"Recognised":"Unlocked"):e.progress>0?std::to_string(e.progress)+" / "+std::to_string(d.target):"Locked"; - draw->AddText(body.font,body.size,{text.x,at.y+height-body.line-8},PaddockRgb(e.unlocked?0x42D6BC:0xB0C9CC),status.c_str()); - ImGui::PopID();PaddockGap(8); - } - DetailText("A: Open B: Back LB / RB: Filter",width,kSetMuted); - } - if(!state->pinned.empty()){const auto found=std::find_if(definitions.begin(),definitions.end(),[&](auto& d){return d.id==state->pinned;});if(found!=definitions.end())DetailText("Pinned: "+found->title,width,kSetMuted);} - if(g_achievement_reset){ImGui::OpenPopup("Reset achievements##confirm");g_achievement_reset=false;} - if(g_achievement_recognise){ImGui::OpenPopup("Recognise progress##confirm");g_achievement_recognise=false;} - const float confirmation_width=std::max(140.F,std::min(520.F,ImGui::GetMainViewport()->WorkSize.x-80.F)); - if(BeginPaddockModalWindow("Reset achievements##confirm",ImGuiWindowFlags_AlwaysAutoResize)) {DetailText("Reset this achievement collection? Your game saves will not change. A backup is kept.",confirmation_width);DumpActionRow actions(confirmation_width);if(actions.Button("Reset collection")){ach::reset_collection();ImGui::CloseCurrentPopup();}if(actions.Button("Cancel"))ImGui::CloseCurrentPopup();ImGui::EndPopup();} - if(BeginPaddockModalWindow("Recognise progress##confirm",ImGuiWindowFlags_AlwaysAutoResize)) {DetailText("Recognise milestones shown by the current adventure? Time trials and session challenges still need to be played. Recognised entries are labelled, not presented as new gameplay unlocks.",confirmation_width);DumpActionRow actions(confirmation_width);if(actions.Button("Recognise progress")){ach::restore_progress();ImGui::CloseCurrentPopup();}if(actions.Button("Cancel"))ImGui::CloseCurrentPopup();ImGui::EndPopup();} -} diff --git a/patches/achievements/native/achievements.cpp b/patches/achievements/native/achievements.cpp deleted file mode 100644 index 5162d76b..00000000 --- a/patches/achievements/native/achievements.cpp +++ /dev/null @@ -1,228 +0,0 @@ -#include "achievements.hpp" -#include "achievement_symbols.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef _WIN32 -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#else -#include -#include -#include -#endif -namespace dkr::runtime::achievements { -namespace { -using Json=nlohmann::json; -std::atomic online{false};std::atomic owner{0};std::atomic modified{false}; -std::atomic development{false}; -class Memory { - const std::uint8_t* ram_;std::size_t size_; -public: - Memory(const std::uint8_t* ram,std::size_t size):ram_(ram),size_(size){} - bool range(std::uint32_t p,unsigned n)const{return ram_&&p>=0x80000000U&&p<0xa0000000U&&(p&0x1fffffffU)<=size_&&n<=size_-(p&0x1fffffffU);} - unsigned get(std::uint32_t p,unsigned n=4)const {if(!range(p,n))return 0;unsigned v=0;for(unsigned i=0;i(std::chrono::system_clock::now().time_since_epoch()).count();} -Json read(const std::filesystem::path& path) { - std::error_code ec;if(!std::filesystem::exists(path,ec))return Json::object(); - if(std::filesystem::is_symlink(path)||std::filesystem::file_size(path)>512*1024)throw std::runtime_error("Achievement file is unsafe or too large."); - std::ifstream in(path,std::ios::binary);auto doc=Json::parse(in); - if(!doc.is_object()||doc.value("schema",0)!=1||!doc.contains("entries")||!doc["entries"].is_object()||doc["entries"].size()>2048)throw std::runtime_error("Achievement file has an unsupported format."); - for(const auto& [id,e]:doc["entries"].items()) { - if(id.empty()||id.size()>100||!e.is_object())throw std::runtime_error("Invalid achievement entry."); - (void)e.value("unlocked",false);(void)e.value("restored",false);(void)e.value("modded",false); - auto time=e.value("when",std::int64_t(0));auto p=e.value("progress",0); - if(time<0||p<0||p>1000000)throw std::runtime_error("Invalid achievement progress."); - } - return doc; -} -class FileLease { -#ifdef _WIN32 - HANDLE value_=INVALID_HANDLE_VALUE; -#else - int value_=-1; -#endif -public: - explicit FileLease(const std::filesystem::path& path) { - if(std::filesystem::is_symlink(path))throw std::runtime_error("Achievement storage cannot use symbolic links."); -#ifdef _WIN32 - for(unsigned n=0;n<100&&value_==INVALID_HANDLE_VALUE;++n){value_=CreateFileW(path.c_str(),GENERIC_READ|GENERIC_WRITE,0,nullptr,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL,nullptr);if(value_==INVALID_HANDLE_VALUE)std::this_thread::sleep_for(std::chrono::milliseconds(10));} - if(value_==INVALID_HANDLE_VALUE)throw std::runtime_error("Another DKR-R copy is updating achievements. Please try again."); -#else - value_=::open(path.c_str(),O_CREAT|O_RDWR|O_NOFOLLOW,0600); - if(value_<0)throw std::runtime_error("Cannot open the achievement lock."); - if(flock(value_,LOCK_EX)!=0){::close(value_);value_=-1;throw std::runtime_error("Cannot lock achievement progress.");} -#endif - } - ~FileLease(){ -#ifdef _WIN32 - if(value_!=INVALID_HANDLE_VALUE)CloseHandle(value_); -#else - if(value_>=0){flock(value_,LOCK_UN);::close(value_);} -#endif - } -}; -void atomic_write(const std::filesystem::path& path,const Json& doc,bool backup=true) { - std::filesystem::create_directories(path.parent_path()); - if(std::filesystem::is_symlink(path)||std::filesystem::is_symlink(path.parent_path()))throw std::runtime_error("Achievement storage cannot use symbolic links."); - auto pending=path;pending+=".pending"; - auto previous=path;previous+=".backup"; - if(std::filesystem::is_symlink(pending)||std::filesystem::is_symlink(previous))throw std::runtime_error("Achievement storage cannot use symbolic links."); - const auto text=doc.dump(2)+"\n"; - {std::ofstream out(pending,std::ios::binary|std::ios::trunc);out.write(text.data(),std::streamsize(text.size()));out.flush();if(!out)throw std::runtime_error("Cannot write achievement progress.");} - if(backup&&std::filesystem::exists(path)) { - // Never turn a corrupt primary into the last-known-good backup. - (void)read(path); - std::filesystem::copy_file(path,previous,std::filesystem::copy_options::overwrite_existing); - } -#ifdef _WIN32 - if(!MoveFileExW(pending.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))throw std::runtime_error("Cannot commit achievement progress."); -#else - const int file=::open(pending.c_str(),O_RDONLY|O_NOFOLLOW);if(file<0)throw std::runtime_error("Cannot flush achievement progress."); - const auto flushed=::fsync(file);::close(file);if(flushed)throw std::runtime_error("Cannot flush achievement progress."); - std::filesystem::rename(pending,path);const int directory=::open(path.parent_path().c_str(),O_RDONLY|O_DIRECTORY);if(directory>=0){::fsync(directory);::close(directory);} -#endif -} -struct Event {Sample sample;bool reset=false,is_online=false,modded=false;unsigned local=0;std::uint64_t epoch=0;std::uint32_t frame=0;}; -class Service { - std::mutex mutex_;std::condition_variable cv_;bool stopping_=false; - std::deque> jobs_;std::deque toasts_; - std::atomic> published_{std::make_shared()}; - std::thread worker_;std::filesystem::path path_;View state_;Sample previous_,latest_; - bool baseline_=false;Racer adventure_human_;std::array driver_{};std::array,10> earned_balloon_totals_; - std::uint64_t last_epoch_=~0ULL;std::uint32_t last_frame_=~0U;Json retained_=Json::object(); - void publish(){state_.unlocked=unsigned(std::count_if(state_.entries.begin(),state_.entries.end(),[](auto& e){return e.unlocked;}));published_.store(std::make_shared(state_));} - void merge(const Json& doc) { - if(doc.empty())return; - for(unsigned i=0;ivalue("progress",0),0,catalogue()[i].target)); - if(f->value("unlocked",false)&&!e.unlocked)e={true,f->value("restored",false),f->value("modded",false),f->value("when",std::int64_t(0)),e.progress}; - } - } - Json document(){auto doc=retained_;doc["schema"]=1;doc["catalogue"]="dkr-r-94-v1";doc["entries"]=doc.value("entries",Json::object()); - for(unsigned i=0;i=0&&s.slot<3; - if(!same||s.tracks||(s.mode==1&&(s.menu==0||s.menu==3||s.menu==6||s.menu==19))){adventure_human_={};driver_.fill(0);for(auto& v:earned_balloon_totals_)v.clear();} - const Racer* human=nullptr;for(const auto& r:s.racers)if(r.address&&r.player>=0&&r.player<4&&(!event.is_online||unsigned(r.player)==event.local)){human=&r;break;} - if(!human&&same)for(const auto& r:previous_.racers)if(r.address&&r.player>=0&&r.player<4&&(!event.is_online||unsigned(r.player)==event.local)){human=&r;break;} - if(human&&adventure)adventure_human_=*human; - if(!human&&adventure&&adventure_human_.address)human=&adventure_human_; - const int delta=same?s.balloons-previous_.balloons:0; - const bool balloon=adventure&&same&&human&&delta>0&&delta<=2&&previous_.mode==0; - if(balloon&&human->character>=0&&human->character<10){auto& seen=earned_balloon_totals_[human->character];for(int n=previous_.balloons+1;n<=s.balloons;++n)if(seen.insert(n).second)++driver_[human->character];} - bool changed=false; - for(unsigned i=0;i=d.target&&previous_.balloons>(d.argument*2))&3,old=(previous_.trophies>>(d.argument*2))&3;earned=adventure&&same&&human&&value==3&&old<3;break;} - case Kind::Boss:earned=adventure&&same&&human&&(!d.mirrored||s.mirrored)&&(s.bosses&d.argument)&&!(previous_.bosses&d.argument)&&previous_.mode==0;break; - case Kind::Driver:if(adventure){progress=driver_[d.argument];earned=balloon&&progress>=5;}break; - case Kind::Silver: - case Kind::TimeTrial: - case Kind::Developer: - case Kind::Power: - for(unsigned p=0;p3||(event.is_online&&unsigned(r.player)!=event.local)||s.mode!=0||s.paused)continue; - const bool finish=same_race&&r.address==old.address&&r.finished&&!old.finished&&r.total_ticks>0&&s.race_type==0; - if(d.kind==Kind::Power){if(s.race_type!=0&&s.race_type<64)continue;if(d.argument==5){progress=std::max(progress,std::min(r.bananas,10));earned|=r.bananas>=10&&same_race&&r.address==old.address&&old.bananas<10;} - else earned|=r.power==d.argument&&r.power_level>=2&&r.power_count>0&&same_race&&r.address==old.address&&(old.power!=r.power||old.power_level<2||old.power_count==0);continue;} - if(s.custom||s.level!=d.argument)continue; - if(d.kind==Kind::Silver){earned|=finish&&adventure&&s.mirrored&&!s.trial&&r.coins==8&&r.position==1;continue;} - if(!s.trial||s.mirrored||s.laps!=3||r.vehicle!=d.vehicle||!finish)continue; - if(d.kind==Kind::TimeTrial)earned|=r.total_ticks job;{std::unique_lock lock(mutex_);cv_.wait(lock,[&]{return stopping_||!jobs_.empty();});if(jobs_.empty()&&stopping_)return;job=std::move(jobs_.front());jobs_.pop_front();}try{job();}catch(const std::exception& e){state_.error=e.what();state_.status="Achievement progress needs attention";std::fprintf(stderr,"[achievements][storage] %s\n",e.what());publish();}}}){} - ~Service(){{std::lock_guard lock(mutex_);stopping_=true;}cv_.notify_one();worker_.join();} - void post(std::function job){std::lock_guard lock(mutex_);if(jobs_.size()>=8192)throw std::runtime_error("Achievement event queue is full; storage is not keeping up.");jobs_.push_back(std::move(job));cv_.notify_one();} - std::shared_ptr snapshot(){return published_.load();} - void configure(const std::filesystem::path& root){post([this,root]{if(!path_.empty())return;path_=root/"achievements"/"progress-v1.json";std::filesystem::create_directories(path_.parent_path());FileLease lease(path_.parent_path()/"progress.lock"); - Json doc;try{doc=read(path_);}catch(...){auto backup=path_;backup+=".backup";doc=read(backup);if(doc.empty())throw;auto corrupt=path_;corrupt+=".corrupt-"+std::to_string(now());std::filesystem::copy_file(path_,corrupt);atomic_write(path_,doc,false);state_.status="Recovered achievement progress from backup";} - if(!doc.empty()){retained_=doc;merge(doc);state_.pinned=doc.value("pinned",std::string{});if(doc.contains("preferences")){const auto& j=doc["preferences"];auto& p=state_.preferences;p.notifications=j.value("notifications",true);p.sound=j.value("sound",true);p.reduced_motion=j.value("reduced_motion",false);p.position=std::clamp(j.value("position",0),0,2);p.scale=std::clamp(j.value("scale",1.F),.7F,1.6F);p.duration=std::clamp(j.value("duration",5.F),2.F,10.F);}} - publish();std::fprintf(stderr,"[achievements] collection=94 local-only v1; native-event observer ready\n");});} - void sample(Event event){post([this,event]{evaluate(event);});} - void mode(bool developer){post([this,developer]{baseline_=false;state_.status=developer?"Tracking paused for developer tools; your collection is safe":"Saved on this device";publish();});} - void settings(Preferences p){post([this,p]{state_.preferences=p;state_.preferences.position=std::clamp(p.position,0,2);state_.preferences.duration=std::clamp(p.duration,2.F,10.F);state_.preferences.scale=std::clamp(p.scale,.7F,1.6F);persist();publish();});} - void pinned(std::string id){post([this,id=std::move(id)]{state_.pinned=id;persist();publish();});} - void restore(){post([this]{const auto& s=latest_;if(!s.valid||s.tracks||s.custom)throw std::runtime_error("Open a normal adventure before recognising its saved progress."); - for(unsigned i=0;i=d.target;break;case Kind::Key:yes=s.keys&(1<<(d.argument+1));break;case Kind::Amulet:yes=(d.argument?s.wizpig_amulet:s.tt_amulet)==4;break;case Kind::Trophy:yes=((s.trophies>>(2*d.argument))&3)==3;break;case Kind::Boss:yes=s.bosses&d.argument;break;case Kind::Silver:yes=s.mirrored&&d.argument<34&&(s.course_flags[d.argument]&4);break;default:break;}if(yes)award(i,true,true); - }persist();state_.status="Recognised provable adventure progress; race challenges still need gameplay";publish();});} - void reset(){post([this]{state_.entries={};state_.pinned.clear();baseline_=false;driver_.fill(0);persist(false);{std::lock_guard lock(mutex_);toasts_.clear();}publish();});} - void export_to(std::filesystem::path path){post([this,path=std::move(path)]{atomic_write(path,document());state_.status="Achievement backup exported";publish();});} - void import_from(std::filesystem::path path){post([this,path=std::move(path)]{const auto doc=read(path);if(doc.empty())throw std::runtime_error("The achievement backup is empty.");merge(doc);persist();baseline_=false;state_.status="Achievement backup merged; no game saves were changed";publish();});} - void toast_preview(){std::lock_guard lock(mutex_);toasts_.push_back({"preview","Dino Domain Champion",true});} - bool pop(Toast& toast){std::lock_guard lock(mutex_);if(toasts_.empty())return false;toast=std::move(toasts_.front());toasts_.pop_front();return true;} - bool pending(){std::lock_guard lock(mutex_);return !toasts_.empty();} -}; -Service& service(){static Service value;return value;} -std::atomic restart{true}; -} -Sample capture(const std::uint8_t* ram,std::size_t bytes,unsigned revision) noexcept { - Sample s;if(revision!=77&&revision!=80)return s; - const auto& a=revision==80?symbols80:symbols77;Memory g(ram,bytes);s.settings=g.get(a.settings); - if(!g.range(s.settings,0x118))return s; - s.valid=true;s.mode=int(g.get(a.mode));s.menu=int(g.get(a.menu));s.tracks=g.get(a.tracks)!=0;s.mirrored=g.get(a.adventure_two)!=0;s.paused=g.get(a.paused,1)!=0; - s.slot=int(g.get(a.slot));s.level=int(g.get(a.map));s.trial=g.get(a.trial,1)!=0;s.balloons=int(std::int16_t(g.get(g.get(s.settings),2))); - if(s.balloons<0||s.balloons>47){s.valid=false;return s;} - s.keys=int(g.get(s.settings+8,2));s.bosses=int(g.get(s.settings+12,2));s.trophies=int(g.get(s.settings+14,2));s.tt_amulet=int(g.get(s.settings+22,1));s.wizpig_amulet=int(g.get(s.settings+23,1)); - const auto flags=g.get(s.settings+4);if(g.range(flags,34*4))for(unsigned i=0;i<34;++i)s.course_flags[i]=g.get(flags+i*4); - s.trophy_world=int(g.get(a.trophy_world));s.trophy_round=int(g.get(a.trophy_round)); - const auto header=g.get(a.header);if(g.range(header,0x50)){s.laps=int(g.get(header+0x4b,1));s.race_type=int(g.get(header+0x4c,1));} - // Staff headers were checked against the original ROM by inspect_rom.py. - // Use the catalogue's exact native ticks: a stale ghost or a modded ghost - // must not silently move the goalposts for an original-course challenge. - const auto racers=g.get(a.racers);if(s.mode!=0||!g.range(racers,16))return s; - for(unsigned p=0;p<4;++p){const auto object=g.get(racers+p*4);if(!g.range(object,0x68)||g.get(object+0x48,2)!=1)continue; - const auto data=g.get(object+0x64);if(!g.range(data,0x224))continue;auto& r=s.racers[p];r.address=data;r.player=std::int16_t(g.get(data,2));if(r.player<0||r.player>3){r.address=0;continue;} - r.character=int(g.get(data+3,1));r.vehicle=int(g.get(data+0x1d7,1));r.bananas=int(g.get(data+0x185,1));r.power=int(g.get(data+0x172,1));r.power_count=int(g.get(data+0x173,1));r.power_level=int(g.get(data+0x174,1));r.coins=int(g.get(data+0x202,1));r.lap=int(g.get(data+0x193,1));r.finished=int(g.get(data+0x1d8,1));r.position=int(g.get(data+0x1ac,2)); - for(int lap=0;lap36000){r.total_ticks=0;break;}r.total_ticks+=int(time);} - } - return s; -} -void configure(const std::filesystem::path& root){service().configure(root);} -void begin(bool is_online,unsigned local,bool modded,bool developer)noexcept{online=is_online;owner=local;modified=modded;development=developer;restart=true;try{service().mode(developer);}catch(...) {}} -bool online_session()noexcept{return online.load();} -void submit(const Sample& sample,std::uint64_t epoch,std::uint32_t frame)noexcept{if(development)return;try{service().sample({sample,restart.exchange(false),online.load(),modified.load(),owner.load(),epoch,frame});}catch(...){std::fprintf(stderr,"[achievements] Event could not be queued; game state was not changed\n");}} -std::shared_ptr view(){return service().snapshot();} -void preferences(Preferences p){service().settings(p);}void pin(std::string id){service().pinned(std::move(id));} -void restore_progress(){service().restore();}void reset_collection(){service().reset();} -void export_progress(const std::filesystem::path& path){service().export_to(path);}void import_progress(const std::filesystem::path& path){service().import_from(path);} -void preview(){service().toast_preview();}bool next_toast(Toast& toast){return service().pop(toast);}bool notification_active(){return service().pending();} -} diff --git a/patches/achievements/native/achievements.hpp b/patches/achievements/native/achievements.hpp deleted file mode 100644 index 9368aaae..00000000 --- a/patches/achievements/native/achievements.hpp +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::achievements { -enum class Kind { Balloons,Key,Amulet,Trophy,Boss,Silver,TimeTrial,Developer,Power,Driver }; -struct Definition {std::string id,title,description,category;Kind kind;int argument=0,target=1,vehicle=-1;bool mirrored=false,restorable=false;}; -struct Course {int id,vehicle;const char* name;int tt_ticks,developer_centiseconds;}; -inline constexpr std::array courses{{ - {5,0,"Ancient Lake",3677,5343},{3,0,"Fossil Canyon",5411,8155}, - {29,0,"Jungle Falls",3750,5413},{7,2,"Hot Top Volcano",5443,8248}, - {13,2,"Everfrost Peak",6480,9763},{6,0,"Walrus Cove",7577,11660}, - {9,0,"Snowball Valley",3985,5781},{28,0,"Frosty Village",5964,8801}, - {8,1,"Whale Bay",4387,6411},{10,0,"Crescent Island",5590,8501}, - {4,1,"Pirate Lagoon",5314,7351},{30,0,"Treasure Caves",3938,5505}, - {20,2,"Windmill Plains",7988,12316},{18,0,"Greenwood Village",6037,9180}, - {19,1,"Boulder Canyon",7341,11115},{31,0,"Haunted Woods",4171,5825}, - {17,2,"Spacedust Alley",7570,12038},{32,0,"Darkmoon Caverns",7923,12568}, - {15,2,"Spaceport Alpha",7742,11500},{33,0,"Star City",7287,11296} -}}; -// Native 60-Hz integer ghost times, checked against the original ROM headers, -// not rounded screen timestamps or a possibly modified/stale live ghost. -struct Racer {std::uint32_t address=0;int player=-1,character=-1,vehicle=-1,bananas=0,power=-1,power_level=0,power_count=0,coins=0,finished=0,position=0,lap=0,total_ticks=0;}; -struct Sample { - bool valid=false,custom=false,tracks=false,mirrored=false,trial=false,paused=false; - int mode=-1,menu=-1,level=-1,slot=-1,balloons=0,keys=0,tt_amulet=0,wizpig_amulet=0,trophies=0,bosses=0,laps=0,race_type=-1,tt_ticks=0,trophy_world=0,trophy_round=0; - std::uint32_t settings=0;std::array course_flags{};std::array racers{}; -}; -struct Entry {bool unlocked=false,restored=false,modded=false;std::int64_t when=0;int progress=0;}; -struct Preferences {bool notifications=true,sound=true,reduced_motion=false;int position=0;float scale=1,duration=5;}; -struct View {std::array entries{};Preferences preferences;std::string pinned,error,status="Saved on this device";unsigned unlocked=0;}; -struct Toast {std::string id,title;bool preview=false;}; -const std::vector& catalogue(); -Sample capture(const std::uint8_t* ram,std::size_t bytes,unsigned revision) noexcept; -void configure(const std::filesystem::path& root); -void begin(bool online,unsigned local_owner=0,bool modded=false,bool development=false) noexcept; -void submit(const Sample&,std::uint64_t epoch=0,std::uint32_t frame=0) noexcept; -std::shared_ptr view(); -void preferences(Preferences); -void pin(std::string id); -void restore_progress(); -void reset_collection(); -void export_progress(const std::filesystem::path&); -void import_progress(const std::filesystem::path&); -void preview(); -bool next_toast(Toast&); -bool notification_active(); -bool online_session() noexcept; -} diff --git a/patches/achievements/package.py b/patches/achievements/package.py deleted file mode 100644 index 2b4b49e9..00000000 --- a/patches/achievements/package.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Allowlisted local-achievement playtests; never executes game or test targets.""" -from pathlib import Path -import argparse -import importlib.util -import json -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-achievements-playtest.1' -def load(name,path): - spec=importlib.util.spec_from_file_location(name,path);module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module -def main(a): - if a.output.exists():raise ValueError('Choose a NEW package destination') - binary=a.compiled/('libexec/dkr-r/DKR-R-Runtime.exe' if a.platform=='windows' else 'DKR-R') - audit=load('local_achievement_audit',AUTH/'audit.py').inspect(a.stage,binary) - base=load('achievement_package_allowlist',ROOT/'patches/release-prep/v1.0.7/package_release.py');base.VERSION=VERSION - base.notes=lambda root,label:base.copy(AUTH/'ACHIEVEMENTS.txt',root/'README.txt') - a.output.mkdir(parents=True) - root,artifact,files=getattr(base,a.platform)(a) - receipt={'schema':1,'version':VERSION,'platform':a.platform,'arch':a.arch,'artifact':{'name':artifact.name,'bytes':artifact.stat().st_size,'sha256':base.sha(artifact)},'files':files,'achievement_audit':audit,'validation':audit['validation'],'published':False,'automatic_updates_signed':False} - (a.output/'BUILD-RECEIPT.json').write_text(json.dumps(receipt,indent=2)+'\n') - (a.output/'SHA256SUMS.txt').write_text(base.sha(artifact)+' '+artifact.name+'\n') - (a.output/'ACHIEVEMENT-CHECKLIST.json').write_text(json.dumps({'count':94,'entries':audit['entries']},indent=2)+'\n') - checklist=['DKR-R - All 94 Achievement Goals','', - 'Gameplay checklist for the owner. No gameplay or runtime tests were run by the agent.', - 'These are independently implemented local goals, not a RetroAchievements login/sync.',''] - for index,entry in enumerate(audit['entries'],1): - detail='; '.join(str(key).replace('_',' ')+'='+str(value) for key,value in entry.items() if key not in ('id','rule')) - checklist.append('[ ] '+str(index).rjust(2)+'. '+entry['id']+' - '+entry['rule']+('. '+detail if detail else '')) - (a.output/'ACHIEVEMENT-CHECKLIST.txt').write_text('\n'.join(checklist)+'\n',encoding='utf-8') - base.copy(AUTH/'ACHIEVEMENTS.txt',a.output/'ACHIEVEMENTS.txt') - print('Local achievements playtest packaged without target execution:',artifact) -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('platform',choices=('windows','linux')) - for name in ('compiled','template','output','stage','trophy'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--arch',choices=('x86_64','aarch64'),default='x86_64');p.add_argument('--runtime-image',type=Path);main(p.parse_args()) diff --git a/patches/achievements/pipeline.py b/patches/achievements/pipeline.py deleted file mode 100644 index f6a73ad5..00000000 --- a/patches/achievements/pipeline.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Compose local achievements into isolated build copies. Source inputs are read-only.""" -from pathlib import Path -import argparse -import hashlib -import json -import re -import sys -import tomllib -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -sys.path.insert(0,str(ROOT/'patches/custom-vehicles')) -from guest_pipeline import body - -def once(text,old,new): - if text.count(old)!=1:raise ValueError('Achievement anchor changed: '+old[:140]) - return text.replace(old,new,1) -def symbols(): - fields={'settings':'gSettingsPtr','mode':'gGameMode','menu':'gCurrentMenuId','tracks':'gIsInTracksMode','adventure_two':'gIsInAdventureTwo','paused':'gIsPaused','slot':'gSaveFileIndex','map':'gMapId','trial':'gIsTimeTrial','header':'gCurrentLevelHeader2','racers':'gRacersByPort','tt_time':'gTTGhostTimeToBeat','staff_ghost':'gTimeTrialStaffGhost','tt_vehicle':'gTimeTrialVehicle','trophy_world':'gTrophyRaceWorldId','trophy_round':'gTrophyRaceRound'} - result='#pragma once\nnamespace dkr::runtime::achievements {\nstruct Symbols {'+''.join('std::uint32_t '+key+';' for key in fields)+'};\n' - for rev in (77,80): - doc=tomllib.loads((ROOT/f'modding/reference-us-v{rev}/data.toml').read_text(encoding='utf-8')) - found={s['name']:s['vram'] for section in doc['section'] for s in section.get('symbols',[])} - result+='inline constexpr Symbols symbols'+str(rev)+'{'+','.join(hex(found[value])+'U' for value in fields.values())+'};\n' - return result+'}\n' -def ui(text): - text=once(text,'#include "runtime_ui.hpp"','#include "runtime_ui.hpp"\n#include "achievements.hpp"\n#include \n#include ') - text=once(text,'constexpr int kMenuPageCount = 9;','constexpr int kPageAchievements = 9;\nconstexpr int kMenuPageCount = 10;') - text=once(text,'kSidebarOrder{0,5,6,1,2,3,4,8,7,9,10}','kSidebarOrder{0,5,6,1,2,3,4,8,9,7,10,11}') - text=once(text,'#include "texture_dump_ui.inl"','#include "texture_dump_ui.inl"\n#include "achievement_ui.inl"') - for name in ('LauncherSidebarButton','SidebarButton'): - pattern=rf'(?endFrame();',' DrawPinnedAchievement();\n DrawAchievementToast();\n inspector->endFrame();') - text=once(text,' ImGui::Render();',' DrawAchievementToast();\n ImGui::Render();') - return text -def runtime(text): - text=once(text,'#include "runtime_legacy_mods.hpp"','#include "runtime_legacy_mods.hpp"\n#include "achievements.hpp"') - anchor=' if(name=="dkr_shared_records_capture") {' - text=once(text,anchor,''' if(name=="dkr_achievements_context") { - arity(1);const auto assets=session.load(); - result=owns_level(static_cast(args[0])) || - (assets&&!assets->current_content().empty()) || !state_->selected_cup.empty(); - } - else if(name=="dkr_shared_records_capture") {'''.replace('!state_->selected_cup.empty()','!selected_trophy().empty()')) - text+=''' -extern "C" void dkr_achievements_observe(std::uint8_t* ram,unsigned revision) noexcept { - using namespace dkr::runtime; - if(achievements::online_session())return; - try { - auto sample=achievements::capture(ram,legacy::guest_memory_size(),revision); - const auto assets=legacy::session.load(); - sample.custom=sample.level>=65||legacy::owns_level(sample.level)|| - (assets&&!assets->current_content().empty())||!legacy::selected_trophy().empty(); - achievements::submit(sample); - }catch(...){std::fprintf(stderr,"[achievements] Read-only observer unavailable at this boundary\\n");} -} -''' - return text -def driver(text): - text=once(text,'#include "resident_music.hpp"','#include "resident_music.hpp"\n#include "achievements.hpp"\n#include ') - # Projections are host-local observations, not guest state, checkpoints or - # wire effects. Replay replaces the same frame; only commit consumes one. - text=once(text,' std::vector ram_;',' std::map achievement_frames_;\n std::vector ram_;') - anchor=''' return true; - } - bool commit(std::uint64_t epoch, std::uint32_t frame, std::span effects,''' - text=once(text,anchor,''' if(authored_cpu_&&!sdk2_idle) { - auto observed=dkr::runtime::achievements::capture(ram_.data(),ram_.size(),DKR_PROBE_REVISION); - observed.custom=observed.level>=65; -#if DKR_PROBE_HAS_MOD_SERVICES - if(mods_) {std::uint64_t custom=0;const std::uint64_t level=std::uint32_t(observed.level); - if(!mods_->dispatch("dkr_achievements_context",ram_.data(),&context_,&level,1,nullptr,0,custom))observed.custom=observed.custom||bool(custom);} -#endif - achievement_frames_.insert_or_assign(frame,observed); - if(achievement_frames_.size()>1024)achievement_frames_.erase(achievement_frames_.begin()); - } - return true; - } - bool commit(std::uint64_t epoch, std::uint32_t frame, std::span effects,''') - anchor=''' ++confirmed_count_; -#if DKR_PROBE_HAS_MOD_SERVICES''' - text=once(text,anchor,''' ++confirmed_count_; - if(const auto observation=achievement_frames_.find(frame);observation!=achievement_frames_.end()) { - if(scene_sessions_)dkr::runtime::achievements::submit(observation->second,epoch,frame); - achievement_frames_.erase(achievement_frames_.begin(),std::next(observation)); - } -#if DKR_PROBE_HAS_MOD_SERVICES''') - # A scene starts a new frame sequence. Never let an old projection survive - # a scene/epoch transition or be accepted after checkpoint admission. - text=once(text,' scenery_.reset(native_.presentation.scene);',' scenery_.reset(native_.presentation.scene);\n achievement_frames_.clear();') - return text -def main(args): - if args.output.resolve().is_relative_to(ROOT):raise ValueError('Achievements need an isolated build outside the checkout') - args.output.mkdir(parents=True,exist_ok=True) - for file in (AUTH/'native').iterdir(): - if file.is_file():(args.output/file.name).write_bytes(file.read_bytes()) - (args.output/'achievement_symbols.hpp').write_text(symbols(),encoding='utf-8',newline='\n') - swaps=[];guest_counts={77:0,80:0} - for row in json.loads(args.inputs.read_text(encoding='utf-8')): - path=Path(row['path']);original=path.read_bytes();text=original.decode().replace('\r\n','\n');name=path.name - if row.get('guest'): - span=body(text,'main_game_loop') - if not span:continue - rev=80 if row['target'].endswith('80') else 77;guest_counts[rev]+=1 - start,end=span;part=text[start:end] - part,count=re.subn(r'(?m)^ return;$',f' extern void dkr_achievements_observe(uint8_t*,unsigned);\n dkr_achievements_observe(rdram,{rev});\n return;',part) - if count!=1:raise ValueError('Unreviewed main_game_loop return boundary') - text=text[:start]+part+text[end:];next_path=args.output/('guest'+str(rev))/name - else: - if name=='runtime_ui.cpp':text=ui(text) - elif name=='runtime_legacy_mods.cpp':text=runtime(text) - elif name=='probe_driver.cpp':text=driver(text) - elif name=='game_main.cpp': - text=once(text,'#include "runtime_ui.hpp"','#include "runtime_ui.hpp"\n#include "achievements.hpp"') - anchor=' const auto code_set=online_mods?online_mods->code():prepared_mods->code;' - text=once(text,anchor,anchor+'\n dkr::runtime::achievements::begin(online_save.active,0,code_set&&!code_set->ordered.empty(),code_set&&std::any_of(code_set->ordered.begin(),code_set->ordered.end(),[](const auto& p){return p->development;}));') - elif name=='experimental_runtime.cpp': - text='#include "achievements.hpp"\n'+text - text=once(text,' LobbyRoute route(lobby,*launch);const auto local=route.owner(initial.local_slot);',' LobbyRoute route(lobby,*launch);const auto local=route.owner(initial.local_slot);\n dkr::runtime::achievements::begin(true,local,bool(mods),launch->development);') - else:raise ValueError('Unexpected achievement input '+str(path)) - next_path=args.output/name - # Moving a TU must not change quote-include lookup. In particular the - # shared-save header must not be mixed with the older SDK capsule copy. - def adjacent(match): - candidate=path.parent/match.group(1) - return '#include "'+candidate.resolve().as_posix()+'"' if candidate.is_file() else match.group(0) - text=re.sub(r'^#include "([^"\n]+)"',adjacent,text,flags=re.M) - next_path.parent.mkdir(parents=True,exist_ok=True);next_path.write_text(text,encoding='utf-8',newline='\n') - swaps.append({**row,'new':str(next_path.resolve()),'input_sha256':hashlib.sha256(original).hexdigest(),'output_sha256':hashlib.sha256(text.encode()).hexdigest()}) - if guest_counts!={77:1,80:1}:raise ValueError('Missing an original revision frame observer: '+str(guest_counts)) - (args.output/'achievement-swaps.json').write_text(json.dumps(swaps,indent=2)+'\n',encoding='utf-8') - print('All 94 local achievements staged over the accepted texture/vehicle build; source inputs unchanged.') -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);main(p.parse_args()) diff --git a/patches/all-player-detail/PIPELINE.txt b/patches/all-player-detail/PIPELINE.txt deleted file mode 100644 index 30ed39c0..00000000 --- a/patches/all-player-detail/PIPELINE.txt +++ /dev/null @@ -1,56 +0,0 @@ -All-player maximum detail - implementation notes - -Entry point: patches/all-player-detail/configure.py -Overlay: ReleaseOverlay.cmake, composed after achievements and all accepted -vehicle/texture/repair overlays. Authored changes remain in this directory. -No checked-out dependency, RecompiledFuncs or RecompiledPatches is edited. - -Source evidence - -The original track_setup_racers/spawn_object path can omit the first two LODs -for CPUs and multiplayer. The final overlay clears only those two skip bits -when the loaded header identifies a racer. NO_LODS preview policy and ghost -models are preserved. This residency policy is identical on every peer and -does not read a graphics preference. Both US v77/v80 ordinary and owned guest -variants receive the same change. Peer build contracts include the new policy. - -Offline single-player keeps the accepted native highest-resident selector. -Multiplayer and ordinary online sessions leave that canonical selector alone. -Their exact render_mesh ranges are recorded into the existing task identity -sidecar, matched by scene and immutable OSTask data_ptr. Experimental Rollback -uses its existing owned observations. Local maximum-detail preference is read -only when preparing presentation, never by the owned simulation. - -Ordinary snapshots are 8 MiB. High-detail work uses the existing private 16 MiB -camera/car decode workspace, never an out-of-range write to the snapshot or live -guest RAM. Private model buffers C10000-D00000 remain disjoint from immediate -vertices, camera, label, car and model texture banks. The two vertex buffers -and delta storage each accommodate 8192 native vertices without overlapping. -The model-only kernel retains its closed call graph and checked memory writes. - -Racer packets are prepared before optional scenery. Matching pose requests are -reused between views; head angles and transforms stay per view. Racer and scenery -packet/operation/triangle counters are separate. Triangle capacity is per view, -not shared between all four views. State restoration, exact high-driver masks, -texture ownership checks and original-draw fallback are retained. Hidden online -views do not spend high-detail draw capacity. Custom cars retain their existing -local preference path (LOD0 when enabled) with actual-LOD diagnostics added. - -Ownership/capacity failure does not suppress a source draw that was not replaced. -The setting cannot create a missing LOD or repair an invalid model. Protective -limits remain; they are sized for the supported racer/view/model contracts, not -removed. Diagnostics identify scene, viewport, object, requested/effective choice -and fallback reason instead of expiring once early in the process. - -Verification - -audit.py inspects source hashes, final target coverage, both ROM revisions, -immutable-task capture, independent settings, decode workspace bounds, disjoint -model arenas, racer/scenery capacities, masks, ghost/Accurate policy, linked -feature markers and retained achievement integration. Package construction uses -the established allowlist and checks archive/ELF/PE ownership without running -any executable. BUILD-RECEIPT.json records the artifact hashes and peer IDs. - -These checks are not gameplay validation. The owner must approve visual quality, -cross-platform online sessions and performance before release. No gameplay or -runtime qualification targets are run by this pipeline. diff --git a/patches/all-player-detail/PLAYTEST.txt b/patches/all-player-detail/PLAYTEST.txt deleted file mode 100644 index efa23b1c..00000000 --- a/patches/all-player-detail/PLAYTEST.txt +++ /dev/null @@ -1,53 +0,0 @@ -DKR-R v1.0.9 - All-Player Detail Playtest 1 - -Maximum Vehicle Detail should now cover characters and vehicles throughout -the game, not just single player. This includes CPU racers, custom characters, -custom cars, 2p/3p/4p local multiplayer and DKR-R Online. - -Turn it on in Graphics using the Modern presentation profile. Turning it off -keeps the normal distance-based LOD selection. Accurate mode stays unchanged. -This setting changes model detail, not view distance or texture resolution. -It cannot add detail that a mod's own highest model does not contain. - -Online graphics settings are still your own. The host does not choose them -for everyone. The higher-detail model is drawn from a private render copy; -it does not change the shared race simulation, physics or random choices. -Everyone in an online lobby needs this build. Do not mix it with older builds. - -The previous achievements, texture dumping, custom vehicle seating, wheel -animation/interpolation and vehicle-selection work are included. - -What I would like checked - -[ ] Single-player Track Select and Adventure still look right. -[ ] 2p, 3p and 4p: compare the setting on and off in every viewport. -[ ] Check stock characters, custom characters, CPU racers and custom cars. -[ ] Watch distant racers approach and pass another camera. With the setting - on, their model should stay at its highest available detail. -[ ] Check cars, hovercrafts and planes with stock/custom characters. Custom - vehicle package support remains cars only, as before this change. -[ ] Check the race start, finish, end-race camera and replay sequences. -[ ] Check translucent parts, driver/chassis separation and wheel movement. -[ ] Online: compare one player's setting on with another player's setting off. -[ ] Check Experimental Rollback and the other online backends you use. -[ ] Check both online full-screen view and normal split-screen view. -[ ] Check performance on your usual tracks, including a busy four-player race. -[ ] Check switching scenes/restarting does not leave old geometry on screen. - -If anything looks wrong, close DKR-R normally and send runtime.log. This build -adds bounded scene/view/object/LOD messages marked [graphics][all-player-detail] -so we can tell what the renderer actually submitted, or why it kept the native -mesh. Invalid optional presentation data keeps the original drawing rather -than stopping a healthy game. - -Build status - -Windows x64 and Linux x86_64 AppImage. Compiled and statically inspected only. -I have not launched the game, mods, gameplay tests or AppImage. Gameplay and -hardware approval are yours. Higher model detail in more viewports needs more -graphics power; this does not promise identical performance with the setting off. - -Extract Windows into a fresh folder and run DKR-R.exe. Keep libexec and assets -with it. Linux users can make the AppImage executable and run it as usual. -No ROMs, saves, personal settings or installed mods are included. This is a -playtest package, not a published or signed automatic-update release. diff --git a/patches/all-player-detail/ReleaseOverlay.cmake b/patches/all-player-detail/ReleaseOverlay.cmake deleted file mode 100644 index 78975568..00000000 --- a/patches/all-player-detail/ReleaseOverlay.cmake +++ /dev/null @@ -1,112 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../achievements/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_all_player_detail) - set(auth "${DKRPORT_ROOT}/patches/all-player-detail") - set(stage "${CMAKE_BINARY_DIR}/generated/all-player-detail") - file(MAKE_DIRECTORY "${stage}") - set(records "[") - set(separator "") - foreach(pair "DKRPortGame|presentation_identity.cpp" "DKRPortGame|runtime_enhancements.cpp" "DKRPortGame|rt64_renderer.cpp" "DKRPortGame|local_model_rt64.cpp" "DKRPortGame|vehicle_rt64.cpp" "DKRPortGame|f3ddkr_rt64.cpp" "DKRPortGame|runtime_ui.cpp" "DKRLocalModelV77|visual_model_kernel.c" "DKRLocalModelV80|visual_model_kernel.c" "DKRExperimentalOwnedGameV77|probe_native.c" "DKRExperimentalOwnedGameV80|probe_native.c") - string(REPLACE "|" ";" fields "${pair}") - list(GET fields 0 target) - list(GET fields 1 name) - get_target_property(paths ${target} SOURCES) - list(FILTER paths INCLUDE REGEX "[/\\\\]${name}$") - list(LENGTH paths count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "All-player detail needs one final ${target}/${name}") - endif() - list(GET paths 0 path) - file(TO_CMAKE_PATH "${path}" path) - set(extra "") - if(name STREQUAL "runtime_ui.cpp") - get_target_property(directory ${target} SOURCE_DIR) - get_filename_component(parent "${path}" DIRECTORY) - get_property(source_includes SOURCE "${path}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - get_target_property(target_includes ${target} INCLUDE_DIRECTORIES) - set(graphics "") - foreach(candidate IN ITEMS "${parent}" ${source_includes} ${target_includes}) - if(EXISTS "${candidate}/runtime_graphics_ui.inl") - file(TO_CMAKE_PATH "${candidate}/runtime_graphics_ui.inl" graphics) - break() - endif() - endforeach() - if(NOT graphics) - message(FATAL_ERROR "Cannot resolve the existing graphics UI include") - endif() - set(extra ",\"graphics\":\"${graphics}\"") - endif() - string(APPEND records "${separator}{\"target\":\"${target}\",\"path\":\"${path}\"${extra}}") - set(separator ",") - endforeach() - foreach(target DKRRecompiledFuncsV77 DKRRecompiledFuncsV80 DKRExperimentalOwnedGameV77 DKRExperimentalOwnedGameV80) - get_target_property(paths ${target} SOURCES) - foreach(path IN LISTS paths) - if(path MATCHES "\\.c$") - file(TO_CMAKE_PATH "${path}" path) - string(APPEND records "${separator}{\"target\":\"${target}\",\"path\":\"${path}\",\"guest\":true}") - set(separator ",") - endif() - endforeach() - endforeach() - string(APPEND records "]") - file(WRITE "${stage}/inputs.json" "${records}") - file(GLOB authored CONFIGURE_DEPENDS "${auth}/native/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${authored} "${auth}/pipeline.py") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" --inputs "${stage}/inputs.json" --output "${stage}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "All-player detail staging failed") - endif() - file(READ "${stage}/swaps.json" swaps) - string(JSON count LENGTH "${swaps}") - math(EXPR last "${count}-1") - foreach(index RANGE ${last}) - string(JSON target GET "${swaps}" ${index} target) - string(JSON old GET "${swaps}" ${index} path) - string(JSON next GET "${swaps}" ${index} new) - get_target_property(directory ${target} SOURCE_DIR) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage};${prior};${prior}/..;${includes}") - dkr_repair_replace_source(${target} "${old}" "${next}") - endforeach() - target_include_directories(DKRPortGame PRIVATE "${stage}") - # Residency changes are identical on every peer; graphics remain local. - set(ledger "all-player-detail-v1\n") - foreach(name pipeline.py ReleaseOverlay.cmake) - file(SHA256 "${auth}/${name}" hash) - string(APPEND ledger "${name}:${hash}\n") - endforeach() - get_target_property(definitions DKRExperimentalOwnedRegistry COMPILE_DEFINITIONS) - set(updated "") - set(contracts "{") - set(separator "") - foreach(value IN LISTS definitions) - if(value MATCHES "^DKR_OWNED_BUILD_ID_V(77|80)=\"([a-f0-9]+)\"$") - set(revision "${CMAKE_MATCH_1}") - string(SHA256 hash "${CMAKE_MATCH_2}\n${ledger}") - set(value "DKR_OWNED_BUILD_ID_V${revision}=\"${hash}\"") - string(APPEND contracts "${separator}\"${revision}\":\"${hash}\"") - set(separator ",") - endif() - list(APPEND updated "${value}") - endforeach() - if(separator STREQUAL "") - message(FATAL_ERROR "Missing detail peer contract") - endif() - string(APPEND contracts "}") - file(WRITE "${stage}/contracts.json" "${contracts}") - set_property(TARGET DKRExperimentalOwnedRegistry PROPERTY COMPILE_DEFINITIONS "${updated}") - if(DKR_RELEASE_EVIDENCE) - file(WRITE "${DKR_RELEASE_EVIDENCE}/DKRExperimentalOwnedRegistry-COMPILE_DEFINITIONS.txt" "${updated}\n") - endif() - endfunction() - cmake_language(DEFER CALL dkr_apply_all_player_detail) -endif() diff --git a/patches/all-player-detail/audit.py b/patches/all-player-detail/audit.py deleted file mode 100644 index 14fd0584..00000000 --- a/patches/all-player-detail/audit.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Inspect final source composition and linked bytes. Never execute targets.""" -from pathlib import Path -import argparse -from collections import Counter -import hashlib -import importlib.util -import json -import re -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-detail-playtest.1' - -def sha(path):return hashlib.sha256(path.read_bytes()).hexdigest() -def require(value,message): - if not value:raise ValueError(message) -def load(name,path): - spec=importlib.util.spec_from_file_location(name,path);module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module -def inspect(stage,binary=None): - swaps=json.loads((stage/'swaps.json').read_text(encoding='utf-8')) - expected=Counter(DKRPortGame=7,DKRLocalModelV77=1,DKRLocalModelV80=1, - DKRExperimentalOwnedGameV77=2,DKRExperimentalOwnedGameV80=2,DKRRecompiledFuncsV77=2,DKRRecompiledFuncsV80=2) - require(Counter(r['target'] for r in swaps)==expected,'Incomplete target composition') - for r in swaps: - require(not Path(r['new']).resolve().is_relative_to(ROOT),'Output inside checkout') - require(sha(Path(r['path']))==r['input_sha256'],'Upstream input changed: '+r['path']) - require(sha(Path(r['new']))==r['output_sha256'],'Generated copy changed: '+r['new']) - for path in (AUTH/'native').iterdir():require(sha(path)==sha(stage/path.name),'Authored file not staged: '+path.name) - coverage=json.loads((stage/'coverage.json').read_text()) - require(coverage=={t:({'residency':1,'mesh':1} if t.startswith('DKRRecompiledFuncs') else {'residency':1}) for t in expected if t.startswith(('DKRRecompiledFuncs','DKRExperimentalOwnedGame'))},'Incomplete two-revision hook coverage') - native=lambda name:(stage/'DKRPortGame'/name).read_text(encoding='utf-8') - observer=(stage/'detail_observer.inl').read_text(encoding='utf-8') - for marker in ('!netplay::external_side_effects_allowed()','highest==current','half(object+0x48)!=1','g_detail_maps[g_recording_buffer&1U]','std::memcpy(words,&e,sizeof(e))','dkr_owned_draw_event_valid(&e)'): - require(marker in observer,'Missing observer boundary: '+marker) - require(not re.search(r'(?:ram|rdram)\s*\[[^;]*\]\s*=(?!=)',observer),'Observer writes guest memory') - require(not re.search(r'ctx->\w+\s*=(?!=)',observer),'Observer mutates guest registers') - identity=native('presentation_identity.cpp') - for marker in ('frame.details=std::move(g_detail_maps[g_recording_buffer&1U])','g_active_detail=std::move(frame.details)','frame.markers.clear();frame.details.clear()','rows:g_detail_maps','g_active_marker_map.clear();g_active_detail.clear()'): - require(marker in identity,'Missing immutable task ownership: '+marker) - renderer=native('rt64_renderer.cpp') - require(renderer.index('TaskIdentityScope identity_scope'){camera_decoder_}'): - require(marker in renderer,'Missing private decode arena: '+marker) - enhancements=native('runtime_enhancements.cpp') - policy=enhancements[enhancements.index('extern "C" void dkr_apply_maximum_racer_detail('):enhancements.index('extern "C" void dkr_restore_multiplayer_race_music(')] - for marker in ('session().runtime_view().active','ViewportLayout))!=0)return','maximum_detail_enabled()','context->r8 = context->r4','kTimeTrialGhostBehaviour'): - require(marker in policy,'Missing all-mode/native compatibility gate: '+marker) - model=native('local_model_rt64.cpp') - for marker in ('std::array racer_cache','std::array racer_triangles','detail::PreparedSteps','detail::ViewTriangles','detail::KernelOperations','p.build(o.e,false)','p.build(o.e,o.extra)','driver_mask->hide[step.source_triangle]','NativeDrawScope restore(state)','mix_identity(o.e.parameters[2])','o.e.token*0x9E3779B9U','o.replaced=true','original retained'): - require(marker in model,'Missing renderer isolation/capacity: '+marker) - require(model.index('p.build(o.e,false)'),4> drawn;'),'Racers must be prepared before extra scenery') - require(model.index('driver_mask->hide.size()')data_->online_view->suppress_rectangle(*state)'): - require(marker in bridge,'Missing decoder/view boundary: '+marker) - vehicle=native('vehicle_rt64.cpp') - require('maximum_detail_?0U:' in vehicle and 'custom vehicle submitted' in vehicle,'Custom vehicles do not select/record their actual highest LOD') - for rev in (77,80): - kernel=(stage/f'DKRLocalModelV{rev}/visual_model_kernel.c').read_text(encoding='utf-8') - fields={k:int(v,0) for k,v in re.findall(r'\b([A-Z][A-Z0-9_]*)\s*=\s*(0x[0-9A-Fa-f]+|\d+)',kernel)} - spans=[(fields[k],fields[k]+fields['MAX_VERTICES']*10,k) for k in ('VERTICES','VERTEX2','DELTAS')] - spans += [(fields['FRACTION'],fields['COMMANDS'],'fractions'),(fields['COMMANDS'],fields['COMMAND_END'],'commands'),(fields['STACK']-65536,fields['STACK']+64,'stack')] - spans.sort() - require(fields['MAX_VERTICES']==8192 and all(a[1]<=b[0] for a,b in zip(spans,spans[1:])),'Private model arenas overlap') - require(all(0xC10000<=ar2)==1U' in text and 'MEM_W(0X6C,ctx->r29)&=~12U' in text,'Racer-only flag patch missing') - require('maximum_detail_enabled' not in text,'Local preference leaked into canonical guest') - ui=native('runtime_ui.cpp') - require('#include "all_detail_graphics.inl"' in ui,'Launcher not wired to staged help') - require('highest-detail characters and vehicles in every view' in (stage/'all_detail_graphics.inl').read_text(encoding='utf-8'),'Setting help not updated') - contracts=json.loads((stage/'contracts.json').read_text()) - require(set(contracts)=={'77','80'} and all(re.fullmatch('[a-f0-9]{64}',v) for v in contracts.values()),'Missing peer compatibility contracts') - achievements=load('retained_achievement_audit',ROOT/'patches/achievements/audit.py').inspect(stage.parent/'achievements') - if binary: - data=binary.read_bytes() - for marker in (VERSION.encode(),b'all-player-detail-v1',b'highest resident presentation mesh',b'custom vehicle submitted',b'collection=94 local-only v1',b'ACHIEVEMENT UNLOCKED',b'complete-family selection'): - require(marker in data,'Linked feature/version missing: '+repr(marker)) - return {'schema':1,'version':VERSION,'swaps':swaps,'coverage':coverage,'peer_contracts':contracts, - 'retained_achievement_count':achievements['count'],'binary_sha256':sha(binary) if binary else None, - 'validation':'Compilation and static inspection only. No game, mod, gameplay test or AppImage was executed. Owner gameplay acceptance remains pending.'} -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--stage',type=Path,required=True);p.add_argument('--binary',type=Path);p.add_argument('--output',type=Path);a=p.parse_args();report=inspect(a.stage,a.binary) - if a.output:a.output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') - else:print(json.dumps(report,indent=2)) diff --git a/patches/all-player-detail/configure.py b/patches/all-player-detail/configure.py deleted file mode 100644 index c6196e43..00000000 --- a/patches/all-player-detail/configure.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Compose all-player detail over the accepted achievements/vehicle pipeline.""" -from pathlib import Path -import argparse -import importlib.util -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-detail-playtest.1' -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','capsule','log'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--cmake',default='cmake');a=p.parse_args() - spec=importlib.util.spec_from_file_location('detail_release',ROOT/'patches/release-prep/v1.0.7/release.py');release=importlib.util.module_from_spec(spec);spec.loader.exec_module(release) - release.AUTH=AUTH;release.VERSION=VERSION;release.configure(a.build,a.log,a.cmake,a.capsule) diff --git a/patches/all-player-detail/native/detail_contract.hpp b/patches/all-player-detail/native/detail_contract.hpp deleted file mode 100644 index b2eb4eb7..00000000 --- a/patches/all-player-detail/native/detail_contract.hpp +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include "netplay/experimental_draw_events.h" - -namespace dkr::runtime::detail { -// Eight racers, four views and separate opaque/transparent submissions. -// Preparation is shared by matching pose requests; GPU draws remain per view. -inline constexpr unsigned Racers=8,Views=4,Passes=2,Observations=256; -inline constexpr unsigned ModelTriangles=8192,PacketSteps=32768; -inline constexpr unsigned PreparedSteps=Racers*Passes*PacketSteps; -inline constexpr unsigned ViewTriangles=Racers*Passes*ModelTriangles; -inline constexpr unsigned KernelOperations=Racers*Passes*2000000U; -inline constexpr unsigned TextureBegin=0xF00000U,TextureEnd=0x1000000U; -std::span ordinary_events() noexcept; - -struct Evidence { - std::uint32_t scene=0,object=0; - unsigned view=0,source=0,selected=0,layer=0; - bool effective=false; - const char* reason="submitted"; -}; -// Bounded per-scene/change diagnostics. Unlike the previous process-wide -// first-24 budget, a later race and its other viewports get fresh evidence. -inline void note(Evidence e) noexcept { - struct Seen {std::uint32_t object=0;unsigned view=0,layer=0,source=0,selected=0;bool effective=false;std::uint32_t reason=0;}; - struct State {std::uint32_t scene=~0U;unsigned reports=0,calls=0;std::array seen{};}; - static thread_local State state; - if(state.scene!=e.scene){state={};state.scene=e.scene;} - std::uint32_t reason=2166136261U; - for(const char* p=e.reason;*p;++p){reason^=static_cast(*p);reason*=16777619U;} - ++state.calls;bool changed=true; - for(auto& row:state.seen)if(!row.object||(row.object==e.object&&row.view==e.view&&row.layer==e.layer)) { - changed=!row.object||row.source!=e.source||row.selected!=e.selected||row.effective!=e.effective||row.reason!=reason; - row={e.object,e.view,e.layer,e.source,e.selected,e.effective,reason};break; - } - if(changed&&(state.reports<64||state.calls%4096==0)) { - ++state.reports; - std::fprintf(stderr,"[graphics][all-player-detail] scene=%u view=%u object=%08X layer=%u source-lod=%u selected-lod=%u effective=%u reason=%s; all-player-detail-v1\n", - e.scene,e.view,e.object,e.layer,e.source,e.selected,unsigned(e.effective),e.reason); - } -} -} diff --git a/patches/all-player-detail/native/detail_observer.inl b/patches/all-player-detail/native/detail_observer.inl deleted file mode 100644 index 604183e5..00000000 --- a/patches/all-player-detail/native/detail_observer.inl +++ /dev/null @@ -1,46 +0,0 @@ -// Included in the task identity implementation: recording and submission use -// its existing lock, buffer parity and immutable OSTask ownership key. -namespace dkr::runtime::detail { -std::span ordinary_events() noexcept { - return g_active_owned_task?std::span{}:std::span{g_active_detail}; -} -} -extern "C" void dkr_detail_mesh_begin(std::uint8_t* ram,recomp_context* ctx,std::uint32_t* words) noexcept { - dkr_owned_draw_event e{};words[0]=0; - using namespace dkr::runtime; - if(!ram||!ctx||!enhancements::modern_presentation_enabled()||!netplay::external_side_effects_allowed())return; - const auto range=[](std::uint32_t p,unsigned bytes){return p>=0x80000000U&&p<0x80800000U&&bytes<=0x80800000U-p;}; - const auto word=[&](std::uint32_t p){return range(p,4)&&!(p&3U)?ReadU32(ram,p):0U;}; - const auto byte=[&](std::uint32_t p){return range(p,1)?unsigned(ReadU8(ram,p)):0U;}; - const auto half=[&](std::uint32_t p){if(!range(p,2)||(p&1U))return 0U;return byte(p)*256U+byte(p+1);}; - const auto object=std::uint32_t(ctx->r5); - if(!range(object,0x80)||half(object+0x48)!=1)return; // Ghost variants keep their native model. - const auto header=word(object+0x40),slots=word(object+0x68),racer=word(object+0x64); - if(!range(header,0x78)||!range(racer,0x224))return; - const auto count=byte(header+0x55),index=byte(object+0x3a); - if(!count||count>32||index>=count||!range(slots,count*4))return; - unsigned first=0;std::uint32_t highest=0; - for(;firstr29); - if(!range(start,8)||(start&7)||viewport>3||!range(stack+16,4))return; - const auto head=word(stack+16);if(head>1)return; - e.kind=DKR_OWNED_LOCAL_MODEL;e.address=start;e.token=viewport; - e.parameters[1]=object;e.parameters[2]=highest;e.parameters[3]=half(object+0x18); - e.parameters[4]=byte(object+0x3b);e.parameters[5]=std::uint32_t(ctx->r7); - e.parameters[6]=ctx->r6!=0;e.parameters[7]=head;e.parameters[8]=half(racer+0x16a);e.parameters[9]=word(intensity); - static_assert(sizeof(e)==19*sizeof(std::uint32_t));std::memcpy(words,&e,sizeof(e)); -} -extern "C" void dkr_detail_mesh_end(std::uint8_t* ram,recomp_context*,std::uint32_t* words) noexcept { - dkr_owned_draw_event e{};std::memcpy(&e,words,sizeof(e));if(!e.kind)return; - const auto dl=dkr::runtime::revision_addresses::selected_revision()==dkr::runtime::rom::Revision::UsV80?0x8011B40CU:0x8011AE8CU; - e.parameters[0]=ReadU32(ram,dl); - if(dkr_owned_draw_event_valid(&e)) { - std::scoped_lock lock(g_identity_mutex);auto& rows=g_detail_maps[g_recording_buffer&1U]; - if(rows.size()') - text=once(text,'struct SubmittedFrame {','struct SubmittedFrame {\n std::vector details;') - text=once(text,'std::deque g_submitted_frames;', - 'std::array,2> g_detail_maps;\nthread_local std::vector g_active_detail;\nstd::deque g_submitted_frames;') - text=text.replace('g_active_marker_map.clear();','g_active_marker_map.clear();g_active_detail.clear();') - for index in ('g_recording_buffer','g_recording_buffer & 1U'): - text=text.replace('g_marker_maps['+index+'].clear();','g_marker_maps['+index+'].clear();g_detail_maps['+index+'].clear();') - text=once(text,' g_active_marker_map = std::move(frame.markers);',' g_active_marker_map = std::move(frame.markers);\n g_active_detail=std::move(frame.details);') - text=once(text,' frame.matrices = std::move(g_matrix_maps[g_recording_buffer & 1U]);', - ' frame.details=std::move(g_detail_maps[g_recording_buffer&1U]);\n g_detail_maps[g_recording_buffer&1U].reserve(dkr::runtime::detail::Observations);\n frame.matrices = std::move(g_matrix_maps[g_recording_buffer & 1U]);') - text=once(text,' frame.markers.clear();',' frame.markers.clear();frame.details.clear();') - text=once(text,' g_lifetimes.clear();',' for(auto& rows:g_detail_maps)rows.clear();\n g_lifetimes.clear();') - return text+'\n#include "detail_observer.inl"\n' - -def enhancements(text): - text=once(text,'#include "runtime_enhancements.hpp"','#include "runtime_enhancements.hpp"\n#include "runtime_netplay.hpp"\n#include "detail_contract.hpp"') - start=text.index('extern "C" void dkr_apply_maximum_racer_detail(');end=text.index('extern "C" void ',start+14) - part=text[start:end] - part=once(part,' if (!dkr::runtime::enhancements::maximum_detail_enabled()) {', - ''' // Online geometry selection stays canonical, even when peers choose - // different quality. Its immutable render copy applies the local policy. - if(dkr::runtime::netplay::session().runtime_view().active|| - MEM_W(0,RdramAddress(dkr::runtime::revision_addresses::ViewportLayout))!=0)return; - if (!dkr::runtime::enhancements::maximum_detail_enabled()) {''') - pattern=r' static std::atomic detail_reports\{0\};.*?static_cast\(context->r8\)\);' - replacement=''' const auto view=dkr::runtime::revision_addresses::selected_revision()==dkr::runtime::rom::Revision::UsV80?0x8011B634U:0x8011B0B4U; - dkr::runtime::detail::note({dkr::runtime::presentation::recording_scene_generation(), - static_cast(context->r16),static_cast(MEM_W(0,RdramAddress(view))), - authored_lod,static_cast(context->r8),0,true,"native highest resident"});''' - part,count=re.subn(pattern,lambda m:replacement,part,flags=re.S) - if count!=1:raise ValueError('Native detail diagnostic site changed') - return text[:start]+part+text[end:] - -def renderer(text): - text=once(text,'#include "presentation_identity.hpp"','#include "presentation_identity.hpp"\n#include "detail_contract.hpp"') - scope=''' dkr::runtime::presentation::TaskIdentityScope identity_scope( - rdram_snapshot, task->t.data_ptr);''' - text=once(text,scope,'') - text=once(text,' std::uint8_t* decode_bytes=rdram_snapshot;',scope+''' - const bool detail_requested=enhancements::maximum_detail_enabled()&&!detail::ordinary_events().empty(); - std::uint8_t* decode_bytes=rdram_snapshot;''') - text=once(text,' if(camera_requested||cars_requested) {',' if(camera_requested||cars_requested||detail_requested) {') - anchor=''' camera_online.local_slot,unsigned(platform::online_input_profile()));''' - text=once(text,anchor,anchor+''' - // A submission snapshot is only 8 MiB. The existing private camera/car - // workspace supplies the disjoint 16-MiB decode arena; never write beyond - // the snapshot, live RDRAM, or the canonical guest's 8-MiB ownership. - static thread_local LocalModelRT64Pass ordinary_models; - ordinary_models.prepare(detail_requested?detail::ordinary_events():std::span{}, - detail_requested?std::span{camera_decoder_}:std::span{},presentation::task_scene_generation());''') - old='f3ddkr_.process(*application_, *task,nullptr,nullptr,nullptr,camera_requested?camera_pass_.get():nullptr);' - count=text.count(old) - if count!=3:raise ValueError('Normal decoder calls changed: '+str(count)) - return text.replace(old,'f3ddkr_.process(*application_, *task,nullptr,nullptr,detail_requested?&ordinary_models:nullptr,camera_requested?camera_pass_.get():nullptr);') - -def local_model(text): - text=once(text,'#include "local_model_rt64.hpp"','#include "local_model_rt64.hpp"\n#include "detail_contract.hpp"') - text=once(text,' std::array cache;',''' std::array cache; - std::array racer_cache; - std::size_t racer_cached=0,racer_steps=0; - unsigned racer_operations=0; - std::array racer_triangles{};''') - start=text.index(' const Packet* build(');end=text.index('\n};',start) - part=text[start:end] - part=once(part,' const Packet* build(const dkr_owned_draw_event& e) {',''' const Packet* build(const dkr_owned_draw_event& e,bool extra) { - auto packets=extra?std::span{cache}:std::span{racer_cache}; - auto& packet_count=extra?cached:racer_cached; - auto& operations=extra?kernel_operations:racer_operations; - auto& prepared_steps=extra?packet_steps:racer_steps; - const auto operation_limit=extra?12000000U:detail::KernelOperations; - const auto step_limit=extra?65536U:detail::PreparedSteps;''') - part=part.replace('i=12000000U || packet_steps>=65536)return nullptr;', - ' if(packet_count==packets.size()||operations>=operation_limit||prepared_steps>=step_limit)throw std::runtime_error(extra?"scenery preparation capacity":"racer preparation capacity");') - part=once(part,' auto& entry=cache[cached++];',' auto& entry=packets[packet_count++];') - part=part.replace('charge{packet_steps,packet}','charge{prepared_steps,packet}').replace('kernel_operations+=packet.model.operations;','operations+=packet.model.operations;') - part=once(part,'packet.steps.size()>32768 || packet_steps+packet.steps.size()>65536','packet.steps.size()>32768 || prepared_steps+packet.steps.size()>step_limit') - text=text[:start]+part+text[end:] - text=once(text,' p.submitted_triangles=0;',' p.submitted_triangles=0;p.racer_cached=p.racer_steps=p.racer_operations=0;p.racer_triangles.fill(0);') - text=once(text,' if(retained>131072)for(auto& entry:p.cache)std::vector().swap(entry.packet.steps);',''' if(retained>131072)for(auto& entry:p.cache)std::vector().swap(entry.packet.steps); - retained=0;for(const auto& entry:p.racer_cache)retained+=entry.packet.steps.capacity(); - if(retained>detail::PreparedSteps*2U)for(auto& entry:p.racer_cache)std::vector().swap(entry.packet.steps);''') - text=once(text,'p.observations.size()==128','p.observations.size()==detail::Observations') - anchor=''' Reader r{ram}; - std::array,4> drawn;''' - text=once(text,anchor,''' // Preflight racers first. Optional scenery cannot consume their pose, - // packet or texture preparation capacity. Poses matching across views - // use one checked packet; only transforms/head angles remain per view. - for(const auto& o:p.observations)try{(void)p.build(o.e,false);} - catch(const std::exception& error){detail::note({scene,o.e.parameters[1],o.e.token,0,0,1,maximum,error.what()});} - Reader r{ram}; - std::array,4> drawn;''') - old=''' packet=p.build(o.e);if(!packet || packet->triangles>32768U-p.submitted_triangles)continue;''' - text=once(text,old,''' packet=p.build(o.e,o.extra); - if(!packet){detail::note({p.scene,o.e.parameters[1],o.e.token,0,0,1,true,"invalid prepared model; original retained"});continue;} - auto& triangles=o.extra?p.submitted_triangles:p.racer_triangles[o.e.token]; - const auto limit=o.extra?32768U:detail::ViewTriangles; - if(packet->triangles>limit-triangles){detail::note({p.scene,o.e.parameters[1],o.e.token,0,0,1,true,"view triangle capacity; original retained"});continue;}''') - old=''' }catch(const std::exception& error){if(p.logged++<8)std::fprintf(stderr,"[graphics][local-detail] retained original mesh: %s\\n",error.what());continue;}''' - text=once(text,old,''' }catch(const std::exception& error){detail::note({p.scene,o.e.parameters[1],o.e.token,0,0,1,true,error.what()});continue;}''') - text=once(text,' state.flush();o.replaced=true;++p.draws;p.submitted_triangles+=packet->triangles;', - ''' state.flush();o.replaced=true;++p.draws; - if(o.extra)p.submitted_triangles+=packet->triangles;else p.racer_triangles[o.e.token]+=packet->triangles; - if(!o.extra)detail::note({p.scene,o.e.parameters[1],o.e.token,Reader{p.ram}.byte(o.e.parameters[1]+0x3a),0,1,true,"highest resident presentation mesh"});''') - # Scope identities include actual model and pose variant, not only object/view. - text=once(text,'p.scene^o.e.parameters[1]^0x4D4F444CU^(o.e.token*0x9E3779B9U)', - 'p.scene^o.e.parameters[1]^presentation::mix_identity(o.e.parameters[2])^(o.e.parameters[7]<<28)^0x4D4F444CU^(o.e.token*0x9E3779B9U)') - return text - -def car(text): - text=once(text,'#include "vehicle_rt64.hpp"','#include "vehicle_rt64.hpp"\n#include "detail_contract.hpp"') - return once(text,' return indices!=0&&in_front;', - ''' detail::note({presentation::task_scene_generation(),object_,view_,original_lod_,vehicle_lod_,2,maximum_detail_, - indices!=0&&in_front?"custom vehicle submitted":"custom vehicle not visible; original retained"}); - return indices!=0&&in_front;''') - -def bridge(text): - text=once(text,' if(cars_requested)kRDRAMSize=0x1000000U;',' if(cars_requested||models)kRDRAMSize=0x1000000U;') - return once(text,'if(auto* models=active_->data_->local_models)', - 'if(auto* models=active_->data_->local_models;models&&(!active_->data_->online_view||!active_->data_->online_view->suppress_rectangle(*state)))') - -def owned(text): - text=once(text,'static dkr_owned_draw_event local_models[128],local_objects[512];','static dkr_owned_draw_event local_models[256],local_objects[512];') - return once(text,'if(local_model_count==128)','if(local_model_count==256)') - -def kernel(text): - # Allocate each vertex buffer 128 KiB; an 8192-vertex model cannot overlap - # its other buffer/deltas. The whole private arena remains below D00000. - for old,new in [('VERTEX2=0xC30000','VERTEX2=0xC40000'),('DELTAS=0xC40000','DELTAS=0xC60000'),('FRACTION=0xC50000','FRACTION=0xC80000'),('COMMANDS=0xC60000','COMMANDS=0xC90000'),('COMMAND_END=0xCA0000','COMMAND_END=0xCD0000'),('MAX_VERTICES=4096','MAX_VERTICES=8192')]: - text=once(text,old,new) - return text - -def guest(text,normal): - counts=Counter() - span=body(text,'spawn_object') - if span: - start,end=span;part=text[start:end] - # Both reviewed revisions save the original flags at sp+6c and restore - # them later. Do not touch ABI-visible registers or non-racer objects. - if 'MEM_W(0X6C, ctx->r29) = ctx->r5;' not in part:raise ValueError('Spawn flags ABI changed') - at=part.index(' load_object_header(rdram, ctx);');match=re.search(r' after_\d+:',part[at:]) - if not match:raise ValueError('Header-return anchor missing') - pos=at+match.end() - part=part[:pos]+''' - // all-player-residency-v1: every racer spawn uses identical residency on - // all peers, independent of local quality. Ghost/boss calls with no skip - // bits are unchanged, as are intentional NO_LODS previews. - if((uint32_t)ctx->r2>=0x80000000U&&(uint32_t)ctx->r2<=0x807FFF88U&& - MEM_BU(0x54,ctx->r2)==1U) - MEM_W(0X6C,ctx->r29)&=~12U; -'''+part[pos:] - text=text[:start]+part+text[end:];counts['residency']=1 - if normal: - span=body(text,'render_mesh') - if span: - start,end=span;part=text[start:end] - before=''' - uint32_t detail_event[19]={0}; - extern void dkr_detail_mesh_begin(uint8_t*,recomp_context*,uint32_t*); - extern void dkr_detail_mesh_end(uint8_t*,recomp_context*,uint32_t*); - dkr_detail_mesh_begin(rdram,ctx,detail_event); -''' - part,n=re.subn(r'(?m)^ return;$',' dkr_detail_mesh_end(rdram,ctx,detail_event);\n return;',part) - if n!=1:raise ValueError('Mesh observer needs its single reviewed exit') - text=text[:start]+before+part+text[end:];counts['mesh']=1 - return text,counts - -def main(a): - if a.output.resolve().is_relative_to(ROOT):raise ValueError('Use an isolated build outside the checkout') - a.output.mkdir(parents=True,exist_ok=True) - for path in (AUTH/'native').iterdir(): - if path.is_file():(a.output/path.name).write_bytes(path.read_bytes()) - transforms={'presentation_identity.cpp':identity,'runtime_enhancements.cpp':enhancements,'rt64_renderer.cpp':renderer, - 'local_model_rt64.cpp':local_model,'vehicle_rt64.cpp':car,'f3ddkr_rt64.cpp':bridge,'probe_native.c':owned,'visual_model_kernel.c':kernel} - swaps=[];counts={};seen={} - for row in json.loads(a.inputs.read_text(encoding='utf-8')): - path=Path(row['path']);raw=path.read_bytes();text=raw.decode('utf-8').replace('\r\n','\n');name=path.name - if row.get('guest'): - normal=row['target'].startswith('DKRRecompiledFuncs');text,found=guest(text,normal) - counts.setdefault(row['target'],Counter()).update(found) - if not found:continue - elif name=='runtime_ui.cpp': - # The final UI TU already pins its adjacent graphics include. Stage only - # that include; keep every other established launcher section unchanged. - match=re.search(r'^#include "([^"\n]*runtime_graphics_ui\.inl)"',text,re.M) - if not match:raise ValueError('Missing final graphics UI include') - graphics=Path(match[1]);graphics=graphics if graphics.is_absolute() else path.parent/graphics - if not graphics.is_file():graphics=Path(row['graphics']) - ui=graphics.read_text(encoding='utf-8') - ui=once(ui,'Use the most detailed racer models.','Use the highest-detail characters and vehicles in every view, online and offline. Split-screen uses more graphics power.') - (a.output/'all_detail_graphics.inl').write_text(ui,encoding='utf-8',newline='\n') - text=text[:match.start()]+'#include "all_detail_graphics.inl"'+text[match.end():] - else:text=transforms[name](text) - def adjacent(m): - p=path.parent/m[1] - return '#include "'+p.resolve().as_posix()+'"' if p.is_file() else m[0] - text=re.sub(r'^#include "([^"\n]+)"',adjacent,text,flags=re.M) - target=a.output/row['target']/name;target.parent.mkdir(parents=True,exist_ok=True) - data=text.encode('utf-8') - if target in seen and seen[target]!=data:raise ValueError('Conflicting source target') - seen[target]=data;target.write_bytes(data) - swaps.append({**row,'new':str(target.resolve()),'input_sha256':hashlib.sha256(raw).hexdigest(),'output_sha256':hashlib.sha256(data).hexdigest()}) - for target,values in counts.items(): - expected=Counter(residency=1,mesh=1) if target.startswith('DKRRecompiledFuncs') else Counter(residency=1) - if values!=expected:raise ValueError('Missing all-mode hooks: '+target+str(values)) - if len(counts)!=4:raise ValueError('Both ordinary and owned revisions are required') - (a.output/'swaps.json').write_text(json.dumps(swaps,indent=2)+'\n',encoding='utf-8') - (a.output/'coverage.json').write_text(json.dumps({key:dict(value) for key,value in counts.items()},indent=2)+'\n',encoding='utf-8') - print('All-player detail staged: immutable normal/owned rendering, per-view racer capacity, identical residency; no target execution.') -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);main(p.parse_args()) diff --git a/patches/android/README.md b/patches/android/README.md deleted file mode 100644 index 29d4c726..00000000 --- a/patches/android/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Android renderer build-stage patch pipeline - -## Current release composition - -Android is excluded from v1.0.11. The [desktop release pipeline](../release-prep/v1.0.11/BUILDING.txt) does not produce an APK. The module notes below -explain this layer; their dated playtest paths are historical inputs, not the -current release command. The accepted water/source capsule preserves the -composed behaviour. Dependencies and generated guest checkouts stay read-only. - -`rt64-rgba-presentation.patch` is deliberately separate from the dependency -checkout patch manifest. `runtime-recomp/cmake/android/RendererPatches.cmake` -copies only the two relevant translation units into the Android build tree, -checks and applies this patch there, and replaces those two target sources. -It never applies the patch in `extern/rt64` or to generated guest code. - -The isolated generated directory has its own Git metadata solely to make patch -application independent of where the build directory resides. No commits or -index changes are required. Each configure starts from the current dependency -sources; a context mismatch or unexpected source count fails configuration. -Windows and Linux do not include this pipeline. - -Android's surface implementation guarantees RGBA8, whereas RT64's desktop -swapchain and final VI pipeline request BGRA8. Both must change together. The -patch preserves UNORM output and leaves intermediate framebuffer formats, -textures, shaders, interpolation, antialiasing and gameplay unchanged. RT64's -Vulkan inspector already derives its render-pass format from the selected -surface format, so it requires no separate format override. - -Primary reference: -https://android.googlesource.com/platform/frameworks/native/+/refs/heads/main/vulkan/libvulkan/swapchain.cpp -(`GetPhysicalDeviceSurfaceFormatsKHR`: "We must support R8G8B8A8"). - -This repairs a source-proven Android compatibility defect. The supplied phone -log confirms device loss, but omitted startup, so this must not be described as -a device-confirmed resolution until the updated APK is tested on that phone. - -## SDL2 Vulkan lifecycle (preview.4) - -`sdl2-vulkan-surface-lifecycle.patch` is applied to a copy of SDL2 2.32.10's -`src/core/android/SDL_android.c` through `cmake/android/SdlPatches.cmake`. -The original downloaded SDL sources remain unchanged. The Android shared SDL -target substitutes only that translation unit and uses its original include -directory. Configuration fails on missing source/context rather than silently -skipping a fix. - -The surface-change callback formerly attempted EGL surface creation without -checking whether its window was OpenGL. That contradicts the existing -Android_CreateWindow guard/comment explaining that EGL surfaces are incompatible -with attaching a Vulkan surface. The destruction callback also waited up to -500 ms for an EGL backup even for a Vulkan window. Both now require a managed -OpenGL window. Launcher OpenGL handling is retained. - -The project-owned RT64 Android window adapter also scopes -`SDL_HINT_VIDEO_EXTERNAL_CONTEXT=1` to the game renderer's lifetime, restoring -the previous hint on destruction. SDL's Android event pump uses this hint to -avoid saving/restoring an OpenGL context for externally managed graphics. -Reference: https://wiki.libsdl.org/SDL2/SDL_HINT_VIDEO_EXTERNAL_CONTEXT - -These fixes do not implement complete device-loss or replaced-native-surface -recovery. No device crash trace confirming the user's capture failure was -available during this pass. Do not claim universal capture/resume support. diff --git a/patches/camera-plus/CMakeLists.txt b/patches/camera-plus/CMakeLists.txt deleted file mode 100644 index 0faa419e..00000000 --- a/patches/camera-plus/CMakeLists.txt +++ /dev/null @@ -1,57 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(DKRCameraCompileOnly LANGUAGES C CXX) -# Compiler checks only. No executable, CTest, runtime, mod or headset launch. -get_filename_component(DKR_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) -if(NOT DKR_CAMERA_RT64_STAGE OR NOT DKR_OPENXR_SOURCE) - message(FATAL_ERROR "Select the separately staged RT64 and pinned OpenXR SDK") -endif() -include("${CMAKE_CURRENT_LIST_DIR}/SceneKernel.cmake") -foreach(revision 77 80) - if(DKR_CAMERA_KERNEL_V${revision}) - dkr_camera_scene_kernel(CameraSceneV${revision} "${DKR_CAMERA_KERNEL_V${revision}}" ${revision}) - endif() -endforeach() -add_library(CameraCompileOnly OBJECT - native/camera_presentation.cpp native/camera_scene.cpp native/camera_snapshot.cpp native/camera_xr.cpp - native/camera_settings.cpp native/camera_actions.cpp native/camera_session.cpp) -target_compile_features(CameraCompileOnly PRIVATE cxx_std_20) -target_compile_definitions(CameraCompileOnly PRIVATE NOMINMAX) -target_include_directories(CameraCompileOnly PRIVATE - "${CMAKE_CURRENT_LIST_DIR}/sdk" "${CMAKE_CURRENT_LIST_DIR}/native" - "${DKR_ROOT}/runtime-recomp/src/game" - "${DKR_CAMERA_RT64_STAGE}/src/contrib" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/contrib/volk" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/contrib/Vulkan-Headers/include" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/contrib/VulkanMemoryAllocator/include" - "${DKR_OPENXR_SOURCE}/include") -if(MSVC) - target_compile_options(CameraCompileOnly PRIVATE /W4 /fp:strict /permissive-) -else() - target_compile_options(CameraCompileOnly PRIVATE -Wall -Wextra -Werror - -Wno-missing-field-initializers -ffp-contract=off -fno-fast-math) - find_package(SDL2 REQUIRED) - target_compile_definitions(CameraCompileOnly PRIVATE PLUME_SDL_VULKAN_ENABLED=1) - target_include_directories(CameraCompileOnly PRIVATE ${SDL2_INCLUDE_DIRS}) -endif() -# Object compilation only: check the patched implementation as well as the -# headers. No Vulkan device or headset is created by this target. -add_library(CameraVulkanCompileOnly OBJECT - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/plume_vulkan.cpp") -target_compile_features(CameraVulkanCompileOnly PRIVATE cxx_std_17) -get_target_property(camera_includes CameraCompileOnly INCLUDE_DIRECTORIES) -target_include_directories(CameraVulkanCompileOnly PRIVATE ${camera_includes}) -target_compile_definitions(CameraVulkanCompileOnly PRIVATE NOMINMAX) -if(NOT MSVC) - target_compile_definitions(CameraVulkanCompileOnly PRIVATE PLUME_SDL_VULKAN_ENABLED=1) -endif() -if(DKR_CAMERA_NATIVE_STAGE) - # Compile the transformed library against its transformed header, without - # opening a user's installed library or executing any package. - add_library(CameraLibraryCompileOnly OBJECT - "${DKR_CAMERA_NATIVE_STAGE}/src/game/mods/magic_code_library.cpp") - target_compile_features(CameraLibraryCompileOnly PRIVATE cxx_std_20) - target_compile_definitions(CameraLibraryCompileOnly PRIVATE NOMINMAX) - target_include_directories(CameraLibraryCompileOnly PRIVATE - "${DKR_CAMERA_NATIVE_STAGE}/src/game/mods" "${DKR_CAMERA_RT64_STAGE}/src/contrib") -endif() diff --git a/patches/camera-plus/IMPLEMENTATION.md b/patches/camera-plus/IMPLEMENTATION.md deleted file mode 100644 index ec538f20..00000000 --- a/patches/camera-plus/IMPLEMENTATION.md +++ /dev/null @@ -1,44 +0,0 @@ -# ModernCamera++ implementation status - -The flat-screen path is connected end to end: import/approval, presentation-only -activation, retained snapshots, local actions, mod callback, private scene draw, -RT64 decode, native fallback and F7 settings with asynchronous persistence. -The old active Modern camera replacement and its input rewriting are removed -from the staged source. Native Original/scripted cameras and clearance remain. - -## Connected components - -| Component | Boundary | -| --- | --- | -| SDK and mod | Versioned POD ABI; portable MIPS package; Original/chase/bonnet/first/comfort/cinematic; bounded effects, zoom, rear view and vehicle tuning. | -| Executor | Private memory only; one provider; 250,000 instructions, 256 services and eight bounded queries per callback; local quarantine. | -| Snapshot reader | US v1.0/Rev A retained images; actual racer identity, vehicle, original camera and scene/cut ownership. No concurrent CPU read. | -| Collision | Copied static triangles, broad phase, swept volume and budgets. No claimed moving-platform/door adapter. | -| Scene compiler | 146 closed per-view native draw routines per revision; checked memory, 6M operation cap, private command/matrix/vertex/triangle pools and C-only fault fence. Frozen weather/lens-flare drawing, no full scene/logic/audio tick. | -| Decoder | One working owner, bounded 16 MiB command limit only during authorized camera decode, original 8 MiB otherwise; restore before canonical HUD/next view. | -| Presentation | View-local hiding, scene/object matrix identities, cut snapping and topology-keyed wave identities. No generic vertex interpolation. | -| Controls | Dedicated remappable camera actions from existing SDL2/SDL3 polling; no extra controller probe; menu/held-button suppression, local mouse capture and short-tap latching. | -| Editor | Shared Paddock controls, controller navigation, grouped fields, four local profiles, presets, Apply/Revert/Reset/Save; existing on-screen keyboard for text. | -| Storage | Existing bounded LibraryJobs worker; acknowledgement checks the exact saved document/digest. No UI-thread file writes. | -| Online | Separate presentation admission and fingerprints; local racer/physical/profile mapping. No camera writes to rollback RAM, rules, saves or synchronized input. | - -## Not enabled in this playtest - -True OpenXR eye scene/HUD output, head-wall comfort, unrestricted inspection and -rider-only hiding still need implementation. Their renderer capabilities stay -off. The OpenXR/device-integration foundations are separately compiler-checked, -not distributed as working VR. Ordinary bounded right-stick camera orbit and -rear view are available without claiming unrestricted inspection. - -## Evidence and qualification - -Fresh stage receipts verify every copied first-party source against its accepted -portrait baseline, authored transforms and pinned reference symbols. Generated -private draw closures are derived from the accepted generated inputs without -editing them. The dependency exception is constrained to four separately staged -device files and is not needed by the flat-screen packages. - -Windows/MSVC and Linux/GCC compiler/linker results and package hashes accompany -the delivered build. There is no gameplay, replay, unit, mod-execution, visual, -headset or performance acceptance run by the agent. The user performs those -checks. The accepted previous packages are preserved. diff --git a/patches/camera-plus/NativeOverlay.cmake b/patches/camera-plus/NativeOverlay.cmake deleted file mode 100644 index e3aba3e3..00000000 --- a/patches/camera-plus/NativeOverlay.cmake +++ /dev/null @@ -1,95 +0,0 @@ -# Authored first-party staging only. Protected dependency and generated game -# directories are read-only. This composition builds, but does not yet enable, -# the local presentation domain; renderer capabilities are not fabricated. -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - if(NOT DKR_CAMERA_STAGE OR NOT EXISTS "${DKR_CAMERA_STAGE}/camera-receipt.json") - message(FATAL_ERROR "Camera++ requires a verified fresh Patch Pipeline stage") - endif() - function(dkr_camera_overlay_targets directory) - get_property(targets DIRECTORY "${directory}" PROPERTY BUILDSYSTEM_TARGETS) - foreach(target IN LISTS targets) - foreach(property SOURCES INCLUDE_DIRECTORIES INTERFACE_INCLUDE_DIRECTORIES) - get_target_property(values ${target} ${property}) - if(NOT values OR values MATCHES "-NOTFOUND$") - continue() - endif() - set(replaced) - foreach(value IN LISTS values) - set(next "${value}") - if(property STREQUAL "SOURCES" AND NOT IS_ABSOLUTE "${value}" AND NOT value MATCHES "^\\$<") - get_filename_component(next "${value}" ABSOLUTE BASE_DIR "${directory}") - endif() - string(REPLACE "${DKRPORT_ROOT}/runtime-recomp/src/" "${DKR_CAMERA_STAGE}/src/" next "${next}") - string(REPLACE "${DKRPORT_ROOT}/runtime-recomp/tests/replay_probe/" "${DKR_CAMERA_STAGE}/tests/replay_probe/" next "${next}") - if(next STREQUAL "${DKRPORT_ROOT}/runtime-recomp/tests/replay_probe") - set(next "${DKR_CAMERA_STAGE}/tests/replay_probe") - endif() - if(next STREQUAL "${DKRPORT_ROOT}/runtime-recomp/src") - set(next "${DKR_CAMERA_STAGE}/src") - endif() - if(property STREQUAL "SOURCES" AND NOT next STREQUAL value AND next MATCHES "${DKR_CAMERA_STAGE}/(src|tests)/") - if(NOT EXISTS "${next}") - message(FATAL_ERROR "Camera++ stage is missing ${next}") - endif() - foreach(source_property COMPILE_OPTIONS COMPILE_DEFINITIONS INCLUDE_DIRECTORIES SKIP_PRECOMPILE_HEADERS LANGUAGE SKIP_UNITY_BUILD_INCLUSION) - get_property(is_set SOURCE "${value}" DIRECTORY "${directory}" PROPERTY ${source_property} SET) - if(is_set) - get_property(setting SOURCE "${value}" DIRECTORY "${directory}" PROPERTY ${source_property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${source_property} "${setting}") - endif() - endforeach() - endif() - list(APPEND replaced "${next}") - endforeach() - set_property(TARGET ${target} PROPERTY ${property} "${replaced}") - endforeach() - endforeach() - get_property(children DIRECTORY "${directory}" PROPERTY SUBDIRECTORIES) - foreach(child IN LISTS children) - if(child MATCHES "^${DKRPORT_ROOT}/runtime-recomp(/|$)") - dkr_camera_overlay_targets("${child}") - endif() - endforeach() - endfunction() - function(dkr_apply_camera_overlay) - find_package(Python3 REQUIRED COMPONENTS Interpreter) - execute_process(COMMAND "${Python3_EXECUTABLE}" "${DKRPORT_ROOT}/scripts/camera_plus_pipeline.py" - verify --output "${DKR_CAMERA_STAGE}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Camera++ source composition failed inspection") - endif() - dkr_camera_overlay_targets("${DKRPORT_ROOT}/runtime-recomp") - get_target_property(renderer_support rt64 INTERFACE_SOURCES) - if(renderer_support AND NOT renderer_support MATCHES "-NOTFOUND$") - string(REPLACE "${DKRPORT_ROOT}/runtime-recomp/src/" "${DKR_CAMERA_STAGE}/src/" - renderer_support "${renderer_support}") - set_property(TARGET rt64 PROPERTY INTERFACE_SOURCES "${renderer_support}") - endif() - add_library(DKRCameraPresentation STATIC - "${DKR_CAMERA_STAGE}/src/game/camera_presentation.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_scene.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_snapshot.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_settings.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_actions.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_session.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_runtime.cpp" - "${DKR_CAMERA_STAGE}/src/game/camera_rt64.cpp") - target_compile_features(DKRCameraPresentation PUBLIC cxx_std_20) - target_include_directories(DKRCameraPresentation PUBLIC "${DKR_CAMERA_STAGE}/include" - "${DKR_CAMERA_STAGE}/src/game") - target_include_directories(DKRCameraPresentation PRIVATE "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib") - target_link_libraries(DKRCameraPresentation PUBLIC DKRCustomMagicCodes) - if(MSVC) - target_compile_options(DKRCameraPresentation PRIVATE /fp:strict /W4) - else() - target_compile_options(DKRCameraPresentation PRIVATE -ffp-contract=off -fno-fast-math -Wall -Wextra) - endif() - target_link_libraries(DKRPortGame PRIVATE DKRCameraPresentation) - include("${DKRPORT_ROOT}/patches/camera-plus/SceneKernel.cmake") - dkr_camera_scene_kernel(DKRCameraSceneV77 "${DKR_CAMERA_KERNEL_V77}" 77) - dkr_camera_scene_kernel(DKRCameraSceneV80 "${DKR_CAMERA_KERNEL_V80}" 80) - target_link_libraries(DKRCameraPresentation PRIVATE DKRCameraSceneV77 DKRCameraSceneV80) - message(STATUS "Camera++: private presentation provider, per-view scene compiler, local controls and editor enabled") - endfunction() - cmake_language(DEFER CALL dkr_apply_camera_overlay) -endif() diff --git a/patches/camera-plus/OpenXR.cmake b/patches/camera-plus/OpenXR.cmake deleted file mode 100644 index 27accd63..00000000 --- a/patches/camera-plus/OpenXR.cmake +++ /dev/null @@ -1,54 +0,0 @@ -# Optional OpenXR loader for PC builds. Loading the game on a machine with no -# runtime/headset must not require a separately installed openxr_loader DLL. -# The static loader discovers a runtime only after the player requests VR. -function(dkr_camera_openxr target) - find_package(Python3 REQUIRED COMPONENTS Interpreter) - execute_process(COMMAND "${Python3_EXECUTABLE}" "${DKRPORT_ROOT}/patches/camera-plus/vr_device_patch.py" - verify --output "${DKR_CAMERA_RT64_STAGE}" RESULT_VARIABLE vr_result) - if(NOT vr_result EQUAL 0) - message(FATAL_ERROR "Camera++ VR needs the verified staged device patch, never an edited submodule") - endif() - if(NOT WIN32 AND NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") - message(FATAL_ERROR "Camera++ immersive output currently targets Windows and Linux PC runtimes") - endif() - if(NOT DKR_OPENXR_SOURCE OR NOT EXISTS "${DKR_OPENXR_SOURCE}/include/openxr/openxr.h") - message(FATAL_ERROR "Select the pinned Khronos OpenXR-SDK source; do not use an unversioned system loader") - endif() - find_package(Git REQUIRED) - execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${DKR_OPENXR_SOURCE}" rev-parse HEAD - OUTPUT_VARIABLE xr_commit OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE xr_result) - if(NOT xr_result EQUAL 0 OR NOT xr_commit STREQUAL "75c53b6e853dc12c7b3c771edc9c9c841b15faaa") - message(FATAL_ERROR "Unreviewed OpenXR SDK revision") - endif() - execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${DKR_OPENXR_SOURCE}" status --porcelain --untracked-files=all - OUTPUT_VARIABLE xr_dirty OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE xr_result) - if(NOT xr_result EQUAL 0 OR NOT xr_dirty STREQUAL "") - message(FATAL_ERROR "OpenXR source must remain unchanged") - endif() - # Explicitly avoid all executable samples, qualification programs/layers - # and tests. Configuring/building this target does not probe a runtime. - set(BUILD_LOADER ON CACHE BOOL "" FORCE) - set(DYNAMIC_LOADER OFF CACHE BOOL "" FORCE) - set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) - set(BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(BUILD_CONFORMANCE_TESTS OFF CACHE BOOL "" FORCE) - set(BUILD_CONFORMANCE_CLI OFF CACHE BOOL "" FORCE) - if(NOT TARGET openxr_loader) - add_subdirectory("${DKR_OPENXR_SOURCE}" "${CMAKE_BINARY_DIR}/camera-openxr" EXCLUDE_FROM_ALL) - endif() - target_sources(${target} PRIVATE "${DKR_CAMERA_STAGE}/src/game/camera_xr.cpp") - target_include_directories(${target} PRIVATE - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/contrib/volk" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/contrib/Vulkan-Headers/include" - "${DKR_CAMERA_RT64_STAGE}/src/contrib/plume/contrib/VulkanMemoryAllocator/include") - target_link_libraries(${target} PRIVATE openxr_loader) - target_compile_definitions(${target} PRIVATE NOMINMAX) - if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - target_compile_definitions(${target} PRIVATE PLUME_SDL_VULKAN_ENABLED=1) - endif() - # Release packaging must include this notice alongside the renderer's - # existing notices. The SDK source and upstream submodules are untouched. - install(FILES "${DKR_OPENXR_SOURCE}/LICENSE" - DESTINATION "licenses" RENAME "OpenXR-SDK-LICENSE.txt") -endfunction() diff --git a/patches/camera-plus/README.md b/patches/camera-plus/README.md deleted file mode 100644 index 4aece2ea..00000000 --- a/patches/camera-plus/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# ModernCamera++ Patch Pipeline - -## Current release composition - -For v1.0.11 use the [release pipeline](../release-prep/v1.0.11/BUILDING.txt). The module notes below -explain this layer; their dated playtest paths are historical inputs, not the -current release command. The accepted water/source capsule preserves the -composed behaviour. Dependencies and generated guest checkouts stay read-only. - -ModernCamera++ is an optional, importable `.nrm`. Camera behaviour lives in the -mod; DKR-R provides a separate, restricted local presentation API and a copied -per-view scene compiler. The package is not installed or enabled automatically. - -The first game package is a **flat-screen playtest**, not a completed VR release. -Original, chase, bonnet, first person, comfort first person and cinematic chase -are connected. Rear view, orbit controls within the driving camera, per-vehicle -tuning, four local profiles and the live F7 editor are connected. Inspection, -rider-only hiding and headset output are not advertised as renderer capabilities. - -## Install and use - -1. Use the matching Camera++ Windows build or Linux AppImage. -2. Import `ModernCamera++.nrm` under Mods/Hacks > Custom Magic Codes. -3. Approve and enable it, then start a new game. -4. Press F7, or open Graphics > Camera++ settings, during play. -5. Choose the local profile, preset and section. Apply changes live; Save keeps - all four profiles for the next launch. Revert returns to the saved profiles. - -Right stick looks around, right-stick click/O looks back, left-stick click/R -recentres, Y/C cycles cameras, F6 switches first person, and Page Up/Down zoom. -Hold the right mouse button to look with the mouse. Bindings are remappable in -Controls. Only conflicting C-button bindings are consumed while Camera++ is -actually driving an eligible local view. Original DKR and scripted scenes keep -their native cameras. Menu focus blocks camera actions until release. - -## Ownership and online behaviour - -- No camera mod maps live game memory. The private VM has its own regions, - read-only copied snapshots, whitelisted imports and bounded execution. -- The mod has no gameplay hooks, raw references, native calls, filesystem, - networking, clocks, shared RNG, audio or save access. -- Presentation packages are filtered before gameplay settings/resources/save - identity construction. Executable approval remains required. -- Online view identity is the actual local racer slot, not always player one. - Physical input profile and saved profile remain separate from that slot. -- The existing rollback image gets exactly one mutable DecodeWorkspace. Camera - redraws reuse it sequentially and restore the prepared retail bytes before - HUD and another viewport resume. One bounded 8 MiB restoration journal keeps - the renderer's local layout corrections; it is not another mutable workspace. - Normal play uses a task-owned copy - of the already submitted image, not concurrent live CPU RAM. -- A closed, checked C draw closure rebuilds camera-visible scenery, racers, - particles, wave geometry and billboards. It never calls `render_scene`, runs - another logic tick, invokes gameplay mods, advances shared audio/RNG or writes - into the simulation. Original forward culling is not reused as a fake rear view. -- Snow, rain and lens flares redraw from their retained visual state. Weather - logic, snow movement, rain sound and lightning updates are not re-executed. -- New matrix identities are view/scene/object-owned. Arbitrary vertex matching - is off; only topology-keyed wave matrices opt in to vertex interpolation. -- Static collision uses copied solid level triangles. Dynamic door/platform - collision is not advertised. Unsupported/budget-exhausted queries return the - native camera position. A provider fault disables that local provider, not the - race; a draw-closure fault falls back for that view for the rest of the scene. - -## Protected sources - -Only authored Patch Pipeline files change. Native composition verifies the -accepted portrait/save baseline and all staged differences. Actual submodules, -RecompiledFuncs, RecompiledPatches and previous generated payloads remain inputs. - -The separately approved VR dependency experiment is retained: four pinned -RT64/Plume files can differ only in a new copied dependency tree. It is **not -selected by the flat-screen playtest**. The original checked-out dependency -files are not edited. - -## VR work still required - -The OpenXR owner and approved Vulkan device factory compile, including runtime -GPU negotiation, swapchains, predicted eye poses/FOV, recenter and panel layers. -They do not yet consume eye-specific scene renders, a compositor HUD or a -head-wall comfort policy. No headset compatibility or functional VR claim is -made. The target remains vendor-neutral seated PC OpenXR on Windows/Linux, not -standalone Android/Quest. Do not replace this with two copies of a flat frame. - -The agent compiles, inspects and packages. Gameplay, visual, online, performance -and headset testing remain with the user. A successful compiler/linker is not a -runtime acceptance result. diff --git a/patches/camera-plus/SceneKernel.cmake b/patches/camera-plus/SceneKernel.cmake deleted file mode 100644 index 85873349..00000000 --- a/patches/camera-plus/SceneKernel.cmake +++ /dev/null @@ -1,29 +0,0 @@ -function(dkr_camera_scene_kernel target directory revision) - if(NOT EXISTS "${directory}/manifest.json") - message(FATAL_ERROR "Generate a NEW draw-only Camera++ kernel for revision ${revision}") - endif() - find_package(Python3 REQUIRED COMPONENTS Interpreter) - execute_process(COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/scene_kernel_pipeline.py" --verify --output "${directory}" - RESULT_VARIABLE inspected) - if(NOT inspected EQUAL 0) - message(FATAL_ERROR "Private Camera++ draw composition failed inspection") - endif() - add_library(${target} STATIC "${directory}/camera_scene_functions.c" - "${directory}/camera_scene_hooks.c" - "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/native/camera_kernel.c") - target_include_directories(${target} BEFORE PRIVATE "${directory}" - "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/native") - target_compile_features(${target} PRIVATE c_std_11) - foreach(helper access step fault) - target_compile_definitions(${target} PRIVATE - dkr_camera_${helper}=dkr_camera_v${revision}_${helper}) - endforeach() - if(MSVC) - target_compile_options(${target} PRIVATE /fp:strict /FS /wd4244 /wd4267) - else() - target_compile_options(${target} PRIVATE -fno-strict-aliasing -ffp-contract=off - -fno-fast-math -frounding-math -Werror=implicit-function-declaration) - target_link_libraries(${target} PRIVATE m) - endif() -endfunction() diff --git a/patches/camera-plus/camera_editor_ui.inl b/patches/camera-plus/camera_editor_ui.inl deleted file mode 100644 index 53655d77..00000000 --- a/patches/camera-plus/camera_editor_ui.inl +++ /dev/null @@ -1,126 +0,0 @@ -// Shared launcher/overlay theme and existing controller navigation/OSK. -namespace camplus=dkr::runtime::camera; -bool CameraTuningVisible(){return camplus::editor_open.load();} -void LoadCameraTuning(){} // Old Modern-camera INI no longer drives this editor. -void OpenCameraTuning(){camplus::editor_open.store(true);} -void ToggleCameraTuning(){camplus::editor_open.store(!CameraTuningVisible());} -bool g_camera_plus_mouse=false; -unsigned CameraMouseProfile(){const auto a=camplus::activation();return a&&a->online? - unsigned(dkr::runtime::platform::online_input_profile()):0U;} -void UpdateCameraMouseOrbit() { - if(g_camera_plus_mouse&&(CameraTuningVisible()||g_overlay_visible.load()||!camplus::owns_controls(CameraMouseProfile()))) { - SDL_SetRelativeMouseMode(SDL_FALSE);g_camera_plus_mouse=false; - } -} -bool HandleCameraMouseOrbit(const SDL_Event& event) { - if(event.type==SDL_MOUSEBUTTONDOWN&&event.button.button==SDL_BUTTON_RIGHT&& - camplus::owns_controls(CameraMouseProfile())&&!g_overlay_visible.load()&&!CameraTuningVisible()) { - g_camera_plus_mouse=SDL_SetRelativeMouseMode(SDL_TRUE)==0;return g_camera_plus_mouse; - } - if(event.type==SDL_MOUSEBUTTONUP&&event.button.button==SDL_BUTTON_RIGHT&&g_camera_plus_mouse) { - SDL_SetRelativeMouseMode(SDL_FALSE);g_camera_plus_mouse=false;return true; - } - if(event.type==SDL_MOUSEMOTION&&g_camera_plus_mouse) { - camplus::actions().mouse(CameraMouseProfile(),-float(event.motion.xrel),float(event.motion.yrel));return true; - } - return false; -} -void DrawCameraTuningPanel() { - const auto active=camplus::activation(); - static std::weak_ptr previous; - static unsigned profile=0,group=0; - static int preset=0; - static std::optional saving; - static std::uint64_t save_generation=0; - static std::string status; - if(previous.lock()!=active){previous=active;group=0;profile=active&&active->online? - unsigned(dkr::runtime::platform::online_input_profile()):0U;preset=0;saving.reset();status.clear();} - bool open=true; - const auto display=ImGui::GetIO().DisplaySize; - ImGui::SetNextWindowSize({std::min(1120.F,display.x-32.F),std::min(760.F,display.y-32.F)},ImGuiCond_Appearing); - if(!ImGui::Begin("MODERN CAMERA++",&open,ImGuiWindowFlags_NoCollapse)){ - ImGui::End();if(!open)camplus::editor_open.store(false);return; - } - if(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight)&&!ImGui::IsPopupOpen("",ImGuiPopupFlags_AnyPopupId))open=false; - if(!active||!active->editor) { - ImGui::TextWrapped("Import and enable ModernCamera++ in Mods/Hacks, then start a new game."); - const auto problem=camplus::diagnostic();if(!problem.empty())ImGui::TextWrapped("%s",problem.c_str()); - } else { - auto& editor=*active->editor; - const auto library=g_code_library.view(); - if(saving&&!library->busy&&library->generation>save_generation) { - if(library->error.empty()) { - bool matched=false; - for(const auto& item:library->items)if(item.digest==active->frozen.ordered[0]->digest&&item.data==*saving)matched=true; - if(matched){editor.saved(*saving);status="Saved.";}else status="Settings were not saved. Please try again."; - } else status=library->error; - saving.reset(); - } - const float width=ImGui::GetContentRegionAvail().x; - std::vector players; - for(unsigned i=0;i<4;++i)players.emplace_back("Local player "+std::to_string(i+1)); - int selected=int(profile); - if(SettingsDropdown("##camera-owner","Profile",&selected,players,width))profile=unsigned(selected); - std::vector presets;for(const auto& value:editor.presets())presets.emplace_back(value.name); - if(!presets.empty()&&SettingsDropdown("##camera-preset","Preset",&preset,presets,width))editor.preset(profile,unsigned(preset)); - std::vector sections;for(const auto& value:editor.groups())sections.emplace_back(value.name); - int section=int(group); - if(SettingsDropdown("##camera-section","Section",§ion,sections,width))group=unsigned(section); - const float footer=180.F; - ImGui::BeginChild("camera-fields",{0,std::max(48.F,ImGui::GetContentRegionAvail().y-footer)},false); - if(group();std::vector labels; - for(const auto& text:field.choices)labels.emplace_back(text); - // A playtest must not expose a nonfunctional headset switch. - if(field.id=="mode"&&labels.size()==8) { - labels[6].disabled=true;labels[6].detail="Inspection output is not enabled in this playtest."; - labels[7].disabled=true;labels[7].detail="Headset scene output is not connected yet."; - } - if(field.id=="first_visibility"&&labels.size()==3) { - labels[2].disabled=true;labels[2].detail="Choose Show vehicle or Hide vehicle in this build."; - } - if(SettingsDropdown("##enum",field.name,&choice,labels,inner))editor.edit(profile,field.id,choice); - } else if(field.type=="Number") { - const double step=std::max(field.step,(field.maximum-field.minimum)/10000.); - const int count=std::max(1,int(std::ceil((field.maximum-field.minimum)/step))); - int position=std::clamp(int(std::lround((value.get()-field.minimum)/step)),0,count); - if(SettingsRange("##number",field.name,&position,0,count,inner,[&](int point){ - char number[40];std::snprintf(number,sizeof(number),"%.2f",std::min(field.maximum,field.minimum+point*step)); - return SettingsRangeValue{number,""}; - }))editor.edit(profile,field.id,std::min(field.maximum,field.minimum+position*step)); - } else { - SettingsLabel(field.name,inner); - const auto id=field.id;const auto owner=profile; - if(SettingsButton(value.get_ref().empty()?"Enter text":value.get_ref().c_str(),inner)) { - RequestCustomTextKeyboard(field.name.c_str(),value.get(),4097, - [weak=std::weak_ptr(active),id,owner](const std::string& text){ - if(auto a=weak.lock())a->editor->edit(owner,id,text); - }); - } - } - PaddockGap(12.F);ImGui::PopID(); - } - ImGui::EndChild(); - const float gap=ImGui::GetStyle().ItemSpacing.x; - const float button=std::max(1.F,(width-gap*3)/4); - if(SettingsButton("Apply",button,!editor.pending())){editor.apply();status="Applied.";} - ImGui::SameLine();if(SettingsButton("Revert",button))editor.revert(); - ImGui::SameLine();if(SettingsButton("Reset",button))editor.reset(profile); - ImGui::SameLine();if(SettingsButton("Save",button,bool(saving)||library->busy)) { - editor.apply();auto document=editor.save_document(); - if(g_code_library.data(active->frozen.ordered[0]->digest,document)) { - saving=std::move(document);save_generation=library->generation;status="Saving..."; - } - } - if(!editor.error().empty())ImGui::TextWrapped("%s",editor.error().c_str()); - else if(!status.empty())ImGui::TextUnformatted(status.c_str()); - else if(editor.pending()||editor.unsaved())ImGui::TextUnformatted("Unsaved changes"); - } - if(SettingsButton("Close",120.F))open=false; - ImGui::End(); - if(!open)camplus::editor_open.store(false); -} diff --git a/patches/camera-plus/inspect_scene_closure.py b/patches/camera-plus/inspect_scene_closure.py deleted file mode 100644 index 202bbda7..00000000 --- a/patches/camera-plus/inspect_scene_closure.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Static camera-renderer feasibility audit. Does not execute guest functions. - -Rendering the original whole render_scene is prohibited: it advances waves, -colour cycles, fog and weather. Inspect the smaller per-view roots, enumerate -their transitive code/data needs and identify indirect/native-service escapes -before admitting a renderer-only CPU closure. -""" -import argparse -import hashlib -import json -from pathlib import Path -import re -import sys - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / 'scripts')) -from generate_replay_probe import DEF, CALL, strip - -ROOTS = ('render_level_geometry_and_objects', 'skydome_render', - 'trackbg_render_gradient', 'viewport_main', 'mtx_world_origin') -FORBIDDEN = ('audio', 'sound', 'music', 'rumble', 'rand', 'alloc', 'free', - 'dma', 'read_rom', 'load_asset', 'obj_loop', 'update_racer', - 'waves_update', 'weather_update', 'on_frame', 'code_event') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--generated', type=Path, required=True) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--root',action='append',default=[]) - args = parser.parse_args() - source = args.generated.resolve(strict=True) - output = args.output.resolve() - if output.exists() or not (ROOT / 'build').resolve() in output.parents: - raise ValueError('Choose a NEW report in the workspace build directory') - functions = {} - for path in sorted(source.glob('*.c')): - text = path.read_text() - matches = list(DEF.finditer(text)) - for i, match in enumerate(matches): - if match[1] in functions: - raise ValueError('Ambiguous render function: ' + match[1]) - body = text[match.start():matches[i + 1].start() if i + 1 < len(matches) else len(text)] - functions[match[1]] = (path.name, body) - roots=tuple(args.root) if args.root else ROOTS - work = list(roots) - visited = {} - missing = set() - for name in roots: - if name not in functions: - missing.add(name) - while work: - name = work.pop() - if name in visited or name not in functions: - continue - file, body = functions[name] - calls = set(CALL.findall(strip(body))) - child = sorted(calls.intersection(functions) - {name}) - external = sorted(calls.difference(functions) - {'if', 'for', 'while', 'switch', 'sizeof', 'return', 'void'}) - indirect = sorted(c for c in calls if 'LOOKUP' in c or 'lookup' in c or 'indirect' in c) - visited[name] = {'file': file, 'sha256': hashlib.sha256(body.encode()).hexdigest(), - 'calls': child, 'external': external, 'indirect': indirect, - 'requires_review': any(part in name.lower() for part in FORBIDDEN)} - work += child - report = {'schema': 1, 'source': source.as_posix(), 'roots': roots, - 'function_count': len(visited), 'missing_roots': sorted(missing), - 'functions': dict(sorted(visited.items())), - 'review_required': sorted(k for k, v in visited.items() if v['requires_review']), - 'indirect_routes': {k: v['indirect'] for k, v in visited.items() if v['indirect']}, - 'execution_performed': False, 'renderer_capabilities_proven': False} - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(json.dumps(report, indent=2) + '\n') - print(json.dumps({k: v for k, v in report.items() if k != 'functions'}, indent=2)) - - -if __name__ == '__main__': - main() diff --git a/patches/camera-plus/integration_pipeline.py b/patches/camera-plus/integration_pipeline.py deleted file mode 100644 index c617ec05..00000000 --- a/patches/camera-plus/integration_pipeline.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Reviewed first-party transformations; only used in a NEW copied stage.""" -from pathlib import Path - -TRANSFORMS={ - 'src/game/game_main.cpp','src/game/camera_obstruction.cpp', - 'src/game/runtime_input.hpp','src/game/runtime_input.cpp','src/game/runtime_platform.cpp', - 'src/game/runtime_ui.cpp','src/game/runtime_graphics_ui.inl','src/game/runtime_camera_tuning_ui.inl', - 'src/game/runtime_code_mods_ui.inl', - 'src/game/presentation_marker_policy.hpp','src/game/presentation_identity.hpp','src/game/presentation_identity.cpp', - 'src/game/runtime_stubs.cpp','src/game/netplay/experimental_draw_events.h', - 'tests/replay_probe/probe_native.c','src/game/online_view_rt64.cpp', - 'src/game/f3ddkr_rt64.hpp','src/game/f3ddkr_rt64.cpp', - 'src/game/rt64_renderer.hpp','src/game/rt64_renderer.cpp', -} - -def transform(name,text,replace,auth): - def one(old,new,count=1): - nonlocal text - text=replace(text,old,new,count) - if name=='src/game/game_main.cpp': - one('#include "runtime_ui.hpp"','#include "runtime_ui.hpp"\n#include "camera_runtime.hpp"') - one('if(code_set&&online_save.active&&','if(code_set&&!code_set->ordered.empty()&&online_save.active&&') - one(' dkr::runtime::code_mods::begin(code_set,registered_revision,', - ' dkr::runtime::camera::begin(config_directory,DKR_RELEASE_VERSION,registered_revision,online_save.active);\n dkr::runtime::code_mods::begin(code_set,registered_revision,') - one('dkr::runtime::code_mods::reset();','dkr::runtime::code_mods::reset();\n dkr::runtime::camera::reset();',3) - elif name=='src/game/camera_obstruction.cpp': - one('#include "modern_camera.hpp"','') - start=text.index(' /* The local Modern camera') - end=text.index('\n}',start) - text=text[:start]+text[end:] - start=text.index('extern "C" void dkr_modern_camera_restore') - text=text[:start]+'''// Retained generated-hook ABI. Camera++ never changes live camera RAM. -extern "C" void dkr_modern_camera_restore(std::uint8_t*, recomp_context*) {} -''' - elif name=='src/game/runtime_input.hpp': - one(' Accelerate,\n Count,',''' Accelerate, - OrbitUp, OrbitDown, OrbitLeft, OrbitRight, - CameraRecenter, CameraCycle, CameraZoomIn, CameraZoomOut, - CameraFirstPerson, CameraXrRecenter, - Count,''') - one(' bool accelerate = false;',' bool accelerate = false;\n float orbit_x=0,orbit_y=0;\n std::uint32_t camera_actions=0;') - elif name=='src/game/runtime_input.cpp': - one(' {dkr::runtime::input::kUnbound, kAxisSourceBase + SDL_CONTROLLER_AXIS_TRIGGERRIGHT * 2 + 1},', - ''' {dkr::runtime::input::kUnbound, kAxisSourceBase + SDL_CONTROLLER_AXIS_TRIGGERRIGHT * 2 + 1}, - {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTY*2}, - {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTY*2+1}, - {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTX*2}, - {dkr::runtime::input::kUnbound,kAxisSourceBase+SDL_CONTROLLER_AXIS_RIGHTX*2+1}, - {SDL_SCANCODE_R,SDL_CONTROLLER_BUTTON_LEFTSTICK}, - {SDL_SCANCODE_C,SDL_CONTROLLER_BUTTON_Y}, - {SDL_SCANCODE_PAGEUP,dkr::runtime::input::kUnbound}, - {SDL_SCANCODE_PAGEDOWN,dkr::runtime::input::kUnbound}, - {SDL_SCANCODE_F6,dkr::runtime::input::kUnbound}, - {SDL_SCANCODE_HOME,dkr::runtime::input::kUnbound},''') - one('"c_up", "c_down", "c_left", "c_right", "look_back", "accelerate",', - '"c_up", "c_down", "c_left", "c_right", "look_back", "accelerate",\n "camera_up","camera_down","camera_left","camera_right","camera_recenter","camera_cycle",\n "camera_zoom_in","camera_zoom_out","camera_first_person","camera_xr_recenter",') - one(' "Look back", "Accelerate",',''' "Look back", "Accelerate", - "Camera up","Camera down","Camera left","Camera right","Centre camera","Cycle camera", - "Camera zoom in","Camera zoom out","First-person camera","Centre headset",''') - one(' state.look_back = value(Action::LookBack) > 0.5F;', ''' state.look_back = value(Action::LookBack) > 0.5F; - state.orbit_x=std::clamp(value(Action::OrbitLeft)-value(Action::OrbitRight),-1.F,1.F); - state.orbit_y=std::clamp(value(Action::OrbitDown)-value(Action::OrbitUp),-1.F,1.F); - const Action camera_keys[]={Action::LookBack,Action::CameraRecenter,Action::CameraCycle, - Action::CameraZoomIn,Action::CameraZoomOut,Action::CameraFirstPerson,Action::CameraXrRecenter}; - for(unsigned i=0;i<7;++i)if(value(camera_keys[i])>0.5F)state.camera_actions|=1U<=0&&cb==nb)||(ck>=0&&ck==nk))state.buttons&=std::uint16_t(~bits[n]); - } - if(state.accelerate)state.buttons|=0x8000U; - }''') - start=text.index(' if (dkr::runtime::modern_camera::owns_controls(player))') - end=text.index(' g_buttons[player].store',start) - text=text[:start]+text[end:] - one(' dkr::runtime::modern_camera::set_online_routing(enabled);','') - elif name=='src/game/runtime_ui.cpp': - one('#include "modern_camera.hpp"','#include "modern_camera.hpp"\n#include "camera_runtime.hpp"') - one(' if (HandleInputCaptureEvent(event)) {',''' if(CameraTuningVisible()&&!g_online_code_keyboard_visible.load()&&event->type==SDL_KEYDOWN&&event->key.repeat==0&& - event->key.keysym.scancode==SDL_SCANCODE_ESCAPE) { - dkr::runtime::camera::editor_open.store(false);return true; - } - if (HandleInputCaptureEvent(event)) {''') - one(''' if (show_overlay) { - dkr::runtime::platform::update_ui_gamepad_navigation(); - }''',''' if (show_overlay || show_camera_tuning) { - dkr::runtime::platform::update_ui_gamepad_navigation(); - }''') - elif name=='src/game/runtime_camera_tuning_ui.inl': - return (auth/'camera_editor_ui.inl').read_text() - elif name=='src/game/runtime_code_mods_ui.inl': - one('card.info=item.kept_online?"Kept from online":"Gameplay mod";', - 'card.info=item.presentation?"Local camera mod":item.kept_online?"Kept from online":"Gameplay mod";') - elif name=='src/game/runtime_graphics_ui.inl': - start=text.index(' namespace cam = dkr::runtime::modern_camera;') - closing=' ImGui::EndDisabled();' - end=text.index(closing,start) - text=text[:start]+''' ImGui::EndDisabled(); - const auto camera_provider=dkr::runtime::camera::activation(); - SettingsHelp("ModernCamera++ is available as an optional mod in Mods/Hacks.",inner); - if(live&&camera_provider&&SettingsButton("Camera++ settings (F7)",inner)) { - OpenCameraTuning();g_overlay_visible.store(false,std::memory_order_release); - } -'''+text[end+len(closing):] - elif name=='src/game/presentation_marker_policy.hpp': - one(' TrackSelectPreview,',' TrackSelectPreview,\n CameraWorldBegin, CameraWorldEnd,') - one(' if (variant > 31U) return false;',' if (variant > 31U) return false;\n if(kind==PresentationMarkerKind::CameraWorldBegin||kind==PresentationMarkerKind::CameraWorldEnd)return mode==0&&variant==0;') - elif name=='src/game/presentation_identity.hpp': - one('#include ','#include \n#include ') - one('struct OwnedMatrixBinding {','std::vector camera_world_boundaries();\nstruct OwnedMatrixBinding {') - # This header already uses vector for other presentation helpers. - elif name=='src/game/presentation_identity.cpp': - anchor='extern "C" void dkr_presentation_scene_begin' - insert='''std::vector -dkr::runtime::presentation::camera_world_boundaries() { - std::vector out; - if(g_active_owned_task)return out; - for(const auto& [address,list]:g_active_marker_map)for(const auto& marker:list) - if(marker.kind==PresentationMarkerKind::CameraWorldBegin||marker.kind==PresentationMarkerKind::CameraWorldEnd) - out.push_back({address,marker}); - return out; -} - -''' - one(anchor,insert+anchor) - elif name=='src/game/runtime_stubs.cpp': - for function,kind in [('dkr_split_screen_viewport_fill','CameraWorldBegin'),('dkr_split_screen_world_aspect_end','CameraWorldEnd')]: - start=text.index('extern "C" void '+function) - end=text.index('\n#else',start) - fragment=text[start:end] - old=''' if (rdram == nullptr || context == nullptr) { - return; - }''' - new=old+''' - AppendPresentationGroupCommand(rdram,context->r17,PresentationGroupMode::World, - std::uint16_t(MEM_W(0,RdramAddress(dkr::runtime::revision_addresses::ActiveCameraId))&3U), - 0U,PresentationMarkerKind::'''+kind+''');''' - text=text[:start]+replace(fragment,old,new)+text[end:] - elif name=='src/game/netplay/experimental_draw_events.h': - one('DKR_OWNED_LOCAL_MODEL,DKR_OWNED_LOCAL_OBJECT','DKR_OWNED_LOCAL_MODEL,DKR_OWNED_LOCAL_OBJECT,DKR_OWNED_CAMERA_WORLD_END') - one('e->kind>DKR_OWNED_LOCAL_OBJECT','e->kind>DKR_OWNED_CAMERA_WORLD_END') - one(' case DKR_OWNED_ONLINE_HUD:', ' case DKR_OWNED_CAMERA_WORLD_END:\n if(e->token>3)return 0;break;\n case DKR_OWNED_ONLINE_HUD:') - elif name=='tests/replay_probe/probe_native.c': - one(''' if(strcmp(name,"dkr_split_screen_world_aspect_end")==0) { - dkr_probe_online_racers''',''' if(strcmp(name,"dkr_split_screen_world_aspect_end")==0) { - uint32_t camera_addresses[8];dkr_probe_presentation_addresses(camera_addresses); - draw_marker(rdram,ctx->r17,DKR_OWNED_CAMERA_WORLD_END, - (uint32_t)MEM_W(0,guest_address(camera_addresses[7]))&3U); - dkr_probe_online_racers''') - elif name=='src/game/online_view_rt64.cpp': - one('e.kind==DKR_OWNED_LOCAL_OBJECT)continue;', 'e.kind==DKR_OWNED_LOCAL_OBJECT || e.kind==DKR_OWNED_CAMERA_WORLD_END)continue;') - elif name=='src/game/f3ddkr_rt64.hpp': - one('namespace online_view {class Pass;}','namespace online_view {class Pass;}\nnamespace camera {class RenderPass;}') - one('LocalModelRT64Pass* models=nullptr);','LocalModelRT64Pass* models=nullptr,camera::RenderPass* camera=nullptr);') - elif name=='src/game/f3ddkr_rt64.cpp': - one('#include "f3ddkr_rt64.hpp"','#include "f3ddkr_rt64.hpp"\n#include "camera_rt64.hpp"') - one('constexpr std::uint32_t kRDRAMSize = 0x00800000U;', - 'thread_local std::uint32_t kRDRAMSize = 0x00800000U; // widened only inside an authorized private camera decode') - one('struct dkr::runtime::F3DDKRRT64Bridge::StateData {','struct dkr::runtime::F3DDKRRT64Bridge::StateData {\n camera::RenderPass* camera=nullptr;\n std::array camera_matrices{};') - one('LocalModelRT64Pass* models) {','LocalModelRT64Pass* models,camera::RenderPass* camera) {') - one(' active_ = this;',''' struct CameraDecodeScope { - camera::RenderPass* pass; - ~CameraDecodeScope(){if(pass)pass->finish();kRDRAMSize=0x800000U;} - } camera_scope{camera}; - kRDRAMSize=camera&&camera->enabled()?0x1000000U:0x800000U; - active_ = this;''') - one(' fresh.local_scenery=scenery;',' fresh.camera=camera;\n fresh.local_scenery=scenery;') - one(' if (data.task_rejected) return false;',''' if (data.task_rejected) return false; - if(data.camera) { - const auto base=reinterpret_cast(state->RDRAM),p=reinterpret_cast(command); - if(p>=base&&p-base<=kRDRAMSize-8)data.camera->before_command(std::uint32_t(p-base)); - }''') - one(' const auto& marker = markers.markers[index];', ''' const auto& marker = markers.markers[index]; - if(marker.kind==PresentationMarkerKind::CameraWorldBegin||marker.kind==PresentationMarkerKind::CameraWorldEnd)continue; - if(active_->data_->camera&&active_->data_->camera->replaced_command(std::uint32_t(command-rdram_begin))&& - marker.kind!=PresentationMarkerKind::SplitViewport&&marker.kind!=PresentationMarkerKind::HudPass&& - marker.kind!=PresentationMarkerKind::HudWidget&&marker.kind!=PresentationMarkerKind::HudRect)continue;''') - one(' data.interpolation_groups.load_matrix(\n index, matrix_group.identity,', - ''' data.camera_matrices[index]=data.camera&&data.camera->matrix(address,matrix_group); - data.interpolation_groups.load_matrix( - index, matrix_group.identity,''') - for function,end_function,index in [('Matrix','FillRect','index'),('MoveWord','SetTextureImage','data.selected_matrix')]: - start=text.index('void dkr::runtime::F3DDKRRT64Bridge::'+function+'(') - end=text.index('void dkr::runtime::F3DDKRRT64Bridge::'+end_function+'(',start) - fragment=text[start:end] - fragment=replace(fragment,'ActiveAspectMode(',f'data.camera_matrices[{index}]?G_EX_ASPECT_ADJUST:ActiveAspectMode(') - fragment=replace(fragment,'active_group.procedural_water ? active_group.water_scroll_tag : 0U);', - f'active_group.procedural_water ? active_group.water_scroll_tag : 0U,data.camera_matrices[{index}]);') - text=text[:start]+fragment+text[end:] - elif name=='src/game/rt64_renderer.hpp': - one('struct OwnedDecodeScratch;','struct OwnedDecodeScratch;\nnamespace camera {class RenderPass;}') - one(' F3DDKRRT64Bridge f3ddkr_;',' F3DDKRRT64Bridge f3ddkr_;\n std::unique_ptr camera_pass_;\n std::vector camera_decoder_;') - one('#include ','#include \n#include ') - elif name=='src/game/rt64_renderer.cpp': - one('#include "rt64_renderer.hpp"','#include "rt64_renderer.hpp"\n#include "camera_rt64.hpp"\n#include "revision_addresses.hpp"\n#include "netplay/online_input_broker.hpp"') - one(''' RendererSnapshotScope snapshot_scope(application_->core.RDRAM, - application_->state->RDRAM, - rdram_snapshot);''',''' std::uint8_t* decode_bytes=rdram_snapshot; - const bool camera_requested=bool(camera::activation()); - if(camera_requested) { - if(camera_decoder_.size()!=DKR_CAMERA_KERNEL_BYTES)camera_decoder_.resize(DKR_CAMERA_KERNEL_BYTES); - std::memcpy(camera_decoder_.data(),rdram_snapshot,0x800000); - decode_bytes=camera_decoder_.data(); - } - RendererSnapshotScope snapshot_scope(application_->core.RDRAM, - application_->state->RDRAM, - decode_bytes);''') - one(''' rdram_snapshot, task->t.data_ptr); - // DKR authors''',''' rdram_snapshot, task->t.data_ptr); - if(!camera_pass_)camera_pass_=std::make_unique(); - camera::FrameStamp camera_frame{}; - camera_frame.scene=presentation::task_scene_generation(); - camera_frame.time=float(presentation::task_presentation_time()); - camera_frame.frame=std::uint32_t(std::max(0.F,camera_frame.time)*30.F); - camera_frame.dt=1.F/30.F; - int camera_width=0,camera_height=0; - if(auto* w=static_cast(platform::sdl_window()))SDL_GetWindowSize(w,&camera_width,&camera_height); - if(application_->userConfig.aspectRatio==RT64::UserConfiguration::AspectRatio::Expand&&camera_width>0&&camera_height>0) - camera_frame.aspect=float(camera_width)/camera_height; - else if(application_->userConfig.aspectRatio==RT64::UserConfiguration::AspectRatio::Manual) - camera_frame.aspect=float(application_->userConfig.aspectTarget); - const auto camera_online=netplay::online_input_broker().view(); - camera_frame.online=camera_online.enabled; - if(camera_requested)camera_pass_->prepare({rdram_snapshot,0x800000},camera_decoder_,camera_frame,{}, - camera_online.local_slot,unsigned(platform::online_input_profile())); - // DKR authors''') - one('f3ddkr_.process(*application_, *task);','f3ddkr_.process(*application_, *task,nullptr,nullptr,nullptr,camera_requested?camera_pass_.get():nullptr);',3) - anchor=''' application_->state->setRefreshRate(30); - {netplay::experimental::performance::Scope timing''' - one(anchor,''' if(!camera_pass_)camera_pass_=std::make_unique(); - camera::FrameStamp camera_frame{};camera_frame.scene=scene; - camera_frame.epoch=std::uint32_t(lease->epoch());camera_frame.frame=d.frame; - camera_frame.time=float(d.frame)/30.F;camera_frame.dt=1.F/30.F; - camera_frame.aspect=cover*4.F/3.F;camera_frame.online=true; - camera_pass_->prepare(lease->bytes(),workspace->bytes(),camera_frame,lease->draw_events(), - online_view_session_.owner,unsigned(platform::online_input_profile()),owned_decode_scratch_->online.focused()); - application_->state->setRefreshRate(30); - {netplay::experimental::performance::Scope timing''') - one(''' ,&owned_decode_scratch_->local_models -#endif - );''',''' ,&owned_decode_scratch_->local_models -#else - ,nullptr -#endif - ,camera_pass_.get());''') - else: - raise ValueError('Unknown integration target '+name) - return text diff --git a/patches/camera-plus/mod/dkr.json b/patches/camera-plus/mod/dkr.json deleted file mode 100644 index 7a54ce53..00000000 --- a/patches/camera-plus/mod/dkr.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": 1, - "api": 1, - "activation": "restart", - "online": "offline-only", - "revisions": ["us.v77", "us.v80"], - "requires": {"core": 1, "settings": 1, "presentation": 1}, - "systems": ["local-camera"], - "resources": {"camera_editor": "settings.json"} -} diff --git a/patches/camera-plus/mod/main.c b/patches/camera-plus/mod/main.c deleted file mode 100644 index 5ada42d7..00000000 --- a/patches/camera-plus/mod/main.c +++ /dev/null @@ -1,207 +0,0 @@ -#include "dkr/presentation.h" - -/* Camera behaviour belongs to the importable package, not the executable. - * DKR-R only supplies snapshots, local actions, queries and validated views. - * Yaw zero looks down -Z; positive yaw turns towards -X. Pitch is positive - * looking down, matching DKR's camera convention. No raw game references. - */ -#define PI 3.14159265358979323846F -typedef struct CameraState { - DkrU32 valid,scene,epoch,racer,generation,vehicle,mode,requested_mode; - float position[3],last_position[3],forward_position[3]; - float yaw,pitch,roll,forward_yaw,forward_pitch; - float orbit_yaw,orbit_pitch,zoom,zoom_target,still_for,airtime; - float boost,landing,impact,clearance,shake_phase; - float last_speed,last_yaw; - DkrU32 grounded,rear_toggle,rear; -} CameraState; -static CameraState states[4]; - -static float clamp(float x,float lo,float hi){return xhi?hi:x;} -static float absolute(float x){return x<0?-x:x;} -static float mix(float a,float b,float t){return a+(b-a)*t;} -static float wrap(float a){while(a>PI)a-=2*PI;while(a<-PI)a+=2*PI;return a;} -static float follow(float rate,float dt){return rate<=0?1:1-dkr_view_exp(-clamp(rate*dt,0,50));} -static float number(const char* id){return (float)dkr_setting_number(id);} -static float profile(const char* car,const char* hover,const char* plane,DkrU32 vehicle){return number(vehicle==1?hover:vehicle==2?plane:car);} -static float squared_distance(const float* a,const float* b){float sum=0;for(unsigned i=0;i<3;++i){float x=a[i]-b[i];sum+=x*x;}return sum;} -static float limited(float value,float maximum){return clamp(value,-maximum,maximum);} -static void copy3(float* a,const float* b){for(unsigned i=0;i<3;++i)a[i]=b[i];} -static void reset(CameraState* c,const DkrViewSnapshot* s,DkrU32 mode) { - CameraState fresh={0};*c=fresh;c->valid=1;c->scene=s->scene;c->epoch=s->epoch; - c->racer=s->racer_id;c->generation=s->racer_generation;c->vehicle=s->vehicle;c->mode=mode;c->requested_mode=mode; - c->yaw=c->forward_yaw=s->yaw;c->pitch=c->forward_pitch=s->pitch; - c->last_yaw=s->yaw;c->last_speed=s->speed;c->grounded=(s->flags&DKR_VIEW_GROUNDED)!=0; - c->clearance=1;copy3(c->position,s->original_position);copy3(c->last_position,s->position); -} -static DkrU32 supported(DkrU32 mode,const DkrViewSnapshot* s) { - if(!(s->capabilities&DKR_VIEW_POSE))return DKR_VIEW_ORIGINAL; - if(mode==DKR_VIEW_VR_DRIVER&&!(s->capabilities&DKR_VIEW_STEREO))return DKR_VIEW_COMFORT_FIRST; - if(mode==DKR_VIEW_ORBIT&&(!(s->capabilities&DKR_VIEW_ORBIT_COVERAGE)|| - !(s->flags&DKR_VIEW_INSPECTION_ALLOWED)||(s->flags&DKR_VIEW_ONLINE)))return DKR_VIEW_CHASE; - return mode; -} -static void clearance(const DkrViewSnapshot* s,CameraState* c,float* position,float dt,float radius) { - DkrViewQuery q={0};DkrViewHit hit={0};q.api=1;q.size=sizeof(q);q.view=s->view;q.exclude_racer=s->racer_id; - copy3(q.origin,s->position);q.origin[1]+=number("target_height");copy3(q.target,position);q.radius=radius; - q.include_dynamic=(s->capabilities&DKR_VIEW_DYNAMIC_COLLISION)!=0; - DkrU32 result=dkr_view_query(&q,&hit); - if(result==DKR_VIEW_QUERY_UNSUPPORTED||result==DKR_VIEW_QUERY_BUDGET) { - /* A failed query is not proof of clearance. Preserve the native safe - * position rather than exposing a wall while a query budget recovers. */ - copy3(position,s->original_position);c->clearance=1;return; - } - float fraction=result==DKR_VIEW_QUERY_HIT?clamp(hit.fraction-0.015F,0,1):1; - if(fractionclearance)c->clearance=fraction; - else if(fraction>c->clearance+0.015F)c->clearance=mix(c->clearance,fraction,follow(number("clearance_return"),dt)); - for(unsigned i=0;i<3;++i)position[i]=mix(q.origin[i],position[i],c->clearance); -} - -DKR_CALLBACK(on_presentation) void camera_frame(void) { - DkrViewSnapshot s;DkrViewInput input; - if(!dkr_view_snapshot(&s)||!dkr_view_input(&input)||s.view>=4)return; - CameraState* c=&states[s.view]; - DkrU32 mode=supported((DkrU32)number("mode"),&s); - DkrViewPose output={0};output.api=1;output.size=sizeof(output);output.view=s.view; - output.racer_id=s.racer_id;output.racer_generation=s.racer_generation; - output.vertical_fov=s.original_fov;output.near_plane=s.original_near;output.far_plane=s.original_far; - if(mode==DKR_VIEW_ORIGINAL){output.mode=mode;dkr_view_submit(&output);return;} - int discontinuity=!c->valid||c->scene!=s.scene||c->epoch!=s.epoch||c->racer!=s.racer_id|| - c->generation!=s.racer_generation||c->vehicle!=s.vehicle||(s.flags&DKR_VIEW_CUT)|| - squared_distance(c->last_position,s.position)>250000; - if(discontinuity)reset(c,&s,mode); - /* The selected profile stays local to this view. Cycling is session-only; - * changing the saved mode deliberately resets it on the next callback. */ - if(mode!=c->requested_mode){c->requested_mode=mode;c->mode=mode;output.cut=1;} - if(input.pressed&DKR_VIEW_CYCLE){ - DkrU32 count=(s.capabilities&DKR_VIEW_ORBIT_COVERAGE)&&(s.flags&DKR_VIEW_INSPECTION_ALLOWED)?6:5; - c->mode=1+(c->mode%count);output.cut=1; - } - if(input.pressed&DKR_VIEW_FIRST_PERSON){c->mode=c->mode==DKR_VIEW_FIRST?DKR_VIEW_CHASE:DKR_VIEW_FIRST;output.cut=1;} - mode=supported(c->mode,&s);output.mode=mode; - float dt=clamp(s.dt,0,0.1F),speed=absolute(s.speed),speed01=clamp(speed/number("speed_reference"),0,1); - int first=mode==DKR_VIEW_FIRST||mode==DKR_VIEW_COMFORT_FIRST||mode==DKR_VIEW_VR_DRIVER; - int comfort=mode==DKR_VIEW_COMFORT_FIRST||mode==DKR_VIEW_VR_DRIVER; - float reduced=number("reduced_motion")!=0||comfort?0:1; - float effect=number("effects")*reduced; - if(mode==DKR_VIEW_CINEMATIC)effect=clamp(effect*1.35F,0,1.5F); - if(input.pressed&DKR_VIEW_RECENTER){c->orbit_yaw=0;c->orbit_pitch=0;} - float orbit_limit=mode==DKR_VIEW_ORBIT?PI:PI*0.6F; - c->orbit_yaw=limited(c->orbit_yaw+input.orbit_x*number("orbit_sensitivity")*dt,orbit_limit); - c->orbit_pitch=clamp(c->orbit_pitch+input.orbit_y*number("orbit_sensitivity")* - (number("invert_y")!=0?-1:1)*dt,-1.0F,1.0F); - if(absolute(input.orbit_x)+absolute(input.orbit_y)>0.03F)c->still_for=0; - else { - c->still_for+=dt; - if(c->still_for>number("recenter_delay")&&mode!=DKR_VIEW_ORBIT) { - float t=follow(number("recenter_speed"),dt);c->orbit_yaw=mix(c->orbit_yaw,0,t);c->orbit_pitch=mix(c->orbit_pitch,0,t); - } - } - if(input.pressed&DKR_VIEW_ZOOM_IN)c->zoom_target-=15; - if(input.pressed&DKR_VIEW_ZOOM_OUT)c->zoom_target+=15; - c->zoom_target=clamp(c->zoom_target+input.zoom*60*dt,-80,120); - c->zoom=mix(c->zoom,c->zoom_target,follow(number("zoom_response"),dt)); - float direction=s.yaw; - /* Near rest or reversing, keep nose-follow stable instead of inferring a - * heading from a tiny/negative velocity vector. Drift has its own mix. */ - float horizontal=s.velocity[0]*s.velocity[0]+s.velocity[2]*s.velocity[2]; - if(horizontal>9&&s.speed>2) { - float travel=dkr_view_atan2(-s.velocity[0],-s.velocity[2]); - float alignment=number((s.flags&DKR_VIEW_DRIFTING)?"drift_alignment":"travel_alignment"); - direction+=wrap(travel-s.yaw)*alignment; - } - c->forward_yaw=wrap(c->forward_yaw+wrap(direction-c->forward_yaw)*follow(number("rotation_response"),dt)); - float rear_before=(float)c->rear; - if(number("look_back_toggle")!=0) { - if(input.pressed&DKR_VIEW_LOOK_BACK)c->rear_toggle=!c->rear_toggle; - c->rear=c->rear_toggle; - } else c->rear=(input.held&DKR_VIEW_LOOK_BACK)!=0; - if(!(s.capabilities&DKR_VIEW_REAR_COVERAGE)||mode==DKR_VIEW_VR_DRIVER)c->rear=0; - if(rear_before!=(float)c->rear)output.cut=1; - /* Maintain the forward solver even during look-back. A rear transition is - * a cut by default, never a 180-degree blend through the racer's body. */ - float yaw=wrap(c->forward_yaw+c->orbit_yaw+(c->rear?PI:0)); - float pitch_follow=profile("car_pitch","hover_pitch","plane_pitch",s.vehicle); - float bank_follow=profile("car_bank","hover_bank","plane_bank",s.vehicle); - float vertical_response=number("vertical_response")*profile("car_vertical","hover_vertical","plane_vertical",s.vehicle); - float pitch=s.pitch*pitch_follow+c->orbit_pitch+number("pitch_offset")*PI/180; - float distance=mix(number("distance_low"),number("distance_high"),speed01)+c->zoom; - distance*=profile("car_distance","hover_distance","plane_distance",s.vehicle); - if(mode==DKR_VIEW_BONNET)distance=number("bonnet_distance"); - if(c->rear)distance=number("rear_distance"); - float boost_target=(s.flags&DKR_VIEW_BOOSTING)?clamp(s.boost,0,1):0; - c->boost=mix(c->boost,boost_target,follow(boost_target>c->boost?10:number("effect_recovery"),dt)); - int grounded=(s.flags&DKR_VIEW_GROUNDED)!=0; - if(grounded&&!c->grounded&&c->airtime>0.15F)c->landing=clamp(c->airtime,0,1); - if(!grounded)c->airtime+=dt;else c->airtime=0; - if(s.flags&DKR_VIEW_IMPACT)c->impact=1; - c->landing=mix(c->landing,0,follow(number("effect_recovery"),dt)); - c->impact=mix(c->impact,0,follow(number("effect_recovery"),dt)); - c->grounded=(DkrU32)grounded;c->shake_phase+=dt*29; - if(c->shake_phase>2*PI)c->shake_phase-=2*PI; - distance=clamp(distance+c->boost*number("boost_pullback")*effect,2,700); - float sx=dkr_view_sin(yaw),cz=dkr_view_cos(yaw),right_x=cz,right_z=-sx; - float desired[3]; - float side=number("side_offset")+clamp(s.steer,-1,1)*number("sway")*effect; - float height=number("height")-c->landing*number("landing_dip")*effect; - float lookahead=number("look_ahead")*speed01; - float lead_x=limited(s.velocity[0]*lookahead,80),lead_z=limited(s.velocity[2]*lookahead,80); - if(first||mode==DKR_VIEW_BONNET) { - float nose_sin=dkr_view_sin(s.yaw),nose_cos=dkr_view_cos(s.yaw); - float seat_forward=mode==DKR_VIEW_BONNET?number("bonnet_distance"):number("seat_forward"),seat_side=number("seat_side"); - desired[0]=s.position[0]-nose_sin*seat_forward+nose_cos*seat_side; - desired[1]=s.position[1]+number("seat_height"); - desired[2]=s.position[2]-nose_cos*seat_forward-nose_sin*seat_side; - yaw=wrap(s.yaw+c->orbit_yaw+(c->rear?PI:0)); - } else { - desired[0]=s.position[0]+sx*distance+right_x*side+lead_x; - desired[1]=s.position[1]+height; - desired[2]=s.position[2]+cz*distance+right_z*side+lead_z; - float target_height=number("target_height"); - pitch+=dkr_view_atan2(height-target_height,distance); - } - /* One bounded target, no accumulated positional kick or unchecked speed - * multiplier. Native position is never changed, even in inspection mode. */ - for(unsigned i=0;i<3;++i) { - float response=i==1?vertical_response:number("horizontal_response"); - c->position[i]=discontinuity||output.cut?desired[i]:mix(c->position[i],desired[i],follow(response,dt)); - c->position[i]=clamp(c->position[i],desired[i]-number("maximum_lag"),desired[i]+number("maximum_lag")); - output.position[i]=c->position[i]; - } - float roll=s.roll*bank_follow+clamp(s.steer,-1,1)*number("corner_roll")*effect*PI/180; - float shake=number("shake")*effect*c->impact; - if(shake>0) { - output.position[1]+=dkr_view_sin(c->shake_phase)*shake; - pitch+=dkr_view_cos(c->shake_phase)*shake*0.002F; - } - if(s.capabilities&DKR_VIEW_PROJECTION) { - float fov=number("fov")+number("speed_fov")*speed01*effect+number("boost_fov")*c->boost*effect; - output.vertical_fov=clamp(fov,30,120)*PI/180;output.near_plane=first||mode==DKR_VIEW_BONNET?1:s.original_near; - } - /* A centre ray can clear while near-plane corners expose a wall at high - * FOV / ultrawide aspects. Include those corners in the clearance volume. */ - if(!first) { - float half=output.vertical_fov*0.5F; - float near_radius=output.near_plane*dkr_view_sin(half)/dkr_view_cos(half)* - dkr_view_sqrt(1+s.aspect*s.aspect)+1; - if(near_radius>128) { - output.mode=DKR_VIEW_ORIGINAL;dkr_view_submit(&output);return; - } - float radius=number("clearance_radius"); - clearance(&s,c,output.position,dt,radius>near_radius?radius:near_radius); - } - /* Clearance is the last positional operation. Cuts also retire angular - * smoothing, rather than borrowing the previous mode's pitch. */ - c->pitch=discontinuity||output.cut?pitch:mix(c->pitch,pitch,follow(number("rotation_response"),dt)); - c->roll=discontinuity||output.cut?(comfort?0:roll):mix(c->roll,comfort?0:roll,follow(number("rotation_response"),dt)); - output.yaw=yaw;output.pitch=c->pitch;output.roll=c->roll; - if(first) { - DkrU32 visibility=(DkrU32)number("first_visibility"); - if(visibility==DKR_VIEW_HIDE_RIDER_ONLY&&!(s.capabilities&DKR_VIEW_HIDE_RIDER))visibility=DKR_VIEW_HIDE_VEHICLE; - if(visibility==DKR_VIEW_HIDE_VEHICLE&&!(s.capabilities&DKR_VIEW_HIDE_LOCAL_MODEL))visibility=DKR_VIEW_SHOW_MODEL; - output.visibility=visibility; - } - if(discontinuity)output.cut=1; - copy3(c->last_position,s.position);c->last_speed=s.speed;c->last_yaw=s.yaw; - dkr_view_submit(&output); -} diff --git a/patches/camera-plus/mod/settings.json b/patches/camera-plus/mod/settings.json deleted file mode 100644 index 382b5c1e..00000000 --- a/patches/camera-plus/mod/settings.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "presets": [ - {"name":"Original","values":{"mode":0}}, - {"name":"Balanced","defaults":true,"values":{}}, - {"name":"Competitive","defaults":true,"values":{"mode":1,"horizontal_response":28,"rotation_response":30,"effects":0,"speed_fov":0,"boost_fov":0}}, - {"name":"Cinematic","defaults":true,"values":{"mode":5,"effects":1,"sway":10,"corner_roll":5,"boost_pullback":24,"landing_dip":8}}, - {"name":"Comfort","defaults":true,"values":{"mode":1,"reduced_motion":1,"effects":0,"plane_bank":0,"plane_pitch":0.15}}, - {"name":"First Person","defaults":true,"values":{"mode":3,"effects":0.2,"plane_bank":0.15}}, - {"name":"VR Comfort","defaults":true,"values":{"mode":7,"reduced_motion":1,"effects":0,"plane_bank":0,"plane_pitch":0}} - ], - "groups": [ - {"name":"Modes","options":[ - {"id":"mode","name":"Camera","type":"Enum","default":"Balanced chase","options":["Original DKR","Balanced chase","Bonnet","First person","Comfort first person","Cinematic chase","Inspection","VR driver"]}, - {"id":"reduced_motion","name":"Reduced motion","type":"Enum","default":"Off","options":["Off","On"]}, - {"id":"first_visibility","name":"First-person vehicle","type":"Enum","default":"Hide vehicle","options":["Show vehicle","Hide vehicle","Hide rider"]} - ]}, - {"name":"Framing","options":[ - {"id":"distance_low","name":"Distance at low speed","default":140,"min":20,"max":350}, - {"id":"distance_high","name":"Distance at high speed","default":190,"min":20,"max":500}, - {"id":"height","name":"Camera height","default":65,"min":5,"max":200}, - {"id":"side_offset","name":"Side offset","default":0,"min":-80,"max":80}, - {"id":"target_height","name":"Look-at height","default":25,"min":0,"max":120}, - {"id":"bonnet_distance","name":"Bonnet distance","default":12,"min":2,"max":80}, - {"id":"seat_height","name":"Eye height","default":28,"min":5,"max":120}, - {"id":"seat_forward","name":"Seat forward / back","default":4,"min":-80,"max":100}, - {"id":"seat_side","name":"Seat sideways","default":0,"min":-60,"max":60}, - {"id":"pitch_offset","name":"View tilt (degrees)","default":0,"min":-45,"max":45}, - {"id":"fov","name":"Field of view (degrees)","default":60,"min":30,"max":120}, - {"id":"speed_fov","name":"Extra FOV at speed","default":3,"min":0,"max":25}, - {"id":"boost_fov","name":"Extra FOV on boost","default":4,"min":0,"max":25} - ]}, - {"name":"Handling","options":[ - {"id":"horizontal_response","name":"Horizontal response","default":12,"min":0,"max":40}, - {"id":"vertical_response","name":"Vertical response","default":8,"min":0,"max":40}, - {"id":"rotation_response","name":"Turning response","default":12,"min":0,"max":40}, - {"id":"zoom_response","name":"Zoom response","default":10,"min":0,"max":40}, - {"id":"maximum_lag","name":"Maximum camera lag","default":70,"min":0,"max":250}, - {"id":"travel_alignment","name":"Follow direction of travel","default":0.2,"min":0,"max":1}, - {"id":"drift_alignment","name":"Follow drift direction","default":0.4,"min":0,"max":1}, - {"id":"look_ahead","name":"Look ahead","default":0.2,"min":0,"max":1}, - {"id":"speed_reference","name":"Speed for full camera response (units/s)","default":1200,"min":60,"max":7200}, - {"id":"clearance_radius","name":"Wall clearance","default":10,"min":2,"max":60}, - {"id":"clearance_return","name":"Return from a wall","default":5,"min":1,"max":20} - ]}, - {"name":"Effects","options":[ - {"id":"effects","name":"Motion strength","default":0.5,"min":0,"max":1}, - {"id":"sway","name":"Corner sway","default":5,"min":0,"max":30}, - {"id":"corner_roll","name":"Corner roll (degrees)","default":2,"min":0,"max":15}, - {"id":"boost_pullback","name":"Boost pullback","default":12,"min":0,"max":60}, - {"id":"landing_dip","name":"Landing dip","default":4,"min":0,"max":25}, - {"id":"shake","name":"Impact shake","default":1,"min":0,"max":10}, - {"id":"effect_recovery","name":"Motion recovery","default":7,"min":1,"max":30} - ]}, - {"name":"Controls","options":[ - {"id":"look_back_toggle","name":"Look back","type":"Enum","default":"Hold","options":["Hold","Toggle"]}, - {"id":"rear_distance","name":"Rear-view distance","default":150,"min":20,"max":350}, - {"id":"orbit_sensitivity","name":"Orbit sensitivity","default":2,"min":0.1,"max":6}, - {"id":"invert_y","name":"Invert vertical look","type":"Enum","default":"Off","options":["Off","On"]}, - {"id":"recenter_delay","name":"Recenter after (seconds)","default":1.5,"min":0,"max":10}, - {"id":"recenter_speed","name":"Recenter speed","default":4,"min":0.5,"max":20} - ]}, - {"name":"Vehicle profiles","options":[ - {"id":"car_distance","name":"Car distance scale","default":1,"min":0.5,"max":2}, - {"id":"car_pitch","name":"Car slope following","default":0.25,"min":0,"max":1}, - {"id":"car_bank","name":"Car banking","default":0,"min":0,"max":1}, - {"id":"car_vertical","name":"Car vertical response scale","default":1,"min":0.1,"max":2}, - {"id":"hover_distance","name":"Hovercraft distance scale","default":1.1,"min":0.5,"max":2}, - {"id":"hover_pitch","name":"Hovercraft slope following","default":0.1,"min":0,"max":1}, - {"id":"hover_bank","name":"Hovercraft banking","default":0,"min":0,"max":1}, - {"id":"hover_vertical","name":"Hovercraft vertical response scale","default":0.6,"min":0.1,"max":2}, - {"id":"plane_distance","name":"Plane distance scale","default":1.3,"min":0.5,"max":2}, - {"id":"plane_pitch","name":"Plane pitch following","default":0.65,"min":0,"max":1}, - {"id":"plane_bank","name":"Plane banking","default":0.25,"min":0,"max":1}, - {"id":"plane_vertical","name":"Plane vertical response scale","default":1.4,"min":0.1,"max":2} - ]} - ] -} diff --git a/patches/camera-plus/native/camera_actions.cpp b/patches/camera-plus/native/camera_actions.cpp deleted file mode 100644 index a6339ef9..00000000 --- a/patches/camera-plus/native/camera_actions.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include "camera_actions.hpp" -#include -#include - -namespace dkr::runtime::camera { -namespace { -constexpr unsigned KnownActions=DKR_VIEW_LOOK_BACK|DKR_VIEW_RECENTER|DKR_VIEW_CYCLE| - DKR_VIEW_ZOOM_IN|DKR_VIEW_ZOOM_OUT|DKR_VIEW_FIRST_PERSON|DKR_VIEW_XR_RECENTER; -float finite_axis(float value){return std::isfinite(value)?std::clamp(value,-1.F,1.F):0;} -} -void Actions::publish(unsigned profile,const ActionSample& sample) { - if(profile>=4)return; - std::scoped_lock lock(mutex_);auto& p=ports_[profile]; - auto next=sample;next.held&=KnownActions; - next.orbit_x=finite_axis(next.orbit_x);next.orbit_y=finite_axis(next.orbit_y);next.zoom=finite_axis(next.zoom); - if(p.latest.blocked&&!next.blocked) { - p.suppressed|=next.held;p.neutral=true; - } - // Closing UI while holding a button must not trigger a camera action. - if(next.blocked||!next.connected) { - p.suppressed|=next.held;p.pressed=0;p.mouse_x=p.mouse_y=0; - next.orbit_x=next.orbit_y=next.zoom=0; - } - p.suppressed&=next.held;next.held&=~p.suppressed; - if(next.blocked||!next.connected)next.held=0; - if(p.neutral) { - if(!next.blocked&&std::abs(next.orbit_x)<0.15F&&std::abs(next.orbit_y)<0.15F)p.neutral=false; - else next.orbit_x=next.orbit_y=0; - } - p.pressed|=next.held&~p.latest.held;p.latest=next; -} -void Actions::mouse(unsigned profile,float x,float y) { - if(profile>=4||!std::isfinite(x)||!std::isfinite(y))return; - std::scoped_lock lock(mutex_);auto& p=ports_[profile]; - if(p.latest.blocked||!p.latest.connected)return; - p.mouse_x=std::clamp(p.mouse_x+x,-4096.F,4096.F); - p.mouse_y=std::clamp(p.mouse_y+y,-4096.F,4096.F); -} -DkrViewInput Actions::consume(unsigned profile,float dt) { - DkrViewInput out{};out.api=1;out.size=sizeof(out);out.blocked=1; - if(profile>=4||!std::isfinite(dt)||dt<=0||dt>0.1F)return out; - std::scoped_lock lock(mutex_);auto& p=ports_[profile]; - out.blocked=p.latest.blocked||!p.latest.connected; - if(!out.blocked) { - out.held=p.latest.held;out.pressed=p.pressed; - // Mouse is a delta, stick is a rate. Consume the delta once per batch, - // not once per eye. The mod applies its sensitivity and dt afterward. - out.orbit_x=finite_axis(p.latest.orbit_x+p.mouse_x*0.002F/dt); - out.orbit_y=finite_axis(p.latest.orbit_y+p.mouse_y*0.002F/dt); - out.zoom=p.latest.zoom; - } - p.pressed=0;p.mouse_x=p.mouse_y=0;return out; -} -void Actions::reset() {std::scoped_lock lock(mutex_);ports_={};} -} diff --git a/patches/camera-plus/native/camera_actions.hpp b/patches/camera-plus/native/camera_actions.hpp deleted file mode 100644 index 635e16de..00000000 --- a/patches/camera-plus/native/camera_actions.hpp +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once -#include "camera_presentation.hpp" -#include -#include - -namespace dkr::runtime::camera { -// Normalized camera actions, after the existing host resolves device bindings. -// Never opens controllers or writes the N64 input broker. -struct ActionSample { - std::uint32_t held=0; - float orbit_x=0,orbit_y=0,zoom=0; - bool blocked=true,connected=false; -}; -class Actions final { -public: - void publish(unsigned physical_profile,const ActionSample&); - void mouse(unsigned physical_profile,float x_pixels,float y_pixels); - DkrViewInput consume(unsigned physical_profile,float dt); - void reset(); -private: - struct Port { - ActionSample latest; - std::uint32_t pressed=0,suppressed=0; - float mouse_x=0,mouse_y=0; - bool neutral=true; - }; - std::mutex mutex_; - std::array ports_; -}; -} diff --git a/patches/camera-plus/native/camera_kernel.c b/patches/camera-plus/native/camera_kernel.c deleted file mode 100644 index 81273059..00000000 --- a/patches/camera-plus/native/camera_kernel.c +++ /dev/null @@ -1,289 +0,0 @@ -/* A renderer-only, bounded copy of the native per-view draw closure. - * NEVER pass live RAM here. No native game import or callback is linked. - * A C-only fence avoids longjmp through C++ owners/destructors. - */ -#include "camera_scene_addresses.h" -#include "funcs.h" -#include "camera_kernel.h" -#include -#include -#include -#include -#include -enum {COMMANDS=0x80800000U,COMMAND_END=0x80900000U, - MATRICES=0x80900000U,MATRIX_END=0x80980000U, - VERTICES=0x80980000U,VERTEX_END=0x80A80000U, - TRIANGLES=0x80A80000U,TRIANGLE_END=0x80B80000U, - STACK=0x80BF0000U}; -#if defined(_MSC_VER) -#define CAMERA_TLS __declspec(thread) -#else -#define CAMERA_TLS _Thread_local -#endif -static CAMERA_TLS struct { - uint8_t* ram;int active;uint32_t operations,scene,view; - float cover,sky_y[4];unsigned sky_active,wave_active,wave_block,wave_selection,full_screen; - dkr_camera_draw_result* result; - struct {uint32_t first,identity;} objects[16]; - unsigned depth; - const char* failure;jmp_buf fence;fenv_t environment; -} drawing; -void dkr_camera_fault(const char* why){drawing.failure=why;longjmp(drawing.fence,1);} -void dkr_camera_step(void){ - if(!drawing.active||++drawing.operations>6000000U)dkr_camera_fault("camera draw operation budget"); -} -void* dkr_camera_access(uint64_t address,unsigned width) { - dkr_camera_step(); - if(addressUINT64_C(0xffffffff80bfffff)|| - (width!=1&&width!=2&&width!=4)||(address&(width-1))) - dkr_camera_fault("camera draw memory span"); - const uint32_t p=(uint32_t)address-0x80000000U; - if(p>0xC00000U-width)dkr_camera_fault("camera draw memory end"); - return drawing.ram+(p^(width==1?3U:width==2?2U:0U)); -} -static uint64_t guest(uint32_t p){return (uint64_t)(int64_t)(int32_t)p;} -static uint32_t read32(uint32_t p){return *(uint32_t*)dkr_camera_access(guest(p),4);} -static uint16_t read16(uint32_t p){return *(uint16_t*)dkr_camera_access(guest(p),2);} -static uint8_t read8(uint32_t p){return *(uint8_t*)dkr_camera_access(guest(p),1);} -static void put32(uint32_t p,uint32_t value){*(uint32_t*)dkr_camera_access(guest(p),4)=value;} -static void put16(uint32_t p,uint16_t value){*(uint16_t*)dkr_camera_access(guest(p),2)=value;} -static void put8(uint32_t p,uint8_t value){*(uint8_t*)dkr_camera_access(guest(p),1)=value;} -static void real(uint32_t p,float value){uint32_t bits;if(!isfinite(value))dkr_camera_fault("camera draw non-finite transform");memcpy(&bits,&value,4);put32(p,bits);} -static float read_real(uint32_t p){const uint32_t bits=read32(p);float value;memcpy(&value,&bits,4);if(!isfinite(value))dkr_camera_fault("camera draw non-finite input");return value;} -static uint32_t mix32(uint32_t p){p^=p>>16;p*=0x7feb352dU;p^=p>>15;p*=0x846ca68bU;p=(p^(p>>16))|1U;return p==UINT32_MAX?0xFFFFFFFDU:p;} -static uint16_t angle(float value){if(!isfinite(value))dkr_camera_fault("camera angle");return (uint16_t)(int32_t)lroundf(remainderf(value,6.28318530718F)*10430.3783505F);} -static void matrix(uint32_t address,uint32_t identity,unsigned root) { - if(addressMATRIX_END-64||(address&63))dkr_camera_fault("camera matrix allocation"); - for(unsigned i=0;imatrix_count;++i) - if(drawing.result->matrices[i].address==address)return; - if(drawing.result->matrix_count==DKR_CAMERA_MAX_MATRICES)dkr_camera_fault("camera matrix metadata budget"); - dkr_camera_matrix* m=&drawing.result->matrices[drawing.result->matrix_count++]; - m->address=address;m->identity=mix32(drawing.scene^(drawing.view*0x9e3779b9U)^identity);m->root=root; -} -unsigned dkr_presentation_world_origin_matrix(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;matrix(read32((uint32_t)ctx->r16),0x43414d52U,1);return 0; -} -unsigned dkr_presentation_object_begin(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;if(drawing.depth==16)dkr_camera_fault("camera draw object nesting"); - const uint32_t object=(uint32_t)ctx->r7; - const uint32_t holder=read32((uint32_t)ctx->r29+0x24); - drawing.objects[drawing.depth].first=read32(holder); - drawing.objects[drawing.depth++].identity=mix32(object^read16(object+0x2C));return 0; -} -unsigned dkr_presentation_object_end(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx;if(!drawing.depth)dkr_camera_fault("camera draw object scope"); - const unsigned slot=--drawing.depth; - const uint32_t first=drawing.objects[slot].first,end=read32(CAMERA_gObjectCurrMatrix); - if(end256*64U||(end-first)%64)dkr_camera_fault("camera object matrix range"); - for(uint32_t p=first;pr12=1;return 0; // Re-evaluate visibility from the local camera. -} -unsigned dkr_legacy_texture_animation(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx;return 1; // Freeze the captured, already advanced material phase. -} -unsigned dkr_skybox_cover_begin(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx; - if(drawing.sky_active)return 0; - const float scale=drawing.cover<=1.75F?drawing.cover:3.0625F/drawing.cover; - for(unsigned i=0;i<4;++i) { - const uint32_t p=CAMERA_gViewProjMatrixF+i*16+4,bits=read32(p); - memcpy(&drawing.sky_y[i],&bits,4);real(p,drawing.sky_y[i]*scale); - } - drawing.sky_active=1;return 0; -} -unsigned dkr_skybox_cover_end(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx;if(!drawing.sky_active)return 0; - for(unsigned i=0;i<4;++i)real(CAMERA_gViewProjMatrixF+i*16+4,drawing.sky_y[i]); - drawing.sky_active=0;return 0; -} -static float sky_y_scale(float cover){return cover<=1.75F?cover:3.0625F/cover;} -static float split_cover(void){return drawing.cover*(read32(CAMERA_gViewportLayout)==1&&!drawing.full_screen?2.F:1.F);} -unsigned dkr_widen_gradient_sky(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;const uint32_t start=(uint32_t)ctx->r3; - const float horizontal=split_cover(); - float vertical=sky_y_scale(horizontal); - if(drawing.full_screen){vertical=fmaxf(1,vertical);if(read32(CAMERA_gViewportLayout)==1)vertical*=2;} - for(unsigned i=0;i<4;++i) { - const uint32_t p=start+i*10; - put16(p,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf((int16_t)read16(p)*horizontal)))); - put16(p+2,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf((int16_t)read16(p+2)*vertical)))); - } - return 0; -} -unsigned dkr_widen_void_primitive(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;const uint32_t start=(uint32_t)ctx->r2-40; - const float lx=read_real(CAMERA_D_8011D4A0),lz=read_real(CAMERA_D_8011D4A4); - const float cx=read_real(CAMERA_D_8011D4AC),cz=read_real(CAMERA_D_8011D4B0),length=lx*lx+lz*lz; - if(length<0.5F||length>1.5F)return 0; - const float scale=split_cover()-1; - for(unsigned i=0;i<4;++i) { - const uint32_t p=start+i*10;const float x=(int16_t)read16(p),z=(int16_t)read16(p+4); - const float lateral=((x-cx)*lx+(z-cz)*lz)/length; - put16(p,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf(x+lx*lateral*scale)))); - put16(p+4,(uint16_t)(int16_t)fmaxf(-32768,fminf(32767,lroundf(z+lz*lateral*scale)))); - } - return 0; -} -unsigned dkr_presentation_wave_begin(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx;drawing.wave_active=1;drawing.wave_block=0;drawing.wave_selection=256;return 0; -} -unsigned dkr_presentation_wave_end(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx;drawing.wave_active=0;return 0; -} -unsigned dkr_presentation_wave_block(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;drawing.wave_block=(uint32_t)ctx->r2;return 0; -} -unsigned dkr_presentation_wave_selection(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;drawing.wave_selection=read32((uint32_t)ctx->r29+0x104)&255U;return 0; -} -unsigned dkr_presentation_wave_matrix(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;if(!drawing.wave_active||!drawing.wave_block||drawing.wave_selection>25)return 0; - const uint32_t transform=(uint32_t)ctx->r6; - uint32_t identity=drawing.wave_block^mix32(drawing.wave_selection); - for(unsigned i=0;i<4;++i)identity=mix32(identity^read32(transform+i*4)); - identity^=mix32(read32(CAMERA_gWaveController))^mix32(read32(CAMERA_gWaveController+0x28)); - matrix(read32((uint32_t)ctx->r5),identity,2); - drawing.wave_selection=256;return 0; -} -unsigned dkr_fix_car_steering_wheel_material(uint8_t* ram,recomp_context* ctx,...) { - (void)ram; - const uint32_t model=(uint32_t)ctx->r23,batch=(uint32_t)ctx->r2,tex=(uint32_t)ctx->r18; - const uint32_t obj=read32((uint32_t)ctx->r29+0xB4),racer=read32(obj+0x64); - if(read16(obj+0x48)!=1 || !racer || read8(racer+0x1D6)!=0 || - (uint32_t)ctx->r10+1U!=read16(model+0x28) || read8(batch)==255 || - (int32_t)ctx->r17!=4 || (int32_t)ctx->r21!=2 || (uint32_t)ctx->r8!=0x80FU || - read8(tex)!=16 || read8(tex+1)!=16 || (read8(tex+2)&15)!=0 || - (read16(tex+6)&0xC0)!=0xC0)return 0; - const unsigned index=read8(batch),count=read16(model+0x22);const uint32_t table=read32(model); - if(indexr8&=~(gpr)0x800; - return 0; -} -void __ll_to_f_recomp(uint8_t* ram,recomp_context* ctx){ - (void)ram;int64_t value=(int64_t)((ctx->r4<<32)|(uint32_t)ctx->r5);ctx->f0.fl=(float)value; -} -void __f_to_ll_recomp(uint8_t* ram,recomp_context* ctx){ - (void)ram;const double x=ctx->f12.fl; - if(!isfinite(x)||x>=9223372036854775808.0||x< -9223372036854775808.0)dkr_camera_fault("camera integer conversion"); - const int64_t value=(int64_t)x;ctx->r2=(uint64_t)(int64_t)(int32_t)((uint64_t)value>>32);ctx->r3=(uint64_t)(int64_t)(int32_t)value; -} -void do_break(uint32_t pc){(void)pc;dkr_camera_fault("camera native division/overflow");} -void switch_error(const char* name,uint32_t pc,uint32_t table){(void)name;(void)pc;(void)table;dkr_camera_fault("camera native jump table");} -static void call(void (*function)(uint8_t*,recomp_context*),recomp_context* ctx){ - function(drawing.ram,ctx); - if(read32(CAMERA_gTrackDL)>COMMAND_END-16||read32(CAMERA_gTrackMtxPtr)>MATRIX_END|| - read32(CAMERA_gTrackVtxPtr)>VERTEX_END||read32(CAMERA_gTrackTriPtr)>TRIANGLE_END) - dkr_camera_fault("camera per-view draw allocation budget"); -} -int CAMERA_ENTRY(uint8_t* ram,size_t bytes,const dkr_camera_draw_request* request,dkr_camera_draw_result* result) { - if(!result)return 0;memset(result,0,sizeof(*result)); - if(!ram||bytesview>3||drawing.active){ - result->failure="camera draw ownership";return 0; - } - drawing.ram=ram;drawing.operations=drawing.depth=0;drawing.result=result;drawing.failure=0; - drawing.sky_active=drawing.wave_active=0;drawing.cover=1;drawing.full_screen=request->full_screen; - drawing.scene=request->scene;drawing.view=request->view; - if(fegetenv(&drawing.environment)){result->failure="camera floating environment";return 0;} - drawing.active=1; - if(setjmp(drawing.fence)) { - result->failure=drawing.failure;result->operations=drawing.operations; - drawing.active=0;fesetenv(&drawing.environment);return 0; - } - const uint32_t camera=CAMERA_gCameras+request->view*0x44; - for(unsigned i=0;i<3;++i)real(camera+12+i*4,request->position[i]); - put16(camera,angle(3.14159265359F-request->yaw));put16(camera+2,angle(request->pitch));put16(camera+4,angle(request->roll)); - put16(camera+0x38,0);real(camera+0x30,0); - /* The new camera can occupy a different regional visibility cell. Use - its own plane culling, not the old camera's authored visibility bitset. */ - put16(camera+0x34,0xFFFFU); - put32(CAMERA_gActiveCameraID,request->view);put32(CAMERA_gSceneCurrentPlayerID,request->view); - put32(CAMERA_gSceneActiveCamera,camera);put8(CAMERA_gCutsceneCameraActive,0); - put32(CAMERA_gTrackDL,COMMANDS);put32(CAMERA_gTrackMtxPtr,MATRICES); - put32(CAMERA_gTrackVtxPtr,VERTICES);put32(CAMERA_gTrackTriPtr,TRIANGLES); - put32(CAMERA_gSceneRenderSkyDome,1);put8(CAMERA_gDrawLevelSegments,1);put32(CAMERA_gDisableShadows,0); - put32(CAMERA_gModelMatrixStackPos,0); - if(request->hide_object) { - const uint32_t p=request->hide_object; - if(p<0x80000000U||p>0x807FFF80U)dkr_camera_fault("camera local model owner"); - put16(p+6,(uint16_t)(read16(p+6)|0x200U<<(request->view&1U))); - } - const float n=request->near_plane,f=request->far_plane,y=1.F/tanf(request->fov*0.5F); - if(!isfinite(y)||n<=0||f<=n)dkr_camera_fault("camera projection"); - for(unsigned i=0;i<16;++i)real(CAMERA_gPerspectiveMatrixF+i*4,0); - real(CAMERA_gPerspectiveMatrixF,y/(4.F/3.F));real(CAMERA_gPerspectiveMatrixF+20,y); - real(CAMERA_gPerspectiveMatrixF+40,(n+f)/(n-f));real(CAMERA_gPerspectiveMatrixF+44,-1); - real(CAMERA_gPerspectiveMatrixF+56,2*n*f/(n-f)); - put16(CAMERA_perspNorm,(uint16_t)fmaxf(1,fminf(65535,131072.F/(n+f)))); - recomp_context ctx={0};ctx.f_odd=&ctx.f0.u32h;ctx.r29=guest(STACK); - ctx.r4=guest(CAMERA_gPerspectiveMatrixF);ctx.r5=guest(CAMERA_gPerspectiveMatrix);call(mtxf_to_mtx,&ctx); - ctx.r4=request->view;call(apply_fog,&ctx); - ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(viewport_main,&ctx); - /* DKR builds its three camera planes from authored reference points and - the INVERSE VIEW matrix, not the perspective matrix. Widen those side - points for this FOV/canvas, then restore the points after plane setup. */ - float saved_x[4];const unsigned side_offsets[]={0x30,0x3C,0x54,0x60}; - if(!isfinite(request->aspect)||request->aspect<0.1F||request->aspect>16)dkr_camera_fault("camera canvas"); - drawing.cover=fmaxf(1,request->aspect/(4.F/3.F)); - const float canvas=request->aspect*(read32(CAMERA_gViewportLayout)==1&&!request->full_screen?2.F:1.F); - const float cull_scale=fmaxf(1,tanf(request->fov*0.5F)*canvas*1.1F/1.3F); - for(unsigned i=0;i<4;++i) { - const uint32_t p=CAMERA_D_800DC8AC+side_offsets[i];uint32_t bits=read32(p); - memcpy(&saved_x[i],&bits,4);real(p,saved_x[i]*cull_scale); - } - call(func_8002A31C,&ctx); - for(unsigned i=0;i<4;++i)real(CAMERA_D_800DC8AC+side_offsets[i],saved_x[i]); - const uint32_t header=read32(CAMERA_gCurrentLevelHeader2); - ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(mtx_world_origin,&ctx); - if(read32(CAMERA_gScenePlayerViewports)==0) { - if((int8_t)read8(header+0x49)==-1)call(trackbg_render_flashy,&ctx);else call(skydome_render,&ctx); - } else { - ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(mtx_perspective,&ctx); - call(trackbg_render_gradient,&ctx); - ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(func_80067D3C,&ctx); - ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr);call(mtx_world_origin,&ctx); - } - ctx.r4=0;call(initialise_player_viewport_vars,&ctx); // No logic/animation time advance. - /* The retained world-end boundary is AFTER native weather/lens flares. - Redraw only their frozen visual state: never call weather_update, - snow_update, rain_update, rain_sound or rain_lightning. */ - ctx.r4=guest((uint32_t)-1);ctx.r5=guest((uint32_t)-512);call(weather_clip_planes,&ctx); - if((int16_t)read16(header+0x90)>0&&read32(CAMERA_gScenePlayerViewports)==0) { - put32(CAMERA_gCurrWeatherDisplayList,read32(CAMERA_gTrackDL)); - put32(CAMERA_gCurrWeatherMatrix,read32(CAMERA_gTrackMtxPtr)); - put32(CAMERA_gCurrWeatherVertexList,read32(CAMERA_gTrackVtxPtr)); - put32(CAMERA_gCurrWeatherTriList,read32(CAMERA_gTrackTriPtr)); - put32(CAMERA_gWeatherCamera,camera);call(get_camera_matrix,&ctx); - put32(CAMERA_gWeatherCameraMatrix,(uint32_t)ctx.r2); - if(read32(CAMERA_gWeatherType)==0) { - if(read32(CAMERA_gSnowParticleCount)>0) { - put32(CAMERA_gSnowVerts,read32(CAMERA_gSnowVertexData+(read32(CAMERA_gSnowVertexFlip)&1U)*4)); - call(snow_vertices,&ctx);call(snow_render,&ctx); - } - } else { - // Prevent fresh splash spawning while retaining existing splashes. - put32(CAMERA_gRainSplashDelay,1);ctx.r4=0;call(rain_render_splashes,&ctx); - if((int32_t)read32(CAMERA_gLightningFrequency)>255) { - ctx.r4=guest(CAMERA_gCurrWeatherDisplayList);ctx.r5=guest(CAMERA_gCurrWeatherMatrix);call(mtx_ortho,&ctx); - for(unsigned i=0;i<2;++i){ctx.r4=guest(CAMERA_gRainGfx+i*0x18);ctx.r5=0;call(rain_render,&ctx);} - ctx.r4=guest(CAMERA_gCurrWeatherDisplayList);call(rendermode_reset,&ctx); - ctx.r4=guest(CAMERA_gCurrWeatherDisplayList);call(viewport_reset,&ctx); - } - } - put32(CAMERA_gTrackDL,read32(CAMERA_gCurrWeatherDisplayList)); - put32(CAMERA_gTrackMtxPtr,read32(CAMERA_gCurrWeatherMatrix)); - put32(CAMERA_gTrackVtxPtr,read32(CAMERA_gCurrWeatherVertexList)); - put32(CAMERA_gTrackTriPtr,read32(CAMERA_gCurrWeatherTriList)); - } - ctx.r4=guest(camera);call(lensflare_override,&ctx); - ctx.r4=guest(CAMERA_gTrackDL);ctx.r5=guest(CAMERA_gTrackMtxPtr); - ctx.r6=guest(CAMERA_gTrackVtxPtr);ctx.r7=guest(camera);call(lensflare_render,&ctx); - const uint32_t end=read32(CAMERA_gTrackDL); - if(request->return_address<0x80000000U||request->return_address>0x807FFFF8U||(request->return_address&7)) - dkr_camera_fault("camera return command"); - put32(end,0x06010000U);put32(end+4,request->return_address&0xFFFFFFU); - result->start=COMMANDS;result->end=end+8;result->operations=drawing.operations; - drawing.active=0;fesetenv(&drawing.environment);return 1; -} diff --git a/patches/camera-plus/native/camera_kernel.h b/patches/camera-plus/native/camera_kernel.h deleted file mode 100644 index 88fae92d..00000000 --- a/patches/camera-plus/native/camera_kernel.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef DKR_CAMERA_KERNEL_H -#define DKR_CAMERA_KERNEL_H -#include -#include -enum {DKR_CAMERA_KERNEL_BYTES=0x1000000,DKR_CAMERA_MAX_MATRICES=4096}; -typedef struct dkr_camera_matrix {uint32_t address,identity,root;} dkr_camera_matrix; -typedef struct dkr_camera_draw_request { - uint32_t view,scene,return_address,hide_object,full_screen; - float position[3],yaw,pitch,roll,fov,near_plane,far_plane,aspect; -} dkr_camera_draw_request; -typedef struct dkr_camera_draw_result { - uint32_t start,end,operations,matrix_count; - const char* failure; - dkr_camera_matrix matrices[DKR_CAMERA_MAX_MATRICES]; -} dkr_camera_draw_result; -#ifdef __cplusplus -extern "C" { -#endif -int dkr_camera_v77_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*); -int dkr_camera_v80_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*); -#ifdef __cplusplus -} -#endif -#endif diff --git a/patches/camera-plus/native/camera_kernel_guest.h b/patches/camera-plus/native/camera_kernel_guest.h deleted file mode 100644 index 591b6a83..00000000 --- a/patches/camera-plus/native/camera_kernel_guest.h +++ /dev/null @@ -1,7 +0,0 @@ -#ifndef DKR_CAMERA_KERNEL_GUEST_H -#define DKR_CAMERA_KERNEL_GUEST_H -#include -void* dkr_camera_access(uint64_t address,unsigned width); -void dkr_camera_step(void); -void dkr_camera_fault(const char*); -#endif diff --git a/patches/camera-plus/native/camera_presentation.cpp b/patches/camera-plus/native/camera_presentation.cpp deleted file mode 100644 index e2bf2172..00000000 --- a/patches/camera-plus/native/camera_presentation.cpp +++ /dev/null @@ -1,180 +0,0 @@ -#include "camera_presentation.hpp" -#include -#include -#include -#include -#include - -namespace dkr::runtime::camera { -namespace { -using namespace mods::code; -[[noreturn]] void fail(const char* reason){throw mods::Error(std::string("Camera mod: ")+reason);} -template void copy_out(Memory& memory,std::uint32_t address,const T& value) { - static_assert(sizeof(T)%4==0); - for(unsigned i=0;i(&value)+i,4); - memory.store(address+i,4,word); - } -} -template T copy_in(const Memory& memory,std::uint32_t address) { - T value{}; - for(unsigned i=0;i(&value)+i,&word,4); - } - return value; -} -bool point(const float* p){return std::all_of(p,p+3,[](float x){return std::isfinite(x)&&std::abs(x)<=1.e7F;});} -template bool zero(const T& words){return std::all_of(std::begin(words),std::end(words),[](auto v){return v==0;});} -std::string text(const Memory& bus,std::uint32_t address) { - std::string out; - for(unsigned i=0;i<97;++i) { - const auto c=char(bus.load(address+i,1));if(!c)return out; - if(i==96)fail("setting name is too long"); - out+=c; - } - fail("invalid setting name"); -} -std::shared_ptr prepare(std::shared_ptr package,const Json& settings,const std::string& revision) { - if(!package||!package->modules.contains("presentation"))fail("package does not declare presentation v1"); - if(!package->dependencies.empty()||!package->tracks.empty())fail("presentation packages cannot depend on gameplay packages or tracks"); - auto linked=link({package},{{package->id,settings}},revision,Program::policy()); - if(!linked->hooks.empty()||!linked->native_functions.empty()||linked->owners.size()!=1)fail("presentation package attempted gameplay access"); - if(linked->resident_bytes>2*mods::MiB)fail("presentation package exceeds its private memory budget"); - if(linked->events.size()!=1||!linked->events.contains("*:on_presentation")||linked->events.at("*:on_presentation").size()!=1) - fail("exactly one on_presentation callback is required"); - return linked; -} -} -mods::code::LinkPolicy Program::policy() { - mods::code::LinkPolicy p; - p.modules={{"core",1},{"memory",1},{"events",1},{"settings",1},{"presentation",1}}; - p.services={"dkr_sdk_version","dkr_setting_number","dkr_view_snapshot","dkr_view_input", - "dkr_view_submit","dkr_view_query","dkr_view_math"}; - p.events={"on_presentation"}; - // No game RAM, native functions, gameplay hooks, resources containing raw - // host paths, audio, clocks, RNG or network services enter this domain. - return p; -} -Program::Program(std::shared_ptr package,mods::code::Json settings,std::string revision): - linked_(prepare(std::move(package),settings,revision)), - ram_(16*mods::MiB+linked_->resident_bytes,0),memory_(ram_,linked_->regions), - settings_(mods::code::configuration(*linked_->owners[0].package,settings)), - entry_(linked_->events.at("*:on_presentation").front()) { - mods::code::install(*linked_,ram_); -} -bool Program::settings(const mods::code::Json& values) noexcept { - if(quarantined_)return false; // Preserve the original fault diagnostic. - try {auto next=mods::code::configuration(package(),values);settings_=std::move(next);error_.clear();return true;} - catch(const std::exception& e){error_=e.what();return false;} -} -void Program::validate(const DkrViewPose& p)const { - if(p.api!=1||p.size!=sizeof(p)||p.view!=snapshot_.view||p.racer_id!=snapshot_.racer_id|| - p.racer_generation!=snapshot_.racer_generation||p.mode>DKR_VIEW_VR_DRIVER||p.cut>1|| - p.visibility>DKR_VIEW_HIDE_RIDER_ONLY||!zero(p.reserved))fail("invalid view ownership or output version"); - if(p.mode==DKR_VIEW_ORIGINAL)return; - if(!(snapshot_.capabilities&DKR_VIEW_POSE))fail("this renderer cannot submit an independent camera"); - if(!point(p.position)||!std::isfinite(p.yaw)||!std::isfinite(p.pitch)||!std::isfinite(p.roll)|| - std::abs(p.yaw)>32||std::abs(p.pitch)>32||std::abs(p.roll)>32)fail("invalid camera pose"); - float distance=0;for(unsigned i=0;i<3;++i){const auto d=p.position[i]-snapshot_.position[i];distance+=d*d;} - if(distance>1000000.F)fail("camera exceeded the local safety envelope"); - if(!std::isfinite(p.vertical_fov)||p.vertical_fov<0.174532925F||p.vertical_fov>2.61799388F|| - !std::isfinite(p.near_plane)||p.near_plane<0.1F||p.near_plane>100|| - !std::isfinite(p.far_plane)||p.far_plane<=p.near_plane||p.far_plane>1.e7F)fail("invalid camera projection"); - if(!(snapshot_.capabilities&DKR_VIEW_PROJECTION)&& - (p.vertical_fov!=snapshot_.original_fov||p.near_plane!=snapshot_.original_near||p.far_plane!=snapshot_.original_far)) - fail("independent projection is unavailable"); - if(p.visibility==DKR_VIEW_HIDE_VEHICLE&&!(snapshot_.capabilities&DKR_VIEW_HIDE_LOCAL_MODEL))fail("per-view vehicle hiding is unavailable"); - if(p.visibility==DKR_VIEW_HIDE_RIDER_ONLY&&!(snapshot_.capabilities&DKR_VIEW_HIDE_RIDER))fail("per-view rider hiding is unavailable"); - if(p.mode==DKR_VIEW_ORBIT&&(!(snapshot_.capabilities&DKR_VIEW_ORBIT_COVERAGE)|| - !(snapshot_.flags&DKR_VIEW_INSPECTION_ALLOWED)||(snapshot_.flags&DKR_VIEW_ONLINE)))fail("unrestricted inspection is unavailable in this session"); - if(p.mode==DKR_VIEW_VR_DRIVER&&!(snapshot_.capabilities&DKR_VIEW_STEREO))fail("stereoscopic headset output is unavailable"); -} -std::optional Program::frame(const DkrViewSnapshot& snapshot,const DkrViewInput& input,const Query& query) { - output_.reset();query_=nullptr;services_=queries_=0; - if(quarantined_)return {}; - bool callback_started=false; - try { - if(snapshot.api!=1||snapshot.size!=sizeof(snapshot)||snapshot.view>=4||!zero(snapshot.reserved)|| - !std::isfinite(snapshot.dt)||snapshot.dt<0||snapshot.dt>0.1F||!std::isfinite(snapshot.time)|| - !std::isfinite(snapshot.aspect)||snapshot.aspect<0.1F||snapshot.aspect>16||!std::isfinite(snapshot.speed)|| - !point(snapshot.position)||!point(snapshot.velocity)||!point(snapshot.original_position)|| - !std::isfinite(snapshot.yaw)||!std::isfinite(snapshot.pitch)||!std::isfinite(snapshot.roll)|| - !std::isfinite(snapshot.steer)||!std::isfinite(snapshot.boost)|| - !std::isfinite(snapshot.original_yaw)||!std::isfinite(snapshot.original_pitch)||!std::isfinite(snapshot.original_roll)|| - !std::isfinite(snapshot.original_fov)||!std::isfinite(snapshot.original_near)||!std::isfinite(snapshot.original_far)) - fail("invalid read-only race snapshot"); - if(input.api!=1||input.size!=sizeof(input)||!zero(input.reserved)||input.blocked>1|| - ((input.held|input.pressed|input.released)&~127U)|| - !std::isfinite(input.orbit_x)||!std::isfinite(input.orbit_y)||!std::isfinite(input.zoom)|| - std::abs(input.orbit_x)>1||std::abs(input.orbit_y)>1||std::abs(input.zoom)>1) - fail("invalid local camera action sample"); - snapshot_=snapshot;input_=input; - if(input_.blocked){input_.held=input_.pressed=0;input_.orbit_x=input_.orbit_y=input_.zoom=0;} - // Include taps latched by the host between presentation intervals. - auto& previous=held_[snapshot.view]; - input_.pressed|=input_.held&~previous;input_.released=previous&~input_.held;previous=input_.held; - if(!(snapshot.flags&DKR_VIEW_DRIVING)||(snapshot.flags&DKR_VIEW_SCRIPTED))return {}; - query_=&query; - mods::code::Registers registers; - registers.integer[29]=std::uint64_t(std::int64_t(std::int32_t(linked_->owners[0].stack))); - mods::code::Budget budget{250000,256,0}; - callback_started=true; - mods::code::execute(memory_,registers,entry_,budget,[this](auto a,auto& c,auto& b){service(a,c,b);}); - query_=nullptr;error_.clear();return output_; - }catch(const std::exception& e){ - query_=nullptr;output_.reset();error_=e.what(); - // A fault may leave half-written private smoothing state. Quarantine - // only this local provider; never retry it every frame or stop a race. - quarantined_=callback_started;return {}; - } -} -void Program::service(std::uint32_t address,mods::code::Registers& c,mods::code::Budget&) { - if(++services_>256)fail("presentation service budget exhausted"); - const auto found=linked_->imports.find(address); - if(found==linked_->imports.end()||found->second.dependency!="*"||found->second.owner!=0||found->second.event) - fail("unavailable presentation import"); - const auto& name=found->second.name; - const auto a=std::uint32_t(c.integer[4]),b=std::uint32_t(c.integer[5]),d=std::uint32_t(c.integer[6]); - if(name=="dkr_sdk_version"){c.integer[2]=1;return;} - if(name=="dkr_setting_number") { - const auto key=text(memory_,a);const auto value=settings_.find(key); - if(value==settings_.end()||!value->is_number())fail("unknown or non-numeric local setting"); - c.floating[0]=std::bit_cast(value->get());return; - } - if(name=="dkr_view_snapshot"){copy_out(memory_,a,snapshot_);c.integer[2]=1;return;} - if(name=="dkr_view_input"){copy_out(memory_,a,input_);c.integer[2]=1;return;} - if(name=="dkr_view_submit") { - if(output_)fail("two camera outputs were submitted for one view"); - const auto pose=copy_in(memory_,a);validate(pose);output_=pose;c.integer[2]=1;return; - } - if(name=="dkr_view_query") { - const auto q=copy_in(memory_,a); - if(q.api!=1||q.size!=sizeof(q)||q.view!=snapshot_.view||q.exclude_racer!=snapshot_.racer_id|| - !zero(q.reserved)||!point(q.origin)||!point(q.target)||!std::isfinite(q.radius)||q.radius<0||q.radius>128||q.include_dynamic>1) - fail("invalid camera clearance query"); - DkrViewHit hit{};hit.api=1;hit.size=sizeof(hit);hit.status=DKR_VIEW_QUERY_UNSUPPORTED; - if(++queries_>8)hit.status=DKR_VIEW_QUERY_BUDGET; - else if(query_&&*query_&&(snapshot_.capabilities&DKR_VIEW_STATIC_COLLISION)&& - (!q.include_dynamic||(snapshot_.capabilities&DKR_VIEW_DYNAMIC_COLLISION)))hit=(*query_)(q); - if(hit.api!=1||hit.size!=sizeof(hit)||hit.status>DKR_VIEW_QUERY_BUDGET||hit.reserved||hit.reserved2|| - !std::isfinite(hit.fraction)||hit.fraction<0||hit.fraction>1||!point(hit.position)||!point(hit.normal)) - fail("invalid retained collision result"); - copy_out(memory_,b,hit);c.integer[2]=hit.status;return; - } - if(name=="dkr_view_math") { - const float x=std::bit_cast(b),y=std::bit_cast(d);float result=0; - if(!std::isfinite(x)||!std::isfinite(y))fail("non-finite presentation arithmetic"); - switch(a) { - case DKR_VIEW_SIN:result=std::sin(x);break;case DKR_VIEW_COS:result=std::cos(x);break; - case DKR_VIEW_ATAN2:result=std::atan2(x,y);break; - case DKR_VIEW_EXP:if(x>20||x<-100)fail("exponential outside presentation range");result=std::exp(x);break; - case DKR_VIEW_SQRT:if(x<0)fail("negative camera distance");result=std::sqrt(x);break; - default:fail("unknown presentation arithmetic operation"); - } - if(!std::isfinite(result))fail("invalid presentation arithmetic result"); - c.integer[2]=std::bit_cast(result);return; - } - fail("unimplemented presentation service"); -} -} diff --git a/patches/camera-plus/native/camera_presentation.hpp b/patches/camera-plus/native/camera_presentation.hpp deleted file mode 100644 index 35743b26..00000000 --- a/patches/camera-plus/native/camera_presentation.hpp +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once -#include "mods/magic_code_link.hpp" -#include -#include -#include -#ifndef DKR_PRESENTATION_HOST -#define DKR_PRESENTATION_HOST -#endif -#include "dkr/presentation.h" - -namespace dkr::runtime::camera { -// The caller owns one Program on its presentation thread. It must never bind -// live guest RAM here. Only typed copies enter a callback; the query callback -// must use a retained scene, not simulation globals or mutable retail scratch. -class Program final { -public: - using Query=std::function; - Program(std::shared_ptr,mods::code::Json settings,std::string revision); - static mods::code::LinkPolicy policy(); - bool settings(const mods::code::Json&) noexcept; - std::optional frame(const DkrViewSnapshot&,const DkrViewInput&,const Query&); - const std::string& error() const {return error_;} - bool quarantined()const {return quarantined_;} - const mods::code::Package& package()const {return *linked_->owners[0].package;} -private: - std::shared_ptr linked_; - mods::Bytes ram_; - mods::code::Memory memory_; - mods::code::Json settings_; - DkrViewSnapshot snapshot_{}; - DkrViewInput input_{}; - const Query* query_=nullptr; - std::optional output_; - std::uint32_t entry_=0; - std::array held_{}; - unsigned services_=0,queries_=0; - bool quarantined_=false; - std::string error_; - void service(std::uint32_t,mods::code::Registers&,mods::code::Budget&); - void validate(const DkrViewPose&)const; -}; -} diff --git a/patches/camera-plus/native/camera_rt64.cpp b/patches/camera-plus/native/camera_rt64.cpp deleted file mode 100644 index 7076b8a8..00000000 --- a/patches/camera-plus/native/camera_rt64.cpp +++ /dev/null @@ -1,124 +0,0 @@ -#include "camera_rt64.hpp" -#include "revision_addresses.hpp" -#include -#include -#include - -namespace dkr::runtime::camera { -namespace { -std::uint32_t word(std::span bytes,std::uint32_t p) { - p&=0xFFFFFFU;if(p>0x800000U-4)return 0; - std::uint32_t out;std::memcpy(&out,bytes.data()+p,4);return out; -} -std::uint32_t racer_object(std::span bytes,rom::Revision revision,unsigned view) { - const auto& a=revision==rom::Revision::UsV77?revision_addresses::kUsV77:revision_addresses::kUsV80; - auto count=word(bytes,a.NumberOfRacers),list=word(bytes,a.RacersByPort); - if(count>8||list<0x80000000U||list>0x807FFFE0U)return 0; - for(unsigned i=0;i0x807FFF80U)continue; - auto state=word(bytes,object+0x64); - if(state<0x80000000U||state>0x807FFDDCU)continue; - std::int16_t player;std::memcpy(&player,bytes.data()+((state-0x80000000U)^2U),2); - if(player==int(view))return object; - } - return 0; -} -} -void RenderPass::prepare(std::span retained,std::span decoder, - FrameStamp stamp,std::span events,unsigned online_owner, - unsigned physical_profile,bool full_screen) { - finish();ranges_.clear();retained_=retained;decoder_=decoder;ended_=0; - const auto next=activation(); - try { - if(next!=active_){session_.reset();active_=next;failed_views_=0;if(next)session_=std::make_unique(next->frozen,next->revision,*next->settings,actions());} - if(!session_||retained.size()<0x800000||decoder.size() boundaries; - if(!events.empty()) { - for(const auto& e:events)if(e.kind==DKR_OWNED_ONLINE_VIEW||e.kind==DKR_OWNED_CAMERA_WORLD_END) { - if(!dkr_owned_draw_event_valid(&e))throw mods::Error("Invalid retained camera boundary."); - boundaries.push_back({e.address&0xFFFFFFU,e.token,e.kind==DKR_OWNED_CAMERA_WORLD_END}); - } - } else { - for(const auto& marker:presentation::camera_world_boundaries()) - boundaries.push_back({marker.address&0xFFFFFFU,marker.marker.token, - marker.marker.kind==presentation::PresentationMarkerKind::CameraWorldEnd}); - } - if(boundaries.size()>8)return; - std::sort(boundaries.begin(),boundaries.end(),[](auto a,auto b){return a.address owners; - for(std::size_t i=0;i+1=4||b.address>=e.address||e.address>0x7FFFF8U)return; - if(stamp.online&&b.view!=online_owner)continue; - const auto profile=stamp.online?physical_profile:b.view; - owners.push_back({b.view,profile,profile,stamp.aspect}); - ranges_.push_back({b.address,e.address,b.view,profile,{}}); - } - stamp.capabilities=DKR_VIEW_POSE|DKR_VIEW_PROJECTION|DKR_VIEW_REAR_COVERAGE| - DKR_VIEW_HIDE_LOCAL_MODEL|DKR_VIEW_STATIC_COLLISION; - const auto batch=session_->evaluate(retained,stamp,owners); - if(!batch.diagnostic.empty())report(batch.diagnostic); - for(auto& range:ranges_)if(batch.poses[range.view])range.pose=*batch.poses[range.view]; - ranges_.erase(std::remove_if(ranges_.begin(),ranges_.end(),[&](const Range& r){return !r.pose.api||r.pose.mode==DKR_VIEW_ORIGINAL||(failed_views_&(1U<end) { - // Decoders synchronously own/copy all geometry and TMEM bytes. Restore - // before the original HUD/weather or another player's view resumes. - std::memcpy(decoder_.data(),prepared_.data(),0x800000); - ended_=physical;drawing_=nullptr; - } - if(drawing_||ended_==physical)return; - const auto range=std::find_if(ranges_.begin(),ranges_.end(),[&](const Range& r){return r.begin==physical;}); - if(range==ranges_.end())return; - // Preserve local HUD/sky/aspect corrections and readbacks before this - // view. The immutable simulation lease remains read-only throughout. - std::memcpy(prepared_.data(),decoder_.data(),0x800000); - dkr_camera_draw_request request{};request.view=range->view;request.scene=scene_; - request.return_address=range->end|0x80000000U;request.full_screen=full_screen_;request.aspect=aspect_; - const auto& pose=range->pose; - if(pose.visibility==DKR_VIEW_HIDE_VEHICLE)request.hide_object=racer_object(retained_,active_->revision,range->view); - std::copy_n(pose.position,3,request.position);request.yaw=pose.yaw;request.pitch=pose.pitch;request.roll=pose.roll; - request.fov=pose.vertical_fov;request.near_plane=pose.near_plane;request.far_plane=pose.far_plane; - const bool okay=active_->revision==rom::Revision::UsV77? - dkr_camera_v77_draw(decoder_.data(),decoder_.size(),&request,&draw_): - dkr_camera_v80_draw(decoder_.data(),decoder_.size(),&request,&draw_); - if(!okay) { - std::memcpy(decoder_.data(),prepared_.data(),0x800000); - failed_views_|=1U<view; - report(draw_.failure?draw_.failure:"Camera scene could not be drawn.");presented(range->profile,false);return; - } - const std::uint32_t branch[2]={0x06010000U,draw_.start&0xFFFFFFU}; - std::memcpy(decoder_.data()+range->begin,branch,8);drawing_=&*range; - presented(range->profile,true); -} -bool RenderPass::replaced_command(std::uint32_t physical)const { - return (ended_&&ended_==physical) || (drawing_&&physical>=drawing_->begin&&physicalend); -} -bool RenderPass::matrix(std::uint32_t physical,presentation::MatrixInterpolation& result)const { - if(!drawing_||physical<0x900000U||physical>=0x980000U)return false; - result={}; - // No address-only vertex matching: the native draw closure can change - // topology from one camera frame to the next. - for(unsigned i=0;ipose.cut?0U:m.identity; - result.camera_root=(m.root&1U)!=0; - result.procedural_water=(m.root&2U)!=0; - result.interpolate_vertices=result.interpolate_texcoords=result.interpolate_tiles=result.procedural_water; - break; - } - return true; -} -void RenderPass::finish()noexcept { - if(drawing_&&decoder_.size()>=0x800000&&prepared_.size()>=0x800000) - std::memcpy(decoder_.data(),prepared_.data(),0x800000); - drawing_=nullptr;ended_=0; -} -} diff --git a/patches/camera-plus/native/camera_rt64.hpp b/patches/camera-plus/native/camera_rt64.hpp deleted file mode 100644 index f993af61..00000000 --- a/patches/camera-plus/native/camera_rt64.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once -#include "camera_runtime.hpp" -#include "camera_kernel.h" -#include "presentation_identity.hpp" -#include "netplay/experimental_draw_events.h" - -namespace dkr::runtime::camera { -// One decoder owner, reused sequentially for all local views. Never borrows -// live CPU memory and never creates another rollback DecodeWorkspace. -class RenderPass final { -public: - void prepare(std::span retained,std::span decoder, - FrameStamp,std::span events={},unsigned online_owner=4, - unsigned physical_profile=0,bool full_screen=false); - void before_command(std::uint32_t physical); - bool replaced_command(std::uint32_t physical)const; - bool matrix(std::uint32_t physical,presentation::MatrixInterpolation&)const; - bool enabled()const{return !ranges_.empty();} - void finish()noexcept; -private: - struct Range {std::uint32_t begin,end;unsigned view,profile;DkrViewPose pose;}; - std::shared_ptr active_; - std::unique_ptr session_; - std::span retained_; - std::span decoder_; - // Immutable restoration journal for the prepared retail bytes, not - // another mutable DecodeWorkspace or an eye/simulation image. - std::vector prepared_; - std::vector ranges_; - dkr_camera_draw_result draw_{}; - const Range* drawing_=nullptr; - std::uint32_t ended_=0,scene_=0; - std::uint32_t failed_scene_=0,failed_views_=0; - bool full_screen_=false; - float aspect_=4.F/3.F; -}; -} diff --git a/patches/camera-plus/native/camera_runtime.cpp b/patches/camera-plus/native/camera_runtime.cpp deleted file mode 100644 index a92c8bf6..00000000 --- a/patches/camera-plus/native/camera_runtime.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include "camera_runtime.hpp" -#include -#include - -namespace dkr::runtime::camera { -namespace { -AtomicSnapshot current; -Actions input; -std::array,4> owned{}; -std::mutex error_mutex; -std::string error; -} -std::shared_ptr activation(){return current.load();} -Actions& actions(){return input;} -std::string diagnostic(){std::scoped_lock lock(error_mutex);return error;} -void report(const std::string& message) { - std::scoped_lock lock(error_mutex); - if(error!=message&&!message.empty())std::fprintf(stderr,"[camera++][fallback] %s\n",message.c_str()); - error=message; -} -void begin(const std::filesystem::path& config,const std::string& version,rom::Revision revision,bool online)noexcept { - reset(); - try { - mods::code::Library library(config/"mods"/"custom-magic-codes",version); - auto set=library.freeze(revision==rom::Revision::UsV77?"us.v77":"us.v80",false,Program::policy().modules,{},true); - if(set.ordered.empty())return; - if(set.ordered.size()!=1)throw mods::Error("Enable only one camera mod."); - auto next=std::make_shared();next->frozen=std::move(set);next->revision=revision;next->online=online; - next->settings=std::make_shared(); - const auto& package=next->frozen.ordered[0]; - next->editor=std::make_shared(package,next->frozen.settings.at(package->id), - next->frozen.data.at(package->id),*next->settings); - // Link/validate without executing. Rejection only disables the camera, - // never the shared race or its save route. - Program checked(package,next->frozen.settings.at(package->id),revision==rom::Revision::UsV77?"us.v77":"us.v80"); - current.store(std::move(next)); - std::fprintf(stderr,"[camera++][ready] provider=%s domain=local-presentation online=%u\n",package->id.c_str(),unsigned(online)); - }catch(const std::exception& e){report(e.what());} -} -void reset()noexcept { - current.store(nullptr);editor_open.store(false);input.reset(); - for(auto& port:owned)port.store(0); - std::scoped_lock lock(error_mutex);error.clear(); -} -void presented(unsigned profile,bool active) { - if(profile<4)owned[profile].store(active?(std::chrono::steady_clock::now()+std::chrono::milliseconds(300)).time_since_epoch().count():0); -} -bool owns_controls(unsigned profile) { - return profile<4&&!editor_open.load()&&owned[profile].load()>std::chrono::steady_clock::now().time_since_epoch().count(); -} -} diff --git a/patches/camera-plus/native/camera_runtime.hpp b/patches/camera-plus/native/camera_runtime.hpp deleted file mode 100644 index 5727668d..00000000 --- a/patches/camera-plus/native/camera_runtime.hpp +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -#include "camera_session.hpp" -#include -#include - -namespace dkr::runtime::camera { -struct Activation { - mods::code::FrozenSet frozen; - std::shared_ptr settings; - std::shared_ptr editor; - rom::Revision revision=rom::Revision::Unsupported; - bool online=false; -}; -void begin(const std::filesystem::path& config,const std::string& version,rom::Revision,bool online)noexcept; -void reset()noexcept; -std::shared_ptr activation(); -Actions& actions(); -bool owns_controls(unsigned physical_profile); -void presented(unsigned physical_profile,bool active); -std::string diagnostic(); -void report(const std::string&); -inline std::atomic editor_open{false}; -} diff --git a/patches/camera-plus/native/camera_scene.cpp b/patches/camera-plus/native/camera_scene.cpp deleted file mode 100644 index 28997e54..00000000 --- a/patches/camera-plus/native/camera_scene.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include "camera_scene.hpp" -#include "camera_obstruction_math.h" -#include -#include -#include - -namespace dkr::runtime::camera { -namespace { -struct Reader { - std::span ram; - std::uint32_t offset(std::uint32_t address,std::size_t count,unsigned alignment)const { - if(address<0x80000000U||address>=0x80800000U||(address&(alignment-1)))throw mods::Error("Invalid retained camera-scene address."); - auto p=address-0x80000000U; - if(p>ram.size()||count>ram.size()-p||count>0x800000U-p)throw mods::Error("Retained camera-scene span exceeds RDRAM."); - return p; - } - std::uint32_t word(std::uint32_t a)const{std::uint32_t v;auto p=offset(a,4,4);std::memcpy(&v,ram.data()+p,4);return v;} - unsigned half(std::uint32_t a)const{std::uint16_t v;auto p=offset(a,2,2);std::memcpy(&v,ram.data()+(p^2U),2);return v;} - unsigned byte(std::uint32_t a)const{return ram[offset(a,1,1)^3U];} - std::array vertex(std::uint32_t a)const { - return {float(std::int16_t(half(a))),float(std::int16_t(half(a+2))),float(std::int16_t(half(a+4)))}; - } -}; -dkr_cam_vec vec(const float* p){return dkr_cam_v(p[0],p[1],p[2]);} -bool finite(const float* p){return std::all_of(p,p+3,[](float x){return std::isfinite(x)&&std::abs(x)<=1.e7F;});} -} -std::shared_ptr CollisionScene::capture(std::span snapshot, - std::uint32_t model,std::uint64_t generation) { - if(snapshot.size()<0x800000U||snapshot.size()%4)throw mods::Error("Camera scene requires an immutable complete retail image."); - Reader r{snapshot};r.offset(model,0x40,4); - const auto count=r.half(model+0x1A),textures=r.half(model+0x18),segments=r.word(model+4),table=r.word(model); - if(!count||count>512||textures>256)throw mods::Error("Camera-scene model exceeds its static capacity."); - r.offset(segments,count*0x44,4);if(textures)r.offset(table,textures*8,4); - auto result=std::shared_ptr(new CollisionScene);result->generation_=generation; - for(unsigned segment=0;segment32767||fc>32767||bc>4096)throw mods::Error("Camera-scene segment exceeds its bounds."); - r.offset(vertices,vc*10,2);r.offset(faces,fc*16,4);r.offset(batches,(bc+1)*12,4); - for(unsigned batch=0;batchend||end>fc||base>vc)throw mods::Error("Invalid camera-scene batch range."); - if(texture!=255&&texture>=textures)throw mods::Error("Invalid camera-scene surface."); - const auto surface=texture==255?255:r.byte(table+texture*8+7); - if(!dkr_cam_solid_batch(r.word(b+8),int(surface)))continue; - Chunk chunk{{1.e9F,1.e9F,1.e9F},{-1.e9F,-1.e9F,-1.e9F},std::uint32_t(result->triangles_.size()),0}; - for(unsigned face=first;facetriangles_.size()>=131072)throw mods::Error("Camera-scene memory budget exceeded."); - Triangle triangle;std::array* corner[]{&triangle.a,&triangle.b,&triangle.c}; - for(unsigned i=0;i<3;++i) { - const auto index=base+r.byte(f+i+1);if(index>=vc)throw mods::Error("Invalid camera-scene triangle vertex."); - *corner[i]=r.vertex(vertices+index*10); - for(unsigned axis=0;axis<3;++axis) { - chunk.lo[axis]=std::min(chunk.lo[axis],(*corner[i])[axis]); - chunk.hi[axis]=std::max(chunk.hi[axis],(*corner[i])[axis]); - } - } - result->triangles_.push_back(triangle);++chunk.count; - } - if(chunk.count) { - if(result->chunks_.size()>=16384)throw mods::Error("Camera-scene batch budget exceeded."); - result->chunks_.push_back(chunk); - } - } - } - return result; -} -std::size_t CollisionScene::bytes()const{return triangles_.size()*sizeof(Triangle)+chunks_.size()*sizeof(Chunk);} -DkrViewHit CollisionScene::query(const DkrViewQuery& q,unsigned budget)const { - DkrViewHit out{};out.api=1;out.size=sizeof(out);out.status=DKR_VIEW_QUERY_UNSUPPORTED; - if(q.api!=1||q.size!=sizeof(q)||q.include_dynamic||!finite(q.origin)||!finite(q.target)|| - !std::isfinite(q.radius)||q.radius<0||q.radius>128||q.reserved[0]||q.reserved[1])return out; - out.status=DKR_VIEW_QUERY_NONE;out.fraction=1; - std::copy_n(q.target,3,out.position); - std::array lo,hi; - for(unsigned i=0;i<3;++i){lo[i]=std::min(q.origin[i],q.target[i])-q.radius;hi[i]=std::max(q.origin[i],q.target[i])+q.radius;} - double best=2;const Triangle* selected=nullptr; - for(const auto& chunk:chunks_) { - bool overlap=true;for(unsigned i=0;i<3;++i)overlap&=lo[i]<=chunk.hi[i]&&hi[i]>=chunk.lo[i]; - if(!overlap)continue; - for(unsigned i=0;ib.data()),vec(selected->a.data())),dkr_cam_sub(vec(selected->c.data()),vec(selected->a.data()))); - const auto length=std::sqrt(dkr_cam_dot(normal,normal)); - if(length>1.e-8) { - normal=dkr_cam_scale(normal,1/length); - if(dkr_cam_dot(normal,dkr_cam_sub(vec(q.origin),vec(selected->a.data())))<0)normal=dkr_cam_scale(normal,-1); - out.normal[0]=float(normal.x);out.normal[1]=float(normal.y);out.normal[2]=float(normal.z); - } - } - return out; -} -} diff --git a/patches/camera-plus/native/camera_scene.hpp b/patches/camera-plus/native/camera_scene.hpp deleted file mode 100644 index fc4f21b4..00000000 --- a/patches/camera-plus/native/camera_scene.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include "camera_presentation.hpp" -#include -#include - -namespace dkr::runtime::camera { -// Immutable copied STATIC collision geometry. No VM or graphics decoder owns -// a reference to the producer's RAM. Dynamic doors/platforms are intentionally -// not claimed by this adapter; callers must not set the dynamic capability. -class CollisionScene final { -public: - static std::shared_ptr capture(std::span snapshot, - std::uint32_t level_model,std::uint64_t scene_generation); - DkrViewHit query(const DkrViewQuery&,unsigned face_budget=8192)const; - std::uint64_t generation()const {return generation_;} - std::size_t bytes()const; -private: - struct Triangle {std::array a,b,c;}; - struct Chunk {std::array lo,hi;std::uint32_t first,count;}; - std::vector triangles_; - std::vector chunks_; - std::uint64_t generation_=0; -}; -} diff --git a/patches/camera-plus/native/camera_session.cpp b/patches/camera-plus/native/camera_session.cpp deleted file mode 100644 index 46935727..00000000 --- a/patches/camera-plus/native/camera_session.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include "camera_session.hpp" -#include -#include - -namespace dkr::runtime::camera { -PresentationSession::PresentationSession(const mods::code::FrozenSet& selected,rom::Revision revision, - const SettingsChannel& settings,Actions& actions):revision_(revision),settings_(settings),actions_(actions) { - if(selected.ordered.empty())return; // Original DKR, no local provider. - if(selected.ordered.size()!=1)throw mods::Error("Enable only one camera provider."); - if(revision!=rom::Revision::UsV77&&revision!=rom::Revision::UsV80) - throw mods::Error("Camera presentation does not support this Game Pak."); - const auto& package=selected.ordered[0]; - const auto configured=selected.settings.find(package->id); - program_=std::make_unique(package,configured==selected.settings.end()?Json::object():configured->second, - revision==rom::Revision::UsV77?"us.v77":"us.v80"); -} -ViewBatch PresentationSession::evaluate(std::span retained,const FrameStamp& stamp, - std::span owners) { - ViewBatch batch; - if(!program_||owners.size()>4)return batch; - if(program_->quarantined()){batch.diagnostic=program_->error();return batch;} - try { - const auto configuration=settings_.current(); - if(!configuration)throw mods::Error("Camera profiles are not ready."); - unsigned seen_views=0,seen_physical=0; - for(const auto& owner:owners) { - if(owner.view>=4||owner.physical_profile>=4||owner.settings_profile>=4|| - !std::isfinite(owner.aspect)||owner.aspect<0.1F||owner.aspect>16|| - (seen_views&(1U< inputs{}; - bool driving=false; - for(const auto& owner:owners) { - frame.aspect=owner.aspect; - inputs[owner.view]=actions_.consume(owner.physical_profile,frame.dt); - auto state=reader_.capture(retained,revision_,owner.view,frame); - batch.snapshots[owner.view]=state; - driving|=state&&(state->flags&DKR_VIEW_DRIVING)&&!(state->flags&DKR_VIEW_SCRIPTED); - } - // No mesh rebuilding or mod execution in Track Select/title menus. - // Consume their local taps instead of replaying them in the race. - if(!driving)return batch; - if(frame.capabilities&DKR_VIEW_STATIC_COLLISION) { - if(retained.size()<0x800000U)throw mods::Error("Camera presentation requires a complete retained image."); - const auto address=revision_==rom::Revision::UsV77?0x800DC918U:0x800DCE88U; - std::uint32_t model;std::memcpy(&model,retained.data()+address-0x80000000U,4); - if(!scene_attempted_||scene_!=stamp.scene||epoch_!=stamp.epoch||model_!=model) { - collision_.reset();scene_error_.clear();scene_attempted_=true; - scene_=stamp.scene;epoch_=stamp.epoch;model_=model; - try {collision_=CollisionScene::capture(retained,model,(std::uint64_t(epoch_)<<32U)|scene_);} - catch(const std::exception& e){scene_error_=e.what();} - } - if(!collision_)frame.capabilities&=~std::uint32_t(DKR_VIEW_STATIC_COLLISION); - } - // This implementation has no retained moving-platform/door adapter. - // Never accidentally promote static queries to dynamic capability. - frame.capabilities&=~std::uint32_t(DKR_VIEW_DYNAMIC_COLLISION); - const Program::Query query=[scene=collision_](const DkrViewQuery& request) { - if(scene)return scene->query(request); - DkrViewHit hit{};hit.api=1;hit.size=sizeof(hit);hit.status=DKR_VIEW_QUERY_UNSUPPORTED;return hit; - }; - for(const auto& owner:owners) { - frame.aspect=owner.aspect; - auto state=batch.snapshots[owner.view]; - if(!state)continue; // No racer/menu/scripted fallback, not a fault. - state->capabilities=frame.capabilities; - batch.snapshots[owner.view]=state; - auto input=inputs[owner.view]; - batch.xr_recenter|=(input.pressed&DKR_VIEW_XR_RECENTER)!=0; - if(!program_->settings(configuration->views[owner.settings_profile])) - throw mods::Error(program_->error()); - batch.poses[owner.view]=program_->frame(*state,input,query); - if(program_->quarantined()) { - batch.poses={};batch.diagnostic=program_->error();return batch; - } - } - batch.diagnostic=scene_error_; - }catch(const std::exception& e){batch.poses={};batch.diagnostic=e.what();} - return batch; -} -} diff --git a/patches/camera-plus/native/camera_session.hpp b/patches/camera-plus/native/camera_session.hpp deleted file mode 100644 index 9e6c8905..00000000 --- a/patches/camera-plus/native/camera_session.hpp +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once -#include "camera_settings.hpp" -#include "camera_actions.hpp" -#include "camera_snapshot.hpp" -#include "camera_scene.hpp" -#include "mods/magic_code_library.hpp" - -namespace dkr::runtime::camera { -struct ViewOwner { - unsigned view=0; // Actual native racer/controller identity. - unsigned physical_profile=0; // Device owner; not assumed to equal view. - unsigned settings_profile=0; // Local saved profile; never a remote slot. - float aspect=4.F/3.F; -}; -struct ViewBatch { - std::array,4> poses; - std::array,4> snapshots; - bool xr_recenter=false; - std::string diagnostic; -}; -// Presentation-thread owner, one local program and one state set per session. -// evaluate() reads a retained image, never the live/rewindable CPU. Evaluate -// ONCE before rendering a batch; both eyes consume the same submitted anchor. -// No mutable decoder is created by this owner and no GPU lifetime is changed. -class PresentationSession final { -public: - PresentationSession(const mods::code::FrozenSet& presentation,rom::Revision, - const SettingsChannel&,Actions&); - ViewBatch evaluate(std::span retained,const FrameStamp&, - std::span owners); - bool active()const{return bool(program_)&&!program_->quarantined();} -private: - rom::Revision revision_; - const SettingsChannel& settings_; - Actions& actions_; - SnapshotReader reader_; - std::unique_ptr program_; - std::shared_ptr collision_; - std::uint32_t scene_=0,epoch_=0,model_=0; - bool scene_attempted_=false; - std::string scene_error_; -}; -} diff --git a/patches/camera-plus/native/camera_settings.cpp b/patches/camera-plus/native/camera_settings.cpp deleted file mode 100644 index ca84928f..00000000 --- a/patches/camera-plus/native/camera_settings.cpp +++ /dev/null @@ -1,111 +0,0 @@ -#include "camera_settings.hpp" -#include -#include - -namespace dkr::runtime::camera { -namespace { -[[noreturn]] void invalid(const char* text){throw mods::Error(std::string("Camera settings: ")+text);} -std::string label(const Json& value,const char* key) { - const auto& text=value.at(key); - if(!text.is_string()||text.get_ref().empty()|| - text.get_ref().size()>160||text.get_ref().find('\0')!=std::string::npos) - invalid("invalid editor label"); - return text.get(); -} -} -void SettingsChannel::publish(const std::array& views) { - auto next=std::make_shared();next->views=views; - if(++generation_==0)++generation_; - next->generation=generation_;current_.store(std::move(next)); -} -SettingsEditor::SettingsEditor(std::shared_ptr package,const Json& base, - const Json& documents,SettingsChannel& channel):package_(std::move(package)),channel_(channel), - documents_(mods::code::structured_data(documents)) { - if(!package_||!package_->modules.contains("presentation"))invalid("not a presentation package"); - const auto defaults=mods::code::configuration(*package_,Json::object()); - for(auto& view:draft_)view=mods::code::configuration(*package_,base); - if(documents_.contains("camera_profiles"))draft_=decode(documents_.at("camera_profiles")); - applied_=saved_=draft_; - const auto resource=package_->resources.find("camera_editor"); - if(resource==package_->resources.end())invalid("missing editor schema"); - const auto schema=mods::code::parse_document(resource->second,128*1024); - if(!schema.contains("groups")||!schema["groups"].is_array()||schema["groups"].size()>16) - invalid("invalid editor groups"); - std::map options; - for(const auto& option:package_->options)options.emplace(option.at("id").get(),option); - std::set seen; - for(const auto& group:schema["groups"]) { - EditorGroup entry;entry.name=label(group,"name"); - const auto& fields=group.at("options"); - if(!fields.is_array()||fields.empty()||fields.size()>128)invalid("invalid group options"); - for(const auto& field:fields) { - EditorField out;out.id=label(field,"id");out.name=label(field,"name"); - const auto found=options.find(out.id); - if(found==options.end()||!seen.insert(out.id).second)invalid("unknown or duplicate editor option"); - const auto& canonical=found->second;out.type=canonical.at("type").get(); - if(field.value("type",std::string("Number"))!=out.type)invalid("editor type differs from executable manifest"); - if(mods::code::configuration(*package_,Json{{out.id,field.at("default")}}).at(out.id)!=defaults.at(out.id)) - invalid("editor default differs from executable manifest"); - if(out.type=="Number") { - out.minimum=canonical.at("min").get();out.maximum=canonical.at("max").get(); - if(field.at("min")!=canonical.at("min")||field.at("max")!=canonical.at("max")) - invalid("editor bounds differ from executable manifest"); - out.step=field.value("step",out.maximum-out.minimum<=2?0.05:out.maximum-out.minimum<=10?0.1:1.0); - if(!std::isfinite(out.step)||out.step<=0||out.step>1.e7)invalid("invalid editor step"); - } else if(out.type=="Enum") { - if(field.at("options")!=canonical.at("options"))invalid("editor choices differ from executable manifest"); - out.choices=canonical.at("options").get>(); - } else if(out.type!="String")invalid("unsupported editor field type"); - entry.fields.push_back(std::move(out)); - } - groups_.push_back(std::move(entry)); - } - if(seen.size()!=options.size())invalid("editor omits executable settings"); - const auto presets=schema.value("presets",Json::array()); - if(!presets.is_array()||presets.size()>32)invalid("invalid presets"); - std::set names; - for(const auto& preset:presets) { - EditorPreset out;out.name=label(preset,"name"); - if(!names.insert(out.name).second)invalid("duplicate preset name"); - out.defaults=preset.value("defaults",false);out.values=preset.at("values"); - (void)mods::code::configuration(*package_,out.values); - presets_.push_back(std::move(out)); - } - channel_.publish(applied_); -} -std::array SettingsEditor::decode(const Json& document)const { - if(!document.is_object()||document.value("schema",0)!=1||!document.contains("views")|| - !document["views"].is_array()||document["views"].size()!=4)invalid("invalid saved view profiles"); - std::array profiles; - for(unsigned i=0;i<4;++i)profiles[i]=mods::code::configuration(*package_,document["views"][i]); - return profiles; -} -bool SettingsEditor::edit(unsigned view,std::string_view id,const Json& value) { - try { - if(view>=4)invalid("invalid local player"); - auto next=draft_[view];next[std::string(id)]=value; - next=mods::code::configuration(*package_,next);draft_[view]=std::move(next);error_.clear();return true; - }catch(const std::exception& e){error_=e.what();return false;} -} -bool SettingsEditor::preset(unsigned view,unsigned index) { - try { - if(view>=4||index>=presets_.size())invalid("invalid preset selection"); - const auto& preset=presets_[index]; - auto next=preset.defaults?mods::code::configuration(*package_,Json::object()):draft_[view]; - for(const auto& [key,value]:preset.values.items())next[key]=value; - draft_[view]=mods::code::configuration(*package_,next);error_.clear();return true; - }catch(const std::exception& e){error_=e.what();return false;} -} -void SettingsEditor::reset(unsigned view) {draft_.at(view)=mods::code::configuration(*package_,Json::object());} -void SettingsEditor::apply() {channel_.publish(draft_);applied_=draft_;error_.clear();} -void SettingsEditor::revert() {draft_=saved_;apply();} -Json SettingsEditor::save_document()const { - auto out=documents_;out["camera_profiles"]={{"schema",1},{"views",applied_}}; - return mods::code::structured_data(out); -} -void SettingsEditor::saved(const Json& document) { - auto checked=mods::code::structured_data(document); - auto profiles=decode(checked.at("camera_profiles")); - documents_=std::move(checked);saved_=std::move(profiles); -} -} diff --git a/patches/camera-plus/native/camera_settings.hpp b/patches/camera-plus/native/camera_settings.hpp deleted file mode 100644 index a390a204..00000000 --- a/patches/camera-plus/native/camera_settings.hpp +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once -#include "camera_presentation.hpp" -#include "atomic_snapshot.hpp" -#include -#include - -namespace dkr::runtime::camera { -using Json=mods::code::Json; -struct EditorField { - std::string id,name,type; - double minimum=0,maximum=0,step=1; - std::vector choices; -}; -struct EditorGroup {std::string name;std::vector fields;}; -struct EditorPreset {std::string name;bool defaults=false;Json values;}; -struct LocalConfiguration { - std::uint64_t generation=0; - std::array views; -}; -// One UI writer publishes immutable configurations; the presentation owner -// acquires one for a whole view batch, never halfway through a stereo pair. -class SettingsChannel final { -public: - std::shared_ptr current()const{return current_.load();} - void publish(const std::array&); -private: - std::uint64_t generation_=0; - AtomicSnapshot current_; -}; -// Generic presentation editor model. Storage belongs to LibraryJobs, never -// this model or the guest mod. No filesystem work occurs while drawing UI. -class SettingsEditor final { -public: - SettingsEditor(std::shared_ptr,const Json& base,const Json& documents, - SettingsChannel&); - const std::vector& groups()const{return groups_;} - const std::vector& presets()const{return presets_;} - const Json& draft(unsigned view)const{return draft_.at(view);} - bool edit(unsigned view,std::string_view id,const Json&); - bool preset(unsigned view,unsigned preset); - void reset(unsigned view); - void revert(); - void apply(); - bool pending()const{return draft_!=applied_;} - bool unsaved()const{return applied_!=saved_;} - const std::string& error()const{return error_;} - // Schedule LibraryJobs::data with this document. Acknowledge ONLY after - // successful storage, using this exact captured document. Newer edits - // made while the worker ran must remain correctly marked unsaved. - Json save_document()const; - void saved(const Json& exact_document); -private: - std::shared_ptr package_; - SettingsChannel& channel_; - Json documents_; - std::array draft_,applied_,saved_; - std::vector groups_; - std::vector presets_; - std::string error_; - std::array decode(const Json&)const; -}; -} diff --git a/patches/camera-plus/native/camera_snapshot.cpp b/patches/camera-plus/native/camera_snapshot.cpp deleted file mode 100644 index 6e7aca33..00000000 --- a/patches/camera-plus/native/camera_snapshot.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include "camera_snapshot.hpp" -#include "revision_addresses.hpp" -#include "camera_clearance_metadata.h" -#include -#include -#include -#include - -namespace dkr::runtime::camera { -namespace { -constexpr float Pi=3.14159265358979323846F; -struct Reader { - std::span ram; - std::uint32_t offset(std::uint32_t address,unsigned count,unsigned alignment)const { - if(address<0x80000000U||address>=0x80800000U||(address&(alignment-1)))throw mods::Error("Invalid camera snapshot address."); - auto off=address-0x80000000U; - if(off>ram.size()||count>ram.size()-off||count>0x800000U-off)throw mods::Error("Camera snapshot span exceeds retail memory."); - return off; - } - std::uint32_t word(std::uint32_t address)const{auto off=offset(address,4,4);std::uint32_t value;std::memcpy(&value,ram.data()+off,4);return value;} - unsigned half(std::uint32_t address)const{auto off=offset(address,2,2);std::uint16_t value;std::memcpy(&value,ram.data()+(off^2U),2);return value;} - unsigned byte(std::uint32_t address)const{return ram[offset(address,1,1)^3U];} - float real(std::uint32_t address)const{auto value=std::bit_cast(word(address));if(!std::isfinite(value))throw mods::Error("Non-finite camera snapshot value.");return value;} - float angle(std::uint32_t address)const{return float(std::int16_t(half(address)))*(Pi/32768.F);} -}; -float wrap(float value){return std::remainder(value,2*Pi);} -} -std::optional SnapshotReader::capture(std::span ram, - rom::Revision revision,unsigned view,const FrameStamp& frame) { - if(view>=4||ram.size()<0x800000U||ram.size()%4|| - (revision!=rom::Revision::UsV77&&revision!=rom::Revision::UsV80)|| - !std::isfinite(frame.dt)||frame.dt<0||frame.dt>0.1F||!std::isfinite(frame.time)|| - !std::isfinite(frame.aspect)||frame.aspect<0.1F||frame.aspect>16)return {}; - try { - Reader r{ram};const auto& table=revision==rom::Revision::UsV77?revision_addresses::kUsV77:revision_addresses::kUsV80; - const auto count=r.word(table.NumberOfRacers),list=r.word(table.RacersByPort); - if(!count||count>8)return {}; - r.offset(list,count*4,4); - std::uint32_t object=0,racer=0; - // Lists can be reordered by race position and custom rosters. Match - // the actual native player identity, not list entry zero/the host. - for(unsigned i=0;i0?1.F:0.F; - out.original_fov=r.real(table.CurrentCameraFov)*Pi/180.F; - // Verified decomp CAMERA_NEAR / CAMERA_FAR, unchanged by the SDK. - out.original_near=10;out.original_far=15000; - const auto mode=r.half(camera+0x36),layout=r.word(table.ViewportLayout); - const auto dialogue=revision==rom::Revision::UsV77?0x8011D586U:0x8011DB06U; - const bool scripted=r.byte(table.CutsceneCameraActive)||r.half(dialogue); - if(scripted)out.flags|=DKR_VIEW_SCRIPTED; - if(!scripted&&dkr_world_projection_eligible(std::int32_t(r.word(table.GameMode)),r.byte(header+0x4C),layout,1)&& - (mode==0||mode==1||mode==4)&&out.vehicle<=2)out.flags|=DKR_VIEW_DRIVING; - if(std::int8_t(r.byte(racer+0x1E2))>0||std::int8_t(r.byte(racer+0x1E5))>0||out.vehicle==2)out.flags|=DKR_VIEW_GROUNDED; - if(std::int8_t(r.byte(racer+0x1E6)))out.flags|=DKR_VIEW_DRIFTING; - if(out.boost>0)out.flags|=DKR_VIEW_BOOSTING; - if(std::abs(r.real(camera+0x30))>0.1F)out.flags|=DKR_VIEW_IMPACT; - if(frame.online)out.flags|=DKR_VIEW_ONLINE; - if(frame.inspection&&!frame.online)out.flags|=DKR_VIEW_INSPECTION_ALLOWED; - if(changed||frame.cut||frame.frame - -namespace dkr::runtime::camera { -struct FrameStamp { - std::uint32_t scene=0,epoch=0,frame=0; - float time=0,dt=0,aspect=4.F/3.F; - std::uint32_t capabilities=0; - bool online=false,cut=false,inspection=false; -}; -// Reads an ALREADY RETAINED image, never the live CPU's concurrent RAM. The -// normal CPU and rollback producer must publish at their own safe boundaries. -// Binding identities are presentation-local and reacquired from the roster. -class SnapshotReader final { -public: - std::optional capture(std::span retained, - rom::Revision,unsigned view,const FrameStamp&); -private: - struct Binding {std::uint32_t object=0,racer=0,scene=0,epoch=0,generation=0,frame=0;}; - std::array bindings_{}; -}; -} diff --git a/patches/camera-plus/native/camera_xr.cpp b/patches/camera-plus/native/camera_xr.cpp deleted file mode 100644 index 59ca7015..00000000 --- a/patches/camera-plus/native/camera_xr.cpp +++ /dev/null @@ -1,419 +0,0 @@ -#include "camera_xr.hpp" -#include "plume_vulkan.h" -#define XR_USE_GRAPHICS_API_VULKAN -#include -#include -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::camera { -namespace { -void check(XrResult value,const char* operation) { - if(XR_FAILED(value))throw mods::Error(std::string(operation)+" failed (OpenXR "+std::to_string(value)+")."); -} -XrQuaternionf multiply(XrQuaternionf a,XrQuaternionf b) { - return {a.w*b.x+a.x*b.w+a.y*b.z-a.z*b.y, - a.w*b.y-a.x*b.z+a.y*b.w+a.z*b.x, - a.w*b.z+a.x*b.y-a.y*b.x+a.z*b.w, - a.w*b.w-a.x*b.x-a.y*b.y-a.z*b.z}; -} -XrVector3f rotate(XrQuaternionf q,XrVector3f p) { - auto r=multiply(multiply(q,{p.x,p.y,p.z,0}),{-q.x,-q.y,-q.z,q.w});return {r.x,r.y,r.z}; -} -XrQuaternionf rotation(const DkrViewPose& p) { - const float y=p.yaw*0.5F,x=-p.pitch*0.5F,z=p.roll*0.5F; - return multiply(multiply({0,std::sin(y),0,std::cos(y)},{std::sin(x),0,0,std::cos(x)}),{0,0,std::sin(z),std::cos(z)}); -} -bool finite(const DkrViewPose& p,float scale) { - for(float v:p.position)if(!std::isfinite(v)||std::abs(v)>1.e7F)return false; - return std::isfinite(p.yaw)&&std::isfinite(p.pitch)&&std::isfinite(p.roll)&& - std::isfinite(p.near_plane)&&p.near_plane>=0.1F&&std::isfinite(p.far_plane)&&p.far_plane>p.near_plane&& - std::isfinite(scale)&&scale>=1&&scale<=1000; -} -bool valid_pose(const XrPosef& pose) { - const auto& q=pose.orientation;const auto& p=pose.position; - const float length=q.x*q.x+q.y*q.y+q.z*q.z+q.w*q.w; - return std::isfinite(length)&&std::abs(length-1.F)<0.01F&& - std::isfinite(p.x)&&std::isfinite(p.y)&&std::isfinite(p.z)&& - std::abs(p.x)<1000&&std::abs(p.y)<1000&&std::abs(p.z)<1000; -} -bool valid_fov(const XrFovf& fov) { - constexpr float limit=1.57079632679F; - return std::isfinite(fov.angleLeft)&&std::isfinite(fov.angleRight)&& - std::isfinite(fov.angleUp)&&std::isfinite(fov.angleDown)&& - fov.angleLeft-limit&&fov.angleRight-limit&&fov.angleUp recenter_requested{true}; - std::string error; - XrGraphicsRequirementsVulkanKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN_KHR}; - PFN_xrGetVulkanInstanceExtensionsKHR instance_extensions=nullptr; - PFN_xrGetVulkanDeviceExtensionsKHR device_extensions=nullptr; - PFN_xrGetVulkanGraphicsDeviceKHR physical_device=nullptr; - PFN_xrGetVulkanGraphicsDevice2KHR physical_device2=nullptr; - PFN_xrCreateVulkanInstanceKHR create_instance=nullptr; - PFN_xrCreateVulkanDeviceKHR create_device=nullptr; - plume::VulkanDevice* device=nullptr; - plume::VulkanCommandQueue* queue=nullptr; - VkInstance selected_instance=VK_NULL_HANDLE; - VkPhysicalDevice selected_device=VK_NULL_HANDLE; - struct Chain { - XrSwapchain handle=XR_NULL_HANDLE; - unsigned width=0,height=0,index=0; - bool acquired=false,waited=false; - std::vector> images; - }; - std::array chains; - - void note(const std::string& value) noexcept { - try {if(log)log("[camera++][openxr] "+value);}catch(...){} - } - VkResult vulkan_result(XrResult xr,VkResult vk,const char* operation) { - if(XR_SUCCEEDED(xr))return vk; - error=std::string(operation)+" failed (OpenXR "+std::to_string(xr)+")."; - note(error);return VK_ERROR_INITIALIZATION_FAILED; - } - - template T function(const char* name) { - PFN_xrVoidFunction function=nullptr;check(xrGetInstanceProcAddr(instance,name,&function),name); - if(!function)throw mods::Error(std::string("Missing OpenXR entry point: ")+name); - return reinterpret_cast(function); - } - template std::vector extensions(F fn) { - std::uint32_t size=0;check(fn(instance,system,0,&size,nullptr),"Read XR Vulkan extensions"); - if(size>16384)throw mods::Error("OpenXR Vulkan extension list exceeds its limit."); - std::vector text(size+1,0);check(fn(instance,system,size,&size,text.data()),"Read XR Vulkan extensions"); - std::istringstream words(text.data());std::vector result;std::string word; - while(words>>word)result.push_back(word); - return result; - } - void poll() { - XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER}; - XrResult result; - while((result=xrPollEvent(instance,&event))==XR_SUCCESS) { - if(event.type==XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING)lost=true; - if(event.type==XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) { - const auto& changed=reinterpret_cast(event); - if(changed.session==session) { - state=changed.state; - note("session-state="+std::to_string(state)); - if(state==XR_SESSION_STATE_READY&&!active) { - XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};begin.primaryViewConfigurationType=XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; - check(xrBeginSession(session,&begin),"Begin headset session");active=true; - } else if(state==XR_SESSION_STATE_STOPPING&&active) { - check(xrEndSession(session),"End headset session");active=false; - } else if(state==XR_SESSION_STATE_EXITING||state==XR_SESSION_STATE_LOSS_PENDING){active=false;lost=true;} - } - } - event={XR_TYPE_EVENT_DATA_BUFFER}; - } - if(result!=XR_EVENT_UNAVAILABLE)check(result,"Poll headset session"); - } - bool acquire(Chain& chain) { - if(!chain.acquired) { - XrSwapchainImageAcquireInfo info{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; - check(xrAcquireSwapchainImage(chain.handle,&info,&chain.index),"Acquire headset image");chain.acquired=true; - } - if(chain.index>=chain.images.size())throw mods::Error("OpenXR returned an invalid swapchain image."); - if(!chain.waited) { - XrSwapchainImageWaitInfo wait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};wait.timeout=100000000; // 100 ms, presentation thread only. - const auto result=xrWaitSwapchainImage(chain.handle,&wait); - if(result==XR_TIMEOUT_EXPIRED)return false; // Retain acquisition; wait again next frame, never reacquire it. - check(result,"Wait for headset image");chain.waited=true; - } - return true; - } - void release(Chain& chain) { - XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; - check(xrReleaseSwapchainImage(chain.handle,&release),"Release headset image");chain.acquired=chain.waited=false; - } - void create_chain(Chain& chain,unsigned width,unsigned height,std::int64_t format) { - chain.width=width;chain.height=height; - XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO}; - info.usageFlags=XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT|XR_SWAPCHAIN_USAGE_SAMPLED_BIT|XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT; - info.format=format;info.sampleCount=1;info.width=width;info.height=height;info.faceCount=info.arraySize=info.mipCount=1; - check(xrCreateSwapchain(session,&info,&chain.handle),"Create headset swapchain"); - std::uint32_t count=0;check(xrEnumerateSwapchainImages(chain.handle,0,&count,nullptr),"Read headset swapchain"); - if(!count||count>16)throw mods::Error("Unsupported headset swapchain image count."); - std::vector images(count,{XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR}); - check(xrEnumerateSwapchainImages(chain.handle,count,&count,reinterpret_cast(images.data())),"Read headset images"); - for(const auto& image:images) { - auto texture=std::make_unique(device,image.image); - auto render_format=format==VK_FORMAT_R8G8B8A8_UNORM?plume::RenderFormat::R8G8B8A8_UNORM:plume::RenderFormat::B8G8R8A8_UNORM; - texture->desc=plume::RenderTextureDesc::ColorTarget(width,height,render_format); - texture->imageFormat=VkFormat(format);texture->fillSubresourceRange();texture->createImageView(VkFormat(format)); - if(!texture->imageView)throw mods::Error("Unable to create a headset render view."); - // VulkanTexture's borrowed-image constructor has ownership=false. - // We destroy only our image view, never the runtime-owned VkImage. - chain.images.push_back(std::move(texture)); - } - } -}; -XrSession::XrSession(Log log):impl_(std::make_unique()){impl_->log=std::move(log);} -XrSession::~XrSession(){shutdown();} -const std::string& XrSession::error()const{return impl_->error;} -bool XrSession::running()const{return impl_->active&&!impl_->lost;} -bool XrSession::discover() noexcept { - auto& p=*impl_;if(p.instance)return !p.lost; - p.lost=false; - try { - std::uint32_t count=0;check(xrEnumerateInstanceExtensionProperties(nullptr,0,&count,nullptr),"Read OpenXR extensions"); - if(count>512)throw mods::Error("OpenXR extension count exceeds its limit."); - std::vector extensions(count,{XR_TYPE_EXTENSION_PROPERTIES}); - check(xrEnumerateInstanceExtensionProperties(nullptr,count,&count,extensions.data()),"Read OpenXR extensions"); - const auto supported=[&](const char* name){return std::any_of(extensions.begin(),extensions.end(),[&](const auto& e){return std::strcmp(e.extensionName,name)==0;});}; - p.enable2=supported(XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME); - if(!p.enable2&&!supported(XR_KHR_VULKAN_ENABLE_EXTENSION_NAME)) - throw mods::Error("This headset runtime does not expose the supported Vulkan graphics binding."); - const char* enabled[]{p.enable2?XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME:XR_KHR_VULKAN_ENABLE_EXTENSION_NAME}; - XrInstanceCreateInfo create{XR_TYPE_INSTANCE_CREATE_INFO}; - constexpr char application_name[]="DKR-R Camera++",engine_name[]="DKR-R"; - std::memcpy(create.applicationInfo.applicationName,application_name,sizeof(application_name)); - std::memcpy(create.applicationInfo.engineName,engine_name,sizeof(engine_name)); - create.applicationInfo.apiVersion=XR_MAKE_VERSION(1,0,0);create.enabledExtensionCount=1;create.enabledExtensionNames=enabled; - check(xrCreateInstance(&create,&p.instance),"Open headset runtime"); - XrInstanceProperties properties{XR_TYPE_INSTANCE_PROPERTIES};check(xrGetInstanceProperties(p.instance,&properties),"Read headset runtime identity"); - p.note("runtime="+std::string(properties.runtimeName)+" version="+std::to_string(properties.runtimeVersion)+" binding="+enabled[0]); - XrSystemGetInfo system{XR_TYPE_SYSTEM_GET_INFO};system.formFactor=XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; - check(xrGetSystem(p.instance,&system,&p.system),"Find headset"); - XrSystemProperties system_properties{XR_TYPE_SYSTEM_PROPERTIES};check(xrGetSystemProperties(p.instance,p.system,&system_properties),"Read headset system"); - p.max_layers=system_properties.graphicsProperties.maxLayerCount; - if(!p.max_layers)throw mods::Error("Headset runtime cannot display composition layers."); - p.note("system="+std::string(system_properties.systemName)+" max-layers="+std::to_string(p.max_layers)); - if(p.enable2) { - p.physical_device2=p.function("xrGetVulkanGraphicsDevice2KHR"); - p.create_instance=p.function("xrCreateVulkanInstanceKHR"); - p.create_device=p.function("xrCreateVulkanDeviceKHR"); - auto requirements=p.function("xrGetVulkanGraphicsRequirements2KHR"); - check(requirements(p.instance,p.system,&p.requirements),"Read headset graphics requirements"); - } else { - p.instance_extensions=p.function("xrGetVulkanInstanceExtensionsKHR"); - p.device_extensions=p.function("xrGetVulkanDeviceExtensionsKHR"); - p.physical_device=p.function("xrGetVulkanGraphicsDeviceKHR"); - auto requirements=p.function("xrGetVulkanGraphicsRequirementsKHR"); - check(requirements(p.instance,p.system,&p.requirements),"Read headset graphics requirements"); - } - check(xrEnumerateEnvironmentBlendModes(p.instance,p.system,XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,0,&count,nullptr),"Read headset blend modes"); - if(!count||count>16)throw mods::Error("Unsupported headset blend-mode count."); - std::vector modes(count); - check(xrEnumerateEnvironmentBlendModes(p.instance,p.system,XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,count,&count,modes.data()),"Read headset blend modes"); - p.blend=modes[0]; - if(std::find(modes.begin(),modes.end(),XR_ENVIRONMENT_BLEND_MODE_OPAQUE)!=modes.end())p.blend=XR_ENVIRONMENT_BLEND_MODE_OPAQUE; - p.error.clear();return true; - }catch(const std::exception& e){p.error=e.what();p.note("unavailable: "+p.error);shutdown();return false;} -} -XrSession::Factory XrSession::graphics_factory() { - auto& p=*impl_; - if(!p.instance||p.lost)throw mods::Error("Discover the headset before graphics initialization."); - plume::VulkanExternalConfiguration config; - const auto generation=p.graphics_generation; - if(p.enable2) { - config.createInstance=[this,generation](const VkInstanceCreateInfo& create,VkInstance* instance) { - auto& p=*impl_; - if(!p.instance||p.lost||p.graphics_generation!=generation)return VK_ERROR_INITIALIZATION_FAILED; - XrVulkanInstanceCreateInfoKHR info{XR_TYPE_VULKAN_INSTANCE_CREATE_INFO_KHR}; - info.systemId=p.system;info.pfnGetInstanceProcAddr=vkGetInstanceProcAddr;info.vulkanCreateInfo=&create; - VkResult result=VK_ERROR_INITIALIZATION_FAILED; - const auto xr=p.create_instance(p.instance,&info,instance,&result); - return p.vulkan_result(xr,result,"Create headset Vulkan instance"); - }; - config.createDevice=[this,generation](VkPhysicalDevice physical,const VkDeviceCreateInfo& create,VkDevice* device) { - auto& p=*impl_; - if(!p.instance||p.lost||p.graphics_generation!=generation)return VK_ERROR_INITIALIZATION_FAILED; - XrVulkanDeviceCreateInfoKHR info{XR_TYPE_VULKAN_DEVICE_CREATE_INFO_KHR}; - info.systemId=p.system;info.pfnGetInstanceProcAddr=vkGetInstanceProcAddr;info.vulkanPhysicalDevice=physical;info.vulkanCreateInfo=&create; - VkResult result=VK_ERROR_INITIALIZATION_FAILED; - const auto xr=p.create_device(p.instance,&info,device,&result); - return p.vulkan_result(xr,result,"Create headset Vulkan device"); - }; - } else { - config.instanceExtensions=p.extensions(p.instance_extensions);config.deviceExtensions=p.extensions(p.device_extensions); - } - const auto version=std::max(p.requirements.minApiVersionSupported,XR_MAKE_VERSION(1,2,0)); - if(version>p.requirements.maxApiVersionSupported)throw mods::Error("Headset runtime does not support the renderer's Vulkan version."); - config.apiVersion=VK_MAKE_VERSION(XR_VERSION_MAJOR(version),XR_VERSION_MINOR(version),0); - config.selectPhysicalDevice=[this,generation](VkInstance instance) { - auto& p=*impl_;VkPhysicalDevice device=VK_NULL_HANDLE; - if(!p.instance||p.lost||p.graphics_generation!=generation)return device; - XrResult result; - if(p.enable2) { - XrVulkanGraphicsDeviceGetInfoKHR info{XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR};info.systemId=p.system;info.vulkanInstance=instance; - result=p.physical_device2(p.instance,&info,&device); - } else result=p.physical_device(p.instance,p.system,instance,&device); - if(XR_FAILED(result)){p.note("GPU selection failed (OpenXR "+std::to_string(result)+").");return VkPhysicalDevice(VK_NULL_HANDLE);} - p.selected_instance=instance;p.selected_device=device;return device; - }; - return [config](plume::RenderWindow window)->std::unique_ptr { -#if PLUME_SDL_VULKAN_ENABLED - auto backend=std::make_unique(window,config); -#else - (void)window;auto backend=std::make_unique(config); -#endif - if(!backend->isValid())return {}; - return backend; - }; -} -bool XrSession::bind(plume::VulkanDevice& device,plume::VulkanCommandQueue& queue) noexcept { - auto& p=*impl_; - try { - if(!p.instance||p.session||device.physicalDevice!=p.selected_device||device.renderInterface->instance!=p.selected_instance||queue.device!=&device||!queue.queue||!queue.queue->mutex) - throw mods::Error("Renderer/headset graphics ownership does not match."); - p.device=&device;p.queue=&queue; - VkPhysicalDeviceProperties properties{};vkGetPhysicalDeviceProperties(device.physicalDevice,&properties); - p.note("gpu="+std::string(properties.deviceName)+" queue-family="+std::to_string(queue.familyIndex)+" queue-index="+std::to_string(queue.queueIndex)); - XrGraphicsBindingVulkanKHR binding{XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR}; - binding.instance=p.selected_instance;binding.physicalDevice=p.selected_device;binding.device=device.vk; - binding.queueFamilyIndex=queue.familyIndex;binding.queueIndex=queue.queueIndex; - XrSessionCreateInfo info{XR_TYPE_SESSION_CREATE_INFO};info.next=&binding;info.systemId=p.system; - check(xrCreateSession(p.instance,&info,&p.session),"Create headset session"); - XrReferenceSpaceCreateInfo space{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};space.poseInReferenceSpace.orientation.w=1; - space.referenceSpaceType=XR_REFERENCE_SPACE_TYPE_LOCAL; - check(xrCreateReferenceSpace(p.session,&space,&p.base),"Create headset origin space"); - check(xrCreateReferenceSpace(p.session,&space,&p.local),"Create seated headset space"); - space.referenceSpaceType=XR_REFERENCE_SPACE_TYPE_VIEW;check(xrCreateReferenceSpace(p.session,&space,&p.view),"Create headset tracking space"); - std::uint32_t count=0;check(xrEnumerateViewConfigurationViews(p.instance,p.system,XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,0,&count,nullptr),"Read headset eye configuration"); - if(count!=2)throw mods::Error("This implementation requires a two-eye headset view configuration."); - std::array views{{{XR_TYPE_VIEW_CONFIGURATION_VIEW},{XR_TYPE_VIEW_CONFIGURATION_VIEW}}}; - check(xrEnumerateViewConfigurationViews(p.instance,p.system,XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,2,&count,views.data()),"Read headset eye sizes"); - check(xrEnumerateSwapchainFormats(p.session,0,&count,nullptr),"Read headset image formats"); - if(count>256)throw mods::Error("Headset format count exceeds its limit."); - std::vector formats(count);check(xrEnumerateSwapchainFormats(p.session,count,&count,formats.data()),"Read headset image formats"); - std::int64_t format=0;for(auto preferred:{VK_FORMAT_R8G8B8A8_UNORM,VK_FORMAT_B8G8R8A8_UNORM}) - if(std::find(formats.begin(),formats.end(),preferred)!=formats.end()){format=preferred;break;} - if(!format)throw mods::Error("No supported headset colour format is available."); - for(unsigned i=0;i<2;++i) { - const auto w=views[i].recommendedImageRectWidth,h=views[i].recommendedImageRectHeight; - if(!w||!h||w>8192||h>8192)throw mods::Error("Headset eye size exceeds the render budget."); - p.create_chain(p.chains[i],w,h,format); - p.note("eye="+std::to_string(i)+" size="+std::to_string(w)+"x"+std::to_string(h)+" format="+std::to_string(format)); - } - p.create_chain(p.chains[2],1536,768,format);p.error.clear();return true; - }catch(const std::exception& e){p.error=e.what();p.note("binding failed: "+p.error);shutdown();return false;} -} -bool XrSession::draw(const DkrViewPose& anchor,float scale,bool immersive,const RenderEye& render,const RenderPanel& panel) noexcept { - auto& p=*impl_;if(!p.session||p.lost)return false; - bool begun=false;XrTime frame_time=0; - try { - if(immersive&&!finite(anchor,scale))throw mods::Error("Invalid headset anchor or world scale."); - p.poll();if(!p.active||p.lost)return false; - XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};XrFrameState frame{XR_TYPE_FRAME_STATE}; - check(xrWaitFrame(p.session,&wait,&frame),"Wait for headset display time"); - frame_time=frame.predictedDisplayTime; - XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO};check(xrBeginFrame(p.session,&begin),"Begin headset frame");begun=true; - if(p.recenter_requested.load(std::memory_order_acquire)) { - XrSpaceLocation head{XR_TYPE_SPACE_LOCATION}; - check(xrLocateSpace(p.view,p.base,frame_time,&head),"Locate seated headset origin"); - constexpr auto required=XR_SPACE_LOCATION_ORIENTATION_VALID_BIT|XR_SPACE_LOCATION_POSITION_VALID_BIT; - if((head.locationFlags&required)==required&&valid_pose(head.pose)) { - auto forward=rotate(head.pose.orientation,{0,0,-1}); - const float yaw=std::atan2(-forward.x,-forward.z)*0.5F; - XrReferenceSpaceCreateInfo origin{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};origin.referenceSpaceType=XR_REFERENCE_SPACE_TYPE_LOCAL; - origin.poseInReferenceSpace.position=head.pose.position;origin.poseInReferenceSpace.orientation={0,std::sin(yaw),0,std::cos(yaw)}; - XrSpace replacement=XR_NULL_HANDLE;check(xrCreateReferenceSpace(p.session,&origin,&replacement),"Recenter seated headset"); - xrDestroySpace(p.local);p.local=replacement;p.recenter_requested.store(false,std::memory_order_release); - } - } - std::array views{{{XR_TYPE_VIEW},{XR_TYPE_VIEW}}};XrViewState state{XR_TYPE_VIEW_STATE}; - XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO};locate.viewConfigurationType=XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; - locate.displayTime=frame.predictedDisplayTime;locate.space=p.local;std::uint32_t count=0; - check(xrLocateViews(p.session,&locate,&state,2,&count,views.data()),"Locate headset eyes"); - bool tracked=count==2&&(state.viewStateFlags&XR_VIEW_STATE_ORIENTATION_VALID_BIT)&&(state.viewStateFlags&XR_VIEW_STATE_POSITION_VALID_BIT); - for(const auto& eye:views)tracked=tracked&&valid_pose(eye.pose)&&valid_fov(eye.fov); - if(tracked!=p.tracking){p.tracking=tracked;p.note(tracked?"tracking-restored":"tracking-unavailable; no world layers submitted");} - std::array projection_views{{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW},{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}}}; - XrCompositionLayerProjection world{XR_TYPE_COMPOSITION_LAYER_PROJECTION};world.space=p.local;world.viewCount=2;world.views=projection_views.data(); - XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};quad.space=p.local;quad.eyeVisibility=XR_EYE_VISIBILITY_BOTH; - quad.pose.orientation.w=1;quad.pose.position={0,immersive?-0.32F:0,immersive?-0.95F:-2.0F};quad.size={immersive?0.9F:2.4F,immersive?0.45F:1.2F}; - quad.layerFlags=XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT; - std::array layers{};unsigned layer_count=0; - bool world_ready=frame.shouldRender&&tracked&&immersive&&bool(render); - if(world_ready) { - auto base=rotation(anchor); - for(unsigned i=0;i<2;++i) { - auto& chain=p.chains[i];if(!p.acquire(chain)){world_ready=false;break;} - XrEye eye;eye.eye=i;eye.width=chain.width;eye.height=chain.height; - auto position=rotate(base,views[i].pose.position);auto orientation=multiply(base,views[i].pose.orientation); - eye.position[0]=anchor.position[0]+position.x*scale;eye.position[1]=anchor.position[1]+position.y*scale;eye.position[2]=anchor.position[2]+position.z*scale; - eye.orientation[0]=orientation.x;eye.orientation[1]=orientation.y;eye.orientation[2]=orientation.z;eye.orientation[3]=orientation.w; - eye.left=views[i].fov.angleLeft;eye.right=views[i].fov.angleRight;eye.up=views[i].fov.angleUp;eye.down=views[i].fov.angleDown; - eye.near_plane=anchor.near_plane;eye.far_plane=anchor.far_plane; - bool rendered=render(eye,*chain.images[chain.index]); - // Renderer callback contract: no outstanding GPU image use. - p.release(chain);if(!rendered){world_ready=false;break;} - projection_views[i].pose=views[i].pose;projection_views[i].fov=views[i].fov; - projection_views[i].subImage.swapchain=chain.handle;projection_views[i].subImage.imageRect.extent={std::int32_t(chain.width),std::int32_t(chain.height)}; - } - } - if(world_ready)layers[layer_count++]=reinterpret_cast(&world); - if(frame.shouldRender&&tracked&&panel&&layer_count(&quad); - } - } - } - XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO};end.displayTime=frame.predictedDisplayTime; - end.environmentBlendMode=p.blend;end.layerCount=layer_count;end.layers=layers.data(); - // OpenXR and Plume share this exact native queue. Synchronize queue - // consumption only here; never hold it during xrWaitFrame/rendering. - begun=false; // An unsuccessful xrEndFrame must not be resubmitted. - XrResult result; - {std::scoped_lock lock(*p.queue->queue->mutex);result=xrEndFrame(p.session,&end);} - check(result,"Submit headset frame"); - if(result==XR_SESSION_LOSS_PENDING){p.lost=true;p.note("session-loss-pending");return false;} - p.error.clear();return layer_count!=0; - }catch(const std::exception& e) { - p.error=e.what(); - p.note("frame stopped: "+p.error); - if(begun) { - // No fabricated eye pose or stale world frame on tracking/error. - XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO};end.environmentBlendMode=p.blend; - end.displayTime=frame_time; - {std::scoped_lock lock(*p.queue->queue->mutex);xrEndFrame(p.session,&end);} - } - p.lost=true; // Owner recreates the session after its graphics drain. - return false; - } -} -bool XrSession::recenter() noexcept { - impl_->recenter_requested.store(true,std::memory_order_release); - return true; // Applied only at the next valid tracked display interval. -} -void XrSession::shutdown() noexcept { - if(!impl_)return; - auto& p=*impl_; - // Caller must drain GPU consumers before this point, even after a frame - // failure. The runtime owns swapchain images; wrappers own image views. - for(auto& chain:p.chains){chain.images.clear();if(chain.handle)xrDestroySwapchain(chain.handle);chain=Impl::Chain{};} - if(p.view)xrDestroySpace(p.view); - if(p.local)xrDestroySpace(p.local); - if(p.base)xrDestroySpace(p.base); - if(p.session)xrDestroySession(p.session); - if(p.instance)xrDestroyInstance(p.instance); - p.view=p.local=p.base=XR_NULL_HANDLE;p.session=XR_NULL_HANDLE;p.instance=XR_NULL_HANDLE;p.system=XR_NULL_SYSTEM_ID; - p.active=false;p.tracking=false;p.device=nullptr;p.queue=nullptr;p.state=XR_SESSION_STATE_UNKNOWN; - p.selected_instance=VK_NULL_HANDLE;p.selected_device=VK_NULL_HANDLE; - ++p.graphics_generation; - p.recenter_requested.store(true,std::memory_order_release); -} -} diff --git a/patches/camera-plus/native/camera_xr.hpp b/patches/camera-plus/native/camera_xr.hpp deleted file mode 100644 index 65aab1e3..00000000 --- a/patches/camera-plus/native/camera_xr.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once -#include "camera_presentation.hpp" -#include "plume_render_interface.h" -#include - -namespace plume {struct VulkanDevice;struct VulkanCommandQueue;} -namespace dkr::runtime::camera { -struct XrEye { - unsigned eye=0,width=0,height=0; - float position[3]{},orientation[4]{0,0,0,1}; // World units, quaternion XYZW. - float left=0,right=0,up=0,down=0; // Asymmetric angular FOV, radians. - float near_plane=1,far_plane=30000; -}; -// Graphics/presentation-thread owner. It never reads simulation RAM, waits on -// a network mutex, polls controllers or drives race cadence. The caller must -// drain graphics work before destroying this owner or its associated device. -class XrSession final { -public: - using Factory=std::function(plume::RenderWindow)>; - // Each callback must finish GPU use before returning true. Eye images must - // be left in Vulkan COLOR_ATTACHMENT_OPTIMAL for the OpenXR compositor. - using RenderEye=std::function; - using RenderPanel=std::function; - // No account names/serial numbers. A supplied logger must not block. - using Log=std::function; - explicit XrSession(Log log={});~XrSession(); - XrSession(const XrSession&)=delete;XrSession& operator=(const XrSession&)=delete; - bool discover() noexcept; - Factory graphics_factory(); - bool bind(plume::VulkanDevice&,plume::VulkanCommandQueue&) noexcept; - bool draw(const DkrViewPose& anchor,float units_per_metre,bool immersive, - const RenderEye&,const RenderPanel&) noexcept; - bool recenter() noexcept; - bool running()const; - const std::string& error()const; - void shutdown() noexcept; -private: - struct Impl;std::unique_ptr impl_; -}; -} diff --git a/patches/camera-plus/package_playtest.py b/patches/camera-plus/package_playtest.py deleted file mode 100644 index 9474ace1..00000000 --- a/patches/camera-plus/package_playtest.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Package the compiled Camera++ playtest without launching any game/mod.""" -import argparse -import hashlib -import json -from pathlib import Path -import shlex -import shutil -import subprocess -import sys -import zipfile - -ROOT=Path(__file__).resolve().parents[2] -sys.path.insert(0,str(ROOT/'scripts')) -from camera_plus_pipeline import new_output,replace,digest - -def mod_file(path): - with zipfile.ZipFile(path) as archive: - if set(archive.namelist())!={'mod.json','dkr.json','mod_syms.bin','mod_binary.bin','settings.json'}: - raise ValueError('Unexpected Camera++ package contents') - if json.loads(archive.read('mod.json'))['id']!='dkr_camera_plus':raise ValueError('Wrong camera mod') - meta=json.loads(archive.read('dkr.json')) - if meta['requires'].get('presentation')!=1:raise ValueError('Not a presentation-domain mod') - -def windows(base,binaries,mod,output): - output=new_output(output,base,binaries,mod.parent) - mod_file(mod) - stage=output/'DKR-R-1.0.7+beta.5-ModernCamera++-Windows-x64' - shutil.copytree(base,stage) - for name in ('DKR-R.exe','libexec/dkr-r/DKR-R-Runtime.exe','libexec/dkr-r/DKR-R-ModWorker.exe', - 'libexec/dkr-r/DKR-R-Updater.exe','libexec/dkr-r/DKR-R-InputHost.exe', - 'libexec/dkr-r/SDL2.dll','libexec/dkr-r/SDL3.dll','libexec/dkr-r/dxcompiler.dll','libexec/dkr-r/dxil.dll'): - source=binaries/name - if not source.is_file():raise ValueError('Missing compiled image: '+str(source)) - shutil.copyfile(source,stage/name) - runtime=(stage/'libexec/dkr-r/DKR-R-Runtime.exe').read_bytes() - if b'[camera++][ready]' not in runtime:raise ValueError('Runtime lacks the Camera++ host') - optional=stage/'Optional-Mods';optional.mkdir(exist_ok=True) - shutil.copyfile(mod,optional/'ModernCamera++.nrm') - shutil.copyfile(Path(__file__).parent/'README.md',stage/'MODERN-CAMERA-README.md') - subprocess.run([sys.executable,str(ROOT/'scripts/update_release.py'),'prepare-tree','--directory',str(stage), - '--version','1.0.7+beta.5','--platform','windows-x64'],check=True) - denied={'.z64','.v64','.n64','.eep','.mpk','.sra','.fla','.dkr-probe','.dkr-bootstrap','.dkr-component','.pdb'} - expected={'DKR-R.exe','libexec/dkr-r/DKR-R-Runtime.exe','libexec/dkr-r/DKR-R-ModWorker.exe', - 'libexec/dkr-r/DKR-R-Updater.exe','libexec/dkr-r/DKR-R-InputHost.exe'} - executables=set() - for path in stage.rglob('*'): - if path.is_symlink():raise ValueError('Package must not contain links') - if not path.is_file():continue - if path.suffix.lower() in denied:raise ValueError('Prohibited package data: '+str(path)) - if path.suffix.lower()=='.exe':executables.add(path.relative_to(stage).as_posix()) - with path.open('rb') as stream: - if stream.read(4) in (b'\x80\x37\x12\x40',b'\x37\x80\x40\x12',b'\x40\x12\x37\x80'): - raise ValueError('ROM header in package') - if executables!=expected:raise ValueError('Unexpected executable layout') - archive=output/(stage.name+'.zip') - with zipfile.ZipFile(archive,'x',zipfile.ZIP_DEFLATED,compresslevel=6) as package: - for path in sorted(stage.rglob('*')): - if path.is_file():package.write(path,path.relative_to(output).as_posix()) - shutil.copyfile(mod,output/'ModernCamera++.nrm') - print('Created:',archive,'SHA256:',digest(archive)) - -def linux_script(mod,output): - output=new_output(output,mod.parent,ROOT/'scripts') - mod_file(mod);output.parent.mkdir(parents=True,exist_ok=True) - script=(ROOT/'scripts/Package-Linux-AppImage.sh').read_text() - script=replace(script,'PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"', - 'PROJECT_ROOT='+shlex.quote(ROOT.as_posix())) - insertion='''mkdir -p "${APPDIR}/usr/share/doc/dkr-port/Optional-Mods" -install -m 0644 @MODFILE@ "${APPDIR}/usr/share/doc/dkr-port/Optional-Mods/ModernCamera++.nrm" -install -m 0644 @README@ "${APPDIR}/usr/share/doc/dkr-port/MODERN-CAMERA-README.md" -'''.replace('@MODFILE@',shlex.quote(mod.resolve().as_posix())).replace('@README@',shlex.quote((Path(__file__).parent/'README.md').as_posix())) - script=replace(script,'collect_linux_dependency_notices "${APPDIR}"',insertion+'collect_linux_dependency_notices "${APPDIR}"') - output.write_text(script,newline='\n') - print('Generated packaging-only Linux script:',output) - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('mode',choices=('windows','linux-script')) - p.add_argument('--base',type=Path);p.add_argument('--binaries',type=Path) - p.add_argument('--mod',type=Path,required=True);p.add_argument('--output',type=Path,required=True);a=p.parse_args() - if a.mode=='windows':windows(a.base.resolve(strict=True),a.binaries.resolve(strict=True),a.mod.resolve(strict=True),a.output) - else:linux_script(a.mod.resolve(strict=True),a.output) diff --git a/patches/camera-plus/sdk/dkr/presentation.h b/patches/camera-plus/sdk/dkr/presentation.h deleted file mode 100644 index 8264ad8a..00000000 --- a/patches/camera-plus/sdk/dkr/presentation.h +++ /dev/null @@ -1,122 +0,0 @@ -#ifndef DKR_PRESENTATION_SDK_H -#define DKR_PRESENTATION_SDK_H - -/* Presentation v1. All lengths are bytes, angles are radians, distances are - * game-world units and time is seconds. No field contains a guest/host pointer. - * The host copies these values into a PRIVATE presentation VM. This API is - * deliberately unavailable to gameplay callbacks, including online rollback. - */ -#ifdef DKR_PRESENTATION_HOST -#include -typedef std::uint32_t DkrViewU32; -typedef std::int32_t DkrViewS32; -#else -#include "magic_codes.h" -typedef DkrU32 DkrViewU32; -typedef DkrS32 DkrViewS32; -#endif - -#define DKR_PRESENTATION_VERSION 1U -enum DkrViewCapability { - DKR_VIEW_POSE=1U, DKR_VIEW_PROJECTION=2U, DKR_VIEW_REAR_COVERAGE=4U, - DKR_VIEW_ORBIT_COVERAGE=8U, DKR_VIEW_HIDE_LOCAL_MODEL=16U, - DKR_VIEW_HIDE_RIDER=32U, DKR_VIEW_STEREO=64U, - DKR_VIEW_STATIC_COLLISION=128U, DKR_VIEW_DYNAMIC_COLLISION=256U -}; -enum DkrViewState { - DKR_VIEW_DRIVING=1U, DKR_VIEW_SCRIPTED=2U, DKR_VIEW_CUT=4U, - DKR_VIEW_GROUNDED=8U, DKR_VIEW_DRIFTING=16U, DKR_VIEW_BOOSTING=32U, - DKR_VIEW_IMPACT=64U, DKR_VIEW_ONLINE=128U, DKR_VIEW_INSPECTION_ALLOWED=256U -}; -enum DkrViewAction { - DKR_VIEW_LOOK_BACK=1U, DKR_VIEW_RECENTER=2U, DKR_VIEW_CYCLE=4U, - DKR_VIEW_ZOOM_IN=8U, DKR_VIEW_ZOOM_OUT=16U, - DKR_VIEW_FIRST_PERSON=32U, DKR_VIEW_XR_RECENTER=64U -}; -enum DkrViewMode { - DKR_VIEW_ORIGINAL=0U, DKR_VIEW_CHASE=1U, DKR_VIEW_BONNET=2U, - DKR_VIEW_FIRST=3U, DKR_VIEW_COMFORT_FIRST=4U, - DKR_VIEW_CINEMATIC=5U, DKR_VIEW_ORBIT=6U, DKR_VIEW_VR_DRIVER=7U -}; -enum DkrViewVisibility { - DKR_VIEW_SHOW_MODEL=0U, DKR_VIEW_HIDE_VEHICLE=1U, DKR_VIEW_HIDE_RIDER_ONLY=2U -}; -enum DkrViewQueryStatus { - DKR_VIEW_QUERY_NONE=0U, DKR_VIEW_QUERY_HIT=1U, - DKR_VIEW_QUERY_UNSUPPORTED=2U, DKR_VIEW_QUERY_BUDGET=3U -}; - -typedef struct DkrViewSnapshot { - DkrViewU32 api,size,capabilities,flags; - DkrViewU32 scene,epoch,frame,view; - DkrViewU32 racer_id,racer_generation,vehicle,character; - float dt,time,aspect,speed; - float position[3],velocity[3]; - float yaw,pitch,roll,steer; - float original_position[3],original_yaw,original_pitch,original_roll; - float original_fov,original_near,original_far,boost; - DkrViewU32 reserved[4]; -} DkrViewSnapshot; - -typedef struct DkrViewInput { - DkrViewU32 api,size,held,pressed,released,blocked; - float orbit_x,orbit_y,zoom; - DkrViewU32 reserved[3]; -} DkrViewInput; - -typedef struct DkrViewPose { - DkrViewU32 api,size,view,mode; - DkrViewU32 racer_id,racer_generation,visibility,cut; - float position[3],yaw,pitch,roll; - float vertical_fov,near_plane,far_plane; - DkrViewU32 reserved[3]; -} DkrViewPose; - -typedef struct DkrViewQuery { - DkrViewU32 api,size,view,exclude_racer; - float origin[3],target[3],radius; - DkrViewU32 include_dynamic,reserved[2]; -} DkrViewQuery; - -typedef struct DkrViewHit { - DkrViewU32 api,size,status,reserved; - float fraction,position[3],normal[3]; - DkrViewU32 reserved2; -} DkrViewHit; - -enum DkrViewMath {DKR_VIEW_SIN=0U,DKR_VIEW_COS=1U,DKR_VIEW_ATAN2=2U, - DKR_VIEW_EXP=3U,DKR_VIEW_SQRT=4U}; - -#ifdef DKR_PRESENTATION_HOST -static_assert(sizeof(DkrViewSnapshot)==160); -static_assert(sizeof(DkrViewInput)==48); -static_assert(sizeof(DkrViewPose)==80); -static_assert(sizeof(DkrViewQuery)==56); -static_assert(sizeof(DkrViewHit)==48); -#else -/* Exactly one on_presentation callback is required. It runs once for an - * eligible local view, not once per gameplay tick or per eye. All writes are - * confined to this package's private memory and validated output structures. - * Extra callbacks, gameplay imports, raw references and native hooks are - * rejected before activation. Tracking and graphics devices stay host-owned. - */ -DKR_IMPORT DkrU32 dkr_view_snapshot(DkrViewSnapshot* out) {(void)out;return 0;} -DKR_IMPORT DkrU32 dkr_view_input(DkrViewInput* out) {(void)out;return 0;} -DKR_IMPORT DkrU32 dkr_view_submit(const DkrViewPose* pose) {(void)pose;return 0;} -DKR_IMPORT DkrU32 dkr_view_query(const DkrViewQuery* query,DkrViewHit* hit) {(void)query;(void)hit;return 0;} -/* Float bits avoid platform-dependent O32 mixed floating/integer arguments. */ -DKR_IMPORT DkrU32 dkr_view_math(DkrU32 operation,DkrU32 x,DkrU32 y) {(void)operation;(void)x;(void)y;return 0;} -static inline float dkr_view_real(DkrU32 bits) {union {DkrU32 u;float f;} v;v.u=bits;return v.f;} -static inline DkrU32 dkr_view_bits(float value) {union {DkrU32 u;float f;} v;v.f=value;return v.u;} -static inline float dkr_view_sin(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_SIN,dkr_view_bits(x),0));} -static inline float dkr_view_cos(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_COS,dkr_view_bits(x),0));} -static inline float dkr_view_atan2(float y,float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_ATAN2,dkr_view_bits(y),dkr_view_bits(x)));} -static inline float dkr_view_exp(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_EXP,dkr_view_bits(x),0));} -static inline float dkr_view_sqrt(float x) {return dkr_view_real(dkr_view_math(DKR_VIEW_SQRT,dkr_view_bits(x),0));} -_Static_assert(sizeof(DkrViewSnapshot)==160,"presentation snapshot ABI"); -_Static_assert(sizeof(DkrViewInput)==48,"presentation input ABI"); -_Static_assert(sizeof(DkrViewPose)==80,"presentation pose ABI"); -_Static_assert(sizeof(DkrViewQuery)==56,"presentation query ABI"); -_Static_assert(sizeof(DkrViewHit)==48,"presentation hit ABI"); -#endif -#endif diff --git a/patches/camera-plus/vr_device_patch.py b/patches/camera-plus/vr_device_patch.py deleted file mode 100644 index 83240400..00000000 --- a/patches/camera-plus/vr_device_patch.py +++ /dev/null @@ -1,175 +0,0 @@ -"""User-approved, four-file VR device hook, ONLY on a new RT64 build copy. - -No OpenXR policy or camera logic is embedded in RT64. The host can supply an -optional Vulkan factory and per-interface device/extension requirements. Empty -configuration follows the original backend unchanged. Never apply in extern. -""" -from pathlib import Path -import hashlib -import json -import shutil -import sys - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / 'scripts')) -from camera_plus_pipeline import new_output, replace, digest - -PINS = { - 'src/hle/rt64_application.h': '7d36ce05c422d6d537aebcf1577560d65a304d6c48f53a8e8de321ff12abf27b', - 'src/hle/rt64_application.cpp': '5bbb4960a262c91b1ccc383ed0027b25c60be839f7d38ed0f205a9fcd0341f84', - 'src/contrib/plume/plume_vulkan.h': 'ca8f3abc3507e61da6e85e7acc30168ec6cc41adf7a368db1947cb6995554977', - 'src/contrib/plume/plume_vulkan.cpp': '70ecc88658c7231497836d15ea9f98d80861a6e991cc6128d40c7ac980193038' -} - - -def transform(name, text): - if name == 'src/hle/rt64_application.h': - text = replace(text, '#include ', '#include \n#include ') - return replace(text, ' bool useConfigurationFile = true;', ''' bool useConfigurationFile = true; - // Optional host-owned device negotiation, e.g. an OpenXR Vulkan GPU. - // No callback preserves the original backend/device selection. - std::function(RenderWindow)> vulkanInterfaceFactory;''') - if name == 'src/hle/rt64_application.cpp': - return replace(text, 'renderInterface = CreateVulkanInterfaceWrapper(appWindow->windowHandle);', - '''renderInterface = appConfig.vulkanInterfaceFactory ? - appConfig.vulkanInterfaceFactory(appWindow->windowHandle) : - CreateVulkanInterfaceWrapper(appWindow->windowHandle);''', 2) - if name == 'src/contrib/plume/plume_vulkan.h': - text = replace(text, '#include ', '#include \n#include ') - text = replace(text, ' struct VulkanInterface : RenderInterface {', ''' // Immutable per-interface requirements, not process-global hooks. Used - // only before graphics workers exist. The callback must return a device - // belonging to this VkInstance; a null/foreign device fails creation. - struct VulkanExternalConfiguration { - std::vector instanceExtensions, deviceExtensions; - std::function selectPhysicalDevice; - std::function createInstance; - std::function createDevice; - uint32_t apiVersion = VK_API_VERSION_1_2; - }; - struct VulkanInterface : RenderInterface { - VulkanExternalConfiguration externalConfiguration;''') - text = replace(text, ' VulkanInterface(RenderWindow sdlWindow);', ''' VulkanInterface(RenderWindow sdlWindow); - VulkanInterface(RenderWindow sdlWindow, const VulkanExternalConfiguration &configuration);''') - return replace(text, ' VulkanInterface();', ''' VulkanInterface(); - explicit VulkanInterface(const VulkanExternalConfiguration &configuration);''') - if name == 'src/contrib/plume/plume_vulkan.cpp': - text = replace(text, ' uint32_t currentDeviceTypeScore = 0;', ''' VkPhysicalDevice requiredPhysicalDevice = VK_NULL_HANDLE; - if (renderInterface->externalConfiguration.selectPhysicalDevice) { - requiredPhysicalDevice = renderInterface->externalConfiguration.selectPhysicalDevice(renderInterface->instance); - if (requiredPhysicalDevice == VK_NULL_HANDLE || - std::find(physicalDevices.begin(), physicalDevices.end(), requiredPhysicalDevice) == physicalDevices.end()) { - fprintf(stderr, "External graphics runtime did not select a valid Vulkan device.\\n"); - return; - } - } - uint32_t currentDeviceTypeScore = 0;''') - text = replace(text, ' for (uint32_t i = 0; i < deviceCount; i++) {\n VkPhysicalDeviceProperties deviceProperties;', ''' for (uint32_t i = 0; i < deviceCount; i++) { - if (requiredPhysicalDevice != VK_NULL_HANDLE && physicalDevices[i] != requiredPhysicalDevice) continue;''') - text = replace(text, ' if (requiredPhysicalDevice != VK_NULL_HANDLE && physicalDevices[i] != requiredPhysicalDevice) continue;', - ' if (requiredPhysicalDevice != VK_NULL_HANDLE && physicalDevices[i] != requiredPhysicalDevice) continue;\n VkPhysicalDeviceProperties deviceProperties;') - text = replace(text, ' bool preferOption = preferDeviceTypeScore || preferUserChoice;', - ' bool preferOption = requiredPhysicalDevice != VK_NULL_HANDLE || preferDeviceTypeScore || preferUserChoice;') - text = replace(text, ' std::unordered_set missingRequiredExtensions = RequiredDeviceExtensions;', ''' std::unordered_set requiredDeviceExtensions = RequiredDeviceExtensions; - requiredDeviceExtensions.insert(renderInterface->externalConfiguration.deviceExtensions.begin(), - renderInterface->externalConfiguration.deviceExtensions.end()); - std::unordered_set missingRequiredExtensions = requiredDeviceExtensions;''') - text = replace(text, ' for (const std::string &extension : RequiredDeviceExtensions) {', - ' for (const std::string &extension : requiredDeviceExtensions) {') - # XR requirements can also be Plume optional extensions. Preserve the - # feature discovery, but never enable an extension name twice. - optional_loop = ''' for (const std::string &extension : supportedOptionalExtensions) { - enabledExtensions.push_back(extension.c_str()); - }''' - for required in ('requiredDeviceExtensions', 'requiredExtensions'): - prefix = ''' for (const std::string &extension : %s) { - enabledExtensions.push_back(extension.c_str()); - } - -''' % required - text = replace(text, prefix + optional_loop, prefix + ''' for (const std::string &extension : supportedOptionalExtensions) { - if (%s.find(extension) == %s.end()) - enabledExtensions.push_back(extension.c_str()); - }''' % (required, required)) - text = replace(text, ''' VulkanInterface::VulkanInterface(RenderWindow sdlWindow) { -#else - VulkanInterface::VulkanInterface() { -#endif''', ''' VulkanInterface::VulkanInterface(RenderWindow sdlWindow) - : VulkanInterface(sdlWindow, VulkanExternalConfiguration{}) {} - VulkanInterface::VulkanInterface(RenderWindow sdlWindow, const VulkanExternalConfiguration &configuration) - : externalConfiguration(configuration) { -#else - VulkanInterface::VulkanInterface() : VulkanInterface(VulkanExternalConfiguration{}) {} - VulkanInterface::VulkanInterface(const VulkanExternalConfiguration &configuration) - : externalConfiguration(configuration) { -#endif''') - text = replace(text, ' VkResult res = vkCreateDevice(physicalDevice, &createInfo, nullptr, &vk);', ''' VkResult res = renderInterface->externalConfiguration.createDevice ? - renderInterface->externalConfiguration.createDevice(physicalDevice, createInfo, &vk) : - vkCreateDevice(physicalDevice, &createInfo, nullptr, &vk);''') - text = replace(text, ' res = vkCreateInstance(&createInfo, nullptr, &instance);', ''' res = externalConfiguration.createInstance ? - externalConfiguration.createInstance(createInfo, &instance) : - vkCreateInstance(&createInfo, nullptr, &instance);''') - text = replace(text, ' appInfo.apiVersion = VK_API_VERSION_1_2;', - ' appInfo.apiVersion = externalConfiguration.apiVersion;') - return replace(text, ' std::unordered_set requiredExtensions = RequiredInstanceExtensions;', ''' std::unordered_set requiredExtensions = RequiredInstanceExtensions; - requiredExtensions.insert(externalConfiguration.instanceExtensions.begin(), externalConfiguration.instanceExtensions.end());''') - raise ValueError('VR patch attempted an unapproved dependency path') - - -def stage(source, output): - source = source.resolve(strict=True) - output = new_output(output, source) - transformed = {} - for name, pin in PINS.items(): - if digest(source / name) != pin: - raise ValueError('Unreviewed renderer input: ' + name) - transformed[name] = transform(name, (source / name).read_text()) - # A full separate source copy keeps every header consumer ABI-consistent. - # Git administration is not copied and no dependency checkout is patched. - shutil.copytree(source, output, ignore=shutil.ignore_patterns('.git', '__pycache__')) - originals = {} - for path in sorted(output.rglob('*')): - if path.is_symlink(): - raise ValueError('Renderer build copy must not contain symbolic links') - if path.is_file(): - originals[path.relative_to(output).as_posix()] = digest(path) - for name in PINS: - (output / name).write_text(transformed[name], newline='\n') - receipt = {'schema': 1, 'source': source.as_posix(), 'pins': PINS, 'originals': originals, - 'changed': {name: digest(output / name) for name in PINS}} - (output / 'camera-vr-receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') - print('User-approved four-file VR hook staged; original submodules unchanged:', output) - - -def verify(output): - receipt = json.loads((output / 'camera-vr-receipt.json').read_text()) - source = Path(receipt['source']) - if receipt['schema'] != 1 or receipt['pins'] != PINS: - raise ValueError('Unknown VR dependency composition') - actual = {p.relative_to(output).as_posix() for p in output.rglob('*') if p.is_file()} - if actual != set(receipt['originals']) | {'camera-vr-receipt.json'}: - raise ValueError('VR renderer source file set changed') - for name, original in receipt['originals'].items(): - if digest(source / name) != original: - raise ValueError('Original renderer input changed: ' + name) - path = output / name - if name in PINS: - if digest(source / name) != PINS[name] or path.read_text() != transform(name, (source / name).read_text()): - raise ValueError('Unreviewed staged VR edit: ' + name) - elif digest(path) != original: - raise ValueError('Unapproved dependency edit: ' + name) - print('VR dependency composition inspected: exactly four approved files differ') - - -if __name__ == '__main__': - import argparse - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('operation', choices=['stage', 'verify']) - parser.add_argument('--source', type=Path) - parser.add_argument('--output', type=Path, required=True) - args = parser.parse_args() - if args.operation == 'stage': - if not args.source: - parser.error('stage requires --source') - stage(args.source, args.output) - else: - verify(args.output.resolve(strict=True)) diff --git a/patches/camera-shadow/NativeOverlay.cmake b/patches/camera-shadow/NativeOverlay.cmake deleted file mode 100644 index c9687b63..00000000 --- a/patches/camera-shadow/NativeOverlay.cmake +++ /dev/null @@ -1,65 +0,0 @@ -# Layer the shadow fix over the already packaged Camera++/launcher pipelines. -include("${CMAKE_CURRENT_LIST_DIR}/../launcher-polish/NativeOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_camera_shadow_fix) - find_package(Python3 REQUIRED COMPONENTS Interpreter) - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${DKRPORT_ROOT}/patches/camera-shadow/pipeline.py" - verify --output "${DKR_CAMERA_SHADOW_STAGE}" RESULT_VARIABLE inspected) - if(NOT inspected EQUAL 0) - message(FATAL_ERROR "Camera shadow composition failed inspection") - endif() - foreach(target DKRPortGame DKRCameraPresentation rt64) - foreach(property SOURCES INTERFACE_SOURCES) - get_target_property(sources ${target} ${property}) - if(NOT sources OR sources MATCHES "-NOTFOUND$") - continue() - endif() - set(replaced) - foreach(source IN LISTS sources) - set(next "${source}") - foreach(name camera_rt64.cpp f3ddkr_rt64.cpp rt64_renderer.cpp) - if(source STREQUAL "${DKR_CAMERA_STAGE}/src/game/${name}") - set(next "${DKR_CAMERA_SHADOW_STAGE}/native/src/game/${name}") - endif() - endforeach() - if(NOT next STREQUAL source) - foreach(setting COMPILE_OPTIONS COMPILE_DEFINITIONS INCLUDE_DIRECTORIES SKIP_PRECOMPILE_HEADERS LANGUAGE SKIP_UNITY_BUILD_INCLUSION) - get_property(is_set SOURCE "${source}" DIRECTORY "${DKRPORT_ROOT}/runtime-recomp" PROPERTY ${setting} SET) - if(is_set) - get_property(value SOURCE "${source}" DIRECTORY "${DKRPORT_ROOT}/runtime-recomp" PROPERTY ${setting}) - set_property(SOURCE "${next}" DIRECTORY "${DKRPORT_ROOT}/runtime-recomp" PROPERTY ${setting} "${value}") - endif() - endforeach() - endif() - list(APPEND replaced "${next}") - endforeach() - set_property(TARGET ${target} PROPERTY ${property} "${replaced}") - endforeach() - endforeach() - target_include_directories(DKRCameraPresentation BEFORE PRIVATE "${DKR_CAMERA_SHADOW_STAGE}/native/src/game") - foreach(revision 77 80) - set(target "DKRCameraSceneV${revision}") - get_target_property(sources ${target} SOURCES) - set(replaced) - foreach(source IN LISTS sources) - if(source MATCHES "/camera_scene_hooks\\.c$") - list(APPEND replaced "${DKR_CAMERA_SHADOW_STAGE}/kernel-v${revision}/camera_scene_hooks.c") - else() - list(APPEND replaced "${source}") - endif() - endforeach() - list(APPEND replaced "${DKR_CAMERA_SHADOW_STAGE}/kernel-v${revision}/camera_shadow_kernel.c") - set_property(TARGET ${target} PROPERTY SOURCES "${replaced}") - target_include_directories(${target} BEFORE PRIVATE "${DKR_CAMERA_SHADOW_STAGE}/native/src/game") - target_compile_definitions(${target} PRIVATE DKR_SHADOW_ENTRY=dkr_camera_v${revision}_shadow_draw) - endforeach() - # VERSION remains a protected baseline input. Stamp this new build's - # advertised network release consistently through the build pipeline. - get_target_property(definitions DKRPortGame COMPILE_DEFINITIONS) - string(REGEX REPLACE "DKR_NETWORK_RELEASE_VERSION=\"[^\"]*\"" - "DKR_NETWORK_RELEASE_VERSION=\"${DKR_RELEASE_VERSION}\"" definitions "${definitions}") - set_property(TARGET DKRPortGame PROPERTY COMPILE_DEFINITIONS "${definitions}") - message(STATUS "Camera++: private per-view canonical shadow interpolation enabled") - endfunction() - cmake_language(DEFER CALL dkr_apply_camera_shadow_fix) -endif() diff --git a/patches/camera-shadow/RELEASE-NOTES.txt b/patches/camera-shadow/RELEASE-NOTES.txt deleted file mode 100644 index f7b4b278..00000000 --- a/patches/camera-shadow/RELEASE-NOTES.txt +++ /dev/null @@ -1,13 +0,0 @@ -DKR-R v1.0.7 beta 6 - ModernCamera++ shadow fix - -Camera++ now keeps the vehicle shadow tied to its own racer and viewport. -Its private redraw uses DKR-R's existing canonical shadow interpolation, -including the vehicle-specific shadow handling, instead of leaving projected -shadow vertices at the authored frame while the vehicle moves between frames. - -Camera cuts, track changes and vehicle changes keep separate shadow history. -The shadow fix does not change race logic, saves or online synchronisation. -The recent launcher cleanup and optional ModernCamera++ mod are included. - -This is a flat-screen playtest. VR output is not enabled yet. -Compiled and packaged only. Gameplay and visual testing remain with the user. diff --git a/patches/camera-shadow/camera_shadow_kernel.c b/patches/camera-shadow/camera_shadow_kernel.c deleted file mode 100644 index fbcf9aec..00000000 --- a/patches/camera-shadow/camera_shadow_kernel.c +++ /dev/null @@ -1,53 +0,0 @@ -/* Private draw-copy observation only. Never registers live game identities or - * advances shadow simulation. The original kernel's C fault fence owns errors. */ -#include "camera_scene_addresses.h" -#include "funcs.h" -#include "camera_shadow_kernel.h" -#include -#if defined(_MSC_VER) -#define SHADOW_TLS __declspec(thread) -#else -#define SHADOW_TLS _Thread_local -#endif -static SHADOW_TLS dkr_camera_shadows* shadow_output; -static SHADOW_TLS unsigned shadow_open; -static uint32_t shadow_word(uint32_t address) { - return *(uint32_t*)dkr_camera_access((uint64_t)(int64_t)(int32_t)address,4); -} -static uint32_t command(void) { - const uint32_t address=shadow_word(CAMERA_gTrackDL); - if(address<0x80800000U||address>0x80900000U||(address&7)) - dkr_camera_fault("camera shadow command span"); - return address; -} -unsigned dkr_shadow_interpolation_begin(uint8_t* ram,recomp_context* ctx,...) { - (void)ram; - if(!shadow_output||shadow_open||shadow_output->count==DKR_CAMERA_MAX_SHADOWS) - dkr_camera_fault("camera shadow metadata budget/scope"); - dkr_camera_shadow* entry=&shadow_output->entries[shadow_output->count++]; - entry->begin=command();entry->end=0; - entry->object=(uint32_t)ctx->r6;entry->shadow=(uint32_t)ctx->r7; - shadow_open=1; - return 0; -} -unsigned dkr_shadow_interpolation_end(uint8_t* ram,recomp_context* ctx,...) { - (void)ram;(void)ctx; - if(!shadow_open)return 0; /* Retail early-return path has no matching begin. */ - shadow_output->entries[shadow_output->count-1].end=command(); - shadow_open=0; - return 0; -} -int DKR_SHADOW_ENTRY(uint8_t* ram,size_t bytes,const dkr_camera_draw_request* request, - dkr_camera_draw_result* result,dkr_camera_shadows* shadows) { - if(!shadows||shadow_output)return 0; - memset(shadows,0,sizeof(*shadows));shadow_output=shadows;shadow_open=0; - const int okay=CAMERA_ENTRY(ram,bytes,request,result); - const int complete=!shadow_open; - shadow_output=0;shadow_open=0; - if(!okay||!complete) { - shadows->count=0; - if(okay&&result)result->failure="camera shadow scope did not close"; - return 0; - } - return 1; -} diff --git a/patches/camera-shadow/camera_shadow_kernel.h b/patches/camera-shadow/camera_shadow_kernel.h deleted file mode 100644 index 16397e07..00000000 --- a/patches/camera-shadow/camera_shadow_kernel.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifndef DKR_CAMERA_SHADOW_KERNEL_H -#define DKR_CAMERA_SHADOW_KERNEL_H -#include "camera_kernel.h" -enum { DKR_CAMERA_MAX_SHADOWS = 128 }; -typedef struct dkr_camera_shadow { - uint32_t begin, end, object, shadow; -} dkr_camera_shadow; -typedef struct dkr_camera_shadows { - uint32_t count; - dkr_camera_shadow entries[DKR_CAMERA_MAX_SHADOWS]; -} dkr_camera_shadows; -#ifdef __cplusplus -extern "C" { -#endif -int dkr_camera_v77_shadow_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*,dkr_camera_shadows*); -int dkr_camera_v80_shadow_draw(uint8_t*,size_t,const dkr_camera_draw_request*,dkr_camera_draw_result*,dkr_camera_shadows*); -#ifdef __cplusplus -} -#endif -#endif diff --git a/patches/camera-shadow/package.py b/patches/camera-shadow/package.py deleted file mode 100644 index aa08771a..00000000 --- a/patches/camera-shadow/package.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Versioned beta-6 packaging composition. Never starts the game or a mod.""" -from pathlib import Path -import argparse -import sys - -sys.dont_write_bytecode=True -ROOT=Path(__file__).resolve().parents[2] -source=ROOT/'patches/camera-plus/package_playtest.py' -text=source.read_text().replace('1.0.7+beta.5','1.0.7+beta.6') -text=text.replace(" subprocess.run([sys.executable,str(ROOT/'scripts/update_release.py')", - " shutil.copyfile(ROOT/'patches/camera-shadow/RELEASE-NOTES.txt',stage/'RELEASE-NOTES.txt')\n" - " subprocess.run([sys.executable,str(ROOT/'scripts/update_release.py')") -text=text.replace(" script=(ROOT/'scripts/Package-Linux-AppImage.sh').read_text()", - " script=(ROOT/'scripts/Package-Linux-AppImage.sh').read_text().replace('1.0.7+beta.5','1.0.7+beta.6')\n" - " script=script.replace('${PROJECT_ROOT}/docs/BETA5-1.0.7.txt','${PROJECT_ROOT}/patches/camera-shadow/RELEASE-NOTES.txt')") -scope={'__file__':str(source),'__name__':'camera_shadow_packaging'} -exec(compile(text,str(source),'exec'),scope) - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('mode',choices=('windows','linux-script')) - p.add_argument('--base',type=Path);p.add_argument('--binaries',type=Path) - p.add_argument('--mod',type=Path,required=True);p.add_argument('--output',type=Path,required=True);a=p.parse_args() - if a.mode=='windows':scope['windows'](a.base.resolve(strict=True),a.binaries.resolve(strict=True),a.mod.resolve(strict=True),a.output) - else:scope['linux_script'](a.mod.resolve(strict=True),a.output) diff --git a/patches/camera-shadow/pipeline.py b/patches/camera-shadow/pipeline.py deleted file mode 100644 index ca4ec03e..00000000 --- a/patches/camera-shadow/pipeline.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Add private Camera++ shadow sidecars in NEW Patch Pipeline output.""" -from pathlib import Path -import argparse -import json -import sys - -sys.dont_write_bytecode = True -ROOT = Path(__file__).resolve().parents[2] -AUTH = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT / 'scripts')) -from camera_plus_pipeline import digest, new_output, replace, verify as verify_camera -sys.path.insert(0, str(ROOT / 'patches/launcher-polish')) -from pipeline import inspect as verify_ui - - -def authored(): - return {p.relative_to(ROOT).as_posix(): digest(p) for p in sorted(AUTH.iterdir()) if p.is_file()} - - -def transform(name, text): - if name == 'camera_rt64.hpp': - text = replace(text, '#include "camera_kernel.h"', '#include "camera_kernel.h"\n#include "camera_shadow_kernel.h"') - text = replace(text, ' void before_command(std::uint32_t physical);', ''' struct ShadowMarker { - std::uint32_t address=0,identity=0; - std::uint64_t history=0; - std::uint8_t batches=0; - bool begin=false; - presentation::RigidShadowOwnerPolicy policy{}; - presentation::ShadowOwnerMotionSample motion{}; - }; - std::span shadows_at(std::uint32_t physical)const; - void before_command(std::uint32_t physical);''') - return replace(text, ' dkr_camera_draw_result draw_{};', ''' struct ShadowOwner { - std::uint32_t object,header,asset,kind,view,continuity; - std::uint64_t history; - }; - void capture_shadows(const Range&); - std::vector shadow_owners_; - std::vector shadow_markers_; - std::array shadow_continuity_{}; - std::array shadow_cut_frame_{{UINT64_MAX,UINT64_MAX,UINT64_MAX,UINT64_MAX}}; - dkr_camera_shadows shadows_{}; - dkr_camera_draw_result draw_{};''') - if name == 'camera_rt64.cpp': - text = replace(text, '#include ', '#include \n#include ') - text = replace(text, 'session_.reset();active_=next;failed_views_=0;', - 'session_.reset();active_=next;failed_views_=0;shadow_owners_.clear();') - text = replace(text, 'if(failed_scene_!=stamp.scene){failed_scene_=stamp.scene;failed_views_=0;}', - 'if(failed_scene_!=stamp.scene){failed_scene_=stamp.scene;failed_views_=0;shadow_owners_.clear();shadow_continuity_={};shadow_cut_frame_.fill(UINT64_MAX);}') - text = replace(text, ' scene_=stamp.scene;full_screen_=full_screen;aspect_=stamp.aspect;', ''' for(const auto& range:ranges_)if(range.pose.cut) { - const auto frame=(std::uint64_t(stamp.epoch)<<32)|stamp.frame; - if(shadow_cut_frame_[range.view]!=frame) { - shadow_cut_frame_[range.view]=frame;++shadow_continuity_[range.view]; - } - } - scene_=stamp.scene;full_screen_=full_screen;aspect_=stamp.aspect;''') - text = replace(text, ' const bool okay=active_->revision==rom::Revision::UsV77?', - ' bool okay=active_->revision==rom::Revision::UsV77?') - for revision in (77, 80): - text = replace(text, f'dkr_camera_v{revision}_draw(decoder_.data(),decoder_.size(),&request,&draw_)', - f'dkr_camera_v{revision}_shadow_draw(decoder_.data(),decoder_.size(),&request,&draw_,&shadows_)') - text = replace(text, ' if(!okay) {', ''' if(okay)try {capture_shadows(*range);}catch(const std::exception& e) { - report(e.what());okay=false;draw_.failure="Camera shadow metadata was rejected."; - } - if(!okay) {''') - text = replace(text, 'void RenderPass::finish()noexcept {', - (AUTH/'shadow_pass.inl').read_text()+'\nvoid RenderPass::finish()noexcept {\n shadow_markers_.clear();') - return text - if name == 'f3ddkr_rt64.cpp': - text = replace(text, ' camera::RenderPass* camera=nullptr;', - ' camera::RenderPass* camera=nullptr;\n std::optional camera_shadow;') - text = replace(text, ''' const auto rigid_policy = - dkr::runtime::presentation::shadow_owner_rigid_policy( - data.shadow_history_key); - const auto owner_motion = - dkr::runtime::presentation::shadow_owner_motion_sample( - data.shadow_history_key);''', ''' const auto rigid_policy = data.camera_shadow ? data.camera_shadow->policy : - dkr::runtime::presentation::shadow_owner_rigid_policy(data.shadow_history_key); - const auto owner_motion = data.camera_shadow ? data.camera_shadow->motion : - dkr::runtime::presentation::shadow_owner_motion_sample(data.shadow_history_key);''') - text = replace(text, ' const std::uint8_t shadow_aspect = ActiveAspectMode(', - ' const std::uint8_t shadow_aspect = data.camera_shadow ? G_EX_ASPECT_ADJUST : ActiveAspectMode(') - text = replace(text, ' interpolate_page_vertices);', - ' interpolate_page_vertices, 0U, data.camera_shadow.has_value());') - text = replace(text, ' shadow_aspect);', - ' shadow_aspect, false, 0U, data.camera_shadow.has_value());') - text = replace(text, ''' const auto markers = - dkr::runtime::presentation::active_presentation_markers(''', ''' if(auto* camera=active_->data_->camera) { - for(const auto& marker:camera->shadows_at(std::uint32_t(command-rdram_begin))) { - auto& data=*active_->data_; - if(marker.begin) { - data.camera_shadow=marker; - ApplyPresentationGroup(state,kPresentationGroupShadowMode,0,marker.batches,PresentationMarkerKind::Geometry); - } else { - ApplyPresentationGroup(state,0,0,0,PresentationMarkerKind::Geometry); - data.camera_shadow.reset(); - } - } - } - const auto markers = - dkr::runtime::presentation::active_presentation_markers(''') - text = replace(text, ''' SelectInterpolationGroup( - *state->rsp, active_group.identity,''', ''' const bool camera_visual=data.camera_matrices[std::min(data.selected_matrix,2U)]; - SelectInterpolationGroup( - *state->rsp, active_group.identity,''') - text = replace(text, ''' ActiveAspectMode(data.interpolation_groups, - data.matrix_aspect_override_active, - data.matrix_aspect_override), false, - active_group.procedural_water ? active_group.water_scroll_tag : 0U);''', ''' camera_visual ? G_EX_ASPECT_ADJUST : ActiveAspectMode(data.interpolation_groups, - data.matrix_aspect_override_active, - data.matrix_aspect_override), false, - active_group.procedural_water ? active_group.water_scroll_tag : 0U,camera_visual);''') - text = replace(text, ''' const std::uint32_t scoped_identity = - dkr::runtime::interpolation::scope_identity_uses_selected_matrix(''', ''' const std::uint32_t scoped_identity = mode==kPresentationGroupShadowMode&&data.camera_shadow - ? data.camera_shadow->identity : - dkr::runtime::interpolation::scope_identity_uses_selected_matrix(''') - text = replace(text, ''' data.shadow_history_key = - presentation_token != 0U && scene_generation != 0U''', ''' data.shadow_history_key = data.camera_shadow ? data.camera_shadow->history : - presentation_token != 0U && scene_generation != 0U''') - return text - if name == 'rt64_renderer.cpp': - return text # Recompile against the new private RenderPass layout. - raise ValueError('Unreviewed Camera shadow input') - - -FILES=('camera_rt64.cpp','camera_rt64.hpp','f3ddkr_rt64.cpp','rt64_renderer.cpp') - - -def expected(camera,kernels): - result={f'native/src/game/{name}':transform(name,(camera/'src/game'/name).read_text()) for name in FILES} - result['native/src/game/camera_shadow_kernel.h']=(AUTH/'camera_shadow_kernel.h').read_text() - for rev,path in kernels.items(): - hooks=(path/'camera_scene_hooks.c').read_text() - for suffix in ('begin','end'): - hooks=replace(hooks,f'unsigned dkr_camera_v{rev}_dkr_shadow_interpolation_{suffix}(uint8_t* ram,recomp_context* ctx,...)' - '{(void)ram;(void)ctx;return 0;}\n','') - result[f'kernel-v{rev}/camera_scene_hooks.c']=hooks - result[f'kernel-v{rev}/camera_shadow_kernel.c']=(AUTH/'camera_shadow_kernel.c').read_text() - return result - - -def inspect(output): - receipt=json.loads((output/'shadow-receipt.json').read_text()) - camera=ROOT/receipt['camera'];ui=ROOT/receipt['ui'] - kernels={int(rev):ROOT/name for rev,name in receipt['kernels'].items()} - verify_camera(camera);verify_ui(ui) - sys.path.insert(0,str(ROOT/'patches/camera-plus')) - from scene_kernel_pipeline import verify as verify_kernel - for path in kernels.values():verify_kernel(path) - if receipt['authored']!=authored():raise ValueError('Shadow recipe changed; generate a fresh stage') - for name,text in expected(camera,kernels).items(): - path=output/name - if path.is_symlink() or path.read_text()!=text or digest(path)!=receipt['files'][name]: - raise ValueError('Shadow source is not its reviewed transformation: '+name) - actual={p.relative_to(output).as_posix() for p in output.rglob('*') if p.is_file() and p.name!='shadow-receipt.json'} - if actual!=set(receipt['files']):raise ValueError('Unexpected shadow stage files') - print('Camera++ shadow sidecars inspected; original camera, launcher and kernels preserved') - - -def stage(camera,ui,kernels,output): - verify_camera(camera);verify_ui(ui) - output=new_output(output,camera,ui,*kernels.values()) - files=expected(camera,kernels) - for name,text in files.items(): - path=output/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(text,newline='\n') - receipt={'schema':1,'camera':camera.relative_to(ROOT).as_posix(),'ui':ui.relative_to(ROOT).as_posix(), - 'kernels':{rev:path.relative_to(ROOT).as_posix() for rev,path in kernels.items()},'authored':authored(), - 'files':{name:digest(output/name) for name in files}} - (output/'shadow-receipt.json').write_text(json.dumps(receipt,indent=2)+'\n') - inspect(output) - - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('mode',choices=('stage','verify')) - p.add_argument('--camera',type=Path);p.add_argument('--ui',type=Path) - p.add_argument('--kernel-v77',type=Path);p.add_argument('--kernel-v80',type=Path) - p.add_argument('--output',type=Path,required=True);a=p.parse_args() - if a.mode=='verify':inspect(a.output.resolve(strict=True)) - else:stage(a.camera.resolve(strict=True),a.ui.resolve(strict=True), - {77:a.kernel_v77.resolve(strict=True),80:a.kernel_v80.resolve(strict=True)},a.output) diff --git a/patches/camera-shadow/shadow_pass.inl b/patches/camera-shadow/shadow_pass.inl deleted file mode 100644 index 4491c535..00000000 --- a/patches/camera-shadow/shadow_pass.inl +++ /dev/null @@ -1,74 +0,0 @@ -// Shadow metadata belongs to this renderer, scene and viewport. Reuse the -// established canonical/rigid-shadow renderer, not arbitrary vertex matching. -namespace { -std::uint64_t new_camera_shadow_history() { - // A renderer/session restart can reuse the same scene/object addresses. - // Allocate an owner lifetime in a host-local namespace, never game state. - static std::atomic next{1}; - const auto serial=next.fetch_add(1,std::memory_order_relaxed); - return serial<0x0100000000000000ULL ? 0xC600000000000000ULL|serial : 0ULL; -} -} -void RenderPass::capture_shadows(const Range& range) { - shadow_markers_.clear(); - const auto valid=[](std::uint32_t p,std::uint32_t size) { - return p>=0x80000000U&&p<=0x80800000U-size; - }; - const auto half=[&](std::uint32_t p) { - std::uint16_t v=0; if(valid(p,2)&&!(p&1))std::memcpy(&v,decoder_.data()+((p&0xFFFFFFU)^2U),2);return v; - }; - const auto byte=[&](std::uint32_t p) { - return valid(p,1)?decoder_[(p&0xFFFFFFU)^3U]:std::uint8_t(0); - }; - const auto number=[&](std::uint32_t p) { - const auto bits=word(decoder_,p);float v;std::memcpy(&v,&bits,4);return v; - }; - for(unsigned i=0;i0x80900000U|| - (source.begin&7)||(source.end&7)||!valid(source.object,0x68)||!valid(source.shadow,0x10)) - throw mods::Error("Camera shadow observation is invalid."); - const auto object=source.object,header=word(decoder_,object+0x40); - const auto kind=half(object+0x48),asset=half(object+0x4A); - const auto start=std::int16_t(half(source.shadow+8)),end=std::int16_t(half(source.shadow+10)); - ShadowMarker marker{};marker.address=source.begin&0xFFFFFFU;marker.begin=true; - marker.batches=std::uint8_t(start>=0&&end>=start? - std::min(end-start,presentation::kMaximumCanonicalShadowBatches):0); - auto owner=std::find_if(shadow_owners_.begin(),shadow_owners_.end(),[&](const ShadowOwner& o) { - return o.object==object&&o.header==header&&o.asset==asset&&o.kind==kind&& - o.view==range.view&&o.continuity==shadow_continuity_[range.view]; - }); - if(owner==shadow_owners_.end()&&shadow_owners_.size()<512U) { - shadow_owners_.push_back({object,header,asset,kind,range.view,shadow_continuity_[range.view],new_camera_shadow_history()}); - owner=shadow_owners_.end()-1; - } - if(owner!=shadow_owners_.end()&&owner->history&&!range.pose.cut) { - marker.identity=presentation::normalise_identity(std::uint32_t(owner->history)^std::uint32_t(owner->history>>32)^0x43534844U); - marker.history=owner->history; - } - int vehicle=-1; - if(kind==1) { - const auto racer=word(decoder_,object+0x64); - if(valid(racer,0x1D8)) { - vehicle=byte(racer+0x1D6); - if(vehicle>2)vehicle=byte(racer+0x1D7); - if(vehicle>2)vehicle=-1; - } - } - const auto texture=word(decoder_,source.shadow+4); - marker.policy=presentation::rigid_shadow_owner_policy(kind,std::int8_t(vehicle),byte(texture),asset&presentation::kObjectAssetIdMask); - marker.motion={{number(object+12),number(object+16),number(object+20)},std::int16_t(half(object+2)),true}; - marker.motion.valid=std::isfinite(marker.motion.position.x)&&std::isfinite(marker.motion.position.y)&& - std::isfinite(marker.motion.position.z); - shadow_markers_.push_back(marker); - marker.address=source.end&0xFFFFFFU;marker.begin=false;shadow_markers_.push_back(marker); - } - std::stable_sort(shadow_markers_.begin(),shadow_markers_.end(),[](const auto& a,const auto& b){return a.address RenderPass::shadows_at(std::uint32_t physical)const { - if(!drawing_||physical<0x800000U||physical>=0x900000U)return {}; - const auto first=std::lower_bound(shadow_markers_.begin(),shadow_markers_.end(),physical, - [](const ShadowMarker& marker,std::uint32_t address){return marker.addressaddress==physical)++last; - return {first,std::size_t(last-first)}; -} diff --git a/patches/character-library-portraits/PIPELINE.txt b/patches/character-library-portraits/PIPELINE.txt deleted file mode 100644 index c6e3c08d..00000000 --- a/patches/character-library-portraits/PIPELINE.txt +++ /dev/null @@ -1,19 +0,0 @@ -Character portraits use the same 100-pixel preview slot as vehicle entries. -The surrounding launcher cards, controls and navigation stay as they are. - -The preview reads only the character.json manifest and its portrait blob on -a background worker. Both hashes and the catalogue identity are checked. -It reuses the established texture dump decoder for native colour, alpha and -interlacing. Palette portraits use the player's matching imported ROM as data. -No game, guest callback, mod activation, importer or renderer is run to decode. - -The worker queue has 32 entries; its immutable image cache has 64. Each image -is at most 256 x 256 RGBA8. SDL textures are made and released on the UI thread, -with 64 cached textures. An eviction never destroys an image referenced by the -current ImGui draw list. Launcher teardown releases textures before the renderer. -Bad or missing preview data cannot prevent the mod card or its controls working. - -This layer includes online-performance/ReleaseOverlay.cmake. Use configure.py -here for the combined playtest. The online compatibility IDs are unchanged by -this presentation-only addition. Build DKRCharacterPortraitChecks alongside -the online layer's build.py target set, then run the fixture without a ROM. diff --git a/patches/character-library-portraits/ReleaseOverlay.cmake b/patches/character-library-portraits/ReleaseOverlay.cmake deleted file mode 100644 index 55df567d..00000000 --- a/patches/character-library-portraits/ReleaseOverlay.cmake +++ /dev/null @@ -1,51 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../online-performance/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_character_portraits) - set(auth "${DKRPORT_ROOT}/patches/character-library-portraits") - set(stage "${CMAKE_BINARY_DIR}/generated/character-library-portraits") - file(MAKE_DIRECTORY "${stage}") - get_target_property(sources DKRPortGame SOURCES) - list(FILTER sources INCLUDE REGEX "[/\\]runtime_ui.cpp$") - list(LENGTH sources count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Character portraits need one final launcher source") - endif() - list(GET sources 0 old) - file(TO_CMAKE_PATH "${old}" old) - file(WRITE "${stage}/inputs.json" "[{\"target\":\"DKRPortGame\",\"path\":\"${old}\"},{\"target\":\"DKRPortGame\",\"path\":\"${CMAKE_BINARY_DIR}/generated/custom-vehicles/native/runtime_mod_library_ui.inl\"}]") - file(GLOB authored CONFIGURE_DEPENDS "${auth}/native/*" "${auth}/tests/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${authored} "${auth}/pipeline.py" "${auth}/ReleaseOverlay.cmake") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" --inputs "${stage}/inputs.json" --output "${stage}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Character portraits staging failed") - endif() - set(next "${stage}/runtime_ui.cpp") - get_target_property(directory DKRPortGame SOURCE_DIR) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage};${prior};${includes}") - dkr_repair_replace_source(DKRPortGame "${old}" "${next}") - add_library(DKRCharacterPortraits STATIC "${stage}/character_portraits.cpp") - target_compile_features(DKRCharacterPortraits PRIVATE cxx_std_20) - target_compile_definitions(DKRCharacterPortraits PRIVATE NOMINMAX) - target_include_directories(DKRCharacterPortraits PUBLIC "${stage}" PRIVATE "${DKRPORT_ROOT}/patches/texture-dumping/native") - target_link_libraries(DKRCharacterPortraits PUBLIC DKRLegacyModCore) - target_link_libraries(DKRPortGame PRIVATE DKRCharacterPortraits) - add_executable(DKRCharacterPortraitChecks EXCLUDE_FROM_ALL "${auth}/tests/portrait_checks.cpp") - target_compile_features(DKRCharacterPortraitChecks PRIVATE cxx_std_20) - target_compile_definitions(DKRCharacterPortraitChecks PRIVATE NOMINMAX) - target_link_libraries(DKRCharacterPortraitChecks PRIVATE DKRCharacterPortraits) - get_target_property(links DKRPortGame LINK_DIRECTORIES) - if(links) - target_link_directories(DKRCharacterPortraitChecks PRIVATE ${links}) - endif() - endfunction() - cmake_language(DEFER CALL dkr_apply_character_portraits) -endif() diff --git a/patches/character-library-portraits/configure.py b/patches/character-library-portraits/configure.py deleted file mode 100644 index 4e8cd6a6..00000000 --- a/patches/character-library-portraits/configure.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Configure portraits after the online repair, preserving all previous layers.""" -from pathlib import Path -import argparse,importlib.util,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','capsule','log'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--cmake',default='cmake');a=p.parse_args() - s=importlib.util.spec_from_file_location('portrait_configure',ROOT/'patches/release-prep/v1.0.7/release.py') - m=importlib.util.module_from_spec(s);s.loader.exec_module(m) - m.AUTH=AUTH;m.VERSION='1.0.11-online-playtest.1';m.configure(a.build,a.log,a.cmake,a.capsule) diff --git a/patches/character-library-portraits/native/character_portraits.cpp b/patches/character-library-portraits/native/character_portraits.cpp deleted file mode 100644 index bc880a8b..00000000 --- a/patches/character-library-portraits/native/character_portraits.cpp +++ /dev/null @@ -1,113 +0,0 @@ -#include "character_portraits.hpp" -#include "legacy_character_artifact.hpp" -#include "texture_decode.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef _WIN32 -#include -#endif -namespace dkr::character_portraits { -namespace { -using namespace mods; -bool digest(std::string_view s){return s.size()==64&&std::all_of(s.begin(),s.end(),[](char c){return (c>='0'&&c<='9')||(c>='a'&&c<='f');});} -void ordinary(const std::filesystem::path& path,bool directory) { - const auto type=std::filesystem::symlink_status(path).type(); - if(type!=(directory?std::filesystem::file_type::directory:std::filesystem::file_type::regular))throw Error("Portrait storage is not an ordinary file or directory."); -#ifdef _WIN32 - const auto attr=GetFileAttributesW(path.c_str()); - if(attr==INVALID_FILE_ATTRIBUTES||(attr&FILE_ATTRIBUTE_REPARSE_POINT))throw Error("Portrait storage cannot be a reparse point."); -#endif -} -Bytes palette(const Request& request) { - // Only palette-based portraits need the player's existing imported ROM. - // Keep the small palette section for the two revisions, never the ROM. - static thread_local std::map palettes; - if(auto it=palettes.find(request.revision);it!=palettes.end())return it->second; - auto roms=request.roms; - if(roms.empty()) { - const auto catalog=request.root.parent_path().parent_path()/"rom-catalog.txt"; - std::error_code error; - if(std::filesystem::is_regular_file(catalog,error)) { - const auto data=read_file(catalog,MiB);std::istringstream lines(std::string(data.begin(),data.end()));std::string line; - while(roms.size()<8&&std::getline(lines,line)){if(!line.empty()&&line.back()=='\r')line.pop_back();if(line.empty())continue; - auto path=utf8_path(line);if(path.is_absolute())roms.push_back(std::move(path));} - } - } - for(const auto& path:roms)try{ - auto rom=read_file(path,MaxImage);canonicalize_rom(rom); - if(verified_revision(rom)!=request.revision)continue; - const AssetImage image(rom,request.revision);const auto section=image.sections[14]; - if(section.size()>MiB)throw Error("Portrait palette exceeds its limit."); - Bytes bytes(section.begin(),section.end()); - if(palettes.size()<2)palettes.emplace(request.revision,bytes); - return bytes; - }catch(const Error&){} - throw Error("The matching imported Game ROM is needed for this portrait palette."); -} -} -std::string Request::key()const{const auto path=root.generic_u8string();return std::string(path.begin(),path.end())+":"+id+":"+artifact+":"+group+":"+storage+":"+revision;} -Image load(const Request& request) { - using nlohmann::json; - if(!digest(request.id)||!digest(request.artifact)||!digest(request.group)||!digest(request.storage)|| - (request.revision!="us.v77"&&request.revision!="us.v80"))throw Error("Invalid portrait source identity."); - const auto root=private_storage_path(request.root);ordinary(root,true); - const auto prepared=root/"prepared-characters",group=prepared/request.group,folder=group/request.storage; - for(const auto& path:{prepared,group,folder,folder/"blobs"})ordinary(path,true); - ordinary(folder/"character.json",false);const auto bytes=read_file(folder/"character.json",MiB); - if(sha256(bytes)!=request.artifact)throw Error("Character portrait manifest integrity failed."); - const auto document=json::parse(bytes.begin(),bytes.end(),[](int depth,json::parse_event_t,json&){if(depth>8)throw Error("Portrait metadata is nested too deeply.");return true;}); - if(document.at("schema")!=1||document.at("adapter")!=CharacterAdapterVersion||document.at("id")!=request.id||document.at("revision")!=request.revision) - throw Error("Character portrait metadata differs from its catalogue entry."); - const auto portrait=document.at("portrait").get();const auto& records=document.at("records"); - if(!records.is_array()||records.size()>32768)throw Error("Portrait record table exceeds its limit."); - for(const auto& record:records)if(record.at("section")==4&&record.at("record")==portrait) { - const auto hash=record.at("hash").get();const auto size=record.at("size").get(); - if(!digest(hash)||size<32||size>4*MiB)throw Error("Invalid portrait blob descriptor."); - const auto path=folder/"blobs"/hash;ordinary(path,false);auto source=read_file(path,4*MiB); - if(source.size()!=size||sha256(source)!=hash)throw Error("Character portrait blob integrity failed."); - const auto compressed=source[0x1d];auto inflated=compressed?inflate_asset(View(source).subspan(32),4*MiB):Bytes{}; - const View frame=compressed?View(inflated):View(source);const auto header=slice(frame,0,32); - const unsigned width=header[0],height=header[1],format=header[2]&15; - if(!width||!height||width>256||height>256||format>8)throw Error("Invalid character portrait dimensions or format."); - const auto palettes=format>=7?palette(request):Bytes{};View colours; - if(format>=7)colours=slice(palettes,be16(header,8),format==7?32:512); - const auto picture=runtime::texture_dump::decode_asset(frame.subspan(32),width,height,format,(be16(header,6)&0x400)!=0,colours); - return {picture.width,picture.height,picture.rgba,{}}; - } - throw Error("This character has no prepared portrait record."); -} -struct Service::Impl { - struct Entry{std::shared_ptr image;std::uint64_t touched=0;}; - std::mutex mutex;std::condition_variable_any changed;std::map pending; - std::set active;std::map cache;std::uint64_t serial=0; - std::jthread thread; - Impl():thread([this](std::stop_token stop){try{ - while(!stop.stop_requested()) { - Request request;std::string key; - {std::unique_lock lock(mutex);changed.wait(lock,stop,[&]{return !pending.empty();});if(stop.stop_requested())break; - auto it=pending.begin();key=it->first;request=std::move(it->second);pending.erase(it);} - auto image=std::make_shared(); - try{*image=load(request);}catch(const std::exception& error){image->error=error.what();} - {std::lock_guard lock(mutex);active.erase(key); - if(cache.size()>=64){auto oldest=std::min_element(cache.begin(),cache.end(),[](const auto& a,const auto& b){return a.second.touched()){} -Service::~Service()=default; -std::shared_ptr Service::request(Request request) { - const auto key=request.key();std::lock_guard lock(impl_->mutex); - if(auto it=impl_->cache.find(key);it!=impl_->cache.end()){it->second.touched=++impl_->serial;return it->second.image;} - if(impl_->active.size()<32&&impl_->active.insert(key).second){impl_->pending.emplace(key,std::move(request));impl_->changed.notify_one();} - return {}; -} -} diff --git a/patches/character-library-portraits/native/character_portraits.hpp b/patches/character-library-portraits/native/character_portraits.hpp deleted file mode 100644 index b62d7ae7..00000000 --- a/patches/character-library-portraits/native/character_portraits.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -namespace dkr::character_portraits { -struct Request { - std::filesystem::path root; - std::string id,artifact,group,storage,revision; - std::vector roms; - std::string key()const; -}; -struct Image {unsigned width=0,height=0;std::vector rgba;std::string error;}; -// Background-only, read-only decode of one verified portrait blob. No gameplay -// asset owner, patch importer, renderer or native guest is created. -Image load(const Request& request); -class Service { - struct Impl;std::unique_ptr impl_; -public: - Service();~Service(); - std::shared_ptr request(Request request); -}; -} diff --git a/patches/character-library-portraits/native/character_portraits_ui.inl b/patches/character-library-portraits/native/character_portraits_ui.inl deleted file mode 100644 index 6b231c86..00000000 --- a/patches/character-library-portraits/native/character_portraits_ui.inl +++ /dev/null @@ -1,41 +0,0 @@ -SDL_Renderer* g_character_portrait_renderer=nullptr; -dkr::character_portraits::Service g_character_portraits; -struct CharacterPortraitTexture {SDL_Texture* texture=nullptr;unsigned width=0,height=0;int used=0;}; -std::map g_character_portrait_textures; -void ReleaseCharacterPortraits() { - for(auto& [key,entry]:g_character_portrait_textures)if(entry.texture)SDL_DestroyTexture(entry.texture); - g_character_portrait_textures.clear(); - g_character_portrait_renderer=nullptr; -} -void DrawCharacterPortrait(const dkr::mods::TrackCatalogItem& item,float width,float height) { - const auto at=ImGui::GetCursorScreenPos(); - if(g_character_portrait_renderer&&ImGui::IsRectVisible({width,height})) { - dkr::character_portraits::Request request{g_config_directory/"mods"/"legacy",item.id,item.artifact,item.group,item.storage,item.revision,{}}; - const auto key=request.key();const int frame=ImGui::GetFrameCount(); - auto found=g_character_portrait_textures.find(key); - if(found==g_character_portrait_textures.end()) { - const auto image=g_character_portraits.request(std::move(request)); - if(image&&!image->rgba.empty()) { - if(g_character_portrait_textures.size()>=64) { - auto oldest=std::min_element(g_character_portrait_textures.begin(),g_character_portrait_textures.end(),[](const auto& a,const auto& b){return a.second.usedsecond.usedsecond.texture);g_character_portrait_textures.erase(oldest);} - } - if(g_character_portrait_textures.size()<64) { - auto* texture=SDL_CreateTexture(g_character_portrait_renderer,SDL_PIXELFORMAT_RGBA32,SDL_TEXTUREACCESS_STATIC,int(image->width),int(image->height)); - if(texture&&SDL_UpdateTexture(texture,nullptr,image->rgba.data(),int(image->width*4))!=0){SDL_DestroyTexture(texture);texture=nullptr;} - if(texture){SDL_SetTextureBlendMode(texture,SDL_BLENDMODE_BLEND);SDL_SetTextureScaleMode(texture,SDL_ScaleModeNearest); - found=g_character_portrait_textures.emplace(key,CharacterPortraitTexture{texture,image->width,image->height,frame}).first;} - } - } - } - if(found!=g_character_portrait_textures.end()) { - auto& entry=found->second;entry.used=frame; - const float ratio=float(entry.width)/entry.height,w=std::min(width,height*ratio),h=w/ratio; - ImGui::GetWindowDrawList()->AddImage(reinterpret_cast(entry.texture), - {at.x+(width-w)*.5F,at.y+(height-h)*.5F},{at.x+(width+w)*.5F,at.y+(height+h)*.5F}); - } - } - ImGui::Dummy({width,height}); -} diff --git a/patches/character-library-portraits/pipeline.py b/patches/character-library-portraits/pipeline.py deleted file mode 100644 index 7ccd063b..00000000 --- a/patches/character-library-portraits/pipeline.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Reuse the vehicle card preview hook for background-decoded character portraits.""" -from pathlib import Path -import argparse,hashlib,json,re,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -def sha(data):return hashlib.sha256(data).hexdigest() -def once(text,old,new): - if text.count(old)!=1:raise ValueError('Portrait anchor differs: '+old[:120]) - return text.replace(old,new,1) -def write(path,data): - path.parent.mkdir(parents=True,exist_ok=True) - if not path.is_file() or path.read_bytes()!=data:path.write_bytes(data) -def compose(rows,out): - if len(rows)!=2 or {Path(r['path']).name for r in rows}!={'runtime_ui.cpp','runtime_mod_library_ui.inl'}:raise ValueError('Portrait source closure differs') - files={p.name:p.read_bytes() for p in (AUTH/'native').glob('*')};swaps=[] - for row in rows: - source=Path(row['path']);raw=source.read_bytes();text=raw.decode().replace('\r\n','\n') - if source.suffix=='.cpp': - text='#include "'+(out/'character_portraits.hpp').as_posix()+'"\n'+text - text=once(text,'#include "runtime_mod_library_ui.inl"','#include "'+(out/'runtime_mod_library_ui.inl').as_posix()+'"') - text=once(text,' ReleaseModStoreTextures();',' ReleaseCharacterPortraits();\n ReleaseModStoreTextures();') - text=once(text,' g_mod_store_renderer = renderer;',' g_mod_store_renderer = renderer;\n g_character_portrait_renderer = renderer;') - else: - text=once(text,'// ------------------------------------------------------------ library data', - '#include "'+(out/'character_portraits_ui.inl').as_posix()+'"\n\n// ------------------------------------------------------------ library data') - text=once(text,' view.info = "Custom racer";', - ' view.info = "Custom racer";\n view.preview_height=100;view.preview=[item](float w,float h){DrawCharacterPortrait(item,w,h);};') - def relative(m): - path=Path(m[1]);path=path if path.is_absolute() else source.parent/path - return '#include "'+path.resolve().as_posix()+'"' if path.is_file() else m[0] - text=re.sub(r'^#include "([^"\n]+)"',relative,text,flags=re.M) - files[source.name]=text.encode();swaps.append({**row,'new':str(out/source.name),'input_sha256':sha(raw),'output_sha256':sha(text.encode()),'compiled':source.suffix=='.cpp'}) - return swaps,files -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);a=p.parse_args() - out=a.output.resolve() - if out.is_relative_to(ROOT):raise ValueError('Portrait outputs must remain outside checkout') - rows,files=compose(json.loads(a.inputs.read_text()),out) - for name,data in files.items():write(out/name,data) - write(out/'swaps.json',(json.dumps(rows,indent=2)+'\n').encode()) - print('Character portraits staged through the existing vehicle card preview hook.') diff --git a/patches/character-library-portraits/run_checks.py b/patches/character-library-portraits/run_checks.py deleted file mode 100644 index 09dff488..00000000 --- a/patches/character-library-portraits/run_checks.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Run the production portrait-loader data fixtures without starting a launcher.""" -from pathlib import Path -import argparse,hashlib,json,os,re,subprocess -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('exe','output'):p.add_argument('--'+name,type=Path,required=True) - a=p.parse_args() - if a.output.exists():raise ValueError('Choose a fresh portrait report') - run=subprocess.run([str(a.exe.resolve())],capture_output=True,timeout=30, - creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) - a.output.with_suffix('.log').write_bytes(run.stdout+run.stderr) - match=re.search(rb'(\d+) character portrait checks passed',run.stdout) - if run.returncode or not match or int(match[1])<14:raise ValueError('Portrait checks failed: '+(run.stdout+run.stderr).decode(errors='replace')) - a.output.write_text(json.dumps({'exit_code':run.returncode,'passed_checks':int(match[1]), - 'executable_sha256':hashlib.sha256(a.exe.read_bytes()).hexdigest(),'game_or_rom_executed':False},indent=2)+'\n') - print('Character portrait data and worker checks passed:',int(match[1])) diff --git a/patches/character-library-portraits/tests/inspect_installed.py b/patches/character-library-portraits/tests/inspect_installed.py deleted file mode 100644 index be754522..00000000 --- a/patches/character-library-portraits/tests/inspect_installed.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Inspect installed portrait artifacts as data through the compiled production loader.""" -from pathlib import Path -import argparse,hashlib,json,os,re,subprocess -def local(p): - return Path('\\\\?\\'+str(p.resolve())) if os.name=='nt' else p -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('exe','root','output'):p.add_argument('--'+name,type=Path,required=True) - a=p.parse_args();rows=[] - paths=sorted((a.root/'prepared-characters').glob('*/*/character.json')) - if not paths or len(paths)>1024:raise ValueError('Choose a bounded installed character dataset') - for path in paths: - data=local(path).read_bytes();document=json.loads(data);folder=path.parent - result=subprocess.run([str(a.exe.resolve()),'--portrait',str(a.root.resolve()),document['id'],hashlib.sha256(data).hexdigest(), - folder.parent.name,folder.name,document['revision']],capture_output=True,timeout=15, - creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) - match=re.fullmatch(rb'(\d+) (\d+) ([a-f0-9]{64})\s*',result.stdout) - if result.returncode or not match:raise ValueError('Portrait data decode failed for '+document['name']+': '+result.stderr.decode(errors='replace')) - rows.append({'manifest_sha256':hashlib.sha256(data).hexdigest(),'width':int(match[1]),'height':int(match[2]),'rgba_sha256':match[3].decode()}) - a.output.write_text(json.dumps({'artifacts':rows,'count':len(rows),'game_or_rom_executed':False},indent=2)+'\n') - print('Installed character portraits decoded as data:',len(rows)) diff --git a/patches/character-library-portraits/tests/portrait_checks.cpp b/patches/character-library-portraits/tests/portrait_checks.cpp deleted file mode 100644 index 3a628bb5..00000000 --- a/patches/character-library-portraits/tests/portrait_checks.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include "character_portraits.hpp" -#include "legacy_mod_format.hpp" -#include "legacy_character_artifact.hpp" -#include -#include -#include -#include -#include -using namespace dkr; -unsigned checks=0; -void check(bool b,const char* why){++checks;if(!b)throw std::runtime_error(why);} -templatevoid rejects(F fn,const char* why){bool failed=false;try{fn();}catch(const std::exception&){failed=true;}check(failed,why);} -int main(int argc,char** argv)try { - namespace fs=std::filesystem;using nlohmann::json;using namespace character_portraits; - if(argc==8&&std::string_view(argv[1])=="--portrait") { - const auto image=load({mods::utf8_path(argv[2]),argv[3],argv[4],argv[5],argv[6],argv[7],{}}); - std::cout<(bytes.data()),bytes.size());} - json document={{"schema",1},{"adapter",mods::CharacterAdapterVersion},{"id",request.id},{"revision",request.revision},{"portrait",122}, - {"records",json::array({{{"section",4},{"record",122},{"hash",hash},{"size",bytes.size()}}})}}; - const auto text=document.dump();request.artifact=mods::sha256(mods::View(reinterpret_cast(text.data()),text.size())); - std::ofstream(folder/"character.json",std::ios::binary|std::ios::trunc)< result; - const auto until=std::chrono::steady_clock::now()+std::chrono::seconds(5); - while(!result&&std::chrono::steady_clock::now()rgba==image.rgba,"background decode publishes immutable image"); - check(service.request(request)==result,"same identity reuses immutable result");} - auto invalid=request;invalid.storage="../escape";rejects([&]{load(invalid);},"path traversal rejected"); - invalid=request;invalid.artifact=std::string(64,'e');rejects([&]{load(invalid);},"changed manifest rejected"); - {std::ofstream(folder/"blobs"/blob,std::ios::binary|std::ios::trunc)<<"bad";} - rejects([&]{load(request);},"changed/truncated blob rejected"); - fixture(0,{1,2,3,4,5,6,7,8});check(load(request).rgba==mods::Bytes({1,2,3,4,5,6,7,8}),"RGBA32 native channels"); - fixture(4,{9,44,177,255});check(load(request).rgba==mods::Bytes({9,9,9,44,177,177,177,255}),"IA16 portrait alpha"); - fixture(5,{0xF1,0x24});check(load(request).rgba==mods::Bytes({255,255,255,17,34,34,34,68}),"IA8 portrait alpha"); - fixture(1,{0xFF});rejects([&]{load(request);},"truncated texels rejected"); - fixture(1,{},0);rejects([&]{load(request);},"zero dimensions rejected"); - fixture(15,{0,0,0,0});rejects([&]{load(request);},"unknown format rejected"); - fixture(7,{0});rejects([&]{load(request);},"palette format requires matching imported ROM"); - std::cout</native -DKR_RELEASE_VERSION=1.0.7+beta.5 -BUILD_TESTING=OFF -``` - -Build Windows and Linux x86_64/AppImage. No game, mod execution, replay tests, -CTest or self-tests are run by this pipeline. Gameplay checks belong to the -user: missing portraits, arena signs, two custom racers sharing a donor, stock -racers, online host/client downloads, and unchanged existing save progress. diff --git a/patches/character-portraits/inspect_packages.py b/patches/character-portraits/inspect_packages.py deleted file mode 100644 index e043a300..00000000 --- a/patches/character-portraits/inspect_packages.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Static archive/binary inspection, including the previous save fix.""" -from pathlib import Path -import argparse,contextlib,io,json,runpy,shutil,sys,zipfile - -p=argparse.ArgumentParser(description=__doc__);p.add_argument('task',type=Path);a=p.parse_args() -root=Path(__file__).resolve().parents[2] -# Reuse the established archive/privacy/architecture/save-fix inspection. -# This is a fresh log alias, never a generated code or executable edit. -source=a.task/'Windows-configure.log';alias=a.task/'windows-version-configure.log' -if alias.exists(): - if alias.read_bytes()!=source.read_bytes():raise ValueError('Configure log alias is stale') -else:shutil.copyfile(source,alias) -sys.argv=[str(root/'patches/save-layout/inspect_packages.py'),str(a.task)] -captured=io.StringIO() -with contextlib.redirect_stdout(captured): - old=runpy.run_path(str(root/'patches/save-layout/inspect_packages.py'),run_name='__main__') -report=json.loads(captured.getvalue()) -with zipfile.ZipFile(old['win']) as archive: - names=archive.namelist() - if not any(n.endswith('/CHARACTER-PORTRAIT-NOTES.md') for n in names):raise ValueError('Portrait notes missing') - windows=archive.read(next(n for n in names if n.endswith('/libexec/dkr-r/DKR-R-Runtime.exe'))) -linux=old['read']('usr/bin/DKR-R') -for name,data in (('Windows',windows),('Linux',linux)): - for marker in (b'Retained portrait failed its source integrity check.',b'Invalid recovered source portrait.',b':portrait-source-v1:'): - if marker not in data:raise ValueError(name+' is missing a compiled portrait correction') -report['character_portraits']='Verified native recovery markers, matching rollback contracts and packaged portrait notes' -print(json.dumps(report,indent=2)) diff --git a/patches/character-portraits/inspect_sources.py b/patches/character-portraits/inspect_sources.py deleted file mode 100644 index 32043729..00000000 --- a/patches/character-portraits/inspect_sources.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Static source/provenance inspection only. Does not execute game/mod code.""" -from pathlib import Path -import argparse,contextlib,hashlib,json,re,sys - -ROOT=Path(__file__).resolve().parents[2] -sys.path.insert(0,str(ROOT/'scripts')) -import character_portrait_pipeline as pipeline -from save_layout_pipeline import DEF - -p=argparse.ArgumentParser(description=__doc__) -p.add_argument('task',type=Path);p.add_argument('baseline',type=Path) -a=p.parse_args() -with contextlib.redirect_stdout(sys.stderr):pipeline.verify_native(a.task/'native') - -def functions(folder): - out={} - for file in folder.glob('*.c'): - text=file.read_text();matches=list(DEF.finditer(text)) - for i,m in enumerate(matches): - if m[1] in out:raise ValueError('Duplicate CPU definition') - end=matches[i+1].start() if i+1at("records"); - if(!source_records.is_array() || source_records.size()>65536) - throw Error("Character source portrait record list is invalid."); - const json* portrait_record=nullptr; - for(const auto& record:source_records) - if(record.at("section")==4 && record.at("record")==portrait_id) { - if(portrait_record)throw Error("Ambiguous retained character portrait."); - portrait_record=&record; - } - // Unchanged source records inherit the verified original. - const auto original_portrait=stock->record(4,portrait_id); - character.source_portrait.assign(original_portrait.begin(),original_portrait.end()); - if(portrait_record) { - const auto hash=field(*portrait_record,"sha256"); - if(!digest(hash))throw Error("Invalid retained portrait digest."); - const auto file=source/"blobs"/hash; - if(std::filesystem::is_symlink(file) || !std::filesystem::is_regular_file(file)) - throw Error("Retained portrait is missing. Prepare this character's source import again."); - auto bytes=read_file(file,4*MiB); - if(sha256(bytes)!=hash || bytes.size()!=portrait_record->at("size").get()) - throw Error("Retained portrait failed its source integrity check."); - character.source_portrait=std::move(bytes); - } - // Decode/validate every frame now, on the preparation worker. - character_portrait_changed(character.source_portrait,original_portrait); - character.source_portrait_id=portrait_id; - } diff --git a/patches/character-portraits/package-windows.ps1 b/patches/character-portraits/package-windows.ps1 deleted file mode 100644 index 5761cbf5..00000000 --- a/patches/character-portraits/package-windows.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -param( - [Parameter(Mandatory=$true)][string]$TaskRoot, - [string]$CompiledRoot='D:/DiddykongWorkFolder/custom-magic-codes-20261015/windows-build' -) -$ErrorActionPreference='Stop' -$projectRoot=Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -& (Join-Path $projectRoot 'patches/save-layout/package-windows.ps1') -TaskRoot $TaskRoot -CompiledRoot $CompiledRoot -if($LASTEXITCODE -ne 0){throw 'Windows packaging failed.'} -$notes=Join-Path $PSScriptRoot 'README.md' -Copy-Item -LiteralPath $notes -Destination (Join-Path $TaskRoot 'release/CHARACTER-PORTRAIT-NOTES.md') -$zip=Join-Path $TaskRoot 'release/DKR-R-1.0.7+beta.5-Windows-x64.zip' -$archive=[IO.Compression.ZipFile]::Open($zip,[IO.Compression.ZipArchiveMode]::Update) -try { - [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive,$notes, - 'DKR-R-1.0.7+beta.5-Windows-x64/CHARACTER-PORTRAIT-NOTES.md') | Out-Null -} finally {$archive.Dispose()} -Write-Host ('Final portrait ZIP SHA-256: '+(Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash) diff --git a/patches/checkpoint-clock-repair/IMPLEMENTATION.txt b/patches/checkpoint-clock-repair/IMPLEMENTATION.txt deleted file mode 100644 index 95866f77..00000000 --- a/patches/checkpoint-clock-repair/IMPLEMENTATION.txt +++ /dev/null @@ -1,59 +0,0 @@ -CHECKPOINT CLOCK REPAIR - PLAYTEST 11 -==================================== - -Evidence --------- -Client attachment d1b13989-4882-4849-bd91-e0165baa8e4f, Playtest 10: - lines 247-248: prepared launch and committed stage 1 accepted. - lines 405-409: owned world ready, admission begun, first offer accepted. - 43 accepted offers; all admission-progress receiving byte counts are zero; - no decoded state/Ready/release; line 475 names the 120-second admission timeout. -Host runtime.log from that same attempt: - countdown and all required launch receipts succeeded; - 18,284,936 raw state bytes encoded to 1,555,134 bytes; repeated offers; - prepared remains 1 of 2. The failure is after launch, before state admission. - -Confirmed source cause ----------------------- -The simulation worker captures now, then invokes PartyRuntime::pulse(now). -Pulse drains incoming messages, then invokes transfer(now). -The Offer handler called receiver.begin(...Clock::now()), setting started_ to -a LATER instant than pulse's now. CheckpointReceiver::expired(now) explicitly -rejects now < started_ as a backwards-clock safety condition. transfer(now) -therefore cancelled the newly started receiver IN THE SAME PULSE and sent Need. -The outgoing sequence was OfferAck followed by Need. Even when the host accepted -that receipt, Need replaced the delivery and the client no longer retained the -receiver for its arriving chunks. That explains the repeating zero-byte loop. - -Fix ---- -Pass pulse's monotonic now into message handling, receiver.begin, receiver.receive -and phase transitions. The existing expiry check uses that exact same instant. -No receiver timeout or backwards-clock guard is removed. No transfer is treated -as admitted without integrity verification and canonical state recapture. -Duplicate matching active offers acknowledge without resetting partial state; -conflicting retries still fail. Bounded event logs show receipt/first chunk/real -expiry, without logging addresses, credentials, saves or checkpoint contents. - -Scope and preservation ----------------------- -Only patches/checkpoint-clock-repair is authored. The overlay composes after -Playtest 10 and stages two first-party cpp files in external build directories: -party_runtime.cpp and experimental_runtime.cpp (compatibility identity only). -The fix's contract hash is included in the runtime compatibility identity, so -old peers cannot silently join a repaired runtime. All checked-out dependencies, -RecompiledFuncs and RecompiledPatches stay untouched. Countdown, host settings, -original lobby/spectator UI, swaps, invites, graphics and mods are retained. - -Recovery snapshot: -D:/DiddykongWorkFolder/checkpoint-clock-repair-20261023/baseline - -Verification ------------- -The source audit re-composes both generated outputs, verifies final compiler -bindings, re-runs retained contract audits, checks unchanged pre-existing files -against the recovery snapshot, and requires the one-clock packet/expiry ordering. -Receiver size, digest, backwards-time and timeout safeguards are checked intact. -Windows and Linux compile/link; static binary and ZIP/AppImage inspection only. -No target/test/game/mod/AppImage execution. User gameplay/network acceptance -remains necessary. No push, public release or updater signing is performed. diff --git a/patches/checkpoint-clock-repair/PLAYTEST.txt b/patches/checkpoint-clock-repair/PLAYTEST.txt deleted file mode 100644 index 04e39ce0..00000000 --- a/patches/checkpoint-clock-repair/PLAYTEST.txt +++ /dev/null @@ -1,33 +0,0 @@ -DKR-R 1.0.9 - Spectator/online checkpoint repair playtest 11 -======================================================== - -Use this complete fresh package on EVERY PC in the lobby. Do not mix playtests -or copy only the executable into an older folder. Existing saves/mods stay put. - -The black "Syncing game" screen in Playtest 10 had a checkpoint timing bug. -An offer started the receiver with a fresh timestamp, but its expiry check used -the earlier timestamp from the start of that same update. The backwards-clock -safety check cancelled the brand-new transfer immediately. The client sent an -acknowledgement followed by a new-checkpoint request, repeating until timeout. - -Packet handling and receiver expiry now use the SAME monotonic owner timestamp. -Duplicate offers preserve the transfer in progress. The existing transfer size, -30-second no-progress / five-minute maximum limits, 120-second racing admission -limit, authentication, integrity hashes and exact-state admission remain intact. -New logs identify offer acknowledgement, first data, and genuine transfer expiry. - -The five-second countdown, original lobby design, spectators, swaps, invites, -Rollback connection check, mod compatibility and save protection are retained. -No graphics/gameplay/dependency changes are included in this repair. - -PLEASE CHECK -1. Two ready racers: five-second countdown, then game/character/track select. -2. Start a race; return to the lobby and start again. -3. Repeat with three/four racers, and with spectators enabled/disabled. -4. Join a spectator, change views and try a safe racing-slot swap. - -If anything stops, close normally and retain BOTH host and client runtime.log. -Look for [party][checkpoint] offer acknowledged / first chunk received / decoded -and [party][release]. No game, mod, test executable or AppImage was run here. -Compilation/linking and static checks only; gameplay acceptance remains yours. -Nothing was pushed, published or signed for automatic updating. diff --git a/patches/checkpoint-clock-repair/ReleaseOverlay.cmake b/patches/checkpoint-clock-repair/ReleaseOverlay.cmake deleted file mode 100644 index ecaec7f1..00000000 --- a/patches/checkpoint-clock-repair/ReleaseOverlay.cmake +++ /dev/null @@ -1,45 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../party-start-repair/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_checkpoint_clock_repair) - set(auth "${DKRPORT_ROOT}/patches/checkpoint-clock-repair") - set(stage "${CMAKE_BINARY_DIR}/generated/checkpoint-clock-repair") - file(MAKE_DIRECTORY "${stage}") - get_target_property(paths DKRPortGame SOURCES) - set(rows "[") - set(separator "") - foreach(path IN LISTS paths) - file(TO_CMAKE_PATH "${path}" path) - if(path MATCHES "/(experimental_runtime|party_runtime)[.]cpp$") - string(APPEND rows "${separator}{\"target\":\"DKRPortGame\",\"path\":\"${path}\"}") - set(separator ",") - endif() - endforeach() - string(APPEND rows "]") - file(WRITE "${stage}/inputs.json" "${rows}") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${auth}/pipeline.py" "${auth}/transforms.py") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" --inputs "${stage}/inputs.json" --output "${stage}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Checkpoint clock repair could not stage") - endif() - file(READ "${stage}/swaps.json" swaps) - string(JSON count LENGTH "${swaps}") - math(EXPR last "${count}-1") - foreach(index RANGE ${last}) - string(JSON old GET "${swaps}" ${index} path) - string(JSON next GET "${swaps}" ${index} new) - get_target_property(directory DKRPortGame SOURCE_DIR) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage};${prior};${includes}") - dkr_repair_replace_source(DKRPortGame "${old}" "${next}") - endforeach() - endfunction() - cmake_language(DEFER CALL dkr_apply_checkpoint_clock_repair) -endif() diff --git a/patches/checkpoint-clock-repair/audit.py b/patches/checkpoint-clock-repair/audit.py deleted file mode 100644 index a9f9cb12..00000000 --- a/patches/checkpoint-clock-repair/audit.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Verify the clock ordering, retained launch contracts and final source bindings.""" -from pathlib import Path -import argparse,hashlib,importlib.util,json,sys -sys.dont_write_bytecode=True -import pipeline -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-spectator-playtest.11' -def need(value,message): - if not value:raise ValueError(message) -def load(name,path): - s=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(s);s.loader.exec_module(m);return m -def sha(path):return pipeline.sha(path.read_bytes()) -def inspect(stage,binary=None,baseline=None,previous=None): - swaps,outputs=pipeline.compose(json.loads((stage/'inputs.json').read_text()),stage) - need(swaps==json.loads((stage/'swaps.json').read_text()),'Clock staging receipts changed') - for name,data in outputs.items(): - need(not (stage/name).resolve().is_relative_to(ROOT),'Generated clock output escaped build') - need((stage/name).read_bytes()==data,'Clock output differs from authored pipeline: '+name) - def mapped(rows): - result=[] - for row in rows: - found=[r for r in swaps if Path(r['path'])==Path(row['new'])] - need(len(found)<=1,'Ambiguous clock source mapping') - result.append({**row,'new':found[0]['new']} if found else row) - return result - saved={n:sys.modules.get(n) for n in ('pipeline','transforms')} - try: - sys.modules['transforms']=load('checkpoint_prior_transforms',ROOT/'patches/party-start-repair/transforms.py') - sys.modules['pipeline']=load('checkpoint_prior_pipeline',ROOT/'patches/party-start-repair/pipeline.py') - prior=load('checkpoint_prior_audit',ROOT/'patches/party-start-repair/audit.py') - finally: - for n,v in saved.items(): - if v is None:sys.modules.pop(n,None) - else:sys.modules[n]=v - prior.VERSION=VERSION - bind=prior.bindings - prior.bindings=lambda old_stage,rows:bind(old_stage,mapped(rows)) - retained=prior.inspect(stage.parent/'party-start-repair') - if previous: - old=json.loads(previous.read_text())['audit'] - need(retained['party_start_contract']==old['party_start_contract'],'Accepted countdown/start layer changed') - need(retained['retained']['lobby_link_contract']==old['retained']['lobby_link_contract'],'Accepted lobby layer changed') - count=0;planning=[] - if baseline: - status=(baseline.parent/'GIT-STATUS.txt').read_text() - for row in json.loads(baseline.read_text()): - path=ROOT/row['path'];current=sha(path) - if row['path'].startswith('patches/lobby-discovery-plan-20261023/'): - need('?? '+row['path'] in status,'Cannot exempt tracked planning work') - planning.append({'path':row['path'],'before_sha256':row['sha256'],'current_sha256':current,'note':'Pre-existing concurrent untracked planning; not authored by this repair.'}) - else:need(current==row['sha256'],'Pre-existing source changed: '+row['path']);count+=1 - party=outputs['party_runtime.cpp'].decode();runtime=outputs['experimental_runtime.cpp'].decode() - start=party.index(' void message(');end=party.index(' void sample(',start);body=party[start:end] - need('Clock::time_point now' in body.splitlines()[0],'Message owner clock was not passed') - need('Clock::now()' not in body,'Message handling samples a later clock') - need(party.count('message(')==3,'Review all packet and local-swap dispatch sites before changing the clock contract') - for marker in ('try {message(packet.source,packet.bytes,now);}', - 'message(local,envelope.b,now);', - 'receiver.begin(offer,match,world.contract().schema,offer.bytes,now,e)', - 'receiver.receive(chunk,now,e)','receiver.active()&&receiver.expired(now)', - 'receiver.active()||held||decoding.valid()', - 'Conflicting checkpoint offer retry.', - '[party][checkpoint] first chunk received', - '[party][checkpoint] offer acknowledged', - '[party][checkpoint] expired; received=', - 'Required party admission did not complete within 120 seconds.', - 'world.admit_initial(*held->state,e)', - 'digest!=delivery->second.raw_digest'): - need(marker in party,'Missing clock/admission invariant: '+marker) - pulse=party[party.index(' bool pulse('):] - need(pulse.index('message(packet.source,packet.bytes,now)')digest','o.bytes<=ConfirmedFeed::MaximumState'): - need(marker in receiver,'Receiver safety contract removed: '+marker) - need(runtime.count(pipeline.transforms.contract())==1,'Clock fix compatibility identity not pinned') - need({Path(r['new']).name for r in swaps}=={'party_runtime.cpp','experimental_runtime.cpp'},'Clock repair changed another unit') - if binary: - data=binary.read_bytes() - for marker in (VERSION.encode(),b'[party][checkpoint] first chunk received',b'[party][checkpoint] offer acknowledged', - b'[party][checkpoint] expired; received=',b'[lobby][countdown] five seconds', - b'[lobby][start-release]',b'PLAYER SWAP REQUESTS',b'BACK TO MY RACE'): - need(marker in data,'Missing linked repair or retained feature: '+repr(marker)) - return {'schema':1,'version':VERSION,'checkpoint_clock_contract':pipeline.transforms.contract(), - 'clock_order':'One monotonic pulse timestamp supplies message transitions, receiver.begin/receive and subsequent expiry; no later transfer start is compared to an earlier expiry instant.', - 'unchanged_authored_files':count,'concurrent_planning':planning,'swaps':swaps, - 'compiler_bindings':bind(stage,swaps),'retained':retained, - 'validation':'Compile/link and static source/binary/archive checks only. No game, mod, test executable or AppImage was run. Real-network playtest remains with the user.'} -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('stage','binary','baseline','previous','output'):p.add_argument('--'+name,type=Path,required=name=='stage') - a=p.parse_args();result=inspect(a.stage,a.binary,a.baseline,a.previous) - if a.output:a.output.write_text(json.dumps(result,indent=2)+'\n') - else:print(json.dumps(result,indent=2)) diff --git a/patches/checkpoint-clock-repair/configure.py b/patches/checkpoint-clock-repair/configure.py deleted file mode 100644 index 6dae0ce2..00000000 --- a/patches/checkpoint-clock-repair/configure.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Configure the checkpoint clock repair for compile/static inspection only.""" -from pathlib import Path -import argparse,importlib.util,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-spectator-playtest.11' -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','capsule','log'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--cmake',default='cmake');a=p.parse_args() - spec=importlib.util.spec_from_file_location('checkpoint_release',ROOT/'patches/release-prep/v1.0.7/release.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) - m.AUTH=AUTH;m.VERSION=VERSION;m.configure(a.build,a.log,a.cmake,a.capsule) diff --git a/patches/checkpoint-clock-repair/package.py b/patches/checkpoint-clock-repair/package.py deleted file mode 100644 index dc0b7694..00000000 --- a/patches/checkpoint-clock-repair/package.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Allowlisted checkpoint repair Windows ZIP and Linux AppImage.""" -from pathlib import Path -import argparse,importlib.util,json,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-spectator-playtest.11' -def load(name,path): - s=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(s);s.loader.exec_module(m);return m -def main(a): - if a.output.exists():raise ValueError('Choose a fresh package directory') - binary=a.compiled/('libexec/dkr-r/DKR-R-Runtime.exe' if a.platform=='windows' else 'DKR-R') - checked=load('checkpoint_package_audit',AUTH/'audit.py').inspect(a.stage,binary,a.baseline,a.previous) - base=load('checkpoint_package_allowlist',ROOT/'patches/release-prep/v1.0.7/package_release.py');base.VERSION=VERSION - base.notes=lambda root,label:base.copy(AUTH/'PLAYTEST.txt',root/'README.txt') - a.output.mkdir(parents=True) - root,artifact,files=getattr(base,a.platform)(a) - receipt={'schema':11,'version':VERSION,'platform':a.platform,'arch':a.arch, - 'artifact':{'name':artifact.name,'sha256':base.sha(artifact),'bytes':artifact.stat().st_size}, - 'files':files,'audit':checked,'published':False,'automatic_updates_signed':False,'validation':checked['validation']} - (a.output/'BUILD-RECEIPT.json').write_text(json.dumps(receipt,indent=2)+'\n') - (a.output/'SHA256SUMS.txt').write_text(base.sha(artifact)+' '+artifact.name+'\n') - base.copy(AUTH/'PLAYTEST.txt',a.output/'PLAYTEST.txt') - print('Checkpoint repair packaged without target execution:',artifact) -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('platform',choices=('windows','linux')) - for name in ('compiled','template','output','stage','trophy','baseline','previous'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--arch',choices=('x86_64','aarch64'),default='x86_64');p.add_argument('--runtime-image',type=Path) - main(p.parse_args()) diff --git a/patches/checkpoint-clock-repair/pipeline.py b/patches/checkpoint-clock-repair/pipeline.py deleted file mode 100644 index b2f6f23c..00000000 --- a/patches/checkpoint-clock-repair/pipeline.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Stage only the checkpoint clock fix and its runtime compatibility identity.""" -from pathlib import Path -import argparse,hashlib,json,re,sys -sys.dont_write_bytecode=True -import transforms -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -INC=re.compile(r'^#include "([^"\n]+)"',re.M) -def sha(data):return hashlib.sha256(data).hexdigest() -def write(path,data): - path.parent.mkdir(parents=True,exist_ok=True) - if not path.is_file() or path.read_bytes()!=data:path.write_bytes(data) -def compose(rows,stage): - expected={('DKRPortGame','experimental_runtime.cpp'),('DKRPortGame','party_runtime.cpp')} - if len(rows)!=2 or {(r['target'],Path(r['path']).name) for r in rows}!=expected:raise ValueError('Unexpected checkpoint clock inputs') - outputs={};swaps=[] - for row in rows: - source=Path(row['path']);raw=source.read_bytes();text=raw.decode().replace('\r\n','\n') - text={'experimental_runtime.cpp':transforms.runtime,'party_runtime.cpp':transforms.party_runtime}[source.name](text) - def adjacent(m): - p=source.parent/m[1] - return '#include "'+p.resolve().as_posix()+'"' if p.is_file() else m[0] - text=INC.sub(adjacent,text);outputs[source.name]=text.encode() - swaps.append({**row,'new':str(stage/source.name),'input_sha256':sha(raw),'output_sha256':sha(text.encode())}) - return swaps,outputs -def main(a): - stage=a.output.resolve() - if stage.is_relative_to(ROOT):raise ValueError('Checkpoint clock stage must be external') - swaps,outputs=compose(json.loads(a.inputs.read_text()),stage) - for name,data in outputs.items():write(stage/name,data) - write(stage/'swaps.json',(json.dumps(swaps,indent=2)+'\n').encode()) - print('Owner-consistent checkpoint clock staged; existing integrity/timeout limits preserved; no target execution.') -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);main(p.parse_args()) diff --git a/patches/checkpoint-clock-repair/transforms.py b/patches/checkpoint-clock-repair/transforms.py deleted file mode 100644 index 9fc24958..00000000 --- a/patches/checkpoint-clock-repair/transforms.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Keep checkpoint admission on its owner's single monotonic pulse timestamp.""" -from pathlib import Path -import hashlib -AUTH=Path(__file__).resolve().parent -PRIOR_IDENTITY='893bbedb02a3c87771e75befab107a2ce54641073742be69e97b995a9d0a3bbf' - -def once(text,before,after): - if text.count(before)!=1:raise ValueError('Checkpoint clock anchor changed: '+before[:160]) - return text.replace(before,after,1) - -def contract(): - return hashlib.sha256((AUTH/'transforms.py').read_bytes().replace(b'\r\n',b'\n')).hexdigest() - -def runtime(text): - return once(text,'base+="'+PRIOR_IDENTITY+'";','base+="'+PRIOR_IDENTITY+'";base+="'+contract()+'";') - -def party_runtime(text): - # pulse(now) drains packets before calling transfer(now). Starting a receiver - # with a later Clock::now() made expired(now)'s backwards-clock guard fire in - # that SAME pulse. It immediately sent Need after OfferAck, repeatedly retiring - # valid offers before any data could arrive. Keep all packet transitions and - # the subsequent expiry check on the same owner-supplied monotonic instant. - text=once(text,'void message(MemberId source,std::span bytes) {', - 'void message(MemberId source,std::span bytes,Clock::time_point now) {') - text=once(text,'try {message(packet.source,packet.bytes);}', - 'try {message(packet.source,packet.bytes,now);}') - text=once(text,'message(local,envelope.b);','message(local,envelope.b,now);') - text=once(text,'const auto now=Clock::now();auto& previous=requested_at[source];', - 'auto& previous=requested_at[source];') - text=once(text,'receiver.begin(offer,match,world.contract().schema,offer.bytes,Clock::now(),e)', - 'receiver.begin(offer,match,world.contract().schema,offer.bytes,now,e)') - text=once(text,'const auto result=receiver.receive(chunk,Clock::now(),e);', - 'const bool first=receiver.received_bytes()==0;\n const auto result=receiver.receive(chunk,now,e);\n if(first&&result==CheckpointReceiver::Result::Accepted)std::fprintf(stderr,"[party][checkpoint] first chunk received\\n");') - start=text.index(' void message(');end=text.index(' void sample(',start) - body=text[start:end] - if body.count('phase_at=Clock::now()')!=2:raise ValueError('Unexpected message phase clocks') - body=body.replace('phase_at=Clock::now()','phase_at=now') - if 'Clock::now()' in body:raise ValueError('Packet handling still introduces a later timestamp') - text=text[:start]+body+text[end:] - # A duplicate offer also must not reset partial receiving/admission state. - text=once(text,'if(offer.transfer==active_transfer&&(held||decoding.valid())){', - 'if(offer.transfer==active_transfer&&(receiver.active()||held||decoding.valid())){') - text=once(text,'if(server())if(auto d=deliveries.find(source);d!=deliveries.end()&&d->second.sender.offer().transfer==transfer)d->second.accepted=true;', - '''if(server())if(auto d=deliveries.find(source);d!=deliveries.end()&&d->second.sender.offer().transfer==transfer&&!d->second.accepted){ - d->second.accepted=true;d->second.progress=now; - if(phase==Phase::Admission)std::fprintf(stderr,"[party][checkpoint] offer acknowledged\\n"); - }''') - return once(text,'if(receiver.active()&&receiver.expired(now)) {receiver.cancel();receiving.reset();send(host,Msg::Need);}', - '''if(receiver.active()&&receiver.expired(now)) { - std::fprintf(stderr,"[party][checkpoint] expired; received=%zu; requesting a fresh transfer\\n",receiver.received_bytes()); - receiver.cancel();receiving.reset();send(host,Msg::Need); - }''') diff --git a/patches/credits-viewport/NativeOverlay.cmake b/patches/credits-viewport/NativeOverlay.cmake deleted file mode 100644 index 9eeb9f07..00000000 --- a/patches/credits-viewport/NativeOverlay.cmake +++ /dev/null @@ -1,37 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../title-text/NativeOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_credits_viewport) - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${DKRPORT_ROOT}/patches/credits-viewport/pipeline.py" - verify --output "${DKR_CREDITS_VIEWPORT_STAGE}" RESULT_VARIABLE status) - if(NOT status EQUAL 0) - message(FATAL_ERROR "Credits Patch Pipeline receipt failed") - endif() - get_target_property(sources DKRPortGame SOURCES) - set(replaced) - set(count 0) - foreach(source IN LISTS sources) - if(source MATCHES "/f3ddkr_rt64\\.cpp$") - math(EXPR count "${count}+1") - set(next "${DKR_CREDITS_VIEWPORT_STAGE}/native/f3ddkr_rt64.cpp") - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${source}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${source}" PROPERTY ${property}) - set_property(SOURCE "${next}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${source}" DIRECTORY) - set_property(SOURCE "${next}" APPEND PROPERTY INCLUDE_DIRECTORIES "${prior}") - list(APPEND replaced "${next}") - else() - list(APPEND replaced "${source}") - endif() - endforeach() - if(NOT count EQUAL 1) - message(FATAL_ERROR "Expected one first-party decoder") - endif() - set_property(TARGET DKRPortGame PROPERTY SOURCES "${replaced}") - message(STATUS "Credits: border-free scene/fades, full-width original-font text") - endfunction() - cmake_language(DEFER CALL dkr_apply_credits_viewport) -endif() diff --git a/patches/credits-viewport/README.md b/patches/credits-viewport/README.md deleted file mode 100644 index 2ba6d0c5..00000000 --- a/patches/credits-viewport/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Full-frame credits - -## Current release composition - -For v1.0.11 use the [release pipeline](../release-prep/v1.0.11/BUILDING.txt). The module notes below -explain this layer; their dated playtest paths are historical inputs, not the -current release command. The accepted water/source capsule preserves the -composed behaviour. Dependencies and generated guest checkouts stay read-only. - -Patch Pipeline-only addition on the owner-accepted corrected title-text stage. -Dependencies, RecompiledFuncs, RecompiledPatches and checked-out runtime sources -remain untouched. No guest memory, simulation, audio, credits timings, ordering, -save state or skip controls change. No application/gameplay tests are run. - -## Source evidence - -menu_credits_init configures the same inset as the old rotating-logo intro: -0,40..320,196 NTSC / 0,38..320,224 PAL, with a full-size custom 3D viewport whose -inclusive bounds give the same 160.5 X centre. Reuse the accepted intro viewport -algorithm in a separately admitted MENU_CREDITS (25) instance. The original -logo helper stays byte-identical. Extend its reviewed scissor/fill rectangles to -the complete native height, centre the 3D viewport, retain both viewport scales, -and let RT64 supply the host aspect. credits_fade uses the same inset rectangle, -so it too covers the complete viewport without changing opacity/timing. - -Credits use postrace_render / draw_menu_elements, not the title-name table. -gCreditsMenuElements contains nine 0x20-byte slots. Headings use FUNFONT (0), -names BIGFONT (2), with ASCII text, horizontal centre alignment and transparent -backgrounds. Their paths enter at X=480, hold at160 and leave at-160. Addresses -for both supported US revisions come from the individual decomp symbol maps. - -Extend the accepted font-pass parser to try those two fonts only in MENU_CREDITS. -Each complete run must match an actual active credit row, its font, colours, -opacity, alignment, atlas, metrics and clipping. Unrecognised shapes retain the -original path. Spaces have separate alignment/cursor kerning rules in retail; -preserve both. Restore signed right-edge positions beyond512 by comparing the -packed coordinate domain. Use at most96 glyphs/1023 scanned words. Centre-path -translation preserves letter sizes and centred layouts on ultrawide screens. -The original title-name matching method remains unchanged and separately gated. - -Only admitted text draws get extended horizontal scissors. Restore each prior -scissor/origin/aspect immediately afterwards. Before fullSync, normalise only -the producer-owned affected framebuffer aggregate to native320 width. This -retains the earlier protection against transient framebuffer/text stretching. -No shaders, rays, extra textures, render targets or GPU waits are introduced. - -## Fade/lower-sky follow-up - -Owner acceptance confirmed the first credits framing/text update, but reported -4:3 fades and lower-sky side bars. The first fill widened its geometry without -also widening the inherited draw scissor. It now uses signed window coordinates -for BOTH, with exact restore and native framebuffer-reference normalisation. -Full 319x239/287 2D/transition scissor resets are normalised to complete native -bounds; partial UI scissors remain untouched. - -The existing dkr_widen_void_primitive hook uses split_sky_horizontal_cover_scale, -which explicitly returns1 for the credits' single-camera layout0. Capture only -the live credits void mesh range from its active ping-pong buffer, final batch -cursor and count. void_generate_primitive flushes at24 vertices, so the range -must cover earlier batches too. Validate its <=2000-byte allocation and the -camera lateral basis. Apply the same camera-horizontal expansion to local -decoded vertices only, preserving Y, forward depth, colours and guest memory. -Disable this path for split layouts to avoid applying their existing fix twice. - -## Build and owner checks - -pipeline.py stage --base --output . -Configure this NativeOverlay.cmake and DKR_CREDITS_VIEWPORT_STAGE, retaining all -previous title/viewport/water/save/Store/SDK stages. Build Windows and Linux, -including its AppImage. Package inspection verifies both credits markers and -every accepted title/water/intro feature. Compile/static inspection is not proof -of visual acceptance; the owner performs that check. - -Check every scene/fade and the rotating character portraits; heading/name -entry/hold/exit; continuation headings; long/spaced/punctuated names; best times -and final/cheat text; 4:3 Fit,16:9,16:10,21:9 and wider; fullscreen/windowed and -resize; original-aspect/Accurate framing; clean transitions back to the logo -intro/title; retained title names and river/waterfall interpolation. diff --git a/patches/credits-viewport/credit_fade.inl b/patches/credits-viewport/credit_fade.inl deleted file mode 100644 index 6f80f231..00000000 --- a/patches/credits-viewport/credit_fade.inl +++ /dev/null @@ -1,34 +0,0 @@ - bool fill(RT64::State& state,const RT64::DisplayList& command) const { - if(!enabled_ || state.rdp->colorImage.width!=320)return false; - const int left=command.p1(12,12),top=command.p1(0,12); - const int right=command.p0(12,12),bottom=command.p0(0,12); - if(!inset(left,top,right,bottom))return false; - auto& rdp=*state.rdp; - state.flush(); - const auto at=rdp.scissorStackSize-1; - const auto previous=rdp.scissorRectStack[at]; - const auto left_origin=rdp.extended.scissorLeftOriginStack[at]; - const auto right_origin=rdp.extended.scissorRightOriginStack[at]; - const auto aspect=rdp.extended.global.rectAspect; - const int height=top==40*4?240:288; - const int gutter=int(std::ceil(640.F*(cover_-1.F))); - // Rectangle origins alone cannot escape a still-native draw scissor. - // Use the same signed window-domain clip as the accepted font path. - auto& clip=rdp.scissorRectStack[at]; - clip.ulx=-gutter;clip.uly=0;clip.lrx=1280+gutter;clip.lry=height*4; - rdp.extended.scissorLeftOriginStack[at]=rdp.extended.scissorRightOriginStack[at]=G_EX_ORIGIN_NONE; - state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); - rdp.setRectAspect(G_EX_ASPECT_ADJUST); - RT64::ExtendedAlignment alignment{}; - alignment.leftOrigin=alignment.rightOrigin=G_EX_ORIGIN_NONE; - rdp.fillRect(-gutter,0,1280+gutter,height*4,alignment); - state.flush(); - rdp.scissorRectStack[at]=previous; - rdp.extended.scissorLeftOriginStack[at]=left_origin; - rdp.extended.scissorRightOriginStack[at]=right_origin; - state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); - rdp.setRectAspect(aspect); - static thread_local bool notice=false; - if(!notice){std::fprintf(stderr,"[credits][fade] signed full-window rectangle and scissor; original opacity\n");notice=true;} - return true; - } diff --git a/patches/credits-viewport/credit_match.inl b/patches/credits-viewport/credit_match.inl deleted file mode 100644 index a23110ae..00000000 --- a/patches/credits-viewport/credit_match.inl +++ /dev/null @@ -1,73 +0,0 @@ - bool match_credit(std::uint32_t element,std::uint32_t env,std::uint32_t prim) { - // Only the actual active ASCII credit rows, never arbitrary dialogue. - const auto name=u32(element+0x14); - if(!valid(name,capacity+1) || u8(element+0x13)!=0 || u8(element+0x12)!=4 || - u16(element+4)!=160 || u16(element+0x1E)!=0 || - font_!=font_base_+std::uint32_t(u8(element+0x11))*0x400U)return false; - const auto expected_env=(std::uint32_t(u8(element+12))<<24)| - (std::uint32_t(u8(element+13))<<16)|(std::uint32_t(u8(element+14))<<8)|u8(element+15); - if(env!=expected_env || prim!=(0xFFFFFF00U|u8(element+16)))return false; - const bool compact=u32(symbols_->kerning)!=0; - int alignment_width=0; - unsigned expected_count=0; - for(unsigned i=0;i<=capacity;++i) { - const auto ch=u8(name+i); - if(!ch)break; - if(i==capacity || ch<32 || ch>=128)return false; - int space=0; - if(ch==32)space=u16(font_+0x24); - else { - const auto data=font_+0x100+(ch-32)*8; - if(u8(data)==255)return false; - space=u16(font_+0x20)?u16(font_+0x20):u8(data+1); - ++expected_count; - } - if(compact && space)--space; - alignment_width+=space; - } - if(!expected_count || expected_count!=count_ || alignment_width>512)return false; - std::array repaired{}; - int advance=0,base_x=0,base_y=0; - unsigned letters=0; - for(unsigned i=0;i<=capacity;++i) { - const auto ch=u8(name+i); - if(!ch) { - const int centre=base_x/4+(alignment_width>>1); - // Retail credits enter at 480, hold at 160 and leave at -160. - if(centre< -161 || centre>481 || base_y<0 || base_y>288*4)return false; - const int shift=int(std::lround((centre-160.F)*(cover_-1.F)*4.F)); - for(unsigned j=0;j0; - // Credits can exceed X=512 while entering from the right. Compare - // the PACKED coordinate domain, then restore the full signed quad. - if((raw.left&4095)!=((clipped?0:left)&4095) || (raw.right&4095)!=(right&4095) || - raw.top!=top || raw.bottom!=bottom || raw.s!=s+(clipped?-left*8:0) || raw.t!=t)return false; - repaired[letters]=raw; - repaired[letters].left=left;repaired[letters].right=right;repaired[letters].s=s; - int space=u16(font_+0x20)?u16(font_+0x20):u8(data+1); - if(compact && space)--space; - advance+=space;++letters; - } - return false; - } diff --git a/patches/credits-viewport/credits_sky_rt64.hpp b/patches/credits-viewport/credits_sky_rt64.hpp deleted file mode 100644 index f77c64e4..00000000 --- a/patches/credits-viewport/credits_sky_rt64.hpp +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once -#include "revision_addresses.hpp" -#include "hle/rt64_state.h" -#include "hle/rt64_rsp.h" -#include -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::presentation { -// The existing lower-void widening hook explicitly excludes layout 0. Credits -// use that single camera, but now expose a wide viewport. Apply the same lateral -// basis expansion to decoder-local vertices, never to the authored guest mesh. -class CreditsSky { - static constexpr std::array,2> symbols_{{ -@CREDITS_VOID_SYMBOLS@ - }}; - std::uint32_t begin_=0,end_=0; - float lateral_x_=0,lateral_z_=0,centre_x_=0,centre_z_=0,length_=1,cover_=1; - static std::uint32_t word(const std::uint8_t* ram,std::uint32_t address) { - std::uint32_t value;std::memcpy(&value,ram+(address&0x7FFFFCU),4);return value; - } - static unsigned byte(const std::uint8_t* ram,std::uint32_t address) {return ram[(address&0x7FFFFFU)^3U];} - static unsigned half(const std::uint8_t* ram,std::uint32_t address) { - std::uint16_t value;std::memcpy(&value,ram+((address&0x7FFFFFU)^2U),2);return value; - } -public: - void begin(const RT64::State& state,float aspect,bool enabled) { - begin_=end_=0; - if(!enabled || !std::isfinite(aspect) || aspect<=4.F/3.F || - word(state.RDRAM,revision_addresses::ViewportLayout)!=0)return; - const bool v80=revision_addresses::CurrentMenuId==revision_addresses::kUsV80.CurrentMenuId; - const auto& symbols=symbols_[v80?1:0]; - if(!word(state.RDRAM,symbols[0]))return; // gVoidData: allocation still alive. - const auto flip=byte(state.RDRAM,symbols[3]); - const auto count=half(state.RDRAM,symbols[4]); - if(flip>1 || count>24 || count%4)return; - const auto first=word(state.RDRAM,symbols[1]+(1-flip)*4)&0x1FFFFFFFU; - const auto tail=word(state.RDRAM,symbols[2])&0x1FFFFFFFU; - const auto end=std::uint64_t(tail)+count*10U; - // void_generate_primitive flushes every 24 vertices. Its final cursor - // plus count covers ALL batches, not merely the last submitted batch. - if(!first || first>=0x800000U || tail0x800000U || - end-first>2000 || (tail-first)%10)return; - const auto value=[&](std::uint32_t p){return std::bit_cast(word(state.RDRAM,p));}; - lateral_x_=value(revision_addresses::VoidLateralX); - lateral_z_=value(revision_addresses::VoidLateralZ); - centre_x_=value(revision_addresses::VoidCentreX); - centre_z_=value(revision_addresses::VoidCentreZ); - length_=lateral_x_*lateral_x_+lateral_z_*lateral_z_; - if(!std::isfinite(length_) || length_<0.5F || length_>1.5F || - !std::isfinite(centre_x_) || !std::isfinite(centre_z_))return; - cover_=std::clamp(aspect/(4.F/3.F),1.F,8.F); - begin_=first;end_=std::uint32_t(end); - } - void vertex(std::uint32_t address,RT64::RSP::Vertex& v) const { - address&=0x1FFFFFFFU; - if(!begin_ || address=end_ || (address-begin_)%10)return; - const float lateral=((float(v.x)-centre_x_)*lateral_x_+(float(v.z)-centre_z_)*lateral_z_)/length_; - const float expansion=lateral*(cover_-1.F); - v.x=std::int16_t(std::clamp(std::lround(float(v.x)+expansion*lateral_x_),-32768L,32767L)); - v.z=std::int16_t(std::clamp(std::lround(float(v.z)+expansion*lateral_z_),-32768L,32767L)); - static thread_local bool notice=false; - if(!notice){std::fprintf(stderr,"[credits][sky] lower backdrop widened along camera lateral basis; horizon unchanged\n");notice=true;} - } -}; -} diff --git a/patches/credits-viewport/package.py b/patches/credits-viewport/package.py deleted file mode 100644 index fdaa80b8..00000000 --- a/patches/credits-viewport/package.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Package credits and all accepted title/water beta 10 repairs, without running DKR-R.""" -from pathlib import Path -import argparse -import importlib.util -import json -import sys -sys.dont_write_bytecode = True -AUTH = Path(__file__).resolve().parent -ROOT = AUTH.parents[1] - - -def load(name, path): - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def main(): - parser = argparse.ArgumentParser(add_help=False) - parser.add_argument('--credits-stage', type=Path, required=True) - args, remaining = parser.parse_known_args() - pipeline = load('dkr_credits', AUTH / 'pipeline.py') - pipeline.verify(args.credits_stage) - title = load('dkr_title_package', ROOT / 'patches/title-text/package.py') - original_load = title.load - def nested_load(name, path): - module = original_load(name, path) - if name == 'dkr_beta10_package': - original_features = module.inspect_features - def features(data): - original_features(data) - for marker in (b'full-frame cinematic; borders removed; original scene proportions', - b'full-width slides; original font proportions; signed glyphs and spaces', - b'signed full-window rectangle and scissor; original opacity', - b'lower backdrop widened along camera lateral basis; horizon unchanged'): - if marker not in data: - raise ValueError('Missing credits repair: ' + repr(marker)) - module.inspect_features = features - original_notes = module.notes - def notes(root, platform): - original_notes(root, platform) - extra = ''' -FULLSCREEN CREDITS UPDATE -Modern + Fit to window expands the credits scene and its fades to the full -viewport. The top, bottom and side borders are removed without stretching the -scene. Moving credit headings/names use the full window width, preserving the -original two fonts, letter sizes, colours, spacing and centred dwell positions. -The music, credits timing, ordering, skip controls and ending text are unchanged. -Accurate and explicitly original-aspect presentation retain their old framing. - -Follow-up: credits fades now use a signed full-window scissor as well as a -full-window rectangle. Ordinary full-screen inclusive scissor resets are -normalised. The single-camera lower-sky mesh is widened along its own lateral -basis in decoder-local vertices, preserving horizon height, depth and colour. - -Includes the owner-accepted corrected title names, native flowing-water -interpolation, border-free logo intro and all previous beta 10 changes. -Compilation and static package inspection only; visual approval is yours. -''' - (root / 'RELEASE-NOTES.txt').write_text((root / 'RELEASE-NOTES.txt').read_text() + extra) - (root / 'FULLSCREEN-CREDITS.txt').write_text(extra.lstrip()) - module.notes = notes - return module - title.load = nested_load - sys.argv = [sys.argv[0]] + remaining - title.main() - path = Path(remaining[remaining.index('--output') + 1]) / 'BUILD-RECEIPT.json' - receipt = json.loads(path.read_text()) - receipt['credits_viewport'] = json.loads((args.credits_stage / 'credits-viewport-receipt.json').read_text()) - receipt['credits_packager_sha256'] = pipeline.sha(Path(__file__)) - path.write_text(json.dumps(receipt, indent=2) + '\n') - - -if __name__ == '__main__': - main() diff --git a/patches/credits-viewport/pipeline.py b/patches/credits-viewport/pipeline.py deleted file mode 100644 index 5a9ecae5..00000000 --- a/patches/credits-viewport/pipeline.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Add border-free credits over the owner-accepted title/water build.""" -from pathlib import Path -import argparse -import hashlib -import json -import os -import re -import sys -sys.dont_write_bytecode = True -AUTH = Path(__file__).resolve().parent -ROOT = AUTH.parents[1] - - -def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def once(text, old, new): - if text.count(old) != 1: - raise ValueError('Credits anchor changed: ' + old[:100]) - return text.replace(old, new) - - -def stage(base, out): - if out.exists() or not out.resolve().is_relative_to(ROOT / 'build'): - raise ValueError('Choose a NEW stage inside build/') - accepted = json.loads((base / 'title-text-receipt.json').read_text()) - for name, expected in accepted['files'].items(): - if sha(base / name) != expected: - raise ValueError('Accepted title/water input changed: ' + name) - inputs = {} - def read(path): - inputs[str(path)] = sha(path) - return path.read_text() - native = out / 'native' - native.mkdir(parents=True) - def put(name, text): - (native / name).write_text(text, encoding='utf-8', newline='\n') - for path in sorted((base / 'native').glob('*.hpp')): - put(path.name, read(path)) - addresses = [] - void_symbols = [] - for revision in (77, 80): - text = read(ROOT / f'extern/dkr-decomp/ver/symbols/symbol_addrs.us.v{revision}.txt') - found = re.findall(r'^gCreditsMenuElements = (0x[0-9A-Fa-f]+);', text, re.M) - if len(found) != 1: - raise ValueError('Ambiguous credit rows symbol') - addresses.append(found[0] + 'U') - values = [] - for name in ('gVoidData', 'gVoidVerts', 'gVoidCurrVerts', 'gVoidVertexFlip', 'gVoidVertCount'): - found = re.findall(r'^' + name + r' = (0x[0-9A-Fa-f]+);', text, re.M) - if len(found) != 1: - raise ValueError('Ambiguous credits backdrop symbol: ' + name) - values.append(found[0] + 'U') - void_symbols.append(' {' + ','.join(values) + '}') - put('credits_sky_rt64.hpp', read(AUTH / 'credits_sky_rt64.hpp').replace('@CREDITS_VOID_SYMBOLS@', ',\n'.join(void_symbols))) - - # Reuse the accepted viewport algorithm in a separately admitted credits - # instance. The original logo helper remains byte-for-byte unchanged. - text = read(base / 'native/intro_viewport_rt64.hpp') - text = text.replace('class IntroViewport', 'class CreditsViewport') - text = text.replace('MENU_LOGOS', 'MENU_CREDITS').replace('[intro][viewport]', '[credits][viewport]') - text = text.replace('full-frame picture; copyright text retained; logo proportions preserved', - 'full-frame cinematic; borders removed; original scene proportions') - text = once(text, ' bool enabled_=false;', ' bool enabled_=false;\n float cover_=1.F;') - text = once(text, ' void begin(bool enabled) {enabled_=enabled;picture_=false;top_=bottom_=0;height_=240;}', ''' void begin(bool enabled,float aspect) { - enabled_=enabled;picture_=false;top_=bottom_=0;height_=240; - cover_=std::clamp(std::isfinite(aspect)?aspect/(4.F/3.F):1.F,1.F,8.F); - }''') - text = once(text, '#include ', '#include \n#include ') - text = once(text, ' if(!picture_)return;', ''' if(!picture_) { - // Ordinary 2D/transition resets use the inclusive 319/239 bounds. - // Keep their native aspect reference complete, without widening - // arbitrary partial UI scissors or changing their projection. - if(r.ulx==0 && r.uly==0 && (r.lrx==319*4 || r.lrx==320*4) && - (r.lry==239*4 || r.lry==240*4 || r.lry==287*4 || r.lry==288*4)) { - r.lrx=320*4;r.lry=r.lry>240*4?288*4:240*4; - state.updateDrawStatusAttribute(RT64::DrawAttribute::Scissor); - } - return; - }''') - start = text.index(' bool fill(') - end = text.index(' template', start) - text = text[:start] + read(AUTH / 'credit_fade.inl') + text[end:] - put('credits_viewport_rt64.hpp', text) - - text = read(base / 'native/title_text_rt64.hpp') - text = once(text, ' static constexpr unsigned capacity=32;', ' static constexpr unsigned capacity=96;') - text = once(text, ' unsigned image_count_=0;', ''' unsigned image_count_=0; - bool credits_=false,quiet_=false; - std::uint32_t font_base_=0,credits_elements_=0; - static constexpr std::array credit_rows_{{''' + ','.join(addresses) + '''}};''') - text = once(text, ' static void skipped(unsigned reason,unsigned detail=0) {', ''' void skipped(unsigned reason,unsigned detail=0) const { - if(quiet_)return;''') - text = once(text, ' bool colour(std::uint32_t env,std::uint32_t prim) const {', ''' bool colour(std::uint32_t env,std::uint32_t prim) const { - if(credits_)return (prim&0xFFFFFF00U)==0xFFFFFF00U; // Exact row colours checked after parsing.''') - text = once(text, 'public:\n void begin', read(AUTH / 'credit_match.inl') + '\npublic:\n void begin') - text = once(text, ' count_=image_count_=0;enabled_=false;ram_=state.RDRAM;', - ' count_=image_count_=0;enabled_=false;credits_=quiet_=false;ram_=state.RDRAM;') - text = once(text, ' if(u32(revision_addresses::CurrentMenuId)!=0 || u32(revision_addresses::TitleRevealTimer)!=0)return;', ''' const auto menu=u32(revision_addresses::CurrentMenuId); - credits_=menu==25; - if(!credits_ && (menu!=0 || u32(revision_addresses::TitleRevealTimer)!=0))return;''') - text = once(text, ' const auto fonts=u32(symbols_->fonts);', ''' credits_elements_=credit_rows_[v80?1:0]; - const auto fonts=u32(symbols_->fonts);font_base_=fonts;''') - text = once(text, ' void prepare(RT64::State& state,const RT64::DisplayList* start) {', ''' void prepare(RT64::State& state,const RT64::DisplayList* start) { - if(!credits_){prepare_one(state,start);return;} - if(!enabled_ || start->p0(16,1) || - (state.rsp->fromSegmented(start->w1)&0x1FFFFFFFU)!=(symbols_->begin&0x1FFFFFFFU))return; - for(const unsigned font:std::array{2,0}) { - font_=font_base_+font*0x400;quiet_=true; - prepare_one(state,start);quiet_=false; - if(count_)return; - } - skipped(8); - } - void prepare_one(RT64::State& state,const RT64::DisplayList* start) {''') - text = once(text, ' for(unsigned i=1;i<256;++i) {', ' for(unsigned i=1;i<1024;++i) {') - old = ''' for(unsigned name=0;name<10;++name) { - glyphs_=raw;count_=n; - if(match_name(u32(symbols_->names+name*0x2C)))return; - }''' - text = once(text, old, ''' if(credits_) { - for(unsigned row=0;row<9;++row) { - const auto element=credits_elements_+row*0x20; - if(!u32(element+0x14))break; - glyphs_=raw;count_=n; - if(match_credit(element,env,prim))return; - } - } else { -''' + old + ''' - }''') - text = once(text, ' std::fprintf(stderr,"[title][names] full-width travel; unclipped signed glyphs; original proportions and colour trails\\n");', ''' if(credits_)std::fprintf(stderr,"[credits][text] full-width slides; original font proportions; signed glyphs and spaces\\n"); - else std::fprintf(stderr,"[title][names] full-width travel; unclipped signed glyphs; original proportions and colour trails\\n");''') - # Admission notices are separate; showing the title cannot suppress the - # later credits notice in the same thread/session. - text = once(text, ' static thread_local bool notice=false;\n if(!notice) {', ''' static thread_local std::array notices{}; - auto& notice=notices[credits_?1:0]; - if(!notice) {''') - put('title_text_rt64.hpp', text) - - text = read(base / 'native/f3ddkr_rt64.cpp') - text = once(text, '#include "intro_viewport_rt64.hpp"', - '#include "intro_viewport_rt64.hpp"\n#include "credits_viewport_rt64.hpp"\n#include "credits_sky_rt64.hpp"') - text = once(text, ' presentation::IntroViewport intro_viewport;', - ' presentation::IntroViewport intro_viewport;\n presentation::CreditsViewport credits_viewport;\n presentation::CreditsSky credits_sky;') - text = once(text, ' data_->intro_viewport.begin(', ''' data_->credits_viewport.begin(enhancements::modern_presentation_enabled() && - enhancements::fit_to_window_enabled() && ReadU32(state->RDRAM,kCurrentMenuIdAddress)==25U,data_->world_window_aspect); - data_->credits_sky.begin(*state,data_->world_window_aspect,data_->credits_viewport.enabled()); - data_->intro_viewport.begin(''') - text = once(text, ' if(opcode==kFullSyncOpcode && active_->data_->intro_viewport.enabled()) {', ''' if(opcode==kFullSyncOpcode && active_->data_->credits_viewport.enabled()) { - state->flush(); - auto& workload=state->ext.workloadQueue->workloads[state->ext.workloadQueue->writeCursor]; - for(unsigned i=0;idata_->credits_viewport.normalise(workload.fbPairs[i]); - } - if(opcode==kFullSyncOpcode && active_->data_->intro_viewport.enabled()) {''') - text = once(text, ' AdjustSplitViewportCommand(state, command, opcode);', ''' if(opcode==kSetScissorOpcode)data.credits_viewport.scissor(*state); - else if(opcode==kMoveMemOpcode && command->p0(16,8)==kViewportMoveMemType) - data.credits_viewport.viewport(*state); - AdjustSplitViewportCommand(state, command, opcode);''') - text = once(text, ' if(data.intro_viewport.fill(*state,**display_list))return;', - ' if(data.intro_viewport.fill(*state,**display_list))return;\n if(data.credits_viewport.fill(*state,**display_list))return;') - text = once(text, ' vertex.color.a = ReadU8(state->RDRAM, address + 9U);', ''' vertex.color.a = ReadU8(state->RDRAM, address + 9U); - if(!canonical_shadow)data.credits_sky.vertex(address,vertex);''') - put('f3ddkr_rt64.cpp', text) - receipt = {'schema': 1, 'version': '1.0.7+beta.10', 'inputs': inputs, - 'accepted_title_text': accepted, - 'files': {p.relative_to(out).as_posix(): sha(p) for p in out.rglob('*') if p.is_file()}, - 'policy': 'Patch Pipeline only. Immutable-task presentation; no guest, timing, save or dependency changes. Owner performs gameplay testing.'} - (out / 'credits-viewport-receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') - - -def verify(out): - receipt = json.loads((out / 'credits-viewport-receipt.json').read_text()) - for group in ('inputs', 'files'): - for name, expected in receipt[group].items(): - if group == 'files': - path = out / name - else: - name = name.replace('\\', '/') - if os.name != 'nt' and re.match(r'^[A-Za-z]:/', name): - name = '/mnt/' + name[0].lower() + name[2:] - path = Path(name) - if sha(path) != expected: - raise ValueError('Credits receipt changed: ' + str(path)) - print('Credits viewport/text composition inspected; no target or tests executed.') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=('stage', 'verify')) - parser.add_argument('--base', type=Path) - parser.add_argument('--output', type=Path, required=True) - args = parser.parse_args() - if args.action == 'stage': - stage(args.base.resolve(strict=True), args.output) - verify(args.output) - - -if __name__ == '__main__': - main() diff --git a/patches/custom-vehicles/PLAYTEST.txt b/patches/custom-vehicles/PLAYTEST.txt deleted file mode 100644 index b67924f1..00000000 --- a/patches/custom-vehicles/PLAYTEST.txt +++ /dev/null @@ -1,153 +0,0 @@ -DKR-R v1.0.9 - CUSTOM CARS PLAYTEST 13 - -Right, cars first! This build adds support for Syeo's .dkrkart exports. -Hovercrafts and planes are coming later. .dkrhover and .dkrplane are reserved -for those, but they are not supported by this playtest. - -This one widens character compatibility. The old car check rejected some -perfectly valid character exports and silently skipped their car picker. -All ten original racers and all 44 custom character entries in the local -library now pass the static ownership audit across all five car LOD slots. -That includes the disabled entries and older Blastoise/Yoshi exports too. -This is source/asset verification, not a claim of completed gameplay testing. - -Rebuilt character models have individually reviewed car-only face masks, -checked against a fingerprint of the complete model. Retextured original -cars can also be recognized without requiring their original UV layout. -Driver geometry, animations and handling stay intact. This does not guess -which parts to hide from texture names or cut away every static vertex. -Unrecognized future exports keep Vanilla safely and explain why in the log. - -The outward-pointing transparent arrows, approved seating, random CPU cars, -wheel motion and controls are unchanged. No cars need to be reimported. - -CPUs can now pick from Vanilla and your enabled cars in Track Select races. -Each CPU keeps its choice for that race. The next race can get a different -lineup. This also works with the frozen Online car catalogue, independently -of whether you have custom CPU characters switched on. Only the vehicle's -looks change; the CPU's character, difficulty and handling stay the same. - -The shared seat alignment also had a unit mismatch: the seat was converted -from Studio units, but the donor anchors were not. That made the inherited -Banjo/Waluigi versus Diddy/Rayman spacing four times too large. Anchors now -use the same quarter-scale conversion as the seat, with one shared reference -calibration to keep the accepted placement. We do not resize the character, -move the car, guess from its height or add character-name exceptions. - -The car picker now uses the game's own wooden picture frames, orange labels, -selection arrows and native fonts. The original track name, records and -balloons stay visible over the live course. There is no large dark panel. -Each player keeps their own choice, including Vanilla, and confirms with A. -Portraits use two safe 64x32 tiles, like the original car images, fixing the -repeated/corrupted image. You do not need to reimport your cars. - -This build also keeps the smoother custom wheel motion. The renderer now -rotates each wheel around its pivot between game ticks, rather than blending -between distorted wheel shapes. Full turns and rotation direction are kept, -including reversing and burnouts. No extra graphics pass is added. - -The accepted 20-unit forward adjustment is retained as the common baseline, -with the Studio-anchor correction above applied consistently to every donor. -The kart and seat remain lowered by 2.5 world units. All six LODs keep the -same placement, and the confirmed steering correction is unchanged. - -The smaller quarter-scale size from playtest 4 is retained. Character size, -physics, collision, camera and handling are unchanged. The changes apply -only while a custom car is active; Vanilla and Adventure are untouched. - -The previous fog/opacity, texture wrapping, safe native-vehicle hiding and -online music/asset-address repairs are retained. - -Online track music is still verified before the match. It now keeps encoded -recordings instead of every track's expanded audio. Only the selected course -is decoded during loading. Racing, audio mixing and rollback do not open a -decoder. Songs still have the existing 64 MiB per-recording safety limit. - -Larger online collections also share byte-identical virtual asset sections -instead of reserving the same character/course data again for each legacy -track. Section bounds, guard ranges and immutable ownership remain checked. -This does not increase the guest RAM allocation or remove memory limits. - -HOW TO USE IT - -Extract the whole build into a new folder. Keep the launcher and libexec -folder together. Back up your saves before trying a development build. -No ROM, save or installed mod library is included in this download. - -Open MODS / HACKS, choose Import mods, then Custom vehicles. You can import -a .dkrkart directly, or the ZIP that contains it. The supplied Mario Kart DS -Standard Kart and Yoshi Egg exports use this format. Importing the same -inner export as both a ZIP and a .dkrkart does not create two copies. - -Your cars appear under Vehicles. Enable the ones you want, then start the -game and use Track Select. Pick your character and the native Car vehicle -type. You then get Vanilla and the enabled custom cars to scroll through. -Left/right changes car, A confirms and B goes back. Each player picks their -own car. You can use the same car as someone else. The normal time-trial -and CPU options are kept. Adventure mode does not use custom cars. - -Vanilla means the existing vehicle for your selected character. It does -not reset your character. Custom cars replace vehicle visuals only: they -do not include a driver, new physics, collision changes or different voices. - -ONLINE - -Use Auto / Experimental Rollback and this exact complete playtest on every -PC. The host's enabled car catalogue is frozen before launch. Clients reuse -matching installed files, or download the missing exact packages. Choices -are part of the rollback state, not a local graphics preference. - -Do not mix this build with playtests 1-11 in the same lobby. This repair -has a new portable rollback contract. Use the complete new package rather -than copying its launcher over an older libexec folder. - -Downloaded cars kept for offline use are added inactive. Existing offline -enablement is preserved. This does not replace character files or choose a -different save. Other online backends are not qualified for custom cars in -this first playtest. The Mod Store does not install vehicles yet. - -WHAT NEEDS YOUR CHECK - -This is a playtest, not a claim that every character/car pairing is already -perfect. The build has been compiled and its package inspected. Gameplay, -controllers, online behaviour and hardware checks are for the owner/testers. - -Please check both supplied cars with the ten native drivers first, then -your custom characters. Watch the seat position, car size, wheel spin and -steering, braking, transparency, shadows and the change back to Vanilla. -Please compare Waluigi and Rayman in the same car, then the other drivers. -Check a race with CPUs: some should use the enabled custom cars, others may -choose Vanilla. In Online, everyone should see the same CPU cars. Disabling -all cars should leave the normal CPU vehicles alone. Adventure stays stock. -Check wheel spin when accelerating, boosting, reversing, braking and doing -burnouts, both above the original frame rate and at native frame rate. -Wheel travel is saved in rollback checkpoints; presentation never reads -live guest memory or advances a separate animation clock. Camera cuts, -course changes and different car/LOD identities do not share wheel history. -Try one to four local players, a normal track, a custom track and a trophy. -For Online, check a fresh client and a client that already has the export. - -The older Yoshi export does not contain its own driver anchor table. It -uses the adapter's base-driver anchors. The newer export contains a table, -but its author identifies Banjo and T.T. as estimates. Those seats need a -visual check. We have not invented hovercraft or plane animation metadata. - -Characters which keep the original car geometry can use the replacement, -including recoloured materials. If a character has rebuilt that geometry, -or ownership is ambiguous, the picker leaves that character on Vanilla. -It will not hide parts of your driver by guessing at texture names. - -All six car LODs are retained. The native farthest driver proxy has the old -car baked into it, so a selected custom car uses the last separable driver -LOD there. This is limited to that racer; it does not bring back the -withdrawn online maximum-detail/scenery redraw path. - -Technical limits for this adapter: 16 enabled cars, six LODs, up to 4,096 -triangles per LOD, four assigned wheel meshes, embedded PNG textures, and -native material tiles of at most 4 KiB each. Malformed or unsupported files -are rejected before activation. Packages never execute native code. - -All authored changes live in patches/custom-vehicles. The Patch Pipeline -creates isolated build copies. Checked-out dependencies, RecompiledFuncs -and RecompiledPatches are not edited. The accepted startup, water, save and -other release fixes are retained, along with the review-editor UI changes. diff --git a/patches/custom-vehicles/REPAIR-NOTES.txt b/patches/custom-vehicles/REPAIR-NOTES.txt deleted file mode 100644 index d9318ee6..00000000 --- a/patches/custom-vehicles/REPAIR-NOTES.txt +++ /dev/null @@ -1,384 +0,0 @@ -CUSTOM CARS PLAYTEST 13 - REPAIR RECORD - -CHARACTER COMPATIBILITY / SILENTLY MISSING HUMAN CAR PICKER - -The two playtester attachments contain launcher handoff only. Neither -records Track Select or identifies their character, so the exact reported -session cannot be attributed to a particular export from those logs. - -The independent static audit found a real silent gate: all ten stock racers -passed, but 12 of 44 prepared custom entries failed the all-five-LOD check. -These include Blastoise variants, Link, Rexy, Yoshi Racing Story variants, -007 Bond, Grunty, Yooka, Tiny Kong and Haunter. This can leave native CPUs -using custom cars while a rejected human character has no picker at all. - -33 distinct exceptional model variants were inspected as native polygon -data, with front/side/top and isolated connected-component diagrams. -Explicit masks preserve the complete driver, including Yoshi's tail, -mixed/adjacent body pieces and Haunter's driver-only distant proxy. -Each mask is bound to the complete decompressed model fingerprint, with -only numeric 3D texture IDs normalized for append-only namespace relocation. -No model bytes, character artwork or animation data are redistributed. -Counts, ranges, overlap and retained-driver bounds are validated. - -The existing accepted exact matcher remains. An additional conservative -fallback recognizes complete original car geometry with reordered corners -or changed UVs, across all ten verified donors and their five LODs. It rejects -ambiguous counts and signatures also belonging to the donor's driver. -There is no partial-percentage acceptance, material-name cut, bounding-box -heuristic, blanket static-vertex cut or forced LOD/animation replacement. -Unsupported new exports retain Vanilla and emit a per-character reason. -The picker logs each actual human's resolved character/type/eligibility. - -After the changes, all 54 locally available native/custom entries pass -static admission across 270 LOD slots. This includes disabled entries. -All existing car size/seat/ride height/steering/wheel interpolation, CPU -choices, shadows, native menu artwork, physics and input remain unchanged. -Online resource identity is versioned so old/new ownership rules cannot -silently mix in a session. Both peers must use this same playtest. - -Only compilation and static source/asset/package checks were performed. -No game, mod, replay, test program or benchmark was executed. The owner -and playtesters must verify actual visuals, controls and online gameplay. -All authored edits are confined to the Patch Pipeline. - -PLAYTEST 12 REPAIR RECORD RETAINED BELOW - -CAR-PICKER ARROW DIRECTION AND TRANSPARENCY - -The owner approved Playtest 11's seating and CPU behaviour, requesting only -that the left/right icons switch places and their black rectangles disappear. -The two existing native image references are swapped; positions, sizes, -input direction, ready/back handling and multiplayer layout are unchanged. - -Evidence: native texrect_draw_scaled selects dTextureRectangleScaledOpa -when primitive alpha is 255, and dTextureRectangleScaledXlu otherwise. -The original unscaled menu-arrow draws always use XLU, including at alpha -255. Our scaled arrows used alpha 255, so transparent texels became black -rectangles. Only those two scaled draws now use alpha 254 to select native -XLU and preserve texture alpha. Icon opacity is 254/255; zero-alpha areas -are fully transparent. There is no black-colour keying, artwork editing, -global render-mode replacement or change to portraits/frame/label rendering. -The native helper restores primitive colour after each draw. - -Vehicle geometry, seating, CPU assignment, random state, wheel interpolation, -physics, imports and all existing guest hooks remain byte-for-byte unchanged. -All authored changes remain inside the Patch Pipeline. Compilation and static -package checks only; gameplay and visual testing remain with the owner. - -PLAYTEST 11 REPAIR RECORD RETAINED BELOW - -CPU CAR ROSTER AND CONSISTENT STUDIO ANCHOR UNITS - -Evidence: render_begin and advance_wheels rejected playerIndex >= 4, while -native Object_Racer marks CPUs as -1 and provides a separate racerIndex. -The existing four reviewed primary-spawn hooks already expose race topology, -entry/index and the returned object in both native revisions and private -rollback worlds. The car adapter now uses those exact hooks independently -of the custom-character selector and its AI preference. Bosses, hubs, demos, -ghosts, time trials and Adventure do not receive random CPU cars. - -Choices use a separate, fixed integer generator seeded from the guest RNG -state and checkpointed race serial. Native RNG is read, never advanced, so -this does not change AI difficulty, item randomness or character selection. -The pool is Vanilla plus the frozen enabled cars, with duplicates allowed. -The selection is made once during race setup, bound to the primary spawned -object, checked against its AI sentinel/index and never chosen while drawing. -Choices, primary guest-object addresses, pending spawn, race serial and all -eight racers' 64-bit wheel phases have a bounded canonical checkpoint. -No host pointers or local graphics settings enter that state. Three-bit -racer identities replace the old two-bit player field in display markers -and interpolation groups; view, car, LOD, mesh and chunk fields remain disjoint. - -Static inspection of the installed Waluigi and Rayman records confirmed -Banjo and Diddy donors, unchanged native header scales, different body meshes -in similar model space, and a 24-unit Z difference in the supplied donor -anchor table. The package calls these post-scale Studio coordinates, just -like its seat/offset. The old adapter converted the seat by 0.25 but treated -anchors as world coordinates. That amplified relative alignment fourfold. -All ten anchors now receive the same 0.25 conversion. One common reference -calibration retains the accepted neutral reference placement; it is not a -per-character exception. Actual immutable custom-header scale is validated -and included in the resource identity instead of blindly using donor scale. -The same formula and profile apply to every LOD, human and CPU. It does not -use bounding boxes, material names, character-name matching or live pose -centres, and does not alter animations, character size, car size or physics. - -The native car picker, portrait repair, wheel arc interpolation, steering, -water fixes and online music/address repairs are retained. No gameplay or -test programs were run. Owner visual/controller/online checks are required. -All authored edits remain inside the Patch Pipeline. - -PLAYTEST 10 REPAIR RECORD RETAINED BELOW - -NATIVE CAR PICKER AND PORTRAIT TILING - - -The owner requested the original game's menu styling instead of the large -dark overlay. The admitted native track-setup renderer now keeps its camera, -colourful track title, real lap/race records and balloons. A counted guest -hook skips only its lower option widgets while the custom-car picker is -active. The original widget branch, delay slots, epilogue, existing custom -track/save guards and Custom Magic Code dispatchers are preserved. - -The replacement choices reuse the already-loaded native orange labels, -selection arrows and actual wooden-frame model. One to four players get -their own framed portrait, measured/wrapped native-font name, choice count -and ready indication. Vanilla uses the original vehicle picture. Frame -placement/scale and height globals are scoped and restored after each draw. -Callbacks come directly from the currently running normal/private guest -variant, rather than looking up a function from the wrong online world. - -Inspection of both supplied PNGs confirmed that their source portraits are -correct. The old draw loaded an 8 KiB 64x64 RGBA16 image as a single native -texture, exceeding 4 KiB TMEM and causing repeated/corrupted halves. Both -portraits now become two 64x32 textures and a terminated native DrawTexture -list with offsets 0 and 32, exactly like the original vehicle images. -Both tile IDs, allocations, dimensions, reference counts and checkpoint -resource counts are bounded and owned together. Imports remain unchanged. - -The 20-unit driver seat, -2.5 ride height, quarter-scale car size, approved -steering sign and playtest 9's circular wheel interpolation are retained. -All edits remain in the Patch Pipeline; protected source/dependency trees -are not edited. Compilation/package inspection do not replace owner testing. - -PLAYTEST 9 REPAIR RECORD RETAINED BELOW - -WHEEL INTERPOLATION AND FINAL SEAT REFINEMENT - -The owner approved the size and steering, then requested that the driver -move back 4 units. The shared forward seat advance is now 20 world units. -Kart height remains -2.5 and the quarter-scale car size is unchanged. - -The old draw path baked the current spin into integer wheel vertices every -native tick. RT64 then interpolated each vertex along a straight chord, -not around the wheel pivot. This could flatten the intermediate wheel and -lose the intended motion when wrapped endpoints hid full revolutions. - -Wheel vertices now remain fixed. A scoped, immutable presentation sidecar -records each wheel's pivot, signed accumulated phase, steering, exact model -identity and unchanged combined camera/racer matrix. Only a matched wheel -uses a circular local rotation at the actual presentation weight. Generic -camera motion still uses component interpolation, not unsafe decomposition -of DKR's combined perspective matrices. Non-wheel draws are unchanged. - -The visual fixed-point phase retains 64 bits and is serialized canonically -in both rollback checkpoints and bounded display markers. Its low 32 bits -advance exactly as before; physics, speed, braking and steering are not -modified. The wider counter preserves full turns and reverse direction. -Track-selection resets also advance a checkpointed wheel epoch. - -Samples are sealed when their exact renderer workload is submitted. Readers -only hold immutable shared captures, never live guest pointers. Resource -identity uses a weak control-block reference, so history does not retain old -mod catalogues or confuse a recycled host address with the same resource. -Matching is -limited to the same resources, full scene, wheel epoch, object, car, player, -viewport, LOD, mesh/chunk, pivot and axle. Camera cuts and missing/evicted -records keep the normal safe fallback. Storage is bounded; there is no new -shader, render target, lighting pass, texture load or GPU readback. - -The Patch Pipeline stages narrowly scoped copies of the existing RT64 state -and transform processor. Checked-out submodules and recompiled sources stay -untouched. Every new native source and pipeline input/output is hashed into -the build receipt and portable online build contract. All peers must use -the same complete playtest. Compilation/static package inspection are not -the owner's gameplay acceptance; no game or automated tests are run here. - -Very fast repetitive spokes can still exhibit ordinary display-sampling -aliasing. This repair preserves their geometry and motion between ticks; -it does not add motion blur or change the actual wheel speed to hide it. - -PLAYTEST 8 REPAIR RECORD RETAINED BELOW - -OWNER'S THIRD SEAT REFINEMENT - -The owner requested another 12 units of forward driver movement: 24 world -units in total, up from 12 in playtest 7. This is the only renderer change. -Kart height, quarter-scale size and the confirmed working wheel steering -are unchanged. This follow-up continues the Windows-only placement build. - -PLAYTEST 7 REPAIR RECORD RETAINED BELOW - -OWNER'S SECOND SEAT REFINEMENT - -The owner requested another 6 units of forward driver movement: 12 world -units in total, up from 6 in playtest 6. This is the only renderer change. -Kart height, quarter-scale size and the confirmed working wheel steering -are unchanged. This follow-up is a Windows-only build at the owner's request. - -PLAYTEST 6 REPAIR RECORD RETAINED BELOW - -OWNER'S SEAT REFINEMENT - -The owner confirmed that playtest 5's wheels steer in the correct direction, -and requested another 4 units of forward driver movement. The shared seat -advance is now 6 world units in total, up from 2. Kart height remains -2.5; -quarter-scale size and the confirmed steering-sign correction are unchanged. -No physics, collision, driver size or other positioning parameter changes. - -PLAYTEST 5 REPAIR RECORD RETAINED BELOW - -PLACEMENT AND STEERING FOLLOW-UP - -The owner's 31.6-second recording shows the smaller Yoshi Egg vehicle with -a visible gap under the tyres and the driver sitting too far back. Static -package inspection confirms both supplied cars' front axles point toward -native -Z after their authored 180-degree yaw. The front/rear axle roles, -not texture names or model bounds, define the forward adjustment. - -The optional custom-car renderer now shifts the whole car down by 2.5 local -world units. The shared seat receives the same height correction; the -driver is additionally advanced 2 world units toward the declared front -axle, after applying the package yaw. The original authored seat/offset, -native driver anchor, car proportions, quarter-scale size and driver size -are retained. These are fixed modest visual offsets, not a claim of -independent tyre/terrain contact simulation for every track or export. -Native suspension and race jump motion still carry the car and driver. - -The front-wheel visual steering angle now converts the native sprite-wheel -yaw with the opposite sign. No game steering input, physics, collision, -wheel spin phase, checkpoint layout or native wheel object is modified. -Rear wheels keep rolling without gaining front steering. Vanilla vehicles, -Adventure mode, carried objects and unrelated geometry are not repositioned. - -All corrections use the same frozen resources/task snapshot as the existing -car pass and apply to every LOD and viewport. Diagnostics record ride height, -seat advance and steering sign. The Patch Pipeline stages only isolated -copies and extends the rollback build contract. Source, archive and video -inspection and compilation do not substitute for the owner's gameplay test. - -PLAYTEST 4 REPAIR RECORD RETAINED BELOW - -SCALE FOLLOW-UP - -The owner's recording shows the Standard Kart dwarfing its driver. The -current runtime.log confirms playtest.3, 181 submitted triangles and the -corrected primitive-opacity material. The car is now visible, but its -model-to-world conversion was incomplete: driver draw scale was cancelled -without applying a native-sized reference scale afterwards. - -Static inspection of the owned US ROM found native driver header scales -between 0.21 and 0.28 (Tiptup is exactly 0.25). Native chassis/body bounds -are approximately 18-23 world units wide, excluding attached wheel sprites. -The Standard Kart's authored post-scale width is 126.4 model units; the -Yoshi Egg's is 104.512. Treating these as world widths explains the size -mismatch. The adapter now uses the fixed clean quarter-scale reference: -31.6 and 26.128 world units respectively, including their custom wheels. -Package-relative differences are retained; no bounds-based per-car fitting -or per-character resizing is introduced. - -Only the car's model-space mesh, wheel pivots, seat and driver offset receive -that conversion. The driver anchor and driver linear transform are not -scaled. A new capped diagnostic records the actual live driver scale and -the vehicle unit conversion. Physics, collision, camera, input, wheel-phase -checkpoint format, menu choices and installed packages are untouched. -Source/archive/video inspection is not a claim of gameplay acceptance. - -PLAYTEST 3 REPAIR RECORD RETAINED BELOW - -FOLLOW-UP EVIDENCE FROM THE OWNER'S LATEST SESSIONS - -The current runtime.log identified playtest.1 and its playtest-1 ModWorker. -That session hit the original aggregate decoded-music limit on Mario Circuit. -runtime-previous.log identified playtest.2 and its playtest-2 ModWorker. It -verified Mario Circuit (6,800,384 frames at 44,100 Hz) and eight recordings -totalling 43,604,257 encoded bytes, then hit virtual asset address exhaustion. -A smaller selection in that same playtest.2 session completed preparation. -These are distinct failures, not evidence that the new music code was absent. - -That session also logged 181 submitted car triangles with opacity 255 and -positive clip-W. CPU submission therefore was not the remaining render fault. -The native RSP fog shader replaces vertex alpha with fog density. The car -combiner incorrectly used that shade alpha for texture coverage; nearby cars -could become completely transparent. Both material cycles now use independent -primitive opacity, including mesh opacity and racer fade. RGB shade and the -existing native state restoration are retained. Texture S/T wrapping and -mask arguments now match the renderer's T/S parameter order. - -Each legacy-track bank previously reserved a whole copy of every virtual -asset section, even when its expanded character and modern-course sections -were identical. The bus now shares sections only when their section number, -complete canonical byte SHA-256 and length match. The receiving mount pins -the exact earlier owner; stale reads retain their existing safety checks. -Section zero remains unique/monotonic because prepared-world clones use its -address as their allocation ordering key. Guards, the original tagged address -range, bank count limit and atomic publication remain unchanged. There is no -global address reuse and no mutable ROM swap. New diagnostics report shared -sections and reserved virtual bytes. - -All bus changes are applied to its isolated first-party source copy by the -Patch Pipeline. Its public class layout and headers are unchanged. The -derived mount lifetime is owned by its shared_ptr control block. No gameplay, -mod execution or runtime qualification is claimed by this source review. - -PREVIOUS REPAIR RETAINED BELOW - -WHAT THIS ADDRESSES - -The first car playtest could hide the old car without a replacement draw. -The owner reported this with the Yoshi Egg and Mario Kart DS Standard Kart -exports. The driver and race continued to render. - -Online resource preparation also retained every enabled course's expanded -stereo recording. Logs showed 109,641,728 bytes already resident after three -songs. Mario Circuit needed another 27,201,536 bytes: 130.50 MiB in total, -over the fixed 128 MiB session limit. Other selections failed on whichever -song crossed that limit. This was not a missing music/main.mp3 file. - -VEHICLE DRAW REPAIR - -Replacement submission now happens at the captured object-root matrix, -before any original car triangles or wheel sprites can be suppressed. The -end marker closes the scope; it no longer performs the first custom draw -after native matrix/attachment teardown. A missing/behind-camera submission -keeps the original vehicle and its driver transform. - -Native viewport-facing culling is respected. Each mesh chunk has its own -transform identity and forces a fresh transform record. Original-water -material state is restored even if replacement drawing fails. Native car -texture replay now preserves canonical TMEM in ordinary play as well as -owned Online play. The original dependency checkout is not changed. - -runtime.log receives at most 24 car draw diagnostic lines per process, -including the selected car, player, viewport, LOD, triangle count, opacity, -clip-W range and whether native hiding was admitted. These are submission -diagnostics, NOT a claim that GPU output was visually verified. - -SELECTED-COURSE MUSIC - -Each unique recording is still decoded during pre-match preparation to -verify its exact decoded shape and portable PCM SHA-256 proof. That temporary -PCM is discarded immediately. The immutable library retains verified encoded -recordings and metadata, deduplicated by the source recording SHA-256. - -The selected recording is decoded from those frozen bytes at ordinary course -loading or inside the private confirmed scene continuation, before baseline -capture/readiness releases the next epoch. Silent Track Select background -previews do not decode songs. Audio callbacks and rollback checkpoint staging -can only use already-resident samples; neither can open a decoder or a file. - -The original integer mixer, tempo, loops, gain ramps, sequence adapter and -native music checkpoint format remain in use. Per-world wrappers pin only -the active song. A weak cache lets concurrent owners share that exact PCM -without retaining all decoded songs. An old checkpoint cannot silently decode -an unprepared recording. No music path writes the offline save or mod files. - -Limits remain explicit: up to 128 MiB of unique encoded music per profile, -64 MiB of decoded stereo PCM per recording, and the existing 15-minute/rate -validation. Ordinary world replacement can briefly pin old and new selected -PCM during a transactional load; it does not decode the entire library. - -INTEGRATION AND DELIVERY - -All authored files are in patches/custom-vehicles. Counted transformations -operate on isolated build copies of the accepted release composition. Source -receipts and revision-specific portable rollback contracts cover the music -and vehicle adapters. The driver copy preserves its accepted adjacent header -precedence, including the confirmed-records interface. - -Playtests 1-6 must not be mixed in a lobby. Windows and Linux x86_64 -AppImage packages are intended for the owner's testing. Hovercrafts and planes -remain deferred. No game, mod, test, replay, benchmark or AppImage is executed -as part of building or inspecting this repair. Compilation and static package -checks do not establish gameplay acceptance. diff --git a/patches/custom-vehicles/ReleaseOverlay.cmake b/patches/custom-vehicles/ReleaseOverlay.cmake deleted file mode 100644 index 3ae5464a..00000000 --- a/patches/custom-vehicles/ReleaseOverlay.cmake +++ /dev/null @@ -1,236 +0,0 @@ -# Authored features are composed over the accepted release and review editor. -# No checked-out dependency or existing generated payload is modified. -include("${CMAKE_CURRENT_LIST_DIR}/../review-editor/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_custom_vehicles) - set(auth "${DKRPORT_ROOT}/patches/custom-vehicles") - set(stage "${CMAKE_BINARY_DIR}/generated/custom-vehicles") - get_target_property(vehicle_driver_sources DKRExperimentalOwnedGameV77 SOURCES) - list(FILTER vehicle_driver_sources INCLUDE REGEX "/probe_driver\\.cpp$") - list(LENGTH vehicle_driver_sources driver_count) - if(NOT driver_count EQUAL 1) - message(FATAL_ERROR "Expected one final confirmed-scene driver") - endif() - list(GET vehicle_driver_sources 0 vehicle_driver) - get_target_property(game_sources DKRPortGame SOURCES) - set(ui_sources ${game_sources}) - list(FILTER ui_sources INCLUDE REGEX "/runtime_ui\\.cpp$") - list(LENGTH ui_sources count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Expected exactly one final launcher source for vehicles") - endif() - list(GET ui_sources 0 ui) - foreach(pair "DKRLegacyModLaunch|legacy_mod_launch.cpp|launch" "DKRPortGame|runtime_legacy_mods.cpp|runtime" "DKRPortGame|f3ddkr_rt64.cpp|bridge" "DKRPortGame|rt64_renderer.cpp|renderer" - "DKRLegacyModCore|online_mod_manifest.cpp|manifest" "DKRLegacyImportLibrary|online_mod_profile.cpp|profile" "DKRLegacyModLaunch|online_mod_runtime.cpp|online_runtime" - "rt64|rt64_state.cpp|rt64_state" "rt64|rt64_transform_processor.cpp|rt64_transform" "DKRLegacyModCore|legacy_asset_bus.cpp|asset_bus") - string(REPLACE "|" ";" fields "${pair}") - list(GET fields 0 target) - list(GET fields 1 filename) - list(GET fields 2 key) - get_target_property(sources ${target} SOURCES) - set(selected ${sources}) - list(FILTER selected INCLUDE REGEX "/${filename}$") - list(LENGTH selected count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Expected one final ${filename} for vehicle composition") - endif() - list(GET selected 0 path) - set(vehicle_${key} "${path}") - endforeach() - file(GLOB authored CONFIGURE_DEPENDS "${auth}/native/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS - "${auth}/pipeline.py" "${auth}/online_pipeline.py" "${auth}/music_pipeline.py" "${auth}/guest_pipeline.py" ${authored}) - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" - --output "${stage}" --ui "${ui}" - --library "${DKR_SDK2_REUSE_STAGE}/native/src/game/runtime_mod_library_ui.inl" - --sdk "${DKR_SDK2_REUSE_STAGE}/modding" - --launch "${vehicle_launch}" --runtime "${vehicle_runtime}" --bridge "${vehicle_bridge}" --renderer "${vehicle_renderer}" - --manifest "${vehicle_manifest}" --profile "${vehicle_profile}" --online-runtime "${vehicle_online_runtime}" - --rt64-state "${vehicle_rt64_state}" - --rt64-transform "${vehicle_rt64_transform}" - --driver "${vehicle_driver}" - --asset-bus "${vehicle_asset_bus}" - RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Custom vehicle Patch Pipeline composition failed") - endif() - add_library(DKRCustomVehicles STATIC - "${stage}/native/vehicle_package.cpp" - "${stage}/native/vehicle_library.cpp" - "${stage}/native/vehicle_resources.cpp" - "${stage}/native/vehicle_binding.cpp") - target_compile_features(DKRCustomVehicles PUBLIC cxx_std_20) - target_compile_definitions(DKRCustomVehicles PRIVATE NOMINMAX) - target_include_directories(DKRCustomVehicles PUBLIC "${stage}/native" - "${DKRPORT_ROOT}/runtime-recomp/src/game/mods" - "${DKR_DEPENDENCIES_DIR}/rt64/src/contrib" - "${DKR_DEPENDENCIES_DIR}/n64-modern-runtime/thirdparty") - target_link_libraries(DKRCustomVehicles PUBLIC DKRCustomMagicCodes DKRLegacyModCore) - target_include_directories(DKRLegacyModCore PRIVATE "${stage}/native") - target_link_libraries(DKRLegacyImportLibrary PRIVATE DKRCustomVehicles) - target_link_libraries(DKRPortGame PRIVATE DKRCustomVehicles) - target_sources(DKRPortGame PRIVATE "${stage}/native/vehicle_world.cpp" "${stage}/native/vehicle_rt64.cpp") - target_link_libraries(DKRLegacyModLaunch PRIVATE DKRCustomVehicles) - target_sources(DKRLegacyModLaunch PRIVATE "${stage}/native/resident_music.cpp") - target_include_directories(DKRLegacyModLaunch PRIVATE "${stage}/native" "${DKR_SDK2_REUSE_STAGE}/native/src/game") - foreach(target DKRExperimentalOwnedGameV77 DKRExperimentalOwnedGameV80) - get_target_property(sources ${target} SOURCES) - set(selected ${sources}) - list(FILTER selected INCLUDE REGEX "/probe_driver\\.cpp$") - list(LENGTH selected count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Expected one owned scene driver for ${target}") - endif() - list(GET selected 0 old) - if(NOT old STREQUAL vehicle_driver) - message(FATAL_ERROR "Owned revisions use different scene driver source inputs") - endif() - get_target_property(directory ${target} SOURCE_DIR) - set(next "${stage}/native/probe_driver.cpp") - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - # The driver's adjacent owned_game.hpp contains the accepted - # confirmed-records ABI. Preserve quote-include precedence when - # relocating the TU; an older SDK header must not shadow it. - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES - "${stage}/native;${prior};${includes};${DKR_SDK2_REUSE_STAGE}/native/src/game") - list(REMOVE_ITEM sources "${old}") - list(APPEND sources "${next}") - set_property(TARGET ${target} PROPERTY SOURCES "${sources}") - endforeach() - foreach(pair "DKRLegacyModLaunch|${vehicle_launch}" "DKRPortGame|${vehicle_runtime}" "DKRPortGame|${vehicle_bridge}" "DKRPortGame|${vehicle_renderer}" - "DKRLegacyModCore|${vehicle_manifest}" "DKRLegacyImportLibrary|${vehicle_profile}" "DKRLegacyModLaunch|${vehicle_online_runtime}" "rt64|${vehicle_rt64_state}" "rt64|${vehicle_rt64_transform}" "DKRLegacyModCore|${vehicle_asset_bus}") - string(REPLACE "|" ";" fields "${pair}") - list(GET fields 0 target) - list(GET fields 1 old) - get_filename_component(filename "${old}" NAME) - set(next "${stage}/native/${filename}") - get_target_property(directory ${target} SOURCE_DIR) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage}/native;${prior};${includes}") - if(MSVC AND filename STREQUAL "runtime_legacy_mods.cpp") - # Native fail/termination must unwind to the existing owned - # boundary even though these particular imports have C linkage. - set_property(SOURCE "${next}" DIRECTORY "${directory}" APPEND PROPERTY COMPILE_OPTIONS /EHs /EHc-) - endif() - get_target_property(sources ${target} SOURCES) - list(REMOVE_ITEM sources "${old}") - list(APPEND sources "${next}") - set_property(TARGET ${target} PROPERTY SOURCES "${sources}") - endforeach() - # Include the new sidecar/checkpoint and guest hooks in the portable - # rollback contract. Never hash local build paths into a peer ID. - set(ledger "custom-cars-adapter-2\n") - foreach(path IN LISTS authored) - file(RELATIVE_PATH name "${auth}" "${path}") - file(SHA256 "${path}" hash) - string(APPEND ledger "${name}:${hash}\n") - endforeach() - foreach(name pipeline.py online_pipeline.py music_pipeline.py guest_pipeline.py) - file(SHA256 "${auth}/${name}" hash) - string(APPEND ledger "${name}:${hash}\n") - endforeach() - get_target_property(definitions DKRExperimentalOwnedRegistry COMPILE_DEFINITIONS) - set(next_definitions "") - set(contracts "{") - set(separator "") - foreach(value IN LISTS definitions) - if(value MATCHES "^DKR_OWNED_BUILD_ID_V(77|80)=\"([a-f0-9]+)\"$") - set(revision "${CMAKE_MATCH_1}") - set(previous "${CMAKE_MATCH_2}") - string(SHA256 hash "${previous}\n${ledger}") - set(value "DKR_OWNED_BUILD_ID_V${revision}=\"${hash}\"") - string(APPEND contracts "${separator}\"${revision}\":\"${hash}\"") - set(separator ",") - endif() - list(APPEND next_definitions "${value}") - endforeach() - string(APPEND contracts "}\n") - if(separator STREQUAL "") - message(FATAL_ERROR "Custom cars could not extend the rollback engine contract") - endif() - set_property(TARGET DKRExperimentalOwnedRegistry PROPERTY COMPILE_DEFINITIONS "${next_definitions}") - file(WRITE "${stage}/vehicle-contracts.json" "${contracts}") - if(DKR_RELEASE_EVIDENCE) - file(WRITE "${DKR_RELEASE_EVIDENCE}/DKRExperimentalOwnedRegistry-COMPILE_DEFINITIONS.txt" "${next_definitions}\n") - endif() - # Re-read after the runtime replacement so the UI swap preserves it. - get_target_property(game_sources DKRPortGame SOURCES) - get_target_property(directory DKRPortGame SOURCE_DIR) - set(next "${stage}/native/runtime_ui.cpp") - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${ui}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${ui}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${ui}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage}/native;${prior};${includes}") - list(REMOVE_ITEM game_sources "${ui}") - list(APPEND game_sources "${next}") - set_property(TARGET DKRPortGame PROPERTY SOURCES "${game_sources}") - foreach(target DKRRecompiledFuncsV77 DKRRecompiledFuncsV80 DKRExperimentalOwnedGameV77 DKRExperimentalOwnedGameV80) - if(NOT TARGET ${target}) - continue() - endif() - get_target_property(sources ${target} SOURCES) - set(json "[") - set(separator "") - foreach(path IN LISTS sources) - if(path MATCHES "\\.c$") - file(TO_CMAKE_PATH "${path}" path) - string(APPEND json "${separator}\"${path}\"") - set(separator ",") - endif() - endforeach() - string(APPEND json "]") - file(WRITE "${stage}/${target}-inputs.json" "${json}") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/guest_pipeline.py" - --sources "${stage}/${target}-inputs.json" --output "${stage}/${target}" - RESULT_VARIABLE guest_result) - if(NOT guest_result EQUAL 0) - message(FATAL_ERROR "Vehicle guest composition failed for ${target}") - endif() - file(READ "${stage}/${target}/swaps.json" swaps) - string(JSON count LENGTH "${swaps}") - math(EXPR last "${count}-1") - get_target_property(directory ${target} SOURCE_DIR) - foreach(index RANGE ${last}) - string(JSON old GET "${swaps}" ${index} old) - string(JSON next GET "${swaps}" ${index} new) - file(TO_CMAKE_PATH "${old}" old) - file(TO_CMAKE_PATH "${next}" next) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - set_property(SOURCE "${next}" DIRECTORY "${directory}" APPEND PROPERTY INCLUDE_DIRECTORIES "${prior}") - list(REMOVE_ITEM sources "${old}") - list(APPEND sources "${next}") - endforeach() - set_property(TARGET ${target} PROPERTY SOURCES "${sources}") - endforeach() - endfunction() - cmake_language(DEFER CALL dkr_apply_custom_vehicles) -endif() diff --git a/patches/custom-vehicles/audit_characters.py b/patches/custom-vehicles/audit_characters.py deleted file mode 100644 index 2d414ad1..00000000 --- a/patches/custom-vehicles/audit_characters.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Static car-ownership audit of native and prepared characters. No game/mod execution.""" -from pathlib import Path -import argparse -from collections import Counter -import hashlib -import json -import math -import re -import struct -import zlib - -AUTH=Path(__file__).resolve().parent -HEADERS=(2,3,4,5,6,7,8,9,1,0) -NAMES=('Krunch','Bumper','Tiptup','Conker','Timber','Banjo','Drumstick','Pipsy','T.T.','Diddy') -u32=lambda b,p:struct.unpack_from('>I',b,p)[0] -u16=lambda b,p:struct.unpack_from('>H',b,p)[0] - -class Bank: - def __init__(self,path): - data=path.read_bytes();digest=hashlib.sha256(data).hexdigest() - lut={'dcf54c82a58f6b38603b5865e90c3baabfe553a4d141a1ea2b282170a2e98876':969568, - '7de1a8fb2a9558cfc3d9ad4497df698c1e89cf7095ac1531557df2af40ba8bcf':970976}[digest] - self.digest=digest - self.sections=[data[lut+208+u32(data,lut+4+4*i):lut+208+u32(data,lut+8+4*i)] for i in range(50)] - def record(self,s,i): - a,b=u32(self.sections[s-1],i*4),u32(self.sections[s-1],i*4+4) - if a>b or b>len(self.sections[s]):raise ValueError('Invalid stock range') - return self.sections[s][a:b] - -def models(h): - n=h[0x55];at=u32(h,0x10) - if h[0x53] or not 1<=n<=32 or at+4*n>len(h):raise ValueError('Invalid car LOD list') - return [u32(h,at+i*4) for i in range(n)] - -def model(packed): - raw=zlib.decompress(packed[5:],-15) - if len(raw)>4*1024*1024 or len(raw)<0x80:raise ValueError('Invalid model size') - nv,nt,nb=(u16(raw,p) for p in (0x24,0x26,0x28)) - vp,tp,bp=(u32(raw,p) for p in (4,8,0x38)) - if not 1<=nv<=8192 or nt>8192 or nb>1024 or vp+nv*10>len(raw) or tp+nt*16>len(raw) or bp+(nb+1)*12>len(raw):raise ValueError('Model exceeds its bounds') - faces=[None]*nt;owners=[None]*nt;ids=[None]*nt;geometry=[None]*nt - animated=u16(raw,0x4a);am=u32(raw,0x4c) - if animated and am+nv*2>len(raw):raise ValueError('Invalid animation map') - animation=[struct.unpack_from('>h',raw,am+i*2)[0] for i in range(nv)] if animated else [-1]*nv - for b in range(nb): - at=bp+b*12;vs,ve,ts,te=(u16(raw,at+p) for p in (2,14,4,16)) - if vs>ve or ve>nv or ts>te or te>nt:raise ValueError('Invalid batch') - for t in range(ts,te): - if owners[t] is not None:raise ValueError('Overlapping triangle ownership') - at=tp+t*16;indices=[vs+raw[at+1+c] for c in range(3)] - if any(i>=ve for i in indices):raise ValueError('Invalid vertex index') - corners=[raw[vp+i*10:vp+i*10+6]+raw[at+4+c*4:at+8+c*4] for c,i in enumerate(indices)] - faces[t]=bytes([raw[at]&0x40])+b''.join(corners);owners[t]=b;ids[t]=indices - geometry[t]=b''.join(sorted(raw[vp+i*10:vp+i*10+6] for i in indices)) - if None in owners:raise ValueError('Unowned triangle') - return {'raw':raw,'faces':faces,'geometry':geometry,'animation':animation,'owners':owners,'indices':ids,'vertices':nv,'triangles':nt,'batches':nb} - -def native_mask(m,mid): - masks={int(k):[int(x) for x in v.split(',')] for k,v in re.findall(r'\{(\d+),\{([\d,]+)\}\}',(AUTH/'native/vehicle_resources.cpp').read_text())} - hidden=[b in masks[mid] for b in m['owners']] - keeps={26:((151,157),(159,165)),27:((111,116),(118,123)),28:((93,99),),40:((15,21),),35:((11,13),),41:((18,22),)} - for a,b in keeps.get(mid,()):hidden[a:b]=[False]*(b-a) - return hidden - -def ownership_digest(raw): - normalized=bytearray(raw);tx=u32(raw,0) - for t in range(u16(raw,0x22)):struct.pack_into('>I',normalized,tx+t*8,0) - return hashlib.sha256(normalized).hexdigest() - -def main(a): - bank=Bank(a.rom) - blob_paths={p.name:p for d in a.library.rglob('blobs') if d.is_dir() for p in d.iterdir() if p.is_file()} - blobs={};cached_models={} - def blob(digest): - if digest not in blobs: - p=blob_paths.get(digest) - if p is None:raise ValueError('Missing immutable blob '+digest) - b=p.read_bytes() - if hashlib.sha256(b).hexdigest()!=digest:raise ValueError('Blob digest mismatch') - blobs[digest]=b - return blobs[digest] - def parsed(record): - key=hashlib.sha256(record).hexdigest() - if key not in cached_models:cached_models[key]=model(record) - return cached_models[key] - donors=[];geometry_donors=[] - for base,header in enumerate(HEADERS): - group=[] - for mid in models(bank.record(34,header))[:5]: - m=parsed(bank.record(29,mid));hidden=native_mask(m,mid) - group.append((mid,Counter(f for f,h in zip(m['faces'],hidden) if h),Counter(f for f,h in zip(m['geometry'],hidden) if h))) - geometry_donors.append((Counter(f for f,h in zip(m['geometry'],hidden) if h),{f for f,h in zip(m['geometry'],hidden) if not h})) - donors.append(group) - reviewed={} - for digest,nv,nt,ranges in re.findall(r'\{"([a-f0-9]{64})",\{(\d+),(\d+),\{(.*?)\}\}\}',(AUTH/'native/vehicle_character_masks.hpp').read_text()): - reviewed[digest]=(int(nv),int(nt),[(int(x),int(y)) for x,y in re.findall(r'\{(\d+),(\d+)\}',ranges)]) - rows=[] - def inspect(name,base,header,record,identity,source,animations=None): - row={'name':name,'base':NAMES[base],'base_id':base,'id':identity,'source':source,'lods':[]} - try: - scale=struct.unpack_from('>f',header,12)[0] - if not math.isfinite(scale) or not 0hhh',m['raw'],u32(m['raw'],4)+i*10) for i in range(m['vertices'])] - mode='exact-native' if matches else 'unsupported';hidden=None - if a.after: - review=reviewed.get(ownership_digest(m['raw'])) - if review: - nv,nt,ranges=review - if (nv,nt)!=(m['vertices'],m['triangles']):raise ValueError('Reviewed counts changed') - hidden=[i for x,y in ranges for i in range(x,y)] - if len(hidden)!=len(set(hidden)) or any(i<0 or i>=nt for i in hidden) or len(hidden)>=nt:raise ValueError('Invalid reviewed mask') - mode='reviewed-ownership' - elif not matches: - for required_geometry,retained in geometry_donors: - if all(f not in retained and available_geometry[f]==n for f,n in required_geometry.items()): - mode='canonical-native';hidden=[i for i,f in enumerate(m['geometry']) if f in required_geometry];break - row['lods'].append({'lod':lod,'model':mid,'vertices':m['vertices'],'triangles':m['triangles'], - 'sha256':hashlib.sha256(m['raw']).hexdigest(),'matches':matches,'closest_donor':best[0], - 'required':sum(required.values()),'found':sum(min(available[f],n) for f,n in required.items()), - 'missing':sum(max(0,n-available[f]) for f,n in required.items()), - 'ambiguous':sum(available[f]>n for f,n in required.items()), - 'geometry':{'donor_base':NAMES[best_geometry[0]],'donor_model':best_geometry[1], - 'found':sum(min(available_geometry[f],n) for f,n in best_geometry[2].items()),'required':sum(best_geometry[2].values()), - 'own_found':sum(min(available_geometry[f],n) for f,n in own_geometry[2].items()), - 'own_required':sum(own_geometry[2].values())},'static_triangles':len(static), - 'mode':mode,'hidden':None if hidden is None else len(hidden),'retained':None if hidden is None else m['triangles']-len(hidden)}) - if a.overview and not matches: - from PIL import Image,ImageDraw - a.overview.mkdir(parents=True,exist_ok=True) - file=a.overview/(hashlib.sha256(m['raw']).hexdigest()+'.png') - if not file.exists(): - image=Image.new('RGB',(1600,1000),(15,24,35));draw=ImageDraw.Draw(image) - static_set=set(static);native_keys=set().union(*(set(g) for _,_,g in donors[base])) - for view,(x,y) in enumerate(((2,1),(0,1),(0,2))): - for i,tri in enumerate(m['indices']): - colour=(130,160,200) if i not in static_set else (245,160,65) - if m['geometry'][i] in native_keys:colour=(65,210,115) - if hidden is not None:colour=(245,130,65) if i in hidden else (75,190,225) - ps=[(260+view*530+xyz[v][x]*scale*6,390-xyz[v][y]*scale*6) for v in tri] - draw.polygon(ps,fill=colour,outline=(30,45,60)) - draw.line((view*530,390,(view+1)*530,390),fill='white') - draw.line((260+view*530,60,260+view*530,650),fill='white') - draw.text((10,10),name+' LOD'+str(lod)+' model '+str(mid)+' / blue=animated; orange=static; green=exact stock car geometry',fill='white') - for b in range(m['batches']): - tris=[i for i,o in enumerate(m['owners']) if o==b] - kept=sum(i in static_set for i in tris);known=sum(m['geometry'][i] in native_keys for i in tris) - text=f'batch {b}: triangles {min(tris) if tris else 0}-{max(tris) if tris else 0}, static {kept}/{len(tris)}, native-car {known}' - draw.text((10+(b//18)*530,660+(b%18)*18),text,fill='white') - image.save(file) - row['compatible']=all(v['mode']!='unsupported' for v in row['lods']) - except (ValueError,KeyError,IndexError,struct.error,zlib.error) as e: - row['compatible']=False;row['error']=str(e) - rows.append(row) - for base,name in enumerate(NAMES):inspect(name,base,bank.record(34,HEADERS[base]),bank.record,'native:'+str(base),'native') - seen=set() - for f in sorted(a.library.glob('prepared-characters/*/*/character.json')): - doc=json.loads(f.read_text());identity=doc['id'] - if identity in seen:continue - seen.add(identity);records={(r['section'],r['record']):r['hash'] for r in doc['records']} - def record(s,i,records=records): - digest=records.get((s,i));original=bank.record(s,i) - if digest is None or hashlib.sha256(original).hexdigest()==digest:return original - return blob(digest) - try:header=record(34,doc['headers'][0]) - except (ValueError,KeyError,IndexError) as e: - rows.append({'name':doc['name'],'id':identity,'compatible':False,'error':str(e),'source':str(f)});continue - inspect(doc['name'],doc['behaviour'],header,record,identity,str(f),{r['model']:r['ids'] for r in doc['animations']}) - report={'inspection':'Read-only static asset/ownership audit; no game, mod or tests executed.', - 'bank_sha256':bank.digest,'characters':len(rows),'compatible':sum(r['compatible'] for r in rows),'rows':rows} - a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(json.dumps(report,indent=2)+'\n') - print(json.dumps({k:v for k,v in report.items() if k!='rows'})) - for r in rows: - if not r['compatible']:print(json.dumps({'name':r['name'],'id':r['id'],'error':r.get('error'),'lods':r.get('lods',[])})) - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--rom',type=Path,required=True);p.add_argument('--library',type=Path,required=True);p.add_argument('--output',type=Path,required=True);p.add_argument('--overview',type=Path);p.add_argument('--after',action='store_true') - main(p.parse_args()) diff --git a/patches/custom-vehicles/character_ownership_review.py b/patches/custom-vehicles/character_ownership_review.py deleted file mode 100644 index c4cf0af1..00000000 --- a/patches/custom-vehicles/character_ownership_review.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Compile a manually reviewed mesh-component selection to digest-bound face masks. - -Input is the static inspector's model/component inventory, NOT a running game. -Only digest/count/range metadata is emitted; no model, texture or animation bytes. -Component numbers below were reviewed in side/front/top and isolated-mesh views. -No material name, character name, position cutoff or animation heuristic decides -ownership at runtime. Relocatable 3D texture IDs alone are zeroed before hashing. -""" -import argparse -import hashlib -import json -from pathlib import Path -import struct -from audit_characters import Bank,model,u32,u16 - -# Original unrelocated model SHA -> explicit car components. Everything else -# stays visible, including Yoshi's tail and Haunter's complete distant proxy. -REVIEW={ - '8ffdfdf96e062e12daeeb4b84552247722ef96d9e93df8486be1ee9d6059670d':list(range(12,18)), - 'cc108aa2f79f4f59966c1aee844b2483f2ee51e1f87ae39fe1cc77e74dc8b5b2':list(range(15,22)), - '7eab6298cbc4f77be15c41462e323808c7e3c9abfb83346133271a8256069bc3':list(range(14,20)), - '4cb773309c9177b263f71c2eb40459caaac92e63a8c7aaa0c44a9bdbe52e570b':list(range(10,18)), - '5842a9e01900761ced8ddd780d4b800fef0abfde4c08fdf521535fa2db29b0c5':list(range(10,18)), - '3fb75a5fb1a00a50b7d6200d463319678343b7c359999e9ac4917e6b83be8cef':list(range(10,18)), - '32ef11a573277f27eebbd7728b90fe521e3b5622539de5511be6f325a2430e96':list(range(8,16)), - '66ec2e27e5ed9625d607e6d629caba8cc18c30533768f054e96f44222e14b314':[0,1,2,3,4,6,7,8], - '4eff4f99308c41979f7ad72b1bfed7616068f3b48a6667e9160fdb6f967c7b3d':[0,1,2,3,4,6,7,8], - 'b3795843a7319fd5da061291336647f1fdfffbda33a59b3de17a3754d7c178a7':[0,1,2,3,4,6,7,8,9], - '2c0f126449c7d765984d7964f8ba068fb1798fa2fa0c33ff9bf3217fec7b62b6':[7,9,11,12,15,22,23], - '920445296ed207d8c3395187eee2c8d34492e1b85ee32c256d256e80cfac7155':[7,9,11,12,15,22,23], - '099747cee7125b82d73244d07417b9aecbbb91e18fdfb015d127296deffaafb6':[7,9,11,12,15,22,23], - '1f3a48cec420c3d848fa342fbac424a0214d732f626c95ac7da5c423b028d274':[7,9,11,12,15,22], - '5d70abf0a3267e85df5a5430280317de5cc20d38e0bee674168f9f5de3a937d6':[1,2,3,4,5,6], - '7c7388f14eb940928c135c1c8ffc3439ff77faf3114a4480321e497f6ca54ef8':list(range(13))+[22], - '7645f878095c44e8670df8139f362a3935623b68b82da09a17ff62e5d748dcc4':list(range(13))+[19], - '7d3a5f78e26fd2558c9b92004fec91cb6e94eacc7fd9152b0421dfd7f6360efb':list(range(13)), - '017bf96e4cfbe78ff5606afea89f8e17255e3d53a3c07c7568eb97ce8fce1fd0':list(range(13)), - '9e9612008e3c7e4d4e5471ccde29cc8044a05dd8b5ca644a48dc403aff915208':list(range(9)), - '0b98a540d65084861140cd5a6945dd56818017d8cb870b4bce906e83f5d71c3a':[0,1,2,3,6], - '3f869a12a48c7f150447d49efa89e26c7c2612fe9b4e5336f6c6223592cffc57':list(range(2,11)), - '53954fb2764207322fa866725538e6dd401e415370dcb3b7696c621e36b3a776':list(range(2,10)), - 'edfa394f0ff5b8d04e5fb8fedad9205fd84fcc8ba322ac201351c9f6f707b952':list(range(2,10)), - '214eae73908fe8347df2d9d227a761f0d7965574302ab5dffaa6871ac0de0767':list(range(2,8)), - 'af2e634ea45dbc1ff34ecc64d8ce40b93e2fac8427d35f22306e4c92c9ea28b2':list(range(3,12)), - 'dfdaeabe38a674e26d4f6f267016b493d302e0c6ba20955bdc9e7179f32b94a5':list(range(3,12)), - '01d96ca19b58e2e6b8ae99e1652787872f7e667c3621be6d8f7e37620cedd445':list(range(3,11)), - '7ac8d3c7736b4f8c4c4efd6865602e8e92a43748f9276e26d6bb700e7ad07791':list(range(3,11)), - '1e3ba550a6afa81d2c3dbd0984c4468e09667627ed740ae2a89f1934f34dbbef':list(range(3,9)), - '0f822e66f7d8416639c7649673ea625cf648f33df4a5f148d9654f2172707eba':[0,9,10,11,12,13,14], - '50203f6753b61c19c959cba66ea27efd4870577c8eddf2ed6ed7f1a244754162':[0,4,5,6,7,8,9], - '0c4fa51902f0743b5944979f1fb26bcb89142cdb3586ba1c95bcb30d19527239':[], -} - -def ownership_digest(raw): - normalized=bytearray(raw);tx=u32(raw,0) - for t in range(u16(raw,0x22)):struct.pack_into('>I',normalized,tx+t*8,0) - return hashlib.sha256(normalized).hexdigest() - -def main(a): - inventory=json.loads(a.components.read_text());bank=Bank(a.rom) - paths={p.name:p for d in a.library.rglob('blobs') if d.is_dir() for p in d.iterdir() if p.is_file()} - needed={};rows=[] - for f in a.library.glob('prepared-characters/*/*/character.json'): - doc=json.loads(f.read_text()) - for r in doc['records']: - if r['section']!=29:continue - packed=paths[r['hash']].read_bytes() if r['hash'] in paths else bank.record(29,r['record']) - if hashlib.sha256(packed).hexdigest()!=r['hash']:raise ValueError('Missing immutable input') - m=model(packed);raw_digest=hashlib.sha256(m['raw']).hexdigest() - if raw_digest in REVIEW:needed[raw_digest]=m - for digest,selected in REVIEW.items(): - m=needed[digest];entry=inventory[digest];hidden=sorted(t for c in selected for t in entry['components'][c]['triangles']) - if len(hidden)!=len(set(hidden)) or len(hidden)>=m['triangles']:raise ValueError('Overlapping mask or no driver retained') - ranges=[] - for t in hidden: - if ranges and t==ranges[-1][1]:ranges[-1][1]+=1 - else:ranges.append([t,t+1]) - rows.append({'name':entry['name'],'lod':entry['lod'],'source_sha256':digest, - 'ownership_sha256':ownership_digest(m['raw']),'vertices':m['vertices'], - 'triangles':m['triangles'],'hide':ranges,'hidden':len(hidden),'retained':m['triangles']-len(hidden)}) - header='#pragma once\n// Static reviewed ownership metadata only; no game/model/texture bytes.\n// SHA covers all decompressed model bytes except relocatable texture IDs.\n#include \n#include \n#include \n#include \nnamespace dkr::mods::vehicles {\nstruct ReviewedCharacterMask { unsigned vertices,triangles; std::vector> ranges; };\ninline const std::map ReviewedCharacterMasks{\n' - for r in rows: - header+=' // '+r['name']+' LOD'+str(r['lod'])+'; keep '+str(r['retained'])+' driver faces.\n' - header+=' {"'+r['ownership_sha256']+'",{'+str(r['vertices'])+','+str(r['triangles'])+',{'+','.join('{'+str(x)+','+str(y)+'}' for x,y in r['hide'])+'}}},\n' - a.output.write_text(header+'};\n}\n') - a.output.with_suffix('.json').write_text(json.dumps(rows,indent=2)+'\n') - print('Reviewed '+str(len(rows))+' distinct model variants; no game/mod execution.') - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for arg in ('rom','library','components','output'):p.add_argument('--'+arg,type=Path,required=True) - main(p.parse_args()) diff --git a/patches/custom-vehicles/configure109.py b/patches/custom-vehicles/configure109.py deleted file mode 100644 index 7abcfc28..00000000 --- a/patches/custom-vehicles/configure109.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Configure the isolated v1.0.9 custom-car playtest composition.""" -from pathlib import Path -import argparse -import importlib.util -import sys - -sys.dont_write_bytecode = True -AUTH = Path(__file__).resolve().parent -ROOT = AUTH.parents[1] - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--build', type=Path, required=True) - parser.add_argument('--capsule', type=Path, required=True) - parser.add_argument('--log', type=Path, required=True) - parser.add_argument('--cmake', default='cmake') - args = parser.parse_args() - spec = importlib.util.spec_from_file_location('release109_vehicle_baseline', ROOT / 'patches/release-prep/v1.0.7/release.py') - release = importlib.util.module_from_spec(spec) - spec.loader.exec_module(release) - release.AUTH = AUTH - release.VERSION = '1.0.9-vehicle-playtest.13' - release.configure(args.build, args.log, args.cmake, args.capsule) diff --git a/patches/custom-vehicles/guest_pipeline.py b/patches/custom-vehicles/guest_pipeline.py deleted file mode 100644 index 80e8e7ff..00000000 --- a/patches/custom-vehicles/guest_pipeline.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Counted, build-local hooks over final normal/private Patch Pipeline output.""" -from pathlib import Path -import argparse -import hashlib -import json -import re - -from pipeline import put, once - -NAMES = ('render_3d_model', 'render_sprite_billboard', 'menu_track_select_init', - 'menu_track_select_unload', 'func_80092188', 'trackmenu_setup_render', 'trackmenu_assets', 'func_80053750') - - -def body(text, name): - # Prefer the original body, preserving the existing code-mod dispatcher. - pattern = r'(?:static void dkr_code_body_' + name + r'|RECOMP_FUNC void ' + name + r')\(uint8_t\* rdram, recomp_context\* ctx\) \{' - matches = list(re.finditer(pattern, text)) - if not matches: - return None - if len(matches) > 1: - matches = [m for m in matches if m.group().startswith('static')] - if len(matches) != 1: - raise ValueError('Ambiguous vehicle hook: ' + name) - begin = matches[0].end() - # Skip comments/strings while finding the matching function brace. - tokens = re.finditer(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\{|\}', text[begin:]) - depth = 1 - for token in tokens: - if token.group() == '{': depth += 1 - if token.group() == '}': depth -= 1 - if depth == 0: return begin, begin + token.start() - raise ValueError('Unbalanced guest function: ' + name) - - -def menu(event): - return '{ recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); dkr_legacy_track_menu(rdram,&vc,' + str(event) + 'U,0,0U); }' - - -def render(event, obj, scope='0'): - return '{ recomp_context vc=*ctx; vc.r8=' + scope + '; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); dkr_legacy_character_event(rdram,&vc,' + str(event) + 'U,' + obj + '); }' - - -def hook(text, name): - span = body(text, name) - if not span: return text, False - start, end = span - original = text[start:end] - before, after = '', '' - if name == 'render_3d_model': - before = ''' - const uint32_t vehicle_object=(uint32_t)ctx->r4; - uint32_t vehicle_scope=0; - { recomp_context vc=*ctx; vc.r2=0; extern void dkr_legacy_character_event(uint8_t*,recomp_context*,unsigned,uint32_t); - dkr_legacy_character_event(rdram,&vc,200U,vehicle_object); vehicle_scope=(uint32_t)vc.r2; } -''' - after = render(201, 'vehicle_object', 'vehicle_scope') - elif name == 'render_sprite_billboard': - before = '\n const uint32_t vehicle_wheel=(uint32_t)ctx->r7;\n' + render(202, 'vehicle_wheel') + '\n' - after = render(203, 'vehicle_wheel') - elif name == 'func_80053750': before = '\n' + render(204, '(uint32_t)ctx->r4') + '\n' - elif name == 'menu_track_select_init': after = menu(100) - elif name == 'menu_track_select_unload': before = '\n' + menu(104) + '\n' - elif name == 'func_80092188': - before = '\n { recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); if(dkr_legacy_track_menu(rdram,&vc,101U,0,0U))return; }\n' - after = menu(102) - elif name == 'trackmenu_setup_render': - before = ''' - int vehicle_picker=0; - { recomp_context vc=*ctx; extern int dkr_legacy_track_menu(uint8_t*,recomp_context*,unsigned,const uint32_t*,unsigned); - vehicle_picker=dkr_legacy_track_menu(rdram,&vc,103U,0,0U); } -''' - # Keep the admitted native camera, title, timestamps and balloons. - # Replace only its lower vehicle/TT/CPU widgets while the picker is - # active. Original branch delay slots and epilogue remain intact. - if '// 0x80090F30:' in original: - pc,reg,lower,finish='800915AC','13','80092010','80092150' - elif '// 0x800913E8:' in original: - pc,reg,lower,finish='80091AA4','15','8009252C','8009266C' - else: raise ValueError('Unreviewed native car-menu revision') - branch=re.search(r'(// 0x'+pc+r': beq[^\n]*\n if \()(ctx->r'+reg+r' == ctx->r1)(\) \{)',original) - if not branch: raise ValueError('Native car-menu widget branch changed') - original=original[:branch.start()]+branch.group(1)+'vehicle_picker || ('+branch.group(2)+')'+branch.group(3)+original[branch.end():] - original=once(original,'L_'+lower+':','''L_'''+lower+''': - if(vehicle_picker) { - recomp_context vc=*ctx; - extern void dkr_vehicle_menu_draw(uint8_t*,recomp_context*,void(*)(uint8_t*,recomp_context*),void(*)(uint8_t*,recomp_context*)); - dkr_vehicle_menu_draw(rdram,&vc,menu_element_render,get_text_width); - goto L_'''+finish+'''; - }''') - elif name == 'trackmenu_assets': - before = '\n const unsigned vehicle_asset_type=(unsigned)ctx->r4;\n' - after = 'if(vehicle_asset_type==1U)' + menu(105) - if after: - # All generated returns are explicit. Do not touch comments/string data. - original, count = re.subn(r'\breturn;', after + ' return;', original) - if count < 1: raise ValueError('Guest hook has no return fence: ' + name) - return text[:start] + before + original + text[end:], True - - -def main(args): - out = args.output.resolve() - sources = json.loads(args.sources.read_text(encoding='utf-8')) - found = dict.fromkeys(NAMES, 0) - swaps = [] - for name in sources: - path = Path(name) - if path.suffix != '.c': continue - text = path.read_text(encoding='utf-8') - changed = False - for symbol in NAMES: - text, yes = hook(text, symbol) - found[symbol] += int(yes) - changed |= yes - if changed: - target = out / path.name - if target == path.resolve(): raise ValueError('Guest input/output must differ') - put(target, text.encode()) - swaps.append({'old': str(path.resolve()), 'new': str(target), - 'input_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), - 'output_sha256': hashlib.sha256(text.encode()).hexdigest()}) - if any(n != 1 for n in found.values()): - raise ValueError('Missing/duplicate final vehicle hooks: ' + repr(found)) - put(out / 'swaps.json', (json.dumps(swaps, indent=2) + '\n').encode()) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--sources', type=Path, required=True) - parser.add_argument('--output', type=Path, required=True) - main(parser.parse_args()) diff --git a/patches/custom-vehicles/inspect_base.py b/patches/custom-vehicles/inspect_base.py deleted file mode 100644 index f6ef5df6..00000000 --- a/patches/custom-vehicles/inspect_base.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Read-only inspection of locally owned native car model structure.""" -from pathlib import Path -import argparse -import hashlib -import json -import struct -import zlib - - -def inspect(path, overview=None, metadata=None, hash_header=False): - data = path.read_bytes() - digest = hashlib.sha256(data).hexdigest() - lut = {'dcf54c82a58f6b38603b5865e90c3baabfe553a4d141a1ea2b282170a2e98876': 969568, - '7de1a8fb2a9558cfc3d9ad4497df698c1e89cf7095ac1531557df2af40ba8bcf': 970976}[digest] - u32 = lambda b, p: struct.unpack_from('>I', b, p)[0] - u16 = lambda b, p: struct.unpack_from('>H', b, p)[0] - sections = [data[lut+208+u32(data,lut+4+4*i):lut+208+u32(data,lut+8+4*i)] for i in range(50)] - def record(section, index): - table = sections[section-1] - return sections[section][u32(table,4*index):u32(table,4*index+4)] - names = ('KRUNCH','BUMPER','TIPTUP','CONKER','TIMBER','BANJO','DRUMSTICK','PIPSY','TT','DIDDY') - headers = (2,3,4,5,6,7,8,9,1,0) - hashes = {} - texture_names = json.loads(metadata.read_text())['files']['order'] if metadata else [] - if overview: - from PIL import Image, ImageDraw - overview.mkdir(parents=True, exist_ok=True) - for name, header_id in zip(names, headers): - header = record(34, header_id) - model_table = u32(header,0x10) - models = [u32(header,model_table+4*i) for i in range(header[0x55])] - result = [] - for model_id in models: - if model_id == 0xffffffff: - continue - packed = record(29,model_id) - raw = zlib.decompress(packed[5:],-15) - hashes[model_id] = hashlib.sha256(raw).hexdigest() - nv, nt, nb, na = (u16(raw,p) for p in (0x24,0x26,0x28,0x4a)) - va, ba, ia = (u32(raw,p) for p in (4,0x38,0x4c)) - ta, tx = u32(raw,8), u32(raw,0) - xyz = [struct.unpack_from('>hhh',raw,va+10*i) for i in range(nv)] - indices = [struct.unpack_from('>h',raw,ia+2*i)[0] for i in range(nv)] if na and ia+nv*2 <= len(raw) else [] - batches = [] - for b in range(nb): - p = ba + b*12 - v, ve = u16(raw,p+2), u16(raw,p+14) - texture_id=u32(raw,tx+raw[p]*8) if raw[p] < u16(raw,0x22) else -1 - batches.append({'batch':b,'texture_slot':raw[p], 'texture_id':texture_id, - 'material':texture_names[texture_id] if 0<=texture_id= 0), - 'y': [min(struct.unpack_from('>h',raw,va+10*i+2)[0] for i in range(v,ve)), - max(struct.unpack_from('>h',raw,va+10*i+2)[0] for i in range(v,ve))]}) - result.append({'model':model_id, 'vertices':nv, 'triangles':nt, 'animated':na, - 'batches':batches}) - if overview: - image=Image.new('RGB',(1700,900),(18,24,32));draw=ImageDraw.Draw(image) - palette=[(240,100,80),(100,210,100),(90,150,250),(245,210,80),(190,110,230),(80,220,220), - (240,150,70),(180,200,100),(170,160,220),(80,180,140),(210,120,160),(150,200,240)] - for view,(a,c) in enumerate(((0,1),(2,1),(0,2))): - origin=(270+view*550,350) - def project(i):return (int(origin[0]+xyz[i][a]*1.7),int(origin[1]-xyz[i][c]*1.7)) - for batch in batches: - b=batch['batch'];first,last=batch['triangles'];base=batch['vertices'][0] - for tri in range(first,last): - points=[project(base+raw[ta+16*tri+1+i]) for i in range(3)] - draw.polygon(points,fill=palette[b%len(palette)],outline=(30,35,40)) - xys=[project(i) for i in range(*batch['vertices'])] - if xys:draw.text((sum(p[0] for p in xys)/len(xys),sum(p[1] for p in xys)/len(xys)),str(b),fill='white',stroke_width=1,stroke_fill='black') - draw.text((view*550+30,450),('front XY','side ZY','top XZ')[view],fill='white') - draw.text((20,15),f'{name} LOD{len(result)-1} model {model_id}: static orthographic asset inspection, not a running game',fill='white') - for i,b in enumerate(batches): - text=f"{b['batch']:2} tex {b['texture_id']:4}: {b['material'].replace('ASSET_TEX3D_OBJECTS_','')} / animated {b['animated']}/{b['vertices'][1]-b['vertices'][0]} / tris {b['triangles']}" - draw.text((20+(i//22)*850,490+(i%22)*18),text,fill=palette[i%len(palette)]) - image.save(overview/f'{name}-lod{len(result)-1}.png') - if not hash_header: print(json.dumps({'driver':name, 'header':header_id, 'models':result})) - if hash_header: - print('#pragma once\n// SHA-256 only: no ROM/model bytes. Verified native model ownership profiles.\n#include \n#include \nnamespace dkr::mods::vehicles {\ninline const std::map BaseModelHashes{') - for model_id, digest in sorted(hashes.items()):print(' {' + str(model_id) + ',"' + digest + '"},') - print('};\n}') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('rom',type=Path) - parser.add_argument('--overview',type=Path) - parser.add_argument('--metadata',type=Path) - parser.add_argument('--hash-header',action='store_true') - args=parser.parse_args() - inspect(args.rom,args.overview,args.metadata,args.hash_header) diff --git a/patches/custom-vehicles/inspect_character_components.py b/patches/custom-vehicles/inspect_character_components.py deleted file mode 100644 index f56a9bdf..00000000 --- a/patches/custom-vehicles/inspect_character_components.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Static connected-mesh inspection for explicit, digest-bound ownership reviews.""" -from pathlib import Path -import argparse -import hashlib -import json -import struct -from audit_characters import Bank, model, u32 - -def main(a): - from PIL import Image,ImageDraw - bank=Bank(a.rom) - paths={p.name:p for d in a.library.rglob('blobs') if d.is_dir() for p in d.iterdir() if p.is_file()} - report=json.loads(a.audit.read_text());result={} - a.output.mkdir(parents=True,exist_ok=True) - for row in report['rows']: - if row['compatible'] or row['source']=='native':continue - doc=json.loads(Path(row['source']).read_text()) - records={(r['section'],r['record']):r['hash'] for r in doc['records']} - for lod in row['lods']: - digest=lod['sha256'] - if digest in result:continue - mid=lod['model'];packed=paths[records[29,mid]].read_bytes() if records[29,mid] in paths else bank.record(29,mid) - if hashlib.sha256(packed).hexdigest()!=records[29,mid]:raise ValueError('Missing exact input') - m=model(packed);vp=u32(m['raw'],4) - xyz=[struct.unpack_from('>hhh',m['raw'],vp+10*i) for i in range(m['vertices'])] - parent=list(range(m['triangles']));seen={} - def root(i): - while parent[i]!=i:parent[i]=parent[parent[i]];i=parent[i] - return i - for t,tri in enumerate(m['indices']): - for v in tri: - p=xyz[v] - if p in seen:parent[root(t)]=root(seen[p]) - else:seen[p]=t - groups={} - for t in range(m['triangles']):groups.setdefault(root(t),[]).append(t) - groups=sorted(groups.values(),key=lambda ts:ts[0]) - components=[] - for ts in groups: - pts=[xyz[v] for t in ts for v in m['indices'][t]] - components.append({'triangles':ts,'bounds':[[min(p[c] for p in pts) for c in range(3)],[max(p[c] for p in pts) for c in range(3)]]}) - result[digest]={'name':row['name'],'lod':lod['lod'],'model':mid,'components':components} - image=Image.new('RGB',(1600,350+((len(groups)+3)//4)*230),(15,24,35));draw=ImageDraw.Draw(image) - draw.text((10,10),f"{row['name']} LOD{lod['lod']} {digest}",fill='white') - for i,comp in enumerate(components): - # Three large side/front/top overviews; separate side panel per component. - colour=((i*71)%170+70,(i*43)%170+70,(i*103)%170+70) - for view,(x,y) in enumerate(((2,1),(0,1),(0,2))): - for t in comp['triangles']: - pts=[(260+view*530+xyz[v][x]*row['native_scale']*5,220-xyz[v][y]*row['native_scale']*5) for v in m['indices'][t]] - draw.polygon(pts,fill=colour,outline=(25,35,45)) - ox=200+(i%4)*400;oy=530+(i//4)*230 - for t in comp['triangles']: - pts=[(ox+xyz[v][2]*row['native_scale']*4,oy-xyz[v][1]*row['native_scale']*4) for v in m['indices'][t]] - draw.polygon(pts,fill=colour,outline=(25,35,45)) - draw.text((10+(i%4)*400,355+(i//4)*230),f"Component {i}: {len(comp['triangles'])} faces / {comp['triangles'][0]}..{comp['triangles'][-1]}",fill='white') - draw.text((10+(i%4)*400,375+(i//4)*230),str(comp['bounds']),fill='white') - image.save(a.output/(digest+'.png')) - (a.output/'components.json').write_text(json.dumps(result,indent=2)+'\n') - for digest,r in result.items():print(r['name'],r['lod'],digest,[(i,len(c['triangles']),c['bounds']) for i,c in enumerate(r['components'])]) - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for arg in ('rom','library','audit','output'):p.add_argument('--'+arg,type=Path,required=True) - main(p.parse_args()) diff --git a/patches/custom-vehicles/inspect_inputs.py b/patches/custom-vehicles/inspect_inputs.py deleted file mode 100644 index 824cc509..00000000 --- a/patches/custom-vehicles/inspect_inputs.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Read supplied ZIP/JSON data only; does not import, launch or execute a mod.""" -from pathlib import Path -from io import BytesIO -import argparse -import hashlib -import json -import zipfile - - -def inspect(path): - raw=path.read_bytes() - with zipfile.ZipFile(BytesIO(raw)) as outer: - entries=[n for n in outer.namelist() if n.lower().endswith('.dkrkart')] - packages=[(Path(n).name,outer.read(n)) for n in entries] if entries else [(path.name,raw)] - for name,data in packages: - with zipfile.ZipFile(BytesIO(data)) as archive: - manifest=json.loads(archive.read('kart.json')) - geometry=json.loads(archive.read('geometry.json')) - textures=geometry['textures'] - worst=0 - lod_counts=[] - for lod in range(6): - document=json.loads(archive.read(f'lods/lod{lod}.json')) - lod_counts.append(sum(len(mesh['faces']) for mesh in document['meshes'])) - for mesh in document['meshes']: - texture=mesh.get('texture',-1) - if texture<0:continue - t=textures[texture] - for uv in mesh['uv']:worst=max(worst,abs(uv[0])*t['width']*32,abs(uv[1])*t['height']*32) - print(json.dumps({'name':name,'sha256':hashlib.sha256(data).hexdigest(), - 'bytes':len(data),'lod_triangles':lod_counts, - 'texture_dimensions':[[t['width'],t['height']] for t in textures], - 'mesh_colors':[m.get('color') for m in geometry['meshes']], - 'max_native_uv':worst,'native_uv_limit':32700, - 'anchor_kind':manifest.get('driver_anchor_kind','adapter defaults'), - 'inspection':'ZIP/JSON data only; no game/mod execution'})) - - -if __name__=='__main__': - parser=argparse.ArgumentParser(description=__doc__) - parser.add_argument('files',type=Path,nargs='+') - for path in parser.parse_args().files:inspect(path) diff --git a/patches/custom-vehicles/inspect_placement.py b/patches/custom-vehicles/inspect_placement.py deleted file mode 100644 index 135b7a12..00000000 --- a/patches/custom-vehicles/inspect_placement.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Static package placement inspection; no game, imports or mod execution.""" -from pathlib import Path -from io import BytesIO -import argparse -import json -import math -import zipfile - -def inspect(path): - data=path.read_bytes() - with zipfile.ZipFile(BytesIO(data)) as outer: - names=[n for n in outer.namelist() if n.lower().endswith('.dkrkart')] - if names:data=outer.read(names[0]) - with zipfile.ZipFile(BytesIO(data)) as package: - settings=json.loads(package.read('kart.json'))['settings'] - lod=json.loads(package.read('lods/lod0.json')) - meshes={m['id']:m for m in lod['meshes']} - wheels=[] - for role in ('front_left','front_right','rear_left','rear_right'): - mesh=meshes[settings['wheels'][role]] - lo=[min(v[a] for v in mesh['vertices']) for a in range(3)] - hi=[max(v[a] for v in mesh['vertices']) for a in range(3)] - centre=[(lo[a]+hi[a])*.5 for a in range(3)] - wheels.append(centre) - print(json.dumps({'package':path.name,'role':role,'centre_model_units':[v*settings['scale'] for v in centre], - 'world_y_range':[lo[1]*settings['scale']*.25,hi[1]*settings['scale']*.25]})) - dx=(wheels[0][0]+wheels[1][0]-wheels[2][0]-wheels[3][0])*.5 - dz=(wheels[0][2]+wheels[1][2]-wheels[2][2]-wheels[3][2])*.5 - length=math.hypot(dx,dz);angle=math.radians(settings['yaw']) - print(json.dumps({'package':path.name,'front_direction_before_yaw':[dx/length,dz/length], - 'front_direction_native':[(dx*math.cos(angle)+dz*math.sin(angle))/length,(-dx*math.sin(angle)+dz*math.cos(angle))/length], - 'seat_world_before_placement':[v*.25 for v in settings['seat']]})) - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('files',type=Path,nargs='+') - for path in p.parse_args().files:inspect(path) diff --git a/patches/custom-vehicles/inspect_scale.py b/patches/custom-vehicles/inspect_scale.py deleted file mode 100644 index ea54f0d8..00000000 --- a/patches/custom-vehicles/inspect_scale.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Static ROM/package unit inspection. Never imports or executes a mod/game.""" -from pathlib import Path -from io import BytesIO -import hashlib -import json -import re -import struct -import zipfile -import zlib - -AUTH = Path(__file__).resolve().parent - -def inspect_native(path): - data = path.read_bytes() - lut = {'dcf54c82a58f6b38603b5865e90c3baabfe553a4d141a1ea2b282170a2e98876':969568, - '7de1a8fb2a9558cfc3d9ad4497df698c1e89cf7095ac1531557df2af40ba8bcf':970976}[hashlib.sha256(data).hexdigest()] - u32 = lambda b,p:struct.unpack_from('>I',b,p)[0] - u16 = lambda b,p:struct.unpack_from('>H',b,p)[0] - sections = [data[lut+208+u32(data,lut+4+4*i):lut+208+u32(data,lut+8+4*i)] for i in range(50)] - def record(s,i): - table=sections[s-1] - return sections[s][u32(table,4*i):u32(table,4*i+4)] - masks = {int(m[0]):[int(v) for v in m[1].split(',')] for m in re.findall(r'\{(\d+),\{([\d,]+)\}\}',(AUTH/'native/vehicle_resources.cpp').read_text())} - for name,header_id in zip(('KRUNCH','BUMPER','TIPTUP','CONKER','TIMBER','BANJO','DRUMSTICK','PIPSY','TT','DIDDY'),(2,3,4,5,6,7,8,9,1,0)): - header=record(34,header_id);native_scale=struct.unpack_from('>f',header,12)[0] - model_id=u32(header,u32(header,16));raw=zlib.decompress(record(29,model_id)[5:],-15) - vp,bp,tp=u32(raw,4),u32(raw,0x38),u32(raw,8) - points=[] - for batch in masks[model_id]: - at=bp+12*batch;first_vertex=u16(raw,at+2) - for triangle in range(u16(raw,at+4),u16(raw,at+16)): - for corner in range(3): - index=first_vertex+raw[tp+triangle*16+1+corner] - points.append(struct.unpack_from('>hhh',raw,vp+10*index)) - lo=[min(p[a] for p in points) for a in range(3)];hi=[max(p[a] for p in points) for a in range(3)] - print(json.dumps({'native':name,'scale':native_scale,'vehicle_raw_bounds':[lo,hi], - 'vehicle_world_extents':[(hi[a]-lo[a])*native_scale for a in range(3)]})) - -def inspect_package(path): - data=path.read_bytes() - with zipfile.ZipFile(BytesIO(data)) as outer: - names=[n for n in outer.namelist() if n.lower().endswith('.dkrkart')] - if names:data=outer.read(names[0]) - with zipfile.ZipFile(BytesIO(data)) as archive: - manifest=json.loads(archive.read('kart.json'));geometry=json.loads(archive.read('lods/lod0.json')) - points=[p for m in geometry['meshes'] for p in m['vertices']] - scale=manifest['settings']['scale'];lo=[min(p[a] for p in points)*scale for a in range(3)];hi=[max(p[a] for p in points)*scale for a in range(3)] - print(json.dumps({'package':path.name,'manifest':manifest,'post_scale_bounds':[lo,hi], - 'readme':archive.read('README.txt').decode(errors='replace') if 'README.txt' in archive.namelist() else ''})) - -if __name__=='__main__': - import argparse - p=argparse.ArgumentParser(description=__doc__);p.add_argument('rom',type=Path);p.add_argument('packages',type=Path,nargs='+');a=p.parse_args() - inspect_native(a.rom) - for path in a.packages:inspect_package(path) diff --git a/patches/custom-vehicles/inspect_seating.py b/patches/custom-vehicles/inspect_seating.py deleted file mode 100644 index e9b30096..00000000 --- a/patches/custom-vehicles/inspect_seating.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Read-only ROM/prepared-character geometry inspection; no game/mod execution.""" -from pathlib import Path -import argparse -import hashlib -import json -import re -import struct -import zlib - -AUTH = Path(__file__).resolve().parent -u32 = lambda b,p:struct.unpack_from('>I',b,p)[0] -u16 = lambda b,p:struct.unpack_from('>H',b,p)[0] - -def model(packed): - raw=zlib.decompress(packed[5:],-15) - vp,tp,bp=(u32(raw,p) for p in (4,8,0x38)) - xyz=[struct.unpack_from('>hhh',raw,vp+10*i) for i in range(u16(raw,0x24))] - triangles=[] - for batch in range(u16(raw,0x28)): - at=bp+batch*12;vs=u16(raw,at+2) - for t in range(u16(raw,at+4),u16(raw,at+16)): - at=tp+t*16;ids=[vs+raw[at+1+i] for i in range(3)] - signature=bytes([raw[at]&0x40])+b''.join(raw[vp+i*10:vp+i*10+6]+raw[at+4+c*4:at+8+c*4] for c,i in enumerate(ids)) - triangles.append((batch,ids,signature)) - return raw,xyz,triangles - -def main(args): - data=args.rom.read_bytes() - lut={'dcf54c82a58f6b38603b5865e90c3baabfe553a4d141a1ea2b282170a2e98876':969568, - '7de1a8fb2a9558cfc3d9ad4497df698c1e89cf7095ac1531557df2af40ba8bcf':970976}[hashlib.sha256(data).hexdigest()] - sections=[data[lut+208+u32(data,lut+4+4*i):lut+208+u32(data,lut+8+4*i)] for i in range(50)] - def stock(s,i):return sections[s][u32(sections[s-1],i*4):u32(sections[s-1],i*4+4)] - masks={int(m[0]):[int(v) for v in m[1].split(',')] for m in re.findall(r'\{(\d+),\{([\d,]+)\}\}',(AUTH/'native/vehicle_resources.cpp').read_text())} - # A few mixed batches keep native driver faces, exactly as the adapter. - keeps={26:((151,157),(159,165)),27:((111,116),(118,123)),28:((93,99),),40:((15,21),),35:((11,13),),41:((18,22),)} - names=('Krunch','Bumper','Tiptup','Conker','Timber','Banjo','Drumstick','Pipsy','TT','Diddy') - heads=(2,3,4,5,6,7,8,9,1,0) - donors=[] - for base,hid in enumerate(heads): - h=stock(34,hid);mid=u32(h,u32(h,16));raw,xyz,ts=model(stock(29,mid)) - hidden={t[2] for i,t in enumerate(ts) if t[0] in masks[mid] and not any(a<=if',header,12)[0];mid=u32(header,u32(header,16)) - raw,xyz,ts=model(record(29,mid));body=[ids for _,ids,sig in ts if sig not in donors[base]] - ids=sorted({i for tri in body for i in tri});points=[xyz[i] for i in ids] - lo=[min(p[a] for p in points) for a in range(3)];hi=[max(p[a] for p in points) for a in range(3)] - # Native head-pivot values at model header 0x54 are examined, not assumed. - print(json.dumps({'name':name,'base':names[base],'header_scale':scale,'body_bounds':[[v*scale for v in lo],[v*scale for v in hi]],'body_triangles':len(body),'model':mid,'model_sha256':hashlib.sha256(raw).hexdigest(),'model_header_tail':raw[0x54:0x80].hex()})) - if args.overview: - from PIL import Image,ImageDraw - args.overview.mkdir(parents=True,exist_ok=True) - image=Image.new('RGB',(1000,650),(15,24,35));draw=ImageDraw.Draw(image) - for view,(a,b) in enumerate(((2,1),(0,1))): - for tri in body: - ps=[(250+view*500+xyz[i][a]*scale*8,420-xyz[i][b]*scale*8) for i in tri] - draw.polygon(ps,fill=(120,170,215),outline=(35,70,100)) - draw.line((view*500,420,(view+1)*500,420),fill='yellow') - draw.line((250+view*500,0,250+view*500,650),fill='yellow') - draw.text((10,10),name+' / driver-only static asset, side ZY | front XY',fill='white') - image.save(args.overview/(re.sub('[^a-zA-Z0-9_-]','_',name)+'.png')) - -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--rom',type=Path,required=True);p.add_argument('--prepared',type=Path,required=True);p.add_argument('--overview',type=Path) - main(p.parse_args()) diff --git a/patches/custom-vehicles/music_pipeline.py b/patches/custom-vehicles/music_pipeline.py deleted file mode 100644 index dc124e1f..00000000 --- a/patches/custom-vehicles/music_pipeline.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Bounded selected-course PCM, composed over admitted source copies only.""" -from pathlib import Path -from pipeline import once - -AUTH=Path(__file__).resolve().parent - - -def runtime_resources(text): - text='#include "resident_music.hpp"\n'+text - start=text.index(' auto music=std::make_shared();std::size_t music_budget=128*MiB;') - end=text.index(' out->music_=std::move(music);',start)+len(' out->music_=std::move(music);') - text=text[:start]+(AUTH/'native/resident_music_prepare.inl').read_text()+text[end:] - text=once(text,'prepared->prepared_bytes()+128*MiB-music_budget','prepared->prepared_bytes()+music_encoded_bytes') - # Do not let an old representation reuse this process's retained resource - # cache. The exact compressed/decoded proofs remain in peer fingerprints. - text=once(text,'const auto key=out->profile_->digest+":"+sha256(bytes);', - 'const auto key=out->profile_->digest+":"+sha256(bytes)+":selected-music-v1";') - return text - - -def runtime_binding(text): - text='#include "resident_music.hpp"\n'+text - text=once(text,' mods::online::MusicState state;', ' mods::online::resident_music::State state;') - text=once(text,'explicit MusicBinding(mods::online::MusicState value)', - 'explicit MusicBinding(mods::online::resident_music::State value)') - text=once(text,' if(name=="prepare_memory") {', - ' if(name=="prepare_memory") {\n mods::online::resident_music::PreparationScope ordinary_load(!world.private_owner);') - # ModWorld::dispatch has a separate private hook, which delegates to this - # same frozen_course_hook. No tick/restore/audio path grants preparation. - return text - - -def driver(text): - text='#include "resident_music.hpp"\n'+text - anchor=' const auto result=run_private(dkr_probe_scene_load_cpu,' - text=once(text,anchor,' dkr::mods::online::resident_music::PreparationScope music_loading;\n'+anchor) - anchor=' const auto result=run_private(background ? dkr_probe_menu_background_resume : dkr_probe_authored_scene_resume,' - text=once(text,anchor,' dkr::mods::online::resident_music::PreparationScope music_loading(true,!background);\n'+anchor) - return text diff --git a/patches/custom-vehicles/native/asset_bus_mount.inl b/patches/custom-vehicles/native/asset_bus_mount.inl deleted file mode 100644 index 8139418a..00000000 --- a/patches/custom-vehicles/native/asset_bus_mount.inl +++ /dev/null @@ -1,69 +0,0 @@ -namespace { -// Preserve AssetBus/Mount's admitted ABI. The shared_ptr control block owns -// this derived object, including pins to earlier identical section owners. -// All mounts in this composed bus are made here; no RTTI or raw downcast of -// caller-owned objects is involved. This metadata is prepared, never replayed. -struct SharedSectionMount final : AssetBus::Mount { - std::array hashes; - std::vector> pins; -}; -} -std::shared_ptr AssetBus::mount(std::shared_ptr directory) { - if(!directory)throw Error("Cannot mount an absent immutable directory."); - auto mounted=std::make_shared(); - mounted->directory_=std::move(directory);mounted->pins.reserve(50); - const auto fingerprint=mounted->directory_->fingerprint(); - // Exact canonical section bytes, not package names, exporter metadata or - // record counts. Only one bounded section copy exists during hashing. - for(unsigned s=0;s<50;++s) - mounted->hashes[s]=sha256(mounted->directory_->read(s,0,mounted->directory_->section_size(s))); - std::lock_guard lock(mutex_); - auto banks=banks_;auto ranges=ranges_; - auto previous=banks.find(fingerprint); - if(previous!=banks.end()) { - if(auto live=previous->second.owner.lock())return live; - for(unsigned s=0;s<50;++s) { - if(mounted->directory_->section_size(s)!=previous->second.sizes[s]) - throw Error("An existing bank fingerprint changed section sizes."); - const auto base=previous->second.bases[s]; - auto& range=ranges.at(base); - if(auto owner=banks.at(range.fingerprint).owner.lock())mounted->pins.push_back(std::move(owner)); - else range={fingerprint,s}; // Revive exactly the same immutable bytes. - } - mounted->bases_=previous->second.bases;previous->second.owner=mounted; - banks_.swap(banks);ranges_.swap(ranges);return mounted; - } - if(banks.size()>=MaxBanks)throw Error("This game session has reached its mounted-content limit."); - Bank bank;std::uint64_t next=next_,reused=0;unsigned shared=0; - for(unsigned s=0;s<50;++s) { - bank.sizes[s]=mounted->directory_->section_size(s); - std::shared_ptr donor;std::uint32_t best=End; - // Section zero stays unique and monotonic: allocation_address() is the - // admitted world-clone ordering key. All other sections share ONLY - // with the same section number, length and complete SHA-256 identity. - if(s)for(const auto& [id,existing]:banks) { - if(existing.sizes[s]!=bank.sizes[s]||existing.bases[s]>=best)continue; - const auto live=existing.owner.lock();if(!live)continue; - const auto typed=std::static_pointer_cast(live); - if(typed->hashes[s]!=mounted->hashes[s])continue; - donor=live;best=existing.bases[s]; - } - if(donor) { - bank.bases[s]=best;mounted->pins.push_back(std::move(donor)); - reused+=bank.sizes[s];++shared;continue; - } - bank.bases[s]=static_cast(next); - next+=((std::uint64_t(bank.sizes[s])+4095U)&~std::uint64_t(4095U))+4096U; - if(next>End)throw Error("This game session has exhausted its virtual asset address space."); - ranges.emplace(bank.bases[s],Range{fingerprint,s}); - } - mounted->bases_=bank.bases;bank.owner=mounted; - banks.emplace(fingerprint,std::move(bank)); - // Publish only after every allocation, hash, owner pin and index succeeds. - // Stale pointers never fall through to cartridge DMA and no different - // bytes reuse a retired address. Read still pins its exact section owner. - banks_.swap(banks);ranges_.swap(ranges);next_=static_cast(next); - std::fprintf(stderr,"[online-mods][asset-addresses] banks=%zu shared-sections=%u shared-bytes=%llu reserved-bytes=%u section-zero-unique=1\n", - banks_.size(),shared,static_cast(reused),next_-Begin); - return mounted; -} diff --git a/patches/custom-vehicles/native/resident_music.cpp b/patches/custom-vehicles/native/resident_music.cpp deleted file mode 100644 index 2db38219..00000000 --- a/patches/custom-vehicles/native/resident_music.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include "resident_music.hpp" -#include -#include -#include - -namespace dkr::mods::online::resident_music { -namespace { -using PCM=dkr::runtime::custom_music::DecodedMusic; -constexpr std::size_t MaxDecoded=64*MiB; -thread_local unsigned preparation_depth=0; -thread_local unsigned silent_preparation_depth=0; -struct Recording { - Bytes encoded; - dkr::runtime::custom_tracks::MusicCodec codec; - PCM descriptor; - std::string proof; - mutable std::mutex mutex; - mutable std::weak_ptr cached; -}; -std::mutex recordings_mutex; -std::map> recordings; - -std::shared_ptr recording(const PCM* p) { - std::lock_guard lock(recordings_mutex); - const auto at=recordings.find(p);return at==recordings.end()?nullptr:at->second.lock(); -} -std::shared_ptr acquire(const std::shared_ptr& r,bool allow_decode) { - std::lock_guard lock(r->mutex); - if(auto pcm=r->cached.lock())return pcm; - if(!allow_decode || !preparation_depth) - throw Error("Custom music was not prepared at a confirmed loading boundary."); - auto pcm=std::make_shared();std::string error; - pcm->samples.reserve(std::size_t(r->descriptor.frames)*2); - if(!dkr::runtime::custom_music::decode_bytes(r->encoded.data(),r->encoded.size(),r->codec,*pcm,error,nullptr,MaxDecoded)) - throw Error("Selected course music cannot be decoded: "+error); - if(pcm->frames!=r->descriptor.frames || pcm->sample_rate!=r->descriptor.sample_rate || - pcm->samples.size()!=pcm->frames*2 || sha256_pcm_le(pcm->samples)!=r->proof) - throw Error("Selected course music differs from its pre-match decoded proof."); - r->cached=pcm; - std::fprintf(stderr,"[online-mods][music-residency] selected-frames=%llu rate=%u decoded-bytes=%zu boundary-only=1\n", - static_cast(pcm->frames),pcm->sample_rate,pcm->samples.size()*sizeof(std::int16_t)); - return pcm; -} -} -PreparationScope::PreparationScope(bool enabled,bool audible)noexcept:enabled_(enabled),silent_(enabled&&!audible){if(enabled_)++preparation_depth;if(silent_)++silent_preparation_depth;} -PreparationScope::~PreparationScope(){if(silent_)--silent_preparation_depth;if(enabled_)--preparation_depth;} - -Prepared prepare(Bytes encoded,const dkr::runtime::custom_tracks::MusicInfo& info,std::stop_token stop) { - if(!info.frames || info.frames>MaxDecoded/4 || info.sample_rate<8000 || info.sample_rate>192000 || - info.frames>std::uint64_t(info.sample_rate)*15*60) - throw Error("A recording exceeds the supported 15-minute or 64 MiB per-song limit."); - auto owner=std::make_shared();owner->encoded=std::move(encoded);owner->codec=info.codec; - PCM pcm;pcm.samples.reserve(std::size_t(info.frames)*2);std::string error; - std::atomic cancel{stop.stop_requested()};std::stop_callback cancellation(stop,[&]{cancel=true;}); - if(!dkr::runtime::custom_music::decode_bytes(owner->encoded.data(),owner->encoded.size(),owner->codec,pcm,error,&cancel,MaxDecoded)) - throw Error(error); - if(stop.stop_requested())throw Error("Online music preparation cancelled."); - if(pcm.frames!=info.frames || pcm.sample_rate!=info.sample_rate || pcm.samples.size()!=pcm.frames*2) - throw Error("The decoded frame count or sample rate differs from the track manifest."); - owner->descriptor.frames=pcm.frames;owner->descriptor.sample_rate=pcm.sample_rate; - owner->proof=sha256_pcm_le(pcm.samples); - std::shared_ptr metadata(owner,&owner->descriptor); - { - std::lock_guard lock(recordings_mutex); - for(auto it=recordings.begin();it!=recordings.end();)it=it->second.expired()?recordings.erase(it):std::next(it); - if(recordings.size()>=1024)throw Error("Too many live immutable music recordings."); - recordings[metadata.get()]=owner; - } - // pcm is released here. Only the encoded recording, shape and proof live - // until a confirmed course load. Identical recordings share this owner. - return {std::move(metadata),owner->proof}; -} -State::State(std::shared_ptr source,bool revision80):sources_(std::move(source)), - revision80_(revision80),active_(activate(UINT32_MAX,false)),native_(active_,revision80){} -std::shared_ptr State::activate(unsigned level,bool allow_decode)const { - auto next=std::make_shared();if(!sources_)return next; - // Copy ONLY the selected row. No mutation of a shared immutable library, - // and no accidental use of a metadata-only descriptor by the mixer. - const auto at=sources_->find(level);if(at==sources_->end())return next; - auto song=at->second; - if(song.pcm)if(const auto r=recording(song.pcm.get()))song.pcm=acquire(r,allow_decode); - if(song.pcm && song.pcm->samples.size()!=song.pcm->frames*2) - throw Error("Selected custom music has no resident samples."); - next->emplace(level,std::move(song));return next; -} -void State::load(CharacterMenuMemory& guest,unsigned level) { - // Track Select's silent background loader must not decode a whole song - // each time the cursor moves. Its checkpoint has no active custom song. - // The actual race continuation loads it before the next epoch is ready. - auto prepared=silent_preparation_depth?std::shared_ptr(std::make_shared()):activate(level,true); - native_.load(guest,level); // Restore any native sequence row first. - MusicState next(prepared,revision80_);next.load(guest,level); - active_=std::move(prepared);native_=std::move(next); -} -State State::stage_checkpoint(View bytes)const { - CheckpointReader reader(bytes);if(reader.u32()!=1)throw Error("Unsupported custom music checkpoint."); - const auto level=reader.u32(); - State next=*this; - next.active_=activate(level,false); // Weak-cache hit only; NEVER decode during rollback. - next.native_=MusicState(next.active_,revision80_).stage_checkpoint(bytes); - return next; -} -} diff --git a/patches/custom-vehicles/native/resident_music.hpp b/patches/custom-vehicles/native/resident_music.hpp deleted file mode 100644 index 9733196a..00000000 --- a/patches/custom-vehicles/native/resident_music.hpp +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once -#include "mods/online_mod_music.hpp" -#include - -namespace dkr::mods::online::resident_music { -// Prepared recordings use an aliasing metadata pointer: the owning object -// pins the verified encoded bytes, NOT a library-wide collection of PCM. -struct Prepared { - std::shared_ptr metadata; - std::string pcm_sha256; -}; -Prepared prepare(Bytes encoded,const dkr::runtime::custom_tracks::MusicInfo&,std::stop_token); - -// Only ordinary course loading or the confirmed owned scene continuation may -// open a decoder. Audio mixing and checkpoint replay never grant this scope. -class PreparationScope final { -public: - explicit PreparationScope(bool enabled=true,bool audible=true) noexcept; - ~PreparationScope(); - PreparationScope(const PreparationScope&)=delete; - PreparationScope& operator=(const PreparationScope&)=delete; -private: - bool enabled_,silent_; -}; - -// Delegates the existing integer mixer/checkpoint ABI without changing any -// accepted MusicSong/MusicLibrary, RuntimeResources or ModWorld header layout. -class State { -public: - State(std::shared_ptr,bool revision80); - void load(CharacterMenuMemory&,unsigned level); - void loaded(CharacterMenuMemory& g,recomp_context& c){native_.loaded(g,c);} - void started(CharacterMenuMemory& g){native_.started(g);} - bool volume(CharacterMenuMemory& g,recomp_context& c){return native_.volume(g,c);} - void tick(CharacterMenuMemory& g){native_.tick(g);} - void mix_s16le(std::span b,unsigned r){native_.mix_s16le(b,r);} - void render(float* b,std::size_t n,unsigned r){native_.render(b,n,r);} - Bytes checkpoint()const{return native_.checkpoint();} - State stage_checkpoint(View)const; -private: - std::shared_ptr sources_; - bool revision80_; - std::shared_ptr active_; - MusicState native_; - std::shared_ptr activate(unsigned level,bool allow_decode)const; -}; -} diff --git a/patches/custom-vehicles/native/resident_music_prepare.inl b/patches/custom-vehicles/native/resident_music_prepare.inl deleted file mode 100644 index bf8647a7..00000000 --- a/patches/custom-vehicles/native/resident_music_prepare.inl +++ /dev/null @@ -1,34 +0,0 @@ - auto music=std::make_shared();std::size_t music_encoded_bytes=0; - std::map decoded; - std::string music_identity; - for(const auto& course:out->authored_->courses) { - const auto track=std::ranges::find_if(out->authored_->tracks,[&](const auto& t){return t.id==course.id;}); - if(track==out->authored_->tracks.end())throw Error("Frozen course has no authored resource root."); - if(!track->music)continue; - MusicSong song;song.info=*track->music; - if(song.info.kind==dkr::runtime::custom_tracks::MusicKind::Recording) { - cancelled(stop);auto input=read_file(song.info.file,dkr::runtime::custom_tracks::kMaxMusicBytes); - if(input.size()!=song.info.bytes || sha256(input)!=song.info.sha256) - throw Error("Music for \""+track->name+"\" changed after package verification. Re-import that track."); - auto found=decoded.find(song.info.sha256); - if(found==decoded.end()) { - if(input.size()>128*MiB-music_encoded_bytes) - throw Error("Encoded custom music exceeds the 128 MiB session limit."); - const auto encoded_size=input.size();resident_music::Prepared prepared; - try{prepared=resident_music::prepare(std::move(input),song.info,stop);} - catch(const std::exception& e){throw Error("Music for \""+track->name+"\" cannot be prepared: "+e.what());} - music_encoded_bytes+=encoded_size; - music_identity+="\n"+song.info.sha256+":"+prepared.pcm_sha256; - found=decoded.emplace(song.info.sha256,std::move(prepared)).first; - } - song.pcm=found->second.metadata;const auto& shape=*song.pcm; - if(!shape.frames || shape.frames!=song.info.frames || shape.sample_rate!=song.info.sample_rate || - song.info.loop_start>=shape.frames || - (song.info.loop_end && (song.info.loop_end>shape.frames || song.info.loop_end<=song.info.loop_start))) - throw Error("Music for \""+track->name+"\" has invalid frame, sample-rate or loop settings. Re-export that track."); - std::fprintf(stderr,"[online-mods][music] course=%s frames=%llu rate=%u unique-recordings=%zu encoded-bytes=%zu idle-decoded-bytes=0\n", - track->id.c_str(),static_cast(shape.frames),shape.sample_rate,decoded.size(),music_encoded_bytes); - } - music->emplace(course.level_id,std::move(song)); - } - out->music_=std::move(music); diff --git a/patches/custom-vehicles/native/vehicle_base_hashes.hpp b/patches/custom-vehicles/native/vehicle_base_hashes.hpp deleted file mode 100644 index 5901ecb4..00000000 --- a/patches/custom-vehicles/native/vehicle_base_hashes.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once -// SHA-256 only: no ROM/model bytes. Verified native model ownership profiles. -#include -#include -namespace dkr::mods::vehicles { -inline const std::map BaseModelHashes{ - {19,"7da92dbc6706b391359b4603518092911edb11c9f174291e74d3c68bd9141ad0"}, - {20,"fe879659b9bb38b2f65f73b8f037e6210a88323fba091f1971e0fe75ad4a4ff5"}, - {21,"8208c8c3eca95b30af64050e82325ec9730021814b0d5b6f89f353b0526f89ad"}, - {22,"841da0fb29d85ac63a26b3a15d40e3f09379afeab74600acce7de7fabdf318ac"}, - {23,"a5c5015108c39e5e485e74fc07a45c047b994d9b036b36caaa0080f1eaeb4088"}, - {24,"dd07b75514fcdd1dbf4c27315742c50eb13258a1c0264396c76a10cbbe11680e"}, - {25,"8509d353e6adb2e0df898665b760b71a152ab89ad28b78e05c8996d2df285ce9"}, - {26,"33b70089c3c6ddd27c99fa5de018ec135e3cfdfc86ebd04a128051200c0ac6ef"}, - {27,"9918c29719318875f4264fba228b9d35466005b6bc620794c0ceeab5a3b8e7e3"}, - {28,"d20c289e56d47823c09e2b6b835c42d99272626ca75439d9f9026a6e81361bb4"}, - {29,"7ab53d7d295a26bbb5819ad429820dbb0226ac0f4de6399b63e78610ffa94513"}, - {30,"20b2818d5d0ab762f11853e35159100815be8a223148c7b35772fdaf5b3bf39b"}, - {31,"aea401fb04c3823fa2b0aaa26960fe52593c7ac134669554007f3209206393da"}, - {32,"f4c593c3b1ce9f93b410056df44fd24fc8788886b10d97f57dc106cb36e92ee4"}, - {33,"1570f99290222483edb27a034fe7bd0dd60d98adf73acc264c495c107a1d53e7"}, - {34,"d6ffffbfdba404724225b684007de54c6916810d7f2238e8cedfe40abebe0691"}, - {35,"ecbd2f47721a9b04e203829c4170be20674813622b4cad7269c19bd3789edf89"}, - {36,"5a7ddbe3d87effae3bc26c95b2929fe9b2c385f88bf687fdf9b3dd154f735ace"}, - {37,"bde65de3e9cab97b5a59f8dcf22fb424f2a5e78ae91ca0b9d41c0ae71d9a31c9"}, - {38,"c224b9e2465e1e653cbc663929d8e95f0368ecee3799a4cf5941ea3f658413a8"}, - {39,"17b1333524735e1de3678bbaad079ba3fe3acd9d600fb2492fd854202c98a281"}, - {40,"29435326ba92e38b186166372d5ab043180fe0eb9e3a3d5f4616da7ff62a5cea"}, - {41,"cff210ac45bc13a764e3bb25abe9164c1875ca42b3d4ffe4650a62b82f205c1c"}, - {42,"5f23ec4b5efe6367b02d0904020725d0282498731bc35fca3d62fc8ef5521519"}, - {43,"b8d66810976a4aafd8270f2820308b965f8619d5849953da6ffbfa8a7bd12f0a"}, - {44,"e185896a320c1feb95df2c56b83b0e2ced92d2dc624f443471dd27be0e325b51"}, - {45,"4e0b0c985fd1e52e957d001d7957170fac2f09ab8de46b09fa196c4fcd41f87c"}, - {46,"98e756dabd8b3d28f9137c87ab6b36202182a4f50ce322ca78a4764a1db33ee2"}, - {47,"385cf295c452e05fa4234e1762a7ba40f18999e8fd2ae3b7853dcd8319ed689b"}, - {48,"e9b1a57d8c2b2a98c4083517227ba784d5ad6c884aa7f1a6e8f7756d6362923d"}, - {49,"e4a0f2cccde7d9f3a4ca651e9f76188a803444ade56caf67d8c22ff55d7357d9"}, - {50,"25a9f7f2c8188d679714555b1a9527c2cbcf1a00def71df2d1f4ecda08be0eda"}, - {51,"55c27cb6a1e46104e5745286ff9bcb63f47b64d2e9d4f3ddcc6cfc535d23afb1"}, - {52,"90ed07ea6f7d3043cca826dbcc39e4f8906ccb7d49892b2cec019ffb05cbaaf6"}, - {53,"be15f12c2e830ba3616edba68a38996c2177743f65f0aa0146d755ea4cd25130"}, - {54,"016ee10d60624cca9d37bead91872814d323de784a3345a3d226681dc6c212f4"}, - {55,"d96658e772f10af2b3eaa716a680439e3a2a28a69019497dac3f32ff47ef33be"}, - {56,"a9a94ca984c7545c7231500b723a42eff08b2743b1727a43dc8e64258c4d8a01"}, - {57,"d09ad5e5f3f3765f91082a650411a1188b12795d4a9f0abd88bc74ae135bc6d0"}, - {58,"d1839dde0f517cf177f0a3e0b0c69ff31447dba9a4abb57cb01bafa6fb375949"}, - {59,"db2ecf373fa1d983b366a8758c8f214a7eee288e1b0ff9320e9479ca0fab5de1"}, - {60,"404720e7541841c3bf6dcedfab820bf6b8c45d9b0af20f0c63869fc2be18ba05"}, - {61,"aa7b6962705909a1c4a9952310780f2e93d5edeb84c205c855924a482519fd9f"}, - {62,"6750e8d74992a2536a19b451f256a198b7d7960777c0c5891ef455881328c531"}, - {63,"14b0be91cacc13cf4acec2e08f42279df7826fed7ba168c03d90e54ed14b8292"}, - {64,"ac3ec50803809514b2bb230f50e05e40fa5b88e1a3aa0ae8a83d273c2eb1c7d8"}, - {65,"07f71aef4d020c486092d441b1f4ec79d4af63a14a7e78fce78fe446c247cbb7"}, - {66,"77e8937a32f816298a4536de98590c12ec22a49a190de34586fd08622843c0ff"}, - {67,"e001baaff392f81852fe826892b5455c2b7367bfe72777440d9e5c1a6f632f14"}, - {68,"5259fd3d65c06c35f098cc625b11c8072e53d0de630fac9a65d4420de63994cf"}, - {69,"18410505bdfab8f6a7f60f7f174e5afd4ae9645bc421ab1b51669db447f83a67"}, - {70,"66481bc56178e767c7a24f841eb9cefa0161c0f2c1b88c8b577de99668f7ebb9"}, - {71,"8ceba732b83c4f24ac56280aad7f4f2aa2c3172e64417521b7df29bb546ca96d"}, - {72,"3a94738a38e083427113fa057d2f7203c9b3519e4b1ef488f6c1ccc3a768aea3"}, - {73,"c0fe830c72ef37e6f2ebf2dd959bdb7073ecfbacd63534e974f2c0d76d4af64b"}, - {75,"d71304bba9be2a100c55263c1f77d537d7364ecd7ea6496b044790adf4e287d0"}, - {76,"cf8738029b3acc4fe58bd658b718d22b892116eca3694fd0b1e25e14cc098377"}, - {77,"5179c7dac5511d9a5f4823f80bf5b7383bd4973c41958571fab3780ce3c2b70e"}, - {78,"058ebd8cff68afaf1bee173f17acb5d7868808848f5d1da927992dba1549656c"}, -}; -} diff --git a/patches/custom-vehicles/native/vehicle_binding.cpp b/patches/custom-vehicles/native/vehicle_binding.cpp deleted file mode 100644 index 008a7db6..00000000 --- a/patches/custom-vehicles/native/vehicle_binding.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include "vehicle_binding.hpp" -#include -#include - -namespace dkr::mods::vehicles { -namespace { -struct Binding { std::weak_ptr owner;std::shared_ptr resources; }; -std::mutex mutex; -std::map bindings; -std::atomic> renderer; -} -void bind_resources(std::shared_ptr owner,std::shared_ptr resources) { - if(!owner||!resources||resources->library.packages.empty())return; - std::lock_guard lock(mutex); - std::erase_if(bindings,[](const auto& b){return b.second.owner.expired();}); - const auto it=bindings.find(owner.get()); - if(it!=bindings.end()&&it->second.resources->identity!=resources->identity)throw Error("A running asset owner cannot change its custom vehicles."); - if(it==bindings.end()&&bindings.size()>=64)throw Error("Too many concurrent custom-vehicle preparation owners."); - bindings.insert_or_assign(owner.get(),Binding{owner,std::move(resources)}); -} -std::shared_ptr resources_for(const void* owner) { - if(!owner)return {}; - std::lock_guard lock(mutex);const auto it=bindings.find(owner); - if(it==bindings.end())return {}; - if(it->second.owner.expired()){bindings.erase(it);return {};} - return it->second.resources; -} -void inherit_resources(const void* source,std::shared_ptr destination) {bind_resources(std::move(destination),resources_for(source));} -void publish_render_resources(std::shared_ptr resources) {renderer.store(std::move(resources),std::memory_order_release);} -std::shared_ptr render_resources() {return renderer.load(std::memory_order_acquire);} -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_binding.hpp b/patches/custom-vehicles/native/vehicle_binding.hpp deleted file mode 100644 index ddc3ed53..00000000 --- a/patches/custom-vehicles/native/vehicle_binding.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include "vehicle_resources.hpp" - -namespace dkr::mods::vehicles { -// Immutable preparation attachments, not mutable gameplay or global choices. -// This avoids changing any accepted RuntimeSession/RuntimeResources layout. -void bind_resources(std::shared_ptr owner,std::shared_ptr); -std::shared_ptr resources_for(const void* owner); -void inherit_resources(const void* source,std::shared_ptr destination); -void publish_render_resources(std::shared_ptr); -std::shared_ptr render_resources(); -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_character_masks.hpp b/patches/custom-vehicles/native/vehicle_character_masks.hpp deleted file mode 100644 index 491a0ca6..00000000 --- a/patches/custom-vehicles/native/vehicle_character_masks.hpp +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once -// Static reviewed ownership metadata only; no game/model/texture bytes. -// SHA covers all decompressed model bytes except relocatable texture IDs. -#include -#include -#include -#include -namespace dkr::mods::vehicles { -struct ReviewedCharacterMask { unsigned vertices,triangles; std::vector> ranges; }; -inline const std::map ReviewedCharacterMasks{ - // DKR_Blastoise - Custom Racer 6 LOD0; keep 378 driver faces. - {"fc0595169658be546bf4161f13184355f03893108060271e395f77a8556219c7",{706,448,{{378,448}}}}, - // DKR_Blastoise - Custom Racer 10 LOD0; keep 367 driver faces. - {"ba8cc38f3d0b7c36aaa33d8653d3e7ea7823d23cfbe63ae9099598811b58fefd",{696,437,{{367,437}}}}, - // DKR_Blastoise - Custom Racer 1 LOD0; keep 378 driver faces. - {"6550b03c5d7d4c9e2f1a77c0b29cc684ad886bf894b01f4264200f4672f34a4a",{706,448,{{378,448}}}}, - // Link LOD0; keep 263 driver faces. - {"2a4eb94c1e3c4cd88a7abab84b3f81886b650b2a5b2ebf407e82a32c51534680",{556,335,{{255,327}}}}, - // Link LOD1; keep 263 driver faces. - {"46a8e9111a9c8f22c839a8b24fd3daa1badeec759471fc2f8d814a3259f1c939",{556,335,{{255,327}}}}, - // Link LOD3; keep 141 driver faces. - {"33080b291c0786903304a49d75e96d85fecf4625008e90ed8dd95ea805b468d9",{358,196,{{141,196}}}}, - // Link LOD4; keep 74 driver faces. - {"bcb68984b9bfeb5a931d63c4a0f5db8c335a7513893a1f6759bac1bb43aef07d",{201,129,{{74,129}}}}, - // Rexy LOD0; keep 117 driver faces. - {"cfb223e014d8f60ce6adc25d67e4996b9d09f36f0d33a0843508f3d814f75b88",{282,193,{{0,64},{181,193}}}}, - // Rexy LOD1; keep 117 driver faces. - {"bea874c27e9b6ff0a0f52f8b715ff8da493f6af5be20d7407e2c57e6bf57d1e0",{282,193,{{0,64},{181,193}}}}, - // Rexy LOD3; keep 71 driver faces. - {"6d98149204e1377bb16a7dc4caaac4474f78be949889368b951865861964f528",{210,130,{{0,47},{118,130}}}}, - // DKR-Yoshi-Racing-Story64v04 - Custom Racer 9 LOD0; keep 322 driver faces. - {"f52e4a5a9782656910f9129b7785e82b1d32060441e51d95a4fa0edd1edb669d",{480,389,{{208,220},{231,233},{249,272},{294,312},{337,343},{383,389}}}}, - // DKR-Yoshi-Racing-Story64v04 - Custom Racer 9 LOD4; keep 71 driver faces. - {"eaf85793a2307437572856cd69396983f0d0402fffdace199a7ffbe2b2c1df76",{155,101,{{54,57},{62,64},{65,80},{84,86},{87,89},{95,101}}}}, - // DKR-Yoshi-Racing-Story64v04 - Custom Racer 5 LOD0; keep 322 driver faces. - {"656cad4efc888abfc31889ef010e1db4fd54e22b42470ab62ae3f42dc2fe23e9",{479,389,{{208,220},{231,233},{249,272},{294,312},{337,343},{383,389}}}}, - // DKR-Yoshi-Racing-Story64v04 - Custom Racer 5 LOD1; keep 322 driver faces. - {"70bf5db2828a47c8cd806058d704b88fcba1b3fbf57a0251d8d4470589c1862c",{473,389,{{208,220},{231,233},{249,272},{294,316},{341,347},{387,389}}}}, - // 007 Bond (car only) LOD0; keep 269 driver faces. - {"557c2cd9aa58225f79941fd4e6537559239f88f2af2909ac376a7e06857a8961",{645,331,{{269,331}}}}, - // Grunty LOD0; keep 462 driver faces. - {"c980b3ad4ed979a16286d84677dce55788cb134e3b4a17607a11d6cebe28f06b",{945,532,{{0,68},{530,532}}}}, - // Grunty LOD1; keep 115 driver faces. - {"b5ba576dfe37b7ad4d08ebb1ee47ccb6c4f16d239ff7f0927ce7a8697670ae91",{367,164,{{0,47},{162,164}}}}, - // Grunty LOD2; keep 73 driver faces. - {"d2fda3ed6688947b8c9ac4a83ee32b0f7ff72fae54eaeca8ffaec59c0c4f2ff1",{266,114,{{0,41}}}}, - // Grunty LOD3; keep 52 driver faces. - {"cedd8a95f452aee0226325155f7953b7f8c405f64d1efb661db9c0daa6d5ac19",{180,77,{{0,25}}}}, - // Grunty LOD4; keep 38 driver faces. - {"9a00037cd1663b49e310ce40b33ab0a16819f58839d5ed6bc99d60a5e9c4499e",{136,57,{{0,19}}}}, - // YOOKA LOD0; keep 262 driver faces. - {"142f943c65de6de9ce95e6f490d4af202a4541ab3facb12e497c2914e106bdf5",{478,329,{{0,61},{323,329}}}}, - // YOOKA LOD1; keep 192 driver faces. - {"115abb12236b36183d8da47bd740cb316b1aed05b6949ffa9f337a57939cde30",{355,241,{{192,241}}}}, - // YOOKA LOD2; keep 144 driver faces. - {"36805d2544fd87828955b653c04dbd44addcd887a32283d77aa6975345aa8f53",{271,188,{{144,188}}}}, - // YOOKA LOD3; keep 116 driver faces. - {"eabc64550216773d48b67007eb94fb5e91ef98738a0f5512dbf8477c1fa89df9",{196,141,{{116,141}}}}, - // YOOKA LOD4; keep 84 driver faces. - {"f7e9a87f261f6d79d2bd943a5f23ab9949a618725e1ff969462f27d1f32071f8",{135,103,{{84,103}}}}, - // TINY KONG LOD0; keep 374 driver faces. - {"169d52f368d3a8aa1fef5160ba92df64c0cdf65126faf6053a482aa7bbfa8340",{521,445,{{368,433},{439,445}}}}, - // TINY KONG LOD1; keep 154 driver faces. - {"c0cc2c84a369c6bcbcb5c5f182feb1ee9b82e0e40ec551babca78391496271d9",{255,203,{{154,203}}}}, - // TINY KONG LOD2; keep 120 driver faces. - {"f155341509539713c34fbf1b1517eb7c78773c8ec72827a358423a8768981155",{204,164,{{120,164}}}}, - // TINY KONG LOD3; keep 92 driver faces. - {"acc1dca251ba11eb3acf5d34538312ce223a6687feb1462477993fcbe3b94c06",{145,117,{{92,117}}}}, - // TINY KONG LOD4; keep 70 driver faces. - {"e376f3ab68e9f72eccd612fd4f8dd0cd890afebb45f94d7f2ec26c2f860694fe",{116,89,{{70,89}}}}, - // Haunter LOD0; keep 302 driver faces. - {"6a8eb2bb216a528216cc3979120ee50fadc1116e4194e6a7b43c75a1c5b5b9bd",{669,395,{{0,4},{306,395}}}}, - // Haunter LOD1; keep 148 driver faces. - {"07b6f01bb6a036f10bde38acd584e1fa73ee9714face71a8362627b297ba6897",{412,241,{{0,4},{16,105}}}}, - // Haunter LOD4; keep 6 driver faces. - {"7ee68ffe76938d7ee506e4d918061a5977fdb088e626cc41525ca9142cce8d92",{8,6,{}}}, -}; -} diff --git a/patches/custom-vehicles/native/vehicle_library.cpp b/patches/custom-vehicles/native/vehicle_library.cpp deleted file mode 100644 index 791937cb..00000000 --- a/patches/custom-vehicles/native/vehicle_library.cpp +++ /dev/null @@ -1,270 +0,0 @@ -#include "vehicle_library.hpp" -#include "magic_code_package.hpp" -#include "online_mod_bundle.hpp" -#include -#include -#include -#ifdef _WIN32 -#define WIN32_LEAN_AND_MEAN -#include -#else -#include -#include -#include -#include -#endif - -namespace dkr::mods::vehicles { -namespace { -constexpr unsigned MaxInstalled=512; -constexpr std::size_t MaxIndex=2*MiB,MaxFrozenSource=64*MiB,MaxFrozenDecoded=128*MiB; -[[noreturn]] void bad(const std::string& s){throw Error("Vehicle library: "+s);} -void cancelled(std::stop_token stop){if(stop.stop_requested())bad("operation cancelled.");} -std::string nonce() { - std::random_device random;Bytes bytes(16);for(auto& b:bytes)b=std::uint8_t(random());return sha256(bytes); -} -struct Lock { -#ifdef _WIN32 - HANDLE handle=INVALID_HANDLE_VALUE; -#else - int handle=-1; -#endif - explicit Lock(const std::filesystem::path& root) { - auto ancestor=root; - while(!std::filesystem::exists(ancestor)) { - const auto parent=ancestor.parent_path();if(parent.empty()||parent==ancestor)bad("storage is unavailable.");ancestor=parent; - } - online::check_storage(ancestor,true);std::filesystem::create_directories(root);online::check_storage(root,true); - const auto path=private_storage_path(root/"library.lock"); -#ifdef _WIN32 - handle=CreateFileW(path.c_str(),GENERIC_READ|GENERIC_WRITE,0,nullptr,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL|FILE_FLAG_OPEN_REPARSE_POINT,nullptr); - if(handle==INVALID_HANDLE_VALUE)bad("the library is busy or not writable."); - BY_HANDLE_FILE_INFORMATION info{}; - if(!GetFileInformationByHandle(handle,&info)||(info.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT))) { - CloseHandle(handle);handle=INVALID_HANDLE_VALUE;bad("unsafe library lock."); - } -#else - handle=open(path.c_str(),O_RDWR|O_CREAT|O_CLOEXEC|O_NOFOLLOW,0600); - if(handle<0)bad("the library is not writable."); - struct stat info{}; - if(fstat(handle,&info)||!S_ISREG(info.st_mode)||flock(handle,LOCK_EX|LOCK_NB)) {close(handle);handle=-1;bad("the library is busy or unsafe.");} -#endif - } - ~Lock(){ -#ifdef _WIN32 - if(handle!=INVALID_HANDLE_VALUE)CloseHandle(handle); -#else - if(handle>=0)close(handle); -#endif - } -}; -void sync_directory(const std::filesystem::path& path) { -#ifndef _WIN32 - const int fd=open(path.c_str(),O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); - if(fd<0)bad("could not open storage for its durability checkpoint."); - const auto result=fsync(fd);const auto failure=errno;close(fd); - if(result&&failure!=EINVAL&&failure!=ENOTSUP)bad("could not confirm storage durability."); -#else - (void)path; -#endif -} -Json index(const std::filesystem::path& root) { - const auto path=root/"library.json"; - if(!std::filesystem::exists(path))return {{"schema",1},{"items",Json::object()}}; - online::check_storage(path,false);auto doc=code::parse_document(read_file(path,MaxIndex),MaxIndex); - if(!doc.is_object()||doc.size()!=2||doc.value("schema",0)!=1||!doc.contains("items")||!doc["items"].is_object()||doc["items"].size()>MaxInstalled)bad("invalid catalogue. Existing packages were not changed."); - for(const auto& [hash,item]:doc["items"].items()) { - if(!valid_digest(hash)||!item.is_object()||item.size()!=7||!item.contains("type")||!item["type"].is_number_unsigned()|| - item["type"].get()>2||!item.contains("enabled")||!item["enabled"].is_boolean())bad("invalid catalogue entry."); - for(const auto* field:{"name","source"}) { - if(!item.contains(field)||!item[field].is_string())bad("invalid display metadata."); - const auto& s=item[field].get_ref(); - if(s.empty()||s.size()>240||std::any_of(s.begin(),s.end(),[](unsigned char c){return c<32||c==127;}))bad("invalid display name."); - } - for(const auto* field:{"width","height"})if(!item.contains(field)||!item[field].is_number_unsigned()||!item[field].get()||item[field].get()>512)bad("invalid portrait dimensions."); - if(!item.contains("portrait")||!item["portrait"].is_string()||!valid_digest(item["portrait"].get()))bad("invalid portrait identity."); - } - return doc; -} -void publish(const std::filesystem::path& root,const Json& doc) { - const auto path=root/"library.json";const auto value=doc.dump();if(value.size()>MaxIndex)bad("the catalogue is too large."); - const auto history=root/"history";std::filesystem::create_directory(history);online::check_storage(history,true); - if(std::filesystem::exists(path)) { - online::check_storage(path,false);const auto old=read_file(path,MaxIndex);const auto backup=history/(sha256(old)+".json"); - if(!std::filesystem::exists(backup))write_new_file(backup,old); - else {online::check_storage(backup,false);if(read_file(backup,MaxIndex)!=old)bad("a catalogue backup failed verification.");} - sync_directory(history); - } - const auto temporary=root/("catalog-"+nonce()+".tmp"); - write_new_file(temporary,View(reinterpret_cast(value.data()),value.size())); - try { -#ifdef _WIN32 - if(!MoveFileExW(private_storage_path(temporary).c_str(),private_storage_path(path).c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))bad("could not commit the catalogue."); -#else - std::filesystem::rename(temporary,path);sync_directory(root); -#endif - }catch(...){std::error_code ec;std::filesystem::remove(temporary,ec);throw;} -} -std::shared_ptr load(const std::filesystem::path& root,const Json& doc,std::string_view hash) { - if(!valid_digest(hash)||!doc["items"].contains(std::string(hash)))bad("this vehicle is not installed."); - const auto& item=doc["items"][std::string(hash)];const auto type=Type(item.at("type").get()); - const auto path=root/"packages"/(std::string(hash)+extension(type));online::check_storage(path,false); - const auto bytes=read_file(path,MaxPackageBytes);if(sha256(bytes)!=hash)bad("an installed package failed verification. Import it again."); - auto p=inspect(bytes,item.at("source").get(),type); - if(p->name!=item.at("name").get())bad("the installed name does not match its package."); - return p; -} -std::string install(const std::filesystem::path& root,Json& doc,const Package& p,bool enable) { - auto& items=doc["items"]; - // Identical inner exports (including a ZIP and its standalone .dkrkart) - // have one identity. Reimport never duplicates or silently enables one. - if(items.contains(p.digest)) { - (void)load(root,doc,p.digest);return p.digest; - } - if(items.size()>=MaxInstalled)bad("the installed vehicle limit has been reached."); - if(enable&&std::count_if(items.begin(),items.end(),[](const auto& i){return i.at("enabled").template get();})>=MaxEnabled)enable=false; - const auto packages=root/"packages",portraits=root/"portraits"; - std::filesystem::create_directory(packages);std::filesystem::create_directory(portraits); - online::check_storage(packages,true);online::check_storage(portraits,true); - const auto source=packages/(p.digest+extension(p.type)),portrait=portraits/(p.digest+".rgba"); - Image preview=p.portrait; - if(preview.width>128||preview.height>128) { - const float factor=std::min(128.F/preview.width,128.F/preview.height); - Image small{std::max(1U,unsigned(preview.width*factor)),std::max(1U,unsigned(preview.height*factor)),{}}; - small.rgba.resize(std::size_t(small.width)*small.height*4); - for(unsigned y=0;y='A'&&c<='Z')c=char(c-'A'+'a'); - return name; -} -std::vector conflicts(const Json& doc,const std::vector>& packages) { - std::vector out; - for(const auto& p:packages) { - if(doc["items"].contains(p->digest))continue; - ImportConflict row{p->digest,p->name,{}}; - for(const auto& [hash,item]:doc["items"].items()) - if(item["type"].get()==unsigned(p->type)&&name_key(item["name"].get())==name_key(p->name)) - row.installed.push_back(hash); - if(!row.installed.empty())out.push_back(std::move(row)); - } - return out; -} -} // namespace -std::vector Library::scan(std::stop_token stop)const { - if(!std::filesystem::exists(root_))return {}; - Lock lock(root_);const auto doc=index(root_);std::vector out; - for(const auto& [hash,e]:doc["items"].items()) { - cancelled(stop);Item item;item.digest=hash;item.id="vehicle:"+hash;item.name=e.at("name").get(); - item.source_name=e.at("source").get();item.enabled=e.at("enabled").get();item.type=Type(e.at("type").get()); - item.portrait.width=e.at("width").get();item.portrait.height=e.at("height").get(); - const auto path=root_/"portraits"/(hash+".rgba");online::check_storage(path,false); - item.portrait.rgba=read_file(path,512*512*4); - if(item.portrait.rgba.size()!=std::size_t(item.portrait.width)*item.portrait.height*4||sha256(item.portrait.rgba)!=e.at("portrait").get())bad("an installed portrait failed verification. Import the vehicle again."); - out.push_back(std::move(item)); - } - std::sort(out.begin(),out.end(),[](const auto& a,const auto& b){return std::pair{a.name,a.digest} Library::import_file(const std::filesystem::path& path) { - return import_prepared(prepare_import(path),ImportPolicy::RejectDuplicates); -} -ImportBatch Library::prepare_import(const std::filesystem::path& path)const { - const auto inputs=read_inputs(path);std::vector> prepared; - // Validate the entire user-selected bundle before changing activation. - std::set> names; - for(const auto& input:inputs) { - auto p=inspect(input.bytes,input.name,input.type); - if(!names.insert({unsigned(p->type),name_key(p->name)}).second) - bad("the bundle contains different cars with the same name. Import them separately."); - prepared.push_back(std::move(p)); - } - Lock lock(root_);const auto doc=index(root_); - auto matches=conflicts(doc,prepared); - return {std::move(prepared),std::move(matches)}; -} -std::vector Library::import_prepared(const ImportBatch& batch,ImportPolicy policy) { - if(batch.packages.empty()||batch.packages.size()>16)bad("invalid prepared import."); - Lock lock(root_);auto doc=index(root_);std::vector hashes; - const auto matches=conflicts(doc,batch.packages); - if(policy==ImportPolicy::RejectDuplicates&&!matches.empty()) - bad("a car with this name is installed. Choose Replace older copies or Keep both."); - if(policy==ImportPolicy::ReplaceSameName&&matches!=batch.conflicts) - bad("the library changed while you were choosing. Import the bundle again."); - std::map replacement_enabled; - if(policy==ImportPolicy::ReplaceSameName)for(const auto& row:matches) { - bool enabled=false; - for(const auto& hash:row.installed) { - enabled|=doc["items"].at(hash).at("enabled").get(); - doc["items"].erase(hash); // Old immutable blobs and catalogue history remain recoverable. - } - replacement_enabled.emplace(row.incoming,enabled); - } - for(const auto& p:batch.packages) { - const auto prior=replacement_enabled.find(p->digest); - hashes.push_back(install(root_,doc,*p,prior==replacement_enabled.end()?true:prior->second)); - } - publish(root_,doc);return hashes; -} -std::string Library::import(Input input,bool enable) { - const auto p=inspect(input.bytes,std::move(input.name),input.type);Lock lock(root_);auto doc=index(root_); - auto hash=install(root_,doc,*p,enable);publish(root_,doc);return hash; -} -void Library::enable(std::string_view hash,bool enabled) { - if(!valid_digest(hash))bad("invalid vehicle identity."); - Lock lock(root_);auto doc=index(root_);auto& items=doc["items"]; - if(!items.contains(std::string(hash)))bad("this vehicle is not installed."); - if(enabled) { - (void)load(root_,doc,hash); - const auto count=std::count_if(items.begin(),items.end(),[](const auto& i){return i.at("enabled").template get();}); - if(!items[std::string(hash)].at("enabled").get()&&count>=MaxEnabled)bad("enable up to 16 cars for one session."); - } - items[std::string(hash)]["enabled"]=enabled;publish(root_,doc); -} -void Library::remove(std::string_view hash) { - if(!valid_digest(hash))bad("invalid vehicle identity."); - Lock lock(root_);auto doc=index(root_); - if(!doc["items"].erase(std::string(hash)))bad("this vehicle is not installed."); - // Publish first. Keep immutable blobs for recovery and an admitted match; - // never delete the user's download or another category's files. - publish(root_,doc); -} -std::shared_ptr Library::package(std::string_view hash)const { - Lock lock(root_);return load(root_,index(root_),hash); -} -Frozen freeze_packages(std::vector> packages) { - if(packages.size()>MaxEnabled)bad("too many enabled cars."); - std::sort(packages.begin(),packages.end(),[](const auto& a,const auto& b){return a->digestdigest;}); - std::string canonical="dkr-vehicles-v2:fixed-reference-seats:cars";std::string previous;std::size_t source=0,decoded=0; - for(const auto& p:packages) { - if(!p||!supported(p->type)||!valid_digest(p->digest)||p->digest==previous)bad("invalid or duplicate frozen vehicle."); - previous=p->digest; - if(p->source.size()>MaxFrozenSource-source)bad("enabled car packages exceed the session source budget.");source+=p->source.size(); - std::size_t size=p->portrait.rgba.size(); - for(const auto& t:p->textures)size+=t.image.rgba.size(); - for(const auto& lod:p->lods)for(const auto& m:lod.meshes)size+=m.vertices.size()*sizeof(Vec3)+m.uv.size()*sizeof(Vec2)+m.faces.size()*sizeof(std::array); - if(size>MaxFrozenDecoded-decoded)bad("enabled cars exceed the session model budget.");decoded+=size; - canonical+=':'+p->digest; - } - return {std::move(packages),sha256(View(reinterpret_cast(canonical.data()),canonical.size()))}; -} -Frozen Library::freeze(std::stop_token stop)const { - if(!std::filesystem::exists(root_))return freeze_packages({}); - Lock lock(root_);const auto doc=index(root_);std::vector> out; - for(const auto& [hash,item]:doc["items"].items()) {cancelled(stop);if(item.at("enabled").get())out.push_back(load(root_,doc,hash));} - return freeze_packages(std::move(out)); -} -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_library.hpp b/patches/custom-vehicles/native/vehicle_library.hpp deleted file mode 100644 index d376da31..00000000 --- a/patches/custom-vehicles/native/vehicle_library.hpp +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once -#include "vehicle_package.hpp" -#include -#include - -namespace dkr::mods::vehicles { -struct Item { - std::string id,digest,name,source_name; - Type type=Type::Car; - bool enabled=false; - Image portrait; -}; -struct Frozen { - std::vector> packages; - std::string identity; -}; -enum class ImportPolicy { RejectDuplicates, KeepBoth, ReplaceSameName }; -struct ImportConflict { - std::string incoming,name; - std::vector installed; - bool operator==(const ImportConflict&)const=default; -}; -struct ImportBatch { - std::vector> packages; - std::vector conflicts; -}; -// Blocking filesystem work; launch preparation and the UI job queue are its -// only callers. Gameplay and replay never open a package or a library file. -class Library { -public: - explicit Library(std::filesystem::path root):root_(std::move(root)){} - std::vector scan(std::stop_token={})const; - std::vector import_file(const std::filesystem::path&); - ImportBatch prepare_import(const std::filesystem::path&)const; - std::vector import_prepared(const ImportBatch&,ImportPolicy); - std::string import(Input, bool enable=true); - void enable(std::string_view digest,bool); - void remove(std::string_view digest); - Frozen freeze(std::stop_token={})const; - std::shared_ptr package(std::string_view digest)const; - const std::filesystem::path& root()const{return root_;} -private: - std::filesystem::path root_; -}; -Frozen freeze_packages(std::vector>); -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_online.hpp b/patches/custom-vehicles/native/vehicle_online.hpp deleted file mode 100644 index 9ee6352a..00000000 --- a/patches/custom-vehicles/native/vehicle_online.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once -#include "vehicle_protocol.hpp" -#include "vehicle_library.hpp" - -namespace dkr::mods::online { -inline std::shared_ptr verified_car(const Manifest& m,const Content& c,View bytes) { - const auto payload=std::find_if(m.payloads.begin(),m.payloads.end(),[&](const auto& p){return p.digest==c.payload;}); - if(c.kind!=CarContent||c.adapter!=CarAdapterVersion||payload==m.payloads.end()||payload->kind!=CarPayload||bytes.size()!=payload->size||sha256(bytes)!=c.payload) - throw Error("A custom car does not match its exact host payload."); - auto package=vehicles::inspect(bytes,payload->source_label,vehicles::Type::Car); - if(package->digest!=c.payload||package->name!=c.name)throw Error("Custom car metadata differs from the host offer."); - return package; -} -} diff --git a/patches/custom-vehicles/native/vehicle_package.cpp b/patches/custom-vehicles/native/vehicle_package.cpp deleted file mode 100644 index 3d876858..00000000 --- a/patches/custom-vehicles/native/vehicle_package.cpp +++ /dev/null @@ -1,345 +0,0 @@ -#include "vehicle_package.hpp" -#include "magic_code_package.hpp" -#include -#include -#include -#include -#include -#include -#include -#include - -namespace dkr::mods::vehicles { -namespace { -[[noreturn]] void bad(const std::string& why) { throw Error("Custom vehicle: " + why); } -std::string lower(std::string s) { - for(auto& c:s)if(c>='A'&&c<='Z')c=char(c-'A'+'a'); - return s; -} -bool safe_name(std::string_view s) { - if(s.empty()||s.size()>240||s.front()=='/'||s.back()=='/'||s.find('\\')!=s.npos)return false; - for(unsigned char c:s)if(c<32||c==127||c==':'||c=='<'||c=='>'||c=='|'||c=='?'||c=='*'||c=='"')return false; - for(std::size_t at=0;at='0'&&stem[3]<='9'))return false; - if(end==s.npos)break;at=end+1; - } - return true; -} -struct Archive { - std::map files; - explicit Archive(View bytes,unsigned maximum=256) { - if(bytes.empty()||bytes.size()>MaxPackageBytes)bad("archive exceeds the 32 MiB limit."); - mz_zip_archive zip{}; - if(!mz_zip_reader_init_mem(&zip,bytes.data(),bytes.size(),0))bad("the archive is unreadable."); - struct End { mz_zip_archive& z;~End(){mz_zip_reader_end(&z);} } end{zip}; - const auto count=mz_zip_reader_get_num_files(&zip); - if(!count||count>maximum)bad("too many files in the archive."); - std::size_t total=0;std::set names; - for(unsigned i=0;i242)bad("invalid archive filename."); - std::string name(size,'\0'); - if(mz_zip_reader_get_filename(&zip,i,name.data(),size)!=size||name.find('\0')!=size-1)bad("ambiguous archive filename."); - name.pop_back();const bool directory=mz_zip_reader_is_file_a_directory(&zip,i)!=0; - if(directory&&name.back()=='/')name.pop_back(); - const auto mode=(stat.m_external_attr>>16)&0170000U; - if(!safe_name(name)||(mode&&mode!=0100000U&&mode!=0040000U)||(stat.m_bit_flag&1))bad("unsafe or encrypted archive entry."); - if(!names.insert(lower(name)).second)bad("duplicate archive filenames."); - if(directory)continue; - if(stat.m_uncomp_size>MaxExpandedBytes-total||stat.m_uncomp_size>MaxPackageBytes)bad("expanded archive exceeds its limit."); - total+=std::size_t(stat.m_uncomp_size);Bytes data(std::size_t(stat.m_uncomp_size)); - if(!mz_zip_reader_extract_to_mem(&zip,i,data.data(),data.size(),0))bad("an archive entry is incomplete or has an invalid checksum."); - files.emplace(std::move(name),std::move(data)); - } - } - const Bytes& require(const std::string& name)const { - const auto it=files.find(name);if(it==files.end())bad("missing " + name);return it->second; - } -}; -float number(const Json& j,float limit=32767.F) { - if(!j.is_number())bad("a model coordinate is not a number."); - const double d=j.get(); - if(!std::isfinite(d)||std::abs(d)>limit)bad("a model coordinate is outside its safe range."); - return float(d); -} -unsigned integer(const Json& j,unsigned limit) { - if(!j.is_number_integer()||j.get()<0||j.get()>limit)bad("an index is outside its safe range."); - return j.get(); -} -Vec3 vector3(const Json& j,float limit=32767.F) { - if(!j.is_array()||j.size()!=3)bad("expected an XYZ coordinate."); - return {number(j[0],limit),number(j[1],limit),number(j[2],limit)}; -} -std::string string(const Json& j,std::size_t limit) { - if(!j.is_string())bad("invalid text metadata."); - auto s=j.get();if(s.empty()||s.size()>limit)bad("invalid text length."); - for(unsigned char c:s)if(c<32||c==127)bad("control characters are not allowed in names."); - return s; -} -Bytes base64(const std::string& s) { - constexpr std::string_view prefix="data:image/png;base64,"; - if(!s.starts_with(prefix)||s.size()>8*MiB)bad("textures must be embedded PNG images."); - const auto encoded=std::string_view(s).substr(prefix.size()); - if(encoded.empty()||encoded.size()%4)bad("invalid embedded PNG encoding."); - Bytes out;out.reserve(encoded.size()/4*3); - const auto digit=[](char c)->int { - if(c>='A'&&c<='Z')return c-'A';if(c>='a'&&c<='z')return c-'a'+26; - if(c>='0'&&c<='9')return c-'0'+52;if(c=='+')return 62;if(c=='/')return 63;return -1; - }; - for(std::size_t i=0;i>4))); - if(!p2)out.push_back(std::uint8_t((b<<4)|(c>>2))); - if(!p3)out.push_back(std::uint8_t((c<<6)|d)); - } - return out; -} -Image image(View bytes,unsigned maximum,std::size_t& budget) { - static constexpr std::uint8_t signature[]{137,80,78,71,13,10,26,10}; - if(bytes.size()<33||bytes.size()>8*MiB||!std::equal(std::begin(signature),std::end(signature),bytes.begin()))bad("invalid PNG image."); - int w=0,h=0,n=0; - if(!stbi_info_from_memory(bytes.data(),int(bytes.size()),&w,&h,&n)||w<=0||h<=0||unsigned(w)>maximum||unsigned(h)>maximum)bad("PNG dimensions exceed the vehicle image limit."); - const auto size=std::size_t(w)*unsigned(h)*4; - if(size>16*MiB-budget)bad("vehicle textures exceed their decoded memory budget."); - auto* decoded=stbi_load_from_memory(bytes.data(),int(bytes.size()),&w,&h,&n,4); - if(!decoded)bad("could not decode a PNG image."); - Image result{unsigned(w),unsigned(h),Bytes(decoded,decoded+size)}; - stbi_image_free(decoded);budget+=size;return result; -} -Json document(View bytes) {return code::parse_document(bytes,MaxPackageBytes);} -Lod geometry(const Json& j,std::size_t textures,float scale) { - if(j.value("schema",0)!=1||!j.contains("meshes")||!j["meshes"].is_array()||j["meshes"].empty()||j["meshes"].size()>64)bad("unsupported mesh layout."); - Lod out;std::set ids;std::size_t vertices=0; - for(const auto& item:j["meshes"]) { - Mesh m;m.id=string(item.at("id"),96);m.name=string(item.at("name"),160); - if(!ids.insert(m.id).second)bad("duplicate mesh identities."); - const auto& v=item.at("vertices");const auto& uv=item.at("uv");const auto& f=item.at("faces"); - if(!v.is_array()||v.empty()||v.size()>8192-vertices||!uv.is_array()||uv.size()!=v.size()||!f.is_array()||f.empty()||f.size()>4096-out.triangles)bad("invalid mesh size, UV count or triangle count."); - vertices+=v.size();out.triangles+=unsigned(f.size()); - for(const auto& p:v)m.vertices.push_back(vector3(p,32700.F/scale)); - for(const auto& p:uv) { - if(!p.is_array()||p.size()!=2)bad("invalid texture coordinates."); - m.uv.push_back({number(p[0],128.F),number(p[1],128.F)}); - } - for(const auto& t:f) { - if(!t.is_array()||t.size()!=3)bad("only triangular faces are supported."); - std::array face{};for(unsigned i=0;i<3;++i)face[i]=integer(t[i],unsigned(v.size()-1)); - if(face[0]==face[1]||face[1]==face[2]||face[2]==face[0])bad("a triangle repeats a vertex."); - m.faces.push_back(face); - } - m.texture=item.value("texture",-1); - if(m.texture < -1 || (m.texture>=0&&std::size_t(m.texture)>=textures))bad("a mesh references a missing texture."); - if(item.contains("color")) { - const auto& c=item["color"];if(!c.is_array()||c.size()!=4)bad("invalid mesh colour."); - for(unsigned i=0;i<4;++i)m.colour[i]=std::uint8_t(integer(c[i],255)); - } - m.double_sided=item.value("double_sided",false); - if(item.value("primitive",0)!=0)bad("unsupported mesh primitive."); - out.meshes.push_back(std::move(m)); - } - return out; -} -} // namespace -bool valid_digest(std::string_view s) { - return s.size()==64&&std::all_of(s.begin(),s.end(),[](char c){return (c>='0'&&c<='9')||(c>='a'&&c<='f');}); -} -Type type_for_extension(std::string_view value) { - const auto s=lower(std::string(value)); - if(s==".dkrkart")return Type::Car;if(s==".dkrhover")return Type::Hovercraft;if(s==".dkrplane")return Type::Plane; - bad("choose a .dkrkart car package or a ZIP containing one."); -} -const char* extension(Type t){switch(t){case Type::Car:return ".dkrkart";case Type::Hovercraft:return ".dkrhover";case Type::Plane:return ".dkrplane";}bad("invalid vehicle class.");} -const char* type_name(Type t){switch(t){case Type::Car:return "Car";case Type::Hovercraft:return "Hovercraft";case Type::Plane:return "Plane";}bad("invalid vehicle class.");} -bool supported(Type t){return t==Type::Car;} -std::vector read_inputs(const std::filesystem::path& path) { - const auto ext=lower(path.extension().string());auto bytes=read_file(path,MaxPackageBytes); - const auto filename=path.filename().u8string();const std::string name(filename.begin(),filename.end()); - if(ext!=".zip")return {{name,std::move(bytes),type_for_extension(ext)}}; - Archive zip(bytes);std::vector out;std::set digests; - for(const auto& [member,data]:zip.files) { - const auto dot=member.rfind('.');const auto suffix=dot==member.npos?std::string{}:lower(member.substr(dot)); - if(suffix!=".dkrkart"&&suffix!=".dkrhover"&&suffix!=".dkrplane")continue; - if(digests.insert(sha256(data)).second)out.push_back({member.substr(member.find_last_of('/')+1),data,type_for_extension(suffix)}); - if(out.size()>16)bad("import up to 16 vehicle packages at once."); - } - if(out.empty())bad("this ZIP contains no vehicle package. Nested ZIPs are not unpacked."); - return out; -} -static std::shared_ptr inspect_impl(View bytes,std::string source_name,Type type) { - if(!supported(type))bad(std::string(type_name(type))+" packages are reserved for a later update. This playtest supports cars."); - Archive zip(bytes);auto p=std::make_shared();p->type=type; - p->digest=sha256(bytes);p->id="vehicle:"+p->digest;p->source.assign(bytes.begin(),bytes.end());p->source_name=std::move(source_name); - const auto manifest=document(zip.require("kart.json")); - if(manifest.value("format","")!="DKR Character Studio Kart"||manifest.value("version",0)!=1)bad("unsupported Studio kart format."); - for(const auto* field:{"vehicle_type","vehicle_class","type"})if(manifest.contains(field)) { - const auto t=lower(string(manifest[field],32)); - if(t!="car"&&t!="kart"&&t!="dkrkart")bad("the manifest vehicle class conflicts with its .dkrkart extension."); - } - p->name=manifest.contains("name")?string(manifest["name"],160):p->source_name; - const auto slash=p->name.find_last_of("/\\");if(slash!=p->name.npos)p->name.erase(0,slash+1); - if(lower(p->name).ends_with(".dkrkart"))p->name.resize(p->name.size()-8); - if(p->name.empty())bad("the car needs a name."); - const auto& settings=manifest.at("settings"); - p->scale=number(settings.at("scale"),4096.F);if(p->scale<0.001F)bad("invalid model scale."); - p->yaw=number(settings.at("yaw"),3600.F);p->driver_yaw=number(settings.value("driver_yaw",Json(0)),3600.F); - p->driver_offset=vector3(settings.value("driver",Json::array({0,0,0})),1024.F); - constexpr const char* drivers[]{"KRUNCH","BUMPER","TIPTUP","CONKER","TIMBER","BANJO","DRUMSTICK","PIPSY","TT","DIDDY"}; - const bool authored=manifest.contains("driver_position_mode")||manifest.contains("driver_seat_positions")|| - settings.contains("driver_seat_positions")||manifest.value("driver_anchor_kind","")=="kart-fixed-base-reference-v1"; - if(authored) { - if(manifest.value("driver_position_mode","")!="per-base-driver"|| - manifest.value("driver_anchor_kind","")!="kart-fixed-base-reference-v1") - bad("this per-driver seating convention is not supported."); - p->seating=SeatingMode::PerBaseDriverV1; - const auto& positions=manifest.at("driver_seat_positions"); - const auto& anchors=manifest.at("driver_anchors"); - if(!positions.is_object()||positions.size()!=10||!anchors.is_object()||anchors.size()!=10) - bad("per-driver seating needs positions and anchors for all ten base drivers."); - for(unsigned i=0;i<10;++i) { - if(!positions.contains(drivers[i])||!anchors.contains(drivers[i])) - bad(std::string("missing seat or anchor for ")+drivers[i]+"."); - p->driver_seats[i]=vector3(positions.at(drivers[i]),1024.F); - p->driver_anchors[i]=vector3(anchors.at(drivers[i]),1024.F); - } - if(settings.contains("driver_seat_positions")&&settings["driver_seat_positions"]!=positions) - bad("the two driver seat tables disagree. Export the car again."); - if(settings.contains("seat")||p->driver_offset.x!=0||p->driver_offset.y!=0||p->driver_offset.z!=0) - bad("per-driver seats cannot be combined with a legacy shared seat or driver offset."); - const auto& transform=manifest.at("kart_transform"); - if(!transform.is_object()||transform.value("order","")!="subtract origin, scale, rotate around +Y, translate"|| - transform.value("units","")!="Y-up Studio units; forward -Z; yaw in degrees"|| - !transform.contains("driver_positions_inherit_transform")|| - !transform["driver_positions_inherit_transform"].is_boolean()|| - transform["driver_positions_inherit_transform"].get()) - bad("unsupported kart transform. Driver seats must stay independent of the kart transform."); - p->origin=vector3(transform.at("origin"),32700.F/p->scale); - p->kart_offset=vector3(transform.at("translation"),1024.F); - if(transform.at("scale")!=settings.at("scale")||transform.at("yaw_degrees")!=settings.at("yaw")|| - manifest.at("geometry_origin")!=transform.at("origin")||settings.at("kart_offset")!=transform.at("translation")) - bad("the kart transform settings disagree. Export the car again."); - p->authored_anchors=true; - }else { - if(manifest.contains("kart_transform")||settings.contains("kart_offset")) - bad("the kart transform needs an explicit per-driver seating convention."); - p->seat=vector3(settings.at("seat"),1024.F); - } - if(manifest.value("forward_axis","")!="-Z")bad("the exporter must use the agreed Y-up, forward -Z convention."); - const auto& policy=manifest.at("vanilla_vehicle_policy"); - if(policy.value("when_custom_kart_active","")!="hide_geometry_and_wheel_sprites"||policy.value("without_custom_kart","")!="use_existing_character_vehicle")bad("unsupported native vehicle visibility policy."); - const std::set required_hidden{"chassis","steering_wheel","rear_spoiler","wheel_sprites","vehicle_attachments"}; - const std::set required_kept{"driver_geometry","driver_animations","driver_voice","physics","collision"}; - const auto hidden=policy.at("hidden_native_parts").get>(),kept=policy.at("preserve").get>(); - if(hidden!=required_hidden||kept!=required_kept)bad("the package requests an unsupported driver/vehicle policy."); - // Adapter defaults for the older neutral-base export. The newer manifest - // can override all ten anchors, never just a partial driver subset. - // Native enum order is NOT the workshop's display order. - if(!authored)p->driver_anchors={Vec3{0,24,0},Vec3{0,24,-9.5F},Vec3{0,9.5F,-4.75F},Vec3{0,9.5F,-4.75F},Vec3{0,14.5F,-4.75F}, - Vec3{0,4.75F,4.75F},Vec3{0,14.5F,-4.75F},Vec3{0,4.75F,-4.75F},Vec3{0,20,0},Vec3{0,4.75F,-19.25F}}; - if(!authored&&manifest.contains("driver_anchors")) { - if(manifest.value("driver_anchor_kind","")!="calibrated-base-pelvis-v1"||!manifest["driver_anchors"].is_object()||manifest["driver_anchors"].size()!=10)bad("unsupported driver anchor table."); - for(unsigned i=0;i<10;++i)p->driver_anchors[i]=vector3(manifest["driver_anchors"].at(drivers[i]),1024.F); - p->authored_anchors=true; - } - constexpr const char* roles[]{"front_left","front_right","rear_left","rear_right"}; - std::set wheel_ids; - const auto& wheels=settings.at("wheels"); - if(!wheels.is_object()||wheels.size()!=4)bad("wheel assignments need the four named wheel roles; unused roles may be empty."); - for(unsigned i=0;i<4;++i) { - const auto& value=wheels.at(roles[i]); - if(authored&&value.is_string()&&value.get_ref().empty())continue; - p->wheels[i]=string(value,96); - if(!wheel_ids.insert(p->wheels[i]).second)bad("a wheel mesh is assigned more than once."); - } - const auto axle=string(settings.at("axle"),1);if(axle!="X"&&axle!="Y"&&axle!="Z")bad("unsupported wheel axle.");p->axle=unsigned(axle=="X"?0:axle=="Y"?1:2); - const auto original=document(zip.require("geometry.json")); - if(authored&&original.at("origin")!=manifest.at("kart_transform").at("origin")) - bad("the geometry origin does not match the kart transform."); - const auto profile=manifest.value("texture_profile",std::string{}); - if(!profile.empty()&&profile!="native-4kib-rgba32-safe-v1")bad("unsupported car texture profile."); - const auto& images=original.at("textures");if(!images.is_array()||images.size()>32)bad("too many car textures."); - std::size_t image_budget=0; - for(const auto& t:images) { - Texture texture;texture.image=image(base64(string(t.at("image"),8*MiB)),256,image_budget); - if(integer(t.at("width"),256)!=texture.image.width||integer(t.at("height"),256)!=texture.image.height)bad("texture metadata does not match its PNG."); - const auto& wrap=t.at("wrap"); - for(const auto* axis:{"wrap-s","wrap-t"}) {const auto mode=wrap.value(axis,"Wrap");if(mode!="Wrap"&&mode!="Clamp")bad("unsupported texture wrapping mode.");} - texture.repeat_s=wrap.value("wrap-s","Wrap")=="Wrap";texture.repeat_t=wrap.value("wrap-t","Wrap")=="Wrap"; - const auto texels=std::size_t(texture.image.width)*texture.image.height; - if(texels*2>4096)bad("a car texture exceeds the native 4 KiB material limit. Export smaller material textures."); - if(!profile.empty()&&texels*4>4096)bad("a car texture exceeds its declared RGBA32-safe 4 KiB profile."); - if((texture.repeat_s&&!std::has_single_bit(texture.image.width))|| - (texture.repeat_t&&!std::has_single_bit(texture.image.height)))bad("repeating car textures need power-of-two dimensions."); - if(t.value("flipped",false))for(unsigned y=0;ytextures.push_back(std::move(texture)); - } - const auto full=geometry(original,p->textures.size(),p->scale); - const auto& lods=manifest.at("lods");if(!lods.is_array()||lods.size()!=6)bad("all six car LODs are required."); - for(unsigned lod=0;lod<6;++lod) { - const auto file="lods/lod"+std::to_string(lod)+".json"; - if(integer(lods[lod].at("level"),5)!=lod||lods[lod].at("file").get()!=file)bad("invalid LOD file mapping."); - const auto doc=document(zip.require(file)); - p->lods[lod]=geometry(doc,p->textures.size(),p->scale); - if(integer(lods[lod].at("triangles"),4096)!=p->lods[lod].triangles)bad("LOD triangle metadata does not match its geometry."); - for(const auto& id:p->wheels)if(!id.empty()&&std::none_of(p->lods[lod].meshes.begin(),p->lods[lod].meshes.end(),[&](const auto& m){return m.id==id;}))bad("a wheel assignment is missing from an LOD."); - if(authored&&doc.contains("origin")&&doc["origin"]!=original.at("origin"))bad("an LOD has a different geometry origin."); - // Material indices have a single owner: the root geometry. Exported - // LODs may repeat them but must not substitute a different image. - if(doc.contains("textures")&&doc["textures"]!=images)bad("LOD textures differ from the root material table."); - } - const auto& pivots=settings.value("wheel_pivots",Json::object()); - for(const auto& lod:p->lods)for(const auto& mesh:lod.meshes)if(mesh.texture>=0) { - const auto& texture=p->textures.at(unsigned(mesh.texture)); - for(const auto& uv:mesh.uv)if(std::abs(uv.x)*texture.image.width*32.F>32700.F||std::abs(uv.y)*texture.image.height*32.F>32700.F) - bad("car UV coordinates exceed the native material range. Reduce the UV repeat count."); - } - if(!pivots.is_object()||pivots.size()>4)bad("invalid wheel pivot table."); - for(const auto& [key,value]:pivots.items()) { - if(wheel_ids.contains(key))continue; - const auto role=std::find(std::begin(roles),std::end(roles),key); - if(role==std::end(roles)||p->wheels[unsigned(role-std::begin(roles))].empty())bad("a wheel pivot has no assigned wheel."); - } - for(unsigned w=0;w<4;++w) { - if(p->wheels[w].empty())continue; - if(pivots.contains(p->wheels[w]))p->pivots[w]=vector3(pivots[p->wheels[w]],32700.F/p->scale); - else if(pivots.contains(roles[w]))p->pivots[w]=vector3(pivots[roles[w]],32700.F/p->scale); - else { - const auto it=std::find_if(p->lods[0].meshes.begin(),p->lods[0].meshes.end(),[&](const auto& m){return m.id==p->wheels[w];}); - auto lo=it->vertices.front(),hi=lo; - for(const auto& v:it->vertices){lo.x=std::min(lo.x,v.x);lo.y=std::min(lo.y,v.y);lo.z=std::min(lo.z,v.z);hi.x=std::max(hi.x,v.x);hi.y=std::max(hi.y,v.y);hi.z=std::max(hi.z,v.z);} - p->pivots[w]={(lo.x+hi.x)*.5F,(lo.y+hi.y)*.5F,(lo.z+hi.z)*.5F}; - } - } - const auto portrait=manifest.at("portrait").at("file").get(); - if(portrait!="portrait.png")bad("unsupported portrait path."); - p->portrait=image(zip.require(portrait),512,image_budget); - return p; -} -std::shared_ptr inspect(View bytes,std::string source_name,Type type) { - try {return inspect_impl(bytes,std::move(source_name),type);} - catch(const Json::exception&) { - bad("the package has a missing or invalid model setting. Export it again with the current Studio version."); - } -} -Bytes native_texture(const Image& image) { - if(!image.width||!image.height||image.width>255||image.height>255||image.rgba.size()!=std::size_t(image.width)*image.height*4)bad("image cannot be represented by a native texture."); - Bytes out(32+image.width*image.height*2,0);out[0]=std::uint8_t(image.width);out[1]=std::uint8_t(image.height);out[2]=1; - out[0x12]=1; // one frame, matching native texture frame-count convention - const auto size=unsigned((out.size()+7)&~std::size_t(7));out.resize(size,0);out[0x16]=std::uint8_t(size>>8);out[0x17]=std::uint8_t(size); - for(std::size_t i=0;i>3)<<11)|(unsigned(c[1]>>3)<<6)|(unsigned(c[2]>>3)<<1)|(c[3]>=128); - out[32+i*2]=std::uint8_t(n>>8);out[33+i*2]=std::uint8_t(n); - } - return out; -} -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_package.hpp b/patches/custom-vehicles/native/vehicle_package.hpp deleted file mode 100644 index 91e772e7..00000000 --- a/patches/custom-vehicles/native/vehicle_package.hpp +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#include "legacy_mod_format.hpp" -#include "vehicle_protocol.hpp" -#include -#include -#include - -namespace dkr::mods::vehicles { -using Json = nlohmann::json; -enum class Type : unsigned { Car, Hovercraft, Plane }; -inline constexpr unsigned AdapterVersion = online::CarAdapterVersion; -enum class SeatingMode { LegacyShared, PerBaseDriverV1 }; -inline constexpr std::size_t MaxPackageBytes = 32 * MiB, MaxExpandedBytes = 64 * MiB; -inline constexpr unsigned MaxEnabled = 16; -struct Vec2 { float x=0,y=0; }; -struct Vec3 { float x=0,y=0,z=0; }; -struct Image { unsigned width=0,height=0; Bytes rgba; }; -struct Mesh { - std::string id,name; - std::vector vertices; - std::vector uv; - std::vector> faces; - std::array colour{255,255,255,255}; - int texture=-1; - bool double_sided=false; -}; -struct Texture { Image image; bool repeat_s=true,repeat_t=true; }; -struct Lod { std::vector meshes; unsigned triangles=0; }; -struct Package { - Type type=Type::Car; - std::string id,name,digest,source_name; - float scale=1,yaw=0,driver_yaw=0; - Vec3 seat{},driver_offset{}; - SeatingMode seating=SeatingMode::LegacyShared; - std::array driver_seats{}; - Vec3 origin{},kart_offset{}; - // Package anchors are in post-scale, Y-up Studio units. Older exports - // use the reviewed adapter table; they are never inferred from bounds. - std::array driver_anchors{}; - bool authored_anchors=false; - bool per_driver()const{return seating==SeatingMode::PerBaseDriverV1;} - std::array wheels{}; // FL, FR, RL, RR - std::array pivots{}; - unsigned axle=0; - std::array lods; - std::vector textures; - Image portrait; - Bytes source; // Exact inner archive, never the containing download ZIP. -}; -struct Input { std::string name; Bytes bytes; Type type=Type::Car; }; -Type type_for_extension(std::string_view); -const char* extension(Type); -const char* type_name(Type); -bool supported(Type); -bool valid_digest(std::string_view); -// Pure preparation: no code, files, ROM or guest memory is modified. -std::vector read_inputs(const std::filesystem::path&); -std::shared_ptr inspect(View, std::string source_name, Type); -// Native RGBA16 artwork; used by the existing guest menu texture loader. -Bytes native_texture(const Image&); -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_protocol.hpp b/patches/custom-vehicles/native/vehicle_protocol.hpp deleted file mode 100644 index b522c15c..00000000 --- a/patches/custom-vehicles/native/vehicle_protocol.hpp +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include "online_mod_manifest.hpp" -#include - -namespace dkr::mods::online { -// Extension values preserve the accepted manifest/ABI layouts. Old builds -// reject the new explicit "car" tags, rather than misreading a car as code, -// a ROM patch or a course. No machine-local path is sent to another player. -inline constexpr Kind CarContent=static_cast(3); -inline constexpr PayloadKind CarPayload=static_cast(3); -inline constexpr unsigned CarAdapterVersion=2; -inline std::size_t car_count(const Manifest& m) { - return std::count_if(m.content.begin(),m.content.end(),[](const auto& c){return c.kind==CarContent;}); -} -} diff --git a/patches/custom-vehicles/native/vehicle_resources.cpp b/patches/custom-vehicles/native/vehicle_resources.cpp deleted file mode 100644 index b8c023c6..00000000 --- a/patches/custom-vehicles/native/vehicle_resources.cpp +++ /dev/null @@ -1,280 +0,0 @@ -#include "vehicle_resources.hpp" -#include "vehicle_seat_alignment.hpp" -#include "vehicle_base_hashes.hpp" -#include "vehicle_character_masks.hpp" -#include -#include -#include -#include -#include - -namespace dkr::mods::vehicles { -namespace { -// Reviewed batch ownership in the VERIFIED US asset bank. No material-name -// heuristics are applied to user models. Recognize complete original geometry -// independently of its UVs, or use a separately reviewed digest-bound mask for -// rebuilt exports. Unknown ownership retains Vanilla, never guessed driver cuts. -// Mixed driver/car batches below are refined per triangle, especially T.T.'s -// gloves and Tiptup/Bumper's shared underside material. -const std::map> VehicleBatches{ - {43,{19,20,21,22,23,24,25,26,28}},{44,{9,21,22,23,24,25,26,27,29}}, - {45,{5,16,17,18,19,20,21,22}},{46,{0,11,12,13,14,15}},{47,{3,11,12}}, - {31,{11,12,24,25,26,27,28,29,30,31}},{32,{1,18,19,20,21,22,23,24,25}}, - {33,{1,18,19,20,21,22,23,24}},{34,{1,16,17,18,19,20}},{35,{1,4,10}}, - {37,{17,18,19,20,21,22,23,24,26,27}},{38,{14,15,16,17,18,19,20,22,23}}, - {39,{7,13,14,15,16,17,18,20}},{40,{0,6,11,12,13,14}},{41,{0,6,10}}, - {55,{14,15,19,20,21,22,23,24,25,26}},{56,{1,17,18,19,20,21,22,23,24}}, - {57,{1,16,17,18,19,20,21,22}},{58,{1,15,16,17,18,19}},{59,{0,13,14}}, - {49,{22,23,28,29,30,31,32,33,34,35}},{50,{11,27,28,29,30,31,32,33,34}}, - {51,{4,17,21,22,23,25,26,27}},{52,{4,17,18,20,21,22}},{53,{2,14,15}}, - {61,{22,23,24,25,26,27,28,29,30,31}},{62,{14,21,22,23,24,25,26,27,28}}, - {63,{16,18,19,20,21,22,23,24}},{64,{5,14,15,16,17,18}},{65,{0,2,12}}, - {67,{4,5,29,30,31,32,33,34,35,36}},{68,{2,23,24,25,26,27,28,29,30}}, - {69,{0,10,17,18,19,20,21,22}},{70,{0,13,14,15,16,17}},{71,{0,12,13}}, - {73,{0,1,16,17,18,19,20,21,22,23,24}},{75,{0,1,9,16,17,18,19,20,21}}, - {76,{3,10,11,12,13,14}},{77,{3,10,11}}, - {25,{0,1,17,18,19,20,21,22,23,24}},{26,{0,14,15,16,17,18,19,20,21,22,23}}, - {27,{0,13,14,15,16,17,18,19,20,21}},{28,{0,1,10,11,12,13,14,15,16}},{29,{0,8,9}}, - {19,{18,19,20,21,22,23,24,25,28,29}},{20,{17,18,19,20,21,22,23,24,27}}, - {21,{15,16,17,18,19,20,21,24}},{22,{13,14,15,16,17,18}},{23,{10,11,12}} -}; -[[noreturn]] void bad(const std::string& s){throw Error("Custom vehicle resources: "+s);} -struct Model { - Bytes raw; - unsigned vertices=0,triangles=0,batches=0; - std::vector faces; - std::vector geometry; - std::vector owners; -}; -Model model(const AssetBank& bank,unsigned id) { - Model out;out.raw=inflate_asset(bank.record(29,id),4*MiB);const View raw=out.raw;slice(raw,0,0x80); - out.vertices=be16(raw,0x24);out.triangles=be16(raw,0x26);out.batches=be16(raw,0x28); - const unsigned nt=be16(raw,0x22),vp=be32(raw,4),tp=be32(raw,8),bp=be32(raw,0x38),textures=be32(raw,0); - if(!out.vertices||out.vertices>8192||out.triangles>8192||out.batches>1024||nt>255)bad("driver model exceeds its profile limits."); - slice(raw,vp,out.vertices*10);slice(raw,tp,out.triangles*16);slice(raw,bp,(out.batches+1)*12);slice(raw,textures,nt*8); - out.faces.resize(out.triangles);out.geometry.resize(out.triangles);out.owners.resize(out.triangles,UINT32_MAX); - for(unsigned b=0;bve||ve>out.vertices||ts>te||te>out.triangles||(texture!=255&&texture>=nt))bad("driver model has an invalid batch."); - for(unsigned t=ts;t,3> positions{}; - for(unsigned corner=0;corner<3;++corner) { - const auto index=raw[at+1+corner];if(index>=ve-vs)bad("driver model indexes outside its batch."); - const auto vertex=slice(raw,vp+(vs+index)*10,6),uv=slice(raw,at+4+corner*4,4); - signature.insert(signature.end(),vertex.begin(),vertex.end());signature.insert(signature.end(),uv.begin(),uv.end()); - std::copy(vertex.begin(),vertex.end(),positions[corner].begin()); - } - out.faces[t]=sha256(signature); - std::sort(positions.begin(),positions.end());Bytes geometry; - for(const auto& p:positions)geometry.insert(geometry.end(),p.begin(),p.end()); - out.geometry[t]=sha256(geometry); - } - } - if(std::ranges::any_of(out.owners,[](unsigned b){return b==UINT32_MAX;}))bad("driver model has unowned triangles."); - return out; -} -std::string ownership_digest(const Model& m) { - // Asset namespace allocation changes ONLY the numeric texture IDs in the - // native model. Exclude those relocations, not geometry, UVs, animation map, - // material layout, counts, batch ownership, flags or other model bytes. - auto normalized=m.raw;const auto tx=be32(normalized,0);const auto count=be16(normalized,0x22); - for(unsigned i=0;i mask(const Model& m,unsigned id) { - const auto found=VehicleBatches.find(id);if(found==VehicleBatches.end())bad("unreviewed base car model."); - std::vector result(m.triangles); - for(const auto batch:found->second)if(batch>=m.batches)bad("base car batch profile changed."); - for(unsigned i=0;isecond,m.owners[i])!=found->second.end(); - const auto preserve=[&](unsigned first,unsigned end){if(end>result.size())bad("base car triangle profile changed.");std::fill(result.begin()+first,result.begin()+end,0);}; - if(id==26){preserve(151,157);preserve(159,165);} - if(id==27){preserve(111,116);preserve(118,123);} - if(id==28)preserve(93,99); - if(id==40)preserve(15,21); - // Bumper's low-detail cap shares the same underside batch as the spoiler. - if(id==35)preserve(11,13); - // Tiptup's low-detail shell keeps the front portion of this mixed batch. - if(id==41)preserve(18,22); - if(std::count(result.begin(),result.end(),std::uint8_t(1))<8)bad("base car profile contains too few vehicle faces."); - return result; -} -std::vector models(const AssetBank& bank,unsigned header) { - const auto h=bank.record(34,header);slice(h,0,0x78); - const unsigned n=h[0x55],at=be32(h,0x10); - if(h[0x53]||!n||n>32)bad("a custom character has no compatible car LOD list."); - slice(h,at,n*4);std::vector ids; - for(unsigned i=0;i Resources::apply(std::shared_ptr bank)const {return AssetBank::append_artwork(std::move(bank),artwork);} -std::shared_ptr prepare_resources(std::shared_ptr original,std::shared_ptr boot, - std::shared_ptr characters,Frozen frozen) { - if(!original||!boot||original->base_fingerprint()!=boot->base_fingerprint())bad("car resources require the admitted original Game Pak."); - auto out=std::make_shared();out->library=std::move(frozen);if(out->library.packages.empty())return out; - constexpr unsigned headers[]{2,3,4,5,6,7,8,9,1,0}; - constexpr const char* names[]{"Krunch","Bumper","Tiptup","Conker","Timber","Banjo","Drumstick","Pipsy","T.T.","Diddy"}; - std::array,10> originals; - std::array,5>,10> masks; - for(unsigned base=0;base<10;++base) { - const auto list=models(*original,headers[base]); - if(list.size()<5)bad("the selected native driver has an unreviewed LOD layout."); - DriverProfile p;p.base=base;p.header=headers[base];p.name=names[base];p.native_scale=std::bit_cast(be32(original->record(34,p.header),0x0c)); - if(!std::isfinite(p.native_scale)||p.native_scale<=0||p.native_scale>16)bad("invalid base car scale."); - for(unsigned lod=0;lod<5;++lod) { - auto& m=originals[base][lod];m=model(*original,list[lod]); - const auto known=BaseModelHashes.find(list[lod]); - if(known==BaseModelHashes.end()||sha256(m.raw)!=known->second) - bad("the selected native driver model variant has not been reviewed for safe car replacement."); - masks[base][lod]=mask(m,list[lod]); - p.lods[lod]={list[lod],m.vertices,m.triangles,masks[base][lod],sha256(m.raw)}; - } - out->drivers.push_back(std::move(p)); - } - if(characters)for(const auto& character:characters->characters) { - DriverProfile p;p.base=character.base_character;p.header=character.headers[0];p.character_id=character.id;p.name=character.name; - if(p.base>=10)bad("invalid inherited driver."); - try { - p.native_scale=std::bit_cast(be32(boot->record(34,p.header),0x0c)); - if(!std::isfinite(p.native_scale)||p.native_scale<=0||p.native_scale>16)bad("invalid custom driver scale."); - const auto list=models(*boot,p.header); - for(unsigned lod=0;lod<5;++lod) { - const auto model_id=list[std::min(lod,list.size()-1)]; - const auto candidate=model(*boot,model_id); - std::map> available; - for(unsigned i=0;i removed(candidate.triangles,0); - bool matched=false; - // Individually reviewed re-exported car geometry: masks are - // immutable, relocatable and exact to this complete model. - if(const auto reviewed=ReviewedCharacterMasks.find(ownership_digest(candidate));reviewed!=ReviewedCharacterMasks.end()) { - const auto& r=reviewed->second; - if(r.vertices!=candidate.vertices||r.triangles!=candidate.triangles)bad("reviewed character model counts changed."); - for(const auto& [first,end]:r.ranges) { - if(first>=end||end>removed.size())bad("reviewed character mask exceeds its model."); - for(unsigned i=first;i required; - for(unsigned i=0;isecond.size()!=count){exact=false;break;} - } - if(!exact)continue; - for(const auto& [signature,count]:required)for(const auto index:available.at(signature))removed[index]=1; - matched=true; - } - // Retextured/reordered stock geometry is still the same car. - // Some exports use another driver's chassis with inherited - // behaviour. Require a COMPLETE unambiguous donor, never a - // percentage threshold or blanket static-vertex suppression. - if(!matched) { - std::map> geometry; - for(unsigned i=0;i required;std::set driver; - for(unsigned i=0;isecond.size()!=count){exact=false;break;} - } - if(!exact)continue; - for(const auto& [signature,count]:required)for(const auto i:geometry.at(signature))removed[i]=1; - matched=true; - } - } - if(!matched)throw Error("Car/driver ownership is not yet recognized at LOD "+std::to_string(lod)+". Original vehicle retained."); - p.lods[lod]={model_id,candidate.vertices,candidate.triangles,std::move(removed),sha256(candidate.raw)}; - } - }catch(const std::exception& e){p.issue=e.what();} - std::fprintf(stderr,"[custom-vehicles][character] name=%s id=%s header=%u base=%u status=%s%s%s\n", - p.name.c_str(),p.character_id.c_str(),p.header,p.base,p.compatible()?"ready":"vanilla-only", - p.compatible()?"":" reason=",p.issue.c_str()); - out->drivers.push_back(std::move(p)); - } - unsigned next=unsigned(boot->record_count(4)); - for(const auto& p:out->library.packages) { - if(next>32765)bad("the portrait namespace is full."); - std::size_t bytes=0; - float radius=0,pivot_radius=0; - for(const auto& lod:p->lods)for(const auto& mesh:lod.meshes)for(const auto& source:mesh.vertices) { - const auto v=dkr::runtime::vehicles::seating::source_point(*p,source); - radius=std::max(radius,std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z)); - } - for(unsigned w=0;w<4;++w)if(!p->wheels[w].empty()) { - const auto v=dkr::runtime::vehicles::seating::source_point(*p,p->pivots[w]); - pivot_radius=std::max(pivot_radius,std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z)); - } - const auto units=std::min(64.F,30000.F/std::max(1.F,(radius+2*pivot_radius)*p->scale)); - if(!std::isfinite(units)||units<=0)bad("car vertex precision cannot be represented safely."); - out->vertex_units.push_back(units); - std::vector pixels; - for(const auto& t:p->textures) { - const auto size=std::size_t(t.image.width)*t.image.height*2; - if(t.image.width>128||t.image.height>128||size>4096)bad("car textures must fit a native 4 KiB material tile (for example 32x32 or 64x32)."); - // RGBA32 stores RG in the lower 2 KiB bank and BA in the upper - // bank. Account for each padded row, not just the texel count. - // All supplied native-4kib-rgba32-safe exports fit unchanged. - // Keep the old RGBA16 path for larger already-supported tiles; - // never resize images, expand TMEM limits or change menu artwork. - const auto bank_row=(std::size_t(t.image.width)*2+7)&~std::size_t(7); - const bool rgba32=bank_row*t.image.height<=2048; - if(rgba32)pixels.push_back({t.image.rgba,true}); - else { - auto encoded=native_texture(t.image); - pixels.push_back({Bytes(encoded.begin()+32,encoded.begin()+32+size),false}); - } - bytes+=pixels.back().bytes.size(); - } - if(bytes>256*1024)bad("a car exceeds its per-racer texture budget."); - out->texture_pixels.push_back(std::move(pixels)); - const auto portrait=thumbnail(p->portrait);out->portraits.push_back(next); - for(unsigned half=0;half<2;++half) - out->artwork.emplace(AssetKey{4,next++},native_texture(portrait_tile(portrait,half))); - } - const auto ready=std::count_if(out->drivers.begin(),out->drivers.end(),[](const auto& d){return d.compatible();}); - std::fprintf(stderr,"[custom-vehicles][compatibility] reviewed-character-ownership=v1 ready=%zu total=%zu; all-five-lods; no material-name cuts\n",std::size_t(ready),out->drivers.size()); - std::string identity="dkr-car-resources-v5-source-colour-fixed-seats:"+boot->fingerprint()+":"+out->library.identity; - for(const auto& d:out->drivers) { - identity+=':'+std::to_string(d.header)+":"+d.character_id+":"+d.issue+":"+std::to_string(std::bit_cast(d.native_scale)); - if(d.compatible())for(const auto& lod:d.lods)identity+=':'+lod.identity+":"+sha256(lod.hide); - } - for(const auto& [key,value]:out->artwork)identity+=':'+std::to_string(key.second)+":"+sha256(value); - out->identity=sha256(View(reinterpret_cast(identity.data()),identity.size()));return out; -} -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_resources.hpp b/patches/custom-vehicles/native/vehicle_resources.hpp deleted file mode 100644 index 62ae85b6..00000000 --- a/patches/custom-vehicles/native/vehicle_resources.hpp +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once -#include "vehicle_library.hpp" -#include "legacy_character_materialize.hpp" - -namespace dkr::mods::vehicles { -struct ModelProfile { - unsigned model=0,vertices=0,triangles=0; - std::vector hide; - std::string identity; -}; -struct DriverProfile { - unsigned header=0,base=0; - float native_scale=1; - std::string character_id,name,issue; - std::array lods; - bool compatible()const{return issue.empty();} -}; -struct MaterialPixels { - Bytes bytes; - bool rgba32=false; // Exact PNG channels when the two TMEM banks fit. -}; -struct Resources { - Frozen library; - std::vector drivers; - std::vector portraits; // first ID of two consecutive 64x32 menu tiles - AssetBank::Overrides artwork; - std::vector> texture_pixels; // Prepared once; menu artwork remains RGBA16. - std::vector vertex_units; // fixed car precision, independent of driver scale - std::string identity; - const DriverProfile* driver(unsigned header)const; - const DriverProfile* character(std::string_view id,unsigned base)const; - std::shared_ptr apply(std::shared_ptr)const; -}; -// Preparation is linear in driver models + car packages, not a cartesian -// character x car asset library. The driver animation files remain unchanged. -std::shared_ptr prepare_resources(std::shared_ptr original, - std::shared_ptr boot,std::shared_ptr,Frozen); -} // namespace dkr::mods::vehicles diff --git a/patches/custom-vehicles/native/vehicle_rt64.cpp b/patches/custom-vehicles/native/vehicle_rt64.cpp deleted file mode 100644 index 92a974a3..00000000 --- a/patches/custom-vehicles/native/vehicle_rt64.cpp +++ /dev/null @@ -1,335 +0,0 @@ -#include "vehicle_rt64.hpp" -#include "vehicle_wheel_interpolation.hpp" -#include "vehicle_seat_alignment.hpp" -#include "native_draw_scope_rt64.hpp" -#include "presentation_identity.hpp" -#include "runtime_enhancements.hpp" -#include "gbi/rt64_gbi_rdp.h" -#include "hle/rt64_workload_queue.h" -#include -#include -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::vehicles { -namespace { -constexpr unsigned Scratch=0x00c00000U; -thread_local const RenderPass* current_pass=nullptr; -// Version-1 Studio exports contain native-sized model coordinates after the -// authored package scale, not world coordinates. Use one quarter-scale native -// reference (the clean Tiptup model's 0.25 scale), never the selected driver's -// individual 0.21..0.28 scale. Keeping this fixed lets any driver use the same -// car without making the car itself larger/smaller on character changes. -constexpr float KartModelToWorld=seating::StudioToWorld; -// Studio's ground/reference frame sits 2.5 local world units below the -// native racer draw root. This common frame conversion follows suspension; -// it is NOT kart_transform.translation and never moves the guest/ground. -// Apply it to the car and driver once, outside their authored transforms. -constexpr float KartRideHeight=-2.5F; -// Legacy shared-seat exports only. Fixed-reference seats never inherit it. -constexpr float DriverSeatAdvance=20.0F; -std::atomic diagnostic_count{0}; -unsigned read(const RT64::State& s,unsigned p,unsigned n=4) { - if(p<0x80000000U||p>0x807fffffU||n>0x80800000U-p)throw mods::Error("Custom car draw has an invalid snapshot address."); - p-=0x80000000U;unsigned v=0;for(unsigned i=0;i(read(s,p));if(!std::isfinite(v))throw mods::Error("Custom car draw has a non-finite transform.");return v;} -bool finite(const hlslpp::float4x4& m) {for(unsigned r=0;r<4;++r)for(unsigned c=0;c<4;++c)if(!std::isfinite(float(m[r][c])))return false;return true;} -hlslpp::float4x4 translate(float x,float y,float z) {auto m=hlslpp::float4x4::identity();m[3]=hlslpp::float4(x,y,z,1);return m;} -hlslpp::float4x4 yaw(float angle) {auto m=hlslpp::float4x4::identity();const auto c=std::cos(angle),s=std::sin(angle);m[0]=hlslpp::float4(c,0,-s,0);m[2]=hlslpp::float4(s,0,c,0);return m;} -constexpr float Tau=2*std::numbers::pi_v; -mods::vehicles::Vec3 seat_target(const mods::vehicles::Package& car,unsigned base) { - if(car.per_driver()) { - auto target=seating::destination(car,base);target.y+=KartRideHeight; - return target; // No kart origin/scale/yaw/translation or legacy trim. - } - // Forward is the declared FRONT axle, not the model's arbitrary origin, - // bounding-box centre or the camera. Apply the same yaw as the car mesh. - // This also handles an export imported with a different orientation. - const auto& p=car.pivots; - const float dx=(p[0].x+p[1].x-p[2].x-p[3].x)*.5F; - const float dz=(p[0].z+p[1].z-p[2].z-p[3].z)*.5F; - const float length=std::hypot(dx,dz),angle=car.yaw*Tau/360.F; - float forward_x=0,forward_z=-1; // Agreed native forward convention. - if(std::isfinite(length)&&length>1e-6F) { - const auto cosine=std::cos(angle),sine=std::sin(angle); - forward_x=(dx*cosine+dz*sine)/length; - forward_z=(-dx*sine+dz*cosine)/length; - } - return {(car.seat.x+car.driver_offset.x)*KartModelToWorld+forward_x*DriverSeatAdvance, - (car.seat.y+car.driver_offset.y)*KartModelToWorld+KartRideHeight, - (car.seat.z+car.driver_offset.z)*KartModelToWorld+forward_z*DriverSeatAdvance}; -} -// RGB uses vertex shade. Opacity MUST use primitive alpha: the RSP fog -// shader replaces shade alpha with fog density (zero near the camera). -// Multiplying texture alpha by that value made otherwise valid cars vanish. -constexpr std::uint32_t Mux0=(1U<<20)|(4U<<15)|(1U<<12)|(3U<<9)|(1U<<5)|4U; -constexpr std::uint32_t Mux1=(8U<<28)|(8U<<24)|(1U<<21)|(3U<<18)|(7U<<15)|(7U<<12)|(7U<<9)|(7U<<6)|(7U<<3)|7U; -constexpr std::uint64_t TextureShade=(std::uint64_t(Mux1)<<32)|Mux0; -// Untextured RGB uses shade; untextured alpha uses the same primitive opacity. -constexpr std::uint32_t Shade0=(15U<<20)|(31U<<15)|(7U<<12)|(7U<<9)|(15U<<5)|31U; -constexpr std::uint32_t Shade1=(15U<<28)|(15U<<24)|(7U<<21)|(7U<<18)|(4U<<15)|(3U<<12)|(4U<<9)|(7U<<6)|(4U<<3)|3U; -constexpr std::uint64_t Shade=(std::uint64_t(Shade1)<<32)|Shade0; -static_assert(((TextureShade>>9)&7U)==3U && ((TextureShade>>50)&7U)==3U); -static_assert(((Shade>>44)&7U)==3U && ((Shade>>32)&7U)==3U); -} -RenderPass::RenderPass(RT64::State& s,std::shared_ptr r,Select select): - state_(s),resources_(std::move(r)),select_(select) { - // Snapshot the SAME effective option used by the character renderer once - // per decoder task. Accurate/profile gating stays identical; neither a - // host preference nor a requested-but-inactive option can force detail. - maximum_detail_=enhancements::maximum_detail_enabled(); - auto& workload=s.ext.workloadQueue->workloads[s.ext.workloadQueue->writeCursor]; - if(resources_&&!resources_->library.packages.empty()) - wheel_interpolation::begin(workload,s.workloadId+1,resources_); - previous_pass_=current_pass;current_pass=this; -} -RenderPass::~RenderPass(){current_pass=previous_pass_;} -const RenderPass* active_render_pass() noexcept{return current_pass;} -bool RenderPass::marker(unsigned kind,unsigned value) { - if(kind>17)return false; - if(kind==0){if(object_||scope_||!value)return false;scope_=value;setup_=0;return true;} - if(kind>=7) { - if(object_||!scope_)return false; - if(kind==7){if(setup_&1U)return false;draw_scale_=std::bit_cast(value);setup_|=1U;} - else if(kind<=11) { - const auto bit=1U<<(kind-7);if(setup_&bit)return false; - spin_[kind-8]=(spin_[kind-8]&0xffffffff00000000ULL)|std::uint64_t(value);setup_|=bit; - }else if(kind==12) { - if(setup_&32U)return false; - // Native sprite-wheel yaw and this package-space Y rotation use - // opposite steering signs. Change only the visual conversion; - // the native wheel angle/input and checkpoint remain untouched. - steer_=-float(std::int16_t(value))*Tau/65536.F;setup_|=32U; - }else if(kind<=16) { - const auto bit=1U<<(kind-7);if(setup_&bit)return false; - spin_[kind-13]=(spin_[kind-13]&0xffffffffULL)|(std::uint64_t(value)<<32);setup_|=bit; - }else { - if(setup_&1024U)return false;wheel_epoch_=value;setup_|=1024U; - } - return true; - } - if(kind==1){if(object_||!scope_||setup_!=2047)return false;begin(value);object_=value;return true;} - if(kind==2) { - if(!object_||value!=object_||wheel_)return false; - if(active_&&!replacement_submitted_)diagnostic("no replacement submitted; original retained"); - object_=scope_=0;active_=root_ready_=replacement_submitted_=false;return true; - } - if(!object_)return false; - if(kind==3||kind==5){if(wheel_)return false;wheel_=value;hide_attachment_=kind==3;} - else {if(wheel_!=value||hide_attachment_!=(kind==4))return false;wheel_=0;hide_attachment_=false;} - return true; -} -void RenderPass::begin(unsigned object) { - active_=root_ready_=replacement_submitted_=false;profile_=nullptr;driver_=nullptr;wheel_=0; - if(!resources_||resources_->library.packages.empty())return; - try { - car_=(scope_&255U)-1;player_=(scope_>>24)&7U;view_=(scope_>>27)&3U;original_lod_=(scope_>>8)&255U; - const auto lod=(scope_>>16)&255U; - if(car_>=resources_->library.packages.size()||lod>4)return; - // Driver ownership still matches the submitted native LOD. Only the - // custom car's presentation mesh is pinned; no guest/simulation write. - vehicle_lod_=maximum_detail_?0U:std::min(original_lod_,5U); - driver_=resources_->driver(read(state_,object+0x2c,2)); - if(!driver_||!driver_->compatible())return;profile_=&driver_->lods[lod]; - const auto instances=read(state_,object+0x68),instance=read(state_,instances+lod*4),model=read(state_,instance); - if(read(state_,model+0x24,2)!=profile_->vertices||read(state_,model+0x26,2)!=profile_->triangles)return; - triangles_=read(state_,model+8)&0x00ffffffU; - if(triangles_>0x800000U||profile_->triangles>(0x800000U-triangles_)/16)return; - scale_=draw_scale_;if(!std::isfinite(scale_)||scale_<.001F||scale_>64)return; - // Choice, live draw scale, steering and camera-independent wheel phase - // arrived in the bounded display stream, not a later/recycled object. - const auto points=read(state_,object+0x60),racer=read(state_,object+0x64); - if(!points||read(state_,points)<4||read(state_,points)>10||!racer)return; - auto& addresses=texture_addresses_[car_]; - if(addresses.empty()) { - const auto& pixels=resources_->texture_pixels.at(car_);std::size_t total=0;for(const auto& b:pixels)total+=(b.bytes.size()+7)&~std::size_t(7); - if(total>0x00f00000U-texture_cursor_)return; - for(const auto& b:pixels) { - addresses.push_back(texture_cursor_); - for(unsigned i=0;ilibrary.packages[car_];const auto anchor=seating::anchor(car,driver_->base); - auto target=seat_target(car,driver_->base); - const auto reference=seating::reference_placement(car); - const auto angle=car.driver_yaw*Tau/360.F,c=std::cos(angle),s=std::sin(angle); - target.x+=reference.x*c+reference.z*s;target.y+=reference.y;target.z+=-reference.x*s+reference.z*c; - auto local=hlslpp::mul(translate(-anchor.x/driver_->native_scale,-anchor.y/driver_->native_scale,-anchor.z/driver_->native_scale),yaw(car.driver_yaw*Tau/360.F)); - // All post-scale Studio positions now share one unit conversion. - // Divide the anchor by the driver's immutable native scale once to - // express it in that driver's model coordinates. Its animation and - // live linear transform remain unchanged; never shrink the character. - local=hlslpp::mul(local,translate(target.x/scale_,target.y/scale_,target.z/scale_)); - adjustment_=hlslpp::mul(hlslpp::mul(inverse,local),root_);root_ready_=finite(adjustment_); - if(!root_ready_){active_=false;return;} - // Submit at the original object root, BEFORE any native chassis or - // wheel is hidden. The end marker arrives after native mtx_pop and - // attachment/material teardown, and must not be our first draw point. - try{replacement_submitted_=draw();} - catch(const std::exception& e){diagnostic(e.what());replacement_submitted_=false;} - if(!replacement_submitted_){active_=root_ready_=false;return;} - } - if(root_ready_) { - rsp.modelMatrixStack[index]=hlslpp::mul(rsp.modelMatrixStack[index],adjustment_); - rsp.modelViewProjChanged=true; - } -} -bool RenderPass::suppress(unsigned triangle)const { - if(!active_||!root_ready_||!replacement_submitted_)return false; - if(wheel_&&hide_attachment_)return true; - if(trianglehide.size()&&profile_->hide[i]!=0; -} -const mods::vehicles::ModelProfile* RenderPass::detail_profile(unsigned object,unsigned instance)const { - if(object!=object_||!active_||!root_ready_||!replacement_submitted_||wheel_)return nullptr; - const auto slots=read(state_,object+0x68); - if(!driver_||!slots||read(state_,slots)!=instance) - throw std::runtime_error("high-detail car driver has no matching LOD0 instance"); - const auto model=read(state_,instance);const auto& high=driver_->lods[0]; - if(read(state_,model+0x24,2)!=high.vertices||read(state_,model+0x26,2)!=high.triangles) - throw std::runtime_error("high-detail car driver ownership differs from LOD0"); - return &high; -} -bool RenderPass::draw() { - auto& s=state_;auto& rsp=*s.rsp;auto& rdp=*s.rdp; - if(!select_||rdp.colorCombinerStackSize>=RDP_EXTENDED_STACK_SIZE||rdp.primColorStackSize>=RDP_EXTENDED_STACK_SIZE||rdp.envColorStackSize>=RDP_EXTENDED_STACK_SIZE){diagnostic("draw-state stack unavailable; original retained");return false;} - NativeDrawScope restore(s); -#if defined(DKR_ENHANCED_WATER) - struct WaterScope { - RT64::State& state;decltype(s.waterMaterial) previous; - explicit WaterScope(RT64::State& value):state(value),previous(value.waterMaterial){state.waterMaterial={};state.updateDrawStatusAttribute(RT64::DrawAttribute::WaterMaterial);} - ~WaterScope(){state.waterMaterial=previous;state.updateDrawStatusAttribute(RT64::DrawAttribute::WaterMaterial);} - } water(s); -#endif - auto& workload=s.ext.workloadQueue->workloads[s.ext.workloadQueue->writeCursor]; - auto& draw=workload.drawData; - const auto first_index=draw.faceIndices.size(); - const auto& car=*resources_->library.packages[car_]; - const float angle=car.yaw*Tau/360.F,car_cos=std::cos(angle),car_sin=std::sin(angle); - const auto units=resources_->vertex_units.at(car_); - // Remove only the selected driver's live draw scale and vertex scratch - // precision, then restore the common native-sized vehicle reference. - // Previously the last step was missing, rendering model units as world - // units. Translation/projection and the artist's package scale are kept. - auto car_root=root_;for(unsigned row=0;row<3;++row)car_root[row]=root_[row]*(KartModelToWorld/(units*scale_)); - // The root's Y basis already contains the native live draw scale. Remove - // it once to move the entire car (including all four wheels) by the same - // fixed local distance as its seat. Do not double-apply vertex precision - // or the package scale, and do not change the canonical guest transform. - const auto offset=seating::scaled(car.kart_offset,KartModelToWorld); - car_root[3]=root_[3]+root_[0]*(offset.x/scale_)+root_[1]*((KartRideHeight+offset.y)/scale_)+root_[2]*(offset.z/scale_); - rsp.modelMatrixStack[1]=car_root;rsp.modelMatrixStackSize=2; - rsp.modelMatrixPhysicalAddressStack[1]=rsp.modelMatrixSegmentedAddressStack[1]=Scratch; - rsp.modelViewProjChanged=true;rsp.modelViewProjInserted=false; - const auto alpha=read(s,object_+0x39,1); - rdp.setEnvColor(0); - unsigned mesh_index=0;bool in_front=false;float min_w=1e30F,max_w=-1e30F; - for(const auto& mesh:car.lods[vehicle_lod_].meshes) { - int wheel=-1;for(unsigned i=0;i<4;++i)if(car.wheels[i]==mesh.id)wheel=int(i); - wheel_interpolation::Sample motion; - if(wheel>=0) { - const auto pivot=seating::source_point(car,car.pivots[unsigned(wheel)]);const auto precision=car.scale*units; - motion.pivot={pivot.x*precision,pivot.y*precision,pivot.z*precision}; - motion.phase=spin_[unsigned(wheel)];motion.axle=car.axle;motion.yaw=angle; - motion.steering=steer_;motion.front=wheel<2;motion.epoch=wheel_epoch_;motion.object=object_; - motion.scene=presentation::task_scene_generation();motion.allowed=presentation::task_interpolation_allowed(); - motion.base=hlslpp::mul(car_root,rsp.extended.viewProjMatrix); - rsp.modelMatrixStack[1]=hlslpp::mul(wheel_interpolation::local(motion,wheel_interpolation::phase_angle(motion.phase),steer_),car_root); - }else rsp.modelMatrixStack[1]=car_root; - const bool textured=mesh.texture>=0; - const auto opacity=unsigned(mesh.colour[3])*alpha/255; - rdp.setPrimColor(0,0,0xffffff00U|opacity); - // Alpha coverage keeps transparent texels from writing solid depth. - // No lighting passes, rays, or additional render target are required. - // These pixel-art material images are already small in the export. - // Point sampling retains their edges instead of adding another blur. - // NativeDrawScope restores the caller's filter/material state. - rdp.setOtherMode(G_TF_POINT|G_TP_PERSP,(alpha<255||mesh.colour[3]<255)?0x005049d8U:textured?0x00553078U:0x00552078U); - if(textured) { - const auto& texture=car.textures.at(unsigned(mesh.texture));const auto width=texture.image.width,height=texture.image.height; - const auto format=resources_->texture_pixels.at(car_).at(unsigned(mesh.texture)).rgba32?G_IM_SIZ_32b:G_IM_SIZ_16b; - rdp.setTextureImage(G_IM_FMT_RGBA,format,width,texture_addresses_[car_].at(unsigned(mesh.texture))); - const auto mask=[](unsigned n){unsigned bits=0;while((1U<0.F? - rsp.cullBothMask&~rsp.cullFrontMask:rsp.cullFrontMask); - unsigned chunk=0; - for(std::size_t first=0;first(10,mesh.faces.size()-first)); - // Disjoint fields: no XOR overlap between a different car, mesh, - // view or chunk. Vertex correspondence never crosses an LOD. - const auto identity=0xc0000000U|((presentation::task_scene_generation()&7U)<<27)| - (view_<<25)|(player_<<22)|(vehicle_lod_<<19)|(car_<<15)|(mesh_index<<9)|chunk; - // Wheel geometry stays fixed. Interpolate its pivot-local angular - // motion at presentation time, never chord/morph its vertices. - select_(rsp,identity,wheel<0); - // Each chunk owns its transform group, even though it uses the - // same physical scratch address. Don't inherit the previous - // chunk's transform index after selecting a new group identity. - rsp.modelViewProjChanged=true; - for(unsigned face=0;face=0) { - motion.identity=identity;motion.transform=rsp.curTransformIndex; - wheel_interpolation::record(workload,s.workloadId+1,resources_,motion); - } - for(unsigned v=0;v0; - } - for(unsigned face=0;facenative_scale,KartRideHeight,car.per_driver()?0.F:DriverSeatAdvance,car.per_driver()?"per-base-driver-v1":"legacy-shared",driver_->base,vehicle_lod_,unsigned(maximum_detail_)); - // A degenerate/behind-camera replacement must never make the original - // disappear. The immutable package already guarantees complete meshes. - return indices!=0&&in_front; -} -} // namespace dkr::runtime::vehicles diff --git a/patches/custom-vehicles/native/vehicle_rt64.hpp b/patches/custom-vehicles/native/vehicle_rt64.hpp deleted file mode 100644 index f0269f4b..00000000 --- a/patches/custom-vehicles/native/vehicle_rt64.hpp +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once -#include "vehicle_binding.hpp" -#include "common/rt64_hlslpp.h" - -namespace RT64 {struct State;struct RSP;} -namespace dkr::runtime::vehicles { -// All addresses refer to a task-owned snapshot/private decoder, never live RAM. -class RenderPass { -public: - using Select=void(*)(RT64::RSP&,std::uint32_t,bool); - explicit RenderPass(RT64::State&,std::shared_ptr,Select); - ~RenderPass(); - RenderPass(const RenderPass&)=delete; - RenderPass& operator=(const RenderPass&)=delete; - bool marker(unsigned kind,unsigned value); - void matrix(unsigned index); - bool suppress(std::uint32_t triangle)const; - // Presentation-only LOD0 redraws must use the HIGH model's ownership mask, - // not the submitted low model's indices. Null means no custom car scope. - const mods::vehicles::ModelProfile* detail_profile(unsigned object,unsigned instance)const; -private: - void begin(unsigned object); - bool draw(); - void diagnostic(const char* message)const; - RT64::State& state_; - const RenderPass* previous_pass_=nullptr; - std::shared_ptr resources_; - Select select_; - unsigned scope_=0,object_=0,wheel_=0,triangles_=0,car_=0,original_lod_=0,vehicle_lod_=0,player_=0,view_=0; - unsigned texture_cursor_=0x00e00000U; - unsigned setup_=0,wheel_epoch_=0; - const mods::vehicles::DriverProfile* driver_=nullptr; - const mods::vehicles::ModelProfile* profile_=nullptr; - std::array,16> texture_addresses_; - hlslpp::float4x4 root_=hlslpp::float4x4::identity(),adjustment_=hlslpp::float4x4::identity(); - float scale_=1,draw_scale_=1,steer_=0; - std::array spin_{}; - bool active_=false,root_ready_=false,replacement_submitted_=false,hide_attachment_=false; - bool maximum_detail_=false; // Local presentation policy, never checkpointed. -}; -// Decoder-thread scope only; never simulation/checkpoint state. -const RenderPass* active_render_pass() noexcept; -} // namespace dkr::runtime::vehicles diff --git a/patches/custom-vehicles/native/vehicle_seat_alignment.hpp b/patches/custom-vehicles/native/vehicle_seat_alignment.hpp deleted file mode 100644 index 3fffce3b..00000000 --- a/patches/custom-vehicles/native/vehicle_seat_alignment.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once -#include "vehicle_package.hpp" - -namespace dkr::runtime::vehicles::seating { -// The export's seat, offset AND calibrated neutral anchors share the same -// post-scale Studio coordinates. They must receive the same model-to-world -// conversion. Treating just the anchors as world units amplified the donor -// differences by four (Banjo/Waluigi versus Diddy/Rayman: 24 instead of 6). -inline constexpr float StudioToWorld=.25F; -constexpr mods::vehicles::Vec3 scaled(mods::vehicles::Vec3 value,float factor) { - return {value.x*factor,value.y*factor,value.z*factor}; -} -inline mods::vehicles::Vec3 anchor(const mods::vehicles::Package& car,unsigned base) { - return scaled(car.driver_anchors.at(base),StudioToWorld); -} -inline mods::vehicles::Vec3 reference_placement(const mods::vehicles::Package& car) { - // The fixed-reference contract maps directly onto the authored seat. - // Its anchors are immutable per KART, not one global calibration table. - if(car.per_driver())return {}; - // Retain the accepted reference placement at neutral scale while fixing - // RELATIVE donor alignment. This one shared calibration applies to the - // car, not a Waluigi/Rayman name, body bounds, character height or LOD. - // Diddy is the neutral reference of this version-1 export convention. - return scaled(car.driver_anchors[9],StudioToWorld-1.F); -} -inline mods::vehicles::Vec3 source_point(const mods::vehicles::Package& car,mods::vehicles::Vec3 value) { - // Both mesh vertices and pivot positions start in the same source space. - // Seats are already AFTER model scale/yaw and never pass through here. - if(car.per_driver())return {value.x-car.origin.x,value.y-car.origin.y,value.z-car.origin.z}; - return value; -} -inline mods::vehicles::Vec3 destination(const mods::vehicles::Package& car,unsigned base) { - return scaled(car.driver_seats.at(base),StudioToWorld); -} -static_assert(scaled({0,0,24},StudioToWorld).z==6.F); -} // namespace dkr::runtime::vehicles::seating diff --git a/patches/custom-vehicles/native/vehicle_ui.inl b/patches/custom-vehicles/native/vehicle_ui.inl deleted file mode 100644 index f50c9362..00000000 --- a/patches/custom-vehicles/native/vehicle_ui.inl +++ /dev/null @@ -1,206 +0,0 @@ -// Included inside the launcher namespace after the existing library/Store UI. -namespace vehicle_mods=dkr::mods::vehicles; -struct VehicleLibraryView { - std::vector items; - bool busy=false; - std::string error,message; - std::shared_ptr pending; -}; -class VehicleLibraryUI { - std::mutex mutex_; - std::filesystem::path root_; - std::shared_ptr view_=std::make_shared(); - std::jthread worker_; -public: - ~VehicleLibraryUI(){if(worker_.joinable()){worker_.request_stop();worker_.join();}} - std::shared_ptr view(){std::lock_guard lock(mutex_);return view_;} - bool job(std::function action={},std::string done={}) { - std::filesystem::path root; - {std::lock_guard lock(mutex_);if(view_->busy||root_.empty())return false; - auto next=std::make_shared(*view_);next->busy=true;next->error.clear();next->message.clear();view_=next;root=root_;} - if(worker_.joinable())worker_.join(); - try { - worker_=std::jthread([this,root,action=std::move(action),done=std::move(done)](std::stop_token stop) { - auto next=std::make_shared(); - try {vehicle_mods::Library library(root);if(action)action(library,*next);next->items=library.scan(stop);if(!next->pending)next->message=done;} - catch(const std::exception& e){std::lock_guard lock(mutex_);next->items=view_->items;next->pending=view_->pending;next->error=e.what();} - std::lock_guard lock(mutex_);view_=std::move(next); - }); - }catch(const std::exception& e){std::lock_guard lock(mutex_);auto next=std::make_shared(*view_);next->busy=false;next->error=e.what();view_=next;return false;} - return true; - } - void configure(std::filesystem::path root){if(worker_.joinable())worker_.join();{std::lock_guard lock(mutex_);root_=std::move(root);}job();} -}; -VehicleLibraryUI g_vehicle_library; -std::array g_vehicle_search{}; -std::string g_vehicle_details; -bool g_vehicle_details_request=false,g_vehicle_remove_confirm=false; -std::map g_vehicle_portraits; -int g_vehicle_last_draw=-2; -const VehicleLibraryView* g_vehicle_preview_view=nullptr; -void ReleaseVehicleTextures() { - for(auto& [hash,t]:g_vehicle_portraits)if(t)SDL_DestroyTexture(t); - g_vehicle_portraits.clear(); -} -void DrawVehiclePortrait(const vehicle_mods::Item& item,float width,float height) { - const auto at=ImGui::GetCursorScreenPos(); - if(g_mod_store_renderer&&ImGui::IsRectVisible({width,height})) { - auto it=g_vehicle_portraits.find(item.digest); - if(it==g_vehicle_portraits.end()&&g_vehicle_portraits.size()<512) { - const auto& p=item.portrait; - auto* t=SDL_CreateTexture(g_mod_store_renderer,SDL_PIXELFORMAT_RGBA32,SDL_TEXTUREACCESS_STATIC,int(p.width),int(p.height)); - if(t&&SDL_UpdateTexture(t,nullptr,p.rgba.data(),int(p.width*4))){SDL_DestroyTexture(t);t=nullptr;} - if(t)SDL_SetTextureBlendMode(t,SDL_BLENDMODE_BLEND); - it=g_vehicle_portraits.emplace(item.digest,t).first; - } - if(it!=g_vehicle_portraits.end()&&it->second) { - const float ratio=float(item.portrait.width)/item.portrait.height; - const float w=std::min(width,height*ratio),h=w/ratio; - ImGui::GetWindowDrawList()->AddImage(reinterpret_cast(it->second), - {at.x+(width-w)*.5F,at.y+(height-h)*.5F},{at.x+(width+w)*.5F,at.y+(height+h)*.5F}); - } - } - ImGui::Dummy({width,height}); -} -void ImportVehicleWithDialog() { - if(g_vehicle_library.view()->busy||g_vehicle_library.view()->pending||DialogJobRunning())return; - StartDialogJob([]()->std::function { - std::filesystem::path source;std::string error; - if(NFD_Init()!=NFD_OKAY)error="The file picker could not open."; - else { - nfdu8char_t* result=nullptr; - const nfdfilteritem_t filters[]={{"Custom car or vehicle ZIP","dkrkart,zip"}}; - const auto dialog=NFD_OpenDialogU8(&result,filters,1,nullptr); - if(dialog==NFD_OKAY){source=std::filesystem::u8path(result);NFD_FreePathU8(result);} - else if(dialog==NFD_ERROR)error=NFD_GetError(); - NFD_Quit(); - } - return [source,error] { - if(!error.empty()){g_legacy_import_status=error;return;} - if(source.empty())return; - g_mods_page.category=3;g_mods_page.section=kModsSectionLibrary; - g_vehicle_library.job([source](auto& library,auto& view){ - auto batch=library.prepare_import(source); - if(!batch.conflicts.empty())view.pending=std::make_shared(std::move(batch)); - else library.import_prepared(batch,vehicle_mods::ImportPolicy::RejectDuplicates); - },"Cars imported. Choose yours after selecting Car in Track Select."); - }; - }); -} -void DrawVehicleLibrary(float requested_width,bool locked) { - const auto snapshot=g_vehicle_library.view(); - const auto frame=ImGui::GetFrameCount(); - if(frame!=g_vehicle_last_draw+1&&!locked&&!snapshot->busy&&!snapshot->pending)g_vehicle_library.job(); - g_vehicle_last_draw=frame; - if(g_vehicle_preview_view!=snapshot.get()){ReleaseVehicleTextures();g_vehicle_preview_view=snapshot.get();} - const bool busy=locked||snapshot->busy||bool(snapshot->pending); - const float width=std::max(1.F,std::min(requested_width,ImGui::GetContentRegionAvail().x)); - ImGui::PushID("vehicle-card-library"); - const auto search=PaddockSearch("search",g_vehicle_search.data(),g_vehicle_search.size(),"Search vehicles...",width); - if(search==PaddockFieldResult::GamepadActivated) - RequestCustomTextKeyboard("Search vehicles",g_vehicle_search.data(),g_vehicle_search.size(),[](const std::string& s){g_vehicle_search.fill(0);std::copy_n(s.begin(),std::min(s.size(),g_vehicle_search.size()-1),g_vehicle_search.begin());}); - PaddockGap(14); - if(snapshot->busy)DrawToolIntro("Updating vehicle library...",width); - if(!snapshot->error.empty())DrawIssue(snapshot->error,dkr::runtime::copy::Area::Mods); - if(!snapshot->message.empty())DrawToolIntro(snapshot->message,width); - auto query=std::string(g_vehicle_search.data()); - std::transform(query.begin(),query.end(),query.begin(),[](unsigned char c){return char(std::tolower(c));}); - std::vector shown; - for(const auto& item:snapshot->items) { - auto name=item.name;std::transform(name.begin(),name.end(),name.begin(),[](unsigned char c){return char(std::tolower(c));}); - if(query.empty()||name.find(query)!=name.npos)shown.push_back(&item); - } - const auto count=std::count_if(snapshot->items.begin(),snapshot->items.end(),[](const auto& item){return item.enabled;}); - PaddockText(PaddockReading(12,false,1.5F),PaddockRgb(0x9BB0BD),std::to_string(count)+" cars enabled. Track Select only.",width);PaddockGap(16); - if(shown.empty()) { - PaddockBox box(width,{24,28});PaddockHeading(snapshot->items.empty()?"Your vehicle library starts here":"No matching vehicles",24,0xFFF0C2,box.Inner()); - PaddockGap(12);ImGui::BeginDisabled(busy); - if(PaddockButton("Import car",PaddockButtonKind::Primary))ImportVehicleWithDialog(); - ImGui::EndDisabled();box.End(CupCardBackground); - }else { - const int columns=std::max(1,int((width+kModsCardGap)/(265+kModsCardGap))); - const float card_width=std::floor((width-kModsCardGap*(columns-1))/columns); - const auto grid=ImGui::GetCursorScreenPos();float y=grid.y; - for(std::size_t first=0;first cards;float height=0; - for(std::size_t i=first;i shown; - if(snapshot->pending&&(shown.owner_before(snapshot->pending)||snapshot->pending.owner_before(shown))) { - shown=snapshot->pending;ImGui::OpenPopup(name); - } - if(!ImGui::IsPopupOpen(name))return; - const auto screen=ImGui::GetIO().DisplaySize; - const float width=std::max(240.F,std::min(640.F,screen.x-48)); - ImGui::SetNextWindowSizeConstraints({width,0},{width,std::max(240.F,screen.y-48)}); - const PaddockFlatScope paddock; - if(!BeginPaddockModalWindow(name,ImGuiWindowFlags_AlwaysAutoResize))return; - if(!snapshot->pending){ImGui::CloseCurrentPopup();ImGui::EndPopup();return;} - PaddockModalText("Some cars have the same names as cars in your library. Replace the older copies, or keep both versions?"); - for(const auto& row:snapshot->pending->conflicts)PaddockModalText(row.name); - PaddockGap(10); - PaddockModalText("Replacing keeps the old files in recovery history. Your downloads and saves are not changed."); - if(!snapshot->error.empty())PaddockModalText(snapshot->error); - if(snapshot->busy)PaddockModalText("Importing cars..."); - ImGui::BeginDisabled(locked||snapshot->busy); - const auto batch=snapshot->pending; - if(PaddockButton("Replace older copies",PaddockButtonKind::Primary)) - g_vehicle_library.job([batch](auto& library,auto&){library.import_prepared(*batch,vehicle_mods::ImportPolicy::ReplaceSameName);},"Cars updated. Older copies were kept in recovery history."); - PaddockGap(8); - if(PaddockButton("Keep both versions",PaddockButtonKind::Plain)) - g_vehicle_library.job([batch](auto& library,auto&){library.import_prepared(*batch,vehicle_mods::ImportPolicy::KeepBoth);},"Cars imported. Both versions were kept."); - PaddockGap(8); - if(PaddockButton("Cancel",PaddockButtonKind::Link))g_vehicle_library.job(); - ImGui::EndDisabled();ImGui::EndPopup(); -} -void DrawVehicleDetails(bool locked) { - DrawVehicleImportConfirm(locked); - constexpr const char* name="Vehicle details"; - if(g_vehicle_details_request){ImGui::OpenPopup(name);g_vehicle_details_request=false;g_vehicle_remove_confirm=false;} - if(!ImGui::IsPopupOpen(name))return; - const auto screen=ImGui::GetIO().DisplaySize; - const float width=std::max(240.F,std::min(620.F,screen.x-48)); - ImGui::SetNextWindowSizeConstraints({width,0},{width,std::max(240.F,screen.y-48)}); - const PaddockFlatScope paddock; - if(!BeginPaddockModalWindow(name,ImGuiWindowFlags_AlwaysAutoResize))return; - const auto snapshot=g_vehicle_library.view(); - const auto it=std::find_if(snapshot->items.begin(),snapshot->items.end(),[](const auto& item){return item.digest==g_vehicle_details;}); - if(it!=snapshot->items.end()) { - const auto inner=ImGui::GetContentRegionAvail().x; - PaddockHeading(it->name,28,0xFFCD67,inner);PaddockGap(12);DrawVehiclePortrait(*it,inner,180);PaddockGap(12); - PaddockModalText("Replaces the car visuals, not your chosen character or its handling. Available from Track Select."); - ImGui::BeginDisabled(locked||snapshot->busy); - bool enabled=it->enabled; - if(PaddockSwitch("Enabled for next game",&enabled,"On","Off"))g_vehicle_library.job([hash=it->digest,enabled](auto& library,auto&){library.enable(hash,enabled);}); - PaddockGap(14); - if(!g_vehicle_remove_confirm) {if(PaddockButton("Remove from library",PaddockButtonKind::Link))g_vehicle_remove_confirm=true;} - else { - PaddockModalText("Remove this car? Your download and saves will be kept."); - if(PaddockButton("Remove car",PaddockButtonKind::Plain)) {g_vehicle_library.job([hash=it->digest](auto& library,auto&){library.remove(hash);},"Car removed. Your download was kept.");ImGui::CloseCurrentPopup();} - } - ImGui::EndDisabled(); - }else PaddockModalText("This car is no longer installed."); - PaddockGap(18);if(PaddockButton("Close",PaddockButtonKind::Link))ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); -} diff --git a/patches/custom-vehicles/native/vehicle_wheel_interpolation.hpp b/patches/custom-vehicles/native/vehicle_wheel_interpolation.hpp deleted file mode 100644 index a52c4169..00000000 --- a/patches/custom-vehicles/native/vehicle_wheel_interpolation.hpp +++ /dev/null @@ -1,170 +0,0 @@ -#pragma once -// Authored presentation sidecar. The Patch Pipeline includes this only in -// isolated car/RT64 source copies; no checked-out dependency is modified. -#include "common/rt64_hlslpp.h" -#include "hle/rt64_workload.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::vehicles::wheel_interpolation { -// Keep the shared renderer header compatible with RT64's C++17 target. -inline constexpr double Tau=6.283185307179586476925286766559; -inline constexpr double Turn=4294967296.0; -inline constexpr std::size_t MaxSamples=8192; -struct Sample { - hlslpp::float4x4 base=hlslpp::float4x4::identity(); - std::array pivot{}; - std::uint64_t phase=0; - std::uint32_t transform=0,identity=0,scene=0,epoch=0,object=0; - unsigned axle=0; - float yaw=0,steering=0; - bool front=false,allowed=false; -}; -struct Capture { - // Control-block identity prevents resource-address reuse without pinning - // an entire retired mod catalogue just to retain a few wheel matrices. - std::weak_ptr owner; - std::uint64_t token=0; - std::vector samples; -}; -struct Slot { - const RT64::Workload* workload=nullptr; - std::uint64_t serial=0; - std::shared_ptr pending; - std::shared_ptr sealed; -}; -struct Registry { - std::mutex mutex; - // RT64 has four workload slots. Bound this sidecar even across renderer - // recreation; an evicted record simply uses the existing snap/fallback. - std::array slots; - std::uint64_t serial=0; -}; -inline Registry registry; -inline std::atomic diagnostic_count{0}; -inline bool finite(const hlslpp::float4x4& matrix) { - for(unsigned r=0;r<4;++r)for(unsigned c=0;c<4;++c) - if(!std::isfinite(float(matrix[r][c])))return false; - return true; -} -inline hlslpp::float4x4 translation(float x,float y,float z) { - auto matrix=hlslpp::float4x4::identity();matrix[3]=hlslpp::float4(x,y,z,1);return matrix; -} -inline hlslpp::float4x4 rotation(unsigned axle,float angle) { - auto matrix=hlslpp::float4x4::identity();const float c=std::cos(angle),s=std::sin(angle); - if(axle==0){matrix[1]=hlslpp::float4(0,c,s,0);matrix[2]=hlslpp::float4(0,-s,c,0);} - else if(axle==1){matrix[0]=hlslpp::float4(c,0,-s,0);matrix[2]=hlslpp::float4(s,0,c,0);} - else {matrix[0]=hlslpp::float4(c,s,0,0);matrix[1]=hlslpp::float4(-s,c,0,0);} - return matrix; -} -inline float phase_angle(std::uint64_t phase) { - return float(double(std::uint32_t(phase))*(Tau/Turn)); -} -inline std::int64_t phase_travel(std::uint64_t previous,std::uint64_t current) { - const auto bits=current-previous;std::int64_t value; - std::memcpy(&value,&bits,sizeof(value));return value; -} -inline hlslpp::float4x4 local(const Sample& sample,float spin,float steering) { - // Static vertices already contain the authored yaw. Undo it before the - // pivot-local rotation, then restore it. No camera/projection decomposition. - auto matrix=hlslpp::mul(rotation(1,-sample.yaw),translation(-sample.pivot[0],-sample.pivot[1],-sample.pivot[2])); - matrix=hlslpp::mul(matrix,rotation(sample.axle,spin)); - if(sample.front)matrix=hlslpp::mul(matrix,rotation(1,steering)); - matrix=hlslpp::mul(matrix,translation(sample.pivot[0],sample.pivot[1],sample.pivot[2])); - return hlslpp::mul(matrix,rotation(1,sample.yaw)); -} -inline Slot& slot_for(const RT64::Workload& workload) { - for(auto& slot:registry.slots)if(slot.workload==&workload)return slot; - auto& slot=*std::min_element(registry.slots.begin(),registry.slots.end(), - [](const Slot& a,const Slot& b){return a.serial owner) { - slot.serial=++registry.serial; - if(!slot.pending||slot.pending->token!=token||slot.pending->owner.owner_before(owner)||owner.owner_before(slot.pending->owner)) { - slot.pending=std::make_shared();slot.pending->token=token;slot.pending->owner=std::move(owner); - slot.pending->samples.reserve(128); - } - return *slot.pending; -} -inline void begin(const RT64::Workload& workload,std::uint64_t token,std::shared_ptr owner) { - std::lock_guard lock(registry.mutex); - // A task may append to an existing workload. Preserve earlier transforms, - // but discard anything belonging to a recycled/reset draw-data range. - const auto first=workload.drawData.worldTransforms.size(); - for(auto& slot:registry.slots)if(slot.workload==&workload) { - if(!slot.pending)return; - if(slot.pending->token!=token||slot.pending->owner.owner_before(owner)||owner.owner_before(slot.pending->owner)) {slot.pending.reset();return;} - auto& samples=slot.pending->samples; - auto at=std::lower_bound(samples.begin(),samples.end(),first, - [](const Sample& sample,std::size_t index){return sample.transform owner,Sample sample) { - if(sample.transform>=workload.drawData.worldTransforms.size()||sample.axle>2||!finite(sample.base))return; - std::lock_guard lock(registry.mutex);auto& capture=writable(slot_for(workload),token,std::move(owner)); - if(!capture.samples.empty()&&capture.samples.back().transform>=sample.transform)return; - if(capture.samples.size()token==token?std::move(slot.pending):nullptr; - // Only the decoder had a mutable reference. Published samples are now - // immutable and remain alive until every render-frame reader is done. - slot.pending.reset();return; - } -} -inline std::shared_ptr capture(const RT64::Workload& workload) { - std::lock_guard lock(registry.mutex); - for(const auto& slot:registry.slots)if(slot.workload==&workload&&slot.sealed&&slot.sealed->token==workload.workloadId) - return slot.sealed; - return {}; -} -inline const Sample* find(const Capture& capture,std::size_t index) { - const auto at=std::lower_bound(capture.samples.begin(),capture.samples.end(),index, - [](const Sample& sample,std::size_t value){return sample.transformtransform==index?&*at:nullptr; -} -inline hlslpp::float4x4 at_weight(const Sample& previous,const Sample& current,float weight, - const hlslpp::float4x4& previous_exact,const hlslpp::float4x4& current_exact) { - if(weight<=0)return previous_exact;if(weight>=1)return current_exact; - // Unsigned modular storage makes checkpoint addition defined. Recover the - // signed accumulated travel, NOT the shortest arc between wrapped angles. - const auto travel=phase_travel(previous.phase,current.phase); - const double phase=double(std::uint32_t(previous.phase))+double(travel)*double(weight); - const float spin=float(std::remainder(phase,Turn)*(Tau/Turn)); - const float steering=previous.steering+float(std::remainder(double(current.steering-previous.steering),Tau))*weight; - auto base=current.base; - for(unsigned row=0;row<4;++row)base[row]=previous.base[row]+(current.base[row]-previous.base[row])*weight; - return hlslpp::mul(local(current,spin,steering),base); -} -inline bool interpolate(const std::shared_ptr& previous,const std::shared_ptr& current, - std::size_t previous_index,std::size_t current_index,float previous_weight,float current_weight, - const hlslpp::float4x4& previous_exact,const hlslpp::float4x4& current_exact, - hlslpp::float4x4& previous_matrix,hlslpp::float4x4& current_matrix) { - if(!previous||!current||previous->owner.owner_before(current->owner)||current->owner.owner_before(previous->owner))return false; - const auto* a=find(*previous,previous_index);const auto* b=find(*current,current_index); - if(!a||!b||!a->allowed||!b->allowed||a->identity!=b->identity||a->scene!=b->scene||a->epoch!=b->epoch|| - a->object!=b->object||a->axle!=b->axle||a->front!=b->front||a->pivot!=b->pivot||a->yaw!=b->yaw|| - !std::isfinite(previous_weight)||!std::isfinite(current_weight))return false; - const auto pm=at_weight(*a,*b,previous_weight,previous_exact,current_exact); - const auto cm=at_weight(*a,*b,current_weight,previous_exact,current_exact); - if(!finite(pm)||!finite(cm))return false; - previous_matrix=pm;current_matrix=cm; - if(current_weight>0&¤t_weight<1&&diagnostic_count.fetch_add(1,std::memory_order_relaxed)<4) - std::fprintf(stderr,"[custom-vehicles][wheel-interpolation] arc-phase64 weight=%.4f travel-turns=%.5f; static mesh, no extra render pass\n", - current_weight,double(phase_travel(a->phase,b->phase))/Turn); - return true; -} -} // namespace dkr::runtime::vehicles::wheel_interpolation diff --git a/patches/custom-vehicles/native/vehicle_world.cpp b/patches/custom-vehicles/native/vehicle_world.cpp deleted file mode 100644 index cd81d88b..00000000 --- a/patches/custom-vehicles/native/vehicle_world.cpp +++ /dev/null @@ -1,436 +0,0 @@ -#include "vehicle_world.hpp" -#include "vehicle_symbols.hpp" -#include "legacy_character_menu.hpp" -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::vehicles { -namespace { -using namespace dkr::mods; -[[noreturn]] void bad(const std::string& s){throw Error("Custom car selection: "+s);} -class Memory { - std::span ram_; - std::size_t offset(std::uint32_t p,unsigned n)const { - if(p<0x80000000U||p>0x9fffffffU||ram_.size()%4)bad("invalid guest address."); - const auto o=p-0x80000000U;if(o>ram_.size()||n>ram_.size()-o)bad("guest range exceeds its owner.");return o; - } -public: - explicit Memory(std::span ram):ram_(ram){} - unsigned get(std::uint32_t p,unsigned n=4)const {const auto at=offset(p,n);unsigned value=0;for(unsigned i=0;i>((n-1-i)*8));} - void bytes(std::uint32_t p,View value){const auto at=offset(p,unsigned(value.size()));for(unsigned i=0;i(static_cast(p));} -class Calls { - std::span ram_;recomp_context context_;Memory g_; -public: - std::uint32_t stack; - Calls(std::span ram,const recomp_context& context):ram_(ram),context_(context),g_(ram),stack(std::uint32_t(context.r29)) { - if((stack&7)||stack<0x80000400U||stack>0x9fffffffU)bad("invalid callback stack."); - stack-=0x400;g_.get(stack);g_.get(stack+0x3fc); - } - unsigned call(RecompiledEntrypoint fn,std::initializer_list args) { - if(!fn||args.size()>8)bad("native menu callback is unavailable."); - auto c=context_;c.r29=pointer(stack);c.f_odd=c.mips3_float_mode?&c.f1.u32l:&c.f0.u32h;unsigned i=0; - for(const auto v:args){switch(i){case 0:c.r4=pointer(v);break;case 1:c.r5=pointer(v);break;case 2:c.r6=pointer(v);break;case 3:c.r7=pointer(v);break;default:g_.set(stack+i*4,v);break;}++i;} - fn(ram_.data(),&c);return unsigned(c.r2); - } -}; -} -const Symbols& symbols(bool rev80){return rev80?VehicleSymbols80:VehicleSymbols77;} -World::World(std::shared_ptr resources,bool rev80):resources_(std::move(resources)),rev80_(rev80) { - if(!resources_||resources_->library.packages.empty()||resources_->library.packages.size()!=resources_->portraits.size())bad("missing immutable car resources."); -} -void World::initialize(std::span ram,recomp_context& ctx,const GamePayload& f) { - if(scratch_||!portraits_.empty())return; - Calls c(ram,ctx);Memory g(ram);scratch_=c.call(f.asset_allocate,{512,0x7f7f7fff}); - if(!scratch_)bad("not enough native memory for vehicle selection.");g.bytes(scratch_,Bytes(512)); - try {for(auto id:resources_->portraits)for(unsigned half=0;half<2;++half) { - const auto p=c.call(f.menu_texture_load,{id+half});if(!p)bad("a vehicle portrait could not be loaded."); - portraits_.push_back(p);if(g.get(p,1)!=64||g.get(p+1,1)!=32)bad("a vehicle portrait tile has invalid dimensions."); - }} - catch(...){release(ram,ctx,f);throw;} -} -void World::release(std::span ram,recomp_context& ctx,const GamePayload& f) { - Calls c(ram,ctx);for(auto p:portraits_)c.call(f.menu_texture_free,{p});portraits_.clear(); - if(scratch_){c.call(f.asset_release,{scratch_});scratch_=0;} -} -bool World::menu(std::span ram,recomp_context& ctx,unsigned event,const GamePayload& f, - const std::function& header) { - Memory g(ram);const auto& s=symbols(rev80_); - const auto sound=[&](unsigned id){Calls c(ram,ctx);c.call(f.menu_sound_play,{id,0});}; - if(event==100) { - in_tracks_=true;picker_=false;option_pending_=false;before_=-2;committed_={};active_={};ready_={};wheel_phases_={};++wheel_epoch_; - cpu_choices_={};cpu_objects_={};race_humans_=race_racers_=spawn_slot_=0; - initialize(ram,ctx,f);return false; - } - if(event==104) {picker_=false;before_=-2;if(scratch_||!portraits_.empty())release(ram,ctx,f);return false;} - if(event==105) {option_pending_=true;committed_={};cpu_choices_={};cpu_objects_={};race_humans_=race_racers_=spawn_slot_=0;wheel_phases_={};++wheel_epoch_;return false;} - if(event==103)return picker_; // Native renderer keeps the title/records. - if(event==102) { - const auto after=int(std::int32_t(g.get(s.stage))); - if(!picker_&&before_==0&&after>0&&g.get(s.delay)==0&&in_tracks_) { - players_=g.get(s.players);if(!players_||players_>4)bad("invalid number of players."); - bool any=false; - for(unsigned p=0;p<4;++p) { - const auto* profile=pdriver(header(p)):nullptr; - active_[p]=pcompatible(); - if(pname.c_str():"unresolved", - active_[p]?"available":"not-offered",profile&&!profile->compatible()?" reason=":"", - profile&&!profile->compatible()?profile->issue.c_str():""); - ready_[p]=!active_[p];if(!active_[p])committed_[p]=0; - any|=active_[p]; - } - option_pending_=false; - if(any){next_=after;picker_=true;} - } - before_=-2;return false; - } - if(event!=101)bad("unknown menu event."); - if(!picker_) { - before_=int(std::int32_t(g.get(s.stage))); - if(option_pending_&&before_>=2&&before_<=3) { - before_=0;menu(ram,ctx,102,f,header);if(picker_)return true; - } - return false; - } - bool moved=false,confirm=false; - for(unsigned p=0;p0||buttons&0x0100U)?1:(axis<0||buttons&0x0200U)?-1:0; - if(direction) { - const unsigned n=unsigned(resources_->library.packages.size()+1); - selected_[p]=unsigned((int(selected_[p])+direction+int(n))%int(n));moved=true; - }else if(buttons&(0x8000U|0x1000U)){ready_[p]=true;confirm=true;} - } - if(moved)sound(0xeb);else if(confirm)sound(0xef); - bool done=true;for(unsigned p=0;p ram,const recomp_context& ctx,unsigned event) { - Memory g(ram);const auto& s=symbols(rev80_); - if(event==0) { - cpu_choices_={};cpu_objects_={};race_humans_=race_racers_=spawn_slot_=0; - wheel_phases_={};++wheel_epoch_;return; - } - if(event==3) { - const auto pending=spawn_slot_;spawn_slot_=0; - if(!pending)return; - const auto object=std::uint32_t(ctx.r2); - // racerIndex/vehicleID are assigned AFTER this spawn continuation. - // Bind only its actual returned primary object, then verify those - // fields when it is updated/drawn. Ghosts cannot inherit its choice. - if(object&&g.get(object+0x48,2)==1&&g.get(object+0x64))cpu_objects_[pending-1]=object; - return; - } - if(event==2) { - spawn_slot_=0;if(!race_racers_)return; - const auto index=std::uint32_t(ctx.r20),entry=std::uint32_t(ctx.r22); - if(index>=race_racers_)bad("primary racer index exceeds its car roster."); - if(index>=race_humans_&&g.get(entry+0xe,2)==4)spawn_slot_=index+1; - return; - } - if(event!=1)bad("unknown car race lifecycle event."); - if(!in_tracks_||!g.get(s.tracks_mode))return; - const auto sp=std::uint32_t(ctx.r29); - if((sp&7)||sp<0x80000000U||sp>0x9ffffe00U)bad("invalid car race setup frame."); - // ABI reviewed by legacy_character_ai_policy for BOTH admitted revisions. - const auto humans=g.get(sp+0x144),racers=g.get(std::uint32_t(ctx.r6)),type=g.get(sp+0x68),mode=g.get(sp+0x138); - if(!humans||humans>4||racers8)bad("invalid car race topology."); - if(mode==1||!(type==0||(type>=64&&type<=66))||racers==humans)return; - race_humans_=humans;race_racers_=racers;++race_serial_; - // Read, but NEVER advance, native RNG. This separate integer stream has - // no wall clock, platform distribution, host entropy or render dependency. - // Its results and exact primary-object bindings are rollback state. - std::uint64_t random=std::uint64_t(g.get(s.rng_seed))^(race_serial_*0x9e3779b97f4a7c15ULL); - const auto count=std::uint64_t(resources_->library.packages.size()+1); // Vanilla remains in the pool. - for(unsigned i=humans;i>30))*0xbf58476d1ce4e5b9ULL; - value=(value^(value>>27))*0x94d049bb133111ebULL;value^=value>>31; - }while(value<(std::uint64_t(0)-count)%count); - cpu_choices_[i]=unsigned(value%count); - } - static std::atomic reports{0}; - if(reports.fetch_add(1,std::memory_order_relaxed)<16) - std::fprintf(stderr,"[custom-vehicles][cpu-roster] race=%llu humans=%u racers=%u choices=%u,%u,%u,%u,%u,%u,%u,%u; deterministic primary racers only\n", - static_cast(race_serial_),humans,racers,cpu_choices_[0],cpu_choices_[1],cpu_choices_[2],cpu_choices_[3],cpu_choices_[4],cpu_choices_[5],cpu_choices_[6],cpu_choices_[7]); -} -unsigned World::slot(std::span ram,std::uint32_t object,std::uint32_t racer)const { - Memory g(ram);const auto player=g.get(racer,2); - if(player<4)return !race_racers_||player=race_racers_||index=8)return 0; - if(index>=race_humans_&&index ram,std::uint32_t object) { - Memory g(ram);const auto& s=symbols(rev80_); - if(!in_tracks_||!g.get(s.tracks_mode)||g.get(object+0x48,2)!=1)return 0; - const auto racer=g.get(object+0x64);if(!racer||g.get(racer+0x1d6,1)!=0)return 0; - const auto player=slot(ram,object,racer),selected=choice(player);if(!selected)return 0; - const auto* profile=resources_->driver(g.get(object+0x2c,2)); - if(!profile||!profile->compatible()||profile->base!=g.get(racer+3,1))return 0; - const auto lod=g.get(object+0x3a,1);if(lod>31)return 0; - // The retail farthest model bakes the stock kart into the driver's proxy. - // Keep only this selected driver's last separable LOD, never force all - // racers to maximum detail or use the withdrawn whole-model redraw path. - const auto view=g.get(s.view);if(view>3)return 0; - const auto draw_lod=std::min(lod,4U);g.set(object+0x3a,draw_lod,1); - const auto scope=selected|(lod<<8)|(draw_lod<<16)|(player<<24)|(view<<27); - if(rendering_)bad("nested racer model rendering."); - rendering_=object;wheel_=0; - marker(g,s,0,scope);marker(g,s,7,g.get(object+8)); - for(unsigned w=0;w<4;++w) { - marker(g,s,8+w,std::uint32_t(wheel_phases_[player][w])); - marker(g,s,13+w,std::uint32_t(wheel_phases_[player][w]>>32)); - } - marker(g,s,17,wheel_epoch_); - const auto points=g.get(object+0x60); - const auto front=points&&g.get(points)>=4&&g.get(points)<=10?g.get(points+12):0; - // Vec3s stores named rotations as Y, X, Z in the native ABI. - marker(g,s,12,front?g.get(front,2):0); - marker(g,s,1,object); - return scope; -} -void World::render_end(std::span ram,std::uint32_t object,std::uint32_t scope) { - if(scope) { - Memory g(ram);if(rendering_!=object||wheel_)bad("unbalanced vehicle model scope."); - marker(g,symbols(rev80_),2,object);g.set(object+0x3a,(scope>>8)&255U,1);rendering_=0; - } -} -void World::wheel_scope(std::span ram,std::uint32_t object,bool begin) { - if(!rendering_)return; - Memory g(ram);const auto points=g.get(rendering_+0x60); - bool owned=false; - if(points&&g.get(points)>=4&&g.get(points)<=10)for(unsigned i=0;i<4;++i)owned|=g.get(points+4+i*4)==object; - if(begin){if(wheel_)bad("nested car attachment scope.");wheel_=object;wheel_hidden_=owned;} - else {if(wheel_!=object)bad("unbalanced car wheel scope.");wheel_=0;} - // Other attachments keep their original draw and transform. In - // particular, carried eggs must not inherit the driver's seat alignment. - marker(g,symbols(rev80_),wheel_hidden_?(begin?3:4):(begin?5:6),object); -} -void World::advance_wheels(std::span ram,std::uint32_t object,float dt) { - Memory g(ram);const auto& s=symbols(rev80_); - if(!in_tracks_||!g.get(s.tracks_mode)||g.get(object+0x48,2)!=1)return; - const auto racer=g.get(object+0x64);if(!racer||g.get(racer+0x1d6,1)!=0)return; - auto player=slot(ram,object,racer); - // update_player_racer saves the human port in gCurrentPlayerIndex, then - // temporarily writes PLAYER_COMPUTER while the finished racer drives on. - // render_begin sees the restored port, but this native wheel callback runs - // INSIDE that handoff. Recover only its verified primary human object; - // never reinterpret a real CPU, ghost or arbitrary actor as a player. - if(player>=8&&g.get(racer,2)==0xffffU&&g.get(racer+0x1d8,1)==1) { - const auto owner=g.get(s.current_player),ports=g.get(s.racers_by_port); - if(owner<4&&(!race_racers_||owner reported_ports{0}; - if(!(reported_ports.fetch_or(1U<driver(g.get(object+0x2c,2));if(!profile||!profile->compatible())return; - const auto points=g.get(object+0x60);if(!points||g.get(points)<4||g.get(points)>10)return; - const auto real=[&](unsigned address){const auto v=std::bit_cast(g.get(address));if(!std::isfinite(v))bad("non-finite native wheel input.");return v;}; - if(!std::isfinite(dt)||dt<=0)return; - dt=std::min(dt,1024.F); // Bound visual arithmetic, never reject a long native tick. - const bool braking=real(racer+0xb8)>.4F; - const auto velocity=braking&&real(racer+0xb4)<.4F?0.F:std::clamp(real(racer+0x2c),-5.F,5.F); - // A turn is 2^32 units. Convert each f32 to fixed point independently; - // the multiply/divide and wrapping state are then entirely integer. No - // wall clock, camera-facing sprite index, fmod or platform trig enters a - // checkpoint. The two power-of-two conversions are exact before truncation. - const auto speed=std::int64_t(velocity*65536.F),time=std::int64_t(dt*65536.F); - const auto burnout=g.get(racer+0x1fb,1); - constexpr unsigned order[]{2,3,0,1}; - for(unsigned w=0;w<4;++w) { - const auto native=order[w],wheel=g.get(points+4+native*4),header=g.get(wheel+0x40); - const auto frames=g.get(header+0x55,1);if(!frames)continue; - if(!(braking&&native>=2)&&!(burnout&&native<3)) - wheel_phases_[player][w]+=std::uint64_t(-(speed*time)/(10LL*frames)); - if(burnout&&native<2)wheel_phases_[player][w]+=(std::uint64_t(1)<<32)/frames; - } -} -Bytes World::checkpoint()const { - CheckpointWriter out;out.u32(5);out.text(resources_->identity);out.flag(rev80_);out.flag(in_tracks_);out.flag(picker_);out.flag(option_pending_);out.u32(wheel_epoch_); - out.u32(race_humans_);out.u32(race_racers_);out.u32(spawn_slot_);out.u64(race_serial_); - for(unsigned i=0;i<8;++i){out.u32(cpu_choices_[i]);out.u32(cpu_objects_[i]);for(auto phase:wheel_phases_[i])out.u64(phase);} - out.u32(unsigned(before_+2));out.u32(unsigned(next_));out.u32(players_);out.u32(scratch_);out.u32(unsigned(portraits_.size())); - for(auto p:portraits_)out.u32(p); - for(unsigned i=0;i<4;++i){out.u32(selected_[i]);out.u32(committed_[i]);out.flag(active_[i]);out.flag(ready_[i]);} - return std::move(out).finish(); -} -std::shared_ptr World::stage_checkpoint(View bytes)const { - CheckpointReader in(bytes); - if(in.u32()!=5||in.text()!=resources_->identity||in.flag()!=rev80_)bad("checkpoint uses a different car catalogue."); - auto out=std::make_shared(resources_,rev80_);out->in_tracks_=in.flag();out->picker_=in.flag();out->option_pending_=in.flag();out->wheel_epoch_=in.u32(); - out->race_humans_=in.bounded(4);out->race_racers_=in.bounded(8);out->spawn_slot_=in.bounded(8);out->race_serial_=in.u64(); - if((out->race_racers_&&(!out->race_humans_||out->race_racers_<=out->race_humans_))|| - (!out->race_racers_&&out->race_humans_)|| - (out->spawn_slot_&&(out->spawn_slot_<=out->race_humans_||out->spawn_slot_>out->race_racers_)))bad("invalid CPU car topology checkpoint."); - std::set objects; - for(unsigned i=0;i<8;++i) { - out->cpu_choices_[i]=in.bounded(unsigned(resources_->library.packages.size()));out->cpu_objects_[i]=in.u32(); - const auto object=out->cpu_objects_[i]; - if((irace_humans_||i>=out->race_racers_)&&(out->cpu_choices_[i]||object))bad("CPU car belongs to an inactive racer."); - if(object&&(object<0x80000000U||object>0x807fff90U||object%4||!objects.insert(object).second))bad("invalid CPU car primary object checkpoint."); - for(auto& phase:out->wheel_phases_[i])phase=in.u64(); - } - out->before_=int(in.bounded(6))-2;out->next_=int(in.bounded(3));out->players_=in.bounded(4);out->scratch_=in.u32(); - const auto count=in.bounded(unsigned(resources_->portraits.size()*2)); - if((!out->scratch_&&count)||(out->scratch_&&(out->scratch_<0x80000000U||out->scratch_>0x807ffe00U||out->scratch_%8))|| - (count&&count!=resources_->portraits.size()*2))bad("invalid menu resource checkpoint."); - for(unsigned i=0;i0x807fffe0U||p%8)bad("invalid portrait checkpoint.");out->portraits_.push_back(p);} - for(unsigned i=0;i<4;++i) {out->selected_[i]=in.bounded(unsigned(resources_->library.packages.size()));out->committed_[i]=in.bounded(unsigned(resources_->library.packages.size()));out->active_[i]=in.flag();out->ready_[i]=in.flag();} - if(out->picker_&&(!out->in_tracks_||!out->players_||!out->scratch_||!count||out->next_<1))bad("invalid picker checkpoint."); - in.end();return out; -} -void World::draw(std::span ram,recomp_context& ctx,const GamePayload& f, - RecompiledEntrypoint frame,RecompiledEntrypoint text_width) { - if(!picker_)return; - if(!scratch_||portraits_.size()!=resources_->portraits.size()*2||!players_||players_>4||!frame||!text_width) - bad("vehicle picker lost its prepared native menu resources."); - Calls c(ram,ctx);Memory g(ram);const auto& s=symbols(rev80_); - const auto label=[&](std::string value) { - if(value.size()>255)value.resize(255);Bytes bytes; - for(unsigned char ch:value)bytes.push_back(ch>=32&&ch<127?ch:'?');bytes.push_back(0);g.bytes(scratch_+128,bytes); - }; - const auto text=[&](const std::string& value,unsigned x,unsigned y,unsigned font,unsigned colour=0xffffffffU) { - label(value);c.call(f.menu_font,{font});c.call(f.menu_background,{0,0,0,0}); - // Native fun-font labels use the same small offset shadow as the - // surrounding game menus. Never tint the track name/records above us. - if(font==0) { - c.call(f.menu_colour,{0,0,0,255,128}); - c.call(f.menu_text,{s.display_list,x+1,y+1,scratch_+128,12}); - } - c.call(f.menu_colour,{colour>>24,(colour>>16)&255,(colour>>8)&255,0,colour&255}); - c.call(f.menu_text,{s.display_list,x,y,scratch_+128,12}); - }; - const auto width=[&](const std::string& value,unsigned font) { - label(value);return c.call(text_width,{scratch_+128,0,font}); - }; - const auto image=[&](unsigned list,float x,float y,float scale,unsigned colour=0xffffffffU) { - if(!list||!g.get(list))return false; - c.call(f.menu_texture_draw,{s.display_list,list,std::bit_cast(x),std::bit_cast(y), - std::bit_cast(scale),std::bit_cast(scale),colour,0});return true; - }; - const auto wooden_frame=[&](float x,float y,float scale) { - // gMenuImages is a guest POINTER to the allocated 18-entry array, - // not the array itself. Never treat the symbol's neighbouring globals - // as a MenuAsset or overwrite them while placing a wooden frame. - const auto images=g.get(s.menu_images);if(!images)return; - struct Placement { - Memory& g;unsigned address,height;std::array words;unsigned old_height; - Placement(Memory& memory,unsigned at,unsigned h):g(memory),address(at),height(h),old_height(g.get(h)) { - for(unsigned i=0;i<8;++i)words[i]=g.get(address+i*4); - } - ~Placement(){for(unsigned i=0;i<8;++i)g.set(address+i*4,words[i]);g.set(height,old_height);} - } saved(g,images+7*32,s.frame_height); - const auto original_scale=std::bit_cast(saved.words[2]); - if(!std::isfinite(original_scale)||original_scale<=0)return; - g.set(saved.address+8,std::bit_cast(original_scale*scale)); - g.set(saved.address+12,std::bit_cast(x-160.F)); - g.set(saved.address+16,std::bit_cast(120.F-y)); - g.set(s.frame_height,std::bit_cast(1.F)); - c.call(f.menu_render_reset,{s.display_list});c.call(frame,{7}); - }; - // The original renderer has already drawn the live course, colourful - // track title, real records and balloons. No full-screen panel or guessed - // duplicate title is drawn here; only its lower choice widgets change. - const bool compact=players_>=3; - const float scale=compact?.70F:1.F,centre_y=173.F,span=64.F*scale; - const float column=320.F/float(players_); - for(unsigned p=0;p",unsigned(centre+offset),unsigned(centre_y),0); - text(std::to_string(selected+1)+" / "+std::to_string(resources_->library.packages.size()+1),unsigned(centre),135,1); - }else text("READY",unsigned(centre),135,1,0xa0ff80ffU); - std::string name=selected?resources_->library.packages.at(selected-1)->name:"Vanilla"; - if(!active_[p]) { - const auto type=g.get(s.vehicles+p,1);if(type==1)name="Hovercraft";else if(type==2)name="Plane"; - } - unsigned font=players_==1?0:1; - const unsigned maximum=unsigned(std::min(296.F,column-8.F)); - if(font==0&&width(name,font)>maximum)font=1; - // Measure actual native glyph widths, not a fixed character count. - // Names wrap below the frame instead of stretching a menu card. - unsigned line=0;std::size_t first=0; - while(firstfirst)end=space; - } - auto part=name.substr(first,end-first); - if(line==2&&endmaximum)part.pop_back();part+="..."; - } - text(part,unsigned(centre),211+line*(font==0?12:8),font);first=end;++line; - } - } - text("A: SELECT B: BACK",160,236,1); - c.call(f.menu_font,{2});c.call(f.menu_background,{0,0,0,0});c.call(f.menu_colour,{255,255,255,0,255}); - c.call(f.menu_render_reset,{s.display_list}); - static std::atomic reports{0}; - if(reports.fetch_add(1,std::memory_order_relaxed)<1) - std::fprintf(stderr,"[custom-vehicles][native-menu] native title/records, wooden frames and arrows; portraits=64x32+64x32; outward-arrows=alpha-blended; players=%u\n",players_); -} -} // namespace dkr::runtime::vehicles diff --git a/patches/custom-vehicles/native/vehicle_world.hpp b/patches/custom-vehicles/native/vehicle_world.hpp deleted file mode 100644 index fc71825d..00000000 --- a/patches/custom-vehicles/native/vehicle_world.hpp +++ /dev/null @@ -1,64 +0,0 @@ -#pragma once -#include "vehicle_resources.hpp" -#include "legacy_checkpoint.hpp" -#include "game_payload.hpp" -#include - -namespace dkr::runtime::vehicles { -// Sourced from both revision-specific SDK symbol tables by the Patch Pipeline. -struct Symbols { - std::uint32_t stage,vehicles,confirmed,ready,players,delay,buttons,stick_x,stick_y, - display_list,dialogue_begin,dialogue_modes,tracks_mode,current_menu, - object_display_list,object_matrix,view,menu_images,frame_height,player_images,arrows,race_images,rng_seed, - current_player,racers_by_port; -}; -const Symbols& symbols(bool revision80); -class World { -public: - using Resources=dkr::mods::vehicles::Resources; - explicit World(std::shared_ptr,bool revision80); - // 100 initialize, 101 before option input, 102 after option input, - // 103 draw options, 104 leave Track Select. True replaces that one call. - bool menu(std::span,recomp_context&,unsigned,const GamePayload&, - const std::function& character_header); - // Callbacks arrive from the currently executing normal/private guest - // variant. Do not look up a global function from the other online world. - void draw(std::span,recomp_context&,const GamePayload&, - RecompiledEntrypoint frame,RecompiledEntrypoint text_width); - // Read-only identity query, apart from the scoped far-LOD cap restored by - // render_end. It never allocates models, changes physics or advances time. - std::uint32_t render_begin(std::span,std::uint32_t object); - void render_end(std::span,std::uint32_t object,std::uint32_t scope); - void wheel_scope(std::span,std::uint32_t object,bool begin); - void advance_wheels(std::span,std::uint32_t object,float native_update_rate); - // Reviewed primary-racer setup hooks, including builds with no custom - // characters. Choices are made on the logic path, never by the renderer. - void race(std::span,const recomp_context&,unsigned event); - dkr::mods::Bytes checkpoint()const; - std::shared_ptr stage_checkpoint(dkr::mods::View)const; - const std::shared_ptr& resources()const{return resources_;} -private: - void initialize(std::span,recomp_context&,const GamePayload&); - void release(std::span,recomp_context&,const GamePayload&); - unsigned slot(std::span,std::uint32_t object,std::uint32_t racer)const; - unsigned choice(unsigned slot)const; - std::shared_ptr resources_; - bool rev80_=false,in_tracks_=false,picker_=false,option_pending_=false; - int before_=-2,next_=0; - unsigned players_=0; - std::array selected_{},committed_{}; - // Low 32 bits are one turn; upper bits retain direction/full revolutions. - // This is deterministic simulation state, not a wall-clock animation. - std::array,8> wheel_phases_{}; - std::uint32_t wheel_epoch_=0; - std::array cpu_choices_{},cpu_objects_{}; - unsigned race_humans_=0,race_racers_=0,spawn_slot_=0; - std::uint64_t race_serial_=0; - std::array active_{},ready_{}; - std::uint32_t scratch_=0; - std::vector portraits_; - // Render-call scratch only. Never part of simulation/checkpoint state. - std::uint32_t rendering_=0,wheel_=0; - bool wheel_hidden_=false; -}; -} // namespace dkr::runtime::vehicles diff --git a/patches/custom-vehicles/online_pipeline.py b/patches/custom-vehicles/online_pipeline.py deleted file mode 100644 index 9898110a..00000000 --- a/patches/custom-vehicles/online_pipeline.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Compose data-only custom-car transport over the accepted online system.""" -from pipeline import once - - -def manifest_source(text): - text = '#include "vehicle_protocol.hpp"\n' + text - text = once(text, 'const char* kind(Kind value) {', 'const char* kind(Kind value) {\n if(value==CarContent)return "car";') - text = once(text, 'const char* payload_kind(PayloadKind value) {', 'const char* payload_kind(PayloadKind value) {\n if(value==CarPayload)return "car";') - text = once(text, 'p.kind==PayloadKind::Code?32*MiB:MaxStaged', '(p.kind==PayloadKind::Code||p.kind==CarPayload)?32*MiB:MaxStaged') - text = once(text, 'std::size_t tracks=0,characters=0;', 'std::size_t tracks=0,characters=0,cars=0;') - anchor = ' const auto expected=c.kind==Kind::Character?CharacterAdapterVersion:' - text = once(text, anchor, ''' if(c.kind==CarContent) { - const auto p=std::find_if(value.payloads.begin(),value.payloads.end(),[&](const auto& p){return p.digest==c.payload;}); - if(c.adapter!=CarAdapterVersion||c.artifact!=c.payload||c.bank!=c.payload||c.id!=c.payload|| - !c.behaviour.empty()||c.behaviour_abi||payloads.at(c.payload)!=CarPayload||p==value.payloads.end()|| - p->source_label.empty()||p->source_label.find_first_of("/\\\\:")!=std::string::npos|| - p->source_label.size()<9||p->source_label.substr(p->source_label.size()-8)!=".dkrkart") - throw Error("This build cannot safely prepare the required custom car."); - ++cars;used.insert(c.payload);continue; - } -''' + anchor) - text = once(text, 'if(tracks>MaxTracks || characters>MaxCharacters || used.size()!=payloads.size())', 'if(tracks>MaxTracks || characters>MaxCharacters || cars>16 || used.size()!=payloads.size())') - text = once(text, 'tag!="xdelta" && tag!="tracklab"&&tag!="code"', 'tag!="xdelta" && tag!="tracklab"&&tag!="code"&&tag!="car"') - text = once(text, 'tag=="xdelta"?PayloadKind::Xdelta:', 'tag=="car"?CarPayload:tag=="xdelta"?PayloadKind::Xdelta:') - text = once(text, 'tag!="track" && tag!="character" && tag!="tracklab"', 'tag!="track" && tag!="character" && tag!="tracklab" && tag!="car"') - text = once(text, 'tag=="track"?Kind::Track:', 'tag=="car"?CarContent:tag=="track"?Kind::Track:') - return text - - -def profile_source(text): - text = '#include "vehicle_online.hpp"\n' + text - text = once(text, ' bundle.manifest=canonical(std::move(bundle.manifest));return bundle;', ''' auto cars=vehicles::Library(library.parent_path()/"vehicles").freeze(stop); - for(const auto& car:cars.packages) { - cancelled(stop);if(car->source.size()>MaxTransferBytes-total)throw Error("Custom cars exceed the online transfer budget."); - auto label=car->source_name;label.replace(label.size()-8,8,".dkrkart"); - bundle.manifest.payloads.push_back({CarPayload,car->digest,{},car->source.size(),label}); - bundle.manifest.content.push_back({CarContent,car->digest,car->name,car->digest,car->digest,car->digest,CarAdapterVersion}); - if(!bundle.payloads.emplace(car->digest,std::make_shared(car->source)).second)throw Error("A custom car payload is duplicated."); - total+=car->source.size(); - } - bundle.manifest=canonical(std::move(bundle.manifest));return bundle;''') - text = once(text, 'refs["code"].size()>128', 'refs["code"].size()>144') - text = once(text, ' if(found.size()!=expected.size())throw Error("Online profile is missing required host content.");', ''' // The existing bounded portable-blob map holds both executable code and - // data-only cars. Only Manifest::code can ever grant code execution. - for(const auto& c:manifest.content)if(c.kind==CarContent) { - const auto at=sources.code.find(c.payload);if(at==sources.code.end())throw Error("A required custom car is missing."); - check_storage(at->second,false);verified_car(manifest,c,read_file(at->second,vehicles::MaxPackageBytes)); - found.insert(std::make_pair(CarContent,c.id)); - } - if(found.size()!=expected.size())throw Error("Online profile is missing required host content.");''') - text = once(text, 'if(sources.code.size()!=manifest.code.size())', 'if(sources.code.size()!=manifest.code.size()+car_count(manifest))') - text = once(text, ' if(profile.manifest.content.empty())return;', ''' vehicles::Library cars(library.parent_path()/"vehicles"); - for(const auto& c:profile.manifest.content)if(c.kind==CarContent) { - cancelled(stop);const auto bytes=read_file(sources.code.at(c.payload),vehicles::MaxPackageBytes); - const auto package=verified_car(profile.manifest,c,bytes); - cars.import({package->source_name,bytes,vehicles::Type::Car},false); - } - if(profile.manifest.content.empty())return;''') - text = once(text, ' // Authored Track Lab packages also have a canonical wire representation.', ''' if(!input_library.empty())for(const auto& p:manifest.payloads)if(p.kind==CarPayload&&!cached_payload(payloads,p)) { - cancelled(stop); - try { - const auto file=input_library.parent_path()/"vehicles"/"packages"/(p.digest+".dkrkart"); - check_storage(file,false);const auto bytes=read_file(file,vehicles::MaxPackageBytes); - if(bytes.size()!=p.size||sha256(bytes)!=p.digest)continue; - try{write_new_file(payloads/p.digest,bytes);}catch(...){if(!cached_payload(payloads,p))throw;} - }catch(const std::exception&){cancelled(stop);} - } - // Authored Track Lab packages also have a canonical wire representation.''') - text = once(text, ' if(payload.kind==PayloadKind::Code) {', ''' if(payload.kind==CarPayload) { - if(!cached_payload(payloads,payload))throw Error("A custom car failed its SHA/size check."); - const auto wanted=std::find_if(out->manifest.content.begin(),out->manifest.content.end(),[&](const auto& c){return c.kind==CarContent&&c.payload==payload.digest;}); - if(wanted==out->manifest.content.end())throw Error("A custom car has no selected host identity."); - verified_car(out->manifest,*wanted,read_file(payloads/payload.digest,vehicles::MaxPackageBytes)); - sources.code.emplace(payload.digest,payloads/payload.digest);++index; - if(progress)progress("Verifying custom cars");continue; - } - if(payload.kind==PayloadKind::Code) {''') - return text - - -def online_runtime_source(text): - from music_pipeline import runtime_resources - text = runtime_resources(text) - text = '#include "vehicle_online.hpp"\n#include "vehicle_binding.hpp"\n' + text - text = once(text, ' auto prepared=out->new_session();', ''' std::vector> cars; - for(const auto& c:out->profile_->manifest.content)if(c.kind==CarContent) { - cancelled(stop);cars.push_back(verified_car(out->profile_->manifest,c,read_file(sources.code.at(c.payload),vehicles::MaxPackageBytes))); - } - if(!cars.empty()) { - auto resources=vehicles::prepare_resources(out->stock_,AssetBank::append_courses(boot,out->additions_),out->characters_,vehicles::freeze_packages(std::move(cars))); - vehicles::bind_resources(out,resources); - } - auto prepared=out->new_session();''') - text = once(text, ' if(out->code_)identity+="\\ncode:"+out->code_->identity;', ' if(out->code_)identity+="\\ncode:"+out->code_->identity;\n if(const auto cars=vehicles::resources_for(out.get()))identity+="\\ncars:"+cars->identity;') - text = once(text, ' if(out->code_)for(const auto& package:out->code_->ordered) {', ''' if(const auto cars=vehicles::resources_for(out.get())) { - for(const auto& car:cars->library.packages) { - cache_bytes+=car->source.size(); - for(const auto& lod:car->lods)for(const auto& mesh:lod.meshes) - cache_bytes+=mesh.vertices.size()*sizeof(vehicles::Vec3)+mesh.uv.size()*sizeof(vehicles::Vec2)+mesh.faces.size()*sizeof(std::array); - for(const auto& t:car->textures)cache_bytes+=t.image.rgba.size(); - } - } - if(out->code_)for(const auto& package:out->code_->ordered) {''') - text = once(text, ' if(prepared_world_)return prepared_world_->instantiate();', ''' if(prepared_world_) { - auto copy=prepared_world_->instantiate();vehicles::inherit_resources(prepared_world_.get(),copy);return copy; - } - const auto cars=vehicles::resources_for(this);''') - text = once(text, ' [additions,counts](std::shared_ptr bank) {', ' [additions,counts,cars](std::shared_ptr bank) {') - text = once(text, ' return AssetBank::append_courses(std::move(bank),*additions);', ' auto next=AssetBank::append_courses(std::move(bank),*additions);return cars?cars->apply(std::move(next)):next;') - text = once(text, ' for(const auto& track:tracks_)result->admit(track);', ' vehicles::bind_resources(result,cars);\n for(const auto& track:tracks_)result->admit(track);') - return text diff --git a/patches/custom-vehicles/package_playtest.py b/patches/custom-vehicles/package_playtest.py deleted file mode 100644 index c1feafb8..00000000 --- a/patches/custom-vehicles/package_playtest.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Allowlisted car playtest packages; never launches a game/helper/AppImage.""" -from pathlib import Path -import argparse -import importlib.util -import json -import re -import sys - -sys.dont_write_bytecode = True -AUTH = Path(__file__).resolve().parent -ROOT = AUTH.parents[1] -VERSION = '1.0.9-vehicle-playtest.13' - - -def baseline(): - path = ROOT / 'patches/release-prep/v1.0.7/package_release.py' - spec = importlib.util.spec_from_file_location('vehicle_allowlisted_packaging', path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - module.VERSION = VERSION - def notes(root, label): - module.copy(AUTH / 'PLAYTEST.txt', root / 'README.txt') - module.notes = notes - return module - - -def main(args): - module = baseline() - if args.output.exists(): raise ValueError('Choose a NEW playtest output directory') - args.output.mkdir(parents=True) - root, artifact, files = getattr(module, args.platform)(args) - binary = root / ('libexec/dkr-r/DKR-R-Runtime.exe' if args.platform == 'windows' else 'usr/bin/DKR-R') - data = binary.read_bytes() - for marker in (b'reviewed-character-ownership=v1', b'[custom-vehicles][picker]', b'outward-arrows=alpha-blended', b'[custom-vehicles][cpu-roster]', b'seat-anchor-units=studio-to-world', b'[custom-vehicles][native-menu]', b'Custom car metadata differs from the host offer.', b'Verifying custom cars', - b'[custom-vehicles][draw]', b'[online-mods][music-residency]', b'selected-music-v1', - b'opacity-source=primitive-not-fog', b'[online-mods][asset-addresses]', b'kart-unit-scale=', - b'steering-sign=-1', b'wheel-motion=arc-phase64', b'[custom-vehicles][wheel-interpolation]'): - if marker not in data: raise ValueError('Missing integrated car feature: ' + repr(marker)) - worker=root / ('libexec/dkr-r/DKR-R-ModWorker.exe' if args.platform == 'windows' else 'usr/libexec/dkr-r/DKR-R-ModWorker') - worker_data=worker.read_bytes() - # The worker validates/packages content; RuntimeResources and its music/ - # virtual-DMA bus run in the main runtime. Do not demand dead-stripped - # runtime-only strings (or a launcher-only version macro) from the worker. - for marker in (b'This build cannot safely prepare the required custom car.',): - if marker not in worker_data:raise ValueError('Online worker is stale or missing repair: '+repr(marker)) - contracts = json.loads((args.stage / 'vehicle-contracts.json').read_text()) - if set(contracts) != {'77', '80'} or any(not re.fullmatch('[a-f0-9]{64}', v) for v in contracts.values()): - raise ValueError('Incomplete portable rollback contract') - # Check the actual linked strings, not only a configure receipt. - for value in contracts.values(): - if value.encode() not in data: raise ValueError('The linked game has a different rollback contract') - guest_receipts={} - for target in ('DKRRecompiledFuncsV77','DKRRecompiledFuncsV80','DKRExperimentalOwnedGameV77','DKRExperimentalOwnedGameV80'): - swaps=json.loads((args.stage/target/'swaps.json').read_text()) - if not swaps:raise ValueError('Missing final guest vehicle hooks: '+target) - for swap in swaps: - if module.sha(Path(swap['old']))!=swap['input_sha256'] or module.sha(Path(swap['new']))!=swap['output_sha256']: - raise ValueError('A guest input/output changed after configuration') - guest_receipts[target]=swaps - native_receipt=json.loads((args.stage/'vehicle-receipt.json').read_text()) - for path,expected in native_receipt['inputs'].items(): - if module.sha(Path(path))!=expected:raise ValueError('A read-only source input changed') - for path,expected in native_receipt['outputs'].items(): - if module.sha(args.stage/path)!=expected:raise ValueError('A staged source output changed') - receipt = { - 'schema': 1, 'version': VERSION, 'platform': args.platform, 'arch': args.arch, - 'artifact': {'name': artifact.name, 'bytes': artifact.stat().st_size, 'sha256': module.sha(artifact)}, - 'files': files, 'rollback_build_ids': contracts, - 'vehicle_pipeline': native_receipt, 'guest_hooks':guest_receipts, - 'validation': 'Compilation/link and static package/source inspection only; no game, mod, test or AppImage execution.', - 'car_only': True, 'hovercrafts_supported': False, 'planes_supported': False, - 'automatic_updates_signed': False, 'published': False, - } - (args.output / 'BUILD-RECEIPT.json').write_text(json.dumps(receipt, indent=2) + '\n') - (args.output / 'SHA256SUMS.txt').write_text(module.sha(artifact) + ' ' + artifact.name + '\n') - print('Custom-car playtest packaged without target execution:', artifact) - - -if __name__ == '__main__': - p = argparse.ArgumentParser(description=__doc__) - p.add_argument('platform', choices=('windows', 'linux')) - for name in ('compiled', 'template', 'output', 'stage', 'trophy'): - p.add_argument('--' + name, type=Path, required=True) - p.add_argument('--arch', choices=('x86_64', 'aarch64'), default='x86_64') - p.add_argument('--runtime-image', type=Path) - main(p.parse_args()) diff --git a/patches/custom-vehicles/pipeline.py b/patches/custom-vehicles/pipeline.py deleted file mode 100644 index c61d31f2..00000000 --- a/patches/custom-vehicles/pipeline.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Stage authored vehicle adapters without changing source inputs or submodules.""" -from pathlib import Path -import argparse -import hashlib -import json -import tomllib - -AUTH = Path(__file__).resolve().parent -ROOT = AUTH.parents[1] - - -def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def put(path, data): - path.parent.mkdir(parents=True, exist_ok=True) - if not path.exists() or path.read_bytes() != data: - path.write_bytes(data) - - -def once(text, old, new): - if text.count(old) != 1: - raise ValueError('Vehicle integration anchor changed: ' + old[:120]) - return text.replace(old, new, 1) - - -def symbols(sdk): - fields = ('gMenuStage', 'gPlayerSelectVehicle', 'gPlayerSelectConfirm', - 'gNumberOfReadyPlayers', 'gNumberOfActivePlayers', 'gMenuDelay', - 'gMenuButtons', 'gMenuStickX', 'gMenuStickY', 'sMenuCurrDisplayList', - 'dDialogueBoxBegin', 'dDialogueBoxDrawModes', 'gIsInTracksMode', - 'gCurrentMenuId', 'gObjectCurrDisplayList', 'gObjectCurrMatrix', - 'gSceneCurrentPlayerID', 'gMenuImages', 'gTrackSelectWoodFrameHeightScale', - 'gTrackSelectPlayerImage', 'gMenuSelectionArrows', 'gRaceSelectionImages', 'gCurrentRNGSeed', - 'gCurrentPlayerIndex', 'gRacersByPort') - text = '#pragma once\n// Generated from the admitted, revision-specific SDK symbol tables.\nnamespace dkr::runtime::vehicles {\n' - for rev in (77, 80): - data = tomllib.loads((sdk / f'reference-us-v{rev}/data.toml').read_text(encoding='utf-8')) - found = {} - for section in data['section']: - for item in section.get('symbols', []): - if item['name'] in fields: - if item['name'] in found: raise ValueError('Duplicate vehicle symbol: ' + item['name']) - found[item['name']] = item['vram'] - if set(found) != set(fields): raise ValueError('Incomplete custom vehicle symbol table') - text += 'inline constexpr Symbols VehicleSymbols' + str(rev) + '{' + ','.join(f'0x{found[f]:08x}U' for f in fields) + '};\n' - return text + '}\n' - - -def launch_source(text): - text = once(text, '#include "legacy_mod_launch.hpp"', '#include "legacy_mod_launch.hpp"\n#include "vehicle_binding.hpp"') - text = once(text, ' const auto root=config/"mods"/"legacy";\n if(!TrackCatalog::has_enabled(root))return finish();', ''' const auto root=config/"mods"/"legacy"; - report("Preparing custom cars"); - auto cars=vehicles::Library(config/"mods"/"vehicles").freeze(stop); - if(!TrackCatalog::has_enabled(root)&&cars.packages.empty())return finish();''') - text = once(text, ' auto session=std::make_shared(stock,names,std::vector{},\n [artwork](std::shared_ptr bank){return AssetBank::append_artwork(std::move(bank),*artwork);});', ''' auto vehicle_resources=vehicles::prepare_resources(stock, - AssetBank::append_artwork(names?names->apply(stock):stock,*artwork),names,std::move(cars)); - auto session=std::make_shared(stock,names,std::vector{}, - [artwork,vehicle_resources](std::shared_ptr bank){ - return vehicle_resources->apply(AssetBank::append_artwork(std::move(bank),*artwork)); - }); - vehicles::bind_resources(session,vehicle_resources);''') - text = once(text, ' std::vector identities;', ' std::vector identities;\n if(!vehicle_resources->library.packages.empty())identities.push_back("vehicles:"+vehicle_resources->identity);') - return text - - -def runtime_source(text): - from music_pipeline import runtime_binding - text = runtime_binding(text) - text = once(text, '#include "runtime_legacy_mods.hpp"', '#include "runtime_legacy_mods.hpp"\n#include "vehicle_binding.hpp"\n#include "vehicle_world.hpp"\n#include ') - text = once(text, 'struct WorldState {', 'struct WorldState {\n std::shared_ptr vehicles;') - ai_entry='extern "C" void dkr_legacy_character_ai_event(std::uint8_t* rdram,recomp_context* ctx,unsigned event,dkr::mods::OriginalPiHandler random) {\n using namespace dkr::runtime;' - text = once(text,ai_entry,ai_entry+''' - // Cars have their own primary-racer lifecycle. Do not gate this on a - // character selector, the custom-AI preference, or an offline-only path. - try { - if(const auto cars=legacy::current_world().vehicles) - cars->race({rdram,legacy::guest_memory_size()},*ctx,event); - }catch(const ultramodern::thread_terminated&){throw;} - catch(const std::exception& error){legacy::fail(error.what());} -''') - text = once(text, ' ordinary_music_enabled=false;ordinary_world.music.store(nullptr);', ''' ordinary_music_enabled=false;ordinary_world.music.store(nullptr); - const auto assets=session.load();const auto cars=mods::vehicles::resources_for(assets.get()); - ordinary_world.vehicles=cars&&!cars->library.packages.empty()?std::make_shared(cars,assets->boot_bank()->revision()=="us.v80"):nullptr; - mods::vehicles::publish_render_resources(cars);''') - text = once(text, ' auto state=legacy::characters.load();if(!state)return;\n try {', ''' if(event>=200) { - try { - const auto cars=legacy::current_world().vehicles; - if(event==200)ctx->r2=cars?cars->render_begin({rdram,legacy::guest_memory_size()},roster_address):0; - else if(event==201){if(cars)cars->render_end({rdram,legacy::guest_memory_size()},roster_address,unsigned(ctx->r8));} - else if(event==202||event==203){if(cars)cars->wheel_scope({rdram,legacy::guest_memory_size()},roster_address,event==202);} - else if(event==204){if(cars)cars->advance_wheels({rdram,legacy::guest_memory_size()},roster_address,std::bit_cast(std::uint32_t(ctx->r6)));} - else throw dkr::mods::Error("Unknown custom vehicle render event."); - return; - }catch(const ultramodern::thread_terminated&){throw;} - catch(const std::exception& error){legacy::fail(error.what());} - } - auto state=legacy::characters.load();if(!state)return; - try {''') - anchor = 'extern "C" int dkr_legacy_track_menu(std::uint8_t* rdram,recomp_context* ctx,unsigned event,const std::uint32_t* fields,unsigned observed) {' - text = once(text, anchor, '''extern "C" void dkr_vehicle_menu_draw(std::uint8_t* rdram,recomp_context* ctx, - dkr::runtime::RecompiledEntrypoint frame,dkr::runtime::RecompiledEntrypoint text_width) { - using namespace dkr::runtime; - try { - const auto cars=legacy::current_world().vehicles;const auto calls=legacy::guest_payload(); - if(cars&&calls)cars->draw({rdram,legacy::guest_memory_size()},*ctx,*calls,frame,text_width); - }catch(const ultramodern::thread_terminated&){throw;} - catch(const std::exception& error){legacy::fail(error.what());} -} - -''' + anchor + ''' - if(event>=100) { - using namespace dkr::runtime; - try { - const auto cars=legacy::current_world().vehicles;if(!cars)return 0; - const auto calls=legacy::guest_payload();if(!calls)throw dkr::mods::Error("Custom vehicles have no admitted native menu callbacks."); - const auto result=cars->menu({rdram,legacy::guest_memory_size()},*ctx,event,*calls,[&](unsigned player) { - if(const auto state=legacy::characters.load()) { - std::lock_guard lock(state->mutex); - if(const auto entry=state->roster.entry(player))return entry->headers[0]; - } - dkr::mods::CharacterMenuFields addresses;addresses.fill(0x80000000U); - dkr::mods::CharacterMenuMemory g({rdram,legacy::guest_memory_size()},addresses); - const auto table=legacy::session.load()->boot_bank()->revision()=="us.v80"?0x80126990U:0x801263f0U; - constexpr unsigned headers[]{2,3,4,5,6,7,8,9,1,0};const auto base=g.read(table+player,1); - if(base>=10)throw dkr::mods::Error("Invalid selected native driver.");return headers[base]; - }); - return result?1:0; - }catch(const ultramodern::thread_terminated&){throw;} - catch(const std::exception& error){legacy::fail(error.what());} - } -''') - text = once(text, ' state_->session.store(assets);state_->private_owner=true;state_->calls=calls;', ''' state_->session.store(assets);state_->private_owner=true;state_->calls=calls; - if(const auto cars=mods::vehicles::resources_for(assets.get());cars&&!cars->library.packages.empty()) { - state_->vehicles=std::make_shared(cars,assets->boot_bank()->revision()=="us.v80"); - mods::vehicles::publish_render_resources(cars); - }''') - # Extend the native sidecar transaction, not the guest save/ROM format. - text = once(text, ' std::shared_ptr music;\n};', ' std::shared_ptr music;\n std::shared_ptr vehicles;\n};') - text = once(text, 'mods::CheckpointWriter out;out.u32(4);out.block(state.session.load()->checkpoint());', 'mods::CheckpointWriter out;out.u32(5);out.block(state.session.load()->checkpoint());\n out.flag(bool(state.vehicles));if(state.vehicles)out.block(state.vehicles->checkpoint());') - text = once(text, 'mods::CheckpointReader in(bytes);if(in.u32()!=4)', 'mods::CheckpointReader in(bytes);if(in.u32()!=5)') - text = once(text, 'out->session=assets->stage_checkpoint(in.block());', 'out->session=assets->stage_checkpoint(in.block());\n if(in.flag()!=bool(state_->vehicles))throw mods::Error("Owned vehicle catalogue differs.");\n if(state_->vehicles)out->vehicles=state_->vehicles->stage_checkpoint(in.block());') - text = once(text, ' state_->pending_course_heap=staged->pending_course_heap;', ' state_->pending_course_heap=staged->pending_course_heap;\n state_->vehicles=std::move(staged->vehicles);') - return text - - -def bridge_source(text): - text = once(text, '#include "f3ddkr_rt64.hpp"', '#include "f3ddkr_rt64.hpp"\n#include "vehicle_rt64.hpp"') - text = once(text, ' camera::RenderPass* camera=nullptr;', ' camera::RenderPass* camera=nullptr;\n vehicles::RenderPass* cars=nullptr;') - text = once(text, ' active_ = this;\n StateData fresh{};', ''' const auto car_resources=mods::vehicles::render_resources(); - const bool cars_requested=car_resources&&!car_resources->library.packages.empty(); - if(cars_requested)kRDRAMSize=0x1000000U; - vehicles::RenderPass car_pass(*application.state,car_resources,[](RT64::RSP& rsp,std::uint32_t id,bool vertices){ - SelectInterpolationGroup(rsp,id,vertices,false,false,G_EX_ASPECT_AUTO,false,0U,true); - }); - active_ = this; - StateData fresh{}; - fresh.cars=&car_pass;''') - text = once(text, ' const Handler handler = active_->original_handlers_[opcode];', ''' if((command->w0&0xffff0000U)==0xb0ca0000U) { - if(!active_->data_->cars||!active_->data_->cars->marker(command->w0&0xffffU,command->w1))RejectTask(display_list); - return; - } - const Handler handler = active_->original_handlers_[opcode];''') - text = once(text, '#endif\n}\n\nvoid dkr::runtime::F3DDKRRT64Bridge::FillRect(', '#endif\n if(data.cars)data.cars->matrix(index);\n}\n\nvoid dkr::runtime::F3DDKRRT64Bridge::FillRect(') - at = text.index('void dkr::runtime::F3DDKRRT64Bridge::Triangle(') - text = text[:at] + once(text[at:], ' rsp.drawIndexedTri(vertices[0], vertices[1], vertices[2]);', ' if(!data.cars||!data.cars->suppress(address))rsp.drawIndexedTri(vertices[0], vertices[1], vertices[2]);') - return text - - -def renderer_source(text): - text = '#include "vehicle_binding.hpp"\n' + text - text = once(text, ' const bool camera_requested=bool(camera::activation());\n if(camera_requested) {', ''' const bool camera_requested=bool(camera::activation()); - const auto car_resources=mods::vehicles::render_resources(); - const bool cars_requested=car_resources&&!car_resources->library.packages.empty(); - if(camera_requested||cars_requested) {''') - return text - - -def rt64_state_source(text): - text = once(text,'#include "local_scenery_texture_scope.hpp"','#include "vehicle_texture_scope.hpp"\n#include "vehicle_wheel_interpolation.hpp"') - return once(text, ' workload.workloadId = ++workloadId;', - ' dkr::runtime::vehicles::wheel_interpolation::seal(workload,workloadId+1);\n workload.workloadId = ++workloadId;') - - -def rt64_transform_source(text): - text = once(text, '#include "rt64_transform_processor.h"', - '#include "rt64_transform_processor.h"\n#include "vehicle_wheel_interpolation.hpp"') - text = once(text, ' const DrawData &prevDrawData = p.workloadQueue->workloads[workloadMap.prevWorkloadIndex].drawData;', ''' const DrawData &prevDrawData = p.workloadQueue->workloads[workloadMap.prevWorkloadIndex].drawData; - const auto currentWheels=dkr::runtime::vehicles::wheel_interpolation::capture(workload); - const auto previousWheels=dkr::runtime::vehicles::wheel_interpolation::capture(p.workloadQueue->workloads[workloadMap.prevWorkloadIndex]);''') - return once(text, ' curMatrix = transformMap.rigidBody.lerp(p.curFrameWeight, prevTransform, curTransform, true);', ''' curMatrix = transformMap.rigidBody.lerp(p.curFrameWeight, prevTransform, curTransform, true); - if(currentWheels&&dkr::runtime::vehicles::wheel_interpolation::find(*currentWheels,t)) { - if(!dkr::runtime::vehicles::wheel_interpolation::interpolate(previousWheels,currentWheels, - transformMap.prevTransformIndex,t,p.prevFrameWeight,p.curFrameWeight, - prevTransform,curTransform,prevMatrix,curMatrix)) { - prevMatrix=curTransform;curMatrix=curTransform; - } - }''') - - -def asset_bus_source(text): - text = '#include \n#include \n' + text - start = text.index('std::shared_ptr AssetBus::mount(') - end = text.index('std::optional AssetBus::resolve(', start) - return text[:start] + (AUTH / 'native/asset_bus_mount.inl').read_text() + '\n' + text[end:] - - -def stage(args): - from online_pipeline import manifest_source, profile_source, online_runtime_source - from music_pipeline import driver - out = args.output.resolve() - for protected in ('extern', 'runtime-recomp/RecompiledFuncs', 'runtime-recomp/RecompiledPatches', 'patches'): - if out.is_relative_to(ROOT / protected): - raise ValueError('The vehicle stage must be a separate build directory') - authored = sorted(p for p in (AUTH / 'native').rglob('*') if p.is_file()) - receipt = {'schema': 1, 'adapter': 1, 'scope': 'custom-car-playtest', 'inputs': {}, - 'authored': {p.relative_to(AUTH).as_posix(): digest(p) for p in authored}} - for path in authored: - put(out / path.relative_to(AUTH), path.read_bytes()) - if args.sdk: - for rev in (77, 80): - path = args.sdk / f'reference-us-v{rev}/data.toml' - receipt['inputs'][str(path.resolve())] = digest(path) - put(out / 'native/vehicle_symbols.hpp', symbols(args.sdk).encode()) - receipt['outputs'] = {'native/vehicle_symbols.hpp': digest(out / 'native/vehicle_symbols.hpp')} - transforms = (('launch',launch_source),('runtime',runtime_source),('bridge',bridge_source),('renderer',renderer_source), - ('manifest',manifest_source),('profile',profile_source),('online_runtime',online_runtime_source),('rt64_state',rt64_state_source),('rt64_transform',rt64_transform_source),('driver',driver),('asset_bus',asset_bus_source)) - for name, transform in transforms: - path = getattr(args, name) - if path: - path = path.resolve(strict=True) - if out.is_relative_to(path.parent) or path.is_relative_to(out): raise ValueError('Native input overlaps the vehicle stage') - receipt['inputs'][str(path)] = digest(path) - put(out / ('native/' + path.name), transform(path.read_text(encoding='utf-8')).encode()) - if args.ui: - ui = args.ui.resolve(strict=True) - library = args.library.resolve(strict=True) - store = ui.parent / 'mod_store_ui.inl' - online_ui = library.parent / 'runtime_online_mods_ui.inl' - texture_scope = ROOT / 'runtime-recomp/src/game/local_scenery_texture_scope.hpp' - for p in (ui, library, store, online_ui, texture_scope): - if out.is_relative_to(p.parent) or p.is_relative_to(out): - raise ValueError('Read-only source input overlaps the vehicle stage') - receipt['inputs'][str(p)] = digest(p) - value = ui.read_text(encoding='utf-8') - value = once(value, '#include "mods/legacy_mod_launch.hpp"', '#include "mods/legacy_mod_launch.hpp"\n#include "vehicle_library.hpp"\n#include "vehicle_protocol.hpp"') - value = once(value, 'void ImportMagicCodeWithDialog() {', 'void ImportVehicleWithDialog();\nvoid ReleaseVehicleTextures();\nvoid ImportMagicCodeWithDialog() {') - value = once(value, '#include "mod_store_ui.inl"', '#include "mod_store_ui.inl"\n#include "vehicle_ui.inl"') - value = once(value, 'g_code_library.view()->items.size(),width);', 'g_code_library.view()->items.size(),g_vehicle_library.view()->items.size(),width);') - value = once(value, ' g_code_library.refresh();', ' g_code_library.refresh();\n g_vehicle_library.job();') - value = once(value, ' if(g_mods_page.category==2){DrawCustomMagicCodeLibrary(width,locked);return;}', ' if(g_mods_page.category==3){DrawVehicleLibrary(width,locked);return;}\n if(g_mods_page.category==2){DrawCustomMagicCodeLibrary(width,locked);return;}') - value = once(value, ' go_to_play, [] { ImportMagicCodeWithDialog(); }});', ' go_to_play, [] { ImportMagicCodeWithDialog(); }, [] { ImportVehicleWithDialog(); }});') - value = once(value, ' DrawCustomMagicCodeDetails(mods_locked || picking);', ' DrawCustomMagicCodeDetails(mods_locked || picking);\n DrawVehicleDetails(mods_locked || picking);') - value = once(value, ' g_code_library.configure(config_directory / "mods" / "custom-magic-codes",DKR_RELEASE_VERSION);', ' g_code_library.configure(config_directory / "mods" / "custom-magic-codes",DKR_RELEASE_VERSION);\n g_vehicle_library.configure(config_directory / "mods" / "vehicles");') - value = once(value, ' const bool locked = mods_locked || mods.busy;\n const std::size_t track_count', ' const bool locked = mods_locked || mods.busy || g_vehicle_library.view()->busy;\n const std::size_t track_count') - value = once(value, ' const bool mods_locked = game_running || lobby || g_mod_launch.snapshot().modal || OnlineModSelectionLocked();', ' const bool mods_locked = game_running || lobby || g_mod_launch.snapshot().modal || OnlineModSelectionLocked() || g_vehicle_library.view()->busy;') - value = once(value, '"Your tracks, racers and race modifiers."', '"Your tracks, racers, vehicles and race modifiers."') - put(out / 'native/runtime_ui.cpp', value.encode()) - value = library.read_text(encoding='utf-8') - value = once(value,'struct LibraryCardView {','struct LibraryCardView {\n float preview_height=0;\n std::function preview;') - value = once(value,' if (view.prepare) height += 42.0F;', ' if (view.prepare) height += 42.0F;\n if(view.preview)height+=view.preview_height+12.F;') - value = once(value,' bool details = false;\n if (view.prepare)', ''' if(view.preview) { - ImGui::SetCursorScreenPos({left,y});view.preview(inner,view.preview_height);y+=view.preview_height+12.F; - } - bool details = false; - if (view.prepare)''') - value = once(value, ' std::function choose_code;', ' std::function choose_code;\n std::function choose_vehicle;') - anchor = ' PaddockGap(18.0F);\n if (PaddockButton("Cancel##import-chooser", PaddockButtonKind::Link))' - option = ''' PaddockGap(18.0F); - DrawModImportOption("vehicle",PaddockFormat::Dkr,"Custom vehicles", - "Import a .dkrkart car or a vehicle ZIP. Choose your car from Track Select.",width,[&] { - ImGui::BeginDisabled(actions.locked); - if(PaddockButton("Choose car package")) { - ImGui::CloseCurrentPopup();if(actions.choose_vehicle)actions.choose_vehicle(); - } - ImGui::EndDisabled(); - }); -''' - value = once(value, anchor, option + anchor) - value = once(value, 'std::size_t codes,float available)', 'std::size_t codes,std::size_t vehicles,float available)') - value = once(value, 'const std::array labels{{"Tracks", "Characters", "Custom Magic Codes"}};', 'const std::array labels{{"Tracks", "Characters", "Custom Magic Codes", "Vehicles"}};') - value = once(value, 'const std::array counts{{std::to_string(tracks), std::to_string(characters),std::to_string(codes)}};', 'const std::array counts{{std::to_string(tracks), std::to_string(characters),std::to_string(codes),std::to_string(vehicles)}};') - # Only this function's fixed-size category arrays and loops expand. - start = value.index('int DrawLibraryCategories(') - prefix, categories = value[:start], value[start:] - categories = once(categories, 'std::array widths{};', 'std::array widths{};') - categories = once(categories, 'std::array positions{};', 'std::array positions{};') - categories = once(categories, 'for(unsigned i=0;i<3;++i)', 'for(unsigned i=0;i<4;++i)') - categories = once(categories, 'for (int index = 0; index < 3; ++index)', 'for (int index = 0; index < 4; ++index)') - value = prefix + categories - value = once(value, ' if (view.code) {\n if(code_toggle)code_toggle(enabled);', ' if (code_toggle) {\n code_toggle(enabled);') - put(out / 'native/runtime_mod_library_ui.inl', value.encode()) - value = store.read_text(encoding='utf-8') - value = once(value, 'void ReleaseModStoreTextures() {', 'void ReleaseModStoreTextures() {\n ReleaseVehicleTextures();') - put(out / 'native/mod_store_ui.inl', value.encode()) - value = online_ui.read_text(encoding='utf-8') - value = once(value,'if(!profile->manifest.code.empty()&&host_mode &&','if((!profile->manifest.code.empty()||dkr::mods::online::car_count(profile->manifest))&&host_mode &&') - value = once(value,'Custom Magic Codes need Auto or Experimental Rollback. Other online modes still support tracks and characters.','Custom cars and Magic Codes need Auto or Experimental Rollback.') - value = once(value,'mod.kind==Kind::Character?"Character":mod.kind==Kind::TrackLab?"Track Lab":"Track"','mod.kind==dkr::mods::online::CarContent?"Car":mod.kind==Kind::Character?"Character":mod.kind==Kind::TrackLab?"Track Lab":"Track"') - put(out / 'native/runtime_online_mods_ui.inl',value.encode()) - value = texture_scope.read_text(encoding='utf-8') - value = once(value,'address>=0x00D00000U&&address<0x00E00000U','address>=0x00D00000U&&address<0x00F00000U') - value = once(value,'if(!enabled_)return;', - 'if(!enabled_&&!active_&&!(address>=0x00E00000U&&address<0x00F00000U))return;') - put(out / 'native/vehicle_texture_scope.hpp',value.encode()) - receipt.setdefault('outputs', {}).update({p.relative_to(AUTH).as_posix(): digest(out / p.relative_to(AUTH)) for p in authored}) - for name, transform in transforms: - path = getattr(args, name) - if path: receipt['outputs']['native/' + path.name] = digest(out / ('native/' + path.name)) - for name in ('native/runtime_ui.cpp', 'native/runtime_mod_library_ui.inl', 'native/mod_store_ui.inl', 'native/runtime_online_mods_ui.inl','native/vehicle_texture_scope.hpp'): - if args.ui: receipt['outputs'][name] = digest(out / name) - for p, expected in receipt['inputs'].items(): - if digest(Path(p)) != expected: - raise ValueError('Read-only input changed while composing vehicles') - put(out / 'vehicle-receipt.json', (json.dumps(receipt, indent=2) + '\n').encode()) - print('Custom car adapters staged; no game, mod or tests executed.') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--ui', type=Path) - parser.add_argument('--library', type=Path) - parser.add_argument('--sdk', type=Path) - parser.add_argument('--launch', type=Path) - parser.add_argument('--runtime', type=Path) - parser.add_argument('--bridge', type=Path) - parser.add_argument('--renderer', type=Path) - parser.add_argument('--manifest', type=Path) - parser.add_argument('--profile', type=Path) - parser.add_argument('--online-runtime', type=Path) - parser.add_argument('--rt64-state', type=Path) - parser.add_argument('--rt64-transform', type=Path) - parser.add_argument('--driver', type=Path) - parser.add_argument('--asset-bus', type=Path) - stage(parser.parse_args()) diff --git a/patches/detail-records-repair/PLAYTEST.txt b/patches/detail-records-repair/PLAYTEST.txt deleted file mode 100644 index 98d66d77..00000000 --- a/patches/detail-records-repair/PLAYTEST.txt +++ /dev/null @@ -1,56 +0,0 @@ -DKR-R v1.0.9 - Detail Playtest 2 - -This replaces Detail Playtest 1. Please use a fresh extracted folder and make -sure everyone joining the online lobby is using this build. - -What changed - -The custom-course records bridge no longer tries to run a guest callback after -the rollback guest has finished executing. It reads the checked settings and -record tables from the admitted owner's memory instead. Both supported US ROM -revisions are covered. Initial record restoration validates every destination -before writing, and only touches added-course slots. Normal save files and the -existing confirmed-only record persistence remain in place. Any remaining -capture failure should now include its actual reason. - -Local 2p/3p/4p now uses the original, fast model selector to keep the highest -available character LOD selected when Maximum Vehicle Detail is on. It no -longer runs a second model-building path for local split-screen. This covers -CPU racers too. The highest models remain loaded by the shared residency policy. -Custom cars still use their independent highest-vehicle-LOD setting. - -Online retains renderer-only detail selection, so the host does not force -everyone else's graphics settings. This path now uses indexed observation and -replacement lookups instead of scanning every observation for every command. -Vertex transforms are uploaded in batches, with the original material order, -head/body matrices, driver/chassis masks and transparent passes preserved. -Validated texture/material layouts are reused between frames only while all -their source bytes still match. Animated pose packets are rebuilt when needed; -they are not blindly reused across different snapshots. - -The setting is still a Modern-profile setting. Turning it off retains ordinary -distance-based LODs. Accurate mode and ghost handling are unchanged. This does -not change view distance, physics, collision, game clocks or mod behaviour. - -What to check - -[ ] Online match starts with the same modern custom tracks and Magic Codes. -[ ] Existing custom-course records remain intact and new records can be saved. -[ ] 2p, 3p and 4p: Maximum Vehicle Detail on, then compare it off. -[ ] Watch CPU and human racers move between near/far cameras in EVERY view. -[ ] Repeat with stock characters, custom characters and custom cars. -[ ] Compare against your previous 20 FPS result on the same track/settings. -[ ] Online: one player on maximum detail, another with it off. -[ ] Check ordinary split-screen and online full-screen view, including finish. -[ ] Check wheel motion, driver placement, translucent parts and HD textures. - -If anything is wrong, close DKR-R normally and send runtime.log. Look for -[graphics][all-player-detail] to see the actual selected native LOD, and -[graphics][detail-cost] for renderer preparation/submission timings and batch -counts. The timing messages are bounded and do not run a benchmark themselves. - -Windows x64 and Linux x86_64 AppImage. Compilation and static inspections are -separate from your gameplay testing: I have not launched the game, mods, test -executables or AppImage. The repairs are implemented, but visual quality and -actual frame-rate recovery still need your approval. No ROMs, saves or personal -settings ship in these packages. This is not a signed automatic-update release. diff --git a/patches/detail-records-repair/ReleaseOverlay.cmake b/patches/detail-records-repair/ReleaseOverlay.cmake deleted file mode 100644 index c4bc88d2..00000000 --- a/patches/detail-records-repair/ReleaseOverlay.cmake +++ /dev/null @@ -1,77 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../all-player-detail/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_detail_records_repair) - set(auth "${DKRPORT_ROOT}/patches/detail-records-repair") - set(stage "${CMAKE_BINARY_DIR}/generated/detail-records-repair") - file(MAKE_DIRECTORY "${stage}") - set(rows "[") - set(separator "") - foreach(name runtime_enhancements.cpp presentation_identity.cpp local_model_rt64.cpp runtime_legacy_mods.cpp) - get_target_property(paths DKRPortGame SOURCES) - list(FILTER paths INCLUDE REGEX "[/\\]${name}$") - list(LENGTH paths count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Missing final repair source: ${name}") - endif() - list(GET paths 0 path) - file(TO_CMAKE_PATH "${path}" path) - string(APPEND rows "${separator}{\"target\":\"DKRPortGame\",\"path\":\"${path}\"}") - set(separator ",") - endforeach() - string(APPEND rows "]") - file(WRITE "${stage}/inputs.json" "${rows}") - file(GLOB authored CONFIGURE_DEPENDS "${auth}/native/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${authored} "${auth}/pipeline.py") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" --inputs "${stage}/inputs.json" --output "${stage}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Records/detail repair staging failed") - endif() - file(READ "${stage}/swaps.json" swaps) - string(JSON count LENGTH "${swaps}") - math(EXPR last "${count}-1") - get_target_property(directory DKRPortGame SOURCE_DIR) - foreach(index RANGE ${last}) - string(JSON old GET "${swaps}" ${index} path) - string(JSON next GET "${swaps}" ${index} new) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage};${prior};${prior}/..;${includes}") - dkr_repair_replace_source(DKRPortGame "${old}" "${next}") - endforeach() - # Both peers receive the same save-observer policy. Local quality is still - # absent from the canonical owned simulation. - set(ledger "checked-record-observer-v2\n") - foreach(name pipeline.py ReleaseOverlay.cmake native/records_guest.inl) - file(SHA256 "${auth}/${name}" hash) - string(APPEND ledger "${name}:${hash}\n") - endforeach() - get_target_property(definitions DKRExperimentalOwnedRegistry COMPILE_DEFINITIONS) - set(updated "") - set(contracts "{") - set(separator "") - foreach(value IN LISTS definitions) - if(value MATCHES "^DKR_OWNED_BUILD_ID_V(77|80)=\"([a-f0-9]+)\"$") - set(revision "${CMAKE_MATCH_1}") - string(SHA256 hash "${CMAKE_MATCH_2}\n${ledger}") - set(value "DKR_OWNED_BUILD_ID_V${revision}=\"${hash}\"") - string(APPEND contracts "${separator}\"${revision}\":\"${hash}\"") - set(separator ",") - endif() - list(APPEND updated "${value}") - endforeach() - string(APPEND contracts "}") - file(WRITE "${stage}/contracts.json" "${contracts}") - set_property(TARGET DKRExperimentalOwnedRegistry PROPERTY COMPILE_DEFINITIONS "${updated}") - if(DKR_RELEASE_EVIDENCE) - file(WRITE "${DKR_RELEASE_EVIDENCE}/DKRExperimentalOwnedRegistry-COMPILE_DEFINITIONS.txt" "${updated}\n") - endif() - endfunction() - cmake_language(DEFER CALL dkr_apply_detail_records_repair) -endif() diff --git a/patches/detail-records-repair/audit.py b/patches/detail-records-repair/audit.py deleted file mode 100644 index 0bd72389..00000000 --- a/patches/detail-records-repair/audit.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Read-only source/linked-byte inspection for the records and LOD repair.""" -from pathlib import Path -import argparse -import hashlib -import importlib.util -import json -import re -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-detail-playtest.2' -def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest() -def require(ok,why): - if not ok:raise ValueError(why) -def load(name,path): - spec=importlib.util.spec_from_file_location(name,path);module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module -def inspect(stage,binary=None): - upstream=load('retained_all_player_audit',ROOT/'patches/all-player-detail/audit.py').inspect(stage.parent/'all-player-detail') - rows=json.loads((stage/'swaps.json').read_text()) - require({Path(r['new']).name for r in rows}=={'runtime_enhancements.cpp','presentation_identity.cpp','local_model_rt64.cpp','runtime_legacy_mods.cpp'} and len(rows)==4,'Final repair target coverage') - for row in rows: - require(sha(Path(row['path']))==row['input_sha256'],'Repair input changed: '+row['path']) - require(sha(Path(row['new']))==row['output_sha256'],'Repair output changed: '+row['new']) - require(not Path(row['new']).resolve().is_relative_to(ROOT),'Repair staged inside checkout') - for p in (AUTH/'native').iterdir():require(sha(p)==sha(stage/p.name),'Native repair not staged: '+p.name) - records=(stage/'records_guest.inl').read_text() - for marker in ('0x80123510U','0x80123A90U','assets->boot_bank()->revision()','course.level_id<65||course.level_id>=128','valid(table,128*2,2)','if(!restore||!valid(p,2,2))','if(!good){if(restore)return {};continue;}','rdram+((p-0x80000000U)^2U)'): - require(marker in records,'Missing records ownership/layout guard: '+marker) - require('payload->get_settings' not in records and 'dkr_probe_run' not in records,'Records observer still executes guest code') - require(records.index('for(const auto& row:rows)')>records.index('if(!good){if(restore)return {};continue;}'),'Record restoration writes before complete destination validation') - runtime=(stage/'runtime_legacy_mods.cpp').read_text() - require('#include "records_guest.inl"' in runtime and 'world.error().empty()' in runtime,'Underlying record error hidden or wrong observer included') - policy=(stage/'runtime_enhancements.cpp').read_text() - policy=policy[policy.index('extern "C" void dkr_apply_maximum_racer_detail('):policy.index('extern "C" void dkr_restore_multiplayer_race_music(')] - require('if(dkr::runtime::netplay::session().runtime_view().active)return;' in policy,'Online selector not canonical') - require('ViewportLayout' not in policy and 'context->r8 = context->r4' in policy and 'kTimeTrialGhostBehaviour' in policy,'Local view LOD restriction still active or highest selector/ghost protection lost') - observer=(stage/'detail_observer.inl').read_text() - require('||!netplay::session().runtime_view().active)return;' in observer,'Local multiplayer still records parallel model work') - identity=(stage/'presentation_identity.cpp').read_text() - require('#include "detail_observer.inl"' in identity,'Final identity TU not bound to repaired observer') - model=(stage/'local_model_rt64.cpp').read_text() - for marker in ('std::lower_bound(p.observations.begin()','std::upper_bound(p.suppressed.begin()','if(!o.extra)p.suppress(','#include "detail_batch.inl"','detail::MicroScope prepare_cost','detail::MicroScope submit_cost','retain_texture(r,p)','p.texture_scene!=scene','std::erase_if(p.observations'): - require(marker in model,'Missing hot-path repair: '+marker) - require('for(auto& o:p.observations)' not in model[model.index('void LocalModelRT64Pass::draw_at'):],'Per-command linear scan remains') - batch=(stage/'detail_batch.inl').read_text() - for marker in ('std::array batch','batch_flags!=step.w0||batch_texture!=step.w1','batch[end].matrix==batch[first].matrix','std::uint8_t(end-first),std::uint8_t(first)','submit_batch();command','driver_mask->hide[step.source_triangle]'): - require(marker in batch,'Missing batch boundary/mask: '+marker) - require('rsp.setVertex(kVertices+c*sizeof' not in model,'Per-corner transform uploads remain') - cache=(stage/'detail_texture_cache.inl').read_text() - for marker in ('std::memcmp(r.ram.data()+input.physical','dependency(header,native_bytes)','dependency(commands,count*8U)','dependency(r.word(commands+c*8+4)&0xFFFFFFU,end-begin)','entry.checked!=texture_frame','retained_textures.size()<256','4U*1024U*1024U-retained_texture_bytes'): - require(marker in cache,'Missing material cache invalidation/bound: '+marker) - contracts=json.loads((stage/'contracts.json').read_text()) - require(set(contracts)=={'77','80'} and all(re.fullmatch('[a-f0-9]{64}',v) for v in contracts.values()),'Missing repaired peer contracts') - if binary: - data=binary.read_bytes() - # GCC can prove the fixed-address word reads are in bounds and remove that - # unreachable error string. Inspect guards above; use reachable service - # diagnostics as the platform-independent linked-byte evidence instead. - for marker in (VERSION.encode(),b'local-all-views-v2',b'batched-detail-v2',b'Could not capture custom-course records from the owned guest: ',b'Custom records have an unsupported owned asset revision.',b'material cache input escapes source snapshot',b'collection=94 local-only v1',b'complete-family selection',b'custom vehicle submitted'): - require(marker in data,'Missing linked repair/retained feature: '+repr(marker)) - return {'schema':1,'version':VERSION,'swaps':rows,'peer_contracts':contracts,'retained_all_player_audit':upstream, - 'binary_sha256':sha(binary) if binary else None,'validation':'Compile/link and static package/source checks only. No game, mod, test executable or AppImage run. Owner gameplay/performance acceptance pending.'} -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--stage',type=Path,required=True);p.add_argument('--binary',type=Path);p.add_argument('--output',type=Path);a=p.parse_args();report=inspect(a.stage,a.binary) - if a.output:a.output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') - else:print(json.dumps(report,indent=2)) diff --git a/patches/detail-records-repair/configure.py b/patches/detail-records-repair/configure.py deleted file mode 100644 index 430f13f6..00000000 --- a/patches/detail-records-repair/configure.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Compose the records/LOD repair over the admitted all-player pipeline.""" -from pathlib import Path -import argparse -import importlib.util -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-detail-playtest.2' -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','capsule','log'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--cmake',default='cmake');a=p.parse_args() - spec=importlib.util.spec_from_file_location('repair_release',ROOT/'patches/release-prep/v1.0.7/release.py');release=importlib.util.module_from_spec(spec);spec.loader.exec_module(release) - release.AUTH=AUTH;release.VERSION=VERSION;release.configure(a.build,a.log,a.cmake,a.capsule) diff --git a/patches/detail-records-repair/native/detail_batch.inl b/patches/detail-records-repair/native/detail_batch.inl deleted file mode 100644 index 262376ba..00000000 --- a/patches/detail-records-repair/native/detail_batch.inl +++ /dev/null @@ -1,34 +0,0 @@ -// Included inside the checked model submission scope. At most 30 vertices fit -// the original 32-slot RSP cache; commands, culling and texture boundaries are -// never crossed. Mixed driver/head matrices are uploaded as separate runs. -std::array batch; -unsigned corners=0,batch_flags=0,batch_texture=0; -const auto submit_batch=[&] { - if(!corners)return; - rsp.textureState.on=std::uint8_t(batch_texture); - rsp.clearGeometryMode(rsp.cullBothMask); - if(!(batch_flags&0x40U))rsp.setGeometryMode(rsp.viewportStack[rsp.viewportStackSize-1].scale.x>0 ? - (rsp.cullBothMask&~rsp.cullFrontMask):rsp.cullFrontMask); - for(unsigned v=0;vsteps) { - if(step.kind==Step::Command){submit_batch();command(state,step.w0,step.w1);continue;} - if(driver_mask&&driver_mask->hide[step.source_triangle])continue; - if(corners&&(corners==batch.size()||batch_flags!=step.w0||batch_texture!=step.w1))submit_batch(); - if(!corners){batch_flags=step.w0;batch_texture=step.w1;} - for(const auto& vertex:step.corners)batch[corners++]=vertex; -} -submit_batch(); diff --git a/patches/detail-records-repair/native/detail_metrics.hpp b/patches/detail-records-repair/native/detail_metrics.hpp deleted file mode 100644 index 4c2c8d92..00000000 --- a/patches/detail-records-repair/native/detail_metrics.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once -#include -#include -namespace dkr::runtime::detail { -struct MicroScope { - double& total; - std::chrono::steady_clock::time_point start=std::chrono::steady_clock::now(); - ~MicroScope(){total+=std::chrono::duration(std::chrono::steady_clock::now()-start).count();} -}; -struct FrameMetrics { - using Clock=std::chrono::steady_clock; - Clock::time_point reported=Clock::now(); - unsigned scene=~0U,frames=0,observations=0; - double prepare_us=0,submit_us=0; - void begin(unsigned next,unsigned count,unsigned& poses,unsigned& uploads,unsigned& batches,unsigned& hits,unsigned& captures) { - const auto now=Clock::now(); - if(scene==next&&frames&&now-reported>=std::chrono::seconds(2)) { - std::fprintf(stderr,"[graphics][detail-cost] scene=%u frames=%u prepare-avg-ms=%.3f submit-avg-ms=%.3f observations-avg=%.1f poses=%u vertex-uploads=%u batches=%u material-cache-hits=%u material-captures=%u; batched-detail-v2\n", - scene,frames,prepare_us/(frames*1000.0),submit_us/(frames*1000.0),double(observations)/frames,poses,uploads,batches,hits,captures); - *this={};scene=next;reported=now;poses=uploads=batches=hits=captures=0; - } - if(scene!=next){*this={};scene=next;reported=now;poses=uploads=batches=hits=captures=0;} - ++frames;observations+=count; - } -}; -} diff --git a/patches/detail-records-repair/native/detail_texture_cache.inl b/patches/detail-records-repair/native/detail_texture_cache.inl deleted file mode 100644 index 8a863802..00000000 --- a/patches/detail-records-repair/native/detail_texture_cache.inl +++ /dev/null @@ -1,49 +0,0 @@ -// Persist immutable materials/layouts, not animated vertices. Every byte on -// which a cached texture depends (including external CI palettes) is compared -// against the NEW task-owned image before reuse. No pointer-only cache keys. -struct TextureDependency {std::uint32_t physical;std::vector bytes;}; -struct RetainedTexture { - std::uint32_t header=0;std::uint64_t checked=0; - std::shared_ptr texture; - std::vector inputs; -}; -std::vector retained_textures; -std::uint64_t texture_frame=0; -std::uint32_t texture_scene=0; -std::size_t retained_texture_bytes=0; -std::shared_ptr retain_texture(const Reader& r,std::uint32_t header) { - for(auto& entry:retained_textures)if(entry.header==header) { - bool matches=true; - if(entry.checked!=texture_frame)for(const auto& input:entry.inputs) { - r.offset(input.physical,input.bytes.size()); - if(std::memcmp(r.ram.data()+input.physical,input.bytes.data(),input.bytes.size())){matches=false;break;} - } - if(matches){entry.checked=texture_frame;++texture_hits;return entry.texture;} - retained_texture_bytes-=entry.texture->bytes.size();entry={};break; - } - auto tex=std::make_shared(netplay::experimental::capture_local_texture(ram,header)); - ++texture_captures; - RetainedTexture entry;entry.header=header;entry.checked=texture_frame;entry.texture=tex; - const auto dependency=[&](std::uint32_t address,std::size_t bytes) { - const auto physical=r.offset(address,bytes); - if(physical>=0x800000U||bytes>0x800000U-physical)throw std::runtime_error("material cache input escapes source snapshot"); - entry.inputs.push_back({physical,{r.ram.begin()+physical,r.ram.begin()+physical+bytes}}); - }; - const auto native_bytes=r.half(header+0x16),count=r.half(header+0xA); - const auto commands=r.word(header+0xC); - dependency(header,native_bytes);dependency(commands,count*8U); - for(unsigned c=0;ccommands[c][0]>>24)==0xFD&&tex->commands[c][1]>=native_bytes) { - const auto begin=tex->commands[c][1];std::size_t end=tex->bytes.size(); - for(const auto& words:tex->commands)if((words[0]>>24)==0xFD&&words[1]>begin)end=std::min(end,std::size_t(words[1])); - if(end<=begin)throw std::runtime_error("material cache palette extent"); - dependency(r.word(commands+c*8+4)&0xFFFFFFU,end-begin); - } - // Bounded persistent storage. A large mod still renders; only optional - // cross-frame caching is bypassed when the retained cache is full. - if(tex->bytes.size()<=4U*1024U*1024U-retained_texture_bytes) { - auto empty=std::find_if(retained_textures.begin(),retained_textures.end(),[](const auto& e){return !e.header;}); - if(empty!=retained_textures.end()){retained_texture_bytes+=tex->bytes.size();*empty=std::move(entry);} - else if(retained_textures.size()<256){retained_texture_bytes+=tex->bytes.size();retained_textures.push_back(std::move(entry));} - } - return tex; -} diff --git a/patches/detail-records-repair/native/records_guest.inl b/patches/detail-records-repair/native/records_guest.inl deleted file mode 100644 index 08d69738..00000000 --- a/patches/detail-records-repair/native/records_guest.inl +++ /dev/null @@ -1,68 +0,0 @@ -// Owner-thread access to the SAME revision-mapped gSettingsPtr that the native -// get_settings routine returns. Never execute guest code from this observer: -// owned capture/initial installation occur outside an active guest call fence. -mods::Bytes course_records(std::uint8_t* rdram,const recomp_context&,bool restore) { - using namespace saves::records; - const auto courses=authored_courses();if(courses.empty())return {}; - const auto payload=guest_payload();if(!payload)return {}; - auto revision=payload->revision; - if(current_world().private_owner) { - const auto assets=current_world().session.load(); - if(!assets||!assets->boot_bank())throw mods::Error("Custom records lost their admitted asset owner."); - const auto id=assets->boot_bank()->revision(); - if(id!="us.v77"&&id!="us.v80")throw mods::Error("Custom records have an unsupported owned asset revision."); - revision=id=="us.v80"?rom::Revision::UsV80:rom::Revision::UsV77; - } - if(revision!=rom::Revision::UsV77&&revision!=rom::Revision::UsV80) - throw mods::Error("Custom records have no supported ROM revision."); - const auto valid=[](std::uint32_t p,unsigned bytes,unsigned alignment) { - return p>=0x80000000U&&p<0x80800000U&&bytes<=0x80800000U-p&&!(p&(alignment-1)); - }; - const auto word=[&](std::uint32_t p) { - if(!valid(p,4,4))throw mods::Error("Custom record pointer is outside owned RDRAM."); - std::uint32_t value;std::memcpy(&value,rdram+(p-0x80000000U),4);return value; - }; - const auto read_half=[&](std::uint32_t p) { - if(!valid(p,2,2))throw mods::Error("Custom record value is outside owned RDRAM."); - std::uint16_t value;std::memcpy(&value,rdram+((p-0x80000000U)^2U),2);return value; - }; - const auto write_half=[&](std::uint32_t p,std::uint16_t value) { - if(!restore||!valid(p,2,2))throw mods::Error("Custom record restoration crossed its owned span."); - std::memcpy(rdram+((p-0x80000000U)^2U),&value,2); - }; - if(!rdram)return {}; - const auto settings=word(revision==rom::Revision::UsV80?0x80123A90U:0x80123510U); - if(!valid(settings,0x48,4))return {}; - Map remembered; - if(restore&&!decode(seed(),remembered))throw mods::Error("Invalid custom-record launch seed."); - // Validate ALL destinations before writing any initial records. Capture - // only reads; original course slots 0..64 can never become destinations. - struct Row {std::string key;unsigned vehicle;std::array p;}; - std::vector rows;rows.reserve(courses.size()*3); - for(const auto& course:courses) { - if(course.level_id<65||course.level_id>=128)continue; - const auto key=mods::sha256(mods::View(reinterpret_cast(course.id.data()),course.id.size())); - for(unsigned vehicle=0;vehicle<3;++vehicle)if(course.vehicles&(1U<second;write_half(p[0],r.lap_name);write_half(p[1],r.lap); - write_half(p[2],r.course_name);write_half(p[3],r.course); - } - Record r{row.key,row.vehicle,read_half(p[1]),read_half(p[0]),read_half(p[3]),read_half(p[2])}; - if(r.lap||r.course)snapshot.emplace(std::pair{row.key,row.vehicle},r); - } - return encode(snapshot); -} diff --git a/patches/detail-records-repair/package.py b/patches/detail-records-repair/package.py deleted file mode 100644 index da17d42d..00000000 --- a/patches/detail-records-repair/package.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Allowlisted repair packages; never run a game, mod, helper or AppImage.""" -from pathlib import Path -import argparse -import importlib.util -import json -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-detail-playtest.2' -def load(name,path): - spec=importlib.util.spec_from_file_location(name,path);module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module -def main(a): - if a.output.exists():raise ValueError('Choose a NEW package destination') - binary=a.compiled/('libexec/dkr-r/DKR-R-Runtime.exe' if a.platform=='windows' else 'DKR-R') - audit=load('detail_records_repair_audit',AUTH/'audit.py').inspect(a.stage,binary) - base=load('repair_package_allowlist',ROOT/'patches/release-prep/v1.0.7/package_release.py');base.VERSION=VERSION - base.notes=lambda root,label:base.copy(AUTH/'PLAYTEST.txt',root/'README.txt') - a.output.mkdir(parents=True) - root,artifact,files=getattr(base,a.platform)(a) - receipt={'schema':1,'version':VERSION,'platform':a.platform,'arch':a.arch, - 'artifact':{'name':artifact.name,'bytes':artifact.stat().st_size,'sha256':base.sha(artifact)}, - 'files':files,'repair_audit':audit,'validation':audit['validation'],'published':False,'automatic_updates_signed':False} - (a.output/'BUILD-RECEIPT.json').write_text(json.dumps(receipt,indent=2)+'\n',encoding='utf-8') - (a.output/'SHA256SUMS.txt').write_text(base.sha(artifact)+' '+artifact.name+'\n',encoding='utf-8') - base.copy(AUTH/'PLAYTEST.txt',a.output/'PLAYTEST.txt') - print('Detail/records repair packaged without target execution:',artifact) -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('platform',choices=('windows','linux')) - for name in ('compiled','template','output','stage','trophy'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--arch',choices=('x86_64','aarch64'),default='x86_64');p.add_argument('--runtime-image',type=Path);main(p.parse_args()) diff --git a/patches/detail-records-repair/pipeline.py b/patches/detail-records-repair/pipeline.py deleted file mode 100644 index 5ee428d8..00000000 --- a/patches/detail-records-repair/pipeline.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Stage the focused records/multiplayer repair; never edit inputs or run targets.""" -from pathlib import Path -import argparse -import hashlib -import json -import re -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -def once(text,old,new): - if text.count(old)!=1:raise ValueError('Repair anchor changed: '+old[:150]) - return text.replace(old,new,1) -def model(text): - text=once(text,'#include "detail_contract.hpp"','#include "detail_contract.hpp"\n#include "detail_metrics.hpp"') - text=once(text,' std::vector images;',''' std::vector images; - // Sorted, merged successful replacement spans. Query cost is logarithmic - // instead of scanning every racer/scenery observation per triangle. - std::vector> suppressed; - detail::FrameMetrics profile; - unsigned kernel_builds=0,vertex_uploads=0,batches=0,texture_hits=0,texture_captures=0; - void suppress(std::uint32_t first,std::uint32_t last) { - auto at=std::lower_bound(suppressed.begin(),suppressed.end(),first,[](const auto& row,auto value){return row.secondfirst<=last){first=std::min(first,at->first);last=std::max(last,at->second);at=suppressed.erase(at);} - suppressed.insert(at,{first,last}); - } - #include "detail_texture_cache.inl"''') - text=once(text,'auto tex=std::make_shared(netplay::experimental::capture_local_texture(ram,p));','auto tex=retain_texture(r,p);') - text=once(text,' if(!kernel(ram.data(),ram.size(),&request,&packet.model)) {',' ++kernel_builds;\n if(!kernel(ram.data(),ram.size(),&request,&packet.model)) {') - text=once(text,' auto& p=*impl_;p.observations.clear();p.images.clear();p.ram=ram;p.scene=scene;p.cursor=kTextureBegin;p.draws=0;', - ''' auto& p=*impl_; - p.profile.begin(scene,unsigned(events.size()),p.kernel_builds,p.vertex_uploads,p.batches,p.texture_hits,p.texture_captures); - detail::MicroScope prepare_cost{p.profile.prepare_us}; - if(p.texture_scene!=scene||p.texture_frame==UINT64_MAX){p.retained_textures.clear();p.retained_texture_bytes=0;p.texture_scene=scene;p.texture_frame=0;} - ++p.texture_frame;p.suppressed.clear(); - p.observations.clear();p.images.clear();p.ram=ram;p.scene=scene;p.cursor=kTextureBegin;p.draws=0;''') - # Ensure address order even when a task contains racers but no extra scenery. - anchor=''' // Preflight racers first. Optional scenery cannot consume their pose,''' - text=once(text,anchor,''' std::stable_sort(p.observations.begin(),p.observations.end(),[](const auto& a,const auto& b){return a.e.address=o.e.address-0x80000000U && commandsecond;''') - text=once(text,''' for(auto& o:p.observations) { - if(o.consumed||o.e.address-0x80000000U!=command_address)continue;o.consumed=true;''', - ''' const auto address=command_address+0x80000000U; - auto found=std::lower_bound(p.observations.begin(),p.observations.end(),address,[](const auto& o,auto value){return o.e.addresse.address!=address)return; - detail::MicroScope submit_cost{p.profile.submit_us}; - for(;found!=p.observations.end()&&found->e.address==address;++found) { - auto& o=*found;if(o.consumed)continue;o.consumed=true;''') - begin=text.index(' for(const auto& step:packet->steps) {',text.index(' select(rsp,presentation::normalise_identity(')) - end=text.index(' state.flush();o.replaced=true;++p.draws;',begin) - text=text[:begin]+' #include "detail_batch.inl"\n'+text[end:] - text=once(text,' state.flush();o.replaced=true;++p.draws;', - ' state.flush();o.replaced=true;++p.draws;\n if(!o.extra)p.suppress(o.e.address-0x80000000U,o.e.parameters[0]-0x80000000U);') - return text -def main(a): - if a.output.resolve().is_relative_to(ROOT):raise ValueError('Stage outside the checkout') - a.output.mkdir(parents=True,exist_ok=True) - for p in (AUTH/'native').iterdir(): - if p.is_file():(a.output/p.name).write_bytes(p.read_bytes()) - # Keep the online sidecar; local split-screen uses the native selector and - # must not build an expensive parallel copy of the same racers. - observer=a.output.parent/'all-player-detail/detail_observer.inl' - data=observer.read_text(encoding='utf-8') - data=once(data,'!netplay::external_side_effects_allowed())return;', - '!netplay::external_side_effects_allowed()||!netplay::session().runtime_view().active)return;') - (a.output/'detail_observer.inl').write_text(data,encoding='utf-8',newline='\n') - swaps=[] - for row in json.loads(a.inputs.read_text(encoding='utf-8')): - old=Path(row['path']);raw=old.read_bytes();text=raw.decode('utf-8').replace('\r\n','\n') - if old.name=='runtime_enhancements.cpp': - text=once(text,''' if(dkr::runtime::netplay::session().runtime_view().active|| - MEM_W(0,RdramAddress(dkr::runtime::revision_addresses::ViewportLayout))!=0)return;''', - ''' // One local machine can safely use the original fast selector for - // ALL viewports. Only online peers require independent render-only LODs. - if(dkr::runtime::netplay::session().runtime_view().active)return;''') - text=once(text,'"native highest resident"','"native highest resident; local-all-views-v2"') - elif old.name=='runtime_legacy_mods.cpp': - text='#include \n#include \n'+text - text,n=re.subn(r'^#include "[^"\n]*record_guest\.inl"','#include "records_guest.inl"',text,flags=re.M) - if n!=1:raise ValueError('Missing records observer') - for before,after in [ - ('throw std::runtime_error("Could not capture custom-course records from the owned guest.");', - 'throw std::runtime_error("Could not capture custom-course records from the owned guest: "+(world.error().empty()?std::string("missing records service result"):world.error()));'), - ('throw std::runtime_error("Custom records could not be installed at the initial owned boundary. Originals were retained.");', - 'throw std::runtime_error("Custom records could not be installed at the initial owned boundary; originals were retained: "+(world.error().empty()?std::string("record tables are not ready"):world.error()));')]:text=once(text,before,after) - text+='\n// checked-record-observer-v2: revision-owned RAM access, no guest callback.\n' - elif old.name=='local_model_rt64.cpp':text=model(text) - elif old.name!='presentation_identity.cpp':raise ValueError('Unexpected repair input') - def adjacent(m): - candidate=old.parent/m[1] - return '#include "'+candidate.resolve().as_posix()+'"' if candidate.is_file() else m[0] - text=re.sub(r'^#include "([^"\n]+)"',adjacent,text,flags=re.M) - new=a.output/old.name;data=text.encode('utf-8');new.write_bytes(data) - swaps.append({**row,'new':str(new.resolve()),'input_sha256':hashlib.sha256(raw).hexdigest(),'output_sha256':hashlib.sha256(data).hexdigest()}) - (a.output/'swaps.json').write_text(json.dumps(swaps,indent=2)+'\n',encoding='utf-8') - print('Records fenced-callback repair, native local LODs and indexed/batched online detail staged; no target execution.') -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);main(p.parse_args()) diff --git a/patches/graphics-handoff/PIPELINE.txt b/patches/graphics-handoff/PIPELINE.txt deleted file mode 100644 index 87ccebca..00000000 --- a/patches/graphics-handoff/PIPELINE.txt +++ /dev/null @@ -1,46 +0,0 @@ -Producer snapshot ownership above the accepted render repair - -Only this additive Patch Pipeline folder is authored. Two final host C++ units -are copied outside the checkout: presentation_identity.cpp and events.cpp. -The shared mailbox implementation is linked once by the game executable. -Submodules, RecompiledFuncs and RecompiledPatches remain read-only. No guest -function, save, allocator, launcher design, online protocol or peer ID changes. - -The existing gfxtask_run_xbus entry callback receives the completed display-list -range before osSendMesg can enqueue it for the game scheduler. External-work -suppression remains before capture; Accurate presentation also receives the -snapshot. The scheduler transfers the memory lease when its RSP task starts. -The current private decode arena and all render guards remain unchanged. - -Keys include the RAM owner, physical root and native command count, matched in -FIFO order. A consumed image cannot be reused. Address or size mismatches, -invalid captures and pending-set overflow disable early matching until session -reset, preserving the existing submission-copy fallback. Never search a later -slot by a recycled root or let a mismatch realign an ambiguous queue. Suppressed -graphics submissions retire any corresponding producer image. Reset discards -pending images without invalidating active worker leases. A session era also -rejects a capture that was copying when reset occurred. Pending metadata is -bounded to four images, and completed storage uses the existing four-slot pool. - -The steady-state protected path still performs one 8 MiB submission copy and -the retained private decode copy. Copy timing moves to the producer. At most -four early pending 8 MiB images can be retained; live graphics-worker images -remain governed by the existing task lifecycle. No extra per-frame RAM dump, -full-memory comparison, synchronous GPU wait or change to SP/DP acknowledgements -is introduced. Logs announce activation and record bounded session counters. - -The new synthetic fixture reproduces a late snapshot seeing reused invalid -indices, then verifies earlier capture preserves the original data. It tests -FIFO roots, invalid ranges, suppression-style retirement, fallback, overflow, -session reset and 8,000 concurrent transfers. Retained render/residency, -allocator, owned-entry and online fixtures remain required. Audit checks both -actual ROM-revision producer callbacks and both final host compiler bindings, -plus the full previous source chain. Packages use the accepted runtime-only -allowlist with archive read-back and static ELF dependency inspection. - -The latest log narrows the fault to submitted data or its interpretation; it -does not identify the exact asset writer/free operation. Closing this verified -unprotected queue interval is a targeted prevention measure, not a claim that -the logged task has been reproduced in real gameplay. Manual acceptance still -requires zero malformed-task/allocator/HUD failures through repeated matching -track/menu tests, followed by online and spectator sessions. diff --git a/patches/graphics-handoff/PLAYTEST.txt b/patches/graphics-handoff/PLAYTEST.txt deleted file mode 100644 index 142f8062..00000000 --- a/patches/graphics-handoff/PLAYTEST.txt +++ /dev/null @@ -1,31 +0,0 @@ -DKR-R v1.0.10 Render Playtest 2 - -This keeps the memory, water, online and spectator fixes from the last build. -The launcher looks the same. - -Completed graphics tasks now keep their own memory image before entering the -game scheduler. That image moves to the graphics worker without another full -submission copy. Previously, the copy happened when the scheduler eventually -started the RSP task, leaving an earlier queue without snapshot protection. -The original SP/DP completion and asset freeing rules stay in place. - -This closes that ownership gap. It does not prove that this was the exact -source of the single malformed task in the last log. The existing guards and -bounded provenance reports remain. A repeat of the same Track Select/preview -sequence is needed to confirm that no malformed tasks remain in gameplay. - -An incomplete 16-frame ripple animation still uses original textures for the -whole animation. The enabled pack contains replacements for only one frame. -No installed texture pack, save, launcher setting or mod is changed. - -Please repeat the same preview changes, stock races and restarts. Check HUD, -water and sound, then test modded lobbies and spectator playback with everyone -using these matching packages. Close normally and keep runtime.log. The -handoff summary should show transferred captures with no mismatch/overflow. - -Validation covers synthetic delayed scheduling, reused/held buffers, rejected -or suppressed tasks, session reset, fallback, concurrency and the retained -rendering, memory and online regressions. No game, ROM, RSP, GPU workload, -AppImage or live lobby is executed by the build process. Real gameplay -acceptance remains necessary. These are private test builds, with no signed -stable update or publication. Keep the previous build folder as a fallback. diff --git a/patches/graphics-handoff/ReleaseOverlay.cmake b/patches/graphics-handoff/ReleaseOverlay.cmake deleted file mode 100644 index 4cf34ebe..00000000 --- a/patches/graphics-handoff/ReleaseOverlay.cmake +++ /dev/null @@ -1,62 +0,0 @@ -include("${CMAKE_CURRENT_LIST_DIR}/../render-stability/ReleaseOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_graphics_handoff) - set(auth "${DKRPORT_ROOT}/patches/graphics-handoff") - set(stage "${CMAKE_BINARY_DIR}/generated/graphics-handoff") - file(MAKE_DIRECTORY "${stage}") - set(rows "[") - set(separator "") - foreach(entry "DKRPortGame|presentation_identity.cpp" "ultramodern|events.cpp") - string(REPLACE "|" ";" fields "${entry}") - list(GET fields 0 target) - list(GET fields 1 name) - get_target_property(paths ${target} SOURCES) - list(FILTER paths INCLUDE REGEX "[/\\]${name}$") - list(LENGTH paths count) - if(NOT count EQUAL 1) - message(FATAL_ERROR "Graphics handoff needs one final ${target}/${name}") - endif() - list(GET paths 0 path) - file(TO_CMAKE_PATH "${path}" path) - string(APPEND rows "${separator}{\"target\":\"${target}\",\"path\":\"${path}\"}") - set(separator ",") - endforeach() - string(APPEND rows "]") - file(WRITE "${stage}/inputs.json" "${rows}") - file(GLOB authored CONFIGURE_DEPENDS "${auth}/native/*" "${auth}/tests/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${authored} "${auth}/pipeline.py" "${auth}/ReleaseOverlay.cmake") - execute_process(COMMAND "${Python3_EXECUTABLE}" -B "${auth}/pipeline.py" --inputs "${stage}/inputs.json" --output "${stage}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Graphics handoff staging failed") - endif() - file(READ "${stage}/swaps.json" swaps) - foreach(index RANGE 1) - string(JSON target GET "${swaps}" ${index} target) - string(JSON old GET "${swaps}" ${index} path) - string(JSON next GET "${swaps}" ${index} new) - get_target_property(directory ${target} SOURCE_DIR) - foreach(property INCLUDE_DIRECTORIES COMPILE_OPTIONS COMPILE_DEFINITIONS) - get_property(defined SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property} SET) - if(defined) - get_property(value SOURCE "${old}" DIRECTORY "${directory}" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY ${property} "${value}") - endif() - endforeach() - get_filename_component(prior "${old}" DIRECTORY) - get_property(includes SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES) - set_property(SOURCE "${next}" DIRECTORY "${directory}" PROPERTY INCLUDE_DIRECTORIES "${stage};${prior};${includes}") - dkr_repair_replace_source(${target} "${old}" "${next}") - endforeach() - target_sources(DKRPortGame PRIVATE "${stage}/producer_snapshot.cpp") - add_executable(DKRGraphicsHandoffChecks EXCLUDE_FROM_ALL "${auth}/tests/handoff_tests.cpp") - target_compile_features(DKRGraphicsHandoffChecks PRIVATE cxx_std_20) - target_include_directories(DKRGraphicsHandoffChecks PRIVATE "${stage}") - if(DKR_RELEASE_EVIDENCE) - foreach(target DKRPortGame ultramodern) - get_target_property(paths ${target} SOURCES) - file(WRITE "${DKR_RELEASE_EVIDENCE}/${target}-HANDOFF-SOURCES.txt" "${paths}\n") - endforeach() - endif() - endfunction() - cmake_language(DEFER CALL dkr_apply_graphics_handoff) -endif() diff --git a/patches/graphics-handoff/audit.py b/patches/graphics-handoff/audit.py deleted file mode 100644 index 7b33f2b8..00000000 --- a/patches/graphics-handoff/audit.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Recompose both handoff units and retain the full render/memory/online audit.""" -from pathlib import Path -import argparse,importlib.util,json,re,sys -sys.dont_write_bytecode=True -import pipeline -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -def need(ok,why): - if not ok:raise ValueError(why) -def load(name,path): - s=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(s);s.loader.exec_module(m);return m -def compiled(build,target): - project=build/(target+'.vcxproj') - if target=='ultramodern':project=build/'n64-modern-runtime/ultramodern/ultramodern.vcxproj' - if project.is_file():return {Path(p).resolve() for p in re.findall(r'r11 = SUB32(ctx->r5, ctx->r4);','ctx->r12 = S32(SIGNED(ctx->r11) >> 3);','MEM_W(0X44, ctx->r7) = ctx->r12;'): - need(token in fn,'Native task command-count ABI changed: '+token) - hits.append({'revision':rev,'path':str(path),'sha256':pipeline.sha(path.read_bytes())}) - need(len(hits)==1,'Missing or duplicate final producer hook');hooks+=hits - old=next(Path(r['path']).read_text() for r in rows if r['target']=='ultramodern') - events=files['ultramodern/events.cpp'].decode() - for token in ('sp_complete();','dp_complete();','host_tasks::registry.mark(', 'action_queue.enqueue(', 'sp_task_queue.enqueue('): - need(old.count(token)==events.count(token),'Interrupt/queue lifecycle changed: '+token) - need(events.count('graphics_handoff::discard(')==1 and events.count('graphics_handoff::reset();')==2,'Suppressed/reset ownership missing') - need(events.index('graphics_handoff::take(')=max((stage/name).stat().st_mtime_ns for name in files),'Binary predates handoff inputs') - data=binary.read_bytes() - for marker in (pipeline.VERSION,'[render][producer-snapshot]','[render][handoff-summary]','original SP/DP completion retained'): - need(marker.encode() in data,'Linked handoff missing: '+marker) - return {'schema':1,'version':pipeline.VERSION,'swaps':rows,'producer_hooks':hooks, - 'rollback_build_ids':retained['rollback_build_ids'],'retained':retained, - 'guest_code_changed':False,'protected_sources_edited':False, - 'validation':'Exact composition; actual producer hooks before the scheduler message; two compiler replacements and one shared implementation; retained complete render/memory/online audit. No game execution.'} -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','binary','baseline','accepted','output'):p.add_argument('--'+name,type=Path,required=name!='binary') - a=p.parse_args();result=inspect(a.build,a.binary,a.baseline,a.accepted) - a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(json.dumps(result,indent=2)+'\n') - print('Producer snapshot ownership and retained render/memory/online chain verified.') diff --git a/patches/graphics-handoff/configure.py b/patches/graphics-handoff/configure.py deleted file mode 100644 index 23176ddf..00000000 --- a/patches/graphics-handoff/configure.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Configure matching private builds through the additive Patch Pipeline.""" -from pathlib import Path -import argparse,importlib.util,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('build','capsule','log'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--cmake',default='cmake');a=p.parse_args() - spec=importlib.util.spec_from_file_location('handoff_configure',ROOT/'patches/release-prep/v1.0.7/release.py') - m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) - m.AUTH=AUTH;m.VERSION='1.0.10-render-playtest.2';m.configure(a.build,a.log,a.cmake,a.capsule) diff --git a/patches/graphics-handoff/finalize.py b/patches/graphics-handoff/finalize.py deleted file mode 100644 index 908d809b..00000000 --- a/patches/graphics-handoff/finalize.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Seal inspected, matching private playtest packages and their evidence.""" -from pathlib import Path -import argparse,hashlib,json,shutil,subprocess,sys,zipfile -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -VERSION='1.0.10-render-playtest.2' -def need(ok,why): - if not ok:raise ValueError(why) -def sha(path): - with path.open('rb') as f:return hashlib.file_digest(f,'sha256').hexdigest() -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--work',type=Path,required=True);p.add_argument('--output',type=Path,required=True);a=p.parse_args() - need(not a.output.exists(),'Choose a fresh handoff folder');artifacts={};receipts={} - for platform in ('windows','linux'): - folder=a.work/'packages'/platform;receipt=json.loads((folder/'BUILD-RECEIPT.json').read_text());artifact=folder/receipt['artifact']['name'] - need(receipt['version']==VERSION and not receipt['published'] and not receipt['automatic_updates_signed'],'Wrong package status') - need(sha(artifact)==receipt['artifact']['sha256'] and artifact.stat().st_size==receipt['artifact']['bytes'],'Artifact changed') - checks=json.loads((a.work/platform/'checks/CHECKS.json').read_text()) - need(not checks['game_or_rom_or_rsp_executed'] and checks['handoff_checks']==37 and checks['concurrent_transfers']==8000,'Handoff regression missing') - render=checks['retained'];need({k:v['passed_checks'] for k,v in render['render'].items()}=={'DKRRenderStabilityChecks':30,'DKRLiquidResidencyChecks':23},'Render regressions missing') - memory=render['retained_memory'] - for rev in ('77','80'): - need(memory['allocator'][rev]['passed_checks']>=180000 and memory['allocator'][rev]['exit_code']==0,'Allocator regression missing') - need(memory['allocator']['owned'+rev]['passed_checks']==13,'Owned entry regression missing') - need(len(memory['retained']['checks'])==7,'Retained online suite incomplete') - for name,value in memory['retained']['checks'].items():need(value['exit_code']==(1 if name in ('before','race-before') else 0),'Retained regression failed') - archive=json.loads((a.work/platform/'ARCHIVE-REPORT.json').read_text());need(archive['artifact_sha256']==sha(artifact) and archive['exact_payload_match'] and not archive['game_or_appimage_executed'],'Archive not verified') - worker=json.loads((a.work/platform/'worker-probe/PROBE-RECEIPT.json').read_text());need(not worker['game_executed'] and not worker['roms_or_mods_loaded'] and worker['online_prepare_batch']==1,'Packaged worker handshake missing') - receipts[platform]=receipt;artifacts[platform]=artifact - peers=json.loads((a.work/'baseline/MEMORY-PEERS.json').read_text()) - for receipt in receipts.values():need(receipt['audit']['rollback_build_ids']==peers,'Presentation handoff changed canonical peer IDs') - elf=json.loads((a.work/'linux/ELF-REPORT.json').read_text());need(not elf['missing_non_system_dependencies'],'Linux dependency closure incomplete') - baseline=json.loads((a.work/'baseline/SOURCE-FILES.json').read_text()) - for row in baseline:need(sha(ROOT/row['path'])==row['sha256'],'Pre-existing source changed: '+row['path']) - a.output.mkdir(parents=True) - for platform,artifact in artifacts.items(): - shutil.copy2(artifact,a.output/artifact.name) - dest=a.output/'Evidence'/platform;dest.mkdir(parents=True) - shutil.copy2(a.work/'packages'/platform/'BUILD-RECEIPT.json',dest/'BUILD-RECEIPT.json') - for path in (a.work/platform/'checks').rglob('*'): - if path.is_file() and path.suffix in ('.json','.log'): - target=dest/'checks'/path.relative_to(a.work/platform/'checks');target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target) - for name in ('source-audit.json','ARCHIVE-REPORT.json','ELF-REPORT.json','qualified-build.log'): - path=a.work/platform/name - if path.is_file():shutil.copy2(path,dest/name) - shutil.copytree(a.work/platform/'worker-probe',dest/'worker-probe') - for name in ('PLAYTEST.txt','PIPELINE.txt'):shutil.copy2(AUTH/name,a.output/name) - diagnostics=a.output/'Evidence/diagnostics';diagnostics.mkdir(parents=True) - for name in ('handoff-epoch.log','SANITIZERS.json'): - path=a.work/'sanitizers'/name - need(path.is_file(),'Final sanitizer evidence missing: '+name);shutil.copy2(path,diagnostics/name) - for name in ('COPY-COST.log','FINDINGS.txt'): - if (a.work/name).is_file():shutil.copy2(a.work/name,diagnostics/name) - source_zip=a.output/'PATCH-PIPELINE-SOURCE.zip' - with zipfile.ZipFile(source_zip,'x',zipfile.ZIP_DEFLATED) as z: - for folder in (ROOT/'patches/memory-stability',ROOT/'patches/render-stability',AUTH): - for path in sorted(folder.rglob('*')): - if path.is_file() and '__pycache__' not in path.parts:z.write(path,path.relative_to(ROOT).as_posix()) - with zipfile.ZipFile(source_zip) as z:need(not z.testzip(),'Source ZIP integrity failure') - git='C:/Program Files/Git/cmd/git.exe' if sys.platform=='win32' else 'git' - head=subprocess.check_output([git,'rev-parse','HEAD'],cwd=ROOT,text=True).strip() - report={'schema':1,'version':VERSION,'base_commit':head,'additive_patch':'patches/graphics-handoff', - 'unchanged_preexisting_authored_files':len(baseline),'rollback_build_ids':peers, - 'published':False,'signed_auto_update':False,'game_rom_rsp_gpu_or_live_multiplayer_executed':False, - 'artifacts':{name:r['artifact'] for name,r in receipts.items()}, - 'limitation':'The scheduler ownership gap is closed and synthetically reproduced. The exact latest malformed-task incident still requires repeated gameplay acceptance; the original guard and provenance remain.'} - (a.output/'HANDOFF.json').write_text(json.dumps(report,indent=2)+'\n') - hashes={path.relative_to(a.output).as_posix():sha(path) for path in a.output.rglob('*') if path.is_file()} - (a.output/'SHA256SUMS.txt').write_text(''.join(value+' '+name+'\n' for name,value in sorted(hashes.items()))) - for name,value in hashes.items():need(sha(a.output/name)==value,'Handoff copy changed') - print('Verified private handoff:',len(hashes),'files;',a.output) diff --git a/patches/graphics-handoff/native/producer_snapshot.cpp b/patches/graphics-handoff/native/producer_snapshot.cpp deleted file mode 100644 index 2bd2af9d..00000000 --- a/patches/graphics-handoff/native/producer_snapshot.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "producer_snapshot.hpp" -#include "performance_trace.hpp" -#include -#include -#include - -namespace dkr::runtime::graphics_handoff { -namespace {ProductionMailbox mailbox;std::atomic failures{0};} -bool capture(const std::uint8_t* ram,std::uint32_t begin,std::uint32_t end) noexcept { - try { - const bool ok=mailbox.capture(ram,begin,end,[](std::uint8_t* dst,const std::uint8_t* src,std::size_t bytes){ - performance_trace::Scope copy_cost(performance_trace::Region::SnapshotCopy,bytes); - std::memcpy(dst,src,bytes); - }); - static std::atomic_flag notice=ATOMIC_FLAG_INIT; - if(ok && !notice.test_and_set(std::memory_order_relaxed)) - std::fputs("[render][producer-snapshot] captured before scheduler enqueue; transferred without a second submission copy\n",stderr); - return ok; - } catch(const std::exception&) { - mailbox.invalidate(); - if(failures.fetch_add(1,std::memory_order_relaxed)<3U) - std::fputs("[render][producer-snapshot] capture unavailable; submission fallback retained\n",stderr); - return false; - } -} -Snapshot take(const std::uint8_t* ram,std::uint32_t root,std::uint32_t count){return mailbox.take(ram,root,count);} -Snapshot acquire(){return mailbox.acquire();} -void discard(const std::uint8_t* ram,std::uint32_t root,std::uint32_t count){auto unused=mailbox.take(ram,root,count);} -void reset() noexcept { - const auto m=mailbox.reset(); - if(m.captured || m.fallback) - std::fprintf(stderr,"[render][handoff-summary] captured=%llu transferred=%llu fallback=%llu mismatch=%llu overflow=%llu; original SP/DP completion retained\n", - static_cast(m.captured),static_cast(m.transferred), - static_cast(m.fallback),static_cast(m.mismatch), - static_cast(m.overflow)); -} -} diff --git a/patches/graphics-handoff/native/producer_snapshot.hpp b/patches/graphics-handoff/native/producer_snapshot.hpp deleted file mode 100644 index 3bf7d308..00000000 --- a/patches/graphics-handoff/native/producer_snapshot.hpp +++ /dev/null @@ -1,97 +0,0 @@ -#pragma once -#include "graphics_snapshot_pool.hpp" -#include -#include -#include -#include -#include -#include - -namespace dkr::runtime::graphics_handoff { -// One owner transfers the memory image across BOTH scheduler queues. Keys are -// checked in submission order; searching by a recycled display-list address -// would risk handing an older frame to a newer task. -template -class Mailbox { -public: - using Pool = GraphicsSnapshotPool; - using Snapshot = typename Pool::Snapshot; - struct Metrics {std::uint64_t captured=0, transferred=0, fallback=0, mismatch=0, overflow=0;}; - struct CopyBytes { - void operator()(std::uint8_t* dst,const std::uint8_t* src,std::size_t bytes) const {std::memcpy(dst,src,bytes);} - }; -private: - struct Entry {const void* owner=nullptr;std::uint32_t root=0, count=0;Snapshot image{};}; - Pool pool_; - std::mutex mutex_; - std::array pending_{}; - std::size_t first_=0, size_=0; - bool desynchronised_=false; - std::uint64_t era_=0; - Metrics metrics_{}; - void discard_locked() { - for(auto& entry:pending_)entry=Entry{}; - first_=size_=0; - } - static bool address(std::uint32_t raw, std::uint32_t& physical) { - const auto segment=raw&0xE0000000U; - if(segment!=0U && segment!=0x80000000U)return false; - physical=raw&0x1FFFFFFFU; - return physical<=Bytes && !(physical&7U); - } -public: - template - bool capture(const std::uint8_t* ram,std::uint32_t begin,std::uint32_t end,Copy copy={}) { - std::uint32_t root=0,finish=0; - if(!ram || !address(begin,root) || !address(end,finish) || finish<=root){invalidate();return false;} - std::uint64_t era=0; - { - std::lock_guard lock(mutex_); - if(desynchronised_)return false; - if(size_==Capacity){++metrics_.overflow;discard_locked();desynchronised_=true;return false;} - era=era_; - } - auto image=pool_.acquire(); - copy(image.get(),ram,Bytes); - std::lock_guard lock(mutex_); - if(desynchronised_ || era!=era_)return false; - if(size_==Capacity){++metrics_.overflow;discard_locked();desynchronised_=true;return false;} - auto& slot=pending_[(first_+size_)%Capacity]; - slot={ram,root,(finish-root)/8U,std::move(image)}; - ++size_;++metrics_.captured; - return true; - } - Snapshot take(const std::uint8_t* ram,std::uint32_t begin,std::uint32_t count) { - std::uint32_t root=0; - const bool valid=ram && address(begin,root) && root0 && count<=(Bytes-root)/8U; - std::lock_guard lock(mutex_); - if(size_ && !desynchronised_) { - auto& slot=pending_[first_]; - if(valid && slot.owner==ram && slot.root==root && slot.count==count) { - auto image=std::move(slot.image);slot=Entry{}; - first_=(first_+1U)%Capacity;--size_;++metrics_.transferred; - return image; - } - ++metrics_.mismatch; - desynchronised_=true; - } - // Never let an unmatched producer image survive and match a later - // reuse of the same root address. The caller retains the old safe - // submission-copy fallback for nonstandard tasks. - discard_locked();++metrics_.fallback; - return Snapshot{}; - } - Snapshot acquire(){return pool_.acquire();} - void invalidate(){std::lock_guard lock(mutex_);discard_locked();desynchronised_=true;} - Metrics metrics(){std::lock_guard lock(mutex_);return metrics_;} - Metrics reset(){std::lock_guard lock(mutex_);auto result=metrics_;++era_;discard_locked();metrics_={};desynchronised_=false;return result;} -}; - -using ProductionMailbox=Mailbox<0x800000>; -using Snapshot=ProductionMailbox::Snapshot; -bool capture(const std::uint8_t*,std::uint32_t,std::uint32_t) noexcept; -Snapshot take(const std::uint8_t*,std::uint32_t,std::uint32_t); -Snapshot acquire(); -void discard(const std::uint8_t*,std::uint32_t,std::uint32_t); -void reset() noexcept; -} diff --git a/patches/graphics-handoff/package.py b/patches/graphics-handoff/package.py deleted file mode 100644 index 9b56d9fb..00000000 --- a/patches/graphics-handoff/package.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Package private matching Windows ZIP and Linux AppImage builds.""" -from pathlib import Path -import argparse,importlib.util,inspect,json,sys -sys.dont_write_bytecode=True -import audit,pipeline -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('platform',choices=('windows','linux')) - for name in ('compiled','template','output','build','trophy','baseline','accepted'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--arch',default='x86_64',choices=('x86_64',));p.add_argument('--runtime-image',type=Path);a=p.parse_args() - if a.output.exists():raise ValueError('Choose a fresh package folder') - binary=a.compiled/('libexec/dkr-r/DKR-R-Runtime.exe' if a.platform=='windows' else 'DKR-R') - checked=audit.inspect(a.build,binary,a.baseline,a.accepted) - s=importlib.util.spec_from_file_location('handoff_allowlist',ROOT/'patches/release-prep/v1.0.7/package_release.py') - m=importlib.util.module_from_spec(s);s.loader.exec_module(m);m.VERSION=pipeline.VERSION - source=inspect.getsource(m.features);old="b'workload-atomic promotion'" - if source.count(old)!=1:raise ValueError('Accepted feature guard changed') - exec(compile(source.replace(old,"b'immutable workload families'",1),str(AUTH/'package.py'),'exec'),m.__dict__) - def notes(root,label): - for name in ('README.txt','RELEASE-NOTES.txt'):m.copy(AUTH/'PLAYTEST.txt',root/name) - m.copy(AUTH/'PIPELINE.txt',root/'HANDOFF-DETAILS.txt') - m.notes=notes;a.output.mkdir(parents=True) - root,artifact,files=getattr(m,a.platform)(a) - receipt={'schema':1,'version':pipeline.VERSION,'platform':a.platform,'arch':a.arch, - 'artifact':{'name':artifact.name,'sha256':m.sha(artifact),'bytes':artifact.stat().st_size},'files':files, - 'audit':checked,'published':False,'automatic_updates_signed':False} - (a.output/'BUILD-RECEIPT.json').write_text(json.dumps(receipt,indent=2)+'\n') - (a.output/'SHA256SUMS.txt').write_text(m.sha(artifact)+' '+artifact.name+'\n') - print('Graphics handoff playtest packaged:',artifact) diff --git a/patches/graphics-handoff/pipeline.py b/patches/graphics-handoff/pipeline.py deleted file mode 100644 index 99be022f..00000000 --- a/patches/graphics-handoff/pipeline.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Add producer-owned graphics snapshots above the accepted render repair.""" -from pathlib import Path -import argparse,hashlib,json,re,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -VERSION='1.0.10-render-playtest.2' -NAMES={('DKRPortGame','presentation_identity.cpp'),('ultramodern','events.cpp')} -def sha(data):return hashlib.sha256(data).hexdigest() -def once(text,old,new): - if text.count(old)!=1:raise ValueError('Graphics handoff anchor changed: '+old[:120]) - return text.replace(old,new,1) -def write(path,data): - path.parent.mkdir(parents=True,exist_ok=True) - if not path.is_file() or path.read_bytes()!=data:path.write_bytes(data) -def compose(rows,out): - if len(rows)!=2 or {(r['target'],Path(r['path']).name) for r in rows}!=NAMES:raise ValueError('Handoff source closure changed') - files={} - for name in ('producer_snapshot.hpp','producer_snapshot.cpp'): - text=(AUTH/'native'/name).read_text() - text=text.replace('#include "graphics_snapshot_pool.hpp"','#include "'+(ROOT/'runtime-recomp/src/game/graphics_snapshot_pool.hpp').as_posix()+'"') - text=text.replace('#include "performance_trace.hpp"','#include "'+(ROOT/'runtime-recomp/src/game/performance_trace.hpp').as_posix()+'"') - files[name]=text.encode() - swaps=[] - for row in rows: - source=Path(row['path']);raw=source.read_bytes();text=raw.decode().replace('\r\n','\n') - text='#include "'+(out/'producer_snapshot.hpp').as_posix()+'"\n'+text - if source.name=='presentation_identity.cpp': - # This existing hook runs at gfxtask_run_xbus ENTRY, before its - # osSendMesg can wake the scheduler. Capture regardless of Modern - # presentation; historical replay keeps its existing suppression. - anchor=' if (!dkr::runtime::enhancements::modern_presentation_enabled()) {\n return;\n }\n SubmittedFrame frame{};' - text=once(text,anchor, - ' dkr::runtime::graphics_handoff::capture(rdram,static_cast(context->r4),\n' - ' static_cast(context->r5));\n'+anchor) - else: - text=once(text,'static RdramSnapshotPool rdram_snapshot_pool;','// Pool ownership is shared with the earlier producer handoff.') - old=''' auto rdram_snapshot = rdram_snapshot_pool.acquire(); - { - dkr::runtime::performance_trace::Scope measure( - dkr::runtime::performance_trace::Region::SnapshotCopy, RDRAMSnapshotSize); - std::memcpy(rdram_snapshot.get(), rdram, RDRAMSnapshotSize); - }''' - new=''' auto rdram_snapshot = dkr::runtime::graphics_handoff::take( - rdram,static_cast(task->t.data_ptr),task->t.data_size); - if (!rdram_snapshot) { - rdram_snapshot = dkr::runtime::graphics_handoff::acquire(); - dkr::runtime::performance_trace::Scope measure( - dkr::runtime::performance_trace::Region::SnapshotCopy, RDRAMSnapshotSize); - std::memcpy(rdram_snapshot.get(), rdram, RDRAMSnapshotSize); - }''' - text=once(text,old,new) - text=once(text,' if (!presentation_allowed) {', - ' if (!presentation_allowed) {\n' - ' dkr::runtime::graphics_handoff::discard(rdram,\n' - ' static_cast(task->t.data_ptr),task->t.data_size);') - text=once(text,'static void reset_event_state() {','static void reset_event_state() {\n dkr::runtime::graphics_handoff::reset();') - anchor=' while (events_context.action_queue.try_dequeue(action)) {}' - # Reset and shutdown both drain the action queue. At shutdown all - # producers have stopped; active leases retain their pool owner. - if text.count(anchor)!=2:raise ValueError('Event drain closure changed') - at=text.rfind(anchor);text=text[:at]+text[at:].replace(anchor,anchor+'\n dkr::runtime::graphics_handoff::reset();',1) - def include(m): - path=Path(m[1]);path=path if path.is_absolute() else source.parent/path - return '#include "'+path.resolve().as_posix()+'"' if path.is_file() else m[0] - text=re.sub(r'^#include "([^"\n]+)"',include,text,flags=re.M) - name=row['target']+'/'+source.name;files[name]=text.encode() - swaps.append({**row,'new':str(out/name),'input_sha256':sha(raw),'output_sha256':sha(text.encode())}) - return swaps,files -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);a=p.parse_args() - out=a.output.resolve() - if out.is_relative_to(ROOT):raise ValueError('Generated handoff files must remain outside checkout') - rows,files=compose(json.loads(a.inputs.read_text()),out) - for name,data in files.items():write(out/name,data) - write(out/'swaps.json',(json.dumps(rows,indent=2)+'\n').encode()) - print('Producer-to-scheduler snapshot ownership staged; guest code and interrupt lifecycle unchanged.') diff --git a/patches/graphics-handoff/run_checks.py b/patches/graphics-handoff/run_checks.py deleted file mode 100644 index 05815e1c..00000000 --- a/patches/graphics-handoff/run_checks.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Execute only ROM-free synthetic fixtures, including the retained suite.""" -from pathlib import Path -import argparse,hashlib,json,os,re,subprocess,sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent;ROOT=AUTH.parents[1] -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__) - for name in ('bin','output'):p.add_argument('--'+name,type=Path,required=True) - a=p.parse_args() - if a.output.exists():raise ValueError('Choose a fresh checks folder') - a.output.mkdir(parents=True) - exe=a.bin/('DKRGraphicsHandoffChecks'+('.exe' if os.name=='nt' else '')) - run=subprocess.run([str(exe.resolve())],capture_output=True,timeout=60,creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) - (a.output/'handoff.log').write_bytes(run.stdout+run.stderr) - if run.returncode or not re.search(rb'37 graphics handoff checks; 8000 concurrent transfers',run.stdout):raise ValueError('Handoff regression failed: '+(run.stdout+run.stderr).decode(errors='replace')) - subprocess.run([sys.executable,'-B',str(ROOT/'patches/render-stability/run_checks.py'), - '--bin',str(a.bin),'--output',str(a.output/'render')],check=True) - result={'game_or_rom_or_rsp_executed':False,'handoff_checks':37,'concurrent_transfers':8000,'exit_code':run.returncode, - 'executable_sha256':hashlib.sha256(exe.read_bytes()).hexdigest(),'retained':json.loads((a.output/'render/CHECKS.json').read_text())} - (a.output/'CHECKS.json').write_text(json.dumps(result,indent=2)+'\n') - print('Producer handoff, rendering, allocator and online synthetic checks passed.') diff --git a/patches/graphics-handoff/tests/copy_cost.cpp b/patches/graphics-handoff/tests/copy_cost.cpp deleted file mode 100644 index e646577a..00000000 --- a/patches/graphics-handoff/tests/copy_cost.cpp +++ /dev/null @@ -1,29 +0,0 @@ -// CPU-only copy/lease measurement. No game, ROM, RSP, GPU or assets. -#include "producer_snapshot.hpp" -#include -#include -#include -#include -#include -int main(){ - using namespace dkr::runtime::graphics_handoff; - using Clock=std::chrono::steady_clock; - ProductionMailbox mailbox;std::vector memory(0x800000,0x35); - std::vector samples;unsigned copies=0;std::size_t bytes=0;std::uint64_t check=0; - for(unsigned i=0;i<1032;++i){ - memory.back()=std::uint8_t(i);const auto start=Clock::now(); - if(!mailbox.capture(memory.data(),0x80001000,0x80001100,[&](auto* dst,const auto* src,auto n){++copies;bytes+=n;std::memcpy(dst,src,n);}))return 1; - auto image=mailbox.take(memory.data(),0x80001000,32); - if(!image || image[0x7FFFFF]!=std::uint8_t(i))return 2; - check+=image[0]+image[0x7FFFFF]; - const auto ms=std::chrono::duration(Clock::now()-start).count(); - if(i>=32)samples.push_back(ms); - } - const auto stats=mailbox.metrics(); - if(copies!=1032 || bytes!=std::size_t(1032)*0x800000 || stats.fallback || stats.mismatch || stats.overflow)return 3; - std::sort(samples.begin(),samples.end()); - std::cout<<"1000 steady-state producer copy/transfers after 32 warmups; mean-ms=" - < -#include -#include -#include -#include -using namespace dkr::runtime::graphics_handoff; -namespace {unsigned checks=0;void check(bool ok,const char* why){++checks;if(!ok)throw std::runtime_error(why);}} -int main(){try{ - Mailbox<0x10000> q;std::vector live(0x10000,0x11); - // Model the actual two-queue ordering: completed valid list -> game - // scheduler message -> asset reuse -> RSP submission -> host render queue. - // The old submission-only capture sees the logged invalid indices. - constexpr unsigned payload=0x3000; - live[(payload+1)^3U]=1;live[(payload+2)^3U]=2;live[(payload+3)^3U]=3; - check(q.capture(live.data(),0x80001000,0x80001100),"Producer capture failed"); - live[(payload+1)^3U]=64;live[(payload+2)^3U]=65; - auto old=q.acquire();std::memcpy(old.get(),live.data(),live.size()); - check(old[(payload+1)^3U]==64 && old[(payload+2)^3U]==65,"Before control did not reproduce late snapshot"); - auto held=q.take(live.data(),0x80001000,32); - check(held && held[(payload+1)^3U]==1 && held[(payload+2)^3U]==2,"Scheduler delay exposed reused payload"); - check(held.get()!=old.get(),"Concurrent owners share memory"); - check(q.metrics().transferred==1 && q.metrics().fallback==0,"Protected path performed a second capture"); - check(!q.take(live.data(),0x80001000,32),"Consumed image reused"); - check(!q.capture(nullptr,0x1000,0x1100),"Null producer accepted"); - check(!q.capture(live.data(),0x80001001,0x80001100),"Unaligned list accepted"); - check(!q.capture(live.data(),0xA0001000,0xA0001100),"Unadmitted segment accepted"); - check(!q.capture(live.data(),0x80001100,0x80001000),"Reversed list accepted"); - check(!q.capture(live.data(),0x1000,0x1000),"Empty list accepted"); - check(!q.capture(live.data(),0x1000,0x10008),"Out-of-range end accepted"); - q.reset(); - live[0x3000]=0x22; - check(q.capture(live.data(),0x1000,0x1100),"Physical root rejected"); - check(!q.take(live.data(),0x80001000,31),"Wrong size matched"); - check(!q.take(live.data(),0x1000,32),"Mismatch left a stale candidate"); - check(!q.capture(live.data(),0x1000,0x1100),"Mismatch did not disable ambiguous matching");q.reset(); - q.capture(live.data(),0x1000,0x1100); - std::vector other(live.size()); - check(!q.take(other.data(),0x1000,32),"Different runtime owner matched"); - q.reset(); - q.capture(live.data(),0x1000,0x1100);q.capture(live.data(),0x2000,0x2100); - check(!q.take(live.data(),0x2000,32),"Out-of-order consume searched a later address"); - check(!q.take(live.data(),0x1000,32),"Out-of-order consume retained stale work"); - q.reset(); - // Duplicate roots are different submissions; keep FIFO image ownership. - live[0x3000]=0x33;q.capture(live.data(),0x1000,0x1100); - live[0x3000]=0x44;q.capture(live.data(),0x1000,0x1100); - auto a=q.take(live.data(),0x1000,32),b=q.take(live.data(),0x1000,32); - check(a && b && a[0x3000]==0x33 && b[0x3000]==0x44,"Recycled root mixed frame order"); - check(held[(payload+1)^3U]==1,"Later frames mutated active lease"); - q.capture(live.data(),0x1000,0x1100);auto m=q.reset(); - check(m.transferred==2,"Transfer accounting changed"); - check(!q.take(live.data(),0x1000,32),"Session reset retained old image"); - check(held[(payload+1)^3U]==1,"Session reset freed active renderer image"); - // More than four pending entries disables early capture for this session. - // Even when two recycled roots collide, the latest image cannot match an - // older in-flight task. Session reset is the only safe way to rearm it. - for(unsigned i=0;i<5;++i)q.capture(live.data(),0x1000+(i&1)*0x100,0x1100+(i&1)*0x100); - check(q.metrics().overflow==1,"Pending metadata not bounded"); - check(!q.take(live.data(),0x1000,32),"Overflow handed new image to old task"); - check(!q.take(live.data(),0x1400,32),"Overflow mismatch left stale latest image"); - check(!q.capture(live.data(),0x1000,0x1100),"Overflow rearmed before session reset"); - q.reset();q.capture(live.data(),0x1000,0x1100); - {auto suppressed=q.take(live.data(),0x1000,32);check(bool(suppressed),"Suppressed frame was not retired");} - live[0x3000]=0x55;q.capture(live.data(),0x1000,0x1100); - auto after_suppressed=q.take(live.data(),0x1000,32); - check(after_suppressed && after_suppressed[0x3000]==0x55,"Suppressed image matched a later task"); - q.reset(); - const bool across_reset=q.capture(live.data(),0x1000,0x1100,[&](auto* dst,const auto* src,auto bytes){ - std::memcpy(dst,src,bytes);q.reset(); - }); - check(!across_reset,"In-flight capture published across session reset"); - check(!q.take(live.data(),0x1000,32),"Old capture entered a new session"); - { - ProductionMailbox full;std::vector ram(0x800000,0xA5);ram.back()=0x12; - unsigned copies=0;std::size_t copied_bytes=0; - check(full.capture(ram.data(),0x807FFF00,0x80800000,[&](auto* dst,const auto* src,auto bytes){ - ++copies;copied_bytes+=bytes;std::memcpy(dst,src,bytes); - }),"Exact 8 MiB boundary rejected"); - ram.back()=0x34;auto image=full.take(ram.data(),0x807FFF00,32); - check(image && image[0x7FFFFF]==0x12,"Upper RAM was not captured"); - check(ram.back()==0x34 && copies==1 && copied_bytes==0x800000,"Snapshot changed live RAM or copied more than once"); - } - // Concurrent producer/consumer transfer with a semaphore-like turn gate. - // Every iteration mutates live memory after capture, then holds the lease - // across a subsequent submission. No game, ROM, RSP or GPU is executed. - q.reset();std::atomic turn{0};std::atomic failed{false}; - constexpr unsigned rounds=8000; - std::thread producer([&]{for(unsigned i=0;i::Snapshot previous{};unsigned previous_id=0; - for(unsigned i=0;i -#include - -namespace dkr::runtime::invites { -namespace { -std::atomic live{false},modal{false},release{false},cancel{false}; -std::atomic shown{0},focus{0}; -std::mutex mutex; -ReturnView returning; -} -void game_running(bool v){live.store(v,std::memory_order_release);} -bool game_running(){return live.load(std::memory_order_acquire);} -bool begin_return(){std::scoped_lock lock(mutex);if(returning.phase!=ReturnPhase::Idle||!game_running())return false;returning={ReturnPhase::Stopping,{}};return true;} -bool return_pending(){std::scoped_lock lock(mutex);return returning.phase==ReturnPhase::Stopping;} -void finish_return(std::string error){ - game_running(false); - {std::scoped_lock lock(mutex);if(returning.phase!=ReturnPhase::Stopping)return;} - // Keep the immutable old session/save route alive until every producer has - // retired. A save failure does not permit admission into a different room. - if(error.empty())netplay::session().disconnect("Leaving this lobby to accept an invitation."); - std::scoped_lock lock(mutex);returning={error.empty()?ReturnPhase::Ready:ReturnPhase::Failed,std::move(error)}; -} -ReturnView return_view(){std::scoped_lock lock(mutex);return returning;} -void clear_return(){std::scoped_lock lock(mutex);if(returning.phase!=ReturnPhase::Stopping)returning={};} -void dialog(bool v){if(modal.exchange(v,std::memory_order_acq_rel)&&!v)release=true;} -bool dialog(){return modal.load(std::memory_order_acquire);} -bool capture(){return dialog()||release.load(std::memory_order_acquire);} -bool release_pending(){return release.load(std::memory_order_acquire);} -void fence_inputs(){release.store(true,std::memory_order_release);} -void release_if_clear(bool clear){if(clear&&!dialog())release=false;} -void visible(std::uint64_t id){shown.store(id,std::memory_order_release);} -void focus_visible(){focus.store(shown.load(std::memory_order_acquire),std::memory_order_release);} -std::uint64_t take_focus(){return focus.exchange(0,std::memory_order_acq_rel);} -void cancel_dialog(){cancel.store(true,std::memory_order_release);} -bool take_cancel(){return cancel.exchange(false,std::memory_order_acq_rel);} -} diff --git a/patches/invite-handoff/native/invite_handoff.hpp b/patches/invite-handoff/native/invite_handoff.hpp deleted file mode 100644 index b2440fc0..00000000 --- a/patches/invite-handoff/native/invite_handoff.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once -#include -#include -#include - -namespace dkr::runtime::invites { -enum class ReturnPhase { Idle, Stopping, Ready, Failed }; -struct ReturnView { ReturnPhase phase=ReturnPhase::Idle; std::string error; }; -void game_running(bool value); -bool game_running(); -bool begin_return(); -bool return_pending(); -// Main/event owner only, after runtime/renderer/save retirement. Never called -// from an ImGui callback or a network worker. -void finish_return(std::string error); -ReturnView return_view(); -void clear_return(); -void dialog(bool value); -bool dialog(); -bool capture(); -bool release_pending(); -void fence_inputs(); -void release_if_clear(bool clear); -void visible(std::uint64_t invite); -void focus_visible(); -std::uint64_t take_focus(); -void cancel_dialog(); -bool take_cancel(); -} diff --git a/patches/invite-handoff/native/invite_ui.inl b/patches/invite-handoff/native/invite_ui.inl deleted file mode 100644 index 008de3a1..00000000 --- a/patches/invite-handoff/native/invite_ui.inl +++ /dev/null @@ -1,247 +0,0 @@ -// UI-owned context survives the game/launcher ImGui context switch. Only IDs -// enter visible notifications; admission keys remain in the private intent. -namespace invite_flow=dkr::runtime::invites; -using LobbyInvitation=dkr::runtime::netplay::FriendLobbyInviteView; -enum class InviteStep { None, Prompt, Stopping, Queued, Joining, Error }; -struct InviteNotice {std::uint64_t id=0;std::string owner;std::chrono::steady_clock::time_point started{};}; -struct InviteIntent { - InviteStep step=InviteStep::None; - std::optional invitation; - bool watch=false,restore_overlay=false,open=false,armed=false,cancelled=false; - std::string error; - std::chrono::steady_clock::time_point started{}; -}; -std::deque g_invite_notices; -InviteIntent g_invite_intent; -bool g_invite_launcher_rom_ready=false; -bool InviteResourcesBusy() { - const auto installs=dkr::store::installations().view(); - bool textures=false;{std::lock_guard lock(g_texture_import_mutex);textures=g_texture_import_state.running;} - return !installs->ready||installs->busy||g_legacy_imports.snapshot().busy||g_code_library.view()->busy|| - g_vehicle_library.view()->busy||g_mod_launch.snapshot().modal||textures||DialogJobRunning(); -} - -std::optional FindLobbyInvitation(std::uint64_t id,const std::string& owner,std::string& error) { - using namespace dkr::runtime::netplay; - const auto snapshot=friend_service().snapshot(); - std::optional found; - for(const auto& i:snapshot->incoming_lobby_invites)if(i.invite_id==id&&(owner.empty()||i.friend_identity==owner)) { - if(found){error="That invitation is ambiguous. Open your invitation list again.";return {};} - found=i; - } - if(!found||found->status!=FriendLobbyInviteStatus::Delivered||found->expires_unix<=std::uint64_t(std::time(nullptr))) { - error="That invitation has expired or was cancelled.";return {}; - } - if(found->compatibility!=DKR_NETWORK_RELEASE_VERSION){error="You and your friend need the same DKR-R build.";return {};} - if(std::any_of(snapshot->friends.begin(),snapshot->friends.end(),[&](const auto& f){return f.identity==found->friend_identity&&f.blocked;})) { - error="Invitations from this player are blocked.";return {}; - } - if(found->lobby_code.empty()||found->admission==secure::Key{}){error="This invitation no longer has a valid admission token.";return {};} - error.clear();return found; -} -void HideInviteNotice(std::uint64_t id) { - std::erase_if(g_invite_notices,[&](const auto& n){return n.id==id;}); - if(!g_invite_notices.empty())g_invite_notices.front().started=std::chrono::steady_clock::now(); - invite_flow::visible(g_invite_notices.empty()?0:g_invite_notices.front().id); -} -bool InviteNoticeArrival(const LobbyInvitation& invitation) { - if(std::any_of(g_invite_notices.begin(),g_invite_notices.end(),[&](const auto& n){return n.id==invitation.invite_id&&n.owner==invitation.friend_identity;}))return true; - if(g_invite_notices.size()>=8)return false; - g_invite_notices.push_back({invitation.invite_id,invitation.friend_identity,std::chrono::steady_clock::now()}); - invite_flow::visible(g_invite_notices.front().id);return true; -} -void CloseInviteIntent() { - const bool restore=g_invite_intent.restore_overlay; - if(g_invite_intent.invitation)g_invite_intent.invitation->admission.fill(0); - g_invite_intent={};invite_flow::dialog(false);invite_flow::clear_return();(void)invite_flow::take_cancel(); - if(invite_flow::game_running())g_overlay_visible.store(restore,std::memory_order_release); -} -void InviteFailure(std::string error) { - if(g_invite_intent.step==InviteStep::None)g_invite_intent.restore_overlay=g_overlay_visible.load(); - g_invite_intent.error=std::move(error);g_invite_intent.step=InviteStep::Error; - g_invite_intent.open=true;g_invite_intent.armed=false;invite_flow::dialog(true); - if(invite_flow::game_running())g_overlay_visible=true; -} -void QueueInviteJoin() { - g_invite_intent.step=InviteStep::Queued;g_invite_intent.started=std::chrono::steady_clock::now(); - g_invite_intent.open=false;invite_flow::dialog(false);invite_flow::fence_inputs(); - g_play_offline_after_leave=false;g_online_mod_host_request.reset(); - if(g_online_mod_sync)g_online_mod_sync->cancel();g_online_mod_watching=false;g_online_mod_notice.clear(); - g_page_navigation_request=kPageOnlineMp;g_online_page.section=OlSection::Play; - g_online_page.open_match_end=false;g_online_failure_modal_requested=g_online_failure_modal_active=false; - g_online_error_notification.clear();g_online_action_status="Preparing to join your friend's lobby..."; - if(dkr::runtime::netplay::session().presentation_view().state==dkr::runtime::netplay::ConnectionState::Failed) - dkr::runtime::netplay::session().disconnect("Accepting a new lobby invitation."); -} -void RequestLobbyInvitation(std::uint64_t id,const std::string& owner) { - if(g_invite_intent.step!=InviteStep::None)return; - std::string error;auto invitation=FindLobbyInvitation(id,owner,error); - if(!invitation){InviteFailure(error);return;} - const auto session=dkr::runtime::netplay::session().presentation_view(); - if(dkr::runtime::netplay::session().presentation_active()&&NormalizeOnlineInvite(session.invite)==NormalizeOnlineInvite(invitation->lobby_code)) { - g_page_navigation_request=kPageOnlineMp;g_overlay_page=kPageOnlineMp;g_online_page.section=OlSection::Lobby; - if(invite_flow::game_running())g_overlay_visible=true;HideInviteNotice(id);return; - } - if(!invite_flow::game_running()&&!g_invite_launcher_rom_ready){InviteFailure("Choose a valid ROM in Play, then accept this invitation again.");return;} - if(!invite_flow::game_running()&&InviteResourcesBusy()){InviteFailure("Finish the current mod, save or file dialog first, then accept this invitation again.");return;} - g_invite_intent.invitation=std::move(invitation);g_invite_intent.watch=g_online_join_watching; - g_invite_intent.restore_overlay=g_overlay_visible.load();g_invite_intent.started=std::chrono::steady_clock::now(); - HideInviteNotice(id); - if(invite_flow::game_running()||dkr::runtime::netplay::session().presentation_active()) { - g_invite_intent.step=InviteStep::Prompt;g_invite_intent.open=true;invite_flow::dialog(true);g_overlay_visible=true; - }else QueueInviteJoin(); -} -void DeclineLobbyInvitation(std::uint64_t id,const std::string& owner) { - if(g_invite_intent.step!=InviteStep::None)return; - std::string error;if(!FindLobbyInvitation(id,owner,error)){InviteFailure(error);return;} - LobbyInvitation declined; - if(!dkr::runtime::netplay::friend_service().respond_lobby_invite(id,false,declined,error))InviteFailure(error); - else HideInviteNotice(id); -} -void PumpInviteLauncher(bool rom_ready,bool manifest_ready) { - g_invite_launcher_rom_ready=rom_ready; - const auto returned=invite_flow::return_view(); - if(g_invite_intent.step==InviteStep::Stopping&&returned.phase!=invite_flow::ReturnPhase::Stopping) { - invite_flow::clear_return(); - if(g_invite_intent.cancelled){CloseInviteIntent();return;} - if(returned.phase!=invite_flow::ReturnPhase::Ready){InviteFailure(returned.error.empty()?"The previous game did not finish safely. The invitation was not joined.":returned.error);return;} - QueueInviteJoin(); - } - if(g_invite_intent.step!=InviteStep::Queued&&g_invite_intent.step!=InviteStep::Joining)return; - using namespace dkr::runtime::netplay; - auto& online=session();const auto view=online.presentation_view(); - if(g_invite_intent.step==InviteStep::Joining) { - if(view.state==ConnectionState::Lobby||view.state==ConnectionState::Loading||view.state==ConnectionState::Running){CloseInviteIntent();return;} - if(view.state==ConnectionState::Failed){InviteFailure(view.status.empty()?"The lobby could not be joined.":view.status);return;} - if(view.state==ConnectionState::Offline){InviteFailure("Joining was cancelled. You are still in the launcher.");return;} - return; // Mod downloads and code consent retain their original UI. - } - if(!g_invite_intent.invitation){InviteFailure("The pending invitation was lost. Please accept it again.");return;} - std::string error;auto current=FindLobbyInvitation(g_invite_intent.invitation->invite_id,g_invite_intent.invitation->friend_identity,error); - if(!current){InviteFailure(error);return;} - if(!rom_ready){InviteFailure("The selected ROM is unavailable. Choose it in Play and accept the invitation again.");return;} - if(!manifest_ready||SharedSaveBusy()||InviteResourcesBusy())return; - if(g_online_mod_sync&&g_online_mod_sync->snapshot().phase!=dkr::mods::online::SyncPhase::Idle) { - g_online_mod_sync->cancel(); - if(std::chrono::steady_clock::now()-g_invite_intent.started>std::chrono::seconds(60))InviteFailure("The previous mod job is still closing. The new lobby was not joined. Try again once it finishes."); - return; - } - if(online.active()){InviteFailure("Another lobby became active. Leave it before accepting this invitation again.");return;} - g_online_join_watching=g_invite_intent.watch;ConfigurePartyJoin(); - g_online_page.join_attempted=true;g_online_page.join_error.clear(); - if(!online.join_friend_invite(current->lobby_code,g_online_player_name,current->admission,error)){InviteFailure(error);return;} - g_joining_friend_invite=*current;g_invite_intent.step=InviteStep::Joining; - g_online_action_status="Joining "+current->friend_display_name+"'s lobby..."; -} -bool InviteUiVisible(){return !g_invite_notices.empty()||g_invite_intent.step!=InviteStep::None;} -void DrawInviteInbox(float width) { - const auto snapshot=dkr::runtime::netplay::friend_service().snapshot(); - bool heading=false; - for(const auto& i:snapshot->incoming_lobby_invites)if(i.status==dkr::runtime::netplay::FriendLobbyInviteStatus::Delivered&&i.expires_unix>std::uint64_t(std::time(nullptr))) { - if(!heading){ImGui::TextUnformatted("LOBBY INVITATIONS");ImGui::Dummy({0,8});heading=true;} - ImGui::PushID(std::to_string(i.invite_id).c_str()); - ImGui::TextWrapped("%s invited you to their lobby.",i.friend_display_name.c_str()); - ImGui::BeginDisabled(g_invite_intent.step!=InviteStep::None); - if(ImGui::Button("Accept invitation",{std::max(1.F,std::min(width,280.F)),42}))RequestLobbyInvitation(i.invite_id,i.friend_identity); - ImGui::EndDisabled();ImGui::PopID();ImGui::Dummy({0,8}); - } - if(heading)ImGui::Separator(); -} -std::string InviteMenuShortcut() { - using namespace dkr::runtime::input; - const auto c=shortcut_controller_binding(ShortcutAction::ToggleOverlay); - auto name=[](ShortcutBinding b,bool controller){ - if(b.primary==kUnbound)return std::string{}; - auto n=controller?controller_binding_name(b.primary):keyboard_binding_name(b.primary); - if(b.secondary!=kUnbound)n+=" + "+(controller?controller_binding_name(b.secondary):keyboard_binding_name(b.secondary));return n; - }; - std::string label="F1 / Esc";const auto keyboard=name(shortcut_keyboard_binding(ShortcutAction::ToggleOverlay),false); - if(!keyboard.empty())label+=" / "+keyboard;const auto controller=name(c,true);if(!controller.empty())label+=" / "+controller; - return label+": respond"; -} -void DrawInviteUi() { - const auto now=std::chrono::steady_clock::now(); - if(const auto focus=invite_flow::take_focus())RequestLobbyInvitation(focus,{}); - constexpr const char* popup="Lobby invitation"; - if(g_invite_intent.open){ImGui::OpenPopup(popup);g_invite_intent.open=false;} - const bool prompt=g_invite_intent.step==InviteStep::Prompt,error=g_invite_intent.step==InviteStep::Error; - if(prompt||error) { - ImGui::SetNextWindowSize({std::min(580.F,std::max(260.F,ImGui::GetIO().DisplaySize.x-36.F)),0},ImGuiCond_Appearing); - if(BeginPaddedModal(popup,ImGuiWindowFlags_AlwaysAutoResize)) { - bool held=false; - for(int key=ImGuiKey_NamedKey_BEGIN;keyfriend_display_name.c_str()); - if(session.host&&dkr::runtime::netplay::session().presentation_active())ImGui::TextWrapped("You are hosting. This will close your lobby for everyone."); - else if(invite_flow::game_running()&&dkr::runtime::netplay::session().presentation_active()&&!dkr::runtime::spectators::party_watching(&dkr::runtime::netplay::session()))ImGui::TextWrapped("Leaving your racing place will end the current match under its normal departure rules."); - else if(invite_flow::game_running()&&!dkr::runtime::netplay::session().presentation_active())ImGui::TextWrapped("This stops the game for all local players. Progress since the last save may be lost."); - ImGui::Dummy({0,8});int role=g_invite_intent.watch?1:0; - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - if(ImGui::Combo("##invite-role",&role,"Racer\0Watch Race\0"))g_invite_intent.watch=role!=0; - } - ImGui::Dummy({0,12});const auto width=ImGui::GetContentRegionAvail().x; - ImGui::BeginDisabled(!g_invite_intent.armed); - const bool clicked_back=ImGui::Button(error?"Close":"Keep playing",{width,42}); - const bool back=g_invite_intent.armed&&(clicked_back||invite_flow::take_cancel()); - ImGui::SetItemDefaultFocus(); - if(back){CloseInviteIntent();ImGui::CloseCurrentPopup();} - if(!error&&g_invite_intent.step==InviteStep::Prompt) { - ImGui::Dummy({0,8}); - if(ImGui::Button("Leave and join",{width,42})) { - std::string failure;const auto current=FindLobbyInvitation(g_invite_intent.invitation->invite_id,g_invite_intent.invitation->friend_identity,failure); - if(!current){InviteFailure(failure);} - else if(invite_flow::game_running()) { - if(invite_flow::begin_return()) { - g_invite_intent.step=InviteStep::Stopping;invite_flow::dialog(false);g_play_offline_after_leave=false; - g_lifecycle_request.store(dkr::runtime::ui::LifecycleRequest::StopGame,std::memory_order_release); - g_overlay_visible=false;ImGui::CloseCurrentPopup(); - } - } else { - dkr::runtime::netplay::session().disconnect("Leaving this lobby to accept an invitation.");QueueInviteJoin();ImGui::CloseCurrentPopup(); - } - } - } - ImGui::EndDisabled();ImGui::EndPopup(); - } - } - if(g_invite_intent.step==InviteStep::Stopping||g_invite_intent.step==InviteStep::Queued) { - ImGui::SetNextWindowPos({ImGui::GetIO().DisplaySize.x*.5F,20},ImGuiCond_Always,{.5F,0}); - ImGui::SetNextWindowBgAlpha(.94F); - if(ImGui::Begin("##invite-progress",nullptr,ImGuiWindowFlags_AlwaysAutoResize|ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing)) { - ImGui::TextUnformatted(g_invite_intent.step==InviteStep::Stopping?"Returning safely to the launcher...":"Preparing to join your friend's lobby..."); - if(ImGui::Button("Cancel joining")) { - if(g_invite_intent.step==InviteStep::Stopping)g_invite_intent.cancelled=true; - else CloseInviteIntent(); - } - }ImGui::End(); - } - if(g_invite_notices.empty())return; - const auto notice=g_invite_notices.front();std::string failure;const auto invitation=FindLobbyInvitation(notice.id,notice.owner,failure); - if(!invitation){HideInviteNotice(notice.id);return;} - const auto screen=ImGui::GetIO().DisplaySize;const float width=std::min(460.F,std::max(270.F,screen.x-36.F)); - const int position=std::clamp(g_friend_online_notification_position,0,3); - ImVec2 at{18,18+OverlayStackOffset(position,true,true)},pivot{0,0}; - if(position==1||position==3){at.x=screen.x-18;pivot.x=1;} - if(position>=2){at.y=screen.y-18-OverlayStackOffset(position,true,true);pivot.y=1;} - ImGui::SetNextWindowPos(at,ImGuiCond_Always,pivot);ImGui::SetNextWindowSize({width,0}); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding,14);ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,{18,14}); - ImGui::SetNextWindowBgAlpha(.94F); - if(ImGui::Begin("##actionable-invite",nullptr,ImGuiWindowFlags_NoDecoration|ImGuiWindowFlags_NoMove|ImGuiWindowFlags_NoNav|ImGuiWindowFlags_NoSavedSettings|ImGuiWindowFlags_NoFocusOnAppearing)) { - ImGui::TextColored(kWarm,"LOBBY INVITE");ImGui::PushTextWrapPos(ImGui::GetCursorPosX()+width-36); - ImGui::TextWrapped("%s invited you to their lobby.",invitation->friend_display_name.c_str()); - ImGui::TextWrapped("%s",InviteMenuShortcut().c_str());ImGui::PopTextWrapPos();ImGui::Dummy({0,8}); - const float button=(ImGui::GetContentRegionAvail().x-ImGui::GetStyle().ItemSpacing.x*2)/3; - ImGui::BeginDisabled(g_invite_intent.step!=InviteStep::None); - if(ImGui::Button("Accept",{button,40}))RequestLobbyInvitation(notice.id,notice.owner);ImGui::SameLine(); - if(ImGui::Button("Decline",{button,40}))DeclineLobbyInvitation(notice.id,notice.owner);ImGui::SameLine(); - if(ImGui::Button("Later",{button,40}))HideInviteNotice(notice.id); - ImGui::EndDisabled(); - if(ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup)&&!g_invite_notices.empty())g_invite_notices.front().started=now; - else if(now-notice.started>std::chrono::seconds(10)&&g_invite_intent.step==InviteStep::None)HideInviteNotice(notice.id); - }ImGui::End();ImGui::PopStyleVar(2); -} diff --git a/patches/invite-handoff/native/release_inputs.inl b/patches/invite-handoff/native/release_inputs.inl deleted file mode 100644 index 3ab3262a..00000000 --- a/patches/invite-handoff/native/release_inputs.inl +++ /dev/null @@ -1,18 +0,0 @@ - // A confirm/back press used by the invitation must be released before - // it can become racing input. Read the already refreshed physical state; - // do not clear or mutate any canonical online controller ports. - if(dkr::runtime::invites::release_pending()) { - bool held=false;int count=0;const auto* keys=SDL_GetKeyboardState(&count); - for(int key=0;key16000; - } - } else for(auto* controller:g_controllers)if(controller) { - for(int button=0;button16000; - } - dkr::runtime::invites::release_if_clear(!held); - } diff --git a/patches/invite-handoff/package.py b/patches/invite-handoff/package.py deleted file mode 100644 index 6a186f02..00000000 --- a/patches/invite-handoff/package.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Allowlisted invitation playtest packages; never launches the resulting game.""" -from pathlib import Path -import argparse -import importlib.util -import json -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] -VERSION='1.0.9-spectator-playtest.3' -def load(name,path): - spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m -def main(a): - if a.output.exists():raise ValueError('Choose a fresh package output; earlier builds are retained') - binary=a.compiled/('libexec/dkr-r/DKR-R-Runtime.exe' if a.platform=='windows' else 'DKR-R') - checked=load('invite_package_audit',AUTH/'audit.py').inspect(a.stage,binary,a.baseline) - base=load('invite_package_allowlist',ROOT/'patches/release-prep/v1.0.7/package_release.py');base.VERSION=VERSION - base.notes=lambda root,label:base.copy(AUTH/'PLAYTEST.txt',root/'README.txt') - a.output.mkdir(parents=True) - root,artifact,files=getattr(base,a.platform)(a) - receipt={'schema':3,'version':VERSION,'platform':a.platform,'arch':a.arch,'artifact':{'name':artifact.name,'sha256':base.sha(artifact),'bytes':artifact.stat().st_size}, - 'files':files,'audit':checked,'published':False,'automatic_updates_signed':False,'validation':checked['validation']} - (a.output/'BUILD-RECEIPT.json').write_text(json.dumps(receipt,indent=2)+'\n',encoding='utf-8') - (a.output/'SHA256SUMS.txt').write_text(base.sha(artifact)+' '+artifact.name+'\n',encoding='utf-8') - base.copy(AUTH/'PLAYTEST.txt',a.output/'PLAYTEST.txt') - print('Invitation playtest packaged without execution:',artifact) -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('platform',choices=('windows','linux')) - for name in ('compiled','template','output','stage','trophy'):p.add_argument('--'+name,type=Path,required=True) - p.add_argument('--baseline',type=Path);p.add_argument('--arch',choices=('x86_64','aarch64'),default='x86_64');p.add_argument('--runtime-image',type=Path);main(p.parse_args()) diff --git a/patches/invite-handoff/pipeline.py b/patches/invite-handoff/pipeline.py deleted file mode 100644 index d237c58b..00000000 --- a/patches/invite-handoff/pipeline.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Actionable invitations, composed only into isolated Patch Pipeline output.""" -from pathlib import Path -import argparse -import hashlib -import json -import re -import sys -sys.dont_write_bytecode=True -AUTH=Path(__file__).resolve().parent -ROOT=AUTH.parents[1] - -def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest() -def once(t,a,b): - if t.count(a)!=1:raise ValueError('Invite handoff anchor changed: '+a[:160]) - return t.replace(a,b,1) -def write(p,data): - p.parent.mkdir(parents=True,exist_ok=True) - if not p.is_file() or p.read_bytes()!=data:p.write_bytes(data) -def ui(t,out): - t='#include "invite_handoff.hpp"\n#include \n'+t - anchor='std::optional g_joining_friend_invite;' - t=once(t,anchor,anchor+''' -bool InviteNoticeArrival(const dkr::runtime::netplay::FriendLobbyInviteView&); -void RequestLobbyInvitation(std::uint64_t,const std::string&); -void DeclineLobbyInvitation(std::uint64_t,const std::string&); -void PumpInviteLauncher(bool,bool); -bool InviteUiVisible(); -void DrawInviteUi(); -void DrawInviteInbox(float); -''') - old=''' if (g_online_notifications.push( - dkr::runtime::ui_notifications::Kind::LobbyInvite, - "LOBBY INVITE", - invite.friend_display_name + - " invited you to race. Open DKR-R ONLINE - OPEN LOBBIES to accept.", - std::chrono::steady_clock::now()))''' - t=once(t,old,' if (InviteNoticeArrival(invite))') - match=re.search(r'^#include "([^"\n]*runtime_online_ui[.]inl)"',t,re.M) - if not match:raise ValueError('Final online UI include is missing') - source=Path(match[1]);s=source.read_text(encoding='utf-8') - s=once(s,' room.action_disabled = !can_join || !compatible;', - ' room.action_disabled = !compatible;') - old=''' g_online_page.join_attempted = true; - g_online_page.join_error.clear(); - ConfigurePartyJoin(); - if (session().join_friend_invite(invite.lobby_code, g_online_player_name, - invite.admission, error)) { - g_joining_friend_invite = invite; - } else { - OlNotify(error); - }''' - s=once(s,old,' RequestLobbyInvitation(invite.invite_id,invite.friend_identity);') - old=''' FriendLobbyInviteView declined{}; - if (!friend_service().respond_lobby_invite(invite.invite_id, false, declined, error)) { - OlNotify(error); - }''' - s=once(s,old,' DeclineLobbyInvitation(invite.invite_id,invite.friend_identity);') - anchor=' const OnlineFrame frame{view, launcher, rom_ready, active, joining, in_lobby, busy, has_space};' - s=once(s,anchor,anchor+'\n if(!launcher||active)DrawInviteInbox(std::max(available_width,1.F));') - dest=out/'runtime_online_ui.inl';write(dest,s.encode()) - t=once(t,match[0],'#include "'+dest.as_posix()+'"\n#include "'+(out/'invite_ui.inl').as_posix()+'"') - t=once(t,' PumpOnlineMods();\n if (rom_ready &&', - ' PumpOnlineMods();\n PumpInviteLauncher(rom_ready,online_manifest.has_value());\n if (rom_ready &&') - # Both launcher and running-game notification passes share this entry. - t=once(t,'void DrawOnlineNotification() {','void DrawOnlineNotification() {\n DrawInviteUi();') - t=once(t,' return g_online_notifications.current(std::chrono::steady_clock::now()) !=', - ' return InviteUiVisible() || g_online_notifications.current(std::chrono::steady_clock::now()) !=') - t=once(t,' const bool show_online_notification = OnlineNotificationActive();', - ' const bool show_online_notification = OnlineNotificationActive() || InviteUiVisible();') - t=once(t,' if (show_overlay || show_camera_tuning) {', - ' if (show_overlay || show_camera_tuning || dkr::runtime::invites::dialog()) {') - anchor=''' ImGui_ImplSDL2_ProcessEvent(&event); - if (HandleInputCaptureEvent(&event)) {''' - t=once(t,anchor,''' ImGui_ImplSDL2_ProcessEvent(&event); - if(dkr::runtime::invites::dialog()&&(event.type==SDL_KEYDOWN||event.type==SDL_KEYUP|| - event.type==SDL_TEXTINPUT||event.type==SDL_MOUSEMOTION||event.type==SDL_MOUSEBUTTONDOWN|| - event.type==SDL_MOUSEBUTTONUP||event.type==SDL_MOUSEWHEEL||event.type==SDL_CONTROLLERBUTTONDOWN|| - event.type==SDL_CONTROLLERBUTTONUP||event.type==SDL_CONTROLLERAXISMOTION)) { - if((event.type==SDL_KEYDOWN&&!event.key.repeat&&event.key.keysym.scancode==SDL_SCANCODE_ESCAPE)|| - (event.type==SDL_CONTROLLERBUTTONDOWN&&event.cbutton.button==SDL_CONTROLLER_BUTTON_B))dkr::runtime::invites::cancel_dialog(); - return; - } - if (HandleInputCaptureEvent(&event)) {''') - t=once(t,' dkr::runtime::hud::editor::active() || (g_capture_action != -1 && !g_capture_finished) ||', - ' dkr::runtime::invites::dialog() || dkr::runtime::hud::editor::active() || (g_capture_action != -1 && !g_capture_finished) ||') - t=once(t,' const bool next = !g_overlay_visible.load(std::memory_order_acquire);', - ' if(!g_overlay_visible.load(std::memory_order_acquire))dkr::runtime::invites::focus_visible();\n const bool next = !g_overlay_visible.load(std::memory_order_acquire);') - t=once(t,' return g_overlay_visible.load(std::memory_order_acquire)||dkr::sdk2::tools_open.load();', - ' return dkr::runtime::invites::capture()||g_overlay_visible.load(std::memory_order_acquire)||dkr::sdk2::tools_open.load();') - t=once(t,'void dkr::runtime::ui::update_camera_mouse() {\n UpdateCameraMouseOrbit();', - 'void dkr::runtime::ui::update_camera_mouse() {\n if(dkr::runtime::invites::capture()&&!g_overlay_visible.load())return;\n UpdateCameraMouseOrbit();') - # Focus belongs to the modal. Other overlay shortcut/sidebar handlers must - # not reinterpret its back/confirm events as racing/menu commands. - anchor=''' if (event == nullptr) { - return false; - }''' - start=t.index('bool dkr::runtime::ui::handle_runtime_event(');head,tail=t[:start],t[start:] - tail=once(tail,anchor,anchor+''' - if(dkr::runtime::invites::dialog()) { - if((event->type==SDL_KEYDOWN&&!event->key.repeat&&event->key.keysym.scancode==SDL_SCANCODE_ESCAPE)|| - (event->type==SDL_CONTROLLERBUTTONDOWN&&event->cbutton.button==SDL_CONTROLLER_BUTTON_B)) { - dkr::runtime::invites::cancel_dialog();return true; - } - std::scoped_lock guard(g_inspector_guard);if(!g_inspector)return true; - std::scoped_lock frame_lock(g_inspector->frameMutex);g_inspector->handleSdlEvent(event);return true; - } -''');t=head+tail - t=once(t,'void dkr::runtime::ui::report_online_game_end(std::string message, bool lobby_retained) {', - 'void dkr::runtime::ui::report_online_game_end(std::string message, bool lobby_retained) {\n if(dkr::runtime::invites::return_pending())return;') - return t -def main_source(t,out): - t='#include "invite_handoff.hpp"\n'+t - t=once(t,' std::atomic runtime_finished{false};', - ' dkr::runtime::invites::game_running(true);\n std::atomic runtime_finished{false};') - t=once(t,' if(runtime_failure==nullptr) {', - ' if(runtime_failure==nullptr&&dkr::runtime::ui::lifecycle_request()!=dkr::runtime::ui::LifecycleRequest::StopGame) {') - # There is another retirement in startup error recovery. Restrict the hook - # to the post-runtime teardown, including its separate records writer. - anchor=' const auto save_failure = dkr::runtime::saves::runtime_save_failure();' - if t.count(anchor)!=1:raise ValueError('Final save failure collection changed') - pos=t.index(anchor);head,tail=t[:pos],t[pos:] - tail=once(tail,anchor,anchor+'\n std::string invite_records_failure;') - tail=once(tail,' {std::string records_error;if(!dkr::runtime::saves::records::finish(records_error)) {', - ' {std::string records_error;if(!dkr::runtime::saves::records::finish(records_error)) {\n invite_records_failure=records_error;') - tail=once(tail,' dkr::runtime::saves::retire_runtime_save_context();', - ' dkr::runtime::saves::retire_runtime_save_context();\n dkr::runtime::invites::game_running(false);') - # This is after worker destruction, Pak route retirement and save failure - # collection, never in the rendering callback which accepted the invitation. - tail=once(tail,' dkr::runtime::ui::reset_lifecycle_request();', - ''' dkr::runtime::invites::finish_return(!save_failure.empty()?save_failure:!invite_records_failure.empty()?invite_records_failure:!mod_failure.empty()?mod_failure: - owned_failed?std::string("The previous online game did not finish safely. Review its error before joining."):std::string{}); - dkr::runtime::ui::reset_lifecycle_request();''') - return head+tail -def platform(t,out): - t='#include "invite_handoff.hpp"\n'+t - anchor=' const bool blocked = dkr::runtime::ui::overlay_visible()' - return once(t,anchor,(AUTH/'native/release_inputs.inl').read_text()+'\n'+anchor) -def main(a): - out=a.output.resolve() - if out.is_relative_to(ROOT):raise ValueError('Invite handoff stage must be outside the checkout') - out.mkdir(parents=True,exist_ok=True) - for p in (AUTH/'native').iterdir(): - if p.is_file():write(out/p.name,p.read_bytes()) - rows=json.loads(a.inputs.read_text(encoding='utf-8'));swaps=[] - functions={'runtime_ui.cpp':ui,'game_main.cpp':main_source,'runtime_platform.cpp':platform} - for row in rows: - old=Path(row['path']) - if old.name not in functions:continue - raw=old.read_bytes();t=functions[old.name](raw.decode().replace('\r\n','\n'),out) - def adjacent(m): - p=old.parent/m[1];return '#include "'+p.resolve().as_posix()+'"' if p.is_file() else m[0] - t=re.sub(r'^#include "([^"\n]+)"',adjacent,t,flags=re.M) - new=out/old.name;write(new,t.encode());swaps.append({**row,'new':str(new),'input_sha256':hashlib.sha256(raw).hexdigest(),'output_sha256':sha(new)}) - if {Path(r['new']).name for r in swaps}!=set(functions):raise ValueError('Missing final invite UI, lifecycle or platform sources') - (out/'swaps.json').write_text(json.dumps(swaps,indent=2)+'\n',encoding='utf-8') - print('Actionable lobby invitations and post-retirement joining staged. No game/tests executed.') -if __name__=='__main__': - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inputs',type=Path,required=True);p.add_argument('--output',type=Path,required=True);main(p.parse_args()) diff --git a/patches/launcher-polish/NativeOverlay.cmake b/patches/launcher-polish/NativeOverlay.cmake deleted file mode 100644 index e93c81dd..00000000 --- a/patches/launcher-polish/NativeOverlay.cmake +++ /dev/null @@ -1,41 +0,0 @@ -# Compose after Camera++, replacing only the three reviewed UI inputs in a -# fresh stage. Checked-out sources, dependencies and older outputs stay intact. -include("${CMAKE_CURRENT_LIST_DIR}/../camera-plus/NativeOverlay.cmake") -if(PROJECT_NAME STREQUAL "DKRRuntimePreparation") - function(dkr_apply_launcher_polish) - find_package(Python3 REQUIRED COMPONENTS Interpreter) - if(NOT DKR_LAUNCHER_UI_STAGE OR NOT EXISTS "${DKR_LAUNCHER_UI_STAGE}/launcher-ui-receipt.json") - message(FATAL_ERROR "Launcher cleanup requires a fresh verified Patch Pipeline stage") - endif() - execute_process(COMMAND "${Python3_EXECUTABLE}" "${DKRPORT_ROOT}/patches/launcher-polish/pipeline.py" - verify --output "${DKR_LAUNCHER_UI_STAGE}" RESULT_VARIABLE result) - if(NOT result EQUAL 0) - message(FATAL_ERROR "Launcher cleanup composition failed inspection") - endif() - get_target_property(sources DKRPortGame SOURCES) - set(replaced) - set(count 0) - foreach(source IN LISTS sources) - if(source STREQUAL "${DKR_CAMERA_STAGE}/src/game/runtime_ui.cpp") - set(next "${DKR_LAUNCHER_UI_STAGE}/src/game/runtime_ui.cpp") - foreach(property COMPILE_OPTIONS COMPILE_DEFINITIONS INCLUDE_DIRECTORIES SKIP_PRECOMPILE_HEADERS LANGUAGE SKIP_UNITY_BUILD_INCLUSION) - get_property(is_set SOURCE "${source}" DIRECTORY "${DKRPORT_ROOT}/runtime-recomp" PROPERTY ${property} SET) - if(is_set) - get_property(value SOURCE "${source}" DIRECTORY "${DKRPORT_ROOT}/runtime-recomp" PROPERTY ${property}) - set_property(SOURCE "${next}" DIRECTORY "${DKRPORT_ROOT}/runtime-recomp" PROPERTY ${property} "${value}") - endif() - endforeach() - math(EXPR count "${count}+1") - list(APPEND replaced "${next}") - else() - list(APPEND replaced "${source}") - endif() - endforeach() - if(NOT count EQUAL 1) - message(FATAL_ERROR "Expected exactly one staged launcher translation unit") - endif() - set_property(TARGET DKRPortGame PROPERTY SOURCES "${replaced}") - message(STATUS "Launcher cleanup: Save/Help disclosures removed; support actions precede final credits") - endfunction() - cmake_language(DEFER CALL dkr_apply_launcher_polish) -endif() diff --git a/patches/launcher-polish/pipeline.py b/patches/launcher-polish/pipeline.py deleted file mode 100644 index bde44696..00000000 --- a/patches/launcher-polish/pipeline.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Compose the launcher cleanup into NEW Patch Pipeline output only.""" -from pathlib import Path -import argparse -import json -import sys - -sys.dont_write_bytecode = True -ROOT = Path(__file__).resolve().parents[2] -AUTH = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT / 'scripts')) -from camera_plus_pipeline import digest, new_output, replace, verify - -FILES = ('runtime_ui.cpp', 'runtime_saves_ui.inl', 'runtime_details_ui.inl') - - -def authored(): - return {p.relative_to(ROOT).as_posix(): digest(p) for p in sorted(AUTH.iterdir()) if p.is_file()} - - -def transformed(name, text): - if name == 'runtime_ui.cpp': - return replace(text, ''' { - PaddockEnter enter(3); - DrawPageDisclosure("versions", "ROM versions and compatibility", width, [](float inner) { - PaddockText(PaddockReading(13.0F, false, 1.55F), PaddockRgb(0xABC0CC), - "Legacy patches need the ROM version they were made for. Native tracks don't need patch preparation.", inner); - }); - PaddockGap(16.0F); - } -''', '') - if name == 'runtime_saves_ui.inl': - return replace(text, ''' PaddockGap(13.0F); - if (ImGui::CollapsingHeader("Save location")) { - SavesNote(PathUtf8(info.path), inner, 14.0F); - if (SavesRaceButton("COPY LOCATION")) SDL_SetClipboardText(PathUtf8(info.path).c_str()); - } -''', '') - if name == 'runtime_details_ui.inl': - text = replace(text, 'void DrawAboutSupport(float width) {', - (AUTH / 'support_actions.inl').read_text() + '\nvoid DrawAboutSupport(float width) {') - start = text.index(' const bool stacked = inner <= 520.0F;', text.index('void DrawAboutSupport')) - end = text.index(' if (!g_support_action_status.empty()) {', start) - text = text[:start] + ' DrawSupportActionRow(inner);\n' + text[end:] - text = replace(text, ' if (support) DrawAboutSupport(width);\n', '') - return replace(text, ' DrawUpdatesCard(width);\n AboutSectionTitle("Credits", width);', - ' DrawUpdatesCard(width);\n if (support) DrawAboutSupport(width);\n AboutSectionTitle("Credits", width);') - raise ValueError('Unreviewed launcher input') - - -def payloads(baseline): - return {name: transformed(name, (baseline / 'src/game' / name).read_text()) for name in FILES} - - -def stage(baseline, output): - baseline = baseline.resolve(strict=True) - verify(baseline) - output = new_output(output, baseline) - sources = payloads(baseline) - game = output / 'src/game' - game.mkdir(parents=True) - for name, text in sources.items(): - (game / name).write_text(text, newline='\n') - receipt = {'schema': 1, 'baseline': baseline.relative_to(ROOT).as_posix(), - 'baseline_receipt': digest(baseline / 'camera-receipt.json'), 'authored': authored(), - 'files': {name: digest(game / name) for name in FILES}} - (output / 'launcher-ui-receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') - inspect(output) - - -def inspect(output): - receipt = json.loads((output / 'launcher-ui-receipt.json').read_text()) - baseline = ROOT / receipt['baseline'] - verify(baseline) - if receipt['schema'] != 1 or receipt['authored'] != authored(): - raise ValueError('Launcher authorship changed; create a fresh stage') - if receipt['baseline_receipt'] != digest(baseline / 'camera-receipt.json'): - raise ValueError('Camera baseline changed') - game = output / 'src/game' - if set(receipt['files']) != set(FILES) or {p.name for p in game.iterdir()} != set(FILES): - raise ValueError('Unexpected launcher stage files') - for name, expected in payloads(baseline).items(): - path = game / name - if path.is_symlink() or digest(path) != receipt['files'][name] or path.read_text() != expected: - raise ValueError('Launcher source differs from reviewed transformation: ' + name) - print('Launcher cleanup: three staged UI inputs inspected; Camera++ baseline preserved') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('mode', choices=('stage', 'verify')) - parser.add_argument('--baseline', type=Path) - parser.add_argument('--output', type=Path, required=True) - args = parser.parse_args() - if args.mode == 'stage': - stage(args.baseline, args.output) - else: - inspect(args.output.resolve(strict=True)) diff --git a/patches/launcher-polish/support_actions.inl b/patches/launcher-polish/support_actions.inl deleted file mode 100644 index 4506de36..00000000 --- a/patches/launcher-polish/support_actions.inl +++ /dev/null @@ -1,67 +0,0 @@ -// Text-sized actions stay on one row. A narrow viewport scrolls horizontally -// rather than clipping labels or growing into several full-width rows. -void DrawSupportActionRow(float width) { - enum class Action { Export, Logs, Dumps, Reports, Performance, StartupLogs }; - struct Button { const char* label; const char* hint; Action action; }; - constexpr Button buttons[]{ -#if defined(__ANDROID__) - {"PERFORMANCE", "Record a performance report", Action::Performance}, - {"STARTUP LOGS", "Export Android startup logs", Action::StartupLogs}, -#endif - {"EXPORT SUMMARY", "Save a support summary", Action::Export}, - {"LOGS", "Open the logs folder", Action::Logs}, - {"CRASH DUMPS", "Open the crash dumps folder", Action::Dumps}, - {"REPORTS", "Open saved support reports", Action::Reports}, - }; - constexpr float gap = 8.0F; - constexpr float font = 17.0F; - float total = -gap; - for (const Button& button : buttons) total += PaddockRaceButtonWidth(button.label, font) + gap; - const float height = 44.0F + (total > width ? ImGui::GetStyle().ScrollbarSize + 2.0F : 0.0F); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {0.0F, 0.0F}); - ImGui::SetNextWindowContentSize({total, 44.0F}); - if (ImGui::BeginChild("##support-actions", {width, height}, false, - ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NavFlattened | - ImGuiWindowFlags_NoBackground)) { - RaceButtonLook look; - look.font_px = font; - look.hover_in = 0.12F; - for (std::size_t index = 0; index < std::size(buttons); ++index) { - const Button& button = buttons[index]; - if (index != 0U) ImGui::SameLine(0.0F, gap); - const bool pressed = PaddockRaceButton(button.label, - {PaddockRaceButtonWidth(button.label, font), 44.0F}, look); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", button.hint); - if (!pressed) continue; - switch (button.action) { - case Action::Export: { - std::filesystem::path output; - std::string error; - g_support_action_status = dkr::runtime::support::export_report(BuildSupportReport(), output, error) - ? "Support report saved." : error; - break; - } - case Action::Logs: - dkr::runtime::support::open_directory(dkr::runtime::support::log_directory(), g_support_action_status); - break; - case Action::Dumps: - dkr::runtime::support::open_directory(dkr::runtime::support::crash_dump_directory(), g_support_action_status); - break; - case Action::Reports: - dkr::runtime::support::open_directory(dkr::runtime::support::support_report_directory(), g_support_action_status); - break; -#if defined(__ANDROID__) - case Action::Performance: dkr::runtime::android::performance_tools(); break; - case Action::StartupLogs: dkr::runtime::android::export_diagnostics(); break; -#endif - default: break; - } - } - } - ImGui::EndChild(); - ImGui::PopStyleVar(); -#if defined(__ANDROID__) - PaddockGap(8.0F); - DetailText("Review logs before sharing; they may contain paths and session details.", width, kSetMuted, 0.0F); -#endif -} diff --git a/patches/library-workflow/RELEASE-NOTES.txt b/patches/library-workflow/RELEASE-NOTES.txt deleted file mode 100644 index 35bb59ba..00000000 --- a/patches/library-workflow/RELEASE-NOTES.txt +++ /dev/null @@ -1,31 +0,0 @@ -DKR-R mod library website workflow update - -FTP picker correction (r3): -- Fix /v1/assets returning service_error when opening existing FTP files. -- Replace the six-part compound SELECT with batched smaller queries compatible - with D1; retain All / Not in Mod Library for packages and images. -- Add a full Worker regression test using Cloudflare's local D1 runtime. -- Worker-only update from r1/r2; workflowRevision is now 20261023-2. - -Correction r2: declare optional batch draft ID/revision during construction so -the Worker editor's type checker accepts both properties. Behavior is retained. - -Fixed consecutive file uploads and stale image selection between listings. -Added image/package replacement, removal, clean form reset and optional defaults -for Publish and add another. Staff publication now uses consistent permissions. - -Both FTP file pickers have All and Not in Mod Library filters. Current releases, -older releases, archived listings, saved catalogue drafts and saved batches count -as associated. Matching uses the full file address, including folder identity. - -Added a bulk workspace for up to 50 entries per batch, local files or existing -FTP packages, manual/suggested artwork matching, shared metadata, sequential FTP -uploads, per-entry results, publication review, saved batches and recovery. - -Transient upload failures retry five times before stopping for attention. -Confirmed server offsets are reconciled before retrying a chunk. - -Legacy Mods is displayed as Vehicles on the website. Its existing category ID, -FTP folders and launcher behavior are preserved. - -Website/API only. No launcher changes or storage migration. diff --git a/patches/library-workflow/pipeline.py b/patches/library-workflow/pipeline.py deleted file mode 100644 index 94e49485..00000000 --- a/patches/library-workflow/pipeline.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Compose a website/API-only update from pinned, read-only portal sources.""" -from pathlib import Path -import argparse -import difflib -import hashlib -import json -import shutil -import urllib.request -import zipfile - -ROOT = Path(__file__).resolve().parents[2] -AUTH = Path(__file__).resolve().parent -CAPSULE = ROOT / 'patches/release-prep/v1.0.7/source-capsule-r2' -REVISION = 'library-20261022-workflow-1' -OBJECTS = { - 'app.js': '1831349d7ab11e40a3fe9cefc1b5a389459b9588c1661f5fba602b4e475940ca', - 'api-store.js': '64bc62e4a41028f710a63ff11e0bbc9e9f3096744b4172a3865b69cc9a5d6e96', - 'uploads-client.js': '9de9a251b6522ff4162e1bce04c8d35365b2eeac41ec7a75e96c7164eb6d9f93', - 'worker-single-file.mjs': 'cc32731c1f72abf9b0629cb5eb49da19ae33661bed607020b999ecd7a027e348', -} -SNAPSHOTS = { - 'index.html': '04ea98e6120649c234dc21c9d2e639e82945de58af1f1de675c56ad423bfc4e0', - 'assets/hosted-files.js': '716be714d89df60e19c40b80cf1ab954f684c665b14945ec720859d91bbeefe0', -} - - -def sha(raw): - return hashlib.sha256(raw).hexdigest() - - -def once(text, before, after): - if text.count(before) != 1: - raise ValueError('Portal anchor changed: ' + before[:100]) - return text.replace(before, after) - - -def frozen(name): - digest = OBJECTS[name] - raw = (CAPSULE / (digest + '.txt')).read_bytes() - if sha(raw) != digest: - raise ValueError('Frozen source changed: ' + name) - return raw.decode('utf-8') - - -def capture(): - for name, digest in SNAPSHOTS.items(): - target = AUTH / 'baseline' / name - if target.exists(): - raw = target.read_bytes() - else: - raw = urllib.request.urlopen('https://positron-portal.com/dkr-r/' + name, timeout=30).read() - if sha(raw) != digest: - raise ValueError('Deployed baseline differs; review before rebasing: ' + name) - if not target.exists(): - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(raw) - print('Public baseline snapshots verified; no remote writes.') - - -def authors(): - return {p.relative_to(AUTH).as_posix(): sha(p.read_bytes()) for p in sorted(AUTH.rglob('*')) - if p.is_file() and '__pycache__' not in p.parts and p.suffix != '.pyc'} - - -def compose(output): - import importlib.util - if output.exists() or not output.resolve().is_relative_to(ROOT / 'build'): - raise ValueError('Choose a NEW stage inside build/.') - for name, digest in SNAPSHOTS.items(): - if sha((AUTH / 'baseline' / name).read_bytes()) != digest: - raise ValueError('Baseline changed: ' + name) - spec = importlib.util.spec_from_file_location('workflow_transforms', AUTH / 'transforms.py') - transforms = importlib.util.module_from_spec(spec) - spec.loader.exec_module(transforms) - ftp = output / 'FTP-PATCH' - assets = ftp / 'assets' - assets.mkdir(parents=True) - for name in ('app.js', 'api-store.js', 'uploads-client.js'): - (assets / name).write_text(transforms.transform(name, frozen(name)), encoding='utf-8', newline='\n') - hosted = (AUTH / 'baseline/assets/hosted-files.js').read_text(encoding='utf-8') - (assets / 'hosted-files.js').write_text(transforms.hosted(hosted), encoding='utf-8', newline='\n') - for name in ('batch-client.js', 'workflow.css'): - shutil.copy2(AUTH / name, assets / name) - index = (AUTH / 'baseline/index.html').read_text(encoding='utf-8').replace('library-20261018-uploads-1', REVISION) - index = once(index, 'characters, legacy mods, magic codes', 'characters, vehicles, magic codes') - index = once(index, ' \n \n