Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/rpg-runtime/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions .github/rpg-runtime/build-candidate.sh
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions .github/rpg-runtime/build-web.sh
Original file line number Diff line number Diff line change
@@ -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"
191 changes: 191 additions & 0 deletions .github/rpg-runtime/candidate_descriptor.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions .github/rpg-runtime/test-checkpoint.sh
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions .github/rpg-runtime/test-gamepad.sh
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions .github/rpg-runtime/test-lazy-data.sh
Original file line number Diff line number Diff line change
@@ -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"
86 changes: 86 additions & 0 deletions .github/rpg-runtime/verify-release.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading