diff --git a/godot-import/.gitignore b/godot-import/.gitignore new file mode 100644 index 00000000..11bc8073 --- /dev/null +++ b/godot-import/.gitignore @@ -0,0 +1,12 @@ +# Generated from the user's own disc. This repo stays clean-room: code, +# schemas, authored mappings and docs only -- never game assets. +/export/ + +# Rust +/target/ +**/*.rs.bk + +# Godot +.godot/ +/port/.godot/ +*.import diff --git a/godot-import/Cargo.lock b/godot-import/Cargo.lock new file mode 100644 index 00000000..214307f6 --- /dev/null +++ b/godot-import/Cargo.lock @@ -0,0 +1,1217 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "binrw" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4b52fe7fbd9e207b879c52c5af7efd861c2f4e234ab4f744b0bdc04a863623" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7196f6935a944081728167ce6e3a599bd38d6d80d21afe4ca17be2097a6682f" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bitbybit" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec187a89ab07e209270175faf9e07ceb2755d984954e58a2296e325ddece2762" +dependencies = [ + "arbitrary-int", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciso" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42222171e20c6a5e2c83cc5295f4a55c27c9397acff30dbae4f3baeffae47f51" +dependencies = [ + "arbitrary-int", + "async-trait", + "bitbybit", + "lz4_flex", + "maybe-async", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom", +] + +[[package]] +name = "proc-bitfield" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "095c6eb206c97ddef87ce3d7e3e492b017093d80bce62317afdf0665df514ade" +dependencies = [ + "proc-bitfield-macros", + "static_assertions", +] + +[[package]] +name = "proc-bitfield-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89409e6b315ead7f4c4d9a79e27dc1e11272f930cbb1fb3d31f2fc64671deb77" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "sylpheed-export" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "image", + "serde", + "serde_json", + "sylpheed-formats", +] + +[[package]] +name = "sylpheed-formats" +version = "0.1.0" +source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?tag=formats-pin-2026-08-29#7eeae3006a7bb92ae31ba8d2b402891f3a31f070" +dependencies = [ + "anyhow", + "binrw", + "flate2", + "futures", + "rayon", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "ttf-parser", + "xdvdfs", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wax" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d12a78aa0bab22d2f26ed1a96df7ab58e8a93506a3e20adb47c51a93b4e1357" +dependencies = [ + "const_format", + "itertools", + "nom", + "pori", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xdvdfs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1777c1ccae32a2185ac98adae015aed3fb06242084b5e7f8b08a811fe0e7b936" +dependencies = [ + "arrayvec", + "async-trait", + "bincode", + "ciso", + "encoding_rs", + "log", + "maybe-async", + "proc-bitfield", + "serde", + "serde-big-array", + "sha3", + "wax", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/godot-import/Cargo.toml b/godot-import/Cargo.toml new file mode 100644 index 00000000..3d3d1ec1 --- /dev/null +++ b/godot-import/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = ["crates/sylpheed-export"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" diff --git a/godot-import/README.md b/godot-import/README.md new file mode 100644 index 00000000..9f847044 --- /dev/null +++ b/godot-import/README.md @@ -0,0 +1,75 @@ +# Sylpheed Godot + +A clean-room Godot 4 port of *Project Sylpheed: Arc of Deception*, starting with +the menu shell: developer splash → intro video → title → main menu → submenus. + +**You need your own copy of the game.** Nothing in this repository is game +content. An offline exporter reads the disc you supply and writes an open, +moddable asset tree; the Godot project reads only that tree and never touches a +disc format. + +``` + your disc ──▶ crates/sylpheed-export ──▶ export/ ──▶ port/ (Godot 4) + (Rust; decoders come from JSON + PNG reads ONLY + sylpheed-formats) + OGG + OGV open formats +``` + +## Why the wall + +Two reasons, and the second is the interesting one: + +1. Godot cannot read IPFB archives, RATC bundles, T8aD textures, XMA banks or + WMV video, and it should not learn to. +2. **Modding is a goal of this port.** If the runtime reads the original formats, + modding means reverse engineering. If it reads JSON and PNG, modding means + opening a file. + +## Where the knowledge comes from + +The decoders live in [`sylpheed-formats`][formats], pinned by revision — a +separate project, where the reverse engineering happens. Its +`docs/port/HANDOFF.md` is the contract: what has been decoded, what was measured +off the running game, and what is known to be undecodable. Read it before +assuming a value is on the disc. + +[formats]: https://git.mc02.dev/fabi/Syplheed-Reborn + +## Layout + +| | | +|---|---| +| `crates/sylpheed-export/` | disc → open formats. Regenerates `export/` wholesale | +| `port/` | the Godot 4 project | +| `authored/` | decisions that are **not** on the disc, each with its reason | +| `export/` | generated, gitignored, never hand-edited | +| `tools/` | verification harnesses that hold the port to the reference renderer | +| `docs/` | the mission, the format spec, the agent's loop prompt | + +## Verifying + +**The oracle is the Xenia Canary capture and the game**, not either renderer. +`sylpheed-cli screen render` is an explorer and extraction CLI for verifying +decodes, and it can be wrong -- three times both it and the port agreed and both +were wrong, each caught only by a capture. + +So `tools/verify-screen` is a **consistency check and a regression detector**, +not a grade. It draws every exported screen both ways -- built from the same +`sylpheed-formats` revision the exporter is pinned to -- and reports the largest +per-channel difference in the frame: + +``` +tools/verify-screen # every screen in the manifest +tools/verify-screen main_menu # one of them +``` + +A difference means the two moved apart; `docs/DECISIONS.md` says which one moved +and why, rather than tuning the port until the number goes down. Correctness is +checked against the captures indexed at `docs/re/captures/ORACLE-CAPTURES.md` -- +mind that they are not gamma-neutral, so RMSE against them has a floor. + +## Status + +**P1.** The exporter writes `GP_TITLE`'s twelve screen builds and their sprites, +and the Godot project draws any of them statically at 1280x720 from that tree +alone. `main_menu` matches the reference renderer to within 3/255 on every +channel of every pixel. Next: P2, keyframe animation. diff --git a/godot-import/authored/README.md b/godot-import/authored/README.md new file mode 100644 index 00000000..3493aae1 --- /dev/null +++ b/godot-import/authored/README.md @@ -0,0 +1,12 @@ +# Authored decisions + +Everything here is a decision **we** made, not something the disc said. It is +hand-written, committed, and survives a re-export — unlike `export/`, which is +regenerated wholesale and must never be hand-edited. + +Every entry carries a `why`. When the RE agent decodes the real answer, **delete +the entry** and let the exporter emit it; that deletion is the measure of +progress. + +See `docs/FORMAT.md` for the schemas and `docs/BLOCKED.md` for which HANDOFF +question each placeholder is standing in for. diff --git a/godot-import/authored/flow.json b/godot-import/authored/flow.json new file mode 100644 index 00000000..5025fa5d --- /dev/null +++ b/godot-import/authored/flow.json @@ -0,0 +1,55 @@ +{ + "format": "sylpheed.flow/1", + "_": [ + "The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a", + "negative -- the order is in none of the four places it could have been. It is", + "not in config.ini's empty [SYSTEM], not in the movie manifest (which carries", + "assets, not transitions), not in a persistent GamePart field (the requested id", + "lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero", + "xrefs of any kind. A transition is a call with a name argument, chosen by code.", + "", + "So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE", + "agent watched the game do, not what any file on the disc says it does. Nothing", + "here may be presented as decoded." + ], + "boot": [ + { + "screen": "publisher_logo", + "why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both." + }, + { + "screen": "developer_logos", + "why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2." + }, + { + "video": "ADV", + "why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.", + "skippable": true, + "skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline." + }, + { + "screen": "title", + "why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go." + } + ], + "dwell": { + "_": [ + "DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer", + "logos at t=190 (3.17 s), both read from the disc. Holding beyond that would", + "be a number nobody has measured, so the sequencer holds for zero extra time", + "and the pacing is the disc's own.", + "", + "When a capture times the real boot, the extra hold per screen goes here." + ] + }, + "screens": { + "_": [ + "What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", + "destination screens and the RE agent later decoded that a transition is a", + "lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,", + "LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`", + "targets when this is written, marked as the name match they are." + ] + } +} diff --git a/godot-import/authored/screen_names.json b/godot-import/authored/screen_names.json new file mode 100644 index 00000000..89df17c8 --- /dev/null +++ b/godot-import/authored/screen_names.json @@ -0,0 +1,94 @@ +{ + "format": "sylpheed.screen_names/1", + "_": [ + "Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its", + "builds, so every name here is a decision. The identifications come from", + "HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer", + "captures of the running game; the exporter stamps the name into the screen", + "file with name_source: \"authored\" so a reader can tell a recovered name from", + "an invented one.", + "", + "KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the", + "ordinal; widening the enumeration to reach the splash renumbers ordinals, and", + "a name that moves when the enumeration rule changes is not a name. The entry", + "was always described here as the stronger locator -- now it is the only", + "stable one.", + "", + "Delete an entry here the day the RE agent decodes a name field." + ], + "archives": { + "dat/GP_TITLE.pak": { + "2": { + "name": "press_start", + "why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture." + }, + "3": { + "name": "press_start_jp", + "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." + }, + "4": { + "name": "title", + "why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture." + }, + "5": { + "name": "main_menu", + "why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)" + }, + "6": { + "name": "extras", + "why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture." + }, + "7": { + "name": "title_jp", + "why": "HANDOFF Q2: the Japanese twin of build 4." + }, + "8": { + "name": "main_menu_jp", + "why": "HANDOFF Q2: the Japanese twin of build 5." + }, + "9": { + "name": "extras_jp", + "why": "HANDOFF Q2: the Japanese twin of build 6." + }, + "10": { + "name": "publisher_logo", + "why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)." + }, + "13": { + "name": "publisher_logo_r", + "why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs." + }, + "11": { + "name": "developer_logos", + "why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements." + }, + "14": { + "name": "developer_logos_r", + "why": "The region twin of entry 11, as 13 is to 10." + } + } + }, + "unnamed": { + "dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing." + }, + "also_export": { + "dat/GP_TITLE.pak": { + "10": { + "name": "publisher_logo", + "why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments." + }, + "11": { + "name": "developer_logos", + "why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn." + }, + "13": { + "name": "publisher_logo_r", + "why": "As entry 10, region twin." + }, + "14": { + "name": "developer_logos_r", + "why": "As entry 11, region twin." + } + } + } +} diff --git a/godot-import/authored/timing.json b/godot-import/authored/timing.json new file mode 100644 index 00000000..2ae18c5c --- /dev/null +++ b/godot-import/authored/timing.json @@ -0,0 +1,59 @@ +{ + "format": "sylpheed.timing/1", + "keyframe_units_per_second": 60, + "why": [ + "HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a", + "`t` is. The unit was MEASURED off the running game, not decoded: a declared", + "15-unit fade lands on round(255*k/15) for all seven of its samples with k", + "stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2", + "units per rendered frame -- and the idle title presents at 28.3-28.8 fps,", + "a 30 Hz game, giving 60 units per second. A second line agrees: the", + "transition quad is declared black for 12 units, and a capture measured the", + "pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.", + "", + "Expressed as units-per-second rather than seconds-per-unit so the value is", + "exact rather than a repeating decimal a reader has to recognise.", + "", + "DELETE THIS FILE when a field on the disc is found that states the unit.", + "Nothing here is on the disc." + ], + "kind": "measured", + "source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md", + "ramp": "linear", + "ramp_why": [ + "Also HANDOFF Q1, and part of the same measurement: the fade lands on the", + "linear value at every one of the seven sampled frames, so there is no ease." + ], + "exit_ramp_seconds": 0.4, + "exit_ramp_why": [ + "HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.", + "", + "Every element of a screen ends on exactly ONE untimed keyframe, so there is", + "exactly one unknown duration per screen -- the ramp INTO that final keyframe.", + "This is that duration. ~0.4 s, which is 24 units at 60 units/s.", + "", + "The alternative readings were tested and refuted. It is not a black quad laid", + "over a frozen screen: under that model a black rect scales every region by the", + "same 1-alpha, so the button-region / background-region brightness RATIO would", + "be constant through the fade. Measured on the RE agent's filmstrip it falls", + "6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen", + "itself plays out: pteff00.prm ramps to opaque black while the button labels,", + "ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.", + "", + "REACH, quoted from the RE agent rather than smoothed over: the filmstrip is", + "downsampled and the button region contains some background, so this pins the", + "DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the", + "number as approximate and the model as established." + ], + "exit_ramp_units": 24, + "dwell_seconds": null, + "dwell_why": [ + "NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos", + "at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would", + "be inventing a number nobody measured, so the sequencer holds for zero extra", + "time and the pacing you see is the disc's own.", + "", + "If a capture ever times the real boot, this is where that number goes." + ] +} diff --git a/godot-import/crates/sylpheed-export/Cargo.toml b/godot-import/crates/sylpheed-export/Cargo.toml new file mode 100644 index 00000000..6b08a7a8 --- /dev/null +++ b/godot-import/crates/sylpheed-export/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "sylpheed-export" +description = "Convert a Project Sylpheed disc into the open asset tree the Godot port reads" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +# The decoders, PINNED BY REVISION. Not vendored and not reimplemented: they are +# disc-wide verified in their own repository, and floating the pin would let a +# decoder change land mid-milestone -- exactly the confusion this prevents. +# +# `sylpheed_formats::media` in particular owns the cases where one playable thing +# is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the +# continuous cutscene-voice stream). Do not re-derive those here. +# +# Pin moved f817dd5 -> 7eeae30 on 2026-08-29. WHAT I WANTED FROM IT: `UiBuild` +# gained a public `records` map (record name -> the nested `.rat` leaf's byte +# range). Without it a consumer could not locate a leaf at all: `parse_build` +# sorted T8aD children into `sprites` and `.rat` children into a PRIVATE map, so +# the focus ring -- which lives inside `ptbtn0Nf.rat`, a record the parent +# bundle declares no element for -- was unreachable through the public API. +# Also brings `Keyframe::rotation_deg`, which the game does render. +# +# PINNED BY TAG, not by sha, and MISSION §2 now requires it. The reachability +# risk this comment used to warn about is closed: a sha reachable only from an +# `auto/*` branch is orphaned when that branch is deleted or -- worse -- +# SQUASH-MERGED, because squash creates new commits, so `main` would look like +# it contained the work while this pin became unreachable. A tag is a permanent +# ref, it says what it is here in the file, and it fails loudly at FETCH rather +# than silently at build. `formats-pin-2026-08-29` is 7eeae30. +# +# Previous pin note, kept because the reasoning still holds: +# Pin moved 5414db3 -> f817dd5 on 2026-08-29. WHAT I WANTED FROM IT: `56cc7ac`, +# "a RATC child's name is stated, not inferred". A child was named by scanning +# backwards for the last printable run before its magic; for `pteff05.t32` the +# three trailing payload bytes are `38 41 58` = `8AX` and beat the real name, so +# the FULL-RESOLUTION BACKGROUND OF ALL FIVE MENU SCREENS registered under a +# name no element declares and resolved to no sprite. This port exported those +# screens without their background and said so in every render as "pteff05 (no +# sprite in the export)" -- which docs/DECISIONS.md then wrote up as correct. +# It was not. f817dd5 is the last commit touching `crates/` on that branch. +# +# Previous pin note, kept because the reasoning still holds: +# Pin moved 8b6dbcf -> 5414db3 on 2026-08-28. WHAT I WANTED FROM IT: the fix to +# `ui_layout::rest()`. At 8b6dbcf a trailing run of identical keyframes was +# always treated as the exit, so an element with no exit animation rested at its +# invisible pre-roll -- `ptframe1`/`ptframe2`, the main menu's circuit bracket, +# which a capture of the running game plainly shows. 5414db3 is the revision at +# which that fix carries its disc-wide check (30 of 13 991 elements move, 4 +# become visible, 0 become invisible), not merely the one where it was written. +sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", tag = "formats-pin-2026-08-29" } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/godot-import/crates/sylpheed-export/src/check.rs b/godot-import/crates/sylpheed-export/src/check.rs new file mode 100644 index 00000000..7a7e9e24 --- /dev/null +++ b/godot-import/crates/sylpheed-export/src/check.rs @@ -0,0 +1,277 @@ +//! `sylpheed-export check` — validate an export tree against `docs/FORMAT.md`. +//! +//! This is the executable form of FORMAT.md, and the reason it exists is that +//! "the export is correct" is otherwise an assertion. It reads `export/` the way +//! the Godot project will — as a stranger, with no access to the disc, the +//! decoders or this exporter's internals — and fails on anything a consumer +//! could not act on: +//! +//! * a document whose `format` is not the version this build writes; +//! * a required field missing, or a colour that is not `0x` + 8 hex digits; +//! * a `paint_order` that is not a permutation of the element indices; +//! * a `buttons` entry naming an element that is not a button, or out of +//! resting-Y order; +//! * a sprite path that does not exist, or a PNG that does not decode; +//! * a name presented as recovered when it was authored. +//! +//! It deliberately does **not** check that the export matches the disc. That is +//! what `sylpheed-cli screen render` is for. + +use anyhow::{bail, Result}; +use serde_json::Value; +use std::path::Path; + +const SCREEN_FORMAT: &str = "sylpheed.screen/3"; +const MANIFEST_FORMAT: &str = "sylpheed.manifest/1"; + +struct Ctx { + file: String, + errors: Vec, +} + +impl Ctx { + fn err(&mut self, msg: impl Into) { + self.errors.push(format!("{}: {}", self.file, msg.into())); + } + fn require<'a>(&mut self, v: &'a Value, key: &str) -> Option<&'a Value> { + match v.get(key) { + Some(Value::Null) | None => { + self.err(format!("missing required field `{key}`")); + None + } + Some(x) => Some(x), + } + } +} + +/// A colour is exported as `0x` + 8 hex digits, with its byte order in the key +/// name. Anything else means a consumer has to guess, which is the whole thing +/// the format exists to prevent. +fn is_hex32(v: Option<&Value>) -> bool { + v.and_then(Value::as_str) + .is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())) +} + +fn check_pose(c: &mut Ctx, where_: &str, p: &Value) { + for (key, want_len) in [("pos", 2usize), ("scale", 2)] { + match p.get(key).and_then(Value::as_array) { + Some(a) if a.len() == want_len && a.iter().all(Value::is_i64) => {} + _ => c.err(format!("{where_}: `{key}` must be {want_len} integers")), + } + } + for key in ["tint_rgba", "fade_argb"] { + if !is_hex32(p.get(key)) { + c.err(format!("{where_}: `{key}` must be 0x + 8 hex digits")); + } + } +} + +fn check_screen(root: &Path, rel: &str, errors: &mut Vec) -> Result<()> { + let raw = std::fs::read_to_string(root.join(rel))?; + let v: Value = serde_json::from_str(&raw)?; + let mut c = Ctx { + file: rel.to_string(), + errors: Vec::new(), + }; + + if v.get("format").and_then(Value::as_str) != Some(SCREEN_FORMAT) { + c.err(format!( + "format is {:?}, expected {SCREEN_FORMAT:?}", + v.get("format") + )); + } + for key in ["exporter", "formats_rev", "name", "name_source"] { + c.require(&v, key); + } + // Rule 2 of the format: a modder must be able to tell a recovered name from + // an invented one, so the provenance is mandatory and closed. + match v.get("name_source").and_then(Value::as_str) { + Some("authored") => { + if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) { + c.err("name_source is `authored` but there is no `name_why`"); + } + } + Some("index") => {} + other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")), + } + if let Some(s) = v.get("source") { + for key in ["archive", "entry", "build"] { + if s.get(key).is_none() { + c.err(format!("source is missing `{key}`")); + } + } + } else { + c.err("missing required field `source`"); + } + match v.get("design").and_then(Value::as_array) { + Some(d) if d.len() == 2 && d.iter().all(Value::is_u64) => {} + _ => c.err("`design` must be two positive integers"), + } + + let Some(elements) = v.get("elements").and_then(Value::as_array) else { + c.err("missing required field `elements`"); + errors.append(&mut c.errors); + return Ok(()); + }; + + let mut indices = Vec::new(); + let mut buttons_by_y: Vec<(i64, String)> = Vec::new(); + for (i, el) in elements.iter().enumerate() { + let id = el.get("id").and_then(Value::as_str).unwrap_or("").to_string(); + let at = format!("element {i} ({id})"); + for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] { + if el.get(key).is_none() { + c.err(format!("{at}: missing `{key}`")); + } + } + let Some(idx) = el.get("index").and_then(Value::as_u64) else { + c.err(format!("{at}: `index` is not an integer")); + continue; + }; + if idx as usize != i { + c.err(format!("{at}: `index` {idx} does not match its position {i}")); + } + indices.push(idx as usize); + + let role = el.get("role").and_then(Value::as_str).unwrap_or(""); + if !matches!(role, "button" | "decoration" | "primitive" | "unknown") { + c.err(format!("{at}: role {role:?} is not one FORMAT.md defines")); + } + // A role of `unknown` must still carry the raw kind, or the information + // is simply lost. + if role == "unknown" && el.get("kind_raw").is_none() { + c.err(format!("{at}: role `unknown` without `kind_raw`")); + } + // A primitive has no texture, so its quad size has to come from the file. + if role == "primitive" && el.get("size").is_none() { + c.err(format!("{at}: primitive without a `size`")); + } + + match el.get("layer_source").and_then(Value::as_str) { + Some("sprite") | Some("implied") => { + if !is_hex32(el.get("layer")) { + c.err(format!("{at}: layer_source claims a key but `layer` is not one")); + } + } + Some("none") => { + if el.get("layer").is_some() { + c.err(format!("{at}: layer_source `none` but a `layer` is present")); + } + } + other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")), + } + + for key in ["sprite", "focus_sprite"] { + if let Some(p) = el.get(key).and_then(Value::as_str) { + let path = root.join(p); + if !path.exists() { + c.err(format!("{at}: `{key}` points at {p}, which does not exist")); + } else if let Err(e) = image::open(&path) { + c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}")); + } + } + } + + if let Some(r) = el.get("rest") { + check_pose(&mut c, &at, r); + if role == "button" { + if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) { + buttons_by_y.push((y, id.clone())); + } + } + } + if let Some(kfs) = el.get("keyframes").and_then(Value::as_array) { + for (k, kf) in kfs.iter().enumerate() { + check_pose(&mut c, &format!("{at} keyframe {k}"), kf); + } + // The last keyframe of a group carries no time slot on the disc, and + // an invented one is exactly the kind of value this format refuses. + if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) { + c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there")); + } + } + } + + // Paint order must be a permutation of the element indices, or the runtime + // either drops an element or draws one twice. + match v.get("paint_order").and_then(Value::as_array) { + Some(po) => { + let mut got: Vec = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect(); + if got.len() != po.len() { + c.err("`paint_order` holds a non-integer"); + } + let mut want = indices.clone(); + got.sort_unstable(); + want.sort_unstable(); + if got != want { + c.err("`paint_order` is not a permutation of the element indices"); + } + } + None => c.err("missing required field `paint_order`"), + } + + // `buttons` is navigation order and is defined as resting Y, ascending. If + // it is not sorted, it is not the thing FORMAT.md says it is. + match v.get("buttons").and_then(Value::as_array) { + Some(b) => { + let listed: Vec<&str> = b.iter().filter_map(Value::as_str).collect(); + buttons_by_y.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + let want: Vec<&str> = buttons_by_y.iter().map(|(_, n)| n.as_str()).collect(); + if listed != want { + c.err(format!( + "`buttons` is {listed:?} but resting-Y order is {want:?}" + )); + } + } + None => c.err("missing required field `buttons`"), + } + + if v.get("unresolved").and_then(Value::as_array).is_none() { + c.err("missing required field `unresolved` (an empty list is a claim; absence is a gap)"); + } + + errors.append(&mut c.errors); + Ok(()) +} + +/// Validate a whole export tree. Returns the number of screens checked. +pub fn run(root: &Path) -> Result { + let manifest_path = root.join("manifest.json"); + if !manifest_path.exists() { + bail!("{} has no manifest.json — is that an export tree?", root.display()); + } + let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?; + let mut errors = Vec::new(); + if m.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT) { + errors.push(format!("manifest.json: format is not {MANIFEST_FORMAT:?}")); + } + for key in ["exporter", "formats_rev", "screens", "warnings"] { + if m.get(key).is_none() { + errors.push(format!("manifest.json: missing `{key}`")); + } + } + let screens = m + .get("screens") + .and_then(Value::as_array) + .map(|s| s.to_vec()) + .unwrap_or_default(); + for s in &screens { + let Some(file) = s.get("file").and_then(Value::as_str) else { + errors.push("manifest.json: a screen entry has no `file`".into()); + continue; + }; + if !root.join(file).exists() { + errors.push(format!("manifest.json: lists {file}, which does not exist")); + continue; + } + check_screen(root, file, &mut errors)?; + } + + if !errors.is_empty() { + for e in &errors { + eprintln!(" ✗ {e}"); + } + bail!("{} problem(s) in {}", errors.len(), root.display()); + } + Ok(screens.len()) +} diff --git a/godot-import/crates/sylpheed-export/src/main.rs b/godot-import/crates/sylpheed-export/src/main.rs new file mode 100644 index 00000000..9b128f08 --- /dev/null +++ b/godot-import/crates/sylpheed-export/src/main.rs @@ -0,0 +1,303 @@ +//! Convert a Project Sylpheed disc into the open asset tree the Godot port reads. +//! +//! The one rule this binary exists to enforce: **Godot never sees a disc format.** +//! Everything proprietary is decoded here and written out as JSON, PNG, Ogg +//! Vorbis and Ogg Theora, so the runtime — and anyone modding it — reads formats +//! a person can open. +//! +//! The output tree is **derived**: regenerated wholesale, never hand-edited. The +//! only thing this program takes from `authored/` is the screen-name map, and +//! every name it applies is stamped `name_source: "authored"` in the file it +//! lands in, so the export stays auditable against the disc. +//! +//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope. + +mod check; +mod video; +mod screen; + +use anyhow::{Context, Result}; +use clap::Parser; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use sylpheed_formats::{pak::PakArchive, ui_layout}; + +/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in +/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an +/// export auditable a month later. +const FORMATS_REV: &str = "8b6dbcf"; +const EXPORTER: &str = concat!("sylpheed-export ", env!("CARGO_PKG_VERSION")); + +#[derive(Parser)] +#[command(about, version)] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(clap::Subcommand)] +enum Cmd { + /// Convert the disc into `export/`. Rewrites the tree wholesale. + Export { + /// Extracted disc root (the directory holding `dat/` and `hidden/`). + #[arg(long, env = "SYLPHEED_DISC")] + disc: PathBuf, + /// Output tree. Rewritten wholesale — never hand-edit it. + #[arg(long, default_value = "export")] + out: PathBuf, + /// Authored decisions applied during export (currently the screen names). + #[arg(long, default_value = "authored")] + authored: PathBuf, + }, + /// Validate an export tree against `docs/FORMAT.md`, with no disc in hand. + /// + /// Reads the tree the way the Godot project will: as a stranger, with no + /// access to the disc, the decoders or this exporter's internals. + Check { + #[arg(long, default_value = "export")] + out: PathBuf, + }, +} + +#[derive(Serialize)] +struct ManifestScreen { + name: String, + file: String, + sprites: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + missing_sprites: Vec, +} + +#[derive(Serialize)] +struct ManifestVideo { + name: String, + file: String, + /// The exact command that produced this file. MISSION §6: a modder who + /// dislikes the quality re-runs one line rather than reverse-engineering it. + command: String, + why: &'static str, +} + +#[derive(Serialize)] +struct Manifest { + format: &'static str, + exporter: &'static str, + /// Which decoders produced this export. Pinned by revision, not floated. + formats_rev: &'static str, + disc: String, + screens: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + videos: Vec, + warnings: Vec, +} + +/// The authored `pak entry index → name` map, keyed by archive path. +/// +/// Keyed by **entry**, not by the enumeration ordinal. The file itself always +/// called the entry "the stronger locator"; it is now also the only stable one, +/// because widening the enumeration to reach the splash renumbers the ordinals. +type NameMap = std::collections::BTreeMap>; + +#[derive(serde::Deserialize)] +struct NameEntry { + name: String, + #[serde(default)] + why: Option, +} + +fn load_names(authored: &Path) -> Result { + let path = authored.join("screen_names.json"); + if !path.exists() { + return Ok(NameMap::new()); + } + #[derive(serde::Deserialize)] + struct File { + archives: NameMap, + #[serde(default)] + also_export: AlsoExport, + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + Ok(serde_json::from_str::(&raw) + .with_context(|| format!("parse {}", path.display()))? + .archives) +} + +/// Extra pak entries to export that `is_build` does not accept, keyed by +/// archive. AUTHORED, and each carries its own `why`. +type AlsoExport = + std::collections::BTreeMap>; + +fn load_also_export(authored: &Path) -> Result { + let path = authored.join("screen_names.json"); + if !path.exists() { + return Ok(AlsoExport::new()); + } + #[derive(serde::Deserialize)] + struct File { + #[serde(default)] + also_export: AlsoExport, + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + Ok(serde_json::from_str::(&raw) + .with_context(|| format!("parse {}", path.display()))? + .also_export) +} + +/// Every RATC entry of a UI pak this exporter treats as a screen. +/// +/// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an +/// authored allow-list of entry indices**. +/// +/// The allow-list exists because the splash screens declare their sprites +/// directly and have no `.rat` child, so `is_build` cannot see them, and **there +/// is no content rule that would**. The RE agent looked: design size fails +/// (every extra composable bundle sampled is 1280x720, the same as every +/// screen) and element count fails (fragments run 2..15 elements in +/// `GP_OPTIONS`/`GP_SAVE_LOAD` while the splash halves are 3 and 7 — the ranges +/// overlap). So the splashes are located **by entry index**, which is a locator +/// and not a claim, and each one says so in its own `why`. +/// +/// This is safe here rather than in general: in `GP_TITLE` the widened set adds +/// exactly four bundles and all four are real screens, with zero fragments. In +/// another archive it would not be, which is why this is an allow-list and not +/// a widened predicate. +fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap>) + -> Vec<(usize, Vec)> +{ + let mut out = Vec::new(); + for (i, e) in ar.entries().iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + let allowed = also.is_some_and(|m| m.contains_key(&i.to_string())); + if ui_layout::is_build(&bytes) || allowed { + out.push((i, bytes)); + } + } + out +} + +fn main() -> Result<()> { + match Args::parse().cmd { + Cmd::Export { + disc, + out, + authored, + } => run_export(&disc, &out, &authored), + Cmd::Check { out } => { + let n = check::run(&out)?; + println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display()); + Ok(()) + } + } +} + +fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { + let names = load_names(authored_dir)?; + + // Derived output is regenerated wholesale: clear it, so a screen that stops + // being exported stops existing rather than lingering as a stale file that + // still validates. + if out.exists() { + std::fs::remove_dir_all(&out).context("clear the output tree")?; + } + std::fs::create_dir_all(&out)?; + + let archive = "dat/GP_TITLE.pak"; + let pak = disc.join(archive); + let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?; + let also = load_also_export(authored_dir)?; + let archive_also = also.get(archive); + let builds = screen_builds(&ar, archive_also); + println!("{archive}: {} screen build(s)", builds.len()); + + let archive_names = names.get(archive); + let mut screens = Vec::new(); + for (build_idx, (entry, bytes)) in builds.iter().enumerate() { + // Keyed by ENTRY, not by the ordinal: widening the enumeration to reach + // the splash renumbers ordinals, and a name that moves when the rule + // changes is not a name. + let key = entry.to_string(); + let named = archive_names + .and_then(|m| m.get(&key)) + .or_else(|| archive_also.and_then(|m| m.get(&key))); + let (name, name_source, why) = match named { + Some(e) => (e.name.clone(), "authored", e.why.clone()), + // Nobody has identified this build. Emit a stable synthetic id and + // say in the file that the name is not a recovered one. + None => (format!("build_{entry:02}"), "index", None), + }; + let ex = screen::export_build( + &out, + archive, + *entry, + build_idx, + bytes, + &name, + name_source, + why, + "title", + EXPORTER, + FORMATS_REV, + ) + .with_context(|| format!("export build {build_idx} of {archive}"))?; + println!( + " [{build_idx}] entry {entry:<3} -> {} ({} sprites{})", + ex.json_path, + ex.sprites, + if ex.missing.is_empty() { + String::new() + } else { + format!(", {} missing", ex.missing.len()) + } + ); + screens.push(ManifestScreen { + name: ex.name, + file: ex.json_path, + sprites: ex.sprites, + missing_sprites: ex.missing, + }); + } + + // MISSION §6: the boot intro and the one new-game intro only. + let mut videos = Vec::new(); + for m in video::MOVIES { + match video::transcode(disc, out, m)? { + Some(t) => { + println!(" video {} -> {}", m.src, t.file); + videos.push(ManifestVideo { + name: t.name, + file: t.file, + command: t.command, + why: t.why, + }); + } + None => println!(" video {} not on this disc -- skipped", m.src), + } + } + + let manifest = Manifest { + format: "sylpheed.manifest/1", + exporter: EXPORTER, + formats_rev: FORMATS_REV, + disc: disc.display().to_string(), + screens, + videos, + warnings: vec![ + "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." + .into(), + "The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \ + layout child, so `is_build` cannot see them and no content rule can: element \ + count and design size both overlap with two-element fragments in other archives. \ + They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \ + which is a locator and not a claim -- see each one's name_why." + .into(), + ], + }; + std::fs::write( + out.join("manifest.json"), + format!("{}\n", serde_json::to_string_pretty(&manifest)?), + )?; + println!("wrote {}/manifest.json", out.display()); + Ok(()) +} diff --git a/godot-import/crates/sylpheed-export/src/screen.rs b/godot-import/crates/sylpheed-export/src/screen.rs new file mode 100644 index 00000000..92735855 --- /dev/null +++ b/godot-import/crates/sylpheed-export/src/screen.rs @@ -0,0 +1,475 @@ +//! One UI build → one `sylpheed.screen/3` JSON document plus its sprite PNGs. +//! +//! Everything here is **derived**: it is what the bundle says, restated in a +//! format Godot can read. The two places a value is not read off the disc are +//! marked in the output itself — `name_source` when a screen's name came from +//! `authored/`, and `layer_source: "implied"` when the paint-order key came from +//! the decoders' measured table rather than from a `T8aD` header. A consumer can +//! tell the difference without reading this file. + +use anyhow::{Context, Result}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::Path; +use sylpheed_formats::{t8ad, ui_layout}; + +/// `elements[].role`, from the decoded element kind. +/// +/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general +/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and +/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the +/// mapping is decoded; anything else exports as `unknown` with its raw kind. +fn role_of(kind: u32, has_sprite: bool) -> &'static str { + match kind { + 0x3002 => "button", + 0x10 if !has_sprite => "primitive", + 0x0 => "decoration", + _ => "unknown", + } +} + +#[derive(Serialize)] +pub struct Source { + /// Path of the archive within the disc root. + pub archive: String, + /// Pak **entry index** — the stable locator, not the display ordinal. + pub entry: usize, + /// Index into this pak's list of screen builds (what `screen --build` takes). + pub build: usize, +} + +/// A placement keyframe, carrying the on-disc time verbatim. +/// +/// `t` is in the disc's own units and is deliberately **not** converted here: +/// the seconds conversion is measured off the running game, not read from the +/// file, so it lives in `authored/timing.json` and is applied in exactly one +/// place. See HANDOFF Q1. +#[derive(Serialize)] +pub struct Keyframe { + /// On-disc time, absent on the final keyframe of a group — which carries no + /// time slot at all. Absent, never invented. + #[serde(skip_serializing_if = "Option::is_none")] + pub t: Option, + /// Top-left of the element at 1:1. Signed: elements animate in from off-screen. + pub pos: [i32; 2], + /// Percent, per axis. Scale grows the element **about its pivot**, not about + /// `pos` — at 100 % the two are identical, which is why it went unnoticed. + pub scale: [u32; 2], + /// Modulate colour, **RGBA** byte order. `0xffffffff` on essentially every + /// keyframe on the disc. + pub tint_rgba: String, + /// The second modulate colour, **ARGB** byte order — the high byte is the + /// alpha that ramps during a fade. Multiplies with `tint_rgba`. + pub fade_argb: String, + /// Screen-plane rotation in **degrees**, clockwise-positive, decoded from + /// the keyframe's `+12`. **The game renders this** — confirmed twice, on + /// different screens and different elements: the title's `ptloop` sweeps + /// declare +30 / −45 and a GPU capture submits their quads at +30.26 / + /// −45.28, and the focus ring ramps 0 → 360 with everything else constant, + /// which a capture caught mid-spin. Rotation is about the **declared + /// pivot**, also measured. + pub rotation_deg: i32, +} + +#[derive(Serialize)] +pub struct Rest { + pub pos: [i32; 2], + pub scale: [u32; 2], + pub tint_rgba: String, + pub fade_argb: String, + /// Screen-plane rotation in **degrees**, clockwise-positive, decoded from + /// the keyframe's `+12`. **The game renders this** — confirmed twice, on + /// different screens and different elements: the title's `ptloop` sweeps + /// declare +30 / −45 and a GPU capture submits their quads at +30.26 / + /// −45.28, and the focus ring ramps 0 → 360 with everything else constant, + /// which a capture caught mid-spin. Rotation is about the **declared + /// pivot**, also measured. + pub rotation_deg: i32, + + #[serde(skip_serializing_if = "Option::is_none")] + pub t: Option, +} + +/// One element of a button's focused-state record. +/// +/// A focus record is **not** a single sprite. `ptbtn0Nf.rat` declares the +/// spinning ring `ptbtneff01.t32` *and* the bright label, and the parent bundle +/// declares **no element for the record at all** — so the leaf is the only +/// source of placement for both, and the parent has nothing to inherit from. +/// That is why these carry their own `pos`, and why they are not simply a +/// second sprite path on the base element. +#[derive(Serialize)] +pub struct FocusElement { + pub id: String, + pub declared: String, + pub sprite: Option, + pub pivot: [u32; 2], + pub rest: Rest, + pub keyframes: Vec, +} + +#[derive(Serialize)] +pub struct Focus { + /// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`. + pub record: String, + /// Back-to-front, in the leaf's own declaration order. + pub elements: Vec, +} + +#[derive(Serialize)] +pub struct Element { + /// Declaration index — the key the placement region and `paint_order` use. + pub index: usize, + /// The declared name with its extension stripped; stable within a screen. + pub id: String, + /// The name exactly as the declaration table spells it. + pub declared: String, + pub role: &'static str, + pub kind_raw: String, + /// Sprite PNG, relative to `export/`. Absent for an untextured primitive. + #[serde(skip_serializing_if = "Option::is_none")] + pub sprite: Option, + /// The highlighted-state sprite: this element's sprite with an `f` before + /// the extension, when the bundle carries one — `ptbtn01.t32` ↔ + /// `ptbtn01f.t32`. 🟡 **A naming convention, not a decoded field.** It holds + /// for all 54 real pairs on the disc (HANDOFF), and it is the only link + /// between a button and its highlight that has survived checking. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus_sprite: Option, + /// The focused state, read from the element's `.rat` leaf. Supersedes + /// `focus_sprite`, which is kept because it is the 54-pair naming + /// convention and a consumer may still want the bare highlight texture. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus: Option, + /// The raw `opt ` link inside this element's `.rat` record. + /// + /// ⚠️ **This is not a focus link.** It was read as one, and that was + /// measured and refuted (HANDOFF, `ui-focus-and-effect-elements.md`) — on the + /// main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two + /// decorations and into a button. It is carried through unresolved and + /// unnamed so that whoever decodes it has it, and so that nothing downstream + /// mistakes it for navigation. + #[serde(skip_serializing_if = "Option::is_none")] + pub opt_link: Option, + pub pivot: [u32; 2], + /// Untextured primitives have no texture to take a size from; the quad is + /// `pivot × 2`, which is 1280×720 for 361 of the disc's 369 primitives. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option<[u32; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + /// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`. + /// `"implied"` = **measured off the running game**, for elements that carry + /// no header. `"none"` = neither; sorts last. + pub layer_source: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, + /// This element is another element's focused state, not a screen element. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub focused: bool, + /// A `loopN` sprite animation rather than a placed element. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub animated: bool, + /// The resting pose: the **hold**, the longest run of consecutive keyframes + /// with an identical pose that does not end the group. Neither the first nor + /// the last keyframe. + #[serde(skip_serializing_if = "Option::is_none")] + pub rest: Option, + pub keyframes: Vec, +} + +#[derive(Serialize)] +pub struct Screen { + pub format: &'static str, + pub exporter: String, + /// Revision of `sylpheed-formats` whose decoders produced this file. + pub formats_rev: &'static str, + pub source: Source, + pub name: String, + /// `"authored"` when the name came from `authored/screen_names.json`, + /// `"index"` when nobody has named this build yet. + pub name_source: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub name_why: Option, + pub design: [u32; 2], + pub elements: Vec, + /// Back-to-front paint order as declaration indices, from the decoded `u16` + /// layer key at `+0x0A` of each sprite header, stable-sorted so equal keys + /// keep declaration order. See `unresolved: paint_order_ties`. + pub paint_order: Vec, + /// Navigation order: `button`-role elements sorted by resting Y. + /// **Geometric, not a decoded neighbour graph** — right for a vertical menu + /// and not to be trusted for anything else. + pub buttons: Vec, + /// What this file does not answer. A consumer needing one of these must get + /// it from `authored/`. + pub unresolved: Vec<&'static str>, +} + +fn hex32(v: u32) -> String { + format!("0x{v:08x}") +} + +/// Strip the extension the declaration table spells, giving a stable id. +pub fn id_of(declared: &str) -> String { + declared + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(declared) + .to_string() +} + +/// What one screen's export produced, for the manifest. +pub struct Exported { + pub name: String, + pub json_path: String, + pub sprites: usize, + /// Sprites an element named that did not resolve or decode. + pub missing: Vec, +} + +/// Convert one build to JSON on disk, writing its sprite PNGs beside it. +/// +/// `sprite_dir` is per-screen: a sprite name is unique within a bundle but not +/// across builds, and two screens' `ptbase.t32` are different pictures. +#[allow(clippy::too_many_arguments)] +pub fn export_build( + out: &Path, + archive: &str, + entry: usize, + build_idx: usize, + bundle: &[u8], + name: &str, + name_source: &'static str, + name_why: Option, + subdir: &str, + exporter: &str, + formats_rev: &'static str, +) -> Result { + let b = ui_layout::parse_build(bundle).context("build did not parse")?; + + // Every sprite an element actually references, decoded once and written as a + // PNG under this screen's own directory. + let sprite_rel = |sprite: &str| format!("sprites/{subdir}/{name}/{}.png", id_of(sprite)); + let sprite_dir = out.join("sprites").join(subdir).join(name); + std::fs::create_dir_all(&sprite_dir)?; + let mut written: BTreeMap = BTreeMap::new(); + let mut missing = Vec::new(); + // Decode one T8aD and write it, from whichever bundle slice and sprite map + // owns it. A leaf's sprites may be indexed in the leaf's own map (offsets + // relative to the leaf slice) or in the parent's; the caller says which. + fn write_from( + dir: &Path, + written: &mut BTreeMap, + sprite: &str, + bytes: &[u8], + map: &std::collections::HashMap, + ) -> Result { + if written.contains_key(sprite) { + return Ok(true); + } + let Some(&(off, size)) = map.get(sprite) else { + return Ok(false); + }; + let Some(img) = t8ad::parse(&bytes[off..off + size]) else { + return Ok(false); + }; + let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba) + .context("T8aD dimensions disagree with its pixel count")?; + buf.save(dir.join(format!("{}.png", id_of(sprite))))?; + written.insert(sprite.to_string(), ()); + Ok(true) + } + + + /// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`. + fn highlight_name(sprite: &str) -> Option { + let (stem, ext) = sprite.rsplit_once('.')?; + Some(format!("{stem}f.{ext}")) + } + + let mut elements = Vec::new(); + for el in &b.elements { + let mut sprite_out = None; + if let Some(s) = &el.sprite { + if write_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? { + sprite_out = Some(sprite_rel(s)); + } else { + missing.push(s.clone()); + } + } + // The highlight pairs by NAME on the sprite, not through the `opt ` + // link: `opt ` is refuted as a focus link and points somewhere else + // entirely on half these elements. + let mut focus_sprite = None; + if let Some(h) = el.sprite.as_deref().and_then(highlight_name) { + if write_from(&sprite_dir, &mut written, &h, bundle, &b.sprites)? { + focus_sprite = Some(sprite_rel(&h)); + } + } + + // The focused state is a RECORD, not a sprite. `ptbtn0Nf.rat` declares + // the spinning ring AND the bright label, and the parent bundle + // declares no element for it at all -- so the leaf is the only source + // of placement for both, and there is nothing for it to inherit. + // + // Contrast with a BASE record, where the leaf duplicates the parent's + // placement and the two can differ by a unit (ptbtn04: parent y=401, + // leaf y=402). There the parent wins. Here there is no parent. + let mut focus = None; + if let Some(rec) = highlight_name(&el.name) { + if let Some(&(off, size)) = b.records.get(&rec) { + if let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) { + let mut fes = Vec::new(); + for fe in &leaf.elements { + // A leaf element's own NAME is its sprite -- `el.sprite` + // is only populated for a T8aD child of the same bundle, + // and these are indexed either in the leaf's map (offsets + // into the leaf slice) or in the parent's. + let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name); + let mut fsprite = None; + if write_from(&sprite_dir, &mut written, sp, + &bundle[off..off + size], &leaf.sprites)? + || write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)? + { + fsprite = Some(sprite_rel(sp)); + } else if sp.ends_with(".t32") { + missing.push(sp.to_string()); + } + let Some(r) = fe.rest() else { continue }; + fes.push(FocusElement { + id: id_of(&fe.name), + declared: fe.name.clone(), + sprite: fsprite, + pivot: [fe.pivot_x, fe.pivot_y], + rest: Rest { + pos: [r.x, r.y], + scale: [r.scale_x, r.scale_y], + tint_rgba: hex32(r.tint), + fade_argb: hex32(r.fade), + rotation_deg: r.rotation_deg, + t: r.time, + }, + keyframes: fe + .keyframes + .iter() + .map(|k| Keyframe { + t: k.time, + pos: [k.x, k.y], + scale: [k.scale_x, k.scale_y], + tint_rgba: hex32(k.tint), + fade_argb: hex32(k.fade), + rotation_deg: k.rotation_deg, + }) + .collect(), + }); + } + if !fes.is_empty() { + focus = Some(Focus { record: rec, elements: fes }); + } + } + } + } + + let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) { + Some(k) => (Some(hex32(k)), "sprite"), + None => match ui_layout::implied_layer_key(&el.name) { + Some(k) => (Some(hex32(k)), "implied"), + None => (None, "none"), + }, + }; + + let kf = |k: &ui_layout::Keyframe| Keyframe { + t: k.time, + pos: [k.x, k.y], + scale: [k.scale_x, k.scale_y], + tint_rgba: hex32(k.tint), + fade_argb: hex32(k.fade), + rotation_deg: k.rotation_deg, + }; + let role = role_of(el.kind, el.sprite.is_some()); + elements.push(Element { + index: el.index, + id: id_of(&el.name), + declared: el.name.clone(), + role, + kind_raw: format!("{:#x}", el.kind), + sprite: sprite_out, + focus_sprite, + focus, + opt_link: el.focus_link.clone(), + pivot: [el.pivot_x, el.pivot_y], + size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]), + parent: el.parent, + layer_source, + layer, + focused: el.focused, + animated: el.animated, + rest: el.rest().map(|k| Rest { + pos: [k.x, k.y], + scale: [k.scale_x, k.scale_y], + tint_rgba: hex32(k.tint), + fade_argb: hex32(k.fade), + rotation_deg: k.rotation_deg, + t: k.time, + }), + keyframes: el.keyframes.iter().map(kf).collect(), + }); + } + + // Navigation order is geometric: buttons top-to-bottom by resting Y. A + // focused-state record is not itself a menu item. + let mut buttons: Vec<(i32, String)> = b + .elements + .iter() + .filter(|e| e.kind == 0x3002 && !e.focused) + .filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name)))) + .collect(); + buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + + let screen = Screen { + format: "sylpheed.screen/3", + exporter: exporter.to_string(), + formats_rev, + source: Source { + archive: archive.to_string(), + entry, + build: build_idx, + }, + name: name.to_string(), + name_source, + name_why, + design: [b.design_w, b.design_h], + elements, + paint_order: ui_layout::derived_paint_order(&b, bundle), + buttons: buttons.into_iter().map(|(_, n)| n).collect(), + unresolved: vec![ + // The time unit is measured off the running game, not on the disc. + "keyframe_time_unit", + // Where two elements share a layer key the game's order is + // unexplained; eight candidates refuted. Costs one element's blend + // on one screen. + "paint_order_ties", + // The last keyframe of a group carries no time slot, so the + // fade-OUT length is not in the file. + "fade_out_duration", + ], + }; + + let dir = out.join("screens").join(subdir); + std::fs::create_dir_all(&dir)?; + let json_path = format!("screens/{subdir}/{name}.json"); + std::fs::write( + out.join(&json_path), + format!("{}\n", serde_json::to_string_pretty(&screen)?), + )?; + + missing.sort(); + missing.dedup(); + Ok(Exported { + name: name.to_string(), + json_path, + sprites: written.len(), + missing, + }) +} diff --git a/godot-import/crates/sylpheed-export/src/video.rs b/godot-import/crates/sylpheed-export/src/video.rs new file mode 100644 index 00000000..963eef81 --- /dev/null +++ b/godot-import/crates/sylpheed-export/src/video.rs @@ -0,0 +1,166 @@ +//! Movies: disc WMV → Ogg Theora, because Godot 4 plays Theora natively and +//! will never be taught to read WMV. +//! +//! The transcode command is **recorded in the manifest verbatim**. A modder who +//! dislikes the quality re-runs one line rather than reverse-engineering what +//! was done to their video, which is the whole reason this project converts the +//! disc instead of reading it at runtime. + +use anyhow::{bail, Context, Result}; +use std::path::Path; +use std::process::Command; + +/// A movie in scope for this port. +pub struct Movie { + /// Path under the disc root. + pub src: &'static str, + /// Output stem under `export/video/`. + pub stem: &'static str, + pub why: &'static str, +} + +/// MISSION §6: the boot intro and the one new-game intro. The disc holds 3.3 GB +/// of video and transcoding all of it is not this milestone. +pub const MOVIES: &[Movie] = &[ + Movie { + src: "dat/movie/ADV.wmv", + stem: "ADV", + why: "HANDOFF Q9: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the \ + attract movie are the SAME asset -- there is no separate boot slot.", + }, + Movie { + src: "dat/movie/S00A.wmv", + stem: "S00A", + why: "HANDOFF Q9: MS00A -> S00A.wmv is the new-game intro. P7.", + }, +]; + +/// The encode. +/// +/// `-q:v 8` was chosen by measurement, not taste: against the decoded source, +/// SSIM over a 10 s sample is 0.9863 at q6, **0.9896 at q8** and 0.9924 at q10, +/// and q8 is visually indistinguishable at 200 % zoom on the reel's hardest +/// case — fine serif text and soft gradients over near-black, which is where +/// Theora usually breaks first. MISSION §6 anticipated that 720p Theora might +/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed* +/// if so. It is not: **no runtime dependency is needed, and none is requested.** +/// +/// The stereo downmix, **stated explicitly rather than inherited**. +/// +/// The disc ships movies in two audio profiles: 28 files are 5.1 WMA Pro (every +/// cutscene, including both movies this port needs) and 69 are already stereo. +/// A bare `-ac 2` therefore does two different things and records neither — the +/// stereo files pass through, and the 5.1 files are folded by **ffmpeg's default +/// matrix**. How loudly centre-channel dialogue sits against the music is a +/// CONTENT decision, and leaving it to a default means it is made by accident +/// and can move under an ffmpeg upgrade. +/// +/// So the matrix is written out: **ITU-R BS.775, LFE dropped**, normalised by +/// `1/(1 + √½ + √½) = 0.4142` so the sum of coefficients cannot clip. +/// +/// This does not change the audio. Measured against the inherited default over a +/// 25 s stretch, the residual is **−91 dB** — roughly one LSB at 16-bit, i.e. +/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's +/// default *is* this matrix; the point is that the manifest now says so. +/// +/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is +/// why the normalisation is here rather than the textbook coefficients. +const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR"; + +/// How many audio channels the source declares. +fn channels(src: &Path) -> Result { + let out = Command::new("ffprobe") + .args([ + "-v", "error", "-select_streams", "a:0", + "-show_entries", "stream=channels", "-of", "csv=p=0", + ]) + .arg(src) + .output() + .context("run ffprobe -- is it on PATH?")?; + Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2)) +} + +fn args(src: &Path, out: &Path, channels: u32) -> Vec { + let mut v: Vec = [ + "-hide_banner", "-loglevel", "error", "-y", + "-i", &src.display().to_string(), + "-c:v", "libtheora", "-q:v", "8", + "-c:a", "libvorbis", "-q:a", "5", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + // Only 5.1 sources are folded. A source that is already stereo is passed + // through untouched rather than run through a matrix that would silently + // reference channels it does not have. + if channels == 6 { + v.push("-af".into()); + v.push(DOWNMIX_51.into()); + } + v.push("-ac".into()); + v.push("2".into()); + v.push(out.display().to_string()); + v +} + +pub struct Transcoded { + pub name: String, + pub file: String, + pub command: String, + pub why: &'static str, +} + +/// Transcode one movie, skipping the encode when the output already exists and +/// was produced by exactly this command against exactly this source. +/// +/// `export/` is still regenerated wholesale — this is a cache, not a hand-edit. +/// The sidecar records the command and the source size, so any change to either +/// re-encodes. Without it every re-export pays ~4 minutes to produce a +/// byte-identical file, and an exporter nobody re-runs is worse than a cache. +pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result> { + let src = disc.join(m.src); + if !src.exists() { + return Ok(None); + } + let dir = out.join("video"); + std::fs::create_dir_all(&dir)?; + let ogv = dir.join(format!("{}.ogv", m.stem)); + let stamp = dir.join(format!("{}.cmd", m.stem)); + + let ch = channels(&src)?; + let argv = args(&src, &ogv, ch); + let command = format!("ffmpeg {}", argv.join(" ")); + let size = std::fs::metadata(&src)?.len(); + let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n"); + + let fresh = ogv.exists() + && std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false); + if !fresh { + // Encode to a temp name and rename on success. A reader that catches + // this mid-write sees no file at all rather than a valid-looking one + // with a wrong duration -- ffprobe reported 33 s against a 137 s source + // during one such race, with no error, and it looked exactly like + // catastrophic truncation. The filesystem is shared with another agent, + // so this is a race and not an edge case. + let partial = dir.join(format!(".{}.partial.ogv", m.stem)); + let mut argv = argv.clone(); + let last = argv.len() - 1; + argv[last] = partial.display().to_string(); + let status = Command::new("ffmpeg") + .args(&argv) + .status() + .context("run ffmpeg -- is it on PATH?")?; + if !status.success() { + let _ = std::fs::remove_file(&partial); + bail!("ffmpeg failed on {}", m.src); + } + std::fs::rename(&partial, &ogv)?; + std::fs::write(&stamp, &want)?; + } + Ok(Some(Transcoded { + name: m.stem.to_string(), + file: format!("video/{}.ogv", m.stem), + command, + why: m.why, + })) +} diff --git a/godot-import/docker/Dockerfile b/godot-import/docker/Dockerfile new file mode 100644 index 00000000..86417f6f --- /dev/null +++ b/godot-import/docker/Dockerfile @@ -0,0 +1,100 @@ +# Autonomous port agent for the Sylpheed Godot menu shell. +# +# DELIBERATELY SMALL. The reverse-engineering container next door is 4.36 GB +# because it builds Xenia Canary and drives it under a software Vulkan stack. +# This agent has no emulator, no oracle and no C++ build: it converts already- +# decoded assets and drives Godot. Keeping it light is what lets both containers +# run on one 12-core / 15 GB box without the memory pressure that has crashed it. +# +# What it needs, and nothing else: Rust (the exporter), Godot 4 (the runtime), +# ffmpeg (the transcode), and a headless display to screenshot Godot for +# comparison against the reference renderer. + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + TZ=Etc/UTC + +RUN apt-get update && apt-get install -y --no-install-recommends \ + # toolchain for the exporter and for building sylpheed-cli from /reborn + build-essential pkg-config git curl ca-certificates \ + libssl-dev \ + # Godot 4 needs these even headless; the windowed run needs the X libs + libx11-6 libxcursor1 libxinerama1 libxrandr2 libxi6 libgl1 \ + libasound2t64 libpulse0 libfontconfig1 \ + # the transcode target (libtheora + libvorbis ship in Ubuntu's ffmpeg) + ffmpeg \ + # headless display + the screenshot path, for diffing Godot's output + # against `sylpheed-cli screen render` + xvfb x11-utils openbox imagemagick \ + # everyday + python3 jq ripgrep unzip file less nano tini sudo procps \ + # expect drives Claude Code's one-time interactive gates + expect \ + && rm -rf /var/lib/apt/lists/* + +# ── Godot 4 ────────────────────────────────────────────────────────────────── +# Pinned. An engine version bump changes rendering, and this project compares +# screenshots against a reference renderer — so an upgrade must be a deliberate, +# stated act rather than a silent drift. +ARG GODOT_VERSION=4.7.2 +RUN cd /tmp \ + && curl -fsSLO "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \ + && unzip -q "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \ + && mv "Godot_v${GODOT_VERSION}-stable_linux.x86_64" /usr/local/bin/godot \ + && chmod +x /usr/local/bin/godot \ + && printf '#!/bin/sh\nexec /usr/local/bin/godot --headless "$@"\n' > /usr/local/bin/godot-headless \ + && chmod +x /usr/local/bin/godot-headless \ + && rm -f "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" + +# ── Node + Claude Code ─────────────────────────────────────────────────────── +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g @anthropic-ai/claude-code \ + && npm cache clean --force \ + && rm -rf /var/lib/apt/lists/* + +# ── The agent user ─────────────────────────────────────────────────────────── +# NOT root: Claude Code refuses --dangerously-skip-permissions with root +# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the +# common case — matching a host user who is also 1000 — collides with it. +ARG AGENT_UID=1000 +ARG AGENT_GID=1000 +RUN if getent passwd "${AGENT_UID}" >/dev/null; then \ + userdel -r "$(getent passwd "${AGENT_UID}" | cut -d: -f1)" 2>/dev/null || true; \ + fi; \ + if getent group "${AGENT_GID}" >/dev/null; then \ + groupdel "$(getent group "${AGENT_GID}" | cut -d: -f1)" 2>/dev/null || true; \ + fi; \ + groupadd -g "${AGENT_GID}" agent \ + && useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/port agent \ + && mkdir -p /sylph-home/port /work /reborn \ + && chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \ + && echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent + +COPY bin/ /usr/local/bin/ +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/* /usr/local/bin/entrypoint.sh + +USER agent +WORKDIR /work + +# CARGO_TARGET_DIR points OUTSIDE the bind-mounted repo so the host and the +# container do not invalidate each other's incremental state on every switch. +ENV RUSTUP_HOME=/sylph-home/port/.rustup \ + CARGO_HOME=/sylph-home/port/.cargo \ + CARGO_TARGET_DIR=/sylph-home/port/target-container \ + PATH=/sylph-home/port/.cargo/bin:/usr/local/bin:/usr/bin:/bin +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal --component clippy --component rustfmt + +RUN mkdir -p /sylph-home/port/target-container /sylph-home/port/.claude + +ENV HOME=/sylph-home/port \ + DISPLAY=:97 \ + SCREEN_GEOMETRY=1280x720x24 \ + PROJECT_DIR=/work + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] +CMD ["bash"] diff --git a/godot-import/docker/bin/build-export b/godot-import/docker/bin/build-export new file mode 100755 index 00000000..6102bde3 --- /dev/null +++ b/godot-import/docker/bin/build-export @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Build and run the exporter against the disc. +# +# build-export build only +# build-export --run build, export to ./export, then validate it +# +# The validate step is not optional politeness: `export` writes a tree and +# `check` is the only thing that says the tree is readable by anything other +# than the program that wrote it. A build that exports and does not check has +# not shown anything. +# +# Jobs are capped: this box runs two agent containers and a desktop, and an +# unbounded parallel build has crashed it. Do not raise this to "use all cores". +set -euo pipefail +cd "${PROJECT_DIR:-/work}" +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}" +cargo build --release -p sylpheed-export +if [ "${1:-}" = "--run" ]; then + shift + disc="${SYLPHEED_DISC:?set SYLPHEED_DISC to the extracted disc root}" + "$CARGO_TARGET_DIR/release/sylpheed-export" export --disc "$disc" --out export "$@" + exec "$CARGO_TARGET_DIR/release/sylpheed-export" check --out export +fi diff --git a/godot-import/docker/bin/build-reference-cli b/godot-import/docker/bin/build-reference-cli new file mode 100755 index 00000000..b86ddee9 --- /dev/null +++ b/godot-import/docker/bin/build-reference-cli @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Build `sylpheed-cli` from the SAME revision of sylpheed-formats the exporter +# is pinned to, and put it on the persistent target volume. +# +# build-reference-cli -> $CARGO_TARGET_DIR/release/sylpheed-cli +# +# Why not just use /reborn/target/release/sylpheed-cli: that binary is built +# from whatever /reborn's working tree is at, which is a LIVE mount of the other +# agent's checkout and moves under you mid-iteration. `sylpheed-cli screen +# render` is the reference the Godot port is diffed against, so if it runs +# different decoders than the exporter, a pixel disagreement has a free variable +# in it and proves nothing about the port. +# +# The pinned source lives in CARGO_HOME, which is on the container overlay and +# does not survive a fresh container -- cargo re-fetches it. The BINARY goes to +# CARGO_TARGET_DIR, which is a volume, so this is a one-off per image. +# +# Jobs are capped for the same reason as build-export. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}" + +rev=$(sed -n 's/.*Syplheed-Reborn\.git", rev = "\([0-9a-f]*\)".*/\1/p' \ + crates/sylpheed-export/Cargo.toml | head -1) +[ -n "$rev" ] || { echo "build-reference-cli: no rev pin found in Cargo.toml" >&2; exit 1; } + +# The checkout only exists once cargo has fetched it; a fresh container has not. +find_checkout() { + find "${CARGO_HOME:?}/git/checkouts" -maxdepth 2 -type d -name "${rev}*" 2>/dev/null | head -1 +} +src=$(find_checkout) +if [ -z "$src" ]; then + echo "build-reference-cli: fetching the pinned decoders ($rev)" + cargo fetch + src=$(find_checkout) +fi +[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; } + +# Build into a target directory KEYED BY THE REVISION. +# +# This is not tidiness. Sharing one target dir across pins silently served a +# stale binary: after the pin moved 8b6dbcf -> 5414db3, cargo reported +# "Finished in 0.13s" and left in place a `sylpheed-cli` built from the OLD +# decoders. `screen list` still worked, so the old check passed, and the +# reference renderer this whole project verifies against was a revision behind +# for three consecutive diff runs. A per-rev tree cannot do that: a new pin has +# no artifacts to reuse. +echo "build-reference-cli: building sylpheed-cli from $rev" +tree="$CARGO_TARGET_DIR/reference-cli/$rev" +CARGO_TARGET_DIR="$tree" cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli + +stable="$CARGO_TARGET_DIR/reference-cli/sylpheed-cli" +mkdir -p "$(dirname "$stable")" +cp -f "$tree/release/sylpheed-cli" "$stable" + +"$stable" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \ + || { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; } + +# And check the binary is actually the pinned code, not merely a working one. +# `screen info` prints each element's resting placement, which is decoder +# output; if this disagrees with what the exporter wrote from the same pin, the +# two halves of the verification are not the same revision and every diff below +# is meaningless. Compare rather than assert a value, so this stays true when +# the pin moves again. +if [ -f "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json" ]; then + cli_rest=$("$stable" screen info "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" --build 5 \ + | sed -n 's/.*ptframe1\.t32.*rest (\([0-9]*\),\([0-9]*\)).*/\1,\2/p') + exp_rest=$(python3 -c ' +import json,sys +d=json.load(open(sys.argv[1])) +e=next(e for e in d["elements"] if e["id"]=="ptframe1") +print("%d,%d" % tuple(e["rest"]["pos"]))' "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json") + if [ "$cli_rest" != "$exp_rest" ]; then + echo "build-reference-cli: STALE OR MISMATCHED BINARY" >&2 + echo " the CLI resolves ptframe1 rest to ($cli_rest) but export/ says ($exp_rest)." >&2 + echo " Both should come from rev $rev. Delete $tree and rebuild." >&2 + exit 1 + fi +fi +echo "build-reference-cli: $stable (rev $rev, agrees with export/ on ptframe1)" diff --git a/godot-import/docker/bin/claude-autonomous b/godot-import/docker/bin/claude-autonomous new file mode 100755 index 00000000..bf644478 --- /dev/null +++ b/godot-import/docker/bin/claude-autonomous @@ -0,0 +1,72 @@ +#!/usr/bin/expect -f +# Start Claude Code for an unattended run, answering the first-run gates. +# +# Claude Code has three one-time interactive prompts, and every one of them is a +# silent, permanent hang for an agent with nobody at the keyboard — no error, no +# log line, just a container that looks healthy and does nothing: +# +# 1. the theme picker (first run, or whenever the installed version is +# newer than lastOnboardingVersion) +# 2. "do you trust this folder?" (per workspace) +# 3. the Bypass Permissions disclaimer (for --dangerously-skip-permissions) +# +# `seed-claude-config.py` pre-sets the config keys for 1 and 2. The disclaimer +# has no such key — it is meant to be accepted by a person once — so it is +# answered here instead. That is the honest reading of `sylph-agent loose`: the +# operator accepted it by choosing to run this, and the container is exactly the +# sandbox the warning asks for. +# +# ── Why the patterns are single words ── +# Claude Code draws its UI with ABSOLUTE COLUMN escapes between words, so the +# prompt arrives on the wire as +# +# 2.\x1b[8GYes,\x1b[13GI\x1b[15Gaccept +# +# A multi-word pattern like {Yes, I accept} therefore never matches, and the +# wrapper sits there looking like it is not running at all. Match one word. + +set timeout 90 +log_user 1 + +# Give the pty a wide, tall geometry. A detached `docker run -t` defaults to +# 80x24, and Claude Code hard-wraps to the terminal width — which truncates the +# Remote Control URL to "https://claude.ai/code/session_01…" in the one place +# you need to read it, and makes `docker logs` nearly unusable generally. +set stty_init "rows 50 cols 200" + +set answered_theme 0 +set answered_trust 0 +set answered_bypass 0 + +spawn -noecho claude --dangerously-skip-permissions {*}$argv + +expect { + -re {Choose} { + if {!$answered_theme} { set answered_theme 1; send "\r" } + exp_continue + } + -re {trust} { + if {!$answered_trust} { + set answered_trust 1 + send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" + send "1\r" + } + exp_continue + } + -re {accept} { + if {!$answered_bypass} { + set answered_bypass 1 + send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n" + send "2\r" + } + exp_continue + } + timeout { + # No new gate for a while: the session is up (or never had one). Stop + # matching so nothing later in the run can be answered by accident. + } + eof { exit } +} + +# Hand the terminal over for the rest of the run. +interact diff --git a/godot-import/docker/bin/push-work b/godot-import/docker/bin/push-work new file mode 100755 index 00000000..d072ff8c --- /dev/null +++ b/godot-import/docker/bin/push-work @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Push the current topic branch to origin — the ONLY sanctioned way out of the +# container. +# +# Why a wrapper instead of plain `git push`: +# +# * **`main` and shared branches are refused.** The agent commits to +# `auto/`; a human merges. A token that can push anywhere is one +# confused iteration away from rewriting the consolidated line. +# * **Force-push is refused**, always. Nothing here needs it, and history +# rewriting is the one mistake that cannot be undone by merging. +# * It pushes the CURRENT branch only, by name, so a stray `--all` cannot +# publish another agent's worktree branch mid-experiment. +# +# Credentials come from a file mounted read-only at ~/.git-credentials (see +# `sylph-agent`). They are never printed, never logged, and never passed on a +# command line. +# +# push-work push the current branch, and any annotated tags on it +# push-work --dry-run say what it would do +set -euo pipefail + +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "push-work: not inside a git repository" >&2; exit 1; } +cd "$repo_root" + +branch=$(git rev-parse --abbrev-ref HEAD) +if [ "$branch" = "HEAD" ]; then + echo "push-work: detached HEAD — check out a branch first" >&2; exit 1 +fi + +case "$branch" in + auto/*) ;; + *) + echo "push-work: refusing to push '$branch'." >&2 + echo " Only auto/* topic branches may leave the container; a human merges" >&2 + echo " them into main. Move your work: git switch -c auto/" >&2 + exit 1 ;; +esac + +if [ ! -s "$HOME/.git-credentials" ]; then + echo "push-work: no credentials mounted at ~/.git-credentials." >&2 + echo " The host must start the container with SYLPH_GIT_CREDENTIALS pointing" >&2 + echo " at a file containing one line:" >&2 + echo " https://:@git.mc02.dev" >&2 + exit 1 +fi + +# Applied to THIS COMMAND ONLY, via `-c`, never `git config --local`. +# +# Writing it to --local config persists it in the repository, and this repo is a +# bind mount the host also uses -- so the host's git inherited +# `store --file=/sylph-home/port/.git-credentials`, a path that exists only +# inside the container, and every host push then failed with +# `unable to get credential storage lock: No such file or directory`. +# +# A tool that configures a shared repository to suit itself breaks every other +# user of that repository. Keep it to the invocation. +CRED_HELPER="store --file=$HOME/.git-credentials" + +ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD) +echo "push-work: $branch — $ahead commit(s) to publish" + +if [ "$DRY" = 1 ]; then + echo "push-work: --dry-run, stopping here" + exit 0 +fi + +# --force-with-lease is deliberately NOT offered. If this is rejected as +# non-fast-forward, someone else moved the branch: fetch and merge, do not +# overwrite. +git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch" +echo "push-work: pushed $branch" diff --git a/godot-import/docker/bin/screenshot b/godot-import/docker/bin/screenshot new file mode 100755 index 00000000..99f94c0b --- /dev/null +++ b/godot-import/docker/bin/screenshot @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Capture the current display to a PNG. +# +# screenshot out.png +# +# Used to diff Godot's rendering against `sylpheed-cli screen render`. Captures +# the whole 1280x720 root window, which is exactly the design space the screens +# are authored in, so a capture and a composite are directly comparable without +# cropping or scaling. +set -euo pipefail +out="${1:?usage: screenshot OUT.png}" +import -display "${DISPLAY:-:97}" -window root "$out" +identify -format 'captured %wx%h -> %f\n' "$out" diff --git a/godot-import/docker/bin/seed-claude-config.py b/godot-import/docker/bin/seed-claude-config.py new file mode 100755 index 00000000..53a6de05 --- /dev/null +++ b/godot-import/docker/bin/seed-claude-config.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Mark Claude Code's onboarding as complete in ~/.claude.json. + +Claude Code re-runs its first-run wizard whenever `lastOnboardingVersion` does +not match the installed version. In a terminal that is a one-key prompt; for an +unattended agent it is a silent, permanent hang on the theme picker — no error, +no log line, and the container looks like it started fine. + +It also pre-accepts the workspace's trust prompt. That is a SECOND, separate +first-run gate: even past onboarding, Claude Code asks "is this a project you +trust?" per directory, and this repo's settings pre-approve 442 tool permissions +so the prompt is emphatic about it. Unattended, it is another silent hang. + +Pre-accepting is safe here precisely because the trust question is being +answered by the person who built and launched the container, for their own +repository — it is not a judgement being made on their behalf about someone +else's code. + +Usage: seed-claude-config.py [workspace...] +""" +import json +import os +import sys + + +def main() -> int: + if len(sys.argv) < 3: + print(f"usage: {sys.argv[0]} [workspace...]", + file=sys.stderr) + return 2 + path, version = sys.argv[1], sys.argv[2] + workspaces = sys.argv[3:] + + cfg = {} + if os.path.exists(path) and os.path.getsize(path): + try: + with open(path) as f: + cfg = json.load(f) + except (OSError, ValueError): + # A corrupt or partial config is not worth failing the container + # over — start from empty rather than block the run. + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + + cfg["hasCompletedOnboarding"] = True + cfg["lastOnboardingVersion"] = version + cfg.setdefault("theme", "dark") + # The THIRD interactive gate: --dangerously-skip-permissions shows a + # "Bypass Permissions mode / you accept all responsibility" confirmation on + # first use. Key name taken from the shipped binary's own strings, not + # guessed. Accepting it here is the whole point of `sylph-agent loose` — + # the container is the sandbox that warning asks you to provide. + cfg["bypassPermissionsModeAccepted"] = True + # A FOURTH gate, and this one fires mid-session rather than at startup, so + # the pty wrapper has already handed over by then: an upsell asking whether + # to try the fullscreen renderer. It is shown while + # `fullscreenUpsellSeenCount` is below an internal threshold, so park it far + # above. Found by reading the shipped binary's strings, same as the others. + cfg["fullscreenUpsellSeenCount"] = 9999 + # An auto-update mid-run would restart the process and lose the loop's + # scheduled wake-up, so pin the version the container was built with. + cfg["autoUpdates"] = False + + projects = cfg.setdefault("projects", {}) + if not isinstance(projects, dict): + projects = cfg["projects"] = {} + for ws in workspaces: + entry = projects.setdefault(ws, {}) + if not isinstance(entry, dict): + entry = projects[ws] = {} + entry["hasTrustDialogAccepted"] = True + entry.setdefault("projectOnboardingSeenCount", 1) + entry["hasClaudeMdExternalIncludesApproved"] = True + entry["hasClaudeMdExternalIncludesWarningShown"] = True + + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(cfg, f, indent=2) + os.replace(tmp, path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/godot-import/docker/entrypoint.sh b/godot-import/docker/entrypoint.sh new file mode 100755 index 00000000..73969dc5 --- /dev/null +++ b/godot-import/docker/entrypoint.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Bring up the headless display, then hand over. +# +# Xvfb and openbox are started as children of PID 1 (tini), NOT of the agent's +# shell, so they outlive any single command. The RE container learned this the +# hard way: a display owned by a shell gets reaped when that shell exits, which +# reads as "Xvfb dies on its own every few minutes". +set -euo pipefail + +: "${DISPLAY:=:97}" +: "${SCREEN_GEOMETRY:=1280x720x24}" + +if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + Xvfb "$DISPLAY" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp & + for _ in $(seq 50); do + xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break + sleep 0.1 + done + openbox >/dev/null 2>&1 & +fi +echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)" + +if [ -d /reborn ]; then + echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract" +fi + +# Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as +# complete. Claude Code re-runs its first-run wizard whenever +# lastOnboardingVersion differs from the installed version, so a container with a +# newer Claude than the host stops on the theme picker -- no error, no log line, +# and an unattended agent sits there forever. +if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then + cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true +fi +# Same reason as .claude.json above: `credential.helper=store` rewrites this +# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to +# a writable path; nothing is ever written back to the host's file. +if [ -f "$HOME/.git-credentials.host" ]; then + cp "$HOME/.git-credentials.host" "$HOME/.git-credentials" 2>/dev/null || true + chmod 600 "$HOME/.git-credentials" 2>/dev/null || true +fi + +CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0) +python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \ + "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true +chmod 600 "$HOME/.claude.json" 2>/dev/null || true + +# ── Claude Code ────────────────────────────────────────────────────────────── +# Without this the loop prompt is handed to `exec` as a command, and the whole +# markdown file is tried as a filename: exit 126, "File name too long". +if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then + # Drop the image's default CMD first, or `claude` is handed the literal string + # "bash" as its prompt and answers a question nobody asked. + if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then + set -- + fi + # Remote Control registers the session with the account so the agent can be + # reached from claude.ai -- the point of a detached run being that nobody is + # sitting in front of it. The name is passed EXPLICITLY: the flag's value is + # optional, so a bare --remote-control swallows the /loop prompt after it. + if [ "${SYLPH_REMOTE:-1}" != "0" ]; then + set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-port}" "$@" + echo "[entrypoint] Remote Control as '${SYLPH_REMOTE_NAME:-sylpheed-port}'" + fi + # claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and + # answers the one-time first-run gates. The Bypass Permissions disclaimer has + # no config key that skips it, so unattended it hangs forever. + set -- claude-autonomous "$@" + echo "[entrypoint] starting Claude Code in $(pwd)" +fi + +exec "$@" diff --git a/godot-import/docker/sylph-port b/godot-import/docker/sylph-port new file mode 100755 index 00000000..28af6b5b --- /dev/null +++ b/godot-import/docker/sylph-port @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Launcher for the Godot port agent. +# +# ./sylph-port build build the image +# ./sylph-port shell interactive shell +# ./sylph-port loose [task] detached, self-running on a fixed interval +# ./sylph-port logs -f follow it +# ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running) +# ./sylph-port remote a link to chat with it from anywhere +# ./sylph-port stop stop it +# +# Env: +# SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4) +# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent) +# SYLPH_REBORN path to the Syplheed-Reborn checkout (read-only mount) +# SYLPH_DISC extracted disc root +# SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work +# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m) +# +# ── Two hard-won constraints ──────────────────────────────────────────────── +# +# 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent +# or a human is working in. Sharing a working tree between two writers means +# files change under whoever is mid-edit, and a `git add -A` by one sweeps up +# the other's work. That happened; do not re-create it. +# +# 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into +# a repo's config captures every commit made in that tree, including a +# human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and +# nobody else's. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The repo to mount at /work. Overridable so this script can be run from a +# worktree -- a human editing on `main` must not repoint the agent's checkout. +REPO="${SYLPH_PORT_REPO:-$(cd "$HERE/.." && pwd)}" +IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}" +NAME="${SYLPH_PORT_NAME:-sylpheed-port}" + +# Half of what the RE container takes. That container builds a C++ emulator and +# drives it; this one converts assets and runs Godot. Two full-size containers +# do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding +# constraint, and an over-committed build has crashed this machine before. +CPUS="${SYLPH_PORT_CPUS:-3}" +MEM_GB="${SYLPH_PORT_MEM_GB:-4}" + +REBORN="${SYLPH_REBORN:-$(cd "$REPO/../Syplheed-Reborn" 2>/dev/null && pwd || true)}" +DISC="${SYLPH_DISC:-$(cd "$REPO/../sylph_extract" 2>/dev/null && pwd || true)}" + +docker_args() { + local _out=( + --name "$NAME" + --hostname sylph-port + --cpus "$CPUS" + --memory "${MEM_GB}g" + --memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build + # thrashes the whole host + --pids-limit 2048 + -v "$REPO:/work" + -v "sylpheed-port-target:/sylph-home/port/target-container" + # CARGO_HOME on a volume, not the container overlay: without it the pinned + # decoder source is re-fetched from the network on every fresh container. + -v "sylpheed-port-cargo:/sylph-home/port/.cargo" + -v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude" + -v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro" + -e "PROJECT_DIR=/work" + ) + + # The RE corpus, READ-ONLY. `docs/port/HANDOFF.md` is the contract, and the + # agent also builds sylpheed-cli from here for the reference renderer. Mounted + # ro so a port iteration cannot edit the other agent's repository. + if [ -n "$REBORN" ] && [ -d "$REBORN" ]; then + _out+=(-v "$REBORN:/reborn:ro") + else + echo "==> NOTE: no Syplheed-Reborn checkout found; the agent cannot read" >&2 + echo " HANDOFF.md or build the reference renderer. Set SYLPH_REBORN." >&2 + fi + + if [ -n "$DISC" ] && [ -d "$DISC" ]; then + _out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc") + else + echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2 + echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2 + fi + + # Commits are attributed to the port agent, via the environment so that + # nothing is written into the repository's config. See constraint 2 above. + _out+=( + -e "GIT_AUTHOR_NAME=Sylpheed port agent" + -e "GIT_AUTHOR_EMAIL=port-agent@localhost" + -e "GIT_COMMITTER_NAME=Sylpheed port agent" + -e "GIT_COMMITTER_EMAIL=port-agent@localhost" + ) + + # Mounted as `.host` and copied to a writable file by the entrypoint, exactly + # like .claude.json. `credential.helper=store` REWRITES its file after a + # successful auth -- it writes a temp file and renames over the target, and + # renaming onto a bind-mount point gives EBUSY, which surfaces as + # `fatal: unable to write credential store: Device or resource busy`. + # + # The push still succeeds, which is the actual danger: a `fatal:` line that is + # routinely wrong teaches the reader to ignore the one that is real. Mounting + # rw would also silence it, but then the container can clobber the host's + # credential file; copying cannot. + local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}" + if [ -f "$gitcred" ]; then + _out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro") + else + echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2 + echo " so its work dies with the container." >&2 + fi + + [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") + printf '%s\n' "${_out[@]}" +} + +mapfile -t ARGS < <(docker_args) + +case "${1:-}" in + build) + exec docker build -t "$IMAGE" \ + --build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE" + ;; + + shell) + TTY=(-i); [ -t 0 ] && TTY=(-it) + exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash + ;; + + loose) + shift + TASK="${1:-}" + if [ -z "$TASK" ]; then + if [ -f "$REPO/docs/loop-task.md" ]; then + TASK="$(cat "$REPO/docs/loop-task.md")" + else + TASK="Work the milestones in docs/MISSION.md." + fi + fi + # A FIXED interval, not self-pacing: the one thing an agent deep in a + # milestone reliably forgets is the bookkeeping after it, and a forgotten + # wake-up silently ends the loop. + INTERVAL="${SYLPH_LOOP_INTERVAL-45m}" + echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}" + echo "==> repo: $REPO" + echo "==> reborn: ${REBORN:-} (read-only)" + docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \ + "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null + echo + echo " running detached as '$NAME'." + echo " ./sylph-port remote link to chat with it from anywhere" + echo " ./sylph-port logs -f follow it" + echo " ./sylph-port attach chat with it locally" + echo " ./sylph-port stop stop it" + ;; + + logs) shift; exec docker logs "$@" "$NAME" ;; + attach) exec docker attach "$NAME" ;; + stop) exec docker rm -f "$NAME" ;; + + remote) + echo "waiting for the session to register" >&2 + for _ in $(seq 60); do + # Read the container LOG, not the session transcript. The transcript + # records every command run inside the container -- including this + # lookup -- so grepping it matched our own pattern string back. + url=$(docker logs "$NAME" 2>&1 \ + | grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \ + | tail -1 || true) + [ -n "$url" ] && { echo "$url"; exit 0; } + sleep 2 + done + echo "no session link yet — try ./sylph-port logs -f" >&2 + exit 1 + ;; + + *) + sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//' + ;; +esac diff --git a/godot-import/docs/AUDIO-VERIFICATION.md b/godot-import/docs/AUDIO-VERIFICATION.md new file mode 100644 index 00000000..41c80c90 --- /dev/null +++ b/godot-import/docs/AUDIO-VERIFICATION.md @@ -0,0 +1,110 @@ +# Verifying audio without an audio device + +Neither container has a sound card, so "does it actually play?" cannot be +answered by listening. It can be answered by measurement, and the two things +usually meant by that question need different measurements. + +**Separate them before reaching for a tool:** + +| question | needs Godot? | needs a device? | +|---|---|---| +| Is the transcoded file faithful to the source? | no | no | +| Does Godot actually route it to an output? | yes | no | +| What does the *game* play on a menu move? | no (Canary) | a virtual one | + +## 1. Transcode fidelity — file against file + +This is the question P4 actually raised, and it needs neither an engine nor a +device. Decode both, subtract, and measure what is left. + +```bash +# Source, for a reference level +ffmpeg -hide_banner -t 25 -i ADV.wmv \ + -af "aformat=channel_layouts=stereo,astats=measure_perchannel=none" -f null - 2>&1 \ + | grep "RMS level" + +# The difference signal: source minus transcode +ffmpeg -hide_banner -t 25 -i ADV.wmv -t 25 -i ADV.ogv -filter_complex \ + "[0:a]aformat=channel_layouts=stereo[a];\ + [1:a]aformat=channel_layouts=stereo,volume=-1[b];\ + [a][b]amix=inputs=2:normalize=0,astats=measure_perchannel=none" -f null - 2>&1 \ + | grep "RMS level" +``` + +A faithful transcode puts the difference **40 dB or more below** the source. + +### Three ways this measurement lies + +Run it wrong and it reports a disaster that is not there. All three of these +were hit on the first attempt: + +* **Alignment.** A one-sample offset makes the difference nearly as loud as the + source. Cross-correlate and compensate *before* subtracting, or the number is + meaningless. A first run gave source −25.3 dB against difference −34.2 dB — + only 9 dB down, which looks catastrophic and proves nothing. +* **Channel layout.** The source and the transcode do not have the same channel + count. You are not comparing like with like unless both sides are downmixed + the same way, and `astats` will give you a confident number regardless. See + [`movie-audio-channels`][mac] for which profile a given movie is in — that is + a disc fact and lives in the RE corpus, not here. +* **A file still being written.** `ffprobe` reported the `.ogv` as 33 s against + the source's 137 s — apparent catastrophic truncation, actually a transcode in + progress. Check `mtime` and packet count before believing a duration, and + write to a temp name and rename on completion so a reader cannot see a partial + file at all. + +⚠️ **The downmix is an unrecorded decision, and it is not ours to make quietly.** +Nothing in the manifest says a fold happened or on what weighting; it is whatever +ffmpeg defaulted to, and that default can change between versions. Centre-channel +dialogue folds into L/R, so this changes how speech sits against music — an +aesthetic judgement, not a container detail. Pin it explicitly and record it, +exactly as MISSION §6 requires of the transcode command itself. + +[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md + +## 2. Engine routing — Godot writes a WAV instead of a device + +Godot does not need a sound card to produce audio you can inspect. Put an +`AudioEffectRecord` on the **Master** bus and it captures the mixed output from +inside a headless run: + +```gdscript +var bus := AudioServer.get_bus_index("Master") +var rec := AudioEffectRecord.new() +AudioServer.add_bus_effect(bus, rec) +rec.set_recording_active(true) +# ... play the scene ... +rec.set_recording_active(false) +rec.get_recording().save_to_wav("user://master.wav") +``` + +Then feed that WAV through §1 against the source. That closes the loop: it +proves the asset is right **and** that the engine reached it, which no amount of +file comparison can show on its own. + +Confirm the dummy driver is what is actually in use rather than assuming it — +`AudioServer.get_driver_name()` — and say so in the write-up, because "recorded +under a dummy driver" is a weaker claim than "heard", and the difference matters. + +## 3. A virtual device, when something insists on a real one + +For anything that opens a device rather than a bus — the emulator, most +obviously — a PulseAudio **null sink** is a real device that records to a file: + +```bash +pactl load-module module-null-sink sink_name=cap sink_properties=device.description=cap +PULSE_SINK=cap +parec -d cap.monitor --file-format=wav /tmp/captured.wav +``` + +This is the route to capturing what the *game* plays — the menu move and confirm +cues behind HANDOFF Q8 — rather than what we think it should play. It needs +`pulseaudio-utils` in the image, so it is a rebuild, not something to reach for +mid-iteration. + +## What none of this establishes + +That it *sounds right*. Every method here shows correspondence to a source, not +that the source is the audio the game plays at that moment, and not that levels +are sane in a mix. A ten-second human listen still answers something no +measurement above does — so when a result rests on one of these, say which one. diff --git a/godot-import/docs/BLOCKED.md b/godot-import/docs/BLOCKED.md new file mode 100644 index 00000000..65861483 --- /dev/null +++ b/godot-import/docs/BLOCKED.md @@ -0,0 +1,239 @@ +# Waiting on the RE agent + +What this port cannot do until an answer lands in +[`/reborn/docs/port/HANDOFF.md`](https://git.mc02.dev/fabi/Syplheed-Reborn). +Recorded so it is not re-discovered every iteration. + +**None of these may be guessed.** A value invented here is indistinguishable from +a decoded one a month from now. Where a milestone can proceed with a placeholder, +the placeholder goes in `authored/` with a `why` naming the question it stands in +for, so it is deleted rather than forgotten when the answer arrives. + +Last reconciled against HANDOFF.md on **2026-08-29**, at `/reborn` HEAD `9a0ca0d`. +(`/reborn` is mounted read-only, so `git -C /reborn pull` fails by design; the +mount is refreshed outside this container and HEAD is read, not fetched.) + +## Still open — these block work + +| Milestone | Needs | HANDOFF | State | +|---|---|---|---| +| ~~P6 audio~~ | ~~which cue fires on move / confirm / back~~ | Q8 | ✅ **answered 2026-08-28** — the RE agent retracted "cannot be extracted". The waves are located in `Static.slb` by playing them: **move `0x1ec0`** (8 192 B, 0.533 s), **confirm `0x5d6c0`** (12 288 B, 1.016 s), **back `0x0ec0`** (4 096 B, 0.344 s), and ⬅➡ play nothing. Move and back reproduce across two boots. 🟡 that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still a name match, and Ⓐ's wave is not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`. P6 can now export real audio; the exporter has to grow an SE path. | +| P6 audio | which BGM the menu plays | Q10 | ❔ **not on the disc.** All 32 banks are named `BGM_001`…`BGM_109` with no semantic name anywhere. The port is choosing a track, and that choice is authored. | +| P6 looping | where a menu loop restarts | Q10 | ❔ `BGM_001` fades out at 167.663 s into 6.15 s of silence, and no loop-point field has been identified. A menu loop is authored. | +| P4/P7 video | whether Ⓐ skips a movie | Q9 | 🟡 unsettled — the corpus says Ⓐ skips every time, the boot harness never taps during a movie because it breaks the title. P4 can play the movie; it cannot yet say what a button press does during one. | +| P5 `NEW GAME` | what Ⓐ on `NEW GAME` opens | Q4 | ❔ untested: Ⓐ on it **hangs the emulator**. The other four destinations are measured. | +| P3 sequencing | what code decides to advance the boot sequence | Q6 | 🟡 the order is observed and the attract cycle timed (~8–10 s idle → fade → `ADV.wmv` in full → title). The *driver* is not decoded. P3 can reproduce the observed behaviour and must say it is reproducing an observation. | + +## Answered since this file was last written — no longer blocking + +Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s +*measured*), Q2 (which build is which screen), Q3 (paint order — a `u16` layer key +at `+0x0A`, **decoded**), Q5 (navigation: ⬆⬇ wrap, ⬅➡ nothing, Ⓑ up with focus +restored), Q7 (transitions: a fade through black, fade-in decoded, ~0.4 s fade-out +measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS00A` → +`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** — +do not concatenate), S1 (Ready Room: no-go). + +Also newly available, and useful to P3/P5 when they author the flow: the title +part's transitions are a **lookup by name**, and the game's own screen +vocabulary includes `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`, `DIFFICULTY`, +`EXTRA_MENU`, `TUTORIAL_MENU`. Three of those are corroborated by measurements +taken before the function was opened (`DIFFICULTY` is what `NEW GAME` opens, +`EXTRA_MENU` is `EXTRAS`, `TUTORIAL_MENU` the lesson list). 🟡 **Candidate, not +decoded** — the RE agent is explicit that the strings are what the call sites +*reference*, not proven arguments, and the same list mixes in `TEXT_FONT` and +`GAMMA_RGB`. So `authored/flow.json` may use these as `goto` names — which is +better than inventing names — but must mark them as a name match, not a +measurement. + +Three of those are **measured**, not decoded, and so are authored here rather +than exported: + +| Authored because it is not on the disc | HANDOFF | Where it lives | +|---|---|---| +| `1 keyframe unit = 1/60 s` | Q1 | not yet written — P2 | +| initial menu focus (not stable across boots; pick one and say so) | Q5 | not yet written — P5 | +| the ~0.4 s fade-out and the 0.17–0.23 s black hold | Q7 | not yet written — P3 | + +## What the port needs next — sent to the RE agent 2026-08-29 + +Ordered by what it costs the port, not by what it costs to answer. + +### 1. How should the exporter recognise the developer-logo splash? (P3, blocking) + +The splash is the **first thing P3 draws** and it is not in `export/`. It +declares its sprites directly and has no `.rat` layout child, so `is_build` +rejects it; `sylpheed-cli` reaches it only via `--all`, which the CLI's own help +says **renumbers `--build`**. So the port cannot address it by build index +without the index meaning something different from everywhere else in this +format. + +What I need is a **predicate**, not an index: something the exporter can apply to +say "this bundle is a composable screen" that admits the splash and does not +admit the 1 894 two-element fragments `--all` also lets in. If the honest answer +is "there is no such rule, take `GP_TITLE` entries 11/14", that is a usable +answer — I will export it under a synthetic name with `name_source` saying it was +located by entry index and not by a rule. + +### 2. Is the ~0.4 s fade-out the whole ramp, or a segment of it? (P3, blocking) + +Q7 measures the screen fade-out at ~0.4 s and the black hold at 0.17–0.23 s. +The port needs to know **which quantity that 0.4 s is**, because the last +keyframe of a group carries no `t` and the port refuses to invent one: + +* the ramp from the hold to the exit pose — i.e. the missing duration of that + final untimed keyframe; or +* hold → exit → fully black, the 0.4 s covering several keyframes; or +* something the game does independently of the group. + +Under the first reading the port writes one authored constant and plays the +group to its end. Under the third it must not. + +### 3. Focus: drawn OVER the base element, or INSTEAD of it? (P5, cheap, avoid rework) + +`sylpheed-cli --focus` is documented as drawing the focused record **over** its +base. The port **replaces** the sprite. Those are different operations and the +port picked its one without evidence. + +Evidence that the port is wrong: rendering `main_menu` with `ptbtn01` focused — +which is how `main-menu-oracle.png` was taken — makes the RMSE against that +capture **worse**, 5.92 % → 7.00 %. The capture also shows a **ring marker** +beside `NEW GAME` that the port draws nowhere. Cheap to answer from a capture +that already exists, and it decides how P5 is built. + +### 4. Rotation — should the port draw it, and about what? (P2/P3, needs a joint decision) + +`67fa1a1` decodes `rotation_deg` at keyframe `+12` and explicitly does **not** +render it: `ui_layout::blit` is axis-aligned. `ptloop01`/`ptloop02` on the title +declare +30° and −45°, and the framebuffer submits them at +30.26 and −45.28. + +A canvas rotation is a few lines in Godot, so the port *can* draw these. But +then the port is deliberately more correct than the reference renderer, and +`verify-screen` — the port's whole verification method — starts reporting a large +diff on the title that means "the port is right". That is a bad state to be in +silently, so I would rather agree it than do it. + +Two sub-questions: **is the rotation about the declared pivot** or about the +element's centre or corner? And would you rather `blit` grow a rotating path so +the diff stays meaningful? The format would go to **v3** to carry +`rotation_deg`; that is my side and I will do it either way, since carrying a +decoded field the renderer ignores is better than dropping it. + +### 5. Is `main-menu-oracle.png` gamma-correct? (not blocking, but it calibrates everything) + +With the background in, the port sits at 5.92 % RMSE against that capture and is +visibly **darker and less saturated** than it across the whole frame. If the +capture path applies a gamma or a colour transform the game does not, then RMSE +against captures has a floor and the port should stop chasing it. If it does +not, something is still missing. The port cannot tell these apart from inside. + +## Questions this port has raised + +### ~~Does a keyframe group loop, or hold its last pose?~~ — answered + +**Answered 2026-08-28 by the RE agent: groups hold.** `ptloop01`/`ptloop02` park +their sprites at x=1521 and x=−839, both off a 1280-wide design, and 18 s of +settled title sits at sd ≤ 0.01. `loop*.rat` is a misleading name — these +animate once during build-in and then rest off-screen. + +The port's own error here was different and is fixed: it settled at the last +*timed* keyframe rather than at the hold. See `docs/DECISIONS.md`. + +Kept for the record: + +Raised at P2 and **unsettled**. The port holds the last timed keyframe, which is +right for an entry animation (the main menu settles at t=80, 1.33 s) and is +proven on the screen P2 gates. The **title** runs to t=269 — 4.48 s — and there +the port's settled pose and the decoders' `rest` disagree badly (max 142/255). + +What is known: no element's alpha reverses direction anywhere in this export, so +nothing pulses, which removes the obvious reason to expect a loop without +disproving one. What would settle it: **a capture of build 4 alone**. The one +live title capture composites the `PRESS Ⓐ` plate (build 2) over it, so it +cannot be diffed against the title by itself. + +⚠️ Independently, **both** of the port's modes draw a washed-out cyan glow over +the title logo that the running game does not have. That is a third problem and +it is P3's; it is noted here so nobody reads the loop question as its cause. + +Not blocking anything today; raised because the port found them and a guess here +would be believed later. + +### ~~`rest_plateau` misfires on elements with no exit animation~~ — fixed + +**Fixed 2026-08-28** in `sylpheed-formats`, and this port's pin moved +`8b6dbcf → 5414db3` to take it. The rule adopted is **not** the condition this +port proposed, which was too loose: a trailing run is the hold exactly when it +is **visible**. The port's condition would have erased the word PAUSE on +`pgptitle.rat`, whose trailing run is two identical *transparent* frames. + +Kept for the record, since the reasoning is still what found it: + +**This one is a decoder bug, not a question**, and it was the highest-value item +on this page for the RE agent. `ui_layout::rest_plateau` excludes a run of +identical keyframes that ends the group, on the grounds that it is the exit. For +an element that **has no exit animation** the trailing run *is* the hold, and the +rule falls back to an earlier run — for a slide-in, the invisible pre-roll. + +The condition that identifies the affected elements exactly, with no false +positives across this export, is: **the final untimed keyframe has the same pose +as the last timed one.** Six elements match; `rest()` misses all six. + +`ptframe1` and `ptframe2` on the main menu are the visible case, and +`docs/re/captures/main-menu-oracle.png` settles it — the game draws the circuit +bracket that `rest` calls invisible. `sylpheed-cli screen render` is missing it +too, so this is not only a port concern. + +The port needs nothing here: it derives the arrived pose from the keyframes and +does not use `rest`. Filed because `rest()` is used elsewhere and because a +capture already proves it. + +### Does the game sample a scaled sprite at the pixel corner or the pixel centre? + +Found at P1, by the only screen it could have been found on. `title_jp`'s +`ptlogo_eff2` is the **single drawn element in the whole export** at a scale that +is not a whole multiple of 100 % (125 %), and `title_jp` is the only one of the +twelve screens whose Godot-vs-CLI diff exceeds 6/255. + +The two renderers pick different source texels at a non-integer ratio. +`sylpheed_formats::ui_layout::blit` samples at the destination pixel's **top-left +corner** (`sxi = col * sw / dw`); a GPU samples at its **centre** +(`floor((col+0.5)*sw/dw)`). At 125 % they disagree on one column in five — ~30 +pixels above 100/255, strung along thin diagonal edges. At every whole multiple +of 100 % they agree exactly, which is why the other eleven screens are clean. + +The port has **not** changed to match: matching would mean reproducing a half- +pixel bias on purpose to make a number smaller. The question for the RE agent, +when it is cheap: **a framebuffer capture of the Japanese title screen** would +settle it outright, and it is the kind of thing a capture answers in one look. + +Cost of being wrong either way: a one-texel edge on one glow, on a screen the +English boot path never shows. This is filed, not urgent. + +### The pivot is not half the texture on `GP_TITLE` + +`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32` +element this is exactly half the decoded texture's dimensions (verified 7/7 on +the tutorial bundle)". Counting it over the whole of `GP_TITLE` as exported: + +* **55 of 93** sprite-bearing `.t32` elements match within ±1 px. +* **38 do not**, and several are not close: `ptlogo_back2` is 1118×262 with pivot + (500, 117) where half is (559, 131); `ptmsg` is 223×38 with pivot (123, 19) + where half is (111.5, 19) — the Y matches and the X does not. + +This changes nothing today: the exporter emits the **declared** pivot and never +derives one, and the pivot only affects drawing when scale ≠ 100 %. But it does +matter, because scale is genuinely animated here — **177 keyframes** across +`GP_TITLE` are not 100 %, including on the title screen the port must draw at P1. + +The question for the RE agent, when it is cheap to answer: **does the running +game anchor a scale to the declared pivot, or to half the texture?** The two +differ by up to 59 px on `ptlogo_back2`, which is visible. Until then the port +follows the decoders and uses the declared pivot, which is also what +`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and +agreement between the two is not evidence. + +**P1 has now been run and that prediction held.** The port and the CLI agree on +every scaled element across all twelve screens; the question is untouched by it. +It will stay untouched by P2 as well, since P2 animates the same two renderers' +shared assumption. Only a capture answers this. diff --git a/godot-import/docs/DECISIONS.md b/godot-import/docs/DECISIONS.md new file mode 100644 index 00000000..60f0a13c --- /dev/null +++ b/godot-import/docs/DECISIONS.md @@ -0,0 +1,811 @@ +# Decisions + +One entry per decision that outlives the container it was made in. Newest last. +A decision that lives only in an agent's context is lost when that container +dies, which is what this file is for. + +--- + +## P0 — the exporter, 2026-08-28 + +### The exporter reads one authored file, and stamps its provenance into the output + +`export/` is derived and `authored/` is hand-written, and the natural reading of +that is that the exporter never touches `authored/`. But a screen has to be +*called* something, and the disc does not name its builds — the identification of +build 5 as the main menu is HANDOFF Q2, **measured against a live capture**, not +a field. + +Two ways to handle that: + +1. the exporter emits `build_05.json` and the runtime renames it from + `authored/screen_names.json`; +2. the exporter reads that map and writes `main_menu.json` directly. + +Chose **2**, with a condition: every name it applies carries `name_source: +"authored"` and a `name_why` quoting the evidence, and `check` **rejects** an +authored name with no `why`. The file that lands in `export/` is therefore still +honest about which of its fields is a measurement — which is the property the +derived/authored split exists to protect — while a human opening the tree sees +`main_menu.json` rather than having to resolve a rename in their head. A build +nobody has identified exports as `build_NN` with `name_source: "index"`, which is +a locator and not a claim. + +This is the **only** authored input the exporter takes. Everything else in +`authored/` is applied by the runtime over `export/`. + +### Sprites are per screen, not a flat pool + +`main_menu` and `extras` both ship a `ptbase.t32` and they are different +pictures. A flat `sprites/` directory would have silently collided; whichever +screen exported second would have won, and the loser would have drawn the wrong +background with no error anywhere. `sprites///.png`. + +### The format is executable + +`sylpheed-export check --out export` validates a tree against `docs/FORMAT.md` +with no disc in hand. It exists because "the export is correct" is otherwise an +assertion, and because the P0 gate is *"validates against FORMAT.md"* — which is +not a thing anyone can confirm by reading. + +It reads the tree the way Godot will: as a stranger, with no access to the disc, +the decoders, or the exporter's internals. It deliberately does **not** check the +export against the disc — that is what `sylpheed-cli screen render` is for, at P1. + +Checked that it bites, rather than assuming: five mutations of a valid +`main_menu.json` — a broken `paint_order` permutation, a dangling +`focus_sprite`, a reversed `buttons` list, a `#rrggbbaa` colour, an invented +`name_source` — are each caught with a specific message. + +### The highlight sprite pairs by name; `opt ` is exported but not believed + +FORMAT v1 said `focus_sprite` came from the element's `opt ` link. That reading +was **measured and refuted** by the RE agent, and this export shows why plainly: +on the main menu, `opt ` chains `ptloop01 → ptloop02 → ptbtn01` — two decorations +and then a button. It is a linked list of something, and it is not focus. + +The highlight is paired by **sprite name** instead (`ptbtn01.t32` ↔ +`ptbtn01f.t32`), which is HANDOFF's convention and holds for all 54 real pairs on +the disc. It resolves all five main-menu buttons. The raw link is still exported +as `opt_link`, renamed so that nothing downstream mistakes it for navigation, and +so that whoever eventually decodes it has the data. + +Note this is 🟡 a naming convention, not a decoded field. It is authored in +effect, and lives in the exporter only because it is a rule over disc data rather +than a value we chose. + +### The paint order is exported, not authored + +Q3 decoded it — a `u16` layer key at `+0x0A` of each `T8aD` sprite header, +stable-sorted with declaration index. So it is read in the exporter, per the +contract's own rule for a decoded answer, and `paint_order` in `export/` is a +derived field. `"paint_order"` is gone from `unresolved`; **`paint_order_ties` +replaces it**, because the tie-break is still unknown and costs one element's +blend on one screen. + +Where an element has no `T8aD` header the key comes from the decoders' table of +keys **measured off the running game**. That is a different kind of fact, so it +is labelled: `layer_source` is `"sprite"`, `"implied"` or `"none"`, and a +consumer that needs to know whether a layer is read or measured can tell. + +### Colours are exported as two fields with the byte order in the name + +There are two modulate colours and they multiply: `tint` is RGBA, `fade` is +**ARGB** and its high byte is the alpha that ramps. v1's single `"#ffffffff"` +could not carry both and silently discarded the ramping alpha. They are exported +as `tint_rgba` and `fade_argb`, raw hex, byte order in the key — because getting +it backwards is silent and looks like an art bug rather than a parse bug. + +### `t` stays raw + +HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working +conversion 1 unit = 1/60 s — but that conversion is **measured off the running +game, not read from the file**, and the finding itself flags the 27.6 present- +frames/second measurement as the part worth re-testing. If the game turns out to +present at 60 Hz, every duration halves. + +So `t` is exported exactly as the disc spells it, `keyframe_time_unit` stays in +`unresolved`, and the conversion will live in one authored place at P2. One +constant to change, in a file that says it is a decision. + +### The final keyframe has no `t`, and `check` enforces that + +The disc has no time slot on the last keyframe of a group. A file that carries +one there has invented it. `check` rejects it — this is the one place where the +temptation to emit a plausible number is strongest and the resulting error is +completely invisible. + +--- + +## P1 — Godot draws the screen, 2026-08-28 + +### The Godot side reads the manifest, not a path + +`ExportTree` is the only class that knows where `export/` is: `SYLPHEED_EXPORT` +if set, otherwise `/../export`. Screens are addressed by their manifest +**name** (`main_menu`), never by a file path, so the runtime never encodes the +archive's subdirectory and a re-export that moves a file does not break it. It +also checks `format` on both the manifest and each screen, and refuses a tree it +was not built to read rather than half-drawing one. + +Textures are read as bytes and decoded with `load_png_from_buffer` at runtime. +They are deliberately **not** Godot-imported resources: `export/` is gitignored +and regenerated wholesale, and a `.import` sidecar per sprite would be derived +state living next to derived state, invalidated on every re-export. + +### One CanvasItem draws the whole screen + +`ScreenView._draw` walks `paint_order` and draws each element itself, rather +than making a node per element and leaning on `z_index`. The export's +`paint_order` is already back-to-front, so honouring it is a loop; expressing +the same order through sixteen nodes' z-indices would hide the one thing that is +still unresolved about it — the **ties** — behind Godot's own sibling rules, +where a change in the export would silently become a change in Godot's tree +order instead of a visible change in the draw sequence. + +### P1 draws `rest` and nothing else + +Every element is drawn at its resting pose. No keyframe interpolation: that is +P2, and it depends on the keyframe time unit, which is **measured** rather than +decoded. A milestone whose gate is a pixel diff must not have a measured +constant inside it, or the diff stops being evidence about the port. + +For the same reason `focused_id` is empty at P1. Initial focus was measured as +unstable boot to boot (HANDOFF Q5), so choosing one is an authored decision and +it belongs to P5, where a human is pressing keys. + +### Nearest-neighbour, and why that is not a preference + +`TEXTURE_FILTER_NEAREST`. The export is a 1:1 copy of the disc's own texels and +elements draw at up to 500 %; a bilinear filter invents detail the disc does not +have. It is also what the reference renderer does — `ui_layout::blit` maps +destination to source by integer division — so a filter difference cannot +masquerade as a placement difference in the diff. + +### The capture is the SubViewport, not the window + +The screen is drawn into a `SubViewport` sized to the export's own `design` +rectangle and shown through a container that scales it to the window. The first +attempt captured `get_viewport()` and got **1235×695**: there is a window manager +on the Xvfb display and its title bar had eaten 45×25 px of a screen the export +declares as 1280×720. A gate that compares a rescaled 1235×695 capture against a +1280×720 composite measures the compositor. + +So `--capture` grabs the SubViewport texture: exactly the design rectangle, +independent of the window, directly comparable with `screen render` with no crop +and no resample. The windowed run is still worth doing — it is what proves a +human sees the screen — but it is not what the numbers come from. + +## P1 gate — the diff, and what it found + +`tools/verify-screen` renders every screen in the manifest both ways and reports +the largest per-channel difference anywhere in the frame. Both renderers are held +to the same inputs: the reference CLI built by `build-reference-cli` from the +revision the exporter is **pinned** to (not `/reborn/target/`, which is a live +mount that moves mid-iteration), `--black` because the screen carries its own +background, and `--primitives --animated` because those are what make the CLI +draw the same element set the port draws at rest. + +| screen | build | max per-channel Δ | | +|---|---|---|---| +| `main_menu` | 5 | **3** | the P0/P1 gate screen | +| `main_menu_jp` | 8 | 3 | | +| `extras` / `extras_jp` | 6 / 9 | 4 / 3 | | +| `press_start` / `press_start_jp` | 2 / 3 | 1 | | +| `build_00` / `build_01` | 0 / 1 | 3 | | +| `build_10` / `build_11` | 10 / 11 | **0** | byte-identical | +| `title` | 4 | 6 | paint-order tie, below | +| `title_jp` | 7 | 154 | sampling phase, below | + +`main_menu` — the milestone's own gate — agrees to **≤3/255 on every channel of +every pixel**, RMSE 0.38 %, with **no** pixel differing by more than 4 %. 3/255 +is what integer-truncating compositing in the CLI and float rounding on a GPU +differ by; there is no structural disagreement anywhere in the frame. + +Three screens exceed that, and each has a named cause rather than a threshold. + +### `title`: a tie in the paint order — neither renderer is wrong + +Build 4 is the one screen where the CLI uses a paint order **measured off the +running game** instead of deriving it. Compared against the order this port +exports, every single disagreement is **inside a tie** — the two orders differ +only among elements carrying *identical* layer keys (`0x8083`, the `back2` glow +group, and `0x80a0`): + +``` +derived : … 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, … +measured: … 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, … +``` + +That is exactly the residual HANDOFF Q3 documents and this export already +declares in `unresolved: ["paint_order_ties"]`. It is worth stating what it +costs: **904 px** in the glow band at (445,117)–(1195,313), all of them 4–6/255. +The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix, +and nothing to tune — a "fix" here would be fitting the port to one screen's +capture. + +Two of the reordered indices (`0x80a0`) are `kind & 0x4` template instances that +both renderers skip, so the only real reorder outside the glow group is +`ptlogo2` against `ptlogo_tm`, which do not overlap. + +### `title_jp`: nearest-neighbour sampling phase — the CLI is the one I would call wrong + +`title_jp` is the **only** screen in the export with a drawn element at a scale +that is not a whole multiple of 100 %: `ptlogo_eff2` at 125 %. It is also the +only screen with a difference above 6/255. The two facts are the same fact. + +At a non-integer ratio the two renderers pick different source texels: + +* `ui_layout::blit` samples the source at the destination pixel's **top-left + corner** — `sxi = col * sw / dw`. +* A GPU samples at the destination pixel's **centre** — `floor((col+0.5)·sw/dw)`. + +At 125 % those disagree on one column in five, which is why the differing pixels +are ~30 above 100/255 strung along thin diagonal edges rather than a shifted +region. At every whole multiple of 100 % they agree exactly, which is why the +other eleven screens are clean. + +**Which is wrong:** the CLI, I think. Corner-sampled nearest is a half- +destination-pixel bias toward the top-left that no rasteriser produces, and the +Xenon GPU that drew this screen sampled at pixel centres. But I have no +framebuffer capture of `title_jp` and the disagreement is sub-pixel on one glow, +so this is a reading, not a measurement — recorded in `docs/BLOCKED.md` rather +than acted on. **The port is not changing to match**, because matching the CLI +here would mean deliberately reproducing a half-pixel offset in order to make a +number smaller. + +### `extras`: two pixels + +Two pixels at 4/255. Rounding. + +### What the diff cannot tell us + +The pivot question in `docs/BLOCKED.md` predicted that a P1 diff could not +distinguish "anchor scale to the declared pivot" from "anchor to half the +texture", because both renderers use the declared pivot. That prediction held: +the port and the CLI agree on every scaled element, and that agreement is **not +evidence** about which anchor the game uses. It stays open. + +### ~~`pteff05.t32` and `pteff04.t32` have no sprite, and that is correct~~ + +**RETRACTED 2026-08-29. This was wrong, and it was the most consequential thing +on this page.** See "The menu had no background" below. + +--- + +## P2 — keyframe animation, 2026-08-28 + +### The time unit is authored, in one file, and says loudly that it is not on the disc + +`authored/timing.json`. HANDOFF Q1 is answered — linear ramp, 2 units per +rendered frame, 1 unit = 1/60 s — but that conversion is **measured off the +running game**, not read from a file, which is exactly the case the +derived/authored split exists for. It is expressed as +`keyframe_units_per_second: 60` rather than seconds-per-unit so the value is +exact instead of a repeating decimal, and it carries the two independent lines +that support it. `t` stays raw everywhere in `export/`; seconds appear only +where this file is applied, which is one line of `boot.gd`. + +`exit_ramp_seconds` is deliberately **null**. See below. + +### The timeline stops at the last *timed* keyframe, and never plays the exit + +The last keyframe of every group carries **no `t`** — the disc has no time slot +there. Across this export that final frame is an *exit* pose: for 116 of 134 +elements it differs from the last timed keyframe **in alpha only** (a fade-out), +for 12 it is the loading splash's scale-and-slide exit, and for 6 it is +identical (no exit animation at all). + +So the group is `pre-roll → ramp in → hold → [exit]`, and the port plays it up to +the hold and stops. Playing into the exit would mean **inventing how long the +ramp takes**, because the disc does not say. That duration is the screen +transition — HANDOFF Q7 measured it at ~0.4 s — and it belongs to P3, with its +own evidence. This is why `exit_ramp_seconds` is null rather than 0.4: P2 has no +business holding it. + +### The interpolation is checked by where it lands, not by inspection + +For **8 of the 12** screens the settled timeline is **byte-identical** to the +`--pose=rest` render. That is the useful assertion: the port walks the keyframes +with an authored time unit and arrives, to the pixel, at the pose the pinned +decoders independently identify as the resting one. `tools/screen-strip` reports +this per screen, so a change to the interpolation that drifts by one unit shows +up as a diff rather than as nothing. + +The four that differ do so for two distinct reasons, below. + +## `rest` misidentifies six elements, and the running game says so + +On `main_menu`, the settled timeline and `rest` differ in exactly one region: +**400×470 at (440,108)** — the bounding box of `ptframe1` and `ptframe2`, and +nothing else on the screen. + +`rest` puts both at their **first** keyframe: off-position and fully +transparent. The keyframes say they slide (620,108)→(440,108) and (403,267)→ +(583,267) while fading 0x00→0xff, and then hold that pose for their last three +keyframes including the untimed one. + +`/reborn/docs/re/captures/main-menu-oracle.png`, a capture of the running game, +**shows them**: the bright circuit-frame bracket around the menu, with a ring at +the bottom right. Cropping the same 250×180 region from the capture and from +both renders puts the ring and its elbow trace in the port's timeline render +**pixel-aligned with the game's**, and absent from the `rest` render. That is +geometry, not luminance, so it does not depend on the capture's gamma or on the +fact that it was taken with `NEW GAME` focused. + +### Why the decoders get it wrong, precisely + +`ui_layout::rest_plateau` excludes a run of identical keyframes that **ends the +group**, because that run is normally the exit — the comment cites the pause +menu, where taking the trailing run erased the word PAUSE. That exclusion is +right in general and wrong for an element with **no exit animation**, where the +trailing run *is* the hold. The rule then falls back to an earlier run, which +for a slide-in is the invisible pre-roll. + +The condition that identifies the affected elements exactly, with no false +positives in this export, is: + +> the final untimed keyframe has the **same pose** as the last timed keyframe + +Six elements match it and `rest` misses all six: `ptframe1`/`ptframe2` on +`main_menu` and `main_menu_jp`, and `pteff02` on `title` and `title_jp`. This is +a **finding for the RE agent** about `sylpheed-formats`, not something this port +fixes: the decoders are pinned and must not be reimplemented here. The port +simply does not use `rest` — it derives the arrived pose from the keyframes, +which needs no heuristic — and `verify-screen` still asks for `--pose=rest` so +that renderer-vs-renderer diffing compares like with like. + +Note what this says about P1: the port and the reference renderer **agreed** on +`main_menu` to 3/255, and both were missing two elements the game draws. Two +renderers reading the same field through the same decoder agreeing is not +evidence that the field is right. `docs/BLOCKED.md` had already said that about +the pivot; here it bit for real. + +## The title is not settled, and P2 does not claim it + +`title` and `title_jp` differ between the two modes by much more (max 142 and +247), and there the disagreement is **not** the six-element bug alone. `rest` +picks a mid-timeline hold for several glows (`pteff01`, `ptlogoall_eff`, +`ptlogoall_eff2`, `ptlogo_back2eff5`) where the timeline runs on to a much +brighter pose. + +I could not settle which is right, and did not try to make the numbers agree: + +* No element's alpha ever reverses direction anywhere in this export, so the + title's 4.48 s timeline is a slow one-way ramp, not a pulse — which removes the + obvious reason to expect a loop, but does not prove there is none. +* The only live title capture composites the **`PRESS Ⓐ` plate (build 2) over + the title (build 4)**, so it cannot be diffed against build 4 alone. Mean + luminance is oracle 64.1, `rest` 62.8, timeline 80.0 — which looks like it + favours `rest`, except that the plate *adds* brightness and `rest` is carrying + a 25 % black dim quad (`pteff02`) that is itself one of the six misidentified + elements. The comparison is confounded in both directions and settles nothing. +* **Both modes are visibly wrong anyway.** Side by side with the capture, the + port draws a washed-out cyan glow slab across the logo that the running game + does not have — in `rest` mode too. That is a third problem, independent of + this one, and it is P3's. + +So: the timeline is the default because it is derived from the disc's own +keyframes with one measured constant and no heuristic, and because it is proven +right on the screen this milestone gates. On the title it is **unverified**, and +P3 should not assume P2 settled it. + +--- + +## P2, corrected — the pin moved, and the settle rule was wrong, 2026-08-28 + +### Answering the RE agent's question: which six, and on what screens + +They asked, having found only two elements on the English main menu satisfying +the condition this port proposed. The six span the whole 12-screen export: + +| element | screens | trailing run | +|---|---|---| +| `ptframe1`, `ptframe2` | `main_menu`, `main_menu_jp` | alpha `0xff` — **visible** | +| `pteff02` | `title`, `title_jp` | alpha `0x00` — **transparent** | + +So four of the six are the pair they already found, once per language build, and +their alpha rule accepts exactly those. The other two are `pteff02`, whose +trailing run is transparent, so their rule **excludes** it and leaves `rest` at +`0x40`. + +**That exclusion is right, and their own measurement proves it.** `pteff02` is +the 25 % dim quad; they measured the title render going from **+13.14 to +0.55** +against the plate-free capture once the dim is drawn. `rest` must therefore stay +at `0x40` and must *not* move to the transparent trailing run — which is what +their rule does. Two investigations converging from opposite directions. + +The condition this port proposed was **too loose**; the alpha discriminator is +the correct rule and the port has no amendment to offer. + +### The pin moved 8b6dbcf → 5414db3 + +Its own commit, and what I wanted from it is the fixed `ui_layout::rest()`. +Pinned at `5414db3` rather than `4bc9706` where the fix was written, because +`5414db3` is where it carries its disc-wide check — 30 of 13 991 elements move, +4 become visible, **0 become invisible**. + +The re-export is the evidence the change was contained: **two files changed, and +within them exactly four `rest` blocks** — `ptframe1`/`ptframe2` on both main +menus moving from `(620,108)/(403,267)` at `t=16` and alpha `0x00` to +`(440,108)/(583,267)` at `t=62` and alpha `0xff`. Every diff line pairs; the +other ten screens are byte-identical, `pteff02` did not move, and no sprite +changed. + +### The settle rule was wrong, and their title finding is what showed it + +P2 shipped "hold the last **timed** keyframe", on the reasoning that the exit is +the final untimed frame. **That is wrong**, and the title is the counter-example: +`pteff02` holds at `t=46` with the dim at alpha `0x40` and then ramps to `0x00` +by `t=236`. The exit is not only the untimed frame — it can be a long run of +timed ones. Running to the end drops the dim and makes the whole screen ~13/255 +too bright, which is exactly the luminance excess P2 recorded (oracle 64.1, +`rest` 62.8, timeline 80.0) and could not explain. + +A group is `pre-roll → ramp in → hold → ramp out → post-roll`, and a screen that +has arrived sits on **the hold**. So the timeline now plays in and stops at +`rest`, which is the decoders' identification of that hold and carries its own +`t`. `settle_units()` is `rest.t`. + +The check is that the disagreement vanishes: on **all twelve** screens the +settled timeline is now byte-identical to the `--pose=rest` render, where before +this change four of them differed by up to 247/255. The timeline's endpoint +*should* be `rest` — the animation is what the timeline adds, not a different +destination — so this is the property to want, and it now holds without a +special case. + +That also retires P2's open question about looping, from the other side: the RE +agent measured that groups hold rather than loop (`ptloop01`/`ptloop02` park +off-screen at x=1521 and x=−839; 18 s of settled title sits at sd ≤ 0.01). + +## The reference renderer was stale for three diff runs + +Worth recording as a process failure, because it defeated the project's whole +verification method for a while and it failed *silently*. + +After bumping the pin I rebuilt the reference CLI, and `build-reference-cli` +reported success at rev `5414db3`. `verify-screen` then showed `main_menu` +jumping from 3/255 to **72/255**. The natural reading — the port had regressed — +was wrong. The port was right and **the reference was a revision behind**: the +shared `CARGO_TARGET_DIR` still held a `sylpheed-cli` built from `8b6dbcf`, and +cargo reported `Finished in 0.13s` and left it in place. Building into a clean +target directory produced a binary that resolves `ptframe1` to `(440,108) t=62`; +the shared one still said `(620,108) t=16`. + +The old check — "does `screen list` run?" — cannot catch this, because a stale +binary runs perfectly. + +Two changes: + +* `build-reference-cli` builds into `$CARGO_TARGET_DIR/reference-cli/$rev`, a + tree **keyed by the pinned revision**, so a new pin has no artifacts to reuse. + A stable copy is placed alongside for consumers. +* It then checks the binary **against `export/`**: both come from the same pin, + so if the CLI resolves `ptframe1`'s rest differently from what the exporter + wrote, the two halves of the verification are not the same revision and it + fails loudly. It compares the two rather than asserting a literal, so it stays + true when the pin moves again. + +`docker/bin/` is baked into the image, so this takes effect on the next image +build; until then the repo copy has to be invoked by path. The RE agent hit the +same class of trap this session from the other side (`./target/debug` stale +against a redirected `CARGO_TARGET_DIR`). It is worth naming the general shape: +**a build system reporting success is not evidence that the artifact you are +about to trust is the code you pinned.** + +### What this did not change + +`title` (6/255), `extras` (4/255) and `title_jp` (154/255) are unchanged, and +their diagnoses stand — a paint-order tie, two pixels, and nearest-neighbour +sampling phase at 125 % scale. The title's swoosh defect the RE agent localised +(drawn thick and white where the game draws it thin and pink) is untouched by +any of this and remains P3's. + +--- + +## The menu had no background, and P1 called that correct, 2026-08-29 + +The pin moved `5414db3 → f817dd5` for `56cc7ac`, "a RATC child's name is stated, +not inferred". `ratc::parse` had named each child by scanning backwards for the +last printable run of bytes before its magic. For `pteff05.t32` the three +trailing payload bytes are `38 41 58` — `8AX` — which beat the real name, so the +child registered under a name no element declares and resolved to no sprite. + +`pteff05.t32` is the **full-resolution background of all five menu screens**. + +So every render this port has produced of a menu screen has been missing its +background, and P1 wrote that up as a property of the disc: *"the bundle declares +them and carries zero RATC children for either, so there is no texture on the +disc to export."* That sentence was false. The bundle carries the child; the +decoder was handing back the wrong name for it. Retracted above rather than +edited away. + +### What the re-export shows + +Six new sprites and nothing else: `pteff05.png` on `main_menu`, `extras` and +their Japanese twins, `pteff04.png` on both titles. Per screen the JSON gains a +`sprite` line and `layer_source` moves `"implied" → "sprite"` — the layer key is +now **read from the file** instead of taken from the decoders' table of keys +measured off the running game. That is the derived/authored ratchet turning the +right way, in the exporter rather than in `authored/`. + +`pteff05.png` is **1280×720**; `ptbase.png`, which had been carrying the +background alone, is 640×360 drawn at 200 %. The screen was being shown its own +art at half resolution. + +### Measured against the live capture, not against the other renderer + +Whole-frame RMSE of the settled `main_menu` against +`captures/main-menu-oracle.png`: + +| | RMSE | +|---|---| +| before this pin | 8.05 % | +| with the real background | **5.92 %** | + +A 26 % reduction, and it is the right kind of evidence: the reference renderer +was missing the same element for the same reason, so a renderer-vs-renderer diff +could not have found this. It is the third time on this project that the +capture caught something both renderers agreed on — the bracket, the title dim +quad, and now the background. + +`verify-screen` after the bump is unchanged in character: everything at 3–4/255 +except `title` (6, the paint-order tie) and `title_jp` (155, the sampling phase). +Both renderers gained the background together. + +### One thing the comparison says that I did not expect + +Rendering with `--focus=ptbtn01`, which is how the capture was taken, makes the +RMSE **worse** — 5.92 % → 7.00 %. The port *replaces* an element's sprite with +its `*f` twin; `sylpheed-cli`'s own `--focus` is documented as drawing the +focused record **over** the base element. Those are different operations, and +the capture shows a ring marker beside `NEW GAME` that the port does not draw. + +This is P5's, not P2's, and it is not being guessed at here. Raised in +`docs/BLOCKED.md`. + +--- + +## P3 — splash → title, unattended, 2026-08-29 + +### The splash is located by entry index, because no rule can find it + +The RE agent looked for a content predicate and there is none: design size fails +(every extra composable bundle sampled is 1280×720, the same as every screen) and +element count fails (fragments run 2…15 elements in `GP_OPTIONS`/`GP_SAVE_LOAD` +while the splash halves are 3 and 7 — the ranges overlap). + +So `screen_builds` is now `is_build` **plus an authored allow-list of entry +indices**, in `authored/screen_names.json` under `also_export`, each with a `why` +that says it is a locator and not a claim. This is safe in `GP_TITLE` and would +not be in general: there, widening adds exactly four bundles and all four are +real screens with zero fragments. That is why it is an allow-list rather than a +loosened predicate. + +**There were two splash screens and the port had neither.** Entries 11/14 are the +developer logos (GAME ARTS / SETA / studio anima); entries **10/13 are the SQUARE +ENIX publisher wordmark, the first thing the boot shows**, and nothing in this +project had noticed them. Both pairs are region twins — ™ on 10, ® on 13 — and +the port shows one of each, not both. + +### `authored/screen_names.json` is now keyed by pak entry, not by ordinal + +Widening the enumeration renumbers the ordinals, and a name that moves when the +enumeration rule changes is not a name. The file had always called the entry +"the stronger locator"; it is now the only stable one. In `GP_TITLE` the two +coincide across all 16 entries, which is also the numbering `sylpheed-cli screen +--build N --all` takes — so `verify-screen` now passes `--all`, and without it +`--build 10` would have landed on entry 12. + +The two previously-unnamed plates therefore renamed `build_10`/`build_11` → +`build_12`/`build_15`. Their names were always locators; now they locate the +right thing. + +### The exit is the group playing itself out, not a black rect over a freeze + +HANDOFF's answer to ask 2 was (a), and it came with a test that discriminates +rather than a plausibility argument. Under "a black quad over a frozen screen" +every region is scaled by the same 1−α, so the button-region / background-region +brightness **ratio** stays constant through the fade. Measured, it falls +6.495 → 5.574 → 3.105 → 2.125 → 1.935 — a 3.4× monotonic drop. The screen plays +out: `pteff00.prm` ramps to opaque black while the labels, `ptmsg`, `pteff10` +and `pteff12` ramp to transparent, and `ptframe1`/`ptframe2` hold. + +Implemented by giving the final untimed keyframe a **synthetic time**, +`exit_ramp_units` after the last timed one, and then interpolating it like any +other. One code path: the difference between arriving and leaving is only how far +`t` is allowed to run, not a second kind of animation. + +`exit_ramp_units = 24` (~0.4 s) is authored, and `authored/timing.json` carries +the RE agent's own reach caveat rather than smoothing it: the filmstrip is +downsampled and the button region contains some background, so this pins the +**direction**, not 0.4 s to ±0.05 s, and it is one transition pair. + +### Nothing waits on a timer the disc does not carry + +`dwell` in `authored/flow.json` is deliberately empty. Each screen's dwell is its +own keyframe group — the publisher wordmark reaches its hold at t=235 (3.92 s), +the developer logos at t=190 (3.17 s), both read from the disc. Adding a hold on +top would be inventing a number nobody measured. The pacing you see is the +disc's own, and the file says where a measured number would go. + +### The last screen holds + +A screen plays itself out because something is taking its place. Nothing takes +the title's place yet, so the sequencer holds there. A boot that ends by fading +to black is a boot that looks like it crashed. P4 puts the intro video in front +of the title and P5 gives the title somewhere to go. + +### `flow.json` reproduces an observation and says so + +Q6 closed with a negative: the order is in none of the four places it could have +been, and a transition is a call with a name argument chosen by code. So this +file is authored and its header says plainly that it reproduces what was watched, +not what any file states. The intro video's place in the real boot is **named as +a gap** rather than the order being quietly rewritten to hide it. + +## P3 gate + +`godot --path port -- --boot --film=/tmp/boot` runs unattended: + +``` +publisher_logo → developer_logos at 4.65 s → title at 8.57 s +boot sequence complete after 13.05 s, holding on title +``` + +The filmstrip shows each screen fading in, holding, and fading through black into +the next, and the title staying up. `verify-screen` covers all **16** screens +now; the four new splash bundles come in at max 1–2/255 against the reference +renderer. The three known differences are unchanged: `title` 6 (paint-order tie), +`main_menu` 4, `title_jp` 155 (sampling phase at 125 % scale). + +## Answers taken from the RE agent without re-deriving them + +* **Focus stays "replace".** Over-vs-instead is unobservable: the focused sprite + covers the base at 100 % of base-visible pixels, and the two compositions + differ by RMSE 1.1 inside the button rect — under the gamma floor. The port's + guess was right for the wrong reason, and the actual gap is that + `ptbtn0Nf.rat` declares **two** sprites — `ptbtneff01.t32`, a glowing ring, and + then the bright label — where `ptbtn0N.rat` declares one. The ring is P5's, and + its placement inside the record is **not decoded**, so it will be authored from + the capture and marked as such. +* **RMSE against captures has a floor, so stop chasing it.** The capture is + `≈ 255·(render/255)^γ` with γ ≈ 1.49 on the menu and `EXTRAS`, 1.34 on the + title, and it is a ramp *the game installed* (`VdGetCurrentDisplayGamma` at + video init), not a capture-path artefact to subtract. Its reach is narrow — + the flat patches it was fitted on are almost all dark — so the port will not + extrapolate it across the range, and will not apply it to rendered output on + this evidence. It is a comparison constant, not a rendering one. +* **Rotation is escalated to a human and the port has not acted.** The RE half is + answered — rotate about the **declared pivot**, measured against the GPU + capture — and it has zero effect on the five screens at rest. The port will + carry `rotation_deg` in a future FORMAT v3 because carrying a decoded field the + renderer ignores beats dropping it, but it will not draw it until the + divergence question is settled. + +--- + +## P4 — the intro video, 2026-08-29 + +### Theora at 720p is fine here, and no runtime dependency is requested + +MISSION §6 anticipated that Theora might be too poor at 720p and permitted the +FFmpeg-GDExtension fallback to be **proposed**. It is not needed, and this was +measured rather than judged by eye alone. SSIM against the decoded source over a +10 s sample: **0.9863 at `-q:v 6`, 0.9896 at 8, 0.9924 at 10**. At 200 % zoom on +the reel's hardest case — fine serif text and soft gradients over near-black, +where Theora breaks first — q8 is indistinguishable from the source. + +`-q:v 8`, and **no GDExtension is being proposed or adopted**. + +`-ac 2` because the source is **6-channel** WMA Pro and Godot's Theora path is +not a surround one. That downmix is a decision, so it lives in the recorded +command where a modder can see and change it rather than in prose. + +### The exact command is in the manifest, per MISSION §6 + +`export/manifest.json` gains a `videos` array, each entry carrying the verbatim +`ffmpeg` line that produced it. A modder who dislikes the quality re-runs one +line instead of reverse-engineering what was done to their video — which is the +whole reason this project converts the disc rather than reading it at runtime. + +### A cache, and why that is not a hand-edit + +`export/` is regenerated wholesale, but re-encoding 232 s of video on every run +costs ~4 minutes to produce a byte-identical file, and an exporter nobody re-runs +is worse than a cache. So each movie gets a `.cmd` sidecar recording the command +and the source size, and the encode is skipped only when both match exactly. Any +change to either re-encodes. This is derived state validating derived state, not +a hand-edit. + +### The player renders into the design viewport, not beside it + +First attempt parented the `VideoStreamPlayer` to the Boot node. It played, and +every captured frame was **black**: the capture reads the SubViewport, and the +player was rendering to the window. Worth stating as more than a capture bug — +everything this port draws composes in the export's own 1280×720 design space, +and a movie outside that space is outside the coordinate system every screen is +expressed in. + +### Ⓐ skips, because Q9 measured it + +The only input the port handles so far. HANDOFF Q9: one Ⓐ press skips a movie, +measured — the title was reached at 57 s against a 193 s baseline. Menu +navigation is still P5. + +## P4 gate + +`godot --path port -- --boot --film=…` runs +`publisher_logo → developer_logos → ADV.ogv → title`, unattended. The filmstrip +shows the SQUARE ENIX ident, then the reel's live-action-styled CG, then the +title. The movie's place in the boot is **measured, not decoded** — Q9 decodes +`ADVERTISE_MOVIE → ADV.wmv` from the movie manifest, but *where it sits in the +boot order* is what the RE agent watched, and `authored/flow.json` says so. + +### What I cannot verify from here + +**Audible playback.** This container has no audio device — Godot falls back to +the dummy driver. What is verified is that the Vorbis stream exists in the +transcode, is 2-channel, and decodes. Whether Godot emits it audibly is +unconfirmed and is stated as unconfirmed rather than assumed from the stream's +presence. It is a cheap check for anyone with a sound device and an impossible +one here. + +--- + +## RETRACTION — `sylpheed-cli` is not the oracle, 2026-08-29 + +**This corrects a framing that runs through everything above, so it is a +retraction rather than an edit.** Every place this file called +`sylpheed-cli screen render` *"the reference renderer"* — and it does so +repeatedly, starting at P1 — overstated what it is. + +The correction comes from the human, via the RE agent, in their words: Reborn +"was/is just a GUI explorer and extraction CLI for verifying the decoding of the +various files. It may very well be wrong." **The oracle is the Xenia Canary +capture and the game.** + +So `tools/verify-screen` is a **consistency check between two decoders that +share their assumptions**, and a regression detector. It is not a correctness +check, and agreement in it is not evidence of correctness. + +### The embarrassing part is that this file already knew + +After the `ptframe1` case, P2's write-up says: *"Two renderers reading one field +through one decoder agreeing is not evidence that the field is right."* Then P1's +numbers kept being quoted as though 3/255 against `sylpheed-cli` meant the port +was right. Having the principle written down did not stop me leaning on the +agreement — which is worth recording, because that is the failure mode, not +ignorance of the principle. + +**Three times** both renderers agreed and both were wrong, all three caught by a +capture and catchable by nothing else: + +| | what both got wrong | how it surfaced | +|---|---|---| +| `pteff05` | the menu screens had **no background** | the RE agent decoded the RATC child name | +| scale 0 | drawn at full size instead of collapsed | RE agent's control run | +| `rest()` | `ptframe1`/`ptframe2` invisible; the menu bracket missing | `main-menu-oracle.png` | + +### What changes + +* `tools/verify-screen` says all of this in its own header, calls the CLI the + **comparison** renderer, and a `DIFFERS` row now means "we moved apart, find + out which of us moved" rather than "the port is wrong". +* The correctness question moves to the captures. The RE agent has committed + nine of them with an index at `docs/re/captures/ORACLE-CAPTURES.md`, covering + all five screens in scope — including a **main menu with `OPTIONS` focused**, + whose difference from the unfocused menu isolates exactly what focus changes. +* Three cautions travel with any capture comparison, and they are the RE agent's: + the captures are **not gamma-neutral** (γ ≈ 1.49 menu, 1.34 title — there is a + floor, do not chase it); **geometry is sound** (best alignment 0,0 at corr + 0.9466, so a positional disagreement is real); and each is **one moment of a + still-animating screen**, so compare settled poses or regions known to be at + rest. + +### What does not change + +The port keeps running `verify-screen` over all 16 screens every iteration. A +consistency check is still worth having — it is total, it is cheap, and it is +what catches a divergence the RE agent introduces on their side. It is simply +not a grade, and this file will stop quoting it as one. diff --git a/godot-import/docs/FORMAT.md b/godot-import/docs/FORMAT.md new file mode 100644 index 00000000..7f9184a3 --- /dev/null +++ b/godot-import/docs/FORMAT.md @@ -0,0 +1,347 @@ +# The open export format — v3 + +The format the disc is converted *into*, and the one the Godot project and any +modding tool read. **It is versioned, so a change is a deliberate act with a +version bump**, not a silent edit. [Changes from v2](#changes-from-v2) and +[Changes from v1](#changes-from-v1) are at the bottom, with a reason for each. + +Design rules, in priority order: + +1. **A human can read and edit it.** Modding is a goal of this port, which makes + the layout part of the product rather than a temp directory. +2. **Names, never hashes.** Where the disc's own name was never recovered — the + six `*2D` archives and `GP_READY_ROOM` — emit a stable synthetic id **and say + in the file that the real name is unknown**. A modder must be able to tell a + recovered name from an invented one. +3. **Provenance travels with the data.** Source archive, entry index, exporter + version, decoder revision. This is what keeps the export auditable against the + disc instead of drifting into an unverifiable fork. +4. **Say what is unknown.** A field we could not decode is absent and listed in + `unresolved` — never guessed, never silently defaulted. + +**JSON, not XML.** Godot parses JSON natively with `JSON.parse_string`; its +`XMLParser` is a SAX-style API that would need a hand-written binding per schema. + +**The format is executable.** `sylpheed-export check --out export` validates a +tree against this document with no disc in hand, reading it the way Godot will — +as a stranger. Where the prose here and `crates/sylpheed-export/src/check.rs` +disagree, that is a bug in one of them and worth saying which. + +## Layout + +``` +export/ # DERIVED. Regenerable. Gitignored. Never hand-edited. + manifest.json + screens/title/*.json + sprites/title//*.png + audio/music/*.ogg audio/sfx/*.ogg audio/cues.json + video/*.ogv +authored/ # AUTHORED. Hand-written. Committed. Survives re-export. + screen_names.json # which build is which screen + flow.json # boot sequence + what each button does + cue_bindings.json # which cue fires on move / confirm / back +``` + +Sprites are **per screen**, not a flat pool: a sprite name is unique within a +bundle and not across them, and `main_menu`'s `ptbase.t32` and `extras`' +`ptbase.t32` are different pictures. + +`authored/screen_names.json` is the one authored file the *exporter* reads; the +rest are applied by the runtime over `export/`. + +## Common header + +```json +{ + "format": "sylpheed.screen/3", + "exporter": "sylpheed-export 0.1.0", + "formats_rev": "8b6dbcf", + "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 } +} +``` + +`source.entry` is the pak **entry index** — the stable locator. `source.build` is +the index into that pak's list of screen builds (what `sylpheed-cli screen +--build N` takes), which is stable only as long as the enumeration rule is. +`formats_rev` pins which decoders produced the file. + +## `screens/*.json` + +```json +{ + "format": "sylpheed.screen/3", + "exporter": "sylpheed-export 0.1.0", + "formats_rev": "8b6dbcf", + "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }, + "name": "main_menu", + "name_source": "authored", + "name_why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English…", + "design": [1280, 720], + "elements": [ + { + "index": 10, + "id": "ptbtn01", + "declared": "ptbtn01.rat", + "role": "button", + "kind_raw": "0x3002", + "sprite": "sprites/title/main_menu/ptbtn01.png", + "focus_sprite": "sprites/title/main_menu/ptbtn01f.png", + "opt_link": "ptbtn01f.rat", + "pivot": [42, 22], + "layer_source": "sprite", + "layer": "0x00008110", + "rest": { "pos": [542, 162], "scale": [100, 100], + "tint_rgba": "0xffffffff", "fade_argb": "0xffffffff", "t": 64 }, + "keyframes": [ + { "t": 28, "pos": [542, 142], "scale": [100, 100], + "tint_rgba": "0xffffffff", "fade_argb": "0x00ffffff" } + ] + } + ], + "paint_order": [1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0], + "buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"], + "unresolved": ["keyframe_time_unit", "paint_order_ties", "fade_out_duration"] +} +``` + +### `name` / `name_source` / `name_why` + +`name_source` is `"authored"` or `"index"` and nothing else. `"authored"` means +the name came from `authored/screen_names.json` and **requires** a `name_why` +saying who decided it and on what evidence. `"index"` means nobody has +identified this build and the name is `build_NN` — a locator, not a claim. + +### `elements[]` + +`index` is the declaration index and is also the key `paint_order` uses; it +always equals the element's position in the array. `id` is `declared` with its +extension stripped. + +**`role`** comes from the decoded element kind: `0x3002` → `button`, `0x10` +without a sprite → `primitive`, `0x0` → `decoration`. Anything else is +`"unknown"` with the raw value in `kind_raw`. Do not invent a name for a kind +nobody has decoded. + +> ⚠️ `0x3002` is **not** a general button test. It is one member of a `0x3000` +> family with sub-bits, and `GP_READY_ROOM` uses `0x3000` / `0x3004` / `0x300c` / +> `0x3008` with zero `0x3002`. Every screen in this milestone is `GP_TITLE`, +> where the mapping is decoded. A consumer meeting `role: "unknown"` should read +> `kind_raw`, not assume. + +> ⚠️ **`kind & 0x4` is a repeated instance of a template.** On the title screen +> those are motion-trail ghosts and are *not* on screen at rest — the draw +> capture shows one quad where the bundle declares three. A runtime should skip a +> `kind & 0x4` element **when another element in the same screen has the same +> `id` and does not have that bit**, and only then: 174 elements on the disc are +> `0x4` with no such template, and a blanket skip erases them. Both are visible +> in this format from `kind_raw` and `id`. + +**`pivot`** is the declared pivot, and it is the **anchor scale grows about** — +`pos` is the element's top-left at 1:1, and at scale `s` the drawn top-left is +`pos − pivot·(s−1)`. At 100 % the pivot cancels, which is why it went unnoticed +for a long time. + +> 🟡 The decoders document the pivot as "exactly half the decoded texture's +> dimensions (verified 7/7 on the tutorial bundle)". **That does not hold on +> `GP_TITLE`**: 38 of its 93 sprite-bearing `.t32` elements disagree, some +> grossly (`ptlogo_back2`, 1118×262, pivot 500,117 where half is 559,131). It is +> not a problem for this port — the exporter emits the declared pivot and never +> derives one — but it is a claim a consumer should not lean on. Raised in +> `docs/BLOCKED.md`. + +**`sprite`** / **`focus_sprite`** are paths relative to `export/`. The highlight +pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which is 🟡 a +naming convention that holds for all 54 real pairs on the disc, not a decoded +field. + +**`focus`** is the focused state, and it **supersedes `focus_sprite`**. A +focused button is not a sprite swap: `ptbtn0Nf.rat` is a nested `.rat` **leaf** +declaring *two* elements — the spinning ring `ptbtneff01.t32` and the bright +label — and **the parent bundle declares no element for the record at all**, so +the leaf is the only source of placement for both and there is nothing for it to +inherit. + +```json +"focus": { + "record": "ptbtn04f.rat", + "elements": [ + { "id": "ptbtneff01", "sprite": "…/ptbtneff01.png", "pivot": [21, 23], + "rest": { "pos": [500, 396], "rotation_deg": 0, "t": 120, … }, + "keyframes": [ { "t": 120, "rotation_deg": 0, … }, + { "rotation_deg": 360, … } ] }, + { "id": "ptbtn04f", "sprite": "…/ptbtn04f.png", "rest": { "pos": [535, 395], … } } + ] +} +``` + +Elements are back-to-front in the leaf's own declaration order — ring first, +then label. Positions are **absolute design-space top-left**, not offsets from +the button. + +> The label's `(−7, −7)` against its base is load-bearing, not noise: +> `ptbtn0Nf.t32` is 13 px larger per axis, and −7 keeps the two **concentric** +> (535 + 96/2 = 583 against 542 + 83/2 = 583.5). Drawing the highlight at the +> base position pushes it 7 px down-right and off-centre. + +> ⚠️ **Leaf placement is authoritative for an `f` record and NOT for a base +> record.** A base record's leaf *duplicates* its parent's placement and the two +> can disagree by a unit (`ptbtn04`: parent y=401, leaf y=402) — there the parent +> wins. The `f` record is the case where the parent declares nothing. + +> ❔ **The ring spins, and its period is unresolved.** Its two keyframes differ +> in `rotation_deg` alone, 0 → 360. But the second is untimed, and what an +> untimed keyframe means *inside a leaf* — as opposed to at screen level, where +> it is the exit ramp — is untested. A consumer should draw the resting angle +> rather than invent a spin rate. This is listed in `unresolved`. + +**`opt_link`** is the raw `opt ` link inside the element's `.rat` record, carried +through unresolved. ⚠️ **It is not a focus link.** That reading was measured and +refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two +decorations and into a button. It is exported so whoever decodes it has it, and +named so nothing downstream mistakes it for navigation. + +**`layer` / `layer_source`** are the paint-order key. `"sprite"` means it was +read from the `u16` at `+0x0A` of the element's `T8aD` header — a decoded disc +field. `"implied"` means the element carries no header and the key came from the +decoders' table of keys **measured off the running game**. `"none"` means neither +is known, and the element sorts last. A consumer that needs to know whether a +layer is a fact or a measurement reads `layer_source`. + +**`size`** appears only on a `primitive`, which has no texture to take a size +from: the quad is `pivot × 2`, and its colour is the keyframe's `fade_argb`. + +**`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the +**start of a ramp toward the next**, not a pose that is held, and the ramp is +linear. The **last keyframe of a group has no `t`** — the disc has no time slot +there — and a file that puts one on it is wrong, not merely odd. The unit of `t` +is measured, not on the disc, and so lives in `authored/` and is applied in +exactly one place. + +**`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive, +decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by +the RE agent on different screens and different elements: the title's `ptloop` +sweeps declare +30 / −45 and a GPU capture submits their quads at +30.26 / +−45.28, and the main menu's focus ring ramps 0 → 360 with position, scale, alpha +and tint all constant — a capture caught it mid-spin. + +Rotation is **about the declared pivot**, which is measured rather than assumed: +the `ptloop` sweeps scale 600 %/800 % vertically, where the pivot term is worth +450 and 630 px, and the capture puts both quad centres at y 359.1/360.0 against +the pivot formula's 360.0 (top-left predicts 810/990, centre-as-position +predicts 270). + +> ⚠️ `sylpheed-cli screen render` does **not** draw rotation yet — its `blit` is +> axis-aligned. A rotation disagreement between it and a consumer that does draw +> rotation means the CLI is behind, not that the consumer is wrong. + +**Two colours multiply.** `tint_rgba` is RGBA and is `0xffffffff` on essentially +every keyframe; `fade_argb` is **ARGB**, and its high byte is the alpha that ramps +during a fade. The byte order is in the key name because getting it backwards is +silent and looks like an art bug. The drawn modulate is their per-channel product. + +**`rest`** is the resting pose: **the hold** — the longest run of consecutive +keyframes with an identical pose that does not end the group. Neither the first +nor the last keyframe, and not the longest-dwell frame either: a long gap after +keyframe *k* means the screen spends that time *arriving at* `k+1`. + +> ⚠️ **`rest` is a heuristic over the keyframes, and it misfires.** The rule +> excludes a run that ends the group, because that run is usually the exit. On +> an element with **no exit animation** the trailing run *is* the hold, and the +> rule then falls back to an earlier run — usually the invisible pre-roll. Six +> elements in this export are affected, and the condition that identifies them +> exactly is *"the final untimed keyframe has the same pose as the last timed +> one"*: `ptframe1`/`ptframe2` on both main menus, and `pteff02` on both titles. +> A live capture of the running main menu shows `ptframe1`/`ptframe2` on screen; +> `rest` says they are invisible. +> +> A consumer that wants the pose after arrival should therefore take **the last +> timed keyframe**, not `rest`. `rest` is kept in the format because it is what +> the pinned decoders say and removing it would hide the disagreement — see +> `docs/DECISIONS.md`. The format is unchanged at **v2**: no field changed +> meaning, this is a warning about one of them. + +**`paint_order`** is back-to-front, as declaration indices, and is a permutation +of them. It is the stable sort by `layer`. See `unresolved: paint_order_ties`. + +**`buttons`** is navigation order: `button`-role elements sorted by resting Y. +This is **geometric, not a decoded neighbour graph** — the disc's real navigation +structure is unknown. It is right for a vertical menu and should not be trusted +for anything else. + +**`unresolved`** lists what this file does not answer; a consumer needing one of +those must get it from `authored/`. An empty list is a claim that nothing is +missing; an absent list is a gap, and `check` rejects it. + +## `authored/screen_names.json` + +Which build is which screen, keyed by archive and build index, each with a +`why`. The exporter reads this and stamps `name` / `name_source` / `name_why` +into the screen file. A build with no entry exports as `build_NN`. + +## `authored/flow.json` + +```json +{ + "format": "sylpheed.flow/1", + "boot": ["splash_developer", "intro_video", "title", "main_menu"], + "screens": { + "main_menu": { + "actions": { + "ptbtn01": { "label": "NEW GAME", "goto": "new_game_intro", + "why": "label read off the sprite; target is a placeholder for HANDOFF Q4" } + } + } + } +} +``` + +`goto` may name an exported screen or a **GamePart id** from the executable's own +table (29 entries at `.rdata 0x820A1630` — that table is a disc fact; which button +reaches which entry is Q4 and is not). + +## `export/manifest.json` + +```json +{ + "format": "sylpheed.manifest/1", + "exporter": "sylpheed-export 0.1.0", + "formats_rev": "8b6dbcf", + "disc": "/disc", + "screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json", + "sprites": 18, "missing_sprites": [] }], + "video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv", + "warnings": ["GP_READY_ROOM not exported -- out of scope"] +} +``` + +`video_transcode` will record the exact command so a modder can re-run it rather +than reverse-engineer what was done. It is absent until P4 writes a video. + +## Changes from v2 + +v2 was written before the keyframe's `+12` was decoded and before anyone could +reach a `.rat` leaf through the public decoder API. + +| Change | Why | +|---|---| +| `rotation_deg` on every keyframe and on `rest` | Decoded at keyframe `+12`, and **the game draws it** — confirmed on two different screens with two different elements against GPU captures. Dropping it would have made the focus ring's whole animation invisible. | +| `focus` (a record with its own elements) added; `focus_sprite` kept but demoted | The focused state is two elements in a nested leaf, not one sprite. v2's single `focus_sprite` could not carry the ring at all, and drew the highlight label 7 px off-centre by inheriting the base's position. `focus_sprite` stays because it is still the 54-pair naming convention and a consumer may want the bare texture. | +| `unresolved` gains `focus_ring_spin_period` | The ring's rotation ramps 0 → 360 across two keyframes whose second is untimed. The screen-level rule for an untimed keyframe (the exit ramp) is not established to apply inside a leaf, so the period is unknown and is not being invented. | + +## Changes from v1 + +v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour +modulate was known. Each change below is a thing v1 could not have said. + +| Change | Why | +|---|---| +| `rest.tint` (one `#rrggbbaa`) → `tint_rgba` **and** `fade_argb` | There are two modulate colours on the disc, in *different byte orders*, and they multiply. One field could not carry both, and a single `#rrggbbaa` silently discarded the alpha that every fade ramps. | +| `scale` is percent integers, not floats | It is a percent integer on the disc. Emitting `1.0` invents a precision the file does not have. | +| `paint_order` added, `"paint_order"` dropped from `unresolved` | Q3 decoded it: a `u16` layer key at `+0x0A`, stable-sorted. It is now derived, so it belongs in `export/` rather than `authored/`. `paint_order_ties` remains unresolved. | +| `layer` / `layer_source` added | Some keys are read from the file and some are measured off the running game. A consumer must be able to tell which. | +| `focus_sprite` now pairs by sprite **name**; `opt_link` exported raw | v1 implied `opt ` was the focus link. That was refuted. Pairing by name is the convention that survives. | +| `kind_raw` on every element, not only on `unknown` | The `0x3002` button test is not general and `kind & 0x4` changes whether an element draws at all. Both need the raw value present unconditionally. | +| `index`, `declared`, `parent`, `size`, `layer` added | Needed to reconstruct the screen: `paint_order` keys on `index`, primitives have no texture to take a size from, and `declared` keeps the disc's own spelling next to the derived `id`. | +| `name_why` required whenever `name_source` is `authored` | Rule 2. A name presented without its evidence is indistinguishable from a recovered one. | +| sprites moved from `sprites/*.png` to `sprites///*.png` | Sprite names collide across builds. `main_menu` and `extras` both ship a `ptbase.t32`, and they are different pictures. | +| `unresolved` is required, and may be empty | An empty list is a claim; an absent one is a gap. | diff --git a/godot-import/docs/MISSION.md b/godot-import/docs/MISSION.md new file mode 100644 index 00000000..cc2be83b --- /dev/null +++ b/godot-import/docs/MISSION.md @@ -0,0 +1,188 @@ +# Primary objective — the menu shell, running in Godot + +**Status:** active, set 2026-08-28. + +Build a Godot 4 project that boots the player's own disc through the sequence the +real game uses, and let a person move through it: + +``` +developer logo splash → intro video → title / PRESS Ⓐ → main menu → submenus +``` + +No gameplay. No 3D. No HUD. No emulator. Done means a human presses a d-pad and +Ⓐ and moves through those screens with the right art, animation, music and +transitions. + +## 1. You are one of two agents + +A **container agent** does the reverse engineering, in the +[Syplheed-Reborn][reborn] repository. It runs the emulator; you do not. You build +the port from what it publishes. + +The contract is `docs/port/HANDOFF.md` in that repository. **Read it before +assuming any value is on the disc.** Every answer there is one of three things, +and the distinction decides what you do: + +| | meaning | what you do | +|---|---|---| +| **decoded** | a field on the disc, with a disc-wide check | read it in the exporter | +| **measured** | not on the disc, but the running game does *this* | put it in `authored/`, cite the finding | +| **undecodable** | looked for, provably not there | put it in `authored/`, say it is a decision | + +If HANDOFF.md does not answer something you need, **say so and move to another +milestone**. Do not guess and do not reverse engineer it yourself — you have no +emulator and no oracle, so a guess here is indistinguishable from a fact and will +be believed later. + +[reborn]: https://git.mc02.dev/fabi/Syplheed-Reborn + +## 2. The wall + +The Godot project **never reads a disc format**. No IPFB, no RATC, no T8aD, no +XMA, no WMV. If Godot cannot read something, the exporter's job is to emit it +differently — not to bridge the gap at runtime. + +* **No GDExtension. No Rust in `port/`.** +* The decoders come from `sylpheed-formats`, **pinned by TAG**: + `sylpheed-formats = { git = "...", tag = "formats-pin-2026-08-29" }`. + + Pin a tag, never a bare sha. A sha reachable only from an `auto/*` branch is + orphaned when that branch is deleted or — worse — **squash-merged**, because + squash creates *new* commits: `main` looks like it contains the work while the + pin becomes unreachable and this project stops building for a fresh checkout. + A tag is a permanent ref, it says what it is in `Cargo.toml`, and it fails + loudly at *fetch* rather than silently at build. +* **Do not float the pin** to a branch. It would not do what it sounds like: + Cargo resolves a git dependency once and writes the sha into `Cargo.lock`, so + floating gives you staleness you cannot see instead of staleness you can read. +* Bump deliberately, as its own commit, saying what you wanted from the new + state. The RE agent tags when it lands something you need and tells you over + the message channel — that is how you stay current without floating. + +**In particular, do not reimplement media assembly.** `sylpheed_formats::media` +already handles the cases where one playable thing is not one archive entry: a +`.pak` entry that spans segment files, a bank with several sub-waves, and the +cutscene voices — which are one continuous XMA stream chunked into `VOICE_*.slb` +entries whose boundaries do **not** match the cues, so *a `.slb` need not hold +the track its name claims*. That last one is the single easiest thing in this +project to get subtly wrong. Use `resolve_movie_voice_region`. + +## 3. Derived vs authored + +| | `export/` | `authored/` | +|---|---|---| +| produced by | the exporter | you, by hand | +| contains | what the disc says | what we decided | +| hand-edited | **never** | always | +| in git | **no** — gitignored | yes | +| on re-export | overwritten wholesale | untouched | + +Tempted to hand-fix a file under `export/`? The fix belongs in the exporter or in +`authored/`. Every `authored/` entry carries a `why`. + +When the RE agent later decodes something you had authored, **delete the authored +entry** and let the exporter emit it. That deletion is the measure of progress. + +## 4. Never commit game assets + +`export/` is generated from the user's own disc and is gitignored. Code, schemas, +`authored/` mappings and docs only. If you are about to commit a sprite PNG or a +transcoded video, stop. + +## 5. Milestones + +A milestone is done when its **artifact** exists, not when the code compiles. + +| | Milestone | Gate | +|---|---|---| +| **P0** | Exporter skeleton; one screen and its sprites to `export/` | `export/screens/title/main_menu.json` validates against FORMAT.md and the PNGs open | +| **P1** | Godot renders that screen statically at 1280×720 | A Godot screenshot beside `sylpheed-cli screen render` of the same build — they should agree, and where they do not, say which is wrong | +| **P2** | Keyframe animation | Buttons slide in. **Blocked on HANDOFF Q1** (the time unit). Do not invent it | +| **P3** | Splash → title, with the transition | Both screens back to back, unattended | +| **P4** | Intro video | `ADV.wmv` plays with audio (§6) | +| **P5** | Main menu: navigation, focus states, Ⓐ into a submenu, B back | A human clicks through it | +| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the P5 gate. **Looping is blocked on HANDOFF Q10** | +| **P7** | New-game intro video after NEW GAME | Plays, then returns to a defined state | + +Work the lowest unfinished milestone. When one is blocked on an RE answer, say so +in `docs/BLOCKED.md`, and take the next milestone that is not. + +## 6. The video problem + +`ADV.wmv` is **WMV3 video with WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4 +plays only **Ogg Theora** natively. + +Transcode with ffmpeg, and **record the exact command in the export manifest** so +a modder who dislikes the quality can re-run it rather than reverse-engineer what +you did. Theora at 720p is not great; if the result is visibly poor, **say so and +propose** the FFmpeg-GDExtension fallback — do not adopt a runtime dependency on +your own authority. + +Only the boot intro and the one new-game intro are in scope. The disc holds +3.3 GB of video; transcoding all of it is not this milestone. + +### The downmix is decided: pin it explicitly + +**Human decision, 2026-08-29.** The cinematics are 5.1 (see +[`movie-audio-channels`][mac] for the disc-wide split — 28 surround, 69 stereo, +and *both* movies this milestone needs are surround). Fold to stereo with an +**explicit matrix**, not ffmpeg's default: + +``` +-af "pan=stereo|FL=0.707*FC+1.0*FL+0.707*FLC+0.707*BL+0.707*SL|FR=0.707*FC+1.0*FR+0.707*FRC+0.707*BR+0.707*SR" +``` + +Centre at −3 dB into both channels, which is the standard ITU fold and keeps +dialogue sitting correctly against the music. Record the full command in the +manifest, per the rule above. + +Pinned rather than left to the default because a default is a decision nobody +made: it is invisible in the output, it can change between ffmpeg versions, and +it silently alters how speech sits in the mix. Adjust the matrix if it sounds +wrong — but adjust it *deliberately*, as a commit. + +[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md + +## 7. Out of scope + +3D, gameplay, HUD, missions, save/load, localisation beyond English, the Ready +Room, and any reverse engineering. If you want an answer the disc has not given +you, that is a request to the container agent, not a task for you. + +## 8. Tooling policy — MCP servers and third-party skills + +Surveyed 2026-08-28. **No Godot MCP server, for now**, and the reason is not +that they are bad: + +* The mature ones ([godot-ai][ga], and most of the field) need a **live Godot + editor** running with a plugin that talks WebSocket to a Python server. This + agent is headless in a container; that is a daemon, an editor process and a + second language runtime added to an unattended loop, all of which can fail in + ways that look like a port bug. +* Their headline feature is **scene-tree introspection and node manipulation** — + built for someone hand-authoring scenes in the editor. This port *generates* + its screens from exported JSON at runtime. The agent writes a loader, not a + scene tree, so the feature that justifies the complexity does not apply here. +* What the agent actually needs to verify its work already exists: + `godot-headless` to run the project and `screenshot` to diff against + `sylpheed-cli screen render`. The verification loop is the valuable part, and + it is a bash job. + +**Third-party skill packs** ([godot-claude-skills][gcs], [GodotPrompter][gp], +[Godot-Claude-Skills][rcs]) are the opposite trade: pure context, no runtime, no +daemon. They are worth revisiting. They are **not installed now** because a skill +is *instructions injected into an agent running with approvals disabled*, which +is a supply-chain decision and not one to make by default — and because P0/P1 are +a Rust exporter and a static sprite draw, which need no advanced GDScript. + +**If you want one, propose it**: name the pack, say which milestone it unblocks, +and let a human vendor and review it. Do not install from a marketplace on your +own authority. + +Revisit this if GDScript quality becomes the bottleneck — most likely at P2, +where keyframe ramps meet tweens. + +[ga]: https://github.com/hi-godot/godot-ai +[gcs]: https://github.com/alexmeckes/godot-claude-skills +[gp]: https://github.com/jame581/GodotPrompter +[rcs]: https://github.com/Randroids-Dojo/Godot-Claude-Skills diff --git a/godot-import/docs/loop-task.md b/godot-import/docs/loop-task.md new file mode 100644 index 00000000..395115a5 --- /dev/null +++ b/godot-import/docs/loop-task.md @@ -0,0 +1,93 @@ +Build the Godot menu port, one milestone at a time. + +## Your objective + +`docs/MISSION.md` — read it every iteration. It defines the milestones P0…P7 and +the gate each must pass, the wall between the exporter and Godot, and the +derived/authored split. + +**You do not reverse engineer.** A separate container agent does that, in the +Syplheed-Reborn repository, mounted read-only at `/reborn`. You have no emulator +and no oracle, so a guess of yours is indistinguishable from a fact and will be +believed later. If you need an answer the disc has not given you, write it in +`docs/BLOCKED.md` and move to another milestone. + +## Read these first, every iteration + +1. `docs/MISSION.md` — milestones, gates, scope. +2. `/reborn/docs/port/HANDOFF.md` — **the contract.** What is decoded, what was + measured off the running game, and what is known undecodable. + + **It is a live read-only mount of the RE agent's working tree**, so it updates + itself and there is nothing to pull — `git -C /reborn pull` cannot work (the + mount is read-only) and should not: it would move another agent's checkout. + `git -C /reborn log -1` shows where they are. + + Because it is live, **it can move under you mid-iteration.** Anything you + copied out of it earlier — `docs/BLOCKED.md` especially — may already be + stale. Re-check it against HANDOFF before trusting it. +3. `docs/FORMAT.md` — the open format. It is versioned and it is yours to + revise, but a change is a deliberate act with a version bump. +4. `docs/BLOCKED.md` — what you are waiting on, so you do not re-discover it. + +`/reborn/docs/re/disc-atlas.html` maps how the assets reference each other. + +## Each iteration + +1. **Pick the lowest unfinished milestone.** If it is blocked on an RE answer, + record that in `docs/BLOCKED.md` and take the next one that is not. +2. **Build the smallest thing that reaches its gate.** The gate is an artifact — + a validating JSON file, a screenshot, a clickable build — never "it compiles". +3. **Keep derived and authored apart.** `export/` is regenerated wholesale and + never hand-edited. A fix you are tempted to make there belongs in the exporter + or in `authored/`, and every `authored/` entry carries a `why`. +4. **Write down what you decided**, in `docs/`. A decision that lives only in + your context is lost when the container dies. +5. **Commit** to `auto/`, one logical change per commit. +6. **Publish**: `push-work`. Every iteration that produced a commit. +7. **Say plainly what you did not settle**, and stop. + +## Hard rules + +* **Never commit game assets.** `export/` is gitignored and generated from the + user's own disc. Code, schemas, `authored/` mappings and docs only. +* **No Rust in `port/`, no GDExtension.** If Godot cannot read something, the + exporter emits it differently. +* **Do not vendor or reimplement `sylpheed-formats`** — it is pinned by revision. + In particular do not reimplement media assembly: `sylpheed_formats::media` + already handles segment-spanning entries, multi-sub-wave banks and the + continuous cutscene-voice stream, and that last one is the easiest thing here + to get subtly wrong. +* **`/reborn` is READ-ONLY.** Never commit there, never edit it. It belongs to + the other agent and you share no working tree with it. +* **Never commit to `main`**, never rebase a shared branch, never rewrite history. +* **Do not adopt a runtime dependency on your own authority.** Propose it. + +## Verifying + +* `sylpheed-cli screen render` (built from `/reborn`) is the reference renderer. + When Godot draws a screen, diff against the CLI's composite of the same build. + Where they disagree, one of them is wrong — say which, and why, rather than + tuning until they match. +* Godot runs headless (`godot-headless`), and windowed under Xvfb with + `screenshot` for a capture. +* A regenerated `export/` that comes out byte-identical is strong evidence a + change was additive. When it does change, check that every diff line pairs. + +## Publishing + +`push-work` pushes the current branch to origin. It refuses anything that is not +`auto/*` and never force-pushes, so the consolidated line stays a human's +decision. Run it **every iteration that produced a commit** — not at the end of +some longer arc, which is exactly when a container dies. + +If it reports no credentials, say so in your reply and continue working. Do not +improvise another route out. + +## Pacing + +One milestone step plus its write-up is a good iteration; a marathon is not. Stop +with a clean commit, a push, and an honest list of what is still open. + +The loop runs on a fixed interval set by the harness, so you do **not** need to +arm the next wakeup yourself. Spend that attention on the write-up instead. diff --git a/godot-import/port/project.godot b/godot-import/port/project.godot new file mode 100644 index 00000000..b38d1c8b --- /dev/null +++ b/godot-import/port/project.godot @@ -0,0 +1,27 @@ +; Godot 4 project for the Sylpheed menu shell. +; +; It reads ONLY the open asset tree produced by crates/sylpheed-export -- no +; disc formats, no GDExtension, no Rust. See ../docs/MISSION.md. + +config_version=5 + +[application] +config/name="Sylpheed" +config/features=PackedStringArray("4.3") +run/main_scene="res://scenes/boot.tscn" + +[display] +; The screens are authored at 1280x720 and every coordinate in the export is in +; that space, so the viewport matches it exactly and scaling happens once, at +; the window edge. +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/stretch/mode="canvas_items" +window/stretch/aspect="keep" + +[rendering] + +; The screens carry their own background; anything the export does not paint is +; black, which is what `sylpheed-cli screen render --black` composites over and +; therefore what a capture is comparable against. +environment/defaults/default_clear_color=Color(0, 0, 0, 1) diff --git a/godot-import/port/scenes/boot.tscn b/godot-import/port/scenes/boot.tscn new file mode 100644 index 00000000..09782733 --- /dev/null +++ b/godot-import/port/scenes/boot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/boot.gd" id="1"] + +[node name="Boot" type="Node"] +script = ExtResource("1") diff --git a/godot-import/port/scripts/boot.gd b/godot-import/port/scripts/boot.gd new file mode 100644 index 00000000..8020b78f --- /dev/null +++ b/godot-import/port/scripts/boot.gd @@ -0,0 +1,273 @@ +# Entry point. +# +# P1 shows one exported screen, statically, so that its pixels can be diffed +# against `sylpheed-cli screen render` of the same build. The boot sequence +# proper (splash -> intro -> title -> menu) is P3 and is not here. +# +# godot --path port -- --screen=main_menu +# godot --path port -- --screen=main_menu --capture=/tmp/godot.png +# godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png +# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png +# godot --path port -- --boot # the whole boot sequence +# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s +# +# `--time` is in SECONDS and freezes the timeline there; without it the screen +# animates in real time from t=0. `--pose=rest` draws the export's declared +# resting pose instead of the timeline -- what the reference renderer draws, so +# that a renderer-vs-renderer diff compares like with like. +# +# The screen is drawn into a SubViewport sized to the export's own `design` +# rectangle and shown through a container that scales it to the window. That is +# the same separation the project settings already make -- design space is +# fixed, the window is not -- and it makes `--capture` exact: the PNG is the +# design rectangle itself, never the window, so it is directly comparable with +# `screen render`'s composite with no cropping or rescaling. +extends Node + +const DEFAULT_SCREEN := "main_menu" + +var view: ScreenView = null +var viewport: SubViewport = null + + +func _ready() -> void: + var args := _args() + var export_tree := ExportTree.locate() + if export_tree.root == "": + push_error(export_tree.error) + get_tree().quit(2) + return + + _flow = export_tree.authored("flow.json") + if args.has("boot"): + if _flow == null: + push_error(export_tree.error) + get_tree().quit(2) + return + for step: Dictionary in _flow["boot"]: + _sequence.append(step) + _film = args.get("film", "") + + var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \ + else args.get("screen", DEFAULT_SCREEN) + if name == "": + name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport + var screen: Dictionary = export_tree.screen(name) + if screen.is_empty(): + push_error(export_tree.error) + print("screens in this export: ", ", ".join(export_tree.screen_names())) + get_tree().quit(2) + return + var design: Array = screen.get("design", [1280, 720]) + + var container := SubViewportContainer.new() + container.stretch = true + container.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(container) + + viewport = SubViewport.new() + viewport.size = Vector2i(int(design[0]), int(design[1])) + viewport.transparent_bg = false + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + container.add_child(viewport) + + view = ScreenView.new() + # The export is a 1:1 copy of the disc's texels and elements are drawn at up + # to 500 %. Nearest is also what the reference renderer does + # (`ui_layout::blit` maps destination to source by integer division), so a + # filter difference cannot masquerade as a placement difference in the diff. + view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + view.focused_id = args.get("focus", "") + if args.get("pose", "") == "rest": + view.pose_mode = ScreenView.Pose.REST + + # The keyframe unit is MEASURED, not on the disc, so it is authored and read + # in exactly one place -- here. + var timing: Variant = export_tree.authored("timing.json") + if timing == null: + push_error(export_tree.error) + get_tree().quit(2) + return + view.units_per_second = float(timing["keyframe_units_per_second"]) + # The one unknown duration per screen: the ramp into the final untimed + # keyframe. Authored, because the disc has no time slot there. + view.exit_ramp_units = float(timing["exit_ramp_units"]) + viewport.add_child(view) + + if not view.load_screen(export_tree, name): + push_error(export_tree.error) + get_tree().quit(2) + return + + var settle := view.settle_time() + print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [ + name, view.screen["elements"].size(), view.screen["paint_order"].size(), + design[0], design[1], settle, settle / view.units_per_second]) + + if args.has("time"): + _frozen = true + view.time_units = float(args["time"]) * view.units_per_second + view.queue_redraw() + + if _film != "": + set_process(true) + _film_capture() + + if args.has("capture"): + await _capture(args["capture"]) + get_tree().quit(0) + + +var _frozen := false +var _flow: Variant = null +var _sequence: Array[Dictionary] = [] +var _player: VideoStreamPlayer = null +var _step := 0 +var _film := "" +var _film_frame := 0 +var _film_next := 0.0 +var _elapsed := 0.0 +var _boot_done := false + + +func _process(delta: float) -> void: + if _frozen or view == null: + return + view.time_units += delta * view.units_per_second + _elapsed += delta + view.queue_redraw() + + if _sequence.is_empty() or _player != null: + return + + # A screen holds at `rest` until it has arrived, then plays itself out and + # the next one begins. Nothing waits on a timer the disc does not carry: the + # pacing is each group's own timeline (authored/flow.json, `dwell`). + if view.holding and view.time_units >= view.settle_time(): + # The LAST screen in the sequence keeps holding. A screen plays itself + # out because something is taking its place; nothing is taking the + # title's place here, and a boot that ends by fading to black is a boot + # that looks like it crashed. P4 puts the intro video in front of the + # title, and P5 gives the title somewhere to go. + if _step + 1 < _sequence.size(): + view.holding = false + elif not _boot_done: + _boot_done = true + print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]]) + if _film == "": + get_tree().quit(0) + elif not view.holding and view.time_units >= view.exit_time(): + _advance() + + +func _advance() -> void: + _step += 1 + var next: Dictionary = _sequence[_step] + if next.has("video"): + _play_video(String(next["video"]), bool(next.get("skippable", false))) + return + var name := String(next["screen"]) + print(" -> %s at %.2f s" % [name, _elapsed]) + view.holding = true + view.time_units = 0.0 + if not view.load_screen(view.tree, name): + push_error(view.tree.error) + get_tree().quit(2) + + +## Play one transcoded movie, full-bleed over the screen. +## +## The port never reads WMV: the exporter transcoded this to Ogg Theora and +## recorded the exact ffmpeg command in the manifest (MISSION §6), so a modder +## who dislikes the quality re-runs one line. +func _play_video(name: String, skippable: bool) -> void: + var v := view.tree.video(name) + if v.is_empty(): + push_error(view.tree.error) + get_tree().quit(2) + return + print(" -> video %s at %.2f s (%s)" % [name, _elapsed, v["path"]]) + + var stream := VideoStreamTheora.new() + stream.file = v["path"] + _player = VideoStreamPlayer.new() + _player.stream = stream + _player.expand = true + _player.set_anchors_preset(Control.PRESET_FULL_RECT) + # Into the SubViewport, not beside it. Everything this port draws composes in + # the export's own 1280x720 design space; a player parented to the Boot node + # renders to the window instead and is invisible to `--capture`, which reads + # the SubViewport. That is not only a capture artefact -- it would also put + # the movie outside the space every screen coordinate is expressed in. + viewport.add_child(_player) + _skippable = skippable + # `play()` needs the node in the tree; calling it before that is an error + # the engine reports and then ignores, which looks like a video that simply + # never starts. + await get_tree().process_frame + _player.finished.connect(_video_finished) + _player.play() + + +var _skippable := false + + +func _video_finished() -> void: + print(" video ended at %.2f s" % _elapsed) + _player.queue_free() + _player = null + _advance() + + +func _unhandled_input(event: InputEvent) -> void: + # HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached + # at 57 s against a 193 s baseline. This is the only input the port handles + # so far; menu navigation is P5. + if _player == null or not _skippable: + return + if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"): + print(" video skipped at %.2f s" % _elapsed) + _player.stop() + _video_finished() + + +func _capture(path: String) -> void: + # Two frames: the first is the one this callback is still inside of. + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + var img := viewport.get_texture().get_image() + print("t = %.2f units (%.3f s), pose = %s" % [ + view.time_units, view.time_units / view.units_per_second, + "rest" if view.pose_mode == ScreenView.Pose.REST else "timeline"]) + print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)]) + if not view.skipped.is_empty(): + print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)]) + var err := img.save_png(path) + if err != OK: + push_error("cannot write %s (%d)" % [path, err]) + return + print("captured %dx%d -> %s" % [img.get_width(), img.get_height(), path]) + + +## A frame every 0.25 s for the whole run, so an unattended boot leaves a +## filmstrip behind rather than requiring someone to be watching it. +func _film_capture() -> void: + while true: + await RenderingServer.frame_post_draw + if _elapsed >= _film_next: + var img := viewport.get_texture().get_image() + img.save_png("%s_%03d.png" % [_film, _film_frame]) + _film_frame += 1 + _film_next += 0.25 + + +# Godot passes everything after `--` through untouched; take `--key=value`. +static func _args() -> Dictionary: + var out := {} + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--") and arg.contains("="): + var pair := arg.substr(2).split("=", true, 1) + out[pair[0]] = pair[1] + elif arg.begins_with("--"): + out[arg.substr(2)] = "1" + return out diff --git a/godot-import/port/scripts/boot.gd.uid b/godot-import/port/scripts/boot.gd.uid new file mode 100644 index 00000000..16d85b5c --- /dev/null +++ b/godot-import/port/scripts/boot.gd.uid @@ -0,0 +1 @@ +uid://cskqmpkw2q6k2 diff --git a/godot-import/port/scripts/export_tree.gd b/godot-import/port/scripts/export_tree.gd new file mode 100644 index 00000000..ea3d141f --- /dev/null +++ b/godot-import/port/scripts/export_tree.gd @@ -0,0 +1,120 @@ +# Locating and reading the open export tree. +# +# The Godot project NEVER reads a disc format (docs/MISSION.md §2). Everything +# it draws comes from `export/`, which is derived, gitignored and regenerated +# wholesale by `crates/sylpheed-export`. This class is the only place that knows +# where that tree is on disk. +class_name ExportTree +extends RefCounted + +const FORMAT_SCREEN := "sylpheed.screen/3" +const FORMAT_MANIFEST := "sylpheed.manifest/1" + +var root: String = "" +var error: String = "" + + +# `SYLPHEED_EXPORT` wins, so a modder can point the game at their own tree +# without touching the project. Otherwise `/../export`, which is the +# layout this repository has. +static func locate() -> ExportTree: + var t := ExportTree.new() + var env := OS.get_environment("SYLPHEED_EXPORT") + var candidate := env + if candidate == "": + candidate = ProjectSettings.globalize_path("res://").path_join("../export").simplify_path() + if not FileAccess.file_exists(candidate.path_join("manifest.json")): + t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate + return t + t.root = candidate + return t + + +# `authored/` sits beside `export/`, never inside it: it is hand-written and +# committed, and a re-export must not be able to touch it. +func authored(name: String) -> Variant: + var path := root.path_join("../authored").simplify_path().path_join(name) + var text := FileAccess.get_file_as_string(path) + if text == "": + error = "cannot read %s" % path + return null + return JSON.parse_string(text) + + +func read_json(rel: String) -> Variant: + var path := root.path_join(rel) + var text := FileAccess.get_file_as_string(path) + if text == "": + error = "cannot read %s" % path + return null + var parsed: Variant = JSON.parse_string(text) + if parsed == null: + error = "%s is not JSON" % path + return null + return parsed + + +func manifest() -> Dictionary: + var m: Variant = read_json("manifest.json") + if m == null: + return {} + if m.get("format") != FORMAT_MANIFEST: + error = "manifest.json is %s, this build reads %s" % [m.get("format"), FORMAT_MANIFEST] + return {} + return m + + +# Screens are addressed by their manifest name, not by a path, so the caller +# never has to know the archive's subdirectory. +func screen(name: String) -> Dictionary: + var m := manifest() + if m.is_empty(): + return {} + for entry: Dictionary in m.get("screens", []): + if entry.get("name") == name: + var s: Variant = read_json(entry["file"]) + if s == null: + return {} + if s.get("format") != FORMAT_SCREEN: + error = "%s is %s, this build reads %s" % [name, s.get("format"), FORMAT_SCREEN] + return {} + return s + error = "no screen named %s in manifest.json" % name + return {} + + +# A transcoded movie, addressed by manifest name. The port never reads WMV -- +# the exporter emits Ogg Theora, which Godot plays natively (MISSION §2, §6). +func video(name: String) -> Dictionary: + for entry: Dictionary in manifest().get("videos", []): + if entry.get("name") == name: + var path := root.path_join(entry["file"]) + if not FileAccess.file_exists(path): + error = "manifest lists %s but %s is not there" % [name, path] + return {} + return {"path": path, "command": entry.get("command", "")} + error = "no video named %s in manifest.json" % name + return {} + + +func screen_names() -> PackedStringArray: + var names := PackedStringArray() + for entry: Dictionary in manifest().get("screens", []): + names.append(entry["name"]) + return names + + +# Textures live outside res://, so they are read as bytes and decoded at +# runtime rather than imported. Nearest-neighbour: the export is a 1:1 copy of +# the disc's own texels and several elements are drawn at 200 %, where a +# bilinear filter would invent detail the disc does not have. +func texture(rel: String) -> Texture2D: + var bytes := FileAccess.get_file_as_bytes(root.path_join(rel)) + if bytes.is_empty(): + error = "cannot read sprite %s" % rel + return null + var img := Image.new() + if img.load_png_from_buffer(bytes) != OK: + error = "%s is not a PNG" % rel + return null + return ImageTexture.create_from_image(img) diff --git a/godot-import/port/scripts/export_tree.gd.uid b/godot-import/port/scripts/export_tree.gd.uid new file mode 100644 index 00000000..5f7e6d54 --- /dev/null +++ b/godot-import/port/scripts/export_tree.gd.uid @@ -0,0 +1 @@ +uid://kyd3xrt1lpnj diff --git a/godot-import/port/scripts/screen_view.gd b/godot-import/port/scripts/screen_view.gd new file mode 100644 index 00000000..5b111f9a --- /dev/null +++ b/godot-import/port/scripts/screen_view.gd @@ -0,0 +1,387 @@ +# Draws one exported screen, either at a moment on its timeline or at the +# `rest` pose the export declares. +# +# TIMELINE is the real behaviour and the default. A keyframe is the start of a +# LINEAR ramp toward the next, and the unit of `t` comes from +# `authored/timing.json` -- it is measured, not on the disc, which is why it is +# authored and applied in exactly one place. +# +# REST reproduces what the export's `rest` field says, which is what +# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can +# hold both renderers to the same assumption. The two modes DISAGREE on six +# elements in this export, and the running game sides with the timeline -- see +# `docs/DECISIONS.md`. +# +# One CanvasItem draws the whole screen in `_draw`, rather than a node per +# element. The export's `paint_order` is already back-to-front, so honouring it +# is a loop; z-indexing sixteen nodes to reproduce the same order would be the +# same information expressed less directly, and would hide a tie behind Godot's +# own sibling rules. +class_name ScreenView +extends Node2D + +## Skip `kind & 0x4` template instances that duplicate a plain element. +## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest. +## The narrow form of the rule matters -- 174 elements on the disc carry the bit +## with no template to duplicate, and a blanket skip would erase them. +const KIND_TEMPLATE_INSTANCE := 0x4 + +enum Pose { TIMELINE, REST } + +## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws +## the export's declared `rest` and is there for renderer-vs-renderer diffing. +var pose_mode: Pose = Pose.TIMELINE + +## Position on the timeline, in the disc's own keyframe units. `t` is left raw +## everywhere; seconds appear only where `units_per_second` is applied. +var time_units: float = 0.0 +var units_per_second: float = 60.0 + +## Duration of the ramp into the final, untimed keyframe -- the screen playing +## itself out. Authored (`authored/timing.json`): the disc has no time slot on +## that keyframe, so this is the one unknown duration per screen. +var exit_ramp_units: float = 24.0 + +## While true the screen holds at `rest` and never plays its exit. The +## sequencer clears it to send the screen away. +var holding: bool = true + +var tree: ExportTree = null +var screen: Dictionary = {} +var textures: Dictionary = {} +var skipped: Array[String] = [] +var drawn: Array[String] = [] + +## Which button is highlighted, by element id. P1 leaves it empty: initial focus +## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an +## authored decision that belongs to P5. +var focused_id: String = "" + + +func load_screen(t: ExportTree, name: String) -> bool: + tree = t + screen = t.screen(name) + if screen.is_empty(): + push_error(t.error) + return false + var design: Array = screen.get("design", [1280, 720]) + # The export's coordinates are in this space and the viewport matches it, so + # a mismatch means the export is not what this project was built to draw. + var viewport := Vector2i( + ProjectSettings.get_setting("display/window/size/viewport_width"), + ProjectSettings.get_setting("display/window/size/viewport_height")) + if Vector2i(int(design[0]), int(design[1])) != viewport: + push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport]) + _load_textures() + queue_redraw() + return true + + +func _load_textures() -> void: + textures.clear() + for element: Dictionary in screen.get("elements", []): + var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")] + # The focus record's own elements carry their own sprites -- the ring is + # only reachable this way. + for fe: Dictionary in element.get("focus", {}).get("elements", []): + paths.append(fe.get("sprite", "")) + for rel: String in paths: + if rel != "" and not textures.has(rel): + var tex := tree.texture(rel) + if tex == null: + push_warning(tree.error) + else: + textures[rel] = tex + + +# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on +# purpose, because the disc spells them differently and a silent swap looks like +# an art bug rather than a parse bug. They multiply per channel. +static func modulate_of(pose: Dictionary) -> Color: + var tint := _rgba(pose.get("tint_rgba", "0xffffffff")) + var fade := _argb(pose.get("fade_argb", "0xffffffff")) + return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a) + + +static func _rgba(hex: String) -> Color: + var v := hex.hex_to_int() + return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff) + + +static func _argb(hex: String) -> Color: + var v := hex.hex_to_int() + return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff) + + +## The drawn rectangle of an element at a pose. +## +## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so +## the top-left moves by `-pivot*(s-1)` and the size is the natural size times +## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly. +static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2: + var pos := _vec(pose.get("pos", [0, 0])) + var s := _vec(pose.get("scale", [100, 100])) / 100.0 + return Rect2(pos - pivot * (s - Vector2.ONE), natural * s) + + +static func _vec(a: Array) -> Vector2: + return Vector2(float(a[0]), float(a[1])) + + +## The pose of one element at `time_units`. +## +## A group is `pre-roll -> ramp in -> HOLD -> ramp out -> post-roll`, and a +## screen that has arrived sits on the **hold**. So the timeline plays in and +## stops at `rest`, which is the decoders' identification of that hold and +## carries its own `t`. +## +## It is emphatically NOT "play to the last timed keyframe". The exit is not +## only the final untimed frame -- it can be a long run of TIMED ones. The +## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and +## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the +## whole screen ~13/255 too bright. That was measured against a plate-free +## capture of the running title, and it is what corrected this rule. +## +## Before the first keyframe the element holds its first pose -- the pre-roll a +## staggered menu needs, with the five buttons starting at t=28,30,32,34,36. +func pose_at(element: Dictionary, t: float) -> Dictionary: + var frames: Array = element.get("keyframes", []) + var timed: Array = [] + for k: Dictionary in frames: + if k.has("t"): + timed.append(k) + if timed.is_empty(): + # No timed frame at all: the group is a single static pose. + return frames[0] if not frames.is_empty() else element.get("rest", {}) + # While holding, stop at the hold: past it the group is ramping out, and a + # screen that has arrived and is sitting there is not leaving. + if holding: + t = minf(t, settle_units(element)) + # The exit. The final keyframe carries no `t` -- the disc has no slot for one + # -- so it is given a synthetic time `exit_ramp_units` after the last timed + # frame and then interpolated like any other. That keeps one code path: the + # difference between arriving and leaving is only how far `t` is allowed to + # run, not a second kind of animation. + # + # The whole group plays out, not just the fade quad: on the main menu + # pteff00 ramps to opaque black while the labels ramp to transparent and + # ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen + # was measured and refuted -- see authored/timing.json. + var last_frame: Dictionary = frames[frames.size() - 1] + if not last_frame.has("t"): + var exit_frame := last_frame.duplicate() + exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units + timed.append(exit_frame) + + if t <= float(timed[0]["t"]): + return timed[0] + for i in range(timed.size() - 1): + var a: Dictionary = timed[i] + var b: Dictionary = timed[i + 1] + var t0 := float(a["t"]) + var t1 := float(b["t"]) + if t < t1: + # A keyframe is the start of a ramp toward the next, and the ramp is + # linear -- measured, `authored/timing.json`. + return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0)) + return timed[timed.size() - 1] + + +# Channels are integers on the disc. The running game's own fade lands on +# `round(255*k/15)`, so rounding -- not truncation -- is what was measured. +static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary: + return { + "pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)], + "scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)], + "tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f), + "fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f), + "rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), f), + } + + +static func _ilerp(a: float, b: float, f: float) -> int: + return int(round(a + (b - a) * f)) + + +# Byte-wise, so it works for both orders without knowing which one it has. +static func _hex_lerp(a: String, b: String, f: float) -> String: + var x := a.hex_to_int() + var y := b.hex_to_int() + var out := 0 + for shift in [24, 16, 8, 0]: + out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift + return "0x%08x" % out + + +## Where one element stops, in keyframe units: its hold. +## +## `rest.t` when the export gives one. An element whose `rest` carries no time is +## a single static pose, and there the last timed keyframe is the same answer. +static func settle_units(element: Dictionary) -> float: + var rest: Dictionary = element.get("rest", {}) + if rest.has("t"): + return float(rest["t"]) + var last := 0.0 + for k: Dictionary in element.get("keyframes", []): + if k.has("t"): + last = maxf(last, float(k["t"])) + return last + + +## The moment the whole screen has arrived: the last element to reach its hold. +func settle_time() -> float: + var last := 0.0 + for element: Dictionary in screen.get("elements", []): + last = maxf(last, settle_units(element)) + return last + + +## The moment the screen has finished playing itself out, in keyframe units -- +## the last element's final timed keyframe plus the authored exit ramp. +func exit_time() -> float: + var last := 0.0 + for element: Dictionary in screen.get("elements", []): + var frames: Array = element.get("keyframes", []) + if frames.is_empty(): + continue + var timed_end := 0.0 + for k: Dictionary in frames: + if k.has("t"): + timed_end = maxf(timed_end, float(k["t"])) + if not frames[frames.size() - 1].has("t"): + timed_end += exit_ramp_units + last = maxf(last, timed_end) + return last + + +# An element is a ghost only when another element on the same screen carries the +# same id *without* the template bit -- the template it is a repeat of. +func _template_instance_ids() -> Dictionary: + var plain := {} + for element: Dictionary in screen.get("elements", []): + if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0: + plain[element.get("id", "")] = true + var ghosts := {} + for element: Dictionary in screen.get("elements", []): + var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int()) + if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")): + ghosts[int(element.get("index", -1))] = true + return ghosts + + +## Draw one textured or solid quad, rotated about its pivot. +## +## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left +## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that +## point is exactly the `pos - pivot*(s-1)` rule the placement already uses. +## +## THE GAME DRAWS ROTATION. Confirmed twice by the RE agent, on different +## screens and different elements -- the title's `ptloop` sweeps declare +30/-45 +## and a GPU capture submits them at +30.26/-45.28, and the main menu's focus +## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a +## capture. The comparison renderer does not draw it yet, so expect a title +## divergence that means "sylpheed-cli is behind", not "the port is broken". +## +## 🟡 The SIGN is an assumption: the decoder documents `+12` as +## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down +## space, so this passes the value straight through. Not yet checked against a +## capture at a known angle. +func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2, + pos: Vector2, rotation_deg: float) -> void: + if is_zero_approx(rotation_deg): + if tex != null: + draw_texture_rect(tex, rect, false, colour) + else: + draw_rect(rect, colour, true) + return + var anchor := pos + pivot + draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE) + var local := Rect2(rect.position - anchor, rect.size) + if tex != null: + draw_texture_rect(tex, local, false, colour) + else: + draw_rect(local, colour, true) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + +static func _rot_of(pose: Dictionary) -> float: + return float(pose.get("rotation_deg", 0)) + + +## Draw a focus record's own elements -- the spinning ring and the bright label. +## +## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements, +## and the parent bundle declares NO element for the record at all, so the leaf +## is the only source of placement for both and there is nothing to inherit. +## The label is 13 px larger per axis than the base and sits at (-7,-7), which +## keeps the two concentric; drawing it at the base position pushes it 7 px +## down-right and off-centre. +func _draw_focus(element: Dictionary) -> void: + var focus: Dictionary = element.get("focus", {}) + for fe: Dictionary in focus.get("elements", []): + var rel: String = fe.get("sprite", "") + if rel == "": + continue + var tex: Texture2D = textures.get(rel) + if tex == null: + skipped.append("%s (focus sprite failed to load)" % fe.get("id", "")) + continue + # The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360 + # with position, scale and alpha all constant -- but the PERIOD is not + # established: the ramp's second keyframe is untimed, and what an untimed + # keyframe means inside a leaf (rather than at screen level, where it is + # the exit) is untested. So this holds the resting angle and does not + # invent a spin rate. + var pose: Dictionary = fe.get("rest", {}) + var pivot := _vec(fe.get("pivot", [0, 0])) + var pos := _vec(pose.get("pos", [0, 0])) + _draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose), + pivot, pos, _rot_of(pose)) + drawn.append(fe.get("id", "")) + + +func _draw() -> void: + if screen.is_empty(): + return + var elements: Array = screen.get("elements", []) + var ghosts := _template_instance_ids() + skipped.clear() + drawn.clear() + for index: int in screen.get("paint_order", []): + var element: Dictionary = elements[index] + var id: String = element.get("id", "") + if ghosts.has(index): + skipped.append("%s (template instance)" % id) + continue + var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \ + else pose_at(element, time_units) + var colour := modulate_of(pose) + if colour.a <= 0.0: + skipped.append("%s (transparent at rest)" % id) + continue + var pivot := _vec(element.get("pivot", [0, 0])) + var pos := _vec(pose.get("pos", [0, 0])) + var rot := _rot_of(pose) + # A focused button draws its own record instead of its base sprite. + if focused_id == id and element.has("focus"): + _draw_focus(element) + continue + var rel: String = element.get("sprite", "") + if focused_id == id and element.get("focus_sprite", "") != "": + rel = element["focus_sprite"] + if rel != "": + var tex: Texture2D = textures.get(rel) + if tex == null: + skipped.append("%s (sprite failed to load)" % id) + continue + _draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot) + drawn.append(id) + elif element.get("role", "") == "primitive" and element.has("size"): + # A primitive has no texture; the quad is its declared size and its + # colour is the pose's own modulate. + _draw_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot) + drawn.append(id) + else: + # A .t32 element whose sprite the exporter could not produce. Saying + # so is the point -- a silently missing element looks like art. + skipped.append("%s (no sprite in the export)" % id) diff --git a/godot-import/port/scripts/screen_view.gd.uid b/godot-import/port/scripts/screen_view.gd.uid new file mode 100644 index 00000000..2844ec78 --- /dev/null +++ b/godot-import/port/scripts/screen_view.gd.uid @@ -0,0 +1 @@ +uid://cf6hspvq602s3 diff --git a/godot-import/tools/screen-strip b/godot-import/tools/screen-strip new file mode 100755 index 00000000..86bda871 --- /dev/null +++ b/godot-import/tools/screen-strip @@ -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 .rest.png and .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 + 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" diff --git a/godot-import/tools/verify-screen b/godot-import/tools/verify-screen new file mode 100755 index 00000000..f1ef2ada --- /dev/null +++ b/godot-import/tools/verify-screen @@ -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 .godot.png, .ref.png and .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 diff --git a/godot-import/tools/verify-video-audio b/godot-import/tools/verify-video-audio new file mode 100755 index 00000000..bf739a7f --- /dev/null +++ b/godot-import/tools/verify-video-audio @@ -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 } }'