diff --git a/.gitignore b/.gitignore index 43892a9..821d612 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target node_modules/ /lib/package* +/bds/* +!/bds/.gitkeep diff --git a/Cargo.lock b/Cargo.lock index c60aa55..7c49a24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -11,6 +11,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bds_enhancer" version = "0.2.0" @@ -20,14 +30,226 @@ dependencies = [ "regex", "serde", "serde_json", + "sourcemap", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "itoa" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "json" version = "0.12.4" @@ -40,30 +262,75 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "memchr" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" -version = "1.0.66" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "regex" version = "1.9.3" @@ -93,6 +360,18 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.15" @@ -116,7 +395,7 @@ checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.29", ] [[package]] @@ -130,6 +409,42 @@ dependencies = [ "serde", ] +[[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 = "sourcemap" +version = "9.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "314d62a489431668f719ada776ca1d49b924db951b7450f8974c9ae51ab05ad7" +dependencies = [ + "base64-simd", + "bitvec", + "data-encoding", + "debugid", + "if_chain", + "rustc-hash", + "serde", + "serde_json", + "unicode-id-start", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.29" @@ -141,8 +456,223 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.29", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] diff --git a/Cargo.toml b/Cargo.toml index 0db31ab..5f856a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "bds_enhancer" version = "0.2.0" edition = "2021" +rust-version = "1.88" [dependencies] serde = { version = "1", features = ["derive"] } @@ -9,10 +10,10 @@ serde_json = "1" regex = "1" lazy_static = "1.4.0" json = "0.12.4" +sourcemap = "9.3.2" [profile.release] lto = true opt-level = "z" codegen-units = 1 strip = true -panic = "abort" diff --git a/README.md b/README.md index a3b4fb2..67cc894 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,27 @@ Please download and use it from [Releases](https://github.com/Lapis256/bds-enhan [Library Documentation](./lib/doc.md) +## Script error sourcemaps + +When a ScriptAPI add-on emits an error, bds-enhancer automatically uses an adjacent source map such as `scripts/main.js.map` and adds the original source position to each resolvable stack frame. + +```text +at callback (src/main.ts:8) (main.js:12) +``` + +The generated JavaScript position is retained for troubleshooting. BDS does not include generated columns in ScriptAPI stack frames, so the original line is resolved from the first mapping on that generated line. + +Active behavior packs are discovered as follows: + +- Every pack in `system_behavior_packs` is included because BDS applies these packs automatically. +- Packs under the active world's `behavior_packs` directory are included only when their manifest UUID and version appear in `world_behavior_packs.json`. +- Packs in `development_behavior_packs` are subject to the same active-world UUID and version check. +- The BDS-level `behavior_packs` directory is internal and is not scanned. + +The active world is read from `level-name` in `server.properties`. Source maps are read when an error occurs, so updates made by a build watcher are picked up without restarting bds-enhancer. + +Source map resolution is best-effort. Missing, malformed, changing, oversized, ambiguous, or otherwise unresolvable maps never suppress the ScriptAPI error. The original stack frame is displayed unchanged and BDS continues running. + ## Development How to run for debugging diff --git a/README_ja.md b/README_ja.md index ff2546d..b168043 100644 --- a/README_ja.md +++ b/README_ja.md @@ -24,6 +24,28 @@ BDS でプレイヤーのサーバー間転送などを可能にするための [ライブラリのドキュメント](./lib/doc_ja.md) +## Scriptエラーのsourcemap解決 + +ScriptAPIアドオンがエラーを出したとき、bds-enhancerは`scripts/main.js.map`のようなJavaScriptに隣接するsource mapを自動的に読み、解決できたstack frameへ元ソース位置を追加します。 + +```text +at callback (src/main.ts:8) (main.js:12) +``` + +デバッグ用に生成JavaScript位置も残します。BDSのScriptAPI stack frameには生成列が含まれないため、生成行内の最初のmappingから元ソース行を解決します。 + +有効なBehavior Packは次の規則で探索します。 + +- `system_behavior_packs`内はBDSによって自動適用されるため、すべてのpackを対象にします。 +- active world内の`behavior_packs`は、manifestのUUID/versionが`world_behavior_packs.json`に定義されているpackだけを対象にします。 +- `development_behavior_packs`にも同じactive worldのUUID/version判定を適用します。 +- (BDS直下の`behavior_packs`は内部用なので探索しません) + +active worldは`server.properties`の`level-name`から特定します。source mapはエラー発生時に +読み込むため、build watcherによる更新はbds-enhancerを再起動しなくても反映されます。 + +sourcemap解決はbest-effortです。mapの欠損、壊れたJSON、更新途中、サイズ超過、候補の曖昧さなどでソースを解決できなかった場合、元のエラー文をそのまま表示します。 + ## 開発 デバッグ実行方法 diff --git a/bds/.gitkeep b/bds/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bds/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/SOURCEMAP_ERROR_PLAN.md b/docs/SOURCEMAP_ERROR_PLAN.md new file mode 100644 index 0000000..0c66442 --- /dev/null +++ b/docs/SOURCEMAP_ERROR_PLAN.md @@ -0,0 +1,229 @@ +# ScriptAPI エラーの sourcemap 解決 — 仕様・実装計画 + +最終更新: 2026-08-19 + +## 目的 + +BDS が出力する ScriptAPI の JavaScript スタックトレースを、各 Behavior Pack の +source map を使って TypeScript などの元ソース位置へ変換する。 + +最優先の要件は可用性である。sourcemap 解決機能の不具合、壊れた map、watch 中の +一時的な不整合、未知のログ形式、同名ファイルの曖昧さがあっても BDS と enhancer を +停止させず、元の ScriptAPI エラーを必ず表示する。 + +## 仕様(暫定合意案) + +### 入出力とフォールバック + +- enhancer が受け取った BDS のログレコードを対象にする。 +- ScriptAPI エラーらしいスタックフレームだけを best-effort で解決する。 +- 解決できたフレームは、元のエラー本文を保ったまま元ソース位置が分かる形にする。 +- 1 フレームだけ失敗した場合、そのフレームは元の文字列のまま残し、他のフレームの + 解決は続ける。 +- ログ全体の解析・map 読み込み・変換・整形のどこかで失敗した場合、そのログ全体を + 入力時の文字列のまま表示する。 +- sourcemap 機能の失敗を理由に `panic!`、`unwrap()`、`expect()` しない。 +- release profile の `panic = "abort"` は resolver 境界の `catch_unwind` を無効化するため + 撤廃し、予期しない panic でも元ログへ戻れる構成にする。 +- map が存在しない通常の JavaScript パックもエラー扱いせず、元ログを表示する。 +- sourcemap 用の診断ログを出す場合も、元の ScriptAPI エラーを先に失わず表示する。 + +### 複数パック + +- active world は `server.properties` の `level-name` から特定する。 +- `system_behavior_packs` 内のpackはBDSによって自動適用されるため、全packを探索する。 +- active world の `world_behavior_packs.json` にある `pack_id` と `version` を、world pack + およびdevelopment packが使用中かどうかのauthorityとする。 +- active world 内の `behavior_packs` は、上記JSONにUUID/versionが一致するpackだけ探索する。 +- BDS直下の `development_behavior_packs` も、上記JSONにUUID/versionが一致するpackだけ + 探索する。 +- BDS直下の `behavior_packs` は内部用なので探索しない。 +- active worldや `world_behavior_packs.json` を安全に読めない場合、world/development + packを推測で採用せず、system packだけを候補にする。 +- 各 pack の `manifest.json` と script module の `entry` を読み、生成 JavaScript と + 隣接する `.map` を候補として索引化する。 +- BDSのスタックにpackを一意に特定できる情報があれば、それを最優先する。 +- BDS 1.26.44.3のエラー先頭にある角括弧は `manifest.json` の `header.name` そのもの + なので、文字列をversion部分へ分割せず、header nameと完全一致させて候補を絞る。 +- 同じheader nameを持つactive packが複数あり、生成ファイルからも一意にできない場合は + versionをエラーから判定できないため未変換にする。 +- 同じ生成ファイル名を持つ複数候補を一意に決められない場合、誤変換を避けてその + フレームを未変換のまま表示する。 +- watch による map の更新をプロセス再起動なしで反映する。読み込み中・書き換え中の + 壊れた snapshot は採用しない。 + +### 表示形式 + +元のスタック行に、解決できた元位置を生成位置の直前へ挿入する。生成位置も調査用に +残す。 + +例(形式は未確定): + +```text +at callback (src/main.ts:8) (main.js:12) +``` + +BDS 1.26.44.3 の観測ログには生成列がない。そのため初期版は generated line 上で最初に +対応する segment を使って元の行を解決し、列は表示しない。行内に複数の元位置がある +場合は厳密な特定ができないため、行だけの best-effort 結果であることを仕様上の制約と +する。 + +### 性能と安全性 + +- 通常ログではファイル I/O をしない。 +- map は必要時に遅延ロードし、更新時刻・サイズなどで再利用可否を判断する。 +- source map の path は pack ディレクトリ外へ無制限に展開しない。 +- `sourcesContent` があれば位置解決に利用できるが、初期版ではソース本文の表示は必須に + しない。 +- enhancer 自身の異常と BDS 子プロセスの終了は区別する。今回の機能が子プロセスの + 生存期間を変更しない。 + +## 受け入れ条件 + +- [x] 単一packの例外で生成位置から `src/*.ts` の行へ解決できる。 +- [x] 2個以上のpackがあり、それぞれの例外を正しいmapへ解決できる。 +- [x] mapなし、壊れたJSON、対応segmentなし、範囲外の行・列で元ログが残る。 +- [x] watchがmapを書き換えてもenhancer/BDSが落ちず、元ログまたは更新後位置が表示される。 +- [x] 同名`scripts/main.js`が曖昧なら、誤ったTypeScript位置を表示しない。 +- [x] sourcemapと無関係な既存ログ・action・標準入力処理の挙動を変えない。 +- [x] resolverの単体テストと、実BDSを使った手動確認の両方を記録する。 + +## 段階計画と進捗 + +### Phase 0: 実ログの観測と仕様確定 — 進行中 + +- [x] enhancer の現在の stdout 集約・表示経路を確認。 +- [x] テスト pack の `main.js.map`、`manifest.json`、watch 構成を確認。 +- [x] テスト pack の nested callback から意図的に例外を発生させ、生ログを採取。 +- [ ] top-level / async(可能なら)のスタック形式を追加比較。 +- [x] スタック内の `[header.name]` でpack候補を識別できることを確認。 +- [x] 表示形式を「生成位置を残して元位置を追記」に決定。 +- [x] world 固有 pack を含む探索対象とactive判定を確定。 + +### Phase 1: 純粋な resolver と失敗系テスト — 完了 + +- [x] ScriptAPI スタックフレーム parser を、非一致なら入力を返す純粋処理として実装。 +- [x] source map VLQ 解決ライブラリに Sentry の Rust `sourcemap` crate 9.3.2 を選定。 +- [x] pack/map index と一意候補選択を実装。 +- [x] 正常系・部分失敗・全体失敗・曖昧候補の単体テストを追加。 + +### Phase 2: stdout パイプライン統合 — 完了 + +- [x] resolver を既存ログ表示直前へ統合。 +- [x] 元ログを所有したまま変換し、失敗時に必ず元ログへ戻す境界を設ける。 +- [x] 既存 action 検出、コマンド応答、ログ色分けへの回帰テストを追加。 + +### Phase 3: watch・複数 pack・実 BDS 検証 — 完了 + +- [x] map更新前後で位置が切り替わることを確認。 +- [x] 2 pack fixtureで自動選択または安全な曖昧フォールバックを確認。 +- [x] mapを一時的に欠損させてもBDS/enhancerが継続し、元エラーが見えることを確認。 +- [x] 実行コマンド、観測ログ、未確認事項をこの文書へ追記。 + +### Phase 4: ドキュメントと仕上げ — 完了 + +- [x] README / README_ja に機能、探索規則、fallback を追記。 +- [x] `cargo fmt`、`cargo test`、`cargo clippy` を実行。 +- [x] release build で最終確認。 + +## 観測メモ + +- テスト pack は `tsdown --watch`、`sourcemap: true` で生成される。 +- 現在の map は `scripts/main.js.map` で、`file` は `main.js`、`sources` は + `../src/main.ts`、`sourcesContent` を含む。 +- 現在の `LogDelimiterStream` は BDS のログ prefix または 50 ms timeout でログを + まとめる。複数行スタックが分割される可能性を Phase 0 で必ず観測する。 +- BDS 1.26.44.3 で nested callback の例外は次の 1 レコードとして観測できた。 + + ```text + [2026-08-19 15:50:34:825 ERROR] [Scripting] [scriptapi-template v0.1.0] Error: BDS_ENHANCER_SOURCEMAP_PROBE at throwNestedSourcemapProbe (main.js:4) + at (main.js:8) + ``` + +- フレームは生成ファイルのbasenameと1-based lineのみで、列・pack path・UUIDは + 含まれない。エラー先頭の角括弧はmanifestのheader nameであり、versionが見える場合も + header name自体に含まれている文字列である。 +- 上記の複数行スタックは現在の `LogDelimiterStream` でも同じ ERROR 色で出力され、 + 少なくとも今回の callback 例外では 50 ms timeout による途中分割は起きなかった。 +- 実ログ採取では既存 BDS と競合しない一時ポート・一時 world・offline mode を使用し、 + 採取後に `server.properties` を元の値へ戻した。 + +## Phase 1 実装メモ + +- `SourcemapResolver::discover` はsystem packを自動採用する。active worldのworld packと + development packは `world_behavior_packs.json` のUUID/version参照に一致するものだけ + 採用し、BDS直下の内部用 `behavior_packs` は探索しない。 +- 採用したpackの `manifest.json` を読み、script moduleのentryと隣接`.map`を索引化する。 + 壊れたmanifest、server properties、world参照JSON、読めないrootは非致命的に無視する。 +- エラーのheader nameと、stack frameの生成ファイル名が一致する候補だけを + 解決する。複数候補のうち実際に解決できるものが1個だけなら採用し、0個または2個 + 以上なら元frameを残す。 +- map は最大64 MiBとし、読み込み前後のsize/mtimeが変わった場合はwatch更新中とみなして + 採用しない。 +- BDSが列を出さない場合、generated line上の最初のmapped tokenを利用する。列0を仮定 + するとtsdown mapでは前行tokenを返すことが実fixtureで判明したためである。 +- mapのsource pathをpack相対で正規化し、pack外へ抜けるpathは表示しない。 +- 実装時のRust 1.85では新しい`url`のICU依存をビルドできなかったため、一時的に + `url` 2.5.2へ固定した。その後Rustを1.97.1へ更新し、MSRVを1.88と明記して固定を撤廃した。 +- resolver全体を`catch_unwind`境界で囲った。releaseでも有効にするため既存の + `panic = "abort"`を撤廃した。 +- 2026-08-19: 実際のtsdown watch mapをfixture化したテストを含む15テストを追加。 + `cargo test`成功。既存Clippy 4件を個別allowした検査では新規警告なし。 + +## Phase 2 実装・実機確認メモ + +- resolverはaction判定とコマンド結果転送の後、stdoutへ色付き表示する直前だけに適用した。 + join/spawn検出にも変換前の元ログを渡す。 +- action、ERROR level、`NO LOG FILE! - `除去、コマンド結果payload、join/spawn event、 + 無関係な通常ログについて回帰テストを追加した。 +- 実BDS 1.26.44.3で次の表示を確認した。生成位置とERROR色を残し、元位置が追記された。 + + ```text + at throwNestedSourcemapProbe (src/main.ts:4) (main.js:4) + at (src/main.ts:9) (main.js:8) + ``` + +- 最初の実機試行では角括弧をname/versionに誤分割して未解決になったが、元エラーはそのまま + 表示されBDSも継続した。角括弧がmanifestのheader nameそのものだと修正後、解決に成功。 +- sandboxではonline modeのMinecraft services接続がtimeoutしたため、実機確認中だけ + `online-mode=false` / `allow-list=false`を使用し、確認後に元のtrueへ戻した。 +- 2026-08-19: Phase 2完了時点で `cargo test` 21件成功。 + +## Phase 3 watch・復旧確認メモ + +- 同じresolverインスタンスでmapのsource pathを書き換え、index再生成なしで新しい位置へ + 解決できるテストを追加した。 +- mapを壊した状態では元ログへ戻り、正常mapへ戻した後は同じresolverで再解決できる + テストを追加した。 +- 2 pack fixtureではheader nameと生成ファイルから各mapを選択でき、同一候補が複数なら + 未変換になることを再確認した。 +- 実BDSを起動したままtest addonへ空行を追加し、watchがmapを更新した後に`reload`した。 + 生成位置は同じまま、元位置が次のように切り替わった。 + + ```text + before: at throwNestedSourcemapProbe (src/main.ts:4) (main.js:4) + after: at throwNestedSourcemapProbe (src/main.ts:5) (main.js:4) + ``` + +- 稼働中に`main.js.map`を一時退避して`reload`すると、元の`(main.js:4)`フレームがそのまま + 表示され、BDSは継続した。map復元後の次の`reload`でTypeScript位置表示へ復帰した。 +- 検証には既存debug版と競合しないrelease版、19142/19143、一時worldを使用した。 + 検証用BDSだけを停止し、test addonの空行、map、server.propertiesは元へ戻した。 +- 2026-08-19: Phase 3完了時点で `cargo test` 23件成功。 + +## 最終決定 + +- resolver固有の診断ログは通常表示しない。解決失敗時の利用者向け結果は元エラーである。 +- BDSが生成列を出さない場合は生成行内の最初のmappingを使う制約をREADMEへ明記する。 + +## Phase 4 最終確認 + +- README / README_jaへ表示例、active pack探索規則、watch反映、fallback保証を追記した。 +- 既存Clippy警告4件を整理し、BDS stdout終了後に子プロセスを`wait()`するようにした。 +- `cargo fmt -- --check`: 成功。 +- `cargo test`: 24件成功。 +- `cargo clippy --all-targets -- -D warnings`: 成功(allowなし)。 +- `cargo build --release`: 成功。 +- Rust 1.97.1への更新後、`rust-version = "1.88"`を追加し、互換用の`url`直接固定を削除した。 +- 仕上げ後の表示調整として、解決済みframeの生成JS位置だけANSI dimを適用した。 + `\x1b[22m`でintensityのみ戻すため、ERRORの赤色は維持する。未解決frameは装飾しない。 diff --git a/src/color.rs b/src/color.rs index 637de02..0702e21 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,5 +1,8 @@ use std::fmt::{self, Display, Formatter}; +pub const ANSI_DIM: &str = "\x1b[2m"; +pub const ANSI_NORMAL_INTENSITY: &str = "\x1b[22m"; + pub enum Color { Red, Yellow, diff --git a/src/main.rs b/src/main.rs index 5ee6d8c..6ec8972 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ pub mod action; pub mod color; pub mod consts; pub mod log_level; +pub mod sourcemap_resolver; pub mod stream; use json::{self, object}; @@ -14,9 +15,10 @@ use std::sync::mpsc::{channel, Receiver, Sender}; use std::thread; use action::Action; -use color::Color; +use color::{Color, ANSI_DIM, ANSI_NORMAL_INTENSITY}; use consts::LOG_PREFIX; use log_level::LogLevel; +use sourcemap_resolver::SourcemapResolver; use stream::LogDelimiterStream; lazy_static::lazy_static! { @@ -62,6 +64,22 @@ fn parse_action(log: &str) -> Option { serde_json::from_str(json).ok()? } +enum IncomingLog<'a> { + Action(Action), + Output { level: LogLevel, original: &'a str }, +} + +fn classify_log(log: &str) -> IncomingLog<'_> { + if let Some(action) = parse_action(log) { + return IncomingLog::Action(action); + } + + IncomingLog::Output { + level: get_log_level(log), + original: log.strip_prefix("NO LOG FILE! - ").unwrap_or(log), + } +} + fn handle_action(child_stdin: &Sender, action: Action, command_status: &mut CommandStatus) { match action { Action::Transfer(arg) => execute_command( @@ -150,45 +168,62 @@ fn custom_handler(log: &str, child_stdin: &Sender) { } } +fn forward_command_result( + log: &str, + child_stdin: &Sender, + command_status: &mut CommandStatus, +) { + if !command_status.waiting { + return; + } + + for i in 0..(log.chars().count() / 1500 + 1) { + let result_tmp = log.chars().skip(i * 1500).take(1500).collect::(); + let result: json::JsonValue = object! { + "command" => command_status.command.clone(), + "result_message" => result_tmp, + "count" => i, + "end" => i == log.chars().count() / 1500, + }; + execute_command( + child_stdin, + format!( + "scriptevent {} {} ", + command_status.scriptevent, + result.dump() + ), + ); + } + command_status.waiting = false; +} + fn handle_child_stdout( child_stdin: Sender, child_stdout: ChildStdout, - mut command_status: &mut CommandStatus, + command_status: &mut CommandStatus, + sourcemap_resolver: &SourcemapResolver, ) { let logs = LogDelimiterStream::new(child_stdout); let mut stdout = std::io::stdout(); for log in logs { - if let Some(action) = parse_action(&log) { - handle_action(&child_stdin, action, &mut command_status); - continue; - } - - let level = get_log_level(&log); - - let log = log.strip_prefix("NO LOG FILE! - ").unwrap_or(&log); - if command_status.waiting { - for i in 0..(log.chars().count() / 1500 + 1) { - let result_tmp = log.chars().skip(i * 1500).take(1500).collect::(); - let result: json::JsonValue = object! { - "command" => command_status.command.clone(), - "result_message" => result_tmp, - "count" => i, - "end" => i == log.chars().count() / 1500, - }; - execute_command( - &child_stdin, - format!( - "scriptevent {} {} ", - command_status.scriptevent, - result.dump() - ), - ); + let (level, log) = match classify_log(&log) { + IncomingLog::Action(action) => { + handle_action(&child_stdin, action, command_status); + continue; } - command_status.waiting = false; - } - let _ = stdout.write(format!("{}{}{}\n", level.to_color(), log, Color::Reset).as_bytes()); + IncomingLog::Output { level, original } => (level, original), + }; + forward_command_result(log, &child_stdin, command_status); + let display_log = sourcemap_resolver.resolve_log_with_generated_style( + log, + ANSI_DIM, + ANSI_NORMAL_INTENSITY, + ); + let _ = stdout + .write(format!("{}{}{}\n", level.to_color(), display_log, Color::Reset).as_bytes()); + // Event detection must continue to use the untouched BDS log. custom_handler(log, &child_stdin); } } @@ -202,9 +237,9 @@ fn execute_shell_command(command: &str, args: Vec) -> Result { let stdout = String::from_utf8_lossy(&output.stdout); - return Ok(stdout.to_string()); + Ok(stdout.to_string()) } - Err(e) => return Err(e), + Err(e) => Err(e), } } @@ -231,13 +266,14 @@ fn main() { let os = env::consts::OS; let cwd = env::args().nth(1).unwrap_or(".".to_string()); let executable_name = env::args().nth(2).unwrap_or("bedrock_server".to_string()); + let sourcemap_resolver = SourcemapResolver::discover(Path::new(&cwd)); let mut child = build_command(os, &cwd, &executable_name) .spawn() .expect("Failed to spawn process"); let child_stdin = child.stdin.take().expect("Failed to get stdin"); - let stdout = child.stdout.expect("Failed to get stdout"); + let stdout = child.stdout.take().expect("Failed to get stdout"); let (tx, rx) = channel::(); let tx2 = tx.clone(); @@ -250,7 +286,8 @@ fn main() { command: "".to_string(), scriptevent: "".to_string(), }; - handle_child_stdout(tx2, stdout, &mut command_status); + handle_child_stdout(tx2, stdout, &mut command_status, &sourcemap_resolver); + let _ = child.wait(); } struct CommandStatus { @@ -258,3 +295,90 @@ struct CommandStatus { command: String, scriptevent: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_logs_are_classified_before_console_rendering() { + let log = r#"[2026 INFO] [Scripting] bds_enhancer:{"action":"reload"}"#; + + assert!(matches!( + classify_log(log), + IncomingLog::Action(Action::Reload) + )); + } + + #[test] + fn output_logs_keep_the_original_text_and_level() { + let log = "NO LOG FILE! - [2026-08-19 16:00:00:000 ERROR] [Scripting] probe"; + + let (level, original) = match classify_log(log) { + IncomingLog::Output { level, original } => (level, original), + IncomingLog::Action(_) => panic!("ordinary logs must be rendered"), + }; + assert_eq!(level.as_str(), "ERROR"); + assert_eq!( + original, + "[2026-08-19 16:00:00:000 ERROR] [Scripting] probe" + ); + } + + #[test] + fn custom_handlers_still_receive_original_join_and_spawn_logs() { + let (sender, receiver) = channel(); + custom_handler("Player connected: Steve, xuid: 123", &sender); + custom_handler( + "Player Spawned: Alex xuid: 456, pfid: test-platform", + &sender, + ); + + assert_eq!( + receiver.recv().expect("join event command must be emitted"), + "scriptevent system:on_join Steve|123\n" + ); + assert_eq!( + receiver + .recv() + .expect("spawn event command must be emitted"), + "scriptevent system:on_spawn Alex|456|test-platform\n" + ); + } + + #[test] + fn command_results_forward_the_original_log() { + let (sender, receiver) = channel(); + let mut status = CommandStatus { + waiting: true, + command: "list".to_owned(), + scriptevent: "bds_enhancer:result".to_owned(), + }; + let original = "[2026-08-19 16:00:00:000 INFO] There are 0/10 players online"; + + forward_command_result(original, &sender, &mut status); + + let command = receiver + .recv() + .expect("command result event must be emitted"); + let payload = command + .strip_prefix("scriptevent bds_enhancer:result ") + .expect("result event prefix must be preserved") + .trim(); + let payload: serde_json::Value = + serde_json::from_str(payload).expect("result payload must remain valid JSON"); + assert_eq!(payload["command"], "list"); + assert_eq!(payload["result_message"], original); + assert_eq!(payload["count"], 0); + assert_eq!(payload["end"], true); + assert!(!status.waiting); + } + + #[test] + fn unrelated_logs_are_unchanged_by_an_empty_resolver() { + let resolver = SourcemapResolver::default(); + let log = "[2026 INFO] Server started."; + + assert_eq!(resolver.resolve_log(log), log); + } +} diff --git a/src/sourcemap_resolver.rs b/src/sourcemap_resolver.rs new file mode 100644 index 0000000..a16cfb5 --- /dev/null +++ b/src/sourcemap_resolver.rs @@ -0,0 +1,881 @@ +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; +use sourcemap::{decode_slice, DecodedMap, Token}; +use std::fs; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Component, Path, PathBuf}; + +const MAX_SOURCEMAP_BYTES: u64 = 64 * 1024 * 1024; + +lazy_static::lazy_static! { + static ref PACK_CONTEXT_REGEX: Option = Regex::new( + r"\[Scripting\] \[(?P[^\]\r\n]+)\]" + ).ok(); + static ref STACK_FRAME_REGEX: Option = Regex::new( + r"\((?P[^()\r\n]+?\.m?js):(?P\d+)(?::(?P\d+))?\)" + ).ok(); +} + +#[derive(Debug, Clone)] +struct ScriptMap { + pack_name: String, + pack_dir: PathBuf, + generated_entry: String, + map_path: PathBuf, +} + +#[derive(Debug, Default)] +pub struct SourcemapResolver { + maps: Vec, +} + +impl SourcemapResolver { + /// Discovers script modules without making a malformed or unreadable pack fatal. + pub fn discover(bds_dir: &Path) -> Self { + let mut maps = discover_packs_in_root(&bds_dir.join("system_behavior_packs"), None); + + if let Some((world_dir, references)) = active_world_pack_references(bds_dir) { + maps.extend(discover_packs_in_root( + &world_dir.join("behavior_packs"), + Some(&references), + )); + maps.extend(discover_packs_in_root( + &bds_dir.join("development_behavior_packs"), + Some(&references), + )); + } + + Self { maps } + } + + /// Returns the original log on every kind of failure, including an unexpected panic. + pub fn resolve_log(&self, log: &str) -> String { + self.resolve_log_with_generated_style(log, "", "") + } + + /// Applies styling only to generated locations in successfully resolved frames. + pub(crate) fn resolve_log_with_generated_style( + &self, + log: &str, + generated_prefix: &str, + generated_suffix: &str, + ) -> String { + catch_unwind(AssertUnwindSafe(|| { + self.resolve_log_inner(log, generated_prefix, generated_suffix) + })) + .ok() + .flatten() + .unwrap_or_else(|| log.to_owned()) + } + + fn resolve_log_inner( + &self, + log: &str, + generated_prefix: &str, + generated_suffix: &str, + ) -> Option { + let context = PACK_CONTEXT_REGEX.as_ref()?.captures(log)?; + let pack_name = context.name("name")?.as_str(); + let pack_maps: Vec<&ScriptMap> = self + .maps + .iter() + .filter(|candidate| candidate.pack_name == pack_name) + .collect(); + + if pack_maps.is_empty() { + return None; + } + + let mut resolved_any = false; + let resolved = + STACK_FRAME_REGEX + .as_ref()? + .replace_all(log, |captures: ®ex::Captures<'_>| { + let original = captures + .get(0) + .map(|capture| capture.as_str()) + .unwrap_or_default(); + let Some(file) = captures.name("file").map(|capture| capture.as_str()) else { + return original.to_owned(); + }; + let Some(line) = captures + .name("line") + .and_then(|capture| capture.as_str().parse::().ok()) + else { + return original.to_owned(); + }; + let column = captures + .name("column") + .and_then(|capture| capture.as_str().parse::().ok()); + + let candidates: Vec<&ScriptMap> = pack_maps + .iter() + .copied() + .filter(|candidate| { + generated_entry_matches(&candidate.generated_entry, file) + }) + .collect(); + let locations: Vec = candidates + .into_iter() + .filter_map(|candidate| resolve_frame(candidate, line, column)) + .collect(); + let [location] = locations.as_slice() else { + return original.to_owned(); + }; + + resolved_any = true; + format!( + "({}:{}) {generated_prefix}{original}{generated_suffix}", + location.source, location.line + ) + }); + + resolved_any.then(|| resolved.into_owned()) + } +} + +#[derive(Debug, Deserialize)] +struct Manifest { + header: ManifestHeader, + #[serde(default)] + modules: Vec, +} + +#[derive(Debug, Deserialize)] +struct ManifestHeader { + name: String, + uuid: Option, + version: Value, +} + +#[derive(Debug, Deserialize)] +struct ManifestModule { + #[serde(rename = "type")] + module_type: String, + entry: Option, +} + +#[derive(Debug, Deserialize)] +struct WorldPackReference { + pack_id: String, + version: Value, +} + +#[derive(Debug, PartialEq, Eq)] +struct PackIdentity { + uuid: String, + version: String, +} + +fn active_world_pack_references(bds_dir: &Path) -> Option<(PathBuf, Vec)> { + let properties = fs::read_to_string(bds_dir.join("server.properties")).ok()?; + let level_name = properties.lines().find_map(|line| { + let line = line.trim(); + if line.starts_with('#') { + return None; + } + line.strip_prefix("level-name=") + .map(str::trim) + .filter(|name| !name.is_empty()) + })?; + let worlds_dir = bds_dir.join("worlds"); + let world_dir = safe_pack_path(&worlds_dir, Path::new(level_name))?; + let references = fs::read(world_dir.join("world_behavior_packs.json")).ok()?; + let references = serde_json::from_slice::>(&references).ok()?; + let identities = references + .into_iter() + .filter_map(|reference| { + Some(PackIdentity { + uuid: reference.pack_id, + version: manifest_version(&reference.version)?, + }) + }) + .collect(); + + Some((world_dir, identities)) +} + +fn discover_packs_in_root(root: &Path, references: Option<&[PackIdentity]>) -> Vec { + let Ok(pack_dirs) = fs::read_dir(root) else { + return Vec::new(); + }; + + pack_dirs + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .flat_map(|path| discover_pack(&path, references)) + .collect() +} + +fn discover_pack(pack_dir: &Path, references: Option<&[PackIdentity]>) -> Vec { + let Ok(manifest_bytes) = fs::read(pack_dir.join("manifest.json")) else { + return Vec::new(); + }; + let Ok(manifest) = serde_json::from_slice::(&manifest_bytes) else { + return Vec::new(); + }; + let Some(version) = manifest_version(&manifest.header.version) else { + return Vec::new(); + }; + if let Some(references) = references { + let Some(uuid) = manifest.header.uuid.as_deref() else { + return Vec::new(); + }; + if !references.iter().any(|reference| { + reference.uuid.eq_ignore_ascii_case(uuid) && reference.version == version + }) { + return Vec::new(); + } + } + + manifest + .modules + .into_iter() + .filter(|module| module.module_type == "script") + .filter_map(|module| module.entry) + .filter_map(|entry| { + let normalized_entry = normalize_slashes(&entry); + let generated_path = safe_pack_path(pack_dir, Path::new(&normalized_entry))?; + let mut map_path = generated_path.as_os_str().to_os_string(); + map_path.push(".map"); + + Some(ScriptMap { + pack_name: manifest.header.name.clone(), + pack_dir: pack_dir.to_owned(), + generated_entry: normalized_entry, + map_path: PathBuf::from(map_path), + }) + }) + .collect() +} + +fn manifest_version(version: &Value) -> Option { + match version { + Value::String(version) => Some(version.clone()), + Value::Array(parts) if !parts.is_empty() => parts + .iter() + .map(|part| match part { + Value::Number(number) => Some(number.to_string()), + Value::String(string) => Some(string.clone()), + _ => None, + }) + .collect::>>() + .map(|parts| parts.join(".")), + _ => None, + } +} + +fn generated_entry_matches(entry: &str, frame_file: &str) -> bool { + let frame_file = normalize_slashes(frame_file); + if entry == frame_file || frame_file.ends_with(&format!("/{entry}")) { + return true; + } + + Path::new(entry).file_name() == Path::new(&frame_file).file_name() +} + +#[derive(Debug, PartialEq, Eq)] +struct OriginalLocation { + source: String, + line: u32, +} + +fn resolve_frame( + candidate: &ScriptMap, + generated_line: u32, + generated_column: Option, +) -> Option { + let generated_line = generated_line.checked_sub(1)?; + let map_bytes = read_stable_map(&candidate.map_path)?; + let map = decode_slice(&map_bytes).ok()?; + + if let Some(generated_column) = generated_column { + let token = map.lookup_token(generated_line, generated_column.saturating_sub(1))?; + return original_location_from_token(candidate, token, generated_line); + } + + // BDS 1.26 does not print generated columns. Use the first mapped token on + // that exact line instead of guessing column zero, which may select a token + // from the preceding line. + match &map { + DecodedMap::Regular(map) => first_location_on_line(candidate, map.tokens(), generated_line), + DecodedMap::Hermes(map) => first_location_on_line(candidate, map.tokens(), generated_line), + DecodedMap::Index(index) => { + let flattened = index.flatten().ok()?; + first_location_on_line(candidate, flattened.tokens(), generated_line) + } + } +} + +fn first_location_on_line<'a>( + candidate: &ScriptMap, + tokens: impl Iterator>, + generated_line: u32, +) -> Option { + let token = tokens + .filter(|token| token.get_dst_line() == generated_line && token.has_source()) + .min_by_key(|token| token.get_dst_col())?; + original_location_from_token(candidate, token, generated_line) +} + +fn original_location_from_token( + candidate: &ScriptMap, + token: Token<'_>, + generated_line: u32, +) -> Option { + // lookup_token may return a token from an earlier generated line. Never use it. + if token.get_dst_line() != generated_line || !token.has_source() { + return None; + } + + let source = token.get_source()?; + let source = source_display_path(candidate, source)?; + let line = token.get_src_line().checked_add(1)?; + + Some(OriginalLocation { source, line }) +} + +fn read_stable_map(path: &Path) -> Option> { + let before = fs::metadata(path).ok()?; + if !before.is_file() || before.len() > MAX_SOURCEMAP_BYTES { + return None; + } + + let bytes = fs::read(path).ok()?; + let after = fs::metadata(path).ok()?; + if before.len() != after.len() || bytes.len() as u64 != after.len() { + return None; + } + if let (Ok(before_modified), Ok(after_modified)) = (before.modified(), after.modified()) { + if before_modified != after_modified { + return None; + } + } + + Some(bytes) +} + +fn source_display_path(candidate: &ScriptMap, source: &str) -> Option { + let entry_parent = Path::new(&candidate.generated_entry) + .parent() + .unwrap_or_else(|| Path::new("")); + let relative = normalize_relative_path(&entry_parent.join(source))?; + safe_pack_path(&candidate.pack_dir, &relative)?; + Some(normalize_slashes(&relative.to_string_lossy())) +} + +fn safe_pack_path(pack_dir: &Path, relative: &Path) -> Option { + let relative = normalize_relative_path(relative)?; + Some(pack_dir.join(relative)) +} + +fn normalize_relative_path(path: &Path) -> Option { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => normalized.push(part), + Component::ParentDir => { + if !normalized.pop() { + return None; + } + } + Component::Prefix(_) | Component::RootDir => return None, + } + } + + Some(normalized) +} + +fn normalize_slashes(value: &str) -> String { + value.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + const MAP: &str = r#"{"version":3,"file":"main.js","sources":["../src/main.ts"],"sourcesContent":["first\nsecond\n"],"names":[],"mappings":";;;AAAA;;;;AACA"}"#; + const TSDOWN_MAP: &str = r#"{"version":3,"file":"main.js","names":[],"sources":["../src/main.ts"],"sourcesContent":["import { world } from '@minecraft/server';\n\nfunction throwNestedSourcemapProbe(): never {\n throw new Error('BDS_ENHANCER_SOURCEMAP_PROBE');\n}\n\nworld.afterEvents.worldLoad.subscribe(() => {\n console.log('Hello world!');\n throwNestedSourcemapProbe();\n});\n"],"mappings":";;AAEA,SAAS,4BAAmC;CAC1C,MAAM,IAAI,MAAM,8BAA8B;AAChD;AAEA,MAAM,YAAY,UAAU,gBAAgB;CAC1C,QAAQ,IAAI,cAAc;CAC1B,0BAA0B;AAC5B,CAAC"}"#; + + struct Fixture { + root: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bds-enhancer-sourcemap-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("fixture root must be created"); + Self { root } + } + + fn add_pack( + &self, + root: &str, + directory: &str, + name: &str, + version: &str, + map: Option<&str>, + ) { + let pack = self.root.join(root).join(directory); + fs::create_dir_all(pack.join("scripts")).expect("scripts directory must be created"); + fs::create_dir_all(pack.join("src")).expect("source directory must be created"); + let uuid = Self::pack_uuid(directory); + let manifest = format!( + r#"{{"format_version":3,"header":{{"name":"{name}","uuid":"{uuid}","version":"{version}"}},"modules":[{{"type":"script","entry":"scripts/main.js"}}]}}"# + ); + fs::write(pack.join("manifest.json"), manifest).expect("manifest must be written"); + fs::write(pack.join("scripts/main.js"), "generated").expect("script must be written"); + fs::write(pack.join("src/main.ts"), "first\nsecond\n").expect("source must be written"); + if let Some(map) = map { + fs::write(pack.join("scripts/main.js.map"), map).expect("map must be written"); + } + } + + fn activate_world(&self, world_name: &str, packs: &[(&str, &str)]) { + let world = self.root.join("worlds").join(world_name); + fs::create_dir_all(&world).expect("active world directory must be created"); + fs::write( + self.root.join("server.properties"), + format!("server-name=Fixture\nlevel-name={world_name}\n"), + ) + .expect("server properties must be written"); + let references: Vec = packs + .iter() + .map(|(directory, version)| { + let version: Vec = version + .split('.') + .map(|part| part.parse().expect("fixture version must be numeric")) + .collect(); + serde_json::json!({ + "pack_id": Self::pack_uuid(directory), + "version": version, + }) + }) + .collect(); + fs::write( + world.join("world_behavior_packs.json"), + serde_json::to_vec(&references).expect("world pack references must serialize"), + ) + .expect("world pack references must be written"); + } + + fn pack_uuid(directory: &str) -> String { + format!("{directory}-uuid") + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn scripting_error(frames: &str) -> String { + format!("[2026 ERROR] [Scripting] [Test Pack] Error: probe{frames}") + } + + #[test] + fn resolves_a_frame_for_the_matching_pack() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + } + + #[test] + fn resolves_known_frames_and_preserves_unknown_frames() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)\n at missing (main.js:99)"); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error( + "\n at probe (src/main.ts:1) (main.js:4)\n at missing (main.js:99)" + ) + ); + } + + #[test] + fn styles_only_generated_locations_in_resolved_frames() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)\n at missing (main.js:99)"); + + assert_eq!( + resolver.resolve_log_with_generated_style(&input, "", ""), + scripting_error( + "\n at probe (src/main.ts:1) (main.js:4)\n at missing (main.js:99)" + ) + ); + } + + #[test] + fn resolves_the_real_tsdown_watch_map_without_generated_columns() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(TSDOWN_MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error( + "\n at throwNestedSourcemapProbe (main.js:4)\n at (main.js:8)", + ); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error( + "\n at throwNestedSourcemapProbe (src/main.ts:4) (main.js:4)\n at (src/main.ts:9) (main.js:8)" + ) + ); + } + + #[test] + fn treats_a_version_like_suffix_as_part_of_the_manifest_name() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "scriptapi-template v0.1.0", + "0.1.0", + Some(TSDOWN_MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = "[2026 ERROR] [Scripting] [scriptapi-template v0.1.0] Error: probe\n at probe (main.js:4)"; + + assert_eq!( + resolver.resolve_log(input), + "[2026 ERROR] [Scripting] [scriptapi-template v0.1.0] Error: probe\n at probe (src/main.ts:4) (main.js:4)" + ); + } + + #[test] + fn leaves_the_whole_log_unchanged_for_a_malformed_map() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some("{"), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn observes_map_updates_without_rebuilding_the_pack_index() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + + let updated_map = MAP.replace("../src/main.ts", "../src/updated.ts"); + fs::write( + fixture + .root + .join("system_behavior_packs/test/scripts/main.js.map"), + updated_map, + ) + .expect("updated map must be written"); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/updated.ts:1) (main.js:4)") + ); + } + + #[test] + fn falls_back_during_a_broken_update_and_recovers_afterward() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + let map_path = fixture + .root + .join("system_behavior_packs/test/scripts/main.js.map"); + + fs::write(&map_path, "{").expect("broken map must be written"); + assert_eq!(resolver.resolve_log(&input), input); + + fs::write(&map_path, MAP).expect("recovered map must be written"); + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + } + + #[test] + fn leaves_the_log_unchanged_when_the_map_is_missing() { + let fixture = Fixture::new(); + fixture.add_pack("system_behavior_packs", "test", "Test Pack", "1.2.3", None); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn leaves_the_log_unchanged_when_pack_identity_does_not_match() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "test", + "Other Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn resolves_multiple_packs_by_name_and_version() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "one", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let second_map = MAP.replace("../src/main.ts", "../src/second.ts"); + fixture.add_pack( + "development_behavior_packs", + "two", + "Second Pack", + "2.0.0", + Some(&second_map), + ); + fixture.activate_world("Active World", &[("two", "2.0.0")]); + let resolver = SourcemapResolver::discover(&fixture.root); + let first = scripting_error("\n at probe (main.js:4)"); + let second = + "[2026 ERROR] [Scripting] [Second Pack] Error: probe\n at probe (main.js:4)"; + + assert_eq!( + resolver.resolve_log(&first), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + assert_eq!( + resolver.resolve_log(second), + "[2026 ERROR] [Scripting] [Second Pack] Error: probe\n at probe (src/second.ts:1) (main.js:4)" + ); + } + + #[test] + fn ignores_the_internal_behavior_packs_root() { + let fixture = Fixture::new(); + fixture.add_pack( + "behavior_packs", + "internal", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn ignores_an_unreferenced_development_pack() { + let fixture = Fixture::new(); + fixture.add_pack( + "development_behavior_packs", + "dev", + "Test Pack", + "1.2.3", + Some(MAP), + ); + fixture.activate_world("Active World", &[]); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn discovers_a_referenced_pack_inside_the_active_world() { + let fixture = Fixture::new(); + fixture.add_pack( + "worlds/Active World/behavior_packs", + "world-pack", + "Test Pack", + "1.2.3", + Some(MAP), + ); + fixture.activate_world("Active World", &[("world-pack", "1.2.3")]); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + } + + #[test] + fn ignores_a_referenced_uuid_when_the_version_differs() { + let fixture = Fixture::new(); + fixture.add_pack( + "development_behavior_packs", + "dev", + "Test Pack", + "1.2.3", + Some(MAP), + ); + fixture.activate_world("Active World", &[("dev", "1.2.4")]); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn leaves_the_log_unchanged_when_matching_candidates_are_ambiguous() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "one", + "Test Pack", + "1.2.3", + Some(MAP), + ); + fixture.add_pack( + "system_behavior_packs", + "two", + "Test Pack", + "1.2.3", + Some(MAP), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } + + #[test] + fn uses_the_only_resolvable_candidate_when_another_map_is_broken() { + let fixture = Fixture::new(); + fixture.add_pack( + "system_behavior_packs", + "one", + "Test Pack", + "1.2.3", + Some(MAP), + ); + fixture.add_pack( + "system_behavior_packs", + "two", + "Test Pack", + "1.2.3", + Some("{"), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + } + + #[test] + fn supports_array_manifest_versions() { + let fixture = Fixture::new(); + let pack = fixture.root.join("system_behavior_packs/test"); + fs::create_dir_all(pack.join("scripts")).expect("scripts directory must be created"); + fs::write( + pack.join("manifest.json"), + r#"{"header":{"name":"Test Pack","version":[1,2,3]},"modules":[{"type":"script","entry":"scripts/main.js"}]}"#, + ) + .expect("manifest must be written"); + fs::write(pack.join("scripts/main.js.map"), MAP).expect("map must be written"); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!( + resolver.resolve_log(&input), + scripting_error("\n at probe (src/main.ts:1) (main.js:4)") + ); + } + + #[test] + fn rejects_source_paths_that_escape_the_pack() { + let fixture = Fixture::new(); + let escaping_map = MAP.replace("../src/main.ts", "../../outside.ts"); + fixture.add_pack( + "system_behavior_packs", + "test", + "Test Pack", + "1.2.3", + Some(&escaping_map), + ); + let resolver = SourcemapResolver::discover(&fixture.root); + let input = scripting_error("\n at probe (main.js:4)"); + + assert_eq!(resolver.resolve_log(&input), input); + } +}