monorepo: one repository for the decoders, the port and the corpus
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
This commit is contained in:
52
tools/port/screen-strip
Executable file
52
tools/port/screen-strip
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render one screen at several points on its timeline and montage them -- the
|
||||
# P2 gate artifact, and the way to eyeball any animation question later.
|
||||
#
|
||||
# tools/screen-strip main_menu # a default spread
|
||||
# tools/screen-strip main_menu 0.45 0.6 0.9 1.35 # explicit seconds
|
||||
#
|
||||
# Also writes <screen>.rest.png and <screen>.settled.png and reports where they
|
||||
# differ. That difference is the interesting number: the timeline is expected to
|
||||
# land EXACTLY on the declared resting pose for every element whose `rest` the
|
||||
# decoders identify correctly, so a clean run shows a difference confined to the
|
||||
# elements we know it misses, and nothing else. A difference anywhere else means
|
||||
# the interpolation is wrong.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
|
||||
name="${1:?usage: screen-strip SCREEN [SECONDS...]}"; shift
|
||||
times=("$@")
|
||||
[ ${#times[@]} -eq 0 ] && times=(0.45 0.52 0.57 0.62 0.67 0.75 0.90 1.35)
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/screen-strip}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
mkdir -p "$OUT"
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
shot() { # shot <godot args...> <outfile>
|
||||
local out="${!#}"
|
||||
godot --path port --resolution 1280x720 -- "--screen=$name" "${@:1:$#-1}" \
|
||||
"--capture=$out" >"$OUT/$name.log" 2>&1
|
||||
}
|
||||
|
||||
labelled=()
|
||||
for t in "${times[@]}"; do
|
||||
shot "--time=$t" "$OUT/$name.t$t.png"
|
||||
convert "$OUT/$name.t$t.png" -resize 320x180 -bordercolor gray30 -border 1 \
|
||||
-background black -fill white -pointsize 13 label:"t = ${t}s" \
|
||||
-gravity center -append "$OUT/$name.lab$t.png"
|
||||
labelled+=("$OUT/$name.lab$t.png")
|
||||
done
|
||||
montage "${labelled[@]}" -tile 4x -geometry +4+4 -background black "$OUT/$name.strip.png"
|
||||
|
||||
shot --pose=rest "$OUT/$name.rest.png"
|
||||
shot --time=99 "$OUT/$name.settled.png"
|
||||
convert "$OUT/$name.settled.png" "$OUT/$name.rest.png" -compose difference -composite "$OUT/$name.d.png"
|
||||
max=$(convert "$OUT/$name.d.png" -format "%[fx:maxima*255]" info:)
|
||||
convert "$OUT/$name.d.png" -colorspace Gray -threshold 0 "$OUT/$name.m.png"
|
||||
if [ "${max%.*}" = "0" ]; then
|
||||
box="(identical)"
|
||||
else
|
||||
box=$(convert "$OUT/$name.m.png" -trim -format "%wx%h%X%Y" info: 2>/dev/null)
|
||||
fi
|
||||
echo "$name: strip -> $OUT/$name.strip.png"
|
||||
echo "$name: settled timeline vs declared rest -- max ${max}/255, differing region $box"
|
||||
109
tools/port/verify-screen
Executable file
109
tools/port/verify-screen
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# Diff Godot's drawing of an exported screen against `sylpheed-cli screen
|
||||
# render` of the same build.
|
||||
#
|
||||
# WHAT THIS IS, AND WHAT IT IS NOT.
|
||||
#
|
||||
# It is a CONSISTENCY check between two decoders that share their assumptions,
|
||||
# and a REGRESSION detector: "did anything move since last commit". It is NOT a
|
||||
# correctness check and agreement here is NOT evidence of correctness.
|
||||
#
|
||||
# `sylpheed-cli` is not the oracle. The oracle is the Xenia Canary capture and
|
||||
# the game. Reborn is an explorer and extraction CLI for verifying decodes, and
|
||||
# it can be wrong -- this corpus has been bitten three times by both renderers
|
||||
# agreeing and both being wrong: pteff05 (the menu background, missing from
|
||||
# both), scale-0, and rest(). Each time the capture caught it and neither
|
||||
# renderer could have.
|
||||
#
|
||||
# So: a DIFFERS row means "we moved apart, go find out which of us moved". It
|
||||
# does not mean the port is wrong. Where a capture and this tool disagree, the
|
||||
# capture wins. Use `tools/verify-capture` for the correctness question.
|
||||
#
|
||||
# tools/verify-screen # every screen in the manifest
|
||||
# tools/verify-screen main_menu title # named screens
|
||||
#
|
||||
# Writes <screen>.godot.png, <screen>.ref.png and <screen>.diff.png into
|
||||
# $OUT (default: a directory under /tmp) and prints, per screen, the largest
|
||||
# per-channel difference anywhere in the frame.
|
||||
#
|
||||
# The two renderers are held to the same inputs on purpose:
|
||||
#
|
||||
# * the COMPARISON CLI is the one built by `build-reference-cli`, from the same
|
||||
# `sylpheed-formats` revision the exporter is pinned to. /reborn's own
|
||||
# target/ is a live mount of the other agent's checkout and moves mid-run; a
|
||||
# pixel disagreement against a moving decoder proves nothing.
|
||||
# * `--black` because Godot clears to black and the screen carries its own
|
||||
# background. The CLI's default dim slate stands in for a 3D scene behind an
|
||||
# in-mission screen, which is not this screen.
|
||||
# * `--primitives --animated` because those are what make the CLI draw the same
|
||||
# element set. `--focus` is NOT passed: nothing is focused at rest (HANDOFF
|
||||
# Q5 measured initial focus as unstable boot to boot, so choosing one is
|
||||
# P5's decision).
|
||||
# * `--pose=rest` on the Godot side. Since P2 the port's DEFAULT is to play the
|
||||
# timeline, and the settled timeline is deliberately NOT what `rest` says --
|
||||
# the export's `rest` misses `ptframe1`/`ptframe2` on the main menu, and the
|
||||
# running game shows them (docs/DECISIONS.md). Both renderers read `rest`
|
||||
# through the same decoder, so asking for it here keeps this a test of the
|
||||
# PORT against the reference. It is not the test of whether `rest` is right;
|
||||
# that one is the oracle capture, and the port already departs from it.
|
||||
#
|
||||
# A difference here is not automatically the port's fault, and it is not
|
||||
# automatically a fault at all. Say which renderer moved and why -- do not tune
|
||||
# until they match.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
|
||||
# `reference-cli/`, not `release/`: the reference binary is built per pinned
|
||||
# revision so a pin change cannot silently reuse the previous revision's build.
|
||||
# See docker/bin/build-reference-cli.
|
||||
CLI="${SYLPHEED_CLI:-${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/reference-cli/sylpheed-cli}"
|
||||
DISC="${SYLPHEED_DISC:-/disc}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-screen}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
|
||||
[ -x "$CLI" ] || { echo "no reference CLI at $CLI -- run build-reference-cli" >&2; exit 2; }
|
||||
[ -f export/manifest.json ] || { echo "no export/manifest.json -- run build-export --run" >&2; exit 2; }
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# Godot needs one scan to register the `class_name` globals; without it every
|
||||
# script fails to parse and the run dies with no frame drawn.
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
screens=("$@")
|
||||
if [ ${#screens[@]} -eq 0 ]; then
|
||||
mapfile -t screens < <(python3 -c '
|
||||
import json; print("\n".join(s["name"] for s in json.load(open("export/manifest.json"))["screens"]))')
|
||||
fi
|
||||
|
||||
status=0
|
||||
for name in "${screens[@]}"; do
|
||||
build=$(python3 -c '
|
||||
import json,sys
|
||||
m=json.load(open("export/manifest.json"))
|
||||
f=next(s["file"] for s in m["screens"] if s["name"]==sys.argv[1])
|
||||
print(json.load(open("export/"+f))["source"]["build"])' "$name")
|
||||
|
||||
# `--all` because the exporter now addresses by PAK ENTRY INDEX, which is the
|
||||
# numbering `--all` uses; without it the CLI enumerates only the 12 bundles
|
||||
# `is_build` accepts and `--build 10` would land on entry 12. `--all` widens
|
||||
# the list, it does not change how any one bundle composites.
|
||||
"$CLI" screen render "$DISC/dat/GP_TITLE.pak" "$OUT/$name.ref.png" \
|
||||
--build "$build" --all --black --primitives --animated >/dev/null
|
||||
|
||||
godot --path port --resolution 1280x720 -- \
|
||||
"--screen=$name" --pose=rest "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1
|
||||
|
||||
convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png"
|
||||
read -r max mean <<<"$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -format "%[fx:maxima*255] %[fx:mean*255]" info:)"
|
||||
|
||||
# 3/255 is what integer-truncating compositing in the CLI and float rounding
|
||||
# in a GPU differ by. Anything above that is a placement, order or colour
|
||||
# disagreement and needs a reason, not a threshold.
|
||||
verdict=OK
|
||||
awk "BEGIN{exit !($max > 3)}" && { verdict=DIFFERS; status=1; }
|
||||
printf '%-16s build %-3s max %-5s mean %-8s %s\n' "$name" "$build" "$max" "${mean:0:6}" "$verdict"
|
||||
done
|
||||
echo "artifacts in $OUT"
|
||||
exit $status
|
||||
86
tools/port/verify-video-audio
Executable file
86
tools/port/verify-video-audio
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prove Godot actually emits a transcoded movie's audio -- with no audio device.
|
||||
#
|
||||
# tools/verify-video-audio ADV
|
||||
#
|
||||
# This container has no sound card and Godot falls back to the dummy driver, so
|
||||
# "does it play" looked unanswerable from here. It is not: an AudioEffectRecord
|
||||
# on the Master bus makes Godot write its own mixed output to a WAV from inside
|
||||
# a headless run. That is Godot rendering audio to a file instead of a device --
|
||||
# no new dependency, no image rebuild, and it tests the real playback path
|
||||
# rather than the file the encoder produced.
|
||||
#
|
||||
# What this checks is that GODOT EMITS NON-SILENCE from the movie. It is
|
||||
# deliberately NOT a fidelity comparison against the source: a difference-signal
|
||||
# RMS between a transcode and its source is inconclusive without cross-
|
||||
# correlation alignment and an agreed downmix -- a one-sample offset makes the
|
||||
# residual nearly as loud as the signal. Level and non-silence are what this
|
||||
# claims.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
name="${1:-ADV}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-video-audio}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
mkdir -p "$OUT"
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
wav="$OUT/$name.godot.wav"
|
||||
rm -f "$wav"
|
||||
cat > "$OUT/probe.gd" <<'GD'
|
||||
extends SceneTree
|
||||
|
||||
func _init() -> void:
|
||||
var args := {}
|
||||
for a in OS.get_cmdline_user_args():
|
||||
if a.begins_with("--") and a.contains("="):
|
||||
var p := a.substr(2).split("=", true, 1)
|
||||
args[p[0]] = p[1]
|
||||
|
||||
var tree_ := ExportTree.locate()
|
||||
if tree_.root == "":
|
||||
push_error(tree_.error); quit(2); return
|
||||
var v: Dictionary = tree_.video(args.get("video", "ADV"))
|
||||
if v.is_empty():
|
||||
push_error(tree_.error); quit(2); return
|
||||
|
||||
# Record the MASTER bus: whatever Godot mixes, including the dummy driver's
|
||||
# output. This is the real playback path, not the encoded file.
|
||||
var rec := AudioEffectRecord.new()
|
||||
AudioServer.add_bus_effect(0, rec)
|
||||
|
||||
var stream := VideoStreamTheora.new()
|
||||
stream.file = v["path"]
|
||||
var p := VideoStreamPlayer.new()
|
||||
p.stream = stream
|
||||
get_root().add_child(p)
|
||||
await process_frame
|
||||
rec.set_recording_active(true)
|
||||
p.play()
|
||||
var seconds := float(args.get("seconds", "6"))
|
||||
var t := 0.0
|
||||
while t < seconds and p.is_playing():
|
||||
await process_frame
|
||||
t += get_root().get_process_delta_time()
|
||||
rec.set_recording_active(false)
|
||||
var clip := rec.get_recording()
|
||||
if clip == null:
|
||||
push_error("no recording came back from the Master bus"); quit(3); return
|
||||
clip.save_to_wav(args.get("out", "/tmp/godot-audio.wav"))
|
||||
print("recorded %.2f s, %d Hz, stereo=%s -> %s" % [
|
||||
t, clip.mix_rate, clip.stereo, args.get("out", "")])
|
||||
quit(0)
|
||||
GD
|
||||
|
||||
godot --path port --resolution 320x180 --script "$OUT/probe.gd" -- \
|
||||
"--video=$name" "--out=$wav" "--seconds=${SECONDS_TO_RECORD:-6}" 2>&1 \
|
||||
| grep -viE "ALSA|Vulkan|V-Sync|OpenGL|audio driver|^ *at: |Condition|^$" || true
|
||||
|
||||
[ -s "$wav" ] || { echo "verify-video-audio: Godot wrote no WAV" >&2; exit 1; }
|
||||
echo "--- what Godot emitted ---"
|
||||
ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \
|
||||
| grep -oE "(max_volume|mean_volume): [-0-9.]+ dB" | sed 's/^/ /'
|
||||
mean=$(ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \
|
||||
| grep -oE "mean_volume: [-0-9.]+" | grep -oE -- "-?[0-9.]+")
|
||||
# Digital silence reports around -91 dB at 16-bit. Anything near that is nothing.
|
||||
awk -v m="$mean" 'BEGIN{ if (m < -80) { print " VERDICT: silence -- Godot is not emitting this movie\047s audio"; exit 1 }
|
||||
else { printf " VERDICT: audio present (mean %.1f dB)\n", m } }'
|
||||
Reference in New Issue
Block a user