diff --git a/.github/rpg-runtime/README.md b/.github/rpg-runtime/README.md new file mode 100644 index 000000000..b2f5a881d --- /dev/null +++ b/.github/rpg-runtime/README.md @@ -0,0 +1,18 @@ +# Retrom Web candidate + +The upstream baseline is `e8294c9070a4fb29a98e6d551fbf773c01214201`. +`retrom-web` retains the host-controlled worker, input and checkpoint ABI; +upstream's independent JSPI `web` backend remains available unchanged. +`retrom-web-meta` retains the metadata parser shipped in the release contract. + +The runner uses upstream miniaudio with host-driven stereo PCM output instead +of opening its own audio device. Frame pacing follows upstream's effective game +speed. Checkpoint v2 preserves fractional priority values and the current array +representation. Resources that v2 cannot serialize (including particle pools, +spatial audio emitters, vertex resources and game speed overrides) explicitly +disable checkpoints rather than produce an incomplete save. + +Run the source contract, Web host tests and native checkpoint test listed in +`AGENTS.md`, then build with `.github/rpg-runtime/build-candidate.sh` into an empty +directory. Local candidate validation does not promote the remote maintenance +branch or change any released Provider pin. diff --git a/.github/rpg-runtime/build-candidate.sh b/.github/rpg-runtime/build-candidate.sh new file mode 100755 index 000000000..0b7070017 --- /dev/null +++ b/.github/rpg-runtime/build-candidate.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +output=${1:?absolute empty output directory is required} +python3 "$root/.github/rpg-runtime/candidate_descriptor.py" prepare "$output" +"$root/.github/rpg-runtime/build-web.sh" "$output" +commit=$(git -C "$root" rev-parse HEAD) +python3 "$root/.github/rpg-runtime/verify-release.py" --output "$output" \ + --repository https://github.com/retrom-project/Butterscotch \ + --tag retrom-core-ge8294c9070a4-r999999 --commit "$commit" +rm "$output/rpg-runtime-release.json" +python3 "$root/.github/rpg-runtime/candidate_descriptor.py" finalize "$output" --core-id butterscotch diff --git a/.github/rpg-runtime/build-web.sh b/.github/rpg-runtime/build-web.sh new file mode 100755 index 000000000..d4a0d32ff --- /dev/null +++ b/.github/rpg-runtime/build-web.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +output=${1:?output directory is required} +mkdir -p "$output" +output=$(cd "$output" && pwd) +if find "$output" -mindepth 1 -print -quit | grep -q .; then + echo "RPG_RUNTIME_OUTPUT_NOT_EMPTY" >&2 + exit 1 +fi + +mkdir -p "$root/.cache" +build_root=$(mktemp -d "$root/.cache/retrom-web-build.XXXXXX") +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT INT TERM + +docker run --rm \ + --platform linux/amd64 \ + --hostname rpg-runtime-butterscotch \ + --user "$(id -u):$(id -g)" \ + --env HOME=/work/home \ + --volume "$root:/source:ro" \ + --volume "$build_root:/work" \ + --workdir /work \ + emscripten/emsdk@sha256:af45409f3199d88db4b1b03af0098532c8fb33a375ac257463eeb0a622870d06 \ + bash -euo pipefail -c ' + mkdir -p "$HOME" full meta + emcmake cmake -S /source -B full \ + -DWERROR=ON -DPLATFORM=retrom-web -DAUDIO_BACKEND=miniaudio -DVIDEO_BACKEND=none -DCMAKE_BUILD_TYPE=Release + cmake --build full --target butterscotch + emcmake cmake -S /source -B meta \ + -DWERROR=ON -DPLATFORM=retrom-web-meta -DAUDIO_BACKEND=none -DVIDEO_BACKEND=none -DCMAKE_BUILD_TYPE=Release + cmake --build meta --target butterscotch + ' + +install -m 0644 "$build_root/full/butterscotch.mjs" "$output/butterscotch.mjs" +install -m 0644 "$build_root/full/butterscotch.wasm" "$output/butterscotch.wasm" +install -m 0644 "$build_root/meta/butterscotch-meta.mjs" "$output/butterscotch-meta.mjs" +install -m 0644 "$build_root/meta/butterscotch-meta.wasm" "$output/butterscotch-meta.wasm" +install -m 0644 "$root/LICENSE" "$output/LICENSE" diff --git a/.github/rpg-runtime/candidate_descriptor.py b/.github/rpg-runtime/candidate_descriptor.py new file mode 100755 index 000000000..4cde21c63 --- /dev/null +++ b/.github/rpg-runtime/candidate_descriptor.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Validate a candidate directory and emit the common Retrom core descriptor.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import stat +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def git_bytes(*arguments: str) -> bytes: + result = subprocess.run(["git", "-C", str(ROOT), *arguments], capture_output=True, check=False) + if result.returncode != 0: + raise SystemExit("PFB_WORKTREE_INVALID") + return result.stdout + + +def digest_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def digest_file(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def checked_output(raw: str, *, empty: bool) -> Path: + output = Path(raw) + if not output.is_absolute() or output.is_symlink() or not output.is_dir(): + raise SystemExit("PFB_CANDIDATE_OUTPUT_INVALID") + if empty and any(output.iterdir()): + raise SystemExit("PFB_CANDIDATE_OUTPUT_INVALID") + return output + + +def is_worktree_root(root: Path) -> bool: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + capture_output=True, check=False, text=True, + ) + return result.returncode == 0 and Path(result.stdout.strip()).resolve() == root.resolve() + + +def source_tree_sha256() -> str: + raw_paths = git_bytes("ls-files", "--cached", "--others", "--exclude-standard", "-z") + raw_modes = git_bytes("ls-files", "--stage", "-z") + tracked = {} + for item in raw_modes.split(b"\0"): + if item: + prefix, path = item.split(b"\t", 1) + parts = prefix.split(b" ") + tracked[path.decode("utf-8")] = ( + parts[0].decode("ascii"), parts[1].decode("ascii"), + ) + records = [] + for raw in sorted(set(raw_paths.split(b"\0"))): + if not raw: + continue + relative = raw.decode("utf-8") + target = ROOT / relative + try: + info = target.lstat() + except FileNotFoundError: + continue + tracked_mode, tracked_object = tracked.get(relative, (None, None)) + if tracked_mode == "160000" and stat.S_ISDIR(info.st_mode): + mode = "160000" + nested = {"indexCommit": tracked_object, "worktreeCommit": None, "sourceTreeSha256": None} + if is_worktree_root(target): + nested["worktreeCommit"] = subprocess.check_output( + ["git", "-C", str(target), "rev-parse", "HEAD"], text=True, + ).strip() + nested["sourceTreeSha256"] = nested_source_tree_sha256(target) + file_digest = digest_bytes( + json.dumps(nested, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + ) + elif stat.S_ISLNK(info.st_mode): + mode = "120000" + file_digest = digest_bytes(os.readlink(target).encode("utf-8")) + elif stat.S_ISREG(info.st_mode): + mode = tracked_mode or ("100755" if info.st_mode & stat.S_IXUSR else "100644") + file_digest = digest_file(target) + else: + raise SystemExit("PFB_WORKTREE_INVALID") + records.append({"path": relative, "mode": mode, "sha256": file_digest}) + canonical = json.dumps(records, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + return digest_bytes(canonical) + + +def nested_source_tree_sha256(root: Path) -> str: + raw_paths = subprocess.check_output( + ["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"], + ) + raw_modes = subprocess.check_output(["git", "-C", str(root), "ls-files", "--stage", "-z"]) + tracked = {} + for item in raw_modes.split(b"\0"): + if item: + prefix, path = item.split(b"\t", 1) + parts = prefix.split(b" ") + tracked[path.decode("utf-8")] = (parts[0].decode("ascii"), parts[1].decode("ascii")) + records = [] + for raw in sorted(set(raw_paths.split(b"\0"))): + if not raw: + continue + relative = raw.decode("utf-8") + target = root / relative + try: + info = target.lstat() + except FileNotFoundError: + continue + tracked_mode, tracked_object = tracked.get(relative, (None, None)) + if tracked_mode == "160000" and stat.S_ISDIR(info.st_mode): + nested = {"indexCommit": tracked_object, "worktreeCommit": None, "sourceTreeSha256": None} + if is_worktree_root(target): + nested["worktreeCommit"] = subprocess.check_output( + ["git", "-C", str(target), "rev-parse", "HEAD"], text=True, + ).strip() + nested["sourceTreeSha256"] = nested_source_tree_sha256(target) + mode = "160000" + file_digest = digest_bytes(json.dumps( + nested, ensure_ascii=False, separators=(",", ":"), sort_keys=True, + ).encode("utf-8")) + elif stat.S_ISLNK(info.st_mode): + mode = "120000" + file_digest = digest_bytes(os.readlink(target).encode("utf-8")) + elif stat.S_ISREG(info.st_mode): + mode = tracked_mode or ("100755" if info.st_mode & stat.S_IXUSR else "100644") + file_digest = digest_file(target) + else: + raise SystemExit("PFB_WORKTREE_INVALID") + records.append({"path": relative, "mode": mode, "sha256": file_digest}) + canonical = json.dumps(records, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + return digest_bytes(canonical) + + +def finalize(output: Path, core_id: str) -> None: + fork = json.loads((ROOT / "retrom-fork.json").read_text(encoding="utf-8")) + expected = sorted(name for name in fork["releaseAssets"] if name != "rpg-runtime-release.json") + actual = sorted(path.name for path in output.iterdir()) + if actual != expected: + raise SystemExit("PFB_CANDIDATE_OUTPUT_INVALID") + branch = git_bytes("symbolic-ref", "--quiet", "--short", "HEAD").decode("utf-8").strip() + commit = git_bytes("rev-parse", "HEAD").decode("ascii").strip() + dirty = bool(git_bytes("status", "--porcelain=v1", "-z")) + files = [ + {"filename": name, "sizeBytes": (output / name).stat().st_size, "sha256": digest_file(output / name)} + for name in expected + ] + descriptor = { + "schemaVersion": 1, + "kind": "RETROM_CORE_CANDIDATE_V1", + "coreId": core_id, + "repository": fork["forkRepository"], + "branch": branch, + "commit": commit, + "dirty": dirty, + "sourceTreeSha256": source_tree_sha256(), + "adapterAbi": fork["adapterAbi"], + "files": files, + } + (output / "retrom-core-candidate.json").write_text( + json.dumps(descriptor, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("prepare", "finalize")) + parser.add_argument("output") + parser.add_argument("--core-id") + args = parser.parse_args() + output = checked_output(args.output, empty=args.action == "prepare") + if args.action == "finalize": + if not args.core_id: + raise SystemExit("PFB_CANDIDATE_OUTPUT_INVALID") + finalize(output, args.core_id) + + +if __name__ == "__main__": + main() diff --git a/.github/rpg-runtime/test-checkpoint.sh b/.github/rpg-runtime/test-checkpoint.sh new file mode 100755 index 000000000..187fcc5c1 --- /dev/null +++ b/.github/rpg-runtime/test-checkpoint.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +mkdir -p "$root/.cache" +work=$(mktemp -d "$root/.cache/checkpoint-test.XXXXXX") +trap 'rm -rf "$work"' EXIT +cd "$root" +${CC:-cc} -std=gnu99 -DENABLE_WAD14 -DENABLE_WAD16 -DENABLE_WAD17 \ + -ffunction-sections -fdata-sections -Isrc -Isrc/video -Ivendor/stb/ds \ + tests/retrom_checkpoint_test.c src/data_win.c src/gml_array.c src/gml_method.c \ + src/instance.c src/int_rvalue_hashmap.c src/json_reader.c src/json_writer.c \ + src/string_builder.c src/stb_ds.c src/log.c src/runner_gamepad.c -Wl,--gc-sections -lm -o "$work/test" +"$work/test" diff --git a/.github/rpg-runtime/test-gamepad.sh b/.github/rpg-runtime/test-gamepad.sh new file mode 100644 index 000000000..c9d0ba3e6 --- /dev/null +++ b/.github/rpg-runtime/test-gamepad.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +mkdir -p "$root/.cache" +work=$(mktemp -d "$root/.cache/gamepad-test.XXXXXX") +trap 'rm -rf "$work"' EXIT +cd "$root" +${CC:-cc} -std=gnu99 -ffunction-sections -fdata-sections -Isrc -Ivendor/stb/ds \ + tests/retrom_gamepad_test.c src/runner_gamepad.c -Wl,--gc-sections -lm -o "$work/test" +"$work/test" +${CC:-cc} -std=gnu99 -DENABLE_WAD14 -DENABLE_WAD16 -DENABLE_WAD17 \ + -ffunction-sections -fdata-sections -Isrc -Isrc/video -Ivendor/stb/ds \ + -Ivendor/md5 -Ivendor/sha1 -Ivendor/base64 \ + tests/retrom_joystick_test.c src/runner_gamepad.c src/log.c \ + src/gml_array.c src/gml_method.c src/instance.c src/int_rvalue_hashmap.c \ + src/string_builder.c src/stb_ds.c \ + -Wl,--gc-sections -lm -o "$work/joystick" +"$work/joystick" diff --git a/.github/rpg-runtime/test-lazy-data.sh b/.github/rpg-runtime/test-lazy-data.sh new file mode 100644 index 000000000..fd37d1063 --- /dev/null +++ b/.github/rpg-runtime/test-lazy-data.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +mkdir -p "$root/.cache" +work=$(mktemp -d "$root/.cache/lazy-data-test.XXXXXX") +trap 'rm -rf "$work"' EXIT +cd "$root" +${CC:-cc} -std=gnu99 -DENABLE_WAD14 -DENABLE_WAD16 -DENABLE_WAD17 \ + -ffunction-sections -fdata-sections -Isrc -Ivendor/stb/ds \ + tests/retrom_lazy_data_test.c src/data_win.c src/binary_reader.c src/stb_ds.c src/log.c \ + -Wl,--gc-sections -Wl,--wrap=fread -lm -o "$work/test" +"$work/test" "$work/data-XXXXXX" diff --git a/.github/rpg-runtime/verify-release.py b/.github/rpg-runtime/verify-release.py new file mode 100755 index 000000000..d6073f927 --- /dev/null +++ b/.github/rpg-runtime/verify-release.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Validate Butterscotch browser assets and emit release metadata.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + + +TAG = re.compile(r"^retrom-core-ge8294c9070a4-r[1-9][0-9]*(-rc\.[1-9][0-9]*)?$") +COMMIT = re.compile(r"^[0-9a-f]{40}$") +BASELINE = "e8294c9070a4fb29a98e6d551fbf773c01214201" + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + value.update(chunk) + return value.hexdigest() + + +def require_file(path: Path, minimum: int) -> None: + if path.is_symlink() or not path.is_file() or path.stat().st_size < minimum: + raise SystemExit(f"RPG_RUNTIME_RELEASE_ASSET_INVALID:{path.name}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--commit", required=True) + args = parser.parse_args() + if TAG.fullmatch(args.tag) is None or COMMIT.fullmatch(args.commit) is None: + raise SystemExit("RPG_RUNTIME_RELEASE_IDENTITY_INVALID") + if args.repository != "https://github.com/retrom-project/Butterscotch": + raise SystemExit("RPG_RUNTIME_RELEASE_REPOSITORY_INVALID") + + names = [ + "butterscotch.mjs", "butterscotch.wasm", + "butterscotch-meta.mjs", "butterscotch-meta.wasm", "LICENSE", + ] + minimum = [50_000, 1_000_000, 20_000, 50_000, 10_000] + paths = [args.output / name for name in names] + for path, size in zip(paths, minimum, strict=True): + require_file(path, size) + for path in (paths[1], paths[3]): + if path.read_bytes()[:8] != b"\x00asm\x01\x00\x00\x00": + raise SystemExit(f"RPG_RUNTIME_RELEASE_WASM_INVALID:{path.name}") + javascript = paths[0].read_text(encoding="utf-8") + for marker in ( + "_setRunnerPaused", "_setGamepadConnected", "_setGamepadButton", + "_setGamepadAxis", "_setGamepads", "_isRunnerCheckpointAvailable", + "_createRunnerCheckpoint", "_restoreRunnerCheckpoint", + "runnerReady", "runnerExit", + ): + if marker not in javascript: + raise SystemExit("RPG_RUNTIME_RELEASE_BRIDGE_INVALID") + + assets = [ + {"filename": path.name, "observedSha256": digest(path), "sizeBytes": path.stat().st_size} + for path in paths + ] + metadata = { + "adapterAbi": "butterscotch-checkpoint-v2", + "assets": assets, + "commit": args.commit, + "digestPolicy": "OBSERVED_CACHE_INTEGRITY_ONLY", + "repository": args.repository, + "schemaVersion": 1, + "sourceCommits": {"engine": BASELINE}, + "tag": args.tag, + } + (args.output / "rpg-runtime-release.json").write_text( + json.dumps(metadata, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/rpg-runtime/verify-source.py b/.github/rpg-runtime/verify-source.py new file mode 100755 index 000000000..79076468f --- /dev/null +++ b/.github/rpg-runtime/verify-source.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Validate the fixed Butterscotch fork baseline and Web host bridge.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +BASELINE = "e8294c9070a4fb29a98e6d551fbf773c01214201" + + +def require(path: str, markers: tuple[str, ...]) -> None: + text = (ROOT / path).read_text(encoding="utf-8") + if any(marker not in text for marker in markers): + raise SystemExit(f"RPG_RUNTIME_SOURCE_CONTRACT_INVALID:{path}") + + +def main() -> int: + manifest = json.loads((ROOT / "retrom-fork.json").read_text(encoding="utf-8")) + expected = { + "schemaVersion": 1, + "forkRepository": "https://github.com/retrom-project/Butterscotch", + "defaultBranch": "retrom/ge8294c9070a4", + "upstreamMirrorBranch": "main", + "upstreams": [{ + "role": "engine", + "repository": "https://github.com/ButterscotchRunner/Butterscotch", + "refType": "COMMIT", + "ref": BASELINE, + "commit": BASELINE, + }], + "releaseTagPattern": ( + r"^retrom-core-ge8294c9070a4-r[1-9][0-9]*" + r"(-rc\.[1-9][0-9]*)?$" + ), + "adapterAbi": "butterscotch-checkpoint-v2", + "releaseAssets": [ + "butterscotch.mjs", "butterscotch.wasm", + "butterscotch-meta.mjs", "butterscotch-meta.wasm", + "LICENSE", "rpg-runtime-release.json", + ], + } + if manifest != expected: + raise SystemExit("RPG_RUNTIME_FORK_MANIFEST_INVALID") + require("src/retrom-web/main.c", ( + "setRunnerPaused", "setGamepadConnected", "setGamepadButton", + "setGamepadAxis", "setGamepads", "createRunnerCheckpoint", "restoreRunnerCheckpoint", + "getRunnerCheckpointStatus", + "isRunnerCheckpointAvailable", "runnerReady", "runnerExit", + )) + require("CMakeLists.txt", ( + "'_getContentReadSlot'", "'_registerContentFile'", + "'_setRunnerPaused'", "'_setGamepadConnected'", + "'_setGamepadButton'", "'_setGamepadAxis'", "'_setGamepads'", + "'_createRunnerCheckpoint'", "'_restoreRunnerCheckpoint'", + "'_getRunnerCheckpointStatus'", "-sEXIT_RUNTIME=1", + "'GL'", + )) + require("src/retrom-web/content_fs.cpp", ("getContentReadSlot", "registerContentFile", "emscripten_futex_wait")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/rpg-runtime-quality.yml b/.github/workflows/rpg-runtime-quality.yml new file mode 100644 index 000000000..8ed9b2cbf --- /dev/null +++ b/.github/workflows/rpg-runtime-quality.yml @@ -0,0 +1,48 @@ +name: RPG Runtime Quality + +on: + pull_request: + branches: ["retrom/ge8294c9070a4"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rpg-runtime-quality-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate fork source contract + run: python3 .github/rpg-runtime/verify-source.py + - name: Run Web host contract tests + run: python3 tests/test_retrom_web_host.py + - name: Test checkpoint round trips + run: .github/rpg-runtime/test-checkpoint.sh + - name: Test lazy content reads + run: bash .github/rpg-runtime/test-lazy-data.sh + - name: Gamepad slot regression + run: bash .github/rpg-runtime/test-gamepad.sh + - name: Build Web assets + run: .github/rpg-runtime/build-web.sh "$RUNNER_TEMP/rpg-runtime-output" + - name: Validate candidate assets + run: | + python3 .github/rpg-runtime/verify-release.py \ + --output "$RUNNER_TEMP/rpg-runtime-output" \ + --repository "https://github.com/$GITHUB_REPOSITORY" \ + --tag retrom-core-ge8294c9070a4-r1-rc.1 \ + --commit "$GITHUB_SHA" + - name: Upload branch integration candidate + uses: actions/upload-artifact@v4 + with: + name: rpg-runtime-butterscotch-${{ github.sha }} + path: ${{ runner.temp }}/rpg-runtime-output + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/rpg-runtime-release.yml b/.github/workflows/rpg-runtime-release.yml new file mode 100644 index 000000000..94ad6a259 --- /dev/null +++ b/.github/workflows/rpg-runtime-release.yml @@ -0,0 +1,67 @@ +name: RPG Runtime Release + +on: + push: + tags: ["retrom-core-ge8294c9070a4-r*"] + +permissions: + contents: write + +concurrency: + group: rpg-runtime-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate release identity + run: | + set -euo pipefail + [[ "$GITHUB_REF_NAME" =~ ^retrom-core-ge8294c9070a4-r[1-9][0-9]*(-rc\.[1-9][0-9]*)?$ ]] + git fetch --force origin \ + "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME" + test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag + test "$(git rev-list -n 1 "refs/tags/$GITHUB_REF_NAME")" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + git fetch --no-tags origin \ + '+refs/heads/retrom/ge8294c9070a4:refs/remotes/origin/retrom/ge8294c9070a4' + git merge-base --is-ancestor "$GITHUB_SHA" origin/retrom/ge8294c9070a4 + git merge-base --is-ancestor e8294c9070a4fb29a98e6d551fbf773c01214201 "$GITHUB_SHA" + test -z "$(git rev-list --min-parents=2 e8294c9070a4fb29a98e6d551fbf773c01214201.."$GITHUB_SHA")" + python3 .github/rpg-runtime/verify-source.py + python3 tests/test_retrom_web_host.py + - name: Test checkpoint round trips + run: .github/rpg-runtime/test-checkpoint.sh + - name: Test lazy content reads + run: bash .github/rpg-runtime/test-lazy-data.sh + - name: Gamepad slot regression + run: bash .github/rpg-runtime/test-gamepad.sh + - name: Build Web assets + run: .github/rpg-runtime/build-web.sh "$RUNNER_TEMP/rpg-runtime-output" + - name: Validate and describe assets + run: | + python3 .github/rpg-runtime/verify-release.py \ + --output "$RUNNER_TEMP/rpg-runtime-output" \ + --repository "https://github.com/$GITHUB_REPOSITORY" \ + --tag "$GITHUB_REF_NAME" \ + --commit "$GITHUB_SHA" + - name: Create immutable-tag release + env: + GH_TOKEN: ${{ github.token }} + run: | + prerelease=() + if [[ "$GITHUB_REF_NAME" == *-rc.* ]]; then prerelease+=(--prerelease); fi + gh release create "$GITHUB_REF_NAME" "${prerelease[@]}" \ + --verify-tag --target "$GITHUB_SHA" \ + --title "Butterscotch Web $GITHUB_REF_NAME" --generate-notes \ + "$RUNNER_TEMP/rpg-runtime-output/butterscotch.mjs" \ + "$RUNNER_TEMP/rpg-runtime-output/butterscotch.wasm" \ + "$RUNNER_TEMP/rpg-runtime-output/butterscotch-meta.mjs" \ + "$RUNNER_TEMP/rpg-runtime-output/butterscotch-meta.wasm" \ + "$RUNNER_TEMP/rpg-runtime-output/LICENSE" \ + "$RUNNER_TEMP/rpg-runtime-output/rpg-runtime-release.json" diff --git a/.gitignore b/.gitignore index fb3756c01..446c84d93 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ CMakeUserPresets.json compat/config.mk /.vs *.log +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..60f89c694 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Retrom Butterscotch fork maintenance rules + +This fork builds the GameMaker: Studio browser runner consumed by +`retrom-project/retrom-runtime`. It remains independent of Retrom application APIs, +databases, review workflows, credentials, and private games. + +## Repository identity + +- `main` is an unmodified, fast-forward-only mirror of `upstream/main`. +- `retrom/ge8294c9070a4` is the Retrom maintenance baseline and default + branch. Changes and release tags originate there, never from `main`. +- `upstream` must point to + `https://github.com/ButterscotchRunner/Butterscotch.git`. +- `retrom-fork.json` is the machine-readable baseline and release contract. + Never replace its fixed upstream commit with a floating branch. +- Updating `main` may only fast-forward it to `upstream/main`. A new fixed + baseline requires a reviewed `sync/upstream-g<12-hex-commit>` branch and a + new `retrom/g<12-hex-commit>` maintenance branch. + +## Branches and commits + +- Use short-lived `fix/*`, `feat/*`, `build/*`, or + `sync/upstream-` branches created from `retrom/ge8294c9070a4`. +- Branch names use lowercase ASCII and hyphens. Do not create `temp`, `clean`, + `final`, `runtime-clean`, parallel maintenance branches, or branches named + after an agent or user. +- Keep downstream changes as small reviewable commits and squash or rebase PRs + onto the maintenance branch; release ancestry must not contain merge commits + after the fixed upstream baseline. +- Never force-push, move immutable tags, or delete another contributor's work. + +## Runtime boundary + +- This fork owns the core Web build and its host bridge. It must not import + `retrom-runtime` or Retrom source code, HTTP routes, database types, or UI. +- A stable Web release must support lifecycle ready/exit signals, pause/resume, + standard browser gamepads, and a bounded checkpoint that restores directly + in a fresh runtime instance without requiring the game's own load menu. +- Checkpoint ABI `butterscotch-checkpoint-v2` includes bounded serialization of + GameMaker map/list/queue/stack/priority/grid pools and preserves pool IDs. + Do not reintroduce a blanket data-structure blocker or label a different wire + format as v2; buffers, motion-planning grids, structs and persistent-room + snapshots, particle pools, spatial audio emitters, vertex resources and game + speed overrides remain unavailable until they have exact round-trip tests. +- A core-owned exit must be observable by the host exactly once. Once exited, + checkpoint creation must fail and all input must be released. +- Tests use only the repository's redistributable, non-commercial fixtures. + Never download or commit private/commercial games, credentials, or saves. +- Do not describe the persistent game save directory as an instant checkpoint. + A candidate missing direct checkpoint restore may be used for local prototype + work, but must not receive a stable release tag. + +## Quality and releases + +- Before pushing, run `python3 .github/rpg-runtime/verify-source.py`, + `python3 tests/test_retrom_web_host.py`, and `.github/rpg-runtime/test-checkpoint.sh`. +- Web changes must also run + `.github/rpg-runtime/build-web.sh ` and + `.github/rpg-runtime/verify-release.py` with a valid candidate identity. +- PRs to `retrom/ge8294c9070a4` must pass + `.github/workflows/rpg-runtime-quality.yml`. +- Release tags are `retrom-core-ge8294c9070a4-rN`, with optional `-rc.N` only + for integration candidates. Increment `rN` for any source, build, asset, or + adapter-contract change on this baseline. +- Existing `rpg-runtime-*` tags are immutable historical records. Never create + another tag in that retired namespace. +- Tags are annotated and immutable. The tag workflow is the only supported way + to build and publish the Web assets and `rpg-runtime-release.json`; never + publish aliases such as `latest` or `stable`. +- Repository, tag, tag commit, asset filename, and adapter ABI define release + identity. Observed SHA-256 values are cache-integrity diagnostics only. + +Do not add Retrom host-product logic, games, credentials, or private test data. diff --git a/CMakeLists.txt b/CMakeLists.txt index 61dc49334..e88460d34 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,7 +115,7 @@ option(ENABLE_WAD14 "Enable support for WAD Version 14 and below" ON) option(ENABLE_WAD16 "Enable support for WAD Version 15/16" ON) option(ENABLE_WAD17 "Enable support for WAD Version 17" ON) option(ENABLE_LEGACY_GL "Enable the legacy OpenGL renderer" ON) -if(PLATFORM STREQUAL "switch" OR PLATFORM STREQUAL "web") +if(PLATFORM STREQUAL "switch" OR PLATFORM STREQUAL "web" OR PLATFORM STREQUAL "retrom-web") set(ENABLE_LEGACY_GL OFF CACHE BOOL "Enable the legacy OpenGL renderer" FORCE) endif() option(ENABLE_MODERN_GL "Enable the modern OpenGL renderer" ON) @@ -156,7 +156,7 @@ file(GLOB SOURCES src/*.c) # These platforms haven't been converted to the refactored system yet. # This list should NEVER grow, all new platforms should use the new system. -if(PLATFORM STREQUAL "ps2" OR PLATFORM STREQUAL "ps3" OR PLATFORM STREQUAL "android") +if(PLATFORM STREQUAL "ps2" OR PLATFORM STREQUAL "ps3" OR PLATFORM STREQUAL "android" OR PLATFORM STREQUAL "retrom-web" OR PLATFORM STREQUAL "retrom-web-meta") list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/loop.c") endif() @@ -614,6 +614,73 @@ if(PLATFORM STREQUAL "cli" OR PLATFORM STREQUAL "vita" OR PLATFORM STREQUAL "swi ${CMAKE_SOURCE_DIR}/vendor/gamecontrollerdb.txt $/gamecontrollerdb.txt ) +elseif(PLATFORM STREQUAL "retrom-web") + enable_language(CXX) + target_sources(butterscotch PRIVATE src/retrom-web/content_fs.cpp) + target_include_directories(butterscotch SYSTEM PRIVATE "${EMSCRIPTEN_ROOT_PATH}/system/lib/wasmfs") + set_target_properties(butterscotch PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) + if (NOT ENABLE_MODERN_GL) + message(FATAL_ERROR "Web requires modern gl!") + endif() + file(GLOB GL_SOURCES src/gl/*.c src/gl_common/*.c src/image/*.c) + target_sources(butterscotch PRIVATE ${GL_SOURCES}) + target_include_directories(butterscotch PRIVATE ${CMAKE_SOURCE_DIR}/src/gl) + target_include_directories(butterscotch PRIVATE ${CMAKE_SOURCE_DIR}/src/gl_common) + target_include_directories(butterscotch PRIVATE ${CMAKE_SOURCE_DIR}/src/image) + + file(GLOB DEBUG_FONT_SOURCES src/debug_font/*.c) + target_sources(butterscotch PRIVATE ${DEBUG_FONT_SOURCES}) + target_include_directories(butterscotch PRIVATE ${CMAKE_SOURCE_DIR}/src/debug_font) + + # stb_image + target_include_directories(butterscotch PUBLIC vendor/stb/image) + + # bzip2 + target_link_libraries(butterscotch PRIVATE bzip2 stb_ds sha1 stb_vorbis) + + set_target_properties(butterscotch PROPERTIES SUFFIX ".mjs") + + target_link_options( + butterscotch + PRIVATE + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sENVIRONMENT=worker" + "-sWASMFS=1" + "-sEXPORTED_RUNTIME_METHODS=['HEAP8', 'HEAPU8', 'HEAP32', 'HEAPU32', 'HEAPF32', 'ccall', 'cwrap', 'GL', 'specialHTMLTargets', 'UTF8ToString']" + "-sEXPORTED_FUNCTIONS=['_main', '_getContentReadSlot', '_registerContentFile', '_mountOpfs', '_startRunner', '_stopRunner', '_setRunnerPaused', '_setGamepadConnected', '_setGamepadButton', '_setGamepadAxis', '_setGamepads', '_isRunnerCheckpointAvailable', '_getRunnerCheckpointStatus', '_createRunnerCheckpoint', '_getRunnerCheckpointSize', '_restoreRunnerCheckpoint', '_malloc', '_free', '_getKeyDownPtr', '_getKeyUpPtr', '_getKeyCount', '_setAudioSampleRate', '_pullAudioFrames']" + "-sOFFSCREENCANVAS_SUPPORT=1" + "-sOFFSCREEN_FRAMEBUFFER=1" + "-sMIN_WEBGL_VERSION=2" + "-sMAX_WEBGL_VERSION=2" + "-sFULL_ES3=1" + "-sALLOW_MEMORY_GROWTH" + "-sSHARED_MEMORY=1" + "-sPTHREAD_POOL_SIZE=4" + "-sEXIT_RUNTIME=1" + "-pthread" + ) + + target_compile_options(butterscotch PRIVATE "-pthread") +elseif(PLATFORM STREQUAL "retrom-web-meta") + set_target_properties(butterscotch PROPERTIES + SUFFIX ".mjs" + OUTPUT_NAME "butterscotch-meta" + ) + + target_link_libraries(butterscotch PRIVATE stb_ds sha1 bzip2) + + target_link_options( + butterscotch + PRIVATE + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sENVIRONMENT=web" + "-sEXPORTED_RUNTIME_METHODS=['HEAPU8', 'ccall', 'cwrap', 'UTF8ToString', 'FS']" + "-sEXPORTED_FUNCTIONS=['_main', '_parseDataWin', '_freeDataWin', '_getGameName', '_getGameDisplayName', '_getMajorVersion', '_getMinorVersion', '_getRelease', '_getBuild', '_getDefaultWindowWidth', '_getDefaultWindowHeight', '_malloc', '_free']" + "-sALLOW_MEMORY_GROWTH=1" + "-sFORCE_FILESYSTEM=1" + ) elseif(PLATFORM STREQUAL "android") if (NOT ENABLE_MODERN_GL) message(FATAL_ERROR "Android requires modern gl!") diff --git a/docs/retrom-content-io.md b/docs/retrom-content-io.md new file mode 100644 index 000000000..d10944f40 --- /dev/null +++ b/docs/retrom-content-io.md @@ -0,0 +1,45 @@ +# Web content reads + +The Web host registers a generic read-only WasmFS file tree using +`registerContentFile(absolutePath, numericId, sizeBytes)` before `startRunner`. +Registration, directory enumeration and stat do not read file bytes. Paths and +IDs are supplied by the host; the core has no HTTP, project-index, authentication, +cache or game-specific loading rules. Saves remain in the separate writable +overlay selected by the host. + +`getContentReadSlot()` returns an address in shared Wasm memory. The host must +service this slot before calling `startRunner` and until the instance is disposed. +The slot contains sixteen atomic 32-bit words followed by a 256 KiB payload: + +| Word | Meaning | +| --- | --- | +| 0 | State: idle 0, requested 1, successful 2, closed 4 | +| 1 | Content-read ABI version, currently 1 | +| 2 | Monotonically increasing request sequence | +| 4 | Returned byte count | +| 6 | Closed flag | +| 7 | Requested byte count, at most 256 KiB | +| 9 | Opaque registered file ID | +| 10, 11 | Unsigned offset, low and high 32 bits | + +Other words are reserved. Native reads are serialized across threads and clamp +at EOF. After publishing a request the core notifies word 0 and waits up to 15 +seconds. The host validates bounds, reads exactly the requested bytes, writes the +payload/count, changes requested to successful with compare-exchange and notifies. +The core copies the payload before returning the state to idle. On cancellation +or failure the host sets the closed flag/state and notifies, so waiting native +threads wake. The slot cannot reopen. Its memory prefix remains valid if the +shared Wasm memory grows. The host never has to execute JS on a paused core +thread to service a read. + +GameMaker TXTR blobs and AUDO size headers/payloads load on first use. Lazy chunks +bypass the parser's per-chunk bulk read, including external audio groups. Other +parsed chunks keep the existing strategy. This does not guarantee that every +game reads only a fraction of its content: game code may request all of it. +Audio playback keeps the existing Web buffering behavior for each played file. + +The checkpoint format remains `butterscotch-checkpoint-v2`. Deploy the new Web +core and a host implementing content-read version 1 together; the old checkpoint +ABI alone does not imply support for this file bridge. Tests use generated sparse +GameMaker chunks (`bash .github/rpg-runtime/test-lazy-data.sh`), never game names, +asset lists or commercial payloads. diff --git a/docs/retrom-gamepad-input.md b/docs/retrom-gamepad-input.md new file mode 100644 index 000000000..6712aebd1 --- /dev/null +++ b/docs/retrom-gamepad-input.md @@ -0,0 +1,44 @@ +# Web gamepad snapshots + +The Web bridge accepts a complete input frame with +`setGamepads(const float* values, int32_t count)`. Each device occupies 22 float32 +values: connected, 17 standard buttons, then four axes. `count` is bounded by +`MAX_GAMEPADS`; slots beyond it and disconnected slots have zero input. Buttons +and axes are clamped to their respective ranges. The caller retains the buffer; +the core copies it synchronously under the runner's input lock. + +Hosts must preserve device indices and empty slots. A complete snapshot is +published under one lock, so the runner cannot sample partial discovery or +partially updated controls between individual host calls. Existing single-field +exports remain available for old callers, but cannot provide this guarantee. + +`gamepad_get_device_count()` returns the highest occupied slot plus one (zero +when none are connected). GML can enumerate that range and use +`gamepad_is_connected()` to find controllers after holes. Connected count remains +separate from this enumeration bound. + +The core keeps devices independent. The game determines which controller(s) +control which player; there are no game-specific selection rules in this bridge. +Pair the snapshot-capable Web core with its updated host adapter; this export is +checked by the release verifier. + +The legacy `joystick_*` API has two one-based channels, independent of modern +`gamepad_*` physical indices. It retains assignments for connected devices, +prefers the historical slot-0/slot-1 correspondence for unassigned channels, and +fills remaining channels with connected devices from other slots. All legacy +discovery, axis, button, direction, POV and metadata queries use that same +assignment. A later lower-numbered device cannot displace a connected one; +disconnect frees only the affected channel. At most two distinct devices are +exposed through this legacy API, with no merged controls or game-name checks. + +Checkpoint v2 JSON includes optional `joystickDevices` channel assignments +(two one-based physical slots; zero means unassigned). Restore validates the +range and uniqueness before changing runner state. Older v2 checkpoints without +this field retain the historical slot-0/slot-1 defaults. Only channel identity +is restored: live buttons, axes and connection state come from the host on the +first resumed frame. Device identity is bounded by the host's slot identity; +the core cannot recognize identical controllers if the host reassigns indices. + +Run `python3 tests/test_retrom_web_host.py` and +`bash .github/rpg-runtime/test-gamepad.sh` for bridge/slot regressions, followed by +the Web build and real browser input, hotplug and fresh-instance restore checks. diff --git a/retrom-fork.json b/retrom-fork.json new file mode 100644 index 000000000..171830260 --- /dev/null +++ b/retrom-fork.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "forkRepository": "https://github.com/retrom-project/Butterscotch", + "defaultBranch": "retrom/ge8294c9070a4", + "upstreamMirrorBranch": "main", + "upstreams": [ + { + "role": "engine", + "repository": "https://github.com/ButterscotchRunner/Butterscotch", + "refType": "COMMIT", + "ref": "e8294c9070a4fb29a98e6d551fbf773c01214201", + "commit": "e8294c9070a4fb29a98e6d551fbf773c01214201" + } + ], + "releaseTagPattern": "^retrom-core-ge8294c9070a4-r[1-9][0-9]*(-rc\\.[1-9][0-9]*)?$", + "adapterAbi": "butterscotch-checkpoint-v2", + "releaseAssets": [ + "butterscotch.mjs", + "butterscotch.wasm", + "butterscotch-meta.mjs", + "butterscotch-meta.wasm", + "LICENSE", + "rpg-runtime-release.json" + ] +} diff --git a/src/audio/miniaudio/ma_audio_system.c b/src/audio/miniaudio/ma_audio_system.c index d70b441be..33bf8477f 100644 --- a/src/audio/miniaudio/ma_audio_system.c +++ b/src/audio/miniaudio/ma_audio_system.c @@ -111,28 +111,35 @@ static void maInit(AudioSystem* audio, DataWin* dataWin, FileSystem* fileSystem) arrput(ma->base.audioGroups, dataWin); ma->fileSystem = fileSystem; - ma_device_config deviceConfig = ma_device_config_init(ma_device_type_playback); - deviceConfig.playback.format = ma_format_f32; - deviceConfig.playback.channels = 2; - deviceConfig.periods = 3; - deviceConfig.periodSizeInMilliseconds = 10; - deviceConfig.dataCallback = ma_engine_data_callback_internal; - deviceConfig.pUserData = &ma->engine; - ma_result deviceResult = ma_device_init(NULL, &deviceConfig, &ma->device); - if (deviceResult != MA_SUCCESS) { - logError("Audio: Failed to initialize playback device (error %d)\n", deviceResult); - return; - } ma_engine_config config = ma_engine_config_init(); - config.pDevice = &ma->device; - + if (ma->hostSampleRate > 0) { + config.noDevice = MA_TRUE; + config.channels = 2; + config.sampleRate = (ma_uint32)ma->hostSampleRate; + } else { + ma_device_config deviceConfig = ma_device_config_init(ma_device_type_playback); + deviceConfig.playback.format = ma_format_f32; + deviceConfig.playback.channels = 2; + deviceConfig.periods = 3; + deviceConfig.periodSizeInMilliseconds = 10; + deviceConfig.dataCallback = ma_engine_data_callback_internal; + deviceConfig.pUserData = &ma->engine; + ma_result deviceResult = ma_device_init(NULL, &deviceConfig, &ma->device); + if (deviceResult != MA_SUCCESS) { + logError("Audio: Failed to initialize playback device (error %d)\n", deviceResult); + return; + } + config.pDevice = &ma->device; + } + ma_result result = ma_engine_init(&config, &ma->engine); if (result != MA_SUCCESS) { logError("Audio: Failed to initialize miniaudio engine (error %d)\n", result); - ma_device_uninit(&ma->device); + if (ma->hostSampleRate == 0) ma_device_uninit(&ma->device); return; } + ma->engineReady = true; memset(ma->instances, 0, sizeof(ma->instances)); ma->nextInstanceCounter = 0; @@ -181,7 +188,7 @@ static void maDestroy(AudioSystem* audio) { } arrfree(ma->base.audioGroups); - ma_device_uninit(&ma->device); + if (ma->hostSampleRate == 0) ma_device_uninit(&ma->device); ma_engine_uninit(&ma->engine); free(ma); } @@ -483,12 +490,12 @@ static void maResumeAll(AudioSystem* audio) { static void maSuspend(AudioSystem* audio) { MaAudioSystem* ma = (MaAudioSystem*) audio; - ma_device_stop(ma_engine_get_device(&ma->engine)); + if (ma->hostSampleRate == 0) ma_device_stop(ma_engine_get_device(&ma->engine)); } static void maResume(AudioSystem* audio) { MaAudioSystem* ma = (MaAudioSystem*) audio; - ma_device_start(ma_engine_get_device(&ma->engine)); + if (ma->hostSampleRate == 0) ma_device_start(ma_engine_get_device(&ma->engine)); } static void maSetSoundGain(AudioSystem* audio, int32_t soundOrInstance, float gain, uint32_t timeMs) { @@ -945,3 +952,19 @@ MaAudioSystem* MaAudioSystem_create(DataWin* dataWin) { ma->base.vtable = &maAudioSystemVtable; return ma; } + +MaAudioSystem* MaAudioSystem_createForHost(DataWin* dataWin, int32_t sampleRate) { + MaAudioSystem* audio = MaAudioSystem_create(dataWin); + audio->hostSampleRate = sampleRate > 0 ? sampleRate : 48000; + return audio; +} + +void MaAudioSystem_pullFrames(MaAudioSystem* audio, float* out, int32_t frameCount) { + if (out == nullptr || frameCount <= 0) return; + if (audio == nullptr || !audio->engineReady) { + memset(out, 0, (size_t)frameCount * 2 * sizeof(float)); + return; + } + ma_uint64 framesRead = 0; + ma_engine_read_pcm_frames(&audio->engine, out, (ma_uint64)frameCount, &framesRead); +} diff --git a/src/audio/miniaudio/ma_audio_system.h b/src/audio/miniaudio/ma_audio_system.h index ec50f08ae..55e5929b4 100644 --- a/src/audio/miniaudio/ma_audio_system.h +++ b/src/audio/miniaudio/ma_audio_system.h @@ -40,6 +40,8 @@ typedef struct { AudioSystem base; ma_engine engine; ma_device device; + int32_t hostSampleRate; + bool engineReady; SoundInstance instances[MAX_SOUND_INSTANCES]; int32_t nextInstanceCounter; FileSystem* fileSystem; @@ -49,5 +51,7 @@ typedef struct { } MaAudioSystem; MaAudioSystem* MaAudioSystem_create(DataWin* dataWin); +MaAudioSystem* MaAudioSystem_createForHost(DataWin* dataWin, int32_t sampleRate); +void MaAudioSystem_pullFrames(MaAudioSystem* audio, float* out, int32_t frameCount); #endif /* _BS_MA_AUDIO_SYSTEM_H_ */ diff --git a/src/data_win.c b/src/data_win.c index 580f0d293..146f9200e 100644 --- a/src/data_win.c +++ b/src/data_win.c @@ -2687,9 +2687,7 @@ void DataWin_loadTxtrIfNeeded(DataWin* dw, uint32_t textureId) { size_t read = fread(tex->blobData, 1, tex->blobSize, dw->lazyLoadFile); fseek(dw->lazyLoadFile, old_seek, SEEK_SET); - if (read != tex->blobSize) { - logWarn("loadTxtrIfNeeded: couldn't read %u bytes to load a texture.\n", tex->blobSize); - } + requireMessage(read == tex->blobSize, "Truncated lazy texture payload"); } static void parseAUDO(BinaryReader* reader, DataWin* dw, bool loadAudioDataLazily) { @@ -2704,8 +2702,14 @@ static void parseAUDO(BinaryReader* reader, DataWin* dw, bool loadAudioDataLazil a->entries = (AudioEntry *)safeCalloc(count, sizeof(AudioEntry)); repeat(count, i) { if (ptrs[i] == 0) continue; - BinaryReader_seek(reader, ptrs[i]); a->entries[i].present = true; + if (loadAudioDataLazily) { + // Reading each size header would touch nearly every network window in AUDO. + a->entries[i].headerPending = true; + a->entries[i].dataOffset = ptrs[i]; + continue; + } + BinaryReader_seek(reader, ptrs[i]); a->entries[i].dataSize = BinaryReader_readUint32(reader); a->entries[i].dataOffset = (uint32_t)BinaryReader_getPosition(reader); // Load audio data into owned buffer @@ -2727,7 +2731,7 @@ void DataWin_loadAudoIfNeeded(DataWin* dw, uint32_t audioEntryId) { Audo* a = &dw->audo; AudioEntry* entry = &a->entries[audioEntryId]; - if (!entry->present || entry->dataSize == 0) return; + if (!entry->present || (!entry->headerPending && entry->dataSize == 0)) return; if (entry->data != nullptr) return; if (!dw->lazyLoadFile) { @@ -2735,17 +2739,24 @@ void DataWin_loadAudoIfNeeded(DataWin* dw, uint32_t audioEntryId) { return; } - entry->data = (uint8_t *)safeMalloc(entry->dataSize); - - memset(entry->data, 0, entry->dataSize); long old_seek = ftell(dw->lazyLoadFile); + requireMessage(entry->dataOffset <= dw->fileSize, "Invalid lazy audio offset"); fseek(dw->lazyLoadFile, entry->dataOffset, SEEK_SET); + if (entry->headerPending) { + requireMessage(dw->fileSize - entry->dataOffset >= 4, "Invalid lazy audio header"); + uint8_t header[4]; + requireMessage(fread(header, 1, 4, dw->lazyLoadFile) == 4, "Truncated lazy audio header"); + entry->dataSize = (uint32_t)header[0] | (uint32_t)header[1] << 8 | + (uint32_t)header[2] << 16 | (uint32_t)header[3] << 24; + entry->dataOffset += 4; + entry->headerPending = false; + } + requireMessage(entry->dataSize <= dw->fileSize - entry->dataOffset, "Invalid lazy audio length"); + entry->data = entry->dataSize ? (uint8_t *)safeMalloc(entry->dataSize) : nullptr; size_t read = fread(entry->data, 1, entry->dataSize, dw->lazyLoadFile); fseek(dw->lazyLoadFile, old_seek, SEEK_SET); - if (read != entry->dataSize) { - logError("loadAudoIfNeeded: couldn't read %u bytes to load audio entry %u.\n", entry->dataSize, audioEntryId); - } + requireMessage(read == entry->dataSize, "Truncated lazy audio payload"); } // ===[ MAIN PARSE FUNCTION ]=== @@ -2912,7 +2923,10 @@ DataWin* DataWin_parse(const char* filePath, DataWinParserOptions options) { // Bulk-read the chunk data into memory for fast parsing uint8_t* chunkBuffer = nullptr; - if (shouldParse && chunkLength > 0 && options.loadType == DATAWINLOADTYPE_LOAD_PER_CHUNK) { + bool lazyChunk = (options.lazyLoadTextures && memcmp(chunkName, "TXTR", 4) == 0) || + (options.lazyLoadAudio && memcmp(chunkName, "AUDO", 4) == 0) || + (options.lazyLoadRooms && memcmp(chunkName, "ROOM", 4) == 0); + if (shouldParse && !lazyChunk && chunkLength > 0 && options.loadType == DATAWINLOADTYPE_LOAD_PER_CHUNK) { chunkBuffer = (uint8_t *)malloc(chunkLength); if (chunkBuffer) { size_t read = fread(chunkBuffer, 1, chunkLength, reader.file); diff --git a/src/data_win.h b/src/data_win.h index 877714deb..44df93293 100644 --- a/src/data_win.h +++ b/src/data_win.h @@ -872,6 +872,7 @@ typedef struct { // ===[ AUDO - Embedded Audio ]=== typedef struct { bool present; + bool headerPending; // lazy entries retain the size-header offset until first use uint32_t dataOffset; // absolute file offset to audio data uint32_t dataSize; // length of audio data uint8_t* data; // owned copy of audio data diff --git a/src/retrom-web-meta/main.c b/src/retrom-web-meta/main.c new file mode 100644 index 000000000..f1878a762 --- /dev/null +++ b/src/retrom-web-meta/main.c @@ -0,0 +1,71 @@ +#include "common.h" +#include "stdio_compat.h" +#include +#include "string_compat.h" +#include "data_win.h" + +void platformLog(const logType type, const char *format, va_list va) { + FILE *out = stderr; + switch (type) { + case LOG_TYPE_NORMAL: + out = stdout; + break; + case LOG_TYPE_WARNING: + fputs("Warning: ", out); + break; + case LOG_TYPE_ERROR: + fputs("Error: ", out); + break; + case LOG_TYPE_DEBUG: + fputs("Debug: ", out); + break; + } + vfprintf(out, format, va); +} + +DataWin* parseDataWin(const char* path) { + DataWinParserOptions opts = {0}; + opts.parseGen8 = true; + opts.parseStrg = true; // GEN8 stores string offsets that point into STRG + return DataWin_parse(path, opts); +} + +void freeDataWin(DataWin* dw) { + if (dw != nullptr) DataWin_free(dw); +} + +const char* getGameName(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.name : nullptr; +} + +const char* getGameDisplayName(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.displayName : nullptr; +} + +uint32_t getMajorVersion(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.major : 0; +} + +uint32_t getMinorVersion(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.minor : 0; +} + +uint32_t getRelease(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.release : 0; +} + +uint32_t getBuild(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.build : 0; +} + +uint32_t getDefaultWindowWidth(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.defaultWindowWidth : 0; +} + +uint32_t getDefaultWindowHeight(DataWin* dw) { + return (dw != nullptr) ? dw->gen8.defaultWindowHeight : 0; +} + +int main(void) { + return 0; +} diff --git a/src/retrom-web/content_fs.cpp b/src/retrom-web/content_fs.cpp new file mode 100644 index 000000000..a0747b879 --- /dev/null +++ b/src/retrom-web/content_fs.cpp @@ -0,0 +1,125 @@ +// Generic read-only file tree. The host supplies metadata and services bounded reads; +// the core knows nothing about URLs, project indexes, authentication or caching. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "memory_backend.h" +#include "wasmfs.h" + +namespace { +constexpr size_t blockBytes = 256 * 1024; +// content-read-v1: 16 little-endian i32 controls followed by one payload slot. +// 0 state (idle/requested/ok/closed=0/1/2/4), 1 version, 2 sequence, +// 4 result length, 6 closed, 7 length, 9 file ID, 10/11 offset low/high. +struct ReadSlot { + std::atomic control[16]{}; + uint8_t bytes[blockBytes]{}; +}; +static_assert(sizeof(std::atomic) == 4, "content-read-v1 word size"); +ReadSlot slot; +std::mutex readMutex; + +class ContentFile : public wasmfs::DataFile { + int32_t id; + off_t size; + int open(wasmfs::oflags_t flags) override { return (flags & O_ACCMODE) == O_RDONLY ? 0 : -EROFS; } + int close() override { return 0; } + int flush() override { return 0; } + off_t getSize() override { return size; } + int setSize(off_t) override { return -EROFS; } + ssize_t write(const uint8_t*, size_t, off_t) override { return -EROFS; } + ssize_t read(uint8_t* out, size_t length, off_t offset) override { + if (offset < 0) return -EINVAL; + if (offset >= size) return 0; + length = std::min(length, static_cast(size - offset)); + std::lock_guard lock(readMutex); + size_t done = 0; + while (done < length) { + if (slot.control[6].load() || slot.control[0].load() != 0) return -EIO; + size_t count = std::min(blockBytes, length - done); + uint64_t at = static_cast(offset) + done; + slot.control[2].fetch_add(1); + slot.control[4].store(0); + slot.control[7].store(static_cast(count)); + slot.control[9].store(id); + slot.control[10].store(static_cast(at)); + slot.control[11].store(static_cast(at >> 32)); + slot.control[0].store(1); + emscripten_futex_wake(&slot.control[0], INT_MAX); + double deadline = emscripten_get_now() + 15000; + while (slot.control[0].load() == 1) { + double remaining = deadline - emscripten_get_now(); + if (remaining <= 0) { + slot.control[6].store(1); + slot.control[0].store(4); + emscripten_futex_wake(&slot.control[0], INT_MAX); + return -EIO; + } + emscripten_futex_wait(&slot.control[0], 1, remaining); + } + if (slot.control[6].load() || slot.control[0].load() != 2 || + slot.control[4].load() != static_cast(count)) return -EIO; + memcpy(out + done, slot.bytes, count); + int32_t expected = 2; + if (!slot.control[0].compare_exchange_strong(expected, 0)) return -EIO; + emscripten_futex_wake(&slot.control[0], INT_MAX); + done += count; + } + return static_cast(done); + } +public: + ContentFile(mode_t mode, wasmfs::backend_t backend, int32_t fileId, off_t fileSize) + : DataFile(mode, backend), id(fileId), size(fileSize) {} +}; + +class ContentBackend : public wasmfs::Backend { +public: + int32_t nextId = 0; + off_t nextSize = 0; + std::shared_ptr createFile(mode_t mode) override { + return std::make_shared(mode, this, nextId, nextSize); + } + std::shared_ptr createDirectory(mode_t mode) override { + return std::make_shared(mode, this); + } + std::shared_ptr createSymlink(std::string target) override { + return std::make_shared(target, this); + } +}; +ContentBackend* backend = nullptr; +} + +extern "C" { +// Call before starting any game threads, and service this slot until destruction. +void* getContentReadSlot() { + slot.control[1].store(1); + return &slot; +} + +// Register absolute paths in the caller's memory filesystem before startRunner. +// Parent directories and saves remain ordinary writable memory/OPFS directories. +int registerContentFile(const char* path, int32_t id, double size) { + if (!path || path[0] != '/' || id < 0 || !std::isfinite(size) || size < 0 || + size > 9007199254740991.0 || std::floor(size) != size) return -EINVAL; + if (!backend) { + auto owned = std::make_unique(); + backend = owned.get(); + wasmfs::wasmFS.addBackend(std::move(owned)); + } + std::string name(path); + for (size_t pos = name.find('/', 1); pos != std::string::npos; pos = name.find('/', pos + 1)) { + if (mkdir(name.substr(0, pos).c_str(), 0777) && errno != EEXIST) return -errno; + } + backend->nextId = id; + backend->nextSize = static_cast(size); + int fd = wasmfs_create_file(path, 0444, reinterpret_cast(backend)); + if (fd < 0) return -errno; + return close(fd); +} +} diff --git a/src/retrom-web/main.c b/src/retrom-web/main.c new file mode 100644 index 000000000..c9cc0d0b3 --- /dev/null +++ b/src/retrom-web/main.c @@ -0,0 +1,557 @@ +#include "stdio_compat.h" +#include "string_compat.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "data_win.h" +#include "noop_audio_system.h" +#include "ma_audio_system.h" +#include "overlay_file_system.h" +#include "runner.h" +#include "gl/gl_renderer.h" +#include "gettime.h" + +static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE ctx = 0; +static Runner* gRunner; +static MaAudioSystem* gWebAudio = nullptr; +static int32_t gAudioSampleRate = 48000; +static pthread_mutex_t gHostMutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t gHostCondition = PTHREAD_COND_INITIALIZER; +static bool gHostPaused = false; +static bool gLoopPaused = false; +static bool gCheckpointAvailable = false; +static bool gGamepadConnected[MAX_GAMEPADS] = {false}; +static float gGamepadButtons[MAX_GAMEPADS][GP_BUTTON_COUNT] = {{0}}; +static float gGamepadAxes[MAX_GAMEPADS][GP_AXIS_COUNT] = {{0}}; + +uint8_t keyDown[GML_KEY_COUNT] = {0}; +uint8_t keyUp[GML_KEY_COUNT] = {0}; + +void platformLog(const logType type, const char *format, va_list va) { + FILE *out = stderr; + switch (type) { + case LOG_TYPE_NORMAL: + out = stdout; + break; + case LOG_TYPE_WARNING: + out = stdout; + fputs("Warning: ", out); + break; + case LOG_TYPE_ERROR: + fputs("Error: ", out); + break; + case LOG_TYPE_DEBUG: + fputs("Debug: ", out); + break; + } + vfprintf(out, format, va); +} + +// Configures the sample rate that miniaudio will mix at. Must match the AudioContext's sampleRate +// on the JS side, and must be called BEFORE startRunner. +void setAudioSampleRate(int32_t rate) { + if (rate > 0) gAudioSampleRate = rate; +} + +// Pulls frameCount interleaved-stereo float32 frames into outPtr (which must point into wasm memory). +// Called from JS by the worker's audio pull loop. Safe to call before the runner starts (returns silence). +void pullAudioFrames(float* outPtr, int32_t frameCount) { + if (gWebAudio == nullptr || frameCount <= 0) { + if (outPtr != nullptr && frameCount > 0) { + memset(outPtr, 0, (size_t) frameCount * 2 * sizeof(float)); + } + return; + } + MaAudioSystem_pullFrames(gWebAudio, outPtr, frameCount); +} + +uint8_t* getKeyDownPtr() { + return keyDown; +} + +uint8_t* getKeyUpPtr() { + return keyUp; +} + +int getKeyCount() { + return GML_KEY_COUNT; +} + +void setRunnerPaused(int32_t paused) { + pthread_mutex_lock(&gHostMutex); + gHostPaused = paused != 0; + pthread_cond_broadcast(&gHostCondition); + while (gRunner != nullptr && !gRunner->shouldExit && gLoopPaused != gHostPaused) { + pthread_cond_wait(&gHostCondition, &gHostMutex); + } + pthread_mutex_unlock(&gHostMutex); +} + +void setGamepadConnected(int32_t device, int32_t connected) { + if (device < 0 || device >= MAX_GAMEPADS) return; + pthread_mutex_lock(&gHostMutex); + gGamepadConnected[device] = connected != 0; + if (!gGamepadConnected[device]) { + memset(gGamepadButtons[device], 0, sizeof(gGamepadButtons[device])); + memset(gGamepadAxes[device], 0, sizeof(gGamepadAxes[device])); + } + pthread_mutex_unlock(&gHostMutex); +} + +void setGamepadButton(int32_t device, int32_t button, float value) { + if (device < 0 || device >= MAX_GAMEPADS || button < 0 || button >= GP_BUTTON_COUNT) return; + if (value < 0.0f) value = 0.0f; + if (value > 1.0f) value = 1.0f; + pthread_mutex_lock(&gHostMutex); + gGamepadButtons[device][button] = value; + pthread_mutex_unlock(&gHostMutex); +} + +void setGamepadAxis(int32_t device, int32_t axis, float value) { + if (device < 0 || device >= MAX_GAMEPADS || axis < 0 || axis >= GP_AXIS_COUNT) return; + if (value < -1.0f) value = -1.0f; + if (value > 1.0f) value = 1.0f; + pthread_mutex_lock(&gHostMutex); + gGamepadAxes[device][axis] = value; + pthread_mutex_unlock(&gHostMutex); +} + +// One frame consists of count slots of connected, GP_BUTTON_COUNT buttons, +// then GP_AXIS_COUNT axes (all float32). Unspecified slots are disconnected. +// Copy under the same lock used by updateGamepads, so discovery and input for +// all devices become visible to the runner in the same frame. +void setGamepads(const float* values, int32_t count) { + if (count < 0 || count > MAX_GAMEPADS || (count > 0 && values == nullptr)) return; + const int stride = 1 + GP_BUTTON_COUNT + GP_AXIS_COUNT; + pthread_mutex_lock(&gHostMutex); + for (int device = 0; device < MAX_GAMEPADS; device++) { + bool connected = device < count && values[device * stride] > 0.0f; + gGamepadConnected[device] = connected; + for (int button = 0; button < GP_BUTTON_COUNT; button++) { + float value = connected ? values[device * stride + 1 + button] : 0.0f; + if (!isfinite(value) || value < 0.0f) value = 0.0f; + if (value > 1.0f) value = 1.0f; + gGamepadButtons[device][button] = value; + } + for (int axis = 0; axis < GP_AXIS_COUNT; axis++) { + float value = connected ? values[device * stride + 1 + GP_BUTTON_COUNT + axis] : 0.0f; + if (!isfinite(value)) value = 0.0f; + if (value < -1.0f) value = -1.0f; + if (value > 1.0f) value = 1.0f; + gGamepadAxes[device][axis] = value; + } + } + pthread_mutex_unlock(&gHostMutex); +} + +static void waitWhilePaused(void) { + pthread_mutex_lock(&gHostMutex); + gLoopPaused = gHostPaused; + if (gLoopPaused && gRunner != nullptr) { + gCheckpointAvailable = Runner_canCheckpoint(gRunner); + } + pthread_cond_broadcast(&gHostCondition); + while (gHostPaused && gRunner != nullptr && !gRunner->shouldExit) { + pthread_cond_wait(&gHostCondition, &gHostMutex); + } + gLoopPaused = false; + pthread_cond_broadcast(&gHostCondition); + pthread_mutex_unlock(&gHostMutex); +} + +#define CHECKPOINT_HEADER_SIZE 12 +#define CHECKPOINT_MAX_BYTES (16 * 1024 * 1024) +static uint8_t* gCheckpointBytes = nullptr; +static int32_t gCheckpointSize = 0; + +static uint32_t readUint32LE(const uint8_t* source) { + return (uint32_t) source[0] | ((uint32_t) source[1] << 8) | + ((uint32_t) source[2] << 16) | ((uint32_t) source[3] << 24); +} + +static void writeUint32LE(uint8_t* target, uint32_t value) { + target[0] = (uint8_t) value; + target[1] = (uint8_t) (value >> 8); + target[2] = (uint8_t) (value >> 16); + target[3] = (uint8_t) (value >> 24); +} + +int32_t isRunnerCheckpointAvailable(void) { + pthread_mutex_lock(&gHostMutex); + bool available = gRunner != nullptr && !gRunner->shouldExit && gCheckpointAvailable; + pthread_mutex_unlock(&gHostMutex); + return available ? 1 : 0; +} + +int32_t getRunnerCheckpointStatus(void) { + pthread_mutex_lock(&gHostMutex); + int32_t status = gRunner == nullptr || gRunner->shouldExit + ? RUNNER_CHECKPOINT_NO_ACTIVE_ROOM + : (int32_t) Runner_checkpointStatus(gRunner); + if (status == RUNNER_CHECKPOINT_PERSISTENT_ROOM_STATE) { + repeat(gRunner->dataWin->room.count, roomIndex) { + if ((int32_t) roomIndex != gRunner->currentRoomIndex && + gRunner->dataWin->room.rooms[roomIndex].persistent && + gRunner->savedRoomStates[roomIndex].initialized) { + logInfo("Checkpoint unavailable: persistent room state %d is active\n", (int) roomIndex); + } + } + } + pthread_mutex_unlock(&gHostMutex); + return status; +} + +uint8_t* createRunnerCheckpoint(void) { + pthread_mutex_lock(&gHostMutex); + bool allowed = gRunner != nullptr && !gRunner->shouldExit && gLoopPaused && gCheckpointAvailable; + pthread_mutex_unlock(&gHostMutex); + if (!allowed) return nullptr; + char* json = Runner_dumpStateJson(gRunner); + size_t jsonSize = strlen(json); + if (jsonSize == 0 || jsonSize > CHECKPOINT_MAX_BYTES - CHECKPOINT_HEADER_SIZE) { + free(json); + return nullptr; + } + free(gCheckpointBytes); + gCheckpointSize = (int32_t) jsonSize + CHECKPOINT_HEADER_SIZE; + gCheckpointBytes = (uint8_t *)safeMalloc((size_t) gCheckpointSize); + memcpy(gCheckpointBytes, "BSCP", 4); + writeUint32LE(gCheckpointBytes + 4, 2); + writeUint32LE(gCheckpointBytes + 8, (uint32_t) jsonSize); + memcpy(gCheckpointBytes + CHECKPOINT_HEADER_SIZE, json, jsonSize); + free(json); + return gCheckpointBytes; +} + +int32_t getRunnerCheckpointSize(void) { + return gCheckpointSize; +} + +int32_t restoreRunnerCheckpoint(const uint8_t* bytes, int32_t size) { + pthread_mutex_lock(&gHostMutex); + bool allowed = gRunner != nullptr && !gRunner->shouldExit && gLoopPaused; + pthread_mutex_unlock(&gHostMutex); + if (!allowed || bytes == nullptr || size < CHECKPOINT_HEADER_SIZE || size > CHECKPOINT_MAX_BYTES || + memcmp(bytes, "BSCP", 4) != 0 || readUint32LE(bytes + 4) != 2 || + readUint32LE(bytes + 8) != (uint32_t) size - CHECKPOINT_HEADER_SIZE) return -1; + size_t jsonSize = (size_t) size - CHECKPOINT_HEADER_SIZE; + char* json = (char *)safeMalloc(jsonSize + 1); + memcpy(json, bytes + CHECKPOINT_HEADER_SIZE, jsonSize); + json[jsonSize] = '\0'; + bool restored = Runner_restoreStateJson(gRunner, json); + free(json); + if (!restored) return -1; + pthread_mutex_lock(&gHostMutex); + gCheckpointAvailable = Runner_canCheckpoint(gRunner); + pthread_mutex_unlock(&gHostMutex); + return 0; +} + +static void updateGamepads(RunnerGamepadState* gamepads) { + RunnerGamepad_beginFrame(gamepads); + pthread_mutex_lock(&gHostMutex); + for (int device = 0; device < MAX_GAMEPADS; device++) { + GamepadSlot* slot = &gamepads->slots[device]; + memcpy(slot->buttonDownPrev, slot->buttonDown, sizeof(slot->buttonDown)); + slot->connected = gGamepadConnected[device]; + if (!slot->connected) { + memset(slot->buttonDown, 0, sizeof(slot->buttonDown)); + memset(slot->buttonValue, 0, sizeof(slot->buttonValue)); + memset(slot->axisValue, 0, sizeof(slot->axisValue)); + continue; + } + gamepads->connectedCount++; + for (int button = 0; button < GP_BUTTON_COUNT; button++) { + float value = gGamepadButtons[device][button]; + slot->buttonValue[button] = value; + slot->buttonDown[button] = value >= slot->triggerThreshold; + slot->buttonPressed[button] = slot->buttonDown[button] && !slot->buttonDownPrev[button]; + slot->buttonReleased[button] = !slot->buttonDown[button] && slot->buttonDownPrev[button]; + } + memcpy(slot->axisValue, gGamepadAxes[device], sizeof(slot->axisValue)); + } + pthread_mutex_unlock(&gHostMutex); +} + +int main() { + logInfo("Howdy! Loritta is so cute! lol\n"); + emscripten_exit_with_live_runtime(); + return 0; +} + +// Mounts the browser's OPFS at "/butterscotch" in the WASMFS virtual filesystem. +int mountOpfs(void) { + backend_t opfs = wasmfs_create_opfs_backend(); + if (!opfs) { + logWarn("Failed to create OPFS backend\n"); + return -1; + } + int rc = wasmfs_create_directory("/butterscotch", 0777, opfs); + if (rc != 0) { + logWarn("Failed to mount OPFS at /butterscotch: %s\n", strerror(errno)); + return -1; + } + return 0; +} + +// mkdir -p for WASMFS paths. Used to ensure the saves directory exists before the runner tries to write into it. +static int mkdirP(const char* path) { + char buf[512]; + size_t len = strlen(path); + if (len >= sizeof(buf)) return -1; + memcpy(buf, path, len + 1); + for (size_t i = 1; len > i; i++) { + if (buf[i] == '/') { + buf[i] = '\0'; + if (mkdir(buf, 0777) != 0 && errno != EEXIST) return -1; + buf[i] = '/'; + } + } + if (mkdir(buf, 0777) != 0 && errno != EEXIST) return -1; + return 0; +} + +void* loop() { + double lastFrameStartMs = emscripten_get_now(); // for delta_time and frame pacing + + gRunner->gameStartTime = nowNanos(); + while (!gRunner->shouldExit) { + waitWhilePaused(); + if (gRunner->shouldExit) break; + double frameStartMs = emscripten_get_now(); + gRunner->deltaTime = (frameStartMs - lastFrameStartMs) * 1000.0; + lastFrameStartMs = frameStartMs; + + RunnerKeyboard_beginFrame(gRunner->keyboard); + updateGamepads(gRunner->gamepads); + + // Process inputs + repeat(GML_KEY_COUNT, i) { + if (keyDown[i]) { + RunnerKeyboard_onKeyDown(gRunner->keyboard, i); + keyDown[i] = 0; + } + if (keyUp[i]) { + RunnerKeyboard_onKeyUp(gRunner->keyboard, i); + keyUp[i] = 0; + } + } + + emscripten_webgl_make_context_current(ctx); + + float audioDt = (float) (gRunner->deltaTime / 1000000.0); + if (0.0f > audioDt) audioDt = 0.0f; + if (audioDt > 0.1f) audioDt = 0.1f; + gRunner->audioSystem->vtable->update(gRunner->audioSystem, audioDt); + + // Run one game step (Begin Step, Keyboard, Alarms, Step, End Step, room transitions) + Runner_step(gRunner); + + int32_t gameW = (int32_t) gRunner->dataWin->gen8.defaultWindowWidth; + int32_t gameH = (int32_t) gRunner->dataWin->gen8.defaultWindowHeight; + + Runner_drawPre(gRunner, 640, 480); + + Runner_beginFrame(gRunner, gameW, gameH, 640, 480, 640, 480); + + Runner_drawViews(gRunner, gameW, gameH, false); + gRunner->renderer->vtable->endFrameInit(gRunner->renderer); + Runner_drawPost(gRunner, 640, 480); + gRunner->renderer->vtable->endFrameEnd(gRunner->renderer); + Runner_drawGUI(gRunner, 640, 480, gameW, gameH); + + // Just like glfwSwapBuffers. + // Only swap when there isn't a room change to match the original runner. + if (gRunner->pendingRoom == -1) { + emscripten_webgl_commit_frame(); + } + Runner_handlePendingRoomChange(gRunner); + bool checkpointAvailable = Runner_canCheckpoint(gRunner); + pthread_mutex_lock(&gHostMutex); + gCheckpointAvailable = checkpointAvailable; + pthread_mutex_unlock(&gHostMutex); + + // Match upstream pacing, including game_set_speed and the GMS2 default FPS. + // emscripten_get_now() returns milliseconds (performance.now()) and works in workers. + GMLReal gameSpeed = Runner_getEffectiveGameSpeed(gRunner); + if (gameSpeed > 0) { + double targetFrameTimeMs = 1000.0 / (double) gameSpeed; + double nextFrameTimeMs = lastFrameStartMs + targetFrameTimeMs; + double remainingMs = nextFrameTimeMs - emscripten_get_now(); + // Sleep for most of the remaining time, then spin-wait for precision. + if (remainingMs > 2.0) { + struct timespec ts; + ts.tv_sec = 0; + ts.tv_nsec = (long) ((remainingMs - 1.0) * 1000000.0); + nanosleep(&ts, nullptr); + } + while (emscripten_get_now() < nextFrameTimeMs) { + // Spin-wait for the remaining sub-millisecond + } + } + } + + // Cleanup + logInfo("Cleaning up runner!\n"); + + gRunner->audioSystem->vtable->destroy(gRunner->audioSystem); + gRunner->audioSystem = nullptr; + gWebAudio = nullptr; + gRunner->renderer->vtable->destroy(gRunner->renderer); + + DataWin* dataWin = gRunner->dataWin; + VMContext* vm = gRunner->vmContext; + Runner_free(gRunner); + pthread_mutex_lock(&gHostMutex); + gRunner = nullptr; + gHostPaused = false; + gLoopPaused = false; + gCheckpointAvailable = false; + memset(gGamepadConnected, 0, sizeof(gGamepadConnected)); + memset(gGamepadButtons, 0, sizeof(gGamepadButtons)); + memset(gGamepadAxes, 0, sizeof(gGamepadAxes)); + free(gCheckpointBytes); + gCheckpointBytes = nullptr; + gCheckpointSize = 0; + pthread_mutex_unlock(&gHostMutex); + VM_free(vm); + DataWin_free(dataWin); + + // We want to *know* when the runner has actually exited, because we also need to track things like "Leave Game" buttons/actions in the game + MAIN_THREAD_EM_ASM({ postMessage({ type: 'runnerExit' }); }); + + return nullptr; +} + +void setWindowTitle(const char* title) { + MAIN_THREAD_EM_ASM({ postMessage({ type: 'windowTitle', title: UTF8ToString($0) }); }, title); +} + +// gamePath: WASMFS path to the data.win to load (example: "/butterscotch/games/undertale/data.win"). +// savesPath: WASMFS directory where saves should live (example: "/butterscotch/saves/undertale" - Created if it does not exist). +void startRunner(const char* gamePath, const char* savesPath) { + logInfo("Starting runner! gamePath=%s savesPath=%s\n", gamePath, savesPath); + + EmscriptenWebGLContextAttributes attrs; + emscripten_webgl_init_context_attributes(&attrs); + + attrs.majorVersion = 2; + attrs.minorVersion = 0; + attrs.alpha = 0; + attrs.antialias = 0; // Required to avoid "WebGL warning: blitFramebuffer: DRAW_FRAMEBUFFER may not have multiple samples." + // Both of these are required to allow us to use emscripten_webgl_commit_frame + attrs.explicitSwapControl = true; + attrs.renderViaOffscreenBackBuffer = true; + + // Yes, "#canvas" feels nasty as HELL + // But that's how Emscripten works for SOME REASON + ctx = emscripten_webgl_create_context("#canvas", &attrs); + if (0 >= ctx) { + logError("Failed to create WebGL context: %d\n", (int)ctx); + abort(); + } + + emscripten_webgl_make_context_current(ctx); + + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + // Make sure the saves directory exists. The FileSystem impl will write into it. + if (savesPath != nullptr && savesPath[0] != '\0') { + if (mkdirP(savesPath) != 0) { + logWarn("failed to ensure saves dir exists at %s: %s\n", savesPath, strerror(errno)); + } + } + + DataWinParserOptions options = {0}; + options.parseGen8 = true; + options.parseOptn = true; + options.parseLang = true; + options.parseExtn = true; + options.parseSond = true; + options.parseAgrp = true; + options.parseSprt = true; + options.parseBgnd = true; + options.parsePath = true; + options.parseScpt = true; + options.parseGlob = true; + options.parseShdr = true; + options.parseFont = true; + options.parseTmln = true; + options.parseObjt = true; + options.parseRoom = true; + options.parseTpag = true; + options.parseCode = true; + options.parseVari = true; + options.parseFunc = true; + options.parseStrg = true; + options.parseTxtr = true; + options.parseAudo = true; + options.skipLoadingPreciseMasksForNonPreciseSprites = true; + options.lazyLoadRooms = false; + options.lazyLoadTextures = true; + options.lazyLoadAudio = true; + options.eagerlyLoadedRooms = nullptr; + DataWin* dataWin = DataWin_parse(gamePath, options); + + // return strdup(dataWin->gen8.name); + + // Initialize VM + VMContext* vm = VM_create(dataWin); + + Renderer* renderer = GLRenderer_create(); + + // Bundle path = directory containing data.win, e.g. "/butterscotch/games/undertale/". + // Save path = whatever the worker passed in, e.g. "/butterscotch/saves/undertale/". + char* bundleDir = nullptr; + const char* lastSlash = strrchr(gamePath, '/'); + if (lastSlash != nullptr) { + size_t len = (size_t) (lastSlash - gamePath + 1); + bundleDir = (char *)safeMalloc(len + 1); + memcpy(bundleDir, gamePath, len); + bundleDir[len] = '\0'; + } else { + bundleDir = safeStrdup("./"); + } + OverlayFileSystem* overlayFs = OverlayFileSystem_create(bundleDir, savesPath); + free(bundleDir); + + gWebAudio = MaAudioSystem_createForHost(dataWin, gAudioSampleRate); + AudioSystem* audioSystem = (AudioSystem*) gWebAudio; + + // Initialize the runner + Runner* runner = Runner_create(dataWin, vm, renderer, (FileSystem*) overlayFs, audioSystem, 0); + runner->setWindowTitle = setWindowTitle; + runner->windowHasFocus = nullptr; + + setWindowTitle(dataWin->gen8.name); + + gRunner = runner; + + // Initialize the first room and fire Game Start / Room Start events + Runner_initFirstRoom(runner); + pthread_mutex_lock(&gHostMutex); + gCheckpointAvailable = Runner_canCheckpoint(runner); + pthread_mutex_unlock(&gHostMutex); + + MAIN_THREAD_EM_ASM({ postMessage({ type: 'runnerReady' }); }); + + // Start a new thread + pthread_t tid; + pthread_create(&tid, NULL, loop, NULL); + pthread_detach(tid); +} + +void stopRunner() { + logInfo("Marked runner to exit!\n"); + pthread_mutex_lock(&gHostMutex); + if (gRunner != nullptr) gRunner->shouldExit = true; + gHostPaused = false; + pthread_cond_broadcast(&gHostCondition); + pthread_mutex_unlock(&gHostMutex); +} diff --git a/src/retrom-web/stb_impl.c b/src/retrom-web/stb_impl.c new file mode 100644 index 000000000..873f3656b --- /dev/null +++ b/src/retrom-web/stb_impl.c @@ -0,0 +1,6 @@ +#define STB_IMAGE_IMPLEMENTATION +#define STBI_NO_STDIO +#include "stb_image.h" + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb_image_write.h" diff --git a/src/runner.c b/src/runner.c index 2184faeb3..a3b7032a2 100644 --- a/src/runner.c +++ b/src/runner.c @@ -5,6 +5,7 @@ #include "vm.h" #include "utils.h" #include "json_writer.h" +#include "json_reader.h" #include "collision.h" #include "video.h" @@ -1930,51 +1931,9 @@ static void initRoom(Runner* runner, int32_t roomIndex) { logInfo("Runner: Room loaded: %s (room %d) with %d instances\n", room->name, roomIndex, (int) arrlen(runner->instances)); } -// Cleans up the runner state, used when freeing the Runner or when restarting the Runner -static void cleanupState(Runner* runner) { - // Drop VM-side RValue holders (globals, stack, call frames) BEFORE freeing any Instance memory. This way any RVALUE_STRUCT refs decrement against still-live struct memory; otherwise we'd free a struct here and then have VM_free's later VM_reset try to decRef a dangling pointer. - if (runner->vmContext != nullptr) { - VM_reset(runner->vmContext); - } - - // Free all instances - repeat(arrlen(runner->instances), i) { - hmdel(runner->instancesById, runner->instances[i]->instanceId); - Instance_free(runner->instances[i]); - } - arrfree(runner->instances); - runner->instances = nullptr; - - // Empty the per-object lists. We keep the outer instancesByObject array allocated so Runner_reset can be reused; Runner_free releases it. - Runner_clearAllObjectLists(runner); - - // Free saved room states - if (runner->savedRoomStates != nullptr) { - repeat(runner->dataWin->room.count, i) { - SavedRoomState* state = &runner->savedRoomStates[i]; - int32_t savedCount = (int32_t) arrlen(state->instances); - repeat(savedCount, j) { - hmdel(runner->instancesById, state->instances[j]->instanceId); - Instance_free(state->instances[j]); - } - arrfree(state->instances); - hmfree(state->tileLayerMap); - freeRuntimeLayersArray(&state->runtimeLayers); - } - free(runner->savedRoomStates); - } - runner->savedRoomStates = nullptr; - - Particles_freeAll(runner); - { - repeat(arrlen(runner->audioEmitters), i) { - arrfree(runner->audioEmitters[i].voices); - } - } - arrfree(runner->audioEmitters); - runner->audioEmitters = nullptr; - - // Drain ds_map/ds_list pools BEFORE bulk-freeing struct instances. Their RValue entries may hold RVALUE_STRUCT refs to structs in runner->structInstances, and RValue_free would deref freed memory if the structs are gone. +// Drain data-structure pools before freeing struct instances. Their RValues may +// still own references to structs tracked by runner->structInstances. +static void clearCheckpointDataStructures(Runner* runner) { { repeat((int32_t) arrlen(runner->dsMapPool), i) { DsMapEntry* map = runner->dsMapPool[i]; @@ -2050,6 +2009,53 @@ static void cleanupState(Runner* runner) { } arrfree(runner->dsGridPool); runner->dsGridPool = nullptr; +} + +// Cleans up the runner state, used when freeing the Runner or when restarting the Runner +static void cleanupState(Runner* runner) { + // Drop VM-side RValue holders (globals, stack, call frames) BEFORE freeing any Instance memory. This way any RVALUE_STRUCT refs decrement against still-live struct memory; otherwise we'd free a struct here and then have VM_free's later VM_reset try to decRef a dangling pointer. + if (runner->vmContext != nullptr) { + VM_reset(runner->vmContext); + } + + // Free all instances + repeat(arrlen(runner->instances), i) { + hmdel(runner->instancesById, runner->instances[i]->instanceId); + Instance_free(runner->instances[i]); + } + arrfree(runner->instances); + runner->instances = nullptr; + + // Empty the per-object lists. We keep the outer instancesByObject array allocated so Runner_reset can be reused; Runner_free releases it. + Runner_clearAllObjectLists(runner); + + // Free saved room states + if (runner->savedRoomStates != nullptr) { + repeat(runner->dataWin->room.count, i) { + SavedRoomState* state = &runner->savedRoomStates[i]; + int32_t savedCount = (int32_t) arrlen(state->instances); + repeat(savedCount, j) { + hmdel(runner->instancesById, state->instances[j]->instanceId); + Instance_free(state->instances[j]); + } + arrfree(state->instances); + hmfree(state->tileLayerMap); + freeRuntimeLayersArray(&state->runtimeLayers); + } + free(runner->savedRoomStates); + } + runner->savedRoomStates = nullptr; + + Particles_freeAll(runner); + { + repeat(arrlen(runner->audioEmitters), i) { + arrfree(runner->audioEmitters[i].voices); + } + } + arrfree(runner->audioEmitters); + runner->audioEmitters = nullptr; + + clearCheckpointDataStructures(runner); { repeat((int32_t) arrlen(runner->callLaterEntries), i) { @@ -4544,6 +4550,165 @@ void Runner_dumpState(Runner* runner) { // ===[ JSON State Dump ]=== +static bool checkpointRValueSupported(RValue val, int32_t depth, int32_t* remainingCells) { + if (depth > 16 || *remainingCells <= 0) return false; + switch (val.type) { + case RVALUE_UNDEFINED: + case RVALUE_STRING: + case RVALUE_INT32: +#ifndef NO_RVALUE_INT64 + case RVALUE_INT64: +#endif + case RVALUE_BOOL: + case RVALUE_REAL: + case RVALUE_ASSETREF: + return true; + case RVALUE_ARRAY: + if (val.array == nullptr) return true; + repeat(GMLArray_length1D(val.array), i) { + (*remainingCells)--; + RValue* cell = GMLArray_slot(val.array, i); + if (cell != nullptr && !checkpointRValueSupported(*cell, depth + 1, remainingCells)) return false; + } + return true; + case RVALUE_METHOD: + case RVALUE_STRUCT: + return false; + } + return false; +} + +static bool checkpointVariablesSupported(const IntRValueHashMap* variables, int32_t* remainingCells) { + for (uint32_t i = 0; i < variables->capacity; i++) { + const IntRValueEntry* entry = &variables->entries[i]; + if (entry->key == INT_RVALUE_HASHMAP_EMPTY_KEY) continue; + if (!checkpointRValueSupported(entry->value, 0, remainingCells)) return false; + } + return true; +} + +static bool checkpointConsumeCells(int64_t count, int32_t* remainingCells) { + if (count < 0 || count > *remainingCells) return false; + *remainingCells -= (int32_t) count; + return true; +} + +static bool checkpointDataStructuresSupported(Runner* runner, int32_t* remainingCells) { + if (!checkpointConsumeCells((int64_t) arrlen(runner->dsMapPool), remainingCells)) return false; + repeat(arrlen(runner->dsMapPool), i) { + DsMapEntry* map = runner->dsMapPool[i]; + if (!checkpointConsumeCells((int64_t) shlen(map), remainingCells)) return false; + repeat(shlen(map), j) { + if (map[j].key == nullptr || !checkpointRValueSupported(map[j].value, 0, remainingCells)) return false; + } + } + + if (!checkpointConsumeCells((int64_t) arrlen(runner->dsListPool), remainingCells)) return false; + repeat(arrlen(runner->dsListPool), i) { + DsList* list = &runner->dsListPool[i]; + if (list->freed) continue; + if (!checkpointConsumeCells((int64_t) arrlen(list->items), remainingCells)) return false; + repeat(arrlen(list->items), j) { + if (!checkpointRValueSupported(list->items[j], 0, remainingCells)) return false; + } + } + + if (!checkpointConsumeCells((int64_t) arrlen(runner->dsQueuePool), remainingCells)) return false; + repeat(arrlen(runner->dsQueuePool), i) { + DsQueue* queue = &runner->dsQueuePool[i]; + if (queue->freed) continue; + if (!checkpointConsumeCells((int64_t) arrlen(queue->items), remainingCells)) return false; + repeat(arrlen(queue->items), j) { + if (!checkpointRValueSupported(queue->items[j], 0, remainingCells)) return false; + } + } + + if (!checkpointConsumeCells((int64_t) arrlen(runner->dsStackPool), remainingCells)) return false; + repeat(arrlen(runner->dsStackPool), i) { + DsStack* stack = &runner->dsStackPool[i]; + if (stack->freed) continue; + if (!checkpointConsumeCells((int64_t) arrlen(stack->items), remainingCells)) return false; + repeat(arrlen(stack->items), j) { + if (!checkpointRValueSupported(stack->items[j], 0, remainingCells)) return false; + } + } + + if (!checkpointConsumeCells((int64_t) arrlen(runner->dsPriorityPool), remainingCells)) return false; + repeat(arrlen(runner->dsPriorityPool), i) { + DsPriority* priority = &runner->dsPriorityPool[i]; + if (priority->freed) continue; + if (!checkpointConsumeCells((int64_t) arrlen(priority->items), remainingCells)) return false; + repeat(arrlen(priority->items), j) { + if (!isfinite(priority->items[j].depth) || + !checkpointRValueSupported(priority->items[j].item, 0, remainingCells)) return false; + } + } + + if (!checkpointConsumeCells((int64_t) arrlen(runner->dsGridPool), remainingCells)) return false; + repeat(arrlen(runner->dsGridPool), i) { + DsGrid* grid = &runner->dsGridPool[i]; + if (grid->freed) continue; + if (grid->width < 0 || grid->height < 0) return false; + int64_t count = (int64_t) grid->width * (int64_t) grid->height; + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, j) { + if (!checkpointRValueSupported(grid->items[j], 0, remainingCells)) return false; + } + } + return true; +} + +RunnerCheckpointStatus Runner_checkpointStatus(Runner* runner) { + if (runner == nullptr || runner->currentRoom == nullptr) return RUNNER_CHECKPOINT_NO_ACTIVE_ROOM; + if (runner->pendingRoom != -1) return RUNNER_CHECKPOINT_TRANSITION_ACTIVE; + if (runner->currentIni != nullptr || runner->currentIniDirty || + arrlen(runner->structInstances) != 0 || arrlen(runner->callLaterEntries) != 0 || + arrlen(runner->asyncSaveLoadQueue) != 0 || arrlen(runner->asyncBufferGroupOps) != 0 || + runner->asyncBufferGroupActive || runner->currentIniPath != nullptr) { + return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + } + // v2 cannot restore the runtime resources added by the newer upstream. + // Do not emit a checkpoint which would silently lose their state. + repeat(arrlen(runner->particleTypePool), i) { + if (runner->particleTypePool[i].used || runner->particleTypePool[i].refCount > 0) + return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + } + repeat(arrlen(runner->particleSystemPool), i) { + if (runner->particleSystemPool[i].used) return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + } + repeat(arrlen(runner->audioEmitters), i) { + if (runner->audioEmitters[i].active) return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + } + if (runner->newVertexFormat != nullptr || runner->gameSpeedOverride != 0.0) + return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + repeat(arrlen(runner->vertexFormats), i) { + if (runner->vertexFormats[i] != nullptr) return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + } + repeat(runner->vertexBufferCount, i) { + if (runner->vertexBuffers[i] != nullptr) return RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE; + } + repeat(MAX_OPEN_TEXT_FILES, i) if (runner->openTextFiles[i].isOpen) return RUNNER_CHECKPOINT_FILE_OPEN; + repeat(MAX_OPEN_BINARY_FILES, i) if (runner->openBinaryFiles[i].isOpen) return RUNNER_CHECKPOINT_FILE_OPEN; + repeat(arrlen(runner->gmlBufferPool), i) if (runner->gmlBufferPool[i].isValid) return RUNNER_CHECKPOINT_DATA_STRUCTURE_ACTIVE; + repeat(arrlen(runner->mpGridPool), i) if (runner->mpGridPool[i].inUse) return RUNNER_CHECKPOINT_DATA_STRUCTURE_ACTIVE; + repeat(runner->dataWin->room.count, i) { + if ((int32_t) i != runner->currentRoomIndex && runner->dataWin->room.rooms[i].persistent && + runner->savedRoomStates[i].initialized) return RUNNER_CHECKPOINT_PERSISTENT_ROOM_STATE; + } + int32_t remainingCells = 100000; + if (!checkpointDataStructuresSupported(runner, &remainingCells)) return RUNNER_CHECKPOINT_VALUE_UNSUPPORTED; + if (!checkpointVariablesSupported(&runner->vmContext->globalScopeInstance->selfVars, &remainingCells)) return RUNNER_CHECKPOINT_VALUE_UNSUPPORTED; + repeat(arrlen(runner->instances), i) { + Instance* instance = runner->instances[i]; + if (instance->active && !checkpointVariablesSupported(&instance->selfVars, &remainingCells)) return RUNNER_CHECKPOINT_VALUE_UNSUPPORTED; + } + return RUNNER_CHECKPOINT_READY; +} + +bool Runner_canCheckpoint(Runner* runner) { + return Runner_checkpointStatus(runner) == RUNNER_CHECKPOINT_READY; +} + static void writeRValueJson(JsonWriter* w, RValue val) { switch (val.type) { case RVALUE_REAL: @@ -4598,6 +4763,105 @@ static void writeRValueJson(JsonWriter* w, RValue val) { } } +static void writeCheckpointRValueArray(JsonWriter* w, RValue* values, int32_t count) { + JsonWriter_beginArray(w); + repeat(count, i) writeRValueJson(w, values[i]); + JsonWriter_endArray(w); +} + +static void writeCheckpointDataStructures(JsonWriter* w, Runner* runner) { + JsonWriter_key(w, "dataStructures"); + JsonWriter_beginObject(w); + + JsonWriter_key(w, "maps"); + JsonWriter_beginArray(w); + repeat(arrlen(runner->dsMapPool), i) { + DsMapEntry* map = runner->dsMapPool[i]; + JsonWriter_beginObject(w); + repeat(shlen(map), j) { + JsonWriter_key(w, map[j].key); + writeRValueJson(w, map[j].value); + } + JsonWriter_endObject(w); + } + JsonWriter_endArray(w); + + JsonWriter_key(w, "lists"); + JsonWriter_beginArray(w); + repeat(arrlen(runner->dsListPool), i) { + DsList* list = &runner->dsListPool[i]; + if (list->freed) JsonWriter_null(w); + else writeCheckpointRValueArray(w, list->items, (int32_t) arrlen(list->items)); + } + JsonWriter_endArray(w); + + JsonWriter_key(w, "queues"); + JsonWriter_beginArray(w); + repeat(arrlen(runner->dsQueuePool), i) { + DsQueue* queue = &runner->dsQueuePool[i]; + if (queue->freed) JsonWriter_null(w); + else writeCheckpointRValueArray(w, queue->items, (int32_t) arrlen(queue->items)); + } + JsonWriter_endArray(w); + + JsonWriter_key(w, "stacks"); + JsonWriter_beginArray(w); + repeat(arrlen(runner->dsStackPool), i) { + DsStack* stack = &runner->dsStackPool[i]; + if (stack->freed) JsonWriter_null(w); + else writeCheckpointRValueArray(w, stack->items, (int32_t) arrlen(stack->items)); + } + JsonWriter_endArray(w); + + JsonWriter_key(w, "priorities"); + JsonWriter_beginArray(w); + repeat(arrlen(runner->dsPriorityPool), i) { + DsPriority* priority = &runner->dsPriorityPool[i]; + if (priority->freed) { + JsonWriter_null(w); + continue; + } + JsonWriter_beginArray(w); + repeat(arrlen(priority->items), j) { + JsonWriter_beginObject(w); + JsonWriter_propertyDouble(w, "priority", priority->items[j].depth); + JsonWriter_key(w, "value"); + writeRValueJson(w, priority->items[j].item); + JsonWriter_endObject(w); + } + JsonWriter_endArray(w); + } + JsonWriter_endArray(w); + + JsonWriter_key(w, "grids"); + JsonWriter_beginArray(w); + repeat(arrlen(runner->dsGridPool), i) { + DsGrid* grid = &runner->dsGridPool[i]; + if (grid->freed) { + JsonWriter_null(w); + continue; + } + JsonWriter_beginObject(w); + JsonWriter_propertyInt(w, "width", grid->width); + JsonWriter_propertyInt(w, "height", grid->height); + JsonWriter_key(w, "values"); + writeCheckpointRValueArray(w, grid->items, grid->width * grid->height); + JsonWriter_endObject(w); + } + JsonWriter_endArray(w); + + JsonWriter_endObject(w); +} + +static void writeCheckpointJoysticks(JsonWriter* writer, const RunnerGamepadState* gamepads) { + JsonWriter_key(writer, "joystickDevices"); + JsonWriter_beginArray(writer); + for (int i = 0; i < MAX_JOYSTICKS; i++) { + JsonWriter_int(writer, gamepads != nullptr ? gamepads->joystickDevices[i] : 0); + } + JsonWriter_endArray(writer); +} + char* Runner_dumpStateJson(Runner* runner) { DataWin* dataWin = runner->dataWin; int32_t instanceCount = (int32_t) arrlen(runner->instances); @@ -4606,7 +4870,10 @@ char* Runner_dumpStateJson(Runner* runner) { JsonWriter_beginObject(&w); + JsonWriter_propertyInt(&w, "checkpointSchemaVersion", 2); + JsonWriter_propertyInt(&w, "frame", runner->frameCount); + writeCheckpointJoysticks(&w, runner->gamepads); // Room info JsonWriter_key(&w, "room"); @@ -4615,6 +4882,8 @@ char* Runner_dumpStateJson(Runner* runner) { JsonWriter_propertyInt(&w, "index", runner->currentRoomIndex); JsonWriter_endObject(&w); + writeCheckpointDataStructures(&w, runner); + // Instances JsonWriter_key(&w, "instances"); JsonWriter_beginArray(&w); @@ -4650,7 +4919,12 @@ char* Runner_dumpStateJson(Runner* runner) { JsonWriter_propertyDouble(&w, "x", inst->x); JsonWriter_propertyDouble(&w, "y", inst->y); + JsonWriter_propertyDouble(&w, "xprevious", inst->xprevious); + JsonWriter_propertyDouble(&w, "yprevious", inst->yprevious); + JsonWriter_propertyDouble(&w, "xstart", inst->xstart); + JsonWriter_propertyDouble(&w, "ystart", inst->ystart); JsonWriter_propertyInt(&w, "depth", inst->depth); + JsonWriter_propertyInt(&w, "layer", inst->layer); // Sprite JsonWriter_key(&w, "sprite"); @@ -4675,6 +4949,13 @@ char* Runner_dumpStateJson(Runner* runner) { JsonWriter_propertyBool(&w, "active", inst->active); JsonWriter_propertyBool(&w, "solid", inst->solid); JsonWriter_propertyBool(&w, "persistent", inst->persistent); + JsonWriter_propertyDouble(&w, "speed", inst->speed); + JsonWriter_propertyDouble(&w, "direction", inst->direction); + JsonWriter_propertyDouble(&w, "hspeed", inst->hspeed); + JsonWriter_propertyDouble(&w, "vspeed", inst->vspeed); + JsonWriter_propertyDouble(&w, "friction", inst->friction); + JsonWriter_propertyDouble(&w, "gravity", inst->gravity); + JsonWriter_propertyDouble(&w, "gravityDirection", inst->gravityDirection); // Alarms JsonWriter_key(&w, "alarms"); @@ -4778,6 +5059,424 @@ char* Runner_dumpStateJson(Runner* runner) { return result; } +static JsonValue* checkpointField(const JsonValue* object, const char* name, JsonValueType type) { + if (!JsonReader_isObject(object)) return nullptr; + JsonValue* value = JsonReader_getJsonValueByKey(object, name); + return value != nullptr && value->type == type ? value : nullptr; +} + +static bool checkpointNumber(const JsonValue* object, const char* name, double* result) { + JsonValue* value = checkpointField(object, name, JSON_NUMBER); + if (value == nullptr) return false; + *result = JsonReader_getDouble(value); + return isfinite(*result); +} + +static bool checkpointInteger(const JsonValue* object, const char* name, int32_t* result) { + double number; + if (!checkpointNumber(object, name, &number) || number < INT32_MIN || number > INT32_MAX || number != (int32_t) number) return false; + *result = (int32_t) number; + return true; +} + +static bool checkpointBoolean(const JsonValue* object, const char* name, bool* result) { + JsonValue* value = checkpointField(object, name, JSON_BOOL); + if (value == nullptr) return false; + *result = JsonReader_getBool(value); + return true; +} + +static bool checkpointRValueFromJson(VMContext* vm, const JsonValue* value, int32_t depth, int32_t* remainingCells, RValue* result) { + if (value == nullptr || depth > 16 || *remainingCells <= 0) return false; + switch (value->type) { + case JSON_NULL: + *result = RValue_makeUndefined(); + return true; + case JSON_BOOL: + *result = RValue_makeBool(JsonReader_getBool(value)); + return true; + case JSON_NUMBER: { + double number = JsonReader_getDouble(value); + if (!isfinite(number)) return false; + *result = RValue_makeReal(number); + return true; + } + case JSON_STRING: + *result = RValue_makeOwnedString(safeStrdup(JsonReader_getString(value))); + return true; + case JSON_ARRAY: { + int32_t length = JsonReader_arrayLength(value); + if (length < 0 || length > *remainingCells) return false; + GMLArray* array = GMLArray_create(vm->dataWin, 0); + repeat(length, i) { + (*remainingCells)--; + RValue cell = RValue_makeUndefined(); + if (!checkpointRValueFromJson(vm, JsonReader_getArrayElement(value, i), depth + 1, remainingCells, &cell)) { + GMLArray_decRef(array); + return false; + } + GMLArray_add(array, cell); + RValue_free(&cell); + } + *result = RValue_makeArray(array); + return true; + } + case JSON_OBJECT: + return false; + } + return false; +} + +static void freeCheckpointRValueArray(RValue** values) { + repeat(arrlen(*values), i) RValue_free(&(*values)[i]); + arrfree(*values); + *values = nullptr; +} + +static bool restoreCheckpointRValueArray( + VMContext* vm, const JsonValue* source, int32_t* remainingCells, RValue** result +) { + if (!JsonReader_isArray(source)) return false; + int32_t count = JsonReader_arrayLength(source); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + RValue value = RValue_makeUndefined(); + if (!checkpointRValueFromJson(vm, JsonReader_getArrayElement(source, i), 0, remainingCells, &value)) { + freeCheckpointRValueArray(result); + return false; + } + arrput(*result, value); + } + return true; +} + +static bool restoreCheckpointMaps( + Runner* runner, const JsonValue* maps, int32_t* remainingCells +) { + int32_t count = JsonReader_arrayLength(maps); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + JsonValue* source = JsonReader_getArrayElement(maps, i); + if (!JsonReader_isObject(source) || + !checkpointConsumeCells(JsonReader_objectLength(source), remainingCells)) return false; + DsMapEntry* map = nullptr; + arrput(runner->dsMapPool, map); + DsMapEntry** target = &runner->dsMapPool[arrlen(runner->dsMapPool) - 1]; + repeat(JsonReader_objectLength(source), j) { + const char* key = JsonReader_getJsonKeyByIndex(source, j); + if (key == nullptr || shgeti(*target, key) >= 0) return false; + RValue value = RValue_makeUndefined(); + if (!checkpointRValueFromJson( + runner->vmContext, JsonReader_getJsonValueByIndex(source, j), 0, remainingCells, &value + )) return false; + shput(*target, safeStrdup(key), value); + } + } + return true; +} + +static bool restoreCheckpointLists( + Runner* runner, const JsonValue* lists, int32_t* remainingCells +) { + int32_t count = JsonReader_arrayLength(lists); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + JsonValue* source = JsonReader_getArrayElement(lists, i); + DsList list = {0}; + list.freed = JsonReader_isNull(source); + if (!list.freed && !restoreCheckpointRValueArray( + runner->vmContext, source, remainingCells, &list.items + )) return false; + arrput(runner->dsListPool, list); + } + return true; +} + +static bool restoreCheckpointQueues( + Runner* runner, const JsonValue* queues, int32_t* remainingCells +) { + int32_t count = JsonReader_arrayLength(queues); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + JsonValue* source = JsonReader_getArrayElement(queues, i); + DsQueue queue = {0}; + queue.freed = JsonReader_isNull(source); + if (!queue.freed && !restoreCheckpointRValueArray( + runner->vmContext, source, remainingCells, &queue.items + )) return false; + arrput(runner->dsQueuePool, queue); + } + return true; +} + +static bool restoreCheckpointStacks( + Runner* runner, const JsonValue* stacks, int32_t* remainingCells +) { + int32_t count = JsonReader_arrayLength(stacks); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + JsonValue* source = JsonReader_getArrayElement(stacks, i); + DsStack stack = {0}; + stack.freed = JsonReader_isNull(source); + if (!stack.freed && !restoreCheckpointRValueArray( + runner->vmContext, source, remainingCells, &stack.items + )) return false; + arrput(runner->dsStackPool, stack); + } + return true; +} + +static bool restoreCheckpointPriorities( + Runner* runner, const JsonValue* priorities, int32_t* remainingCells +) { + int32_t count = JsonReader_arrayLength(priorities); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + JsonValue* source = JsonReader_getArrayElement(priorities, i); + DsPriority priority = {0}; + priority.freed = JsonReader_isNull(source); + arrput(runner->dsPriorityPool, priority); + DsPriority* target = &runner->dsPriorityPool[arrlen(runner->dsPriorityPool) - 1]; + if (!priority.freed) { + int32_t itemCount = JsonReader_arrayLength(source); + if (!checkpointConsumeCells(itemCount, remainingCells)) return false; + repeat(itemCount, j) { + JsonValue* item = JsonReader_getArrayElement(source, j); + JsonValue* valueSource = item == nullptr ? nullptr : JsonReader_getJsonValueByKey(item, "value"); + DsPriorityItem restored = {0}; + double depth; + if (!checkpointNumber(item, "priority", &depth)) return false; + restored.depth = (GMLReal)depth; + if (!isfinite(restored.depth)) return false; + if (!checkpointRValueFromJson(runner->vmContext, valueSource, 0, remainingCells, &restored.item)) + return false; + arrput(target->items, restored); + } + } + } + return true; +} + +static bool restoreCheckpointGrids( + Runner* runner, const JsonValue* grids, int32_t* remainingCells +) { + int32_t count = JsonReader_arrayLength(grids); + if (!checkpointConsumeCells(count, remainingCells)) return false; + repeat(count, i) { + JsonValue* source = JsonReader_getArrayElement(grids, i); + DsGrid grid = {0}; + grid.freed = JsonReader_isNull(source); + arrput(runner->dsGridPool, grid); + DsGrid* target = &runner->dsGridPool[arrlen(runner->dsGridPool) - 1]; + if (!grid.freed) { + JsonValue* values = checkpointField(source, "values", JSON_ARRAY); + if (!checkpointInteger(source, "width", &target->width) || target->width < 0 || + !checkpointInteger(source, "height", &target->height) || target->height < 0 || values == nullptr) return false; + int64_t cellCount = (int64_t) target->width * (int64_t) target->height; + if (cellCount != JsonReader_arrayLength(values) || + !checkpointConsumeCells(cellCount, remainingCells)) return false; + target->items = cellCount == 0 ? nullptr : (RValue *) safeCalloc((size_t) cellCount, sizeof(RValue)); + repeat(cellCount, j) { + if (!checkpointRValueFromJson( + runner->vmContext, JsonReader_getArrayElement(values, (int32_t) j), 0, remainingCells, &target->items[j] + )) return false; + } + } + } + return true; +} + +static bool restoreCheckpointDataStructures( + Runner* runner, const JsonValue* source, int32_t* remainingCells +) { + JsonValue* maps = checkpointField(source, "maps", JSON_ARRAY); + JsonValue* lists = checkpointField(source, "lists", JSON_ARRAY); + JsonValue* queues = checkpointField(source, "queues", JSON_ARRAY); + JsonValue* stacks = checkpointField(source, "stacks", JSON_ARRAY); + JsonValue* priorities = checkpointField(source, "priorities", JSON_ARRAY); + JsonValue* grids = checkpointField(source, "grids", JSON_ARRAY); + if (maps == nullptr || lists == nullptr || queues == nullptr || stacks == nullptr || + priorities == nullptr || grids == nullptr) return false; + bool valid = restoreCheckpointMaps(runner, maps, remainingCells) && + restoreCheckpointLists(runner, lists, remainingCells) && + restoreCheckpointQueues(runner, queues, remainingCells) && + restoreCheckpointStacks(runner, stacks, remainingCells) && + restoreCheckpointPriorities(runner, priorities, remainingCells) && + restoreCheckpointGrids(runner, grids, remainingCells); + if (!valid) clearCheckpointDataStructures(runner); + return valid; +} + +static bool restoreCheckpointVariables(VMContext* vm, Instance* target, const JsonValue* object, int32_t* remainingCells) { + if (!JsonReader_isObject(object)) return false; + repeat(JsonReader_objectLength(object), i) { + const char* name = JsonReader_getJsonKeyByIndex(object, i); + // Some GameMaker VARI chunks contain an empty name for a real variable ID. + // Checkpoint v2 already writes that name as a valid empty JSON object key. + if (name == nullptr) return false; + RValue value = RValue_makeUndefined(); + if (!checkpointRValueFromJson(vm, JsonReader_getJsonValueByIndex(object, i), 0, remainingCells, &value)) return false; + Instance_setSelfVar(target, VM_getOrAllocateVarID(vm, name), value); + RValue_free(&value); + } + return true; +} + +static void clearCheckpointInstances(Runner* runner) { + repeat(arrlen(runner->instances), i) { + Instance* instance = runner->instances[i]; + Runner_removeInstanceLayerElement(runner, instance->instanceId); + hmdel(runner->instancesById, instance->instanceId); + Instance_free(instance); + } + arrfree(runner->instances); + runner->instances = nullptr; + Runner_clearAllObjectLists(runner); + runner->drawableListStructureDirty = true; +} + +static bool restoreCheckpointInstance(Runner* runner, const JsonValue* value, int32_t* remainingCells) { + int32_t instanceId, objectIndex, depth, layer; + double x, y, xprevious, yprevious, xstart, ystart; + double speed, direction, hspeed, vspeed, friction, gravity, gravityDirection; + bool visible, active, solid, persistent; + JsonValue* sprite = checkpointField(value, "sprite", JSON_OBJECT); + JsonValue* scale = checkpointField(value, "scale", JSON_OBJECT); + JsonValue* alarms = checkpointField(value, "alarms", JSON_OBJECT); + JsonValue* variables = checkpointField(value, "selfVariables", JSON_OBJECT); + int32_t spriteIndex, blend; + double imageIndex, imageSpeed, imageXscale, imageYscale, imageAngle, imageAlpha; + if (!checkpointInteger(value, "instanceId", &instanceId) || instanceId < 0 || + !checkpointInteger(value, "objectIndex", &objectIndex) || objectIndex < 0 || + (uint32_t) objectIndex >= runner->dataWin->objt.count || + !checkpointNumber(value, "x", &x) || !checkpointNumber(value, "y", &y) || + !checkpointNumber(value, "xprevious", &xprevious) || !checkpointNumber(value, "yprevious", &yprevious) || + !checkpointNumber(value, "xstart", &xstart) || !checkpointNumber(value, "ystart", &ystart) || + !checkpointInteger(value, "depth", &depth) || !checkpointInteger(value, "layer", &layer) || + !checkpointInteger(sprite, "index", &spriteIndex) || !checkpointNumber(sprite, "imageIndex", &imageIndex) || + !checkpointNumber(sprite, "imageSpeed", &imageSpeed) || !checkpointNumber(scale, "x", &imageXscale) || + !checkpointNumber(scale, "y", &imageYscale) || !checkpointNumber(value, "angle", &imageAngle) || + !checkpointNumber(value, "alpha", &imageAlpha) || !checkpointInteger(value, "blend", &blend) || + !checkpointBoolean(value, "visible", &visible) || !checkpointBoolean(value, "active", &active) || + !checkpointBoolean(value, "solid", &solid) || !checkpointBoolean(value, "persistent", &persistent) || + !checkpointNumber(value, "speed", &speed) || !checkpointNumber(value, "direction", &direction) || + !checkpointNumber(value, "hspeed", &hspeed) || !checkpointNumber(value, "vspeed", &vspeed) || + !checkpointNumber(value, "friction", &friction) || !checkpointNumber(value, "gravity", &gravity) || + !checkpointNumber(value, "gravityDirection", &gravityDirection) || alarms == nullptr || variables == nullptr) return false; + + Instance* instance = createAndInitInstance(runner, instanceId, objectIndex, x, y); + instance->xprevious = (float) xprevious; + instance->yprevious = (float) yprevious; + instance->xstart = (float) xstart; + instance->ystart = (float) ystart; + instance->depth = depth; + instance->layer = layer; + instance->spriteIndex = spriteIndex; + instance->imageIndex = (float) imageIndex; + instance->imageSpeed = (float) imageSpeed; + instance->imageXscale = (float) imageXscale; + instance->imageYscale = (float) imageYscale; + instance->imageAngle = (float) imageAngle; + instance->imageAlpha = (float) imageAlpha; + instance->imageBlend = (uint32_t) blend; + instance->visible = visible; + instance->active = active; + instance->solid = solid; + instance->persistent = persistent; + instance->speed = (float) speed; + instance->direction = (float) direction; + instance->hspeed = (float) hspeed; + instance->vspeed = (float) vspeed; + instance->friction = (float) friction; + instance->gravity = (float) gravity; + instance->gravityDirection = (float) gravityDirection; + repeat(JsonReader_objectLength(alarms), i) { + const char* alarmName = JsonReader_getJsonKeyByIndex(alarms, i); + char* end = nullptr; + long alarmIndex = strtol(alarmName, &end, 10); + JsonValue* alarmValue = JsonReader_getJsonValueByIndex(alarms, i); + if (end == alarmName || *end != '\0' || alarmIndex < 0 || alarmIndex >= GML_ALARM_COUNT || + !JsonReader_isNumber(alarmValue)) return false; + int64_t alarm = JsonReader_getInt(alarmValue); + if (alarm < INT32_MIN || alarm > INT32_MAX) return false; + instance->alarm[alarmIndex] = (int32_t) alarm; + if (alarm >= 0) instance->activeAlarmMask |= (uint16_t) (1u << alarmIndex); + } + if (!restoreCheckpointVariables(runner->vmContext, instance, variables, remainingCells)) return false; + if (layer >= 0 && Runner_findRuntimeLayerById(runner, layer) != nullptr) { + Runner_addInstanceLayerElement(runner, layer, instanceId); + } + instance->createEventFired = true; + if ((uint32_t) instanceId >= runner->nextInstanceId) runner->nextInstanceId = (uint32_t) instanceId + 1; + return true; +} + +static bool readCheckpointJoysticks(const JsonValue* root, int32_t devices[MAX_JOYSTICKS]) { + JsonValue* values = JsonReader_getJsonValueByKey(root, "joystickDevices"); + // Older checkpoints used direct physical slots for joystick 1/2. + if (values == nullptr) { + for (int i = 0; i < MAX_JOYSTICKS; i++) devices[i] = i + 1; + return true; + } + if (values->type != JSON_ARRAY || JsonReader_arrayLength(values) != MAX_JOYSTICKS) return false; + int32_t parsed[MAX_JOYSTICKS]; + for (int i = 0; i < MAX_JOYSTICKS; i++) { + JsonValue* value = JsonReader_getArrayElement(values, i); + if (value == nullptr || value->type != JSON_NUMBER) return false; + double number = JsonReader_getDouble(value); + if (!isfinite(number) || number < 0 || number > MAX_GAMEPADS || number != (int32_t) number) return false; + parsed[i] = (int32_t) number; + for (int j = 0; j < i; j++) { + if (parsed[i] != 0 && parsed[j] == parsed[i]) return false; + } + } + memcpy(devices, parsed, sizeof(parsed)); + return true; +} + +bool Runner_restoreStateJson(Runner* runner, const char* json) { + JsonValue* root = JsonReader_parse(json); + if (root == nullptr) return false; + int32_t schema, frame, roomIndex; + int32_t joystickDevices[MAX_JOYSTICKS]; + JsonValue* room = checkpointField(root, "room", JSON_OBJECT); + JsonValue* instances = checkpointField(root, "instances", JSON_ARRAY); + JsonValue* globals = checkpointField(root, "globalVariables", JSON_OBJECT); + JsonValue* dataStructures = checkpointField(root, "dataStructures", JSON_OBJECT); + bool valid = checkpointInteger(root, "checkpointSchemaVersion", &schema) && schema == 2 && + checkpointInteger(root, "frame", &frame) && frame >= 0 && checkpointInteger(room, "index", &roomIndex) && + roomIndex >= 0 && (uint32_t) roomIndex < runner->dataWin->room.count && instances != nullptr && globals != nullptr && + dataStructures != nullptr && readCheckpointJoysticks(root, joystickDevices) && + JsonReader_arrayLength(instances) >= 0 && JsonReader_arrayLength(instances) <= 100000; + if (!valid) {JsonReader_free(root); return false;} + + Runner_reset(runner); + Runner_initFirstRoom(runner); + if (runner->currentRoomIndex != roomIndex) { + runner->pendingRoom = roomIndex; + Runner_handlePendingRoomChange(runner); + } + clearCheckpointInstances(runner); + IntRValueHashMap_freeAllValues(&runner->vmContext->globalScopeInstance->selfVars); + clearCheckpointDataStructures(runner); + int32_t remainingCells = 100000; + valid = restoreCheckpointDataStructures(runner, dataStructures, &remainingCells) && + restoreCheckpointVariables(runner->vmContext, runner->vmContext->globalScopeInstance, globals, &remainingCells); + for (int i = 0; valid && i < JsonReader_arrayLength(instances); i++) { + valid = restoreCheckpointInstance(runner, JsonReader_getArrayElement(instances, i), &remainingCells); + } + if (valid) { + runner->frameCount = frame; + // Restore channel identity only. Button and axis states come from the + // currently connected devices on the first resumed frame. + if (runner->gamepads != nullptr) { + memcpy(runner->gamepads->joystickDevices, joystickDevices, sizeof(joystickDevices)); + } + } + JsonReader_free(root); + return valid; +} + void Runner_free(Runner* runner) { if (runner == nullptr) return; diff --git a/src/runner.h b/src/runner.h index 64c6adcae..ad2ab1de0 100644 --- a/src/runner.h +++ b/src/runner.h @@ -980,6 +980,21 @@ int32_t Runner_surfaceGetTarget(Runner* runner); void Runner_dumpState(Runner* runner); char* Runner_dumpStateJson(Runner* runner); + +typedef enum RunnerCheckpointStatus { + RUNNER_CHECKPOINT_READY = 0, + RUNNER_CHECKPOINT_NO_ACTIVE_ROOM = 1, + RUNNER_CHECKPOINT_TRANSITION_ACTIVE = 2, + RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE = 3, + RUNNER_CHECKPOINT_FILE_OPEN = 4, + RUNNER_CHECKPOINT_DATA_STRUCTURE_ACTIVE = 5, + RUNNER_CHECKPOINT_PERSISTENT_ROOM_STATE = 6, + RUNNER_CHECKPOINT_VALUE_UNSUPPORTED = 7, +} RunnerCheckpointStatus; + +RunnerCheckpointStatus Runner_checkpointStatus(Runner* runner); +bool Runner_canCheckpoint(Runner* runner); +bool Runner_restoreStateJson(Runner* runner, const char* json); void Runner_free(Runner* runner); RuntimeLayer* Runner_findRuntimeLayerByName(Runner* runner, char* name); RuntimeLayer* Runner_findRuntimeLayerById(Runner* runner, int32_t id); diff --git a/src/runner_gamepad.c b/src/runner_gamepad.c index afb4047d7..949b6740b 100644 --- a/src/runner_gamepad.c +++ b/src/runner_gamepad.c @@ -88,7 +88,11 @@ void RunnerGamepad_beginFrame(RunnerGamepadState* gp) { } int RunnerGamepad_getDeviceCount(RunnerGamepadState* gp) { - return gp->connectedCount; + // GML enumerates [0, device_count); occupied slots need not be contiguous. + for (int device = MAX_GAMEPADS - 1; device >= 0; device--) { + if (gp->slots[device].connected) return device + 1; + } + return 0; } bool RunnerGamepad_isConnected(RunnerGamepadState* gp, int device) { @@ -96,6 +100,40 @@ bool RunnerGamepad_isConnected(RunnerGamepadState* gp, int device) { return gp->slots[device].connected; } +int RunnerGamepad_joystickDevice(RunnerGamepadState* gp, int joystick) { + if (gp == NULL || joystick < 1 || joystick > MAX_JOYSTICKS) return -1; + bool assigned[MAX_GAMEPADS] = {false}; + // Keep connected devices in their existing legacy channels. Re-enumerating + // by connected count on every query would move a live pad after hotplug. + for (int i = 0; i < MAX_JOYSTICKS; i++) { + int device = gp->joystickDevices[i] - 1; + if (RunnerGamepad_isConnected(gp, device) && !assigned[device]) { + assigned[device] = true; + } else { + gp->joystickDevices[i] = 0; + } + } + // Preserve the historical slot-0/slot-1 correspondence when available. + for (int i = 0; i < MAX_JOYSTICKS; i++) { + if (gp->joystickDevices[i] == 0 && gp->slots[i].connected && !assigned[i]) { + gp->joystickDevices[i] = i + 1; + assigned[i] = true; + } + } + // The two-channel legacy API must also see pads in later physical slots. + // Modern gamepad_* calls continue to use their original physical indices. + for (int device = 0; device < MAX_GAMEPADS; device++) { + if (!gp->slots[device].connected || assigned[device]) continue; + for (int i = 0; i < MAX_JOYSTICKS; i++) { + if (gp->joystickDevices[i] != 0) continue; + gp->joystickDevices[i] = device + 1; + assigned[device] = true; + break; + } + } + return gp->joystickDevices[joystick - 1] - 1; +} + bool RunnerGamepad_buttonCheck(RunnerGamepadState* gp, int device, int button) { if (device < 0 || device >= MAX_GAMEPADS) return false; if (!gp->slots[device].connected) return false; diff --git a/src/runner_gamepad.h b/src/runner_gamepad.h index a4198d638..2cb2521a0 100644 --- a/src/runner_gamepad.h +++ b/src/runner_gamepad.h @@ -5,6 +5,7 @@ #include #define MAX_GAMEPADS 16 +#define MAX_JOYSTICKS 2 #define GP_BUTTON_COUNT 17 #define GP_AXIS_COUNT 4 @@ -51,6 +52,8 @@ typedef struct { typedef struct { GamepadSlot slots[MAX_GAMEPADS]; int connectedCount; + // Legacy joystick 1/2 -> one-based physical slot; zero means unassigned. + int32_t joystickDevices[MAX_JOYSTICKS]; } RunnerGamepadState; RunnerGamepadState* RunnerGamepad_create(void); @@ -61,6 +64,7 @@ void RunnerGamepad_beginFrame(RunnerGamepadState* gp); int RawToGPUndertale(int32_t gmlButton); int RunnerGamepad_getDeviceCount(RunnerGamepadState* gp); bool RunnerGamepad_isConnected(RunnerGamepadState* gp, int device); +int RunnerGamepad_joystickDevice(RunnerGamepadState* gp, int joystick); bool RunnerGamepad_buttonCheck(RunnerGamepadState* gp, int device, int button); bool RunnerGamepad_buttonCheckPressed(RunnerGamepadState* gp, int device, int button); bool RunnerGamepad_buttonCheckReleased(RunnerGamepadState* gp, int device, int button); diff --git a/src/vm_builtins.c b/src/vm_builtins.c index 5f2a25f1b..6f426a3de 100644 --- a/src/vm_builtins.c +++ b/src/vm_builtins.c @@ -8044,6 +8044,7 @@ static RValue builtin_ini_close(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE if (runner->currentIni == nullptr) { free(runner->currentIniPath); runner->currentIniPath = nullptr; + runner->currentIniDirty = false; // No ini open = empty return RValue_makeOwnedString(safeStrdup("")); } @@ -8056,6 +8057,7 @@ static RValue builtin_ini_close(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE FileSystem* fs = runner->fileSystem; fs->vtable->writeFileText(fs, runner->currentIniPath, serialized); } + runner->currentIniDirty = false; // Move to cache instead of freeing discardIniCache(runner); @@ -8788,7 +8790,7 @@ static RValue builtin_joystick_exists(VMContext* ctx, RValue* args, int32_t argC REQUIRE_ARGC_AT_LEAST("joystick_exists", 1, RValue_makeBool(false)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeBool(false); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); return RValue_makeBool(RunnerGamepad_isConnected(runner->gamepads, id)); } @@ -8796,7 +8798,7 @@ static RValue builtin_joystick_xpos(VMContext* ctx, RValue* args, int32_t argCou REQUIRE_ARGC_AT_LEAST("joystick_xpos", 1, RValue_makeReal(0.0)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeReal(0.0); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); return RValue_makeReal((GMLReal) RunnerGamepad_axisValue(runner->gamepads, id, GP_AXIS_LH)); } @@ -8804,7 +8806,7 @@ static RValue builtin_joystick_ypos(VMContext* ctx, RValue* args, int32_t argCou REQUIRE_ARGC_AT_LEAST("joystick_ypos", 1, RValue_makeReal(0.0)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeReal(0.0); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); return RValue_makeReal((GMLReal) RunnerGamepad_axisValue(runner->gamepads, id, GP_AXIS_LV)); } @@ -8813,7 +8815,7 @@ static RValue builtin_joystick_direction(VMContext* ctx, RValue* args, int32_t a REQUIRE_ARGC_AT_LEAST("joystick_direction", 1, RValue_makeReal(101.0)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeReal(101.0); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); float haxis = RunnerGamepad_axisValue(runner->gamepads, id, GP_AXIS_LH); float vaxis = RunnerGamepad_axisValue(runner->gamepads, id, GP_AXIS_LV); @@ -8842,7 +8844,7 @@ static RValue builtin_joystick_pov(VMContext* ctx, RValue* args, int32_t argCoun REQUIRE_ARGC_AT_LEAST("joystick_pov", 1, RValue_makeReal(-1.0)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeReal(-1.0); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); RunnerGamepadState* gp = runner->gamepads; bool up = RunnerGamepad_buttonCheck(gp, id, GP_PADU); bool down = RunnerGamepad_buttonCheck(gp, id, GP_PADD); @@ -8864,7 +8866,7 @@ static RValue builtin_joystick_check_button(VMContext* ctx, RValue* args, int32_ REQUIRE_ARGC_AT_LEAST("joystick_check_button", 2, RValue_makeBool(false)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeBool(false); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); int32_t button = RawToGPUndertale(RValue_toInt32(args[1])); //UNDERTALE HACK return RValue_makeBool(RunnerGamepad_buttonCheck(runner->gamepads, id, button)); } @@ -8873,7 +8875,7 @@ static RValue builtin_joystick_has_pov(VMContext* ctx, RValue* args, int32_t arg REQUIRE_ARGC_AT_LEAST("joystick_has_pov", 1, RValue_makeBool(false)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeBool(false); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); return RValue_makeBool(RunnerGamepad_isConnected(runner->gamepads, id)); } @@ -8881,7 +8883,7 @@ static RValue builtin_joystick_buttons(VMContext* ctx, RValue* args, int32_t arg REQUIRE_ARGC_AT_LEAST("joystick_buttons", 1, RValue_makeReal(0.0)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeReal(0.0); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); if (!RunnerGamepad_isConnected(runner->gamepads, id)) return RValue_makeReal(0.0); return RValue_makeReal(GP_BUTTON_COUNT); } @@ -8890,7 +8892,7 @@ static RValue builtin_joystick_name(VMContext* ctx, RValue* args, MAYBE_UNUSED i REQUIRE_ARGC_AT_LEAST("joystick_name", 1, RValue_makeOwnedString(safeStrdup(""))); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeOwnedString(safeStrdup("")); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); return RValue_makeOwnedString(safeStrdup(RunnerGamepad_getDescription(runner->gamepads, id))); } @@ -8898,7 +8900,7 @@ static RValue builtin_joystick_axes(VMContext* ctx, RValue* args, MAYBE_UNUSED i REQUIRE_ARGC_AT_LEAST("joystick_axes", 1, RValue_makeReal(0.0)); Runner* runner = ctx->runner; if (runner == NULL || runner->gamepads == NULL) return RValue_makeReal(0.0); - int32_t id = RValue_toInt32(args[0]) - 1; + int32_t id = RunnerGamepad_joystickDevice(runner->gamepads, RValue_toInt32(args[0])); return RValue_makeReal(RunnerGamepad_getAxisCount(runner->gamepads, id)); } diff --git a/tests/retrom_checkpoint_test.c b/tests/retrom_checkpoint_test.c new file mode 100644 index 000000000..2ea385530 --- /dev/null +++ b/tests/retrom_checkpoint_test.c @@ -0,0 +1,98 @@ +#include +#include "../src/runner.c" + +void platformLog(MAYBE_UNUSED logType type, const char* format, va_list args) { + vfprintf(stderr, format, args); +} + +int main(void) { + // A late lower physical slot must not change the saved legacy selection + // when a new runner restores with both devices already connected. + RunnerGamepadState originalPads = {0}; + originalPads.slots[2].connected = true; + assert(RunnerGamepad_joystickDevice(&originalPads, 1) == 2); + originalPads.slots[0].connected = true; + assert(RunnerGamepad_joystickDevice(&originalPads, 2) == 0); + JsonWriter padWriter = JsonWriter_create(); + JsonWriter_beginObject(&padWriter); + writeCheckpointJoysticks(&padWriter, &originalPads); + JsonWriter_endObject(&padWriter); + char* padJson = JsonWriter_copyOutput(&padWriter); + JsonValue* padDocument = JsonReader_parse(padJson); + RunnerGamepadState restoredPads = {0}; + restoredPads.slots[0].connected = true; + restoredPads.slots[2].connected = true; + assert(readCheckpointJoysticks(padDocument, restoredPads.joystickDevices)); + assert(RunnerGamepad_joystickDevice(&restoredPads, 1) == 2); + assert(RunnerGamepad_joystickDevice(&restoredPads, 2) == 0); + JsonReader_free(padDocument); + JsonWriter_free(&padWriter); + free(padJson); + const char* badPads[] = { + "{\"joystickDevices\":[3,3]}", "{\"joystickDevices\":[-1,0]}", + "{\"joystickDevices\":[17,0]}", "{\"joystickDevices\":[0.5,0]}", + "{\"joystickDevices\":[0]}", "{\"joystickDevices\":null}", + }; + for (int i = 0; i < (int)(sizeof(badPads) / sizeof(badPads[0])); i++) { + padDocument = JsonReader_parse(badPads[i]); + assert(!readCheckpointJoysticks(padDocument, restoredPads.joystickDevices)); + JsonReader_free(padDocument); + } + padDocument = JsonReader_parse("{}"); + assert(readCheckpointJoysticks(padDocument, restoredPads.joystickDevices)); + assert(restoredPads.joystickDevices[0] == 1 && restoredPads.joystickDevices[1] == 2); + JsonReader_free(padDocument); + + DataWin data = {0}; + data.gen8.wadVersion = 17; + data.detectedFormat.major = 2; + data.detectedFormat.minor = 3; + VMContext vm = {0}; + vm.dataWin = &data; + Runner runner = {0}; + runner.vmContext = &vm; + JsonValue* document = JsonReader_parse("[[{\"priority\":1.25,\"value\":[7]}]]"); + int32_t cells = 100; + assert(restoreCheckpointPriorities(&runner, document, &cells)); + assert(arrlen(runner.dsPriorityPool) == 1); + DsPriorityItem restored = runner.dsPriorityPool[0].items[0]; + assert(restored.depth == 1.25); + assert(restored.item.type == RVALUE_ARRAY); + assert(restored.item.array->type == GML_MODERN_ARRAY); + assert(RValue_toReal(*GMLArray_slot(restored.item.array, 0)) == 7); + JsonWriter writer = JsonWriter_create(); + JsonWriter_beginObject(&writer); + writeCheckpointDataStructures(&writer, &runner); + JsonWriter_endObject(&writer); + char* encoded = JsonWriter_copyOutput(&writer); + assert(strstr(encoded, "1.25") != NULL); + free(encoded); + JsonWriter_free(&writer); + JsonReader_free(document); + clearCheckpointDataStructures(&runner); + Room room = {0}; + Instance globals = {0}; + runner.currentRoom = &room; + runner.dataWin = &data; + runner.pendingRoom = -1; + vm.globalScopeInstance = &globals; + assert(Runner_checkpointStatus(&runner) == RUNNER_CHECKPOINT_READY); + ParticleType type = {0}; + type.used = true; + arrput(runner.particleTypePool, type); + assert(Runner_checkpointStatus(&runner) == RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE); + arrfree(runner.particleTypePool); + AudioEmitter emitter = {0}; + emitter.active = true; + arrput(runner.audioEmitters, emitter); + assert(Runner_checkpointStatus(&runner) == RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE); + arrfree(runner.audioEmitters); + runner.gameSpeedOverride = 60.0; + assert(Runner_getEffectiveGameSpeed(&runner) == 60.0); + assert(Runner_checkpointStatus(&runner) == RUNNER_CHECKPOINT_RUNTIME_RESOURCE_ACTIVE); + runner.gameSpeedOverride = 0.0; + room.speed = 45; + assert(Runner_getEffectiveGameSpeed(&runner) == 45.0); + assert(Runner_checkpointStatus(&runner) == RUNNER_CHECKPOINT_READY); + return 0; +} diff --git a/tests/retrom_gamepad_test.c b/tests/retrom_gamepad_test.c new file mode 100644 index 000000000..fdbff6ac9 --- /dev/null +++ b/tests/retrom_gamepad_test.c @@ -0,0 +1,25 @@ +#include +#include "runner_gamepad.h" + +int main(void) { + RunnerGamepadState state = {0}; + state.slots[1].connected = true; + state.slots[1].axisValue[0] = 0.75f; + state.connectedCount = 1; + // Enumerating [0, device_count) must include pads after empty slots. + int found = 0; + for (int i = 0; i < RunnerGamepad_getDeviceCount(&state); i++) { + if (RunnerGamepad_isConnected(&state, i)) found++; + } + assert(found == 1); + assert(RunnerGamepad_axisValue(&state, 1, GP_AXIS_LH) == 0.75f); + state.slots[0].connected = true; + state.connectedCount = 2; + assert(RunnerGamepad_getDeviceCount(&state) >= 2); + state.slots[0].connected = false; + state.connectedCount = 1; + assert(RunnerGamepad_getDeviceCount(&state) >= 2); + state.slots[MAX_GAMEPADS - 1].connected = true; + assert(RunnerGamepad_getDeviceCount(&state) == MAX_GAMEPADS); + return 0; +} diff --git a/tests/retrom_joystick_test.c b/tests/retrom_joystick_test.c new file mode 100644 index 000000000..710bd007c --- /dev/null +++ b/tests/retrom_joystick_test.c @@ -0,0 +1,71 @@ +#include +#include "../src/vm_builtins.c" + +void platformLog(MAYBE_UNUSED logType type, const char* format, va_list args) { + vfprintf(stderr, format, args); +} + +int main(void) { + RunnerGamepadState pads = {0}; + Runner runner = {0}; + runner.gamepads = &pads; + VMContext vm = {0}; + vm.runner = &runner; + RValue first[] = {RValue_makeReal(1), RValue_makeReal(2)}; + RValue second[] = {RValue_makeReal(2), RValue_makeReal(2)}; + + // Legacy games query joystick 1 and 2 even when the only physical pad is + // in slot 2 or later. All legacy queries must resolve the same device. + pads.slots[2].connected = true; + pads.slots[2].axisValue[0] = 0.75f; + pads.slots[2].axisValue[1] = -0.75f; + pads.slots[2].buttonDown[0] = true; + pads.slots[2].buttonDown[12] = true; + strcpy(pads.slots[2].description, "Third-slot pad"); + assert(RValue_toBool(builtin_joystick_exists(&vm, first, 1))); + assert(!RValue_toBool(builtin_joystick_exists(&vm, second, 1))); + assert(RValue_toReal(builtin_joystick_xpos(&vm, first, 1)) == 0.75); + assert(RValue_toReal(builtin_joystick_ypos(&vm, first, 1)) == -0.75); + assert(RValue_toReal(builtin_joystick_direction(&vm, first, 1)) == 105); + assert(RValue_toReal(builtin_joystick_pov(&vm, first, 1)) == 0); + assert(RValue_toBool(builtin_joystick_check_button(&vm, first, 2))); + assert(RValue_toBool(builtin_joystick_has_pov(&vm, first, 1))); + assert(RValue_toReal(builtin_joystick_buttons(&vm, first, 1)) == GP_BUTTON_COUNT); + assert(RValue_toReal(builtin_joystick_axes(&vm, first, 1)) == GP_AXIS_COUNT); + RValue name = builtin_joystick_name(&vm, first, 1); + assert(strcmp(name.string, "Third-slot pad") == 0); + RValue_free(&name); + + // A lower-numbered device appearing later must not steal joystick 1. + pads.slots[0].connected = true; + pads.slots[0].axisValue[0] = -0.5f; + assert(RValue_toReal(builtin_joystick_xpos(&vm, first, 1)) == 0.75); + assert(RValue_toReal(builtin_joystick_xpos(&vm, second, 1)) == -0.5); + assert(RunnerGamepad_axisValue(&pads, 0, GP_AXIS_LH) == -0.5f); + assert(RunnerGamepad_axisValue(&pads, 2, GP_AXIS_LH) == 0.75f); + + // Disconnect releases the old channel; the other channel does not move. + pads.slots[2].connected = false; + assert(!RValue_toBool(builtin_joystick_exists(&vm, first, 1))); + assert(!RValue_toBool(builtin_joystick_check_button(&vm, first, 2))); + assert(RValue_toReal(builtin_joystick_ypos(&vm, first, 1)) == 0); + assert(RValue_toReal(builtin_joystick_xpos(&vm, second, 1)) == -0.5); + pads.slots[3].connected = true; + pads.slots[3].axisValue[0] = 1; + assert(RValue_toReal(builtin_joystick_xpos(&vm, first, 1)) == 1); + assert(RValue_toReal(builtin_joystick_xpos(&vm, second, 1)) == -0.5); + + // Existing slot-0/slot-1 identities remain compatible with older saves. + memset(&pads, 0, sizeof(pads)); + pads.slots[1].connected = true; + assert(!RValue_toBool(builtin_joystick_exists(&vm, first, 1))); + assert(RValue_toBool(builtin_joystick_exists(&vm, second, 1))); + pads.slots[0].connected = true; + assert(RValue_toBool(builtin_joystick_exists(&vm, first, 1))); + assert(RValue_toBool(builtin_joystick_exists(&vm, second, 1))); + first[0] = RValue_makeReal(0); + assert(!RValue_toBool(builtin_joystick_exists(&vm, first, 1))); + first[0] = RValue_makeReal(3); + assert(!RValue_toBool(builtin_joystick_exists(&vm, first, 1))); + return 0; +} diff --git a/tests/retrom_lazy_data_test.c b/tests/retrom_lazy_data_test.c new file mode 100644 index 000000000..b4b13011d --- /dev/null +++ b/tests/retrom_lazy_data_test.c @@ -0,0 +1,80 @@ +#include +#include +#include +#include +#include "data_win.h" +#include "log.h" + +static size_t readBytes; +static long furthestRead; +size_t __real_fread(void*, size_t, size_t, FILE*); +size_t __wrap_fread(void* out, size_t size, size_t count, FILE* file) { + long at = ftell(file); + size_t result = __real_fread(out, size, count, file); + readBytes += result * size; + if (at > furthestRead) furthestRead = at; + return result; +} +void platformLog(logType type, const char* format, va_list args) { + (void)type; + vfprintf(stderr, format, args); +} +static void number(FILE* file, uint32_t value) { + unsigned char bytes[4] = {value, value >> 8, value >> 16, value >> 24}; + assert(fwrite(bytes, 1, 4, file) == 4); +} +int main(int argc, char** argv) { + assert(argc == 2); + char* path = argv[1]; + int fd = mkstemp(path); + assert(fd >= 0); + FILE* file = fdopen(fd, "wb"); + const uint32_t length = 8 * 1024 * 1024; + const uint32_t first = 2 * 1024 * 1024, second = 6 * 1024 * 1024; + fwrite("FORM", 1, 4, file); number(file, length - 8); + fwrite("AUDO", 1, 4, file); number(file, length - 16); + number(file, 2); number(file, first); number(file, second); + fseek(file, first, SEEK_SET); number(file, 4); fwrite("wave", 1, 4, file); + fseek(file, second, SEEK_SET); number(file, 0); + assert(ftruncate(fd, length) == 0); + fclose(file); + DataWinParserOptions options = {0}; + options.parseAudo = true; + options.lazyLoadAudio = true; + DataWin* data = DataWin_parse(path, options); + assert(data->audo.count == 2); + assert(readBytes < 1024); + assert(furthestRead < 1024); + DataWin_loadAudoIfNeeded(data, 0); + assert(data->audo.entries[0].dataSize == 4); + assert(memcmp(data->audo.entries[0].data, "wave", 4) == 0); + size_t loaded = readBytes; + DataWin_loadAudoIfNeeded(data, 0); + assert(readBytes == loaded); + DataWin_loadAudoIfNeeded(data, 1); + assert(data->audo.entries[1].dataSize == 0); + loaded = readBytes; + DataWin_loadAudoIfNeeded(data, 1); + assert(readBytes == loaded); + DataWin_free(data); + file = fopen(path, "wb"); + fwrite("FORM", 1, 4, file); number(file, length - 8); + fwrite("TXTR", 1, 4, file); number(file, length - 16); + number(file, 2); number(file, 28); number(file, 36); + number(file, 0); number(file, first); number(file, 0); number(file, second); + fseek(file, first, SEEK_SET); fwrite("page", 1, 4, file); + fflush(file); assert(ftruncate(fileno(file), length) == 0); fclose(file); + readBytes = 0; furthestRead = 0; + options.parseAudo = false; options.parseTxtr = true; options.lazyLoadTextures = true; + data = DataWin_parse(path, options); + assert(data->txtr.count == 2 && readBytes < 1024 && furthestRead < 1024); + assert(data->txtr.textures[0].blobData == NULL && data->txtr.textures[1].blobData == NULL); + DataWin_loadTxtrIfNeeded(data, 0); + assert(memcmp(data->txtr.textures[0].blobData, "page", 4) == 0); + assert(data->txtr.textures[1].blobData == NULL); + loaded = readBytes; DataWin_loadTxtrIfNeeded(data, 0); assert(readBytes == loaded); + DataWin_free(data); + unlink(path); + puts("lazy audio and texture indexes and random reads passed"); + return 0; +} diff --git a/tests/test_retrom_web_host.py b/tests/test_retrom_web_host.py new file mode 100755 index 000000000..0d32515b6 --- /dev/null +++ b/tests/test_retrom_web_host.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Small source-boundary regressions for the Retrom Web host bridge.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class RetromWebHostTests(unittest.TestCase): + def setUp(self) -> None: + self.source = (ROOT / "src/retrom-web/main.c").read_text(encoding="utf-8") + self.builtins = (ROOT / "src/vm_builtins.c").read_text(encoding="utf-8") + self.cmake = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8") + self.release_workflow = (ROOT / ".github/workflows/rpg-runtime-release.yml").read_text(encoding="utf-8") + self.build_script = (ROOT / ".github/rpg-runtime/build-web.sh").read_text(encoding="utf-8") + + def test_web_gamepads_are_sampled_at_the_runner_frame_boundary(self) -> None: + loop = self.source.index("while (!gRunner->shouldExit)") + gamepads = self.source.index("updateGamepads(gRunner->gamepads);", loop) + step = self.source.index("Runner_step(gRunner);", loop) + self.assertLess(gamepads, step) + self.assertIn("slot->buttonPressed[button] =", self.source) + self.assertIn("slot->buttonReleased[button] =", self.source) + self.assertIn("gamepads->connectedCount++;", self.source) + + def test_pause_and_exit_share_the_host_condition(self) -> None: + self.assertIn("while (gHostPaused && gRunner != nullptr && !gRunner->shouldExit)", self.source) + self.assertGreaterEqual(self.source.count("pthread_cond_broadcast(&gHostCondition);"), 2) + self.assertIn("if (gRunner != nullptr) gRunner->shouldExit = true;", self.source) + + def test_ready_and_exit_are_explicit_host_events(self) -> None: + self.assertEqual(self.source.count("type: 'runnerReady'"), 1) + self.assertEqual(self.source.count("type: 'runnerExit'"), 1) + + def test_web_warnings_do_not_use_the_browser_error_stream(self) -> None: + start = self.source.index("void platformLog") + end = self.source.index("// Configures the sample rate", start) + logger = self.source[start:end] + warning = logger.index("case LOG_TYPE_WARNING:") + error = logger.index("case LOG_TYPE_ERROR:") + warning_body = logger[warning:error] + self.assertIn("out = stdout;", warning_body) + self.assertIn('fputs("Warning: ", out);', warning_body) + + def test_web_exports_are_stable(self) -> None: + for name in ( + "_setRunnerPaused", "_setGamepadConnected", + "_setGamepadButton", "_setGamepadAxis", + ): + self.assertIn(name, self.cmake) + self.assertIn('"-sEXIT_RUNTIME=1"', self.cmake) + + def test_gamepad_snapshot_is_published_under_one_frame_lock(self) -> None: + start = self.source.index("void setGamepads(") + end = self.source.index("static void waitWhilePaused", start) + snapshot = self.source[start:end] + self.assertEqual(snapshot.count("pthread_mutex_lock(&gHostMutex)"), 1) + self.assertEqual(snapshot.count("pthread_mutex_unlock(&gHostMutex)"), 1) + self.assertLess(snapshot.index("pthread_mutex_lock"), snapshot.index("for (int device")) + self.assertGreater(snapshot.index("pthread_mutex_unlock"), snapshot.index("gGamepadAxes[device][axis] =")) + self.assertIn("'_setGamepads'", self.cmake) + + def test_checkpoint_is_bounded_and_requires_a_paused_frame_boundary(self) -> None: + self.assertIn("#define CHECKPOINT_MAX_BYTES (16 * 1024 * 1024)", self.source) + self.assertIn("gLoopPaused && gCheckpointAvailable", self.source) + self.assertIn('memcmp(bytes, "BSCP", 4) != 0', self.source) + self.assertIn("Runner_restoreStateJson(gRunner, json)", self.source) + + def test_checkpoint_v2_preserves_supported_game_data_structures(self) -> None: + runner = (ROOT / "src/runner.c").read_text(encoding="utf-8") + self.assertIn('writeCheckpointDataStructures(&w, runner);', runner) + self.assertIn( + 'restoreCheckpointDataStructures(runner, dataStructures, &remainingCells)', + runner, + ) + self.assertIn('"checkpointSchemaVersion", 2', runner) + self.assertIn("clearCheckpointDataStructures(runner);", runner) + self.assertNotIn( + "runner->dsMapPool[i] != nullptr && shlen(runner->dsMapPool[i]) > 0) " + "return RUNNER_CHECKPOINT_DATA_STRUCTURE_ACTIVE", + runner, + ) + self.assertIn("writeUint32LE(gCheckpointBytes + 4, 2);", self.source) + self.assertIn("readUint32LE(bytes + 4) != 2", self.source) + + def test_checkpoint_v2_restores_empty_game_variable_names(self) -> None: + runner = (ROOT / "src/runner.c").read_text(encoding="utf-8") + start = runner.index("static bool restoreCheckpointVariables") + end = runner.index("static void clearCheckpointInstances", start) + restore = runner[start:end] + self.assertIn("name == nullptr", restore) + self.assertNotIn("name[0] == '\\0'", restore) + self.assertIn("VM_getOrAllocateVarID(vm, name)", restore) + + def test_closing_a_dirty_ini_clears_the_transient_checkpoint_blocker(self) -> None: + start = self.builtins.index("static RValue builtin_ini_close") + end = self.builtins.index("static RValue builtin_ini_read_string", start) + close_body = self.builtins[start:end] + cache_move = close_body.index("runner->cachedIni = runner->currentIni;") + self.assertLess(close_body.index("runner->currentIniDirty = false;"), cache_move) + + def test_release_workflow_supports_immutable_integration_candidates(self) -> None: + self.assertIn("(-rc\\.[1-9][0-9]*)?", self.release_workflow) + self.assertIn("prerelease+=(--prerelease)", self.release_workflow) + + def test_clean_checkout_build_creates_its_ignored_workspace(self) -> None: + self.assertLess(self.build_script.index('mkdir -p "$root/.cache"'), self.build_script.index("mktemp -d")) + + +if __name__ == "__main__": + unittest.main()