Compare commits
6 Commits
410b2e64e9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a7bc6aca1 | |||
| 94b1b9a1f5 | |||
| 3afc122404 | |||
| 86a39dace7 | |||
| 2cc2a367b6 | |||
| bc9221d4cc |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@
|
||||
.idea/
|
||||
.claude/
|
||||
.qoder/
|
||||
.mcdev.json
|
||||
.mcdev.json
|
||||
.emod-cli/
|
||||
518
Cargo.lock
generated
518
Cargo.lock
generated
@@ -28,6 +28,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
@@ -86,6 +95,23 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -113,6 +139,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
@@ -151,6 +183,20 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -213,6 +259,12 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.16"
|
||||
@@ -262,6 +314,40 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.9"
|
||||
@@ -310,6 +396,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "emod-cli"
|
||||
version = "0.1.0-dev"
|
||||
@@ -318,8 +410,10 @@ dependencies = [
|
||||
"clap",
|
||||
"rand",
|
||||
"regex",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
@@ -343,6 +437,94 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
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"
|
||||
@@ -385,6 +567,36 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.7.0"
|
||||
@@ -425,6 +637,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.169"
|
||||
@@ -474,12 +697,27 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[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.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
@@ -490,6 +728,12 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.31"
|
||||
@@ -559,6 +803,26 @@ dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
@@ -588,6 +852,47 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
@@ -603,6 +908,32 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.217"
|
||||
@@ -623,6 +954,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.134"
|
||||
@@ -667,6 +1009,12 @@ version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -729,6 +1077,41 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"pin-project-lite",
|
||||
"tokio-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -770,6 +1153,37 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[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 = "typenum"
|
||||
version = "1.17.0"
|
||||
@@ -832,6 +1246,51 @@ version = "0.11.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.9"
|
||||
@@ -841,6 +1300,65 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
|
||||
@@ -12,6 +12,8 @@ clap = { version = "4.5.32", features = ["derive"] }
|
||||
uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
rmcp = { version = "0.16.0", features = ["server", "macros", "transport-io"] }
|
||||
tokio = { version = "1", features = ["macros", "rt", "io-std", "time"] }
|
||||
base64 = "0.22"
|
||||
toml = "0.8"
|
||||
zip = "2.2.2"
|
||||
@@ -30,5 +32,6 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
20
README.md
20
README.md
@@ -54,7 +54,25 @@ emod-cli debug --new # 创建全新调试存档
|
||||
2. 清理运行时旧包链接
|
||||
3. 注册调试 MOD 并链接用户 MOD 目录
|
||||
4. 准备开发世界(含自动加入游戏配置)
|
||||
5. 启动游戏进程,挂载 IPC 日志与热重载
|
||||
5. 启动游戏进程;启用内置调试 MOD 时挂载 IPC 日志、热重载与 debug MCP bridge
|
||||
|
||||
### mcp
|
||||
|
||||
启动标准 stdio MCP server,让 AI 连接正在运行的 `debug` 会话并在游戏内放置方块。`mcp` 命令不会启动游戏;必须先为同一项目启动启用了内置调试 MOD 的 `debug`。
|
||||
|
||||
```bash
|
||||
# 终端 1:启动游戏并注入 debug MCP bridge
|
||||
emod-cli debug --path <项目路径>
|
||||
|
||||
# MCP 客户端配置:启动 stdio MCP server
|
||||
emod-cli mcp --path <项目路径>
|
||||
```
|
||||
|
||||
工具示例:
|
||||
|
||||
```text
|
||||
place_block {"x":0,"y":5,"z":0,"name":"minecraft:stone"}
|
||||
```
|
||||
|
||||
### release
|
||||
|
||||
|
||||
@@ -18,4 +18,11 @@ def GET_DEBUG_IPC_PORT():
|
||||
port = os.getenv("MCDEV_DEBUG_IPC_PORT")
|
||||
if port is None:
|
||||
return None
|
||||
return int(port)
|
||||
return int(port)
|
||||
|
||||
def GET_MCP_BRIDGE_STATE_PATH():
|
||||
import os
|
||||
path = os.getenv("MCDEV_MCP_BRIDGE_STATE")
|
||||
if not path:
|
||||
return None
|
||||
return path
|
||||
77
debug_mod/DEBUG_ENV_SCRIPT/DebugClientSystem.py
Normal file
77
debug_mod/DEBUG_ENV_SCRIPT/DebugClientSystem.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import mod.client.extraClientApi as clientApi
|
||||
|
||||
from . import IPCSystem, Lifecycle
|
||||
from .Config import DEBUG_CONFIG
|
||||
from .Game import (
|
||||
INIT_RELOAD_TIME,
|
||||
RELOAD_ADDON,
|
||||
RELOAD_MOD,
|
||||
RELOAD_SHADERS,
|
||||
RELOAD_WORLD,
|
||||
)
|
||||
|
||||
|
||||
ClientSystem = clientApi.GetClientSystemCls()
|
||||
ENGINE_NAMESPACE = clientApi.GetEngineNamespace()
|
||||
ENGINE_SYSTEM_NAME = clientApi.GetEngineSystemName()
|
||||
|
||||
|
||||
class DebugClientSystem(ClientSystem):
|
||||
def __init__(self, namespace, system_name):
|
||||
ClientSystem.__init__(self, namespace, system_name)
|
||||
self._initialized = False
|
||||
self._listening = False
|
||||
|
||||
def Update(self):
|
||||
if not self._initialized:
|
||||
Lifecycle.activate(self)
|
||||
IPCSystem.ON_CLIENT_INIT()
|
||||
self.ListenForEvent(
|
||||
ENGINE_NAMESPACE,
|
||||
ENGINE_SYSTEM_NAME,
|
||||
"OnKeyPressInGame",
|
||||
self,
|
||||
self.on_key_press_in_game,
|
||||
)
|
||||
self._listening = True
|
||||
self._initialized = True
|
||||
INIT_RELOAD_TIME()
|
||||
IPCSystem.UPDATE_CLIENT()
|
||||
return ClientSystem.Update(self)
|
||||
|
||||
def Destroy(self):
|
||||
try:
|
||||
if self._listening:
|
||||
self.UnListenForEvent(
|
||||
ENGINE_NAMESPACE,
|
||||
ENGINE_SYSTEM_NAME,
|
||||
"OnKeyPressInGame",
|
||||
self,
|
||||
self.on_key_press_in_game,
|
||||
)
|
||||
self._listening = False
|
||||
finally:
|
||||
try:
|
||||
IPCSystem.ON_CLIENT_EXIT()
|
||||
finally:
|
||||
self._initialized = False
|
||||
Lifecycle.deactivate(self)
|
||||
|
||||
def on_key_press_in_game(self, args):
|
||||
if args["isDown"] != "0":
|
||||
return
|
||||
if args["screenName"] != "hud_screen" and not DEBUG_CONFIG.get(
|
||||
"reload_key_global", False
|
||||
):
|
||||
return
|
||||
|
||||
key = args["key"]
|
||||
if key == str(DEBUG_CONFIG.get("reload_key", "82")):
|
||||
RELOAD_MOD()
|
||||
elif key == str(DEBUG_CONFIG.get("reload_world_key", "")):
|
||||
RELOAD_WORLD()
|
||||
elif key == str(DEBUG_CONFIG.get("reload_addon_key", "")):
|
||||
RELOAD_ADDON()
|
||||
elif key == str(DEBUG_CONFIG.get("reload_shaders_key", "")):
|
||||
RELOAD_SHADERS()
|
||||
33
debug_mod/DEBUG_ENV_SCRIPT/DebugServerSystem.py
Normal file
33
debug_mod/DEBUG_ENV_SCRIPT/DebugServerSystem.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import mod.server.extraServerApi as serverApi
|
||||
|
||||
from . import IPCSystem, Lifecycle, MCPBridge
|
||||
|
||||
|
||||
ServerSystem = serverApi.GetServerSystemCls()
|
||||
|
||||
|
||||
class DebugServerSystem(ServerSystem):
|
||||
def __init__(self, namespace, system_name):
|
||||
ServerSystem.__init__(self, namespace, system_name)
|
||||
self._initialized = False
|
||||
|
||||
def Update(self):
|
||||
if not self._initialized:
|
||||
Lifecycle.activate(self)
|
||||
IPCSystem.ON_SERVER_INIT()
|
||||
MCPBridge.ON_SERVER_INIT()
|
||||
self._initialized = True
|
||||
IPCSystem.UPDATE_SERVER()
|
||||
MCPBridge.UPDATE()
|
||||
return ServerSystem.Update(self)
|
||||
|
||||
def Destroy(self):
|
||||
try:
|
||||
MCPBridge.ON_SERVER_EXIT()
|
||||
finally:
|
||||
try:
|
||||
IPCSystem.ON_SERVER_EXIT()
|
||||
finally:
|
||||
self._initialized = False
|
||||
Lifecycle.deactivate(self)
|
||||
@@ -4,6 +4,7 @@ import mod.client.extraClientApi as clientApi
|
||||
from .Config import GET_DEBUG_IPC_PORT
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
|
||||
def U16_BE(b):
|
||||
@@ -24,74 +25,162 @@ class IPCSystem:
|
||||
self.port = port
|
||||
self.sock = None
|
||||
self.mLock = threading.Lock()
|
||||
self.handers = {}
|
||||
self.handlers = {}
|
||||
self.pendingLock = threading.Lock()
|
||||
self.pendingClient = []
|
||||
self.pendingServer = []
|
||||
self.running = False
|
||||
self.generation = 0
|
||||
self.thread = None
|
||||
self.nextStartTime = 0.0
|
||||
|
||||
def registerHandler(self, typeID, handler):
|
||||
# type: (int, callable) -> None
|
||||
self.handers[typeID] = handler
|
||||
self.handlers[typeID] = handler
|
||||
|
||||
def updateHandlers(self, handlers):
|
||||
# type: (dict[int, callable]) -> None
|
||||
self.handers.update(handlers)
|
||||
self.handlers.update(handlers)
|
||||
|
||||
def start(self):
|
||||
if self.sock or not self.port:
|
||||
return
|
||||
threading.Thread(target=self._threadListenLoop).start()
|
||||
now = time.time()
|
||||
with self.mLock:
|
||||
if self.running or not self.port or now < self.nextStartTime:
|
||||
return
|
||||
self.running = True
|
||||
self.nextStartTime = now + 1.0
|
||||
self.generation += 1
|
||||
generation = self.generation
|
||||
thread = threading.Thread(
|
||||
target=self._threadListenLoop,
|
||||
args=(generation,),
|
||||
)
|
||||
thread.daemon = True
|
||||
self.thread = thread
|
||||
thread.start()
|
||||
|
||||
def close(self):
|
||||
sock = None
|
||||
with self.mLock:
|
||||
self.running = False
|
||||
self.generation += 1
|
||||
self.nextStartTime = 0.0
|
||||
sock = self.sock
|
||||
self.sock = None
|
||||
thread = self.thread
|
||||
if sock:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
sock.close()
|
||||
|
||||
def _threadListenLoop(self):
|
||||
with self.mLock:
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock = self.sock
|
||||
sock.connect(("localhost", self.port))
|
||||
sock.settimeout(0.05)
|
||||
print("[IPCSystem] 已连接到调试服务器,端口:" + str(self.port))
|
||||
# [2B TypeID][4B DataLength][Data]
|
||||
def _recvAll(sock, length):
|
||||
# type: (socket.socket, int) -> bytearray
|
||||
buf = bytearray()
|
||||
while len(buf) < length:
|
||||
more = sock.recv(length - len(buf))
|
||||
if not more:
|
||||
raise EOFError("Socket closed before receiving all data")
|
||||
buf.extend(more)
|
||||
return buf
|
||||
while 1:
|
||||
try:
|
||||
header = _recvAll(sock, 6)
|
||||
typeID = U16_BE(header[0:2])
|
||||
dataLength = U32_BE(header[2:6])
|
||||
data = _recvAll(sock, dataLength)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except EOFError:
|
||||
break
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except socket.error:
|
||||
break
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except socket.error:
|
||||
pass
|
||||
if thread:
|
||||
thread.join(1.0)
|
||||
with self.pendingLock:
|
||||
self.pendingClient = []
|
||||
self.pendingServer = []
|
||||
|
||||
def updateClient(self):
|
||||
self.start()
|
||||
with self.pendingLock:
|
||||
pending = self.pendingClient
|
||||
self.pendingClient = []
|
||||
self._runPending(pending)
|
||||
|
||||
def updateServer(self):
|
||||
with self.pendingLock:
|
||||
pending = self.pendingServer
|
||||
self.pendingServer = []
|
||||
self._runPending(pending)
|
||||
|
||||
@staticmethod
|
||||
def _runPending(pending):
|
||||
for handler, data in pending:
|
||||
try:
|
||||
handler(data)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
if typeID in self.handers:
|
||||
try:
|
||||
self.handers[typeID](data)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print("[IPCSystem] 未知的TypeID数据包:" + str(typeID))
|
||||
|
||||
def _threadListenLoop(self, generation):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
with self.mLock:
|
||||
self.sock = None
|
||||
print("[IPCSystem] 连接已关闭")
|
||||
if not self.running or generation != self.generation:
|
||||
sock.close()
|
||||
return
|
||||
self.sock = sock
|
||||
|
||||
try:
|
||||
sock.settimeout(2.0)
|
||||
sock.connect(("localhost", self.port))
|
||||
sock.settimeout(0.2)
|
||||
print("[IPCSystem] 已连接到调试服务器,端口:" + str(self.port))
|
||||
while self._isCurrent(generation, sock):
|
||||
header = self._recvAll(generation, sock, 6)
|
||||
typeID = U16_BE(header[0:2])
|
||||
dataLength = U32_BE(header[2:6])
|
||||
data = self._recvAll(generation, sock, dataLength)
|
||||
self._queueHandler(generation, typeID, data)
|
||||
except EOFError:
|
||||
pass
|
||||
except socket.error:
|
||||
if self._isCurrent(generation, sock):
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except socket.error:
|
||||
pass
|
||||
with self.mLock:
|
||||
if self.sock is sock:
|
||||
self.sock = None
|
||||
if self.thread is threading.currentThread():
|
||||
self.thread = None
|
||||
if generation == self.generation:
|
||||
self.running = False
|
||||
print("[IPCSystem] 连接已关闭")
|
||||
|
||||
def _isCurrent(self, generation, sock):
|
||||
with self.mLock:
|
||||
return (
|
||||
self.running
|
||||
and generation == self.generation
|
||||
and self.sock is sock
|
||||
)
|
||||
|
||||
def _queueHandler(self, generation, typeID, data):
|
||||
handler = self.handlers.get(typeID)
|
||||
if handler is None:
|
||||
print("[IPCSystem] 未知的TypeID数据包:" + str(typeID))
|
||||
return
|
||||
with self.mLock:
|
||||
if not self.running or generation != self.generation:
|
||||
return
|
||||
with self.pendingLock:
|
||||
if typeID == 4:
|
||||
self.pendingServer.append((handler, data))
|
||||
else:
|
||||
self.pendingClient.append((handler, data))
|
||||
|
||||
def _recvAll(self, generation, sock, length):
|
||||
buf = bytearray()
|
||||
while len(buf) < length:
|
||||
try:
|
||||
more = sock.recv(length - len(buf))
|
||||
except socket.timeout:
|
||||
if not self._isCurrent(generation, sock):
|
||||
raise EOFError("IPC connection stopped")
|
||||
continue
|
||||
if not more:
|
||||
raise EOFError("Socket closed before receiving all data")
|
||||
buf.extend(more)
|
||||
return buf
|
||||
|
||||
_CL_GAME_COMP = None
|
||||
_SR_GAME_COMP = None
|
||||
@@ -168,14 +257,25 @@ _IPCSYSTEM.updateHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
def UPDATE_CLIENT():
|
||||
_IPCSYSTEM.updateClient()
|
||||
|
||||
def UPDATE_SERVER():
|
||||
_IPCSYSTEM.updateServer()
|
||||
|
||||
def ON_CLIENT_INIT():
|
||||
global _CL_GAME_COMP
|
||||
_CL_GAME_COMP = clientApi.GetEngineCompFactory().CreateGame(clientApi.GetLevelId())
|
||||
_IPCSYSTEM.start()
|
||||
|
||||
def ON_CLIENT_EXIT():
|
||||
global _CL_GAME_COMP
|
||||
_IPCSYSTEM.close()
|
||||
_CL_GAME_COMP = None
|
||||
|
||||
def ON_SERVER_INIT():
|
||||
global _SR_GAME_COMP
|
||||
_SR_GAME_COMP = serverApi.GetEngineCompFactory().CreateGame(serverApi.GetLevelId())
|
||||
_SR_GAME_COMP = serverApi.GetEngineCompFactory().CreateGame(serverApi.GetLevelId())
|
||||
|
||||
def ON_SERVER_EXIT():
|
||||
global _SR_GAME_COMP
|
||||
_SR_GAME_COMP = None
|
||||
|
||||
98
debug_mod/DEBUG_ENV_SCRIPT/Lifecycle.py
Normal file
98
debug_mod/DEBUG_ENV_SCRIPT/Lifecycle.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
_OUTPUT_PREFIX = "[Python] "
|
||||
_ACTIVE_SYSTEMS = set()
|
||||
_STATE_LOCK = threading.Lock()
|
||||
_ORIGINAL_STDOUT = None
|
||||
_ORIGINAL_STDERR = None
|
||||
|
||||
|
||||
class OutputWrapper(object):
|
||||
_emod_cli_debug_wrapper = True
|
||||
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
self.lock = threading.Lock()
|
||||
self.buffer = []
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.stream, name)
|
||||
|
||||
def write(self, data):
|
||||
with self.lock:
|
||||
parts = data.splitlines(True)
|
||||
for part in parts:
|
||||
if part.endswith("\n"):
|
||||
if self.buffer:
|
||||
line = "".join(self.buffer) + part
|
||||
self.buffer = []
|
||||
else:
|
||||
line = part
|
||||
self.stream.write(_OUTPUT_PREFIX + line)
|
||||
else:
|
||||
self.buffer.append(part)
|
||||
|
||||
def close(self):
|
||||
self.flush()
|
||||
return self.stream.close()
|
||||
|
||||
def flush(self):
|
||||
with self.lock:
|
||||
if self.buffer:
|
||||
self.stream.write(_OUTPUT_PREFIX + "".join(self.buffer))
|
||||
self.buffer = []
|
||||
self.stream.flush()
|
||||
|
||||
def writelines(self, lines):
|
||||
for line in lines:
|
||||
self.write(line)
|
||||
|
||||
def fileno(self):
|
||||
return self.stream.fileno()
|
||||
|
||||
|
||||
def _install_output_wrapper_locked():
|
||||
global _ORIGINAL_STDOUT, _ORIGINAL_STDERR
|
||||
|
||||
if not getattr(sys.stdout, "_emod_cli_debug_wrapper", False):
|
||||
_ORIGINAL_STDOUT = sys.stdout
|
||||
sys.stdout = OutputWrapper(sys.stdout)
|
||||
elif _ORIGINAL_STDOUT is None:
|
||||
_ORIGINAL_STDOUT = getattr(sys.stdout, "stream")
|
||||
|
||||
if not getattr(sys.stderr, "_emod_cli_debug_wrapper", False):
|
||||
_ORIGINAL_STDERR = sys.stderr
|
||||
sys.stderr = OutputWrapper(sys.stderr)
|
||||
elif _ORIGINAL_STDERR is None:
|
||||
_ORIGINAL_STDERR = getattr(sys.stderr, "stream")
|
||||
|
||||
|
||||
def install_output_wrapper():
|
||||
with _STATE_LOCK:
|
||||
_install_output_wrapper_locked()
|
||||
|
||||
|
||||
def activate(system):
|
||||
with _STATE_LOCK:
|
||||
_install_output_wrapper_locked()
|
||||
_ACTIVE_SYSTEMS.add(system)
|
||||
|
||||
|
||||
def deactivate(system):
|
||||
global _ORIGINAL_STDOUT, _ORIGINAL_STDERR
|
||||
|
||||
with _STATE_LOCK:
|
||||
_ACTIVE_SYSTEMS.discard(system)
|
||||
if _ACTIVE_SYSTEMS:
|
||||
return
|
||||
if _ORIGINAL_STDOUT is None or _ORIGINAL_STDERR is None:
|
||||
return
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
sys.stdout = _ORIGINAL_STDOUT
|
||||
sys.stderr = _ORIGINAL_STDERR
|
||||
_ORIGINAL_STDOUT = None
|
||||
_ORIGINAL_STDERR = None
|
||||
438
debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py
Normal file
438
debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py
Normal file
@@ -0,0 +1,438 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
import mod.server.extraServerApi as serverApi
|
||||
|
||||
from .Config import GET_MCP_BRIDGE_STATE_PATH
|
||||
|
||||
try:
|
||||
basestring
|
||||
except NameError:
|
||||
basestring = str
|
||||
|
||||
try:
|
||||
integer_types = (int, long)
|
||||
except NameError:
|
||||
integer_types = (int,)
|
||||
|
||||
_BRIDGE = None
|
||||
_SERVER_ACTIVE = False
|
||||
_SERVER_TASKS = []
|
||||
_SERVER_TASKS_LOCK = threading.Lock()
|
||||
|
||||
MAX_PENDING_TASKS = 32
|
||||
MAX_REQUEST_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class BridgeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _is_int(value):
|
||||
return isinstance(value, integer_types) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _event_is_set(event):
|
||||
if hasattr(event, "is_set"):
|
||||
return event.is_set()
|
||||
return event.isSet()
|
||||
|
||||
|
||||
def _create_daemon_thread(target, args=()):
|
||||
thread = threading.Thread(target=target, args=args)
|
||||
try:
|
||||
thread.daemon = True
|
||||
except Exception:
|
||||
thread.setDaemon(True)
|
||||
return thread
|
||||
|
||||
|
||||
def _to_text(data):
|
||||
if isinstance(data, bytes):
|
||||
return data.decode("utf-8")
|
||||
return data
|
||||
|
||||
|
||||
def _send_json_line(sock, payload):
|
||||
data = json.dumps(payload, separators=(",", ":")) + "\n"
|
||||
if not isinstance(data, bytes):
|
||||
data = data.encode("utf-8")
|
||||
sock.sendall(data)
|
||||
|
||||
|
||||
def _require_params(params):
|
||||
if not isinstance(params, dict):
|
||||
raise BridgeError("place_block params must be an object")
|
||||
|
||||
for key in ("x", "y", "z"):
|
||||
if key not in params or not _is_int(params.get(key)):
|
||||
raise BridgeError("place_block.%s must be an integer" % key)
|
||||
|
||||
name = params.get("name")
|
||||
if not isinstance(name, basestring) or not name.strip() or ":" not in name:
|
||||
raise BridgeError("place_block.name must be a non-empty namespaced block id")
|
||||
|
||||
dimension = params.get("dimension", 0)
|
||||
if not _is_int(dimension):
|
||||
raise BridgeError("place_block.dimension must be an integer")
|
||||
|
||||
old_block_handling = params.get("old_block_handling", 0)
|
||||
if not _is_int(old_block_handling) or old_block_handling not in (0, 1, 2):
|
||||
raise BridgeError("place_block.old_block_handling must be 0, 1, or 2")
|
||||
|
||||
aux = params.get("aux", None)
|
||||
states = params.get("states", None)
|
||||
if aux is not None and states is not None:
|
||||
raise BridgeError("place_block.aux and place_block.states cannot both be provided")
|
||||
if aux is not None and not _is_int(aux):
|
||||
raise BridgeError("place_block.aux must be an integer")
|
||||
if states is not None and not isinstance(states, dict):
|
||||
raise BridgeError("place_block.states must be an object")
|
||||
|
||||
is_legacy = params.get("is_legacy", True)
|
||||
if not isinstance(is_legacy, bool):
|
||||
raise BridgeError("place_block.is_legacy must be a boolean")
|
||||
|
||||
update_neighbors = params.get("update_neighbors", True)
|
||||
if not isinstance(update_neighbors, bool):
|
||||
raise BridgeError("place_block.update_neighbors must be a boolean")
|
||||
|
||||
return {
|
||||
"x": params["x"],
|
||||
"y": params["y"],
|
||||
"z": params["z"],
|
||||
"name": name,
|
||||
"dimension": dimension,
|
||||
"aux": aux,
|
||||
"states": states,
|
||||
"old_block_handling": old_block_handling,
|
||||
"is_legacy": is_legacy,
|
||||
"update_neighbors": update_neighbors,
|
||||
}
|
||||
|
||||
|
||||
def _place_block_on_server(request):
|
||||
name = request["name"]
|
||||
aux = request["aux"]
|
||||
states = request["states"]
|
||||
if states is not None:
|
||||
block_state = serverApi.GetEngineCompFactory().CreateBlockState(serverApi.GetLevelId())
|
||||
aux = block_state.GetBlockAuxValueFromStates(name, states)
|
||||
if aux == -1:
|
||||
raise BridgeError("invalid block states for %s" % name)
|
||||
elif aux is None:
|
||||
aux = 0
|
||||
|
||||
x = request["x"]
|
||||
y = request["y"]
|
||||
z = request["z"]
|
||||
dimension = request["dimension"]
|
||||
old_block_handling = request["old_block_handling"]
|
||||
is_legacy = request["is_legacy"]
|
||||
update_neighbors = request["update_neighbors"]
|
||||
|
||||
block_info = serverApi.GetEngineCompFactory().CreateBlockInfo(serverApi.GetLevelId())
|
||||
changed = block_info.SetBlockNew(
|
||||
(x, y, z),
|
||||
{"name": name, "aux": aux},
|
||||
old_block_handling,
|
||||
dimension,
|
||||
is_legacy,
|
||||
update_neighbors,
|
||||
)
|
||||
return {
|
||||
"changed": bool(changed),
|
||||
"position": [x, y, z],
|
||||
"dimension": dimension,
|
||||
"block": {"name": name, "aux": aux},
|
||||
"old_block_handling": old_block_handling,
|
||||
"is_legacy": is_legacy,
|
||||
"update_neighbors": update_neighbors,
|
||||
}
|
||||
|
||||
|
||||
def _place_block(params):
|
||||
request = _require_params(params)
|
||||
task = {
|
||||
"request": request,
|
||||
"done": threading.Event(),
|
||||
"cancelled": False,
|
||||
"started": False,
|
||||
}
|
||||
with _SERVER_TASKS_LOCK:
|
||||
if not _SERVER_ACTIVE:
|
||||
raise BridgeError("server system is not ready")
|
||||
if len(_SERVER_TASKS) >= MAX_PENDING_TASKS:
|
||||
raise BridgeError("server request queue is full")
|
||||
_SERVER_TASKS.append(task)
|
||||
|
||||
task["done"].wait(5.0)
|
||||
if not _event_is_set(task["done"]):
|
||||
with _SERVER_TASKS_LOCK:
|
||||
if task["started"]:
|
||||
started = True
|
||||
else:
|
||||
started = False
|
||||
task["cancelled"] = True
|
||||
if task in _SERVER_TASKS:
|
||||
_SERVER_TASKS.remove(task)
|
||||
if started:
|
||||
task["done"].wait()
|
||||
else:
|
||||
raise BridgeError("place_block timed out waiting for server thread")
|
||||
if "error" in task:
|
||||
raise BridgeError(task["error"])
|
||||
return task["result"]
|
||||
|
||||
|
||||
def UPDATE():
|
||||
with _SERVER_TASKS_LOCK:
|
||||
tasks = list(_SERVER_TASKS)
|
||||
del _SERVER_TASKS[:]
|
||||
|
||||
for task in tasks:
|
||||
with _SERVER_TASKS_LOCK:
|
||||
if task["cancelled"]:
|
||||
task["done"].set()
|
||||
continue
|
||||
task["started"] = True
|
||||
try:
|
||||
task["result"] = _place_block_on_server(task["request"])
|
||||
except BridgeError as err:
|
||||
task["error"] = str(err)
|
||||
except Exception as err:
|
||||
traceback.print_exc()
|
||||
task["error"] = str(err)
|
||||
finally:
|
||||
task["done"].set()
|
||||
|
||||
|
||||
def _cancel_server_tasks():
|
||||
with _SERVER_TASKS_LOCK:
|
||||
tasks = list(_SERVER_TASKS)
|
||||
del _SERVER_TASKS[:]
|
||||
for task in tasks:
|
||||
task["cancelled"] = True
|
||||
task["error"] = "server stopped before executing request"
|
||||
task["done"].set()
|
||||
|
||||
|
||||
class MCPBridge(object):
|
||||
def __init__(self, state_path):
|
||||
self.state_path = state_path
|
||||
self.sock = None
|
||||
self.lock = threading.Lock()
|
||||
self.running = False
|
||||
self.connections = set()
|
||||
self.accept_thread = None
|
||||
self.client_threads = set()
|
||||
self.port = None
|
||||
self.tmp_path = None
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
if self.running:
|
||||
return
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(4)
|
||||
sock.settimeout(0.2)
|
||||
self.sock = sock
|
||||
self.running = True
|
||||
port = sock.getsockname()[1]
|
||||
self.port = port
|
||||
|
||||
self._write_state(port)
|
||||
print("[MCPBridge] Listening on 127.0.0.1:%d" % port)
|
||||
thread = _create_daemon_thread(self._accept_loop)
|
||||
with self.lock:
|
||||
self.accept_thread = thread
|
||||
thread.start()
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
sock = self.sock
|
||||
self.sock = None
|
||||
self.running = False
|
||||
connections = list(self.connections)
|
||||
self.connections.clear()
|
||||
accept_thread = self.accept_thread
|
||||
self.accept_thread = None
|
||||
client_threads = list(self.client_threads)
|
||||
self.client_threads.clear()
|
||||
if sock:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
for conn in connections:
|
||||
try:
|
||||
conn.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if accept_thread and accept_thread.is_alive():
|
||||
accept_thread.join(1.0)
|
||||
for thread in client_threads:
|
||||
if thread.is_alive():
|
||||
thread.join(1.0)
|
||||
self._delete_state()
|
||||
|
||||
def _write_state(self, port):
|
||||
state_dir = os.path.dirname(self.state_path)
|
||||
if state_dir and not os.path.isdir(state_dir):
|
||||
os.makedirs(state_dir)
|
||||
tmp_path = "%s.%d.tmp" % (self.state_path, port)
|
||||
self.tmp_path = tmp_path
|
||||
data = json.dumps(
|
||||
{"version": 1, "host": "127.0.0.1", "port": port},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
with open(tmp_path, "w") as fp:
|
||||
fp.write(data)
|
||||
os.rename(tmp_path, self.state_path)
|
||||
self.tmp_path = None
|
||||
|
||||
def _delete_state(self):
|
||||
try:
|
||||
if self.tmp_path and os.path.exists(self.tmp_path):
|
||||
os.remove(self.tmp_path)
|
||||
self.tmp_path = None
|
||||
if not os.path.exists(self.state_path) or self.port is None:
|
||||
return
|
||||
with open(self.state_path, "r") as fp:
|
||||
state = json.load(fp)
|
||||
if state.get("host") == "127.0.0.1" and state.get("port") == self.port:
|
||||
os.remove(self.state_path)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
self.port = None
|
||||
|
||||
def _accept_loop(self):
|
||||
while True:
|
||||
with self.lock:
|
||||
sock = self.sock
|
||||
running = self.running
|
||||
if not running or sock is None:
|
||||
return
|
||||
try:
|
||||
conn, _addr = sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except socket.error:
|
||||
with self.lock:
|
||||
if not self.running:
|
||||
return
|
||||
traceback.print_exc()
|
||||
return
|
||||
except Exception:
|
||||
with self.lock:
|
||||
if not self.running:
|
||||
return
|
||||
traceback.print_exc()
|
||||
return
|
||||
with self.lock:
|
||||
if not self.running:
|
||||
conn.close()
|
||||
return
|
||||
self.connections.add(conn)
|
||||
thread = _create_daemon_thread(self._handle_client, (conn,))
|
||||
self.client_threads.add(thread)
|
||||
conn.settimeout(0.2)
|
||||
thread.start()
|
||||
|
||||
def _handle_client(self, conn):
|
||||
try:
|
||||
pending = b""
|
||||
while True:
|
||||
with self.lock:
|
||||
if not self.running:
|
||||
return
|
||||
try:
|
||||
chunk = conn.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not chunk:
|
||||
return
|
||||
pending += chunk
|
||||
if len(pending) > MAX_REQUEST_BYTES:
|
||||
raise BridgeError("request line exceeds maximum size")
|
||||
while b"\n" in pending:
|
||||
raw_line, pending = pending.split(b"\n", 1)
|
||||
line = _to_text(raw_line).strip()
|
||||
if line:
|
||||
self._handle_line(conn, line)
|
||||
except Exception:
|
||||
with self.lock:
|
||||
if self.running:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with self.lock:
|
||||
self.connections.discard(conn)
|
||||
self.client_threads.discard(threading.currentThread())
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle_line(self, conn, line):
|
||||
request_id = None
|
||||
try:
|
||||
request = json.loads(line)
|
||||
if not isinstance(request, dict):
|
||||
raise BridgeError("request must be an object")
|
||||
request_id = request.get("id")
|
||||
method = request.get("method")
|
||||
if method != "place_block":
|
||||
raise BridgeError("unknown MCP bridge method: %s" % method)
|
||||
result = _place_block(request.get("params", {}))
|
||||
_send_json_line(conn, {"id": request_id, "ok": True, "result": result})
|
||||
except BridgeError as err:
|
||||
_send_json_line(conn, {"id": request_id, "ok": False, "error": str(err)})
|
||||
except Exception as err:
|
||||
traceback.print_exc()
|
||||
_send_json_line(conn, {"id": request_id, "ok": False, "error": str(err)})
|
||||
|
||||
|
||||
def ON_SERVER_INIT():
|
||||
global _BRIDGE, _SERVER_ACTIVE
|
||||
state_path = GET_MCP_BRIDGE_STATE_PATH()
|
||||
if not state_path:
|
||||
return
|
||||
if _BRIDGE:
|
||||
return
|
||||
bridge = MCPBridge(state_path)
|
||||
with _SERVER_TASKS_LOCK:
|
||||
_SERVER_ACTIVE = True
|
||||
try:
|
||||
bridge.start()
|
||||
except Exception:
|
||||
with _SERVER_TASKS_LOCK:
|
||||
_SERVER_ACTIVE = False
|
||||
bridge.close()
|
||||
raise
|
||||
_BRIDGE = bridge
|
||||
|
||||
|
||||
def ON_SERVER_EXIT():
|
||||
global _BRIDGE, _SERVER_ACTIVE
|
||||
bridge = _BRIDGE
|
||||
_BRIDGE = None
|
||||
with _SERVER_TASKS_LOCK:
|
||||
_SERVER_ACTIVE = False
|
||||
_cancel_server_tasks()
|
||||
if bridge:
|
||||
bridge.close()
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .Util import SystemSide
|
||||
lambda: "By Zero123"
|
||||
|
||||
IsServerUser = False
|
||||
ModDirName = SystemSide.__module__.split(".")[0]
|
||||
QuModLibsPath = SystemSide.__module__[:SystemSide.__module__.rfind(".")]
|
||||
|
||||
class RuntimeService:
|
||||
_serverSystemList = []
|
||||
_clientSystemList = []
|
||||
_serverStarting = False
|
||||
_clientStarting = False
|
||||
# LOADER SYSTEM
|
||||
_serverLoadBefore = [] # type: list[function]
|
||||
_clientLoadBefore = [] # type: list[function]
|
||||
_serverLoadFinish = [] # type: list[function]
|
||||
_clientLoadFinish = [] # type: list[function]
|
||||
# THREAD ID
|
||||
_serverThreadID = None
|
||||
_clientThreadID = None
|
||||
_envPlayerId = None
|
||||
|
||||
def getUnderlineModDirName():
|
||||
# type: () -> str
|
||||
""" 获取下划线MOD目录名称 返回结果与preset内置变量__LQuModName__一致 (仅支持ascii字符串) """
|
||||
newStr = [] # type: list[int]
|
||||
for i, _charStr in enumerate(ModDirName):
|
||||
_char = ord(_charStr)
|
||||
if (_char >= 65 and _char <= 90):
|
||||
# 大写内容 进行处理
|
||||
if i > 0:
|
||||
newStr.append(ord("_"))
|
||||
newStr.append(_char + (97 - 65))
|
||||
continue
|
||||
# 常规小写内容 直接追加
|
||||
newStr.append(_char)
|
||||
return "".join((chr(x) for x in newStr))
|
||||
|
||||
def GET_THREAD_ID():
|
||||
""" 获取当前线程ID """
|
||||
from threading import current_thread
|
||||
return current_thread().ident
|
||||
|
||||
def IS_SERVER_THREAD():
|
||||
""" 检查是不是服务端线程 """
|
||||
return RuntimeService._serverThreadID != None and GET_THREAD_ID() == RuntimeService._serverThreadID
|
||||
|
||||
def IS_CLIENT_THREAD():
|
||||
""" 检查是不是客户端线程 """
|
||||
return RuntimeService._clientThreadID != None and GET_THREAD_ID() == RuntimeService._clientThreadID
|
||||
|
||||
def GET_THREAD_TYPE():
|
||||
""" 获取线程类型 -1.主线程 0.服务端线程 1.客户端线程 """
|
||||
tid = GET_THREAD_ID()
|
||||
if tid == RuntimeService._serverThreadID:
|
||||
return 0
|
||||
elif tid == RuntimeService._clientThreadID:
|
||||
return 1
|
||||
return -1
|
||||
@@ -1,17 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
Version = "1.4.1" # 版本信息
|
||||
ApiVersion = 4 # API版本
|
||||
Author = "Zero123" # 创作者
|
||||
ContactInformation = "QQ:913702423" # 联系方式
|
||||
Other = """
|
||||
# QuModLibs By Zero123(网易:游趣开发组) 别名:一灵 | h2v-wither123... BilBil-UID:456549011
|
||||
# 开源协议: BSD(3)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
"""
|
||||
@@ -1,164 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from mod.common.mod import Mod
|
||||
import mod.server.extraServerApi as serverApi
|
||||
import mod.client.extraClientApi as clientApi
|
||||
from .Util import TRY_EXEC_FUN
|
||||
from . import IN
|
||||
|
||||
class _TempData:
|
||||
""" modMain临时数据储存 包含了初始化注册的一些信息 """
|
||||
_nativePyServer = []
|
||||
_nativePyClient = []
|
||||
_serverInitCall = []
|
||||
_clientInitCall = []
|
||||
_threadAnalysis = False
|
||||
|
||||
@Mod.Binding(name = "QuMod_"+IN.ModDirName, version = "1.0.0")
|
||||
class QMain(object):
|
||||
""" QuMod MAIN入口逻辑 """
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@Mod.InitServer()
|
||||
def serverInit(self):
|
||||
# 服务端初始化
|
||||
IN.IsServerUser = True
|
||||
self._regNativePyServer()
|
||||
self._loadServerInitFuncs()
|
||||
if _TempData._threadAnalysis:
|
||||
from threading import current_thread
|
||||
IN.RuntimeService._serverThreadID = current_thread().ident
|
||||
if IN.RuntimeService._serverSystemList or IN.RuntimeService._serverLoadBefore:
|
||||
from .Systems.Loader.Server import LoaderSystem
|
||||
LoaderSystem.getSystem() # 初始化服务端加载器
|
||||
|
||||
@Mod.InitClient()
|
||||
def clientInit(self):
|
||||
IN.RuntimeService._envPlayerId = clientApi.GetLocalPlayerId()
|
||||
self._regNativePyClient()
|
||||
self._loadClientInitFuncs()
|
||||
if _TempData._threadAnalysis:
|
||||
from threading import current_thread
|
||||
IN.RuntimeService._clientThreadID = current_thread().ident
|
||||
if IN.RuntimeService._clientSystemList or IN.RuntimeService._clientLoadBefore:
|
||||
from .Systems.Loader.Client import LoaderSystem
|
||||
LoaderSystem.getSystem() # 初始化客户端加载器
|
||||
|
||||
def _regNativePyClient(self):
|
||||
""" 加载原版Python客户端系统注册 """
|
||||
for args in _TempData._nativePyClient:
|
||||
clientApi.RegisterSystem(*args)
|
||||
_TempData._nativePyClient = []
|
||||
|
||||
def _regNativePyServer(self):
|
||||
""" 加载原版Python服务端系统注册 """
|
||||
for args in _TempData._nativePyServer:
|
||||
serverApi.RegisterSystem(*args)
|
||||
_TempData._nativePyServer = []
|
||||
|
||||
def _loadServerInitFuncs(self):
|
||||
""" 加载服务端初始化函数 """
|
||||
for funObj in _TempData._serverInitCall:
|
||||
TRY_EXEC_FUN(funObj)
|
||||
_TempData._serverInitCall = []
|
||||
|
||||
def _loadClientInitFuncs(self):
|
||||
""" 加载客户端初始化函数 """
|
||||
for funObj in _TempData._clientInitCall:
|
||||
TRY_EXEC_FUN(funObj)
|
||||
_TempData._clientInitCall = []
|
||||
|
||||
@Mod.DestroyServer()
|
||||
def serverDestroy(self):
|
||||
pass
|
||||
|
||||
@Mod.DestroyClient()
|
||||
def clientDestroy(self):
|
||||
pass
|
||||
|
||||
class EasyMod:
|
||||
""" 简易Mod构造器 """
|
||||
def __init__(self, modDirName=None):
|
||||
# type: (str | None) -> None
|
||||
self._modDirName = modDirName if modDirName else IN.ModDirName
|
||||
""" Mod目录名 """
|
||||
|
||||
def regServer(self, relPath="", systemName=None):
|
||||
# type: (str, str | None) -> EasyMod
|
||||
""" 注册服务端(相对目录) """
|
||||
REG_SERVER_MODULE("{}.{}".format(self._modDirName, relPath), systemName)
|
||||
return self
|
||||
|
||||
def Server(self, relPath="", systemName=None):
|
||||
# type: (str, str | None) -> EasyMod
|
||||
""" 便捷式服务端注册 """
|
||||
return self.regServer(relPath, systemName)
|
||||
|
||||
def Client(self, relPath="", systemName=None):
|
||||
# type: (str, str | None) -> EasyMod
|
||||
""" 便捷式客户端注册 """
|
||||
return self.regClient(relPath, systemName)
|
||||
|
||||
def regClient(self, relPath="", systemName=None):
|
||||
# type: (str, str | None) -> EasyMod
|
||||
""" 注册客户端(相对目录) """
|
||||
REG_CLIENT_MODULE("{}.{}".format(self._modDirName, relPath), systemName)
|
||||
return self
|
||||
|
||||
def addServerInitCallFunc(self, callFunc=lambda: None):
|
||||
# type: (object) -> EasyMod
|
||||
""" 添加服务端初始化调用方法 """
|
||||
REG_SERVER_INIT_CALL(callFunc)
|
||||
return self
|
||||
|
||||
def addClientInitCallFunc(self, callFunc=lambda: None):
|
||||
# type: (object) -> EasyMod
|
||||
""" 添加客户端初始化调用方法 """
|
||||
REG_CLIENT_INIT_CALL(callFunc)
|
||||
return self
|
||||
|
||||
def START_THREAD_ANALYSIS():
|
||||
""" 启用线程分析 """
|
||||
_TempData._threadAnalysis = True
|
||||
|
||||
def STOP_THREAD_ANALYSIS():
|
||||
""" 禁用线程分析 """
|
||||
_TempData._threadAnalysis = False
|
||||
|
||||
def REG_SERVER_MODULE(absPath, systemName=None, _index=-1):
|
||||
# type: (str, str | None, int) -> None
|
||||
""" 注册服务端模块 (绝对路径) """
|
||||
if _index < 0:
|
||||
return IN.RuntimeService._serverSystemList.append((absPath, systemName))
|
||||
return IN.RuntimeService._serverSystemList.insert(_index, (absPath, systemName))
|
||||
|
||||
def REG_CLIENT_MODULE(absPath, systemName=None, _index=-1):
|
||||
# type: (str, str | None, int) -> None
|
||||
""" 注册客户端模块 (绝对路径) """
|
||||
if _index < 0:
|
||||
return IN.RuntimeService._clientSystemList.append((absPath, systemName))
|
||||
return IN.RuntimeService._clientSystemList.insert(_index, (absPath, systemName))
|
||||
|
||||
def REG_SERVER_INIT_CALL(func=lambda: None):
|
||||
# type: (function) -> function
|
||||
""" 注册服务端初始化调用函数 """
|
||||
_TempData._serverInitCall.append(func)
|
||||
return func
|
||||
|
||||
def REG_CLIENT_INIT_CALL(func=lambda: None):
|
||||
# type: (function) -> function
|
||||
""" 注册客户端初始化调用函数 """
|
||||
_TempData._clientInitCall.append(func)
|
||||
return func
|
||||
|
||||
def PRE_SERVER_LOADER_HOOK(func=lambda: None):
|
||||
# type: (function) -> function
|
||||
""" 注册服务端加载器处理前的前置逻辑 (此时依然可以注册文件 该功能用于前置关联的校验处理) """
|
||||
IN.RuntimeService._serverLoadBefore.append(func)
|
||||
return func
|
||||
|
||||
def PRE_CLIENT_LOADER_HOOK(func=lambda: None):
|
||||
# type: (function) -> function
|
||||
""" 注册客户端加载器处理前的前置逻辑 (此时依然可以注册文件 该功能用于前置关联的校验处理) """
|
||||
IN.RuntimeService._clientLoadBefore.append(func)
|
||||
return func
|
||||
@@ -1,185 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import mod.client.extraClientApi as clientApi
|
||||
from ...Util import errorPrint, TRY_EXEC_FUN, getObjectPathName
|
||||
from ...IN import RuntimeService
|
||||
from .SharedRes import (
|
||||
CallObjData,
|
||||
EasyListener,
|
||||
SERVER_CALL_EVENT,
|
||||
CLIENT_CALL_EVENT,
|
||||
NAMESPACE,
|
||||
SYSTEMNAME
|
||||
)
|
||||
lambda: "By Zero123"
|
||||
ClientSystem = clientApi.GetClientSystemCls()
|
||||
engineSpaceName, engineSystemName = clientApi.GetEngineNamespace(), clientApi.GetEngineSystemName()
|
||||
|
||||
def clientImportModule(filePath):
|
||||
""" 客户端文件导入 """
|
||||
return clientApi.ImportModule(filePath)
|
||||
|
||||
class LoaderSystem(ClientSystem, EasyListener):
|
||||
""" QuMod加载器系统
|
||||
加载器承担了系统文件的加载以及事件监听 系统通信
|
||||
"""
|
||||
@staticmethod
|
||||
def getSystem():
|
||||
# type: () -> LoaderSystem
|
||||
""" 获取加载器系统 如果未注册将会自动注册并返回 """
|
||||
system = clientApi.GetSystem(NAMESPACE, SYSTEMNAME)
|
||||
if system:
|
||||
return system
|
||||
return clientApi.RegisterSystem(NAMESPACE, SYSTEMNAME, LoaderSystem.__module__ + "." + LoaderSystem.__name__)
|
||||
|
||||
_REG_CALL_FUNCS = {}
|
||||
_REG_STATIC_LISTEN_FUNCS = {}
|
||||
_DY_IMP_CACHE = {}
|
||||
|
||||
@staticmethod
|
||||
def dyImportModule(modulePath):
|
||||
if not modulePath in LoaderSystem._DY_IMP_CACHE:
|
||||
LoaderSystem._DY_IMP_CACHE[modulePath] = clientImportModule(modulePath)
|
||||
return LoaderSystem._DY_IMP_CACHE[modulePath]
|
||||
|
||||
@staticmethod
|
||||
def REG_DESTROY_CALL_FUNC(func=lambda: None):
|
||||
""" 适用于静态函数的注册销毁时回调 """
|
||||
keyName = getObjectPathName(func)
|
||||
if not keyName in LoaderSystem._REG_CALL_FUNCS:
|
||||
# callFunc = lambda: LoaderSystem._REG_CALL_FUNCS[keyName]()
|
||||
LoaderSystem.getSystem().addDestroyCall(func)
|
||||
LoaderSystem._REG_CALL_FUNCS[keyName] = func
|
||||
|
||||
@staticmethod
|
||||
def REG_STATIC_LISTEN_FUNC(eventName="", funcObj=lambda: None):
|
||||
""" 注册静态监听函数 """
|
||||
keyName = getObjectPathName(funcObj)
|
||||
if not keyName in LoaderSystem._REG_STATIC_LISTEN_FUNCS:
|
||||
# callFunc = lambda *args: LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName](*args)
|
||||
LoaderSystem.getSystem().nativeStaticListen(eventName, funcObj)
|
||||
LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName] = funcObj
|
||||
|
||||
def __init__(self, namespace, systemName):
|
||||
ClientSystem.__init__(self, namespace, systemName)
|
||||
EasyListener.__init__(self)
|
||||
RuntimeService._clientStarting = True
|
||||
self.namespace = namespace
|
||||
self.systemName = systemName
|
||||
self._systemList = RuntimeService._clientSystemList
|
||||
self._initState = False
|
||||
self._regInitState = False
|
||||
self._waitTime = 0.0
|
||||
self._callQueue = [] # type: list[CallObjData]
|
||||
self._onDestroyCall = []
|
||||
self._onDestroyCall_LAST = []
|
||||
""" 后置销毁触发 通常是内部使用确保在用户业务之后执行 """
|
||||
self._initSystemListen()
|
||||
self.systemInit()
|
||||
|
||||
def _initSystemListen(self):
|
||||
self.ListenForEvent(NAMESPACE, SYSTEMNAME, SERVER_CALL_EVENT, self, self._systemCallListener)
|
||||
|
||||
def _easyListenForEvent(self, eventName="", parent=None, func=lambda: None):
|
||||
return self.ListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
|
||||
|
||||
def _easyUnListenForEvent(self, eventName="", parent=None, func=lambda: None):
|
||||
return self.UnListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
|
||||
|
||||
def sendCall(self, apiName="", args=tuple(), kwargs=dict()):
|
||||
""" 向服务器端请求调用 """
|
||||
sendData = self._packageCallArgs(apiName, args, kwargs)
|
||||
self.NotifyToServer(CLIENT_CALL_EVENT, sendData)
|
||||
|
||||
def addDestroyCall(self, funObj, doubleCheck=True):
|
||||
""" 添加销毁触发 """
|
||||
if doubleCheck and funObj in self._onDestroyCall:
|
||||
return
|
||||
self._onDestroyCall.append(funObj)
|
||||
|
||||
def removeDestroyCall(self, funObj):
|
||||
""" 移除销毁触发 """
|
||||
if funObj in self._onDestroyCall:
|
||||
self._onDestroyCall.remove(funObj)
|
||||
|
||||
def Destroy(self):
|
||||
# 用户级destroy执行
|
||||
for obj in self._onDestroyCall:
|
||||
TRY_EXEC_FUN(obj)
|
||||
self._onDestroyCall = []
|
||||
# 高权限destroy执行
|
||||
for obj in self._onDestroyCall_LAST:
|
||||
TRY_EXEC_FUN(obj)
|
||||
self._onDestroyCall_LAST = []
|
||||
RuntimeService._clientStarting = False
|
||||
|
||||
def getSystemList(self):
|
||||
# type: () -> list[tuple[str, str | None]]
|
||||
return self._systemList
|
||||
|
||||
def removeCallObjByUid(self, _uid = ""):
|
||||
""" 尝试移除特定uid的callObj 如果存在 """
|
||||
for i, obj in enumerate(self._callQueue):
|
||||
if obj._uid == _uid:
|
||||
del self._callQueue[i]
|
||||
break
|
||||
|
||||
def proxyRegister(self, funcObj):
|
||||
""" 代理注册 """
|
||||
from functools import wraps
|
||||
@wraps(funcObj)
|
||||
def newFun(*args, **kwargs):
|
||||
callObj = CallObjData(funcObj, args, kwargs)
|
||||
self._callQueue.append(callObj)
|
||||
return callObj
|
||||
return newFun
|
||||
|
||||
def unsafeUpdate(self, callObjData):
|
||||
# type: (CallObjData) -> bool
|
||||
""" 不安全的强制刷新 """
|
||||
if callObjData in self._callQueue:
|
||||
self._callQueue.remove(callObjData)
|
||||
callObjData.callObj(*callObjData.args, **callObjData.kwargs)
|
||||
return True
|
||||
return False
|
||||
|
||||
def Update(self):
|
||||
self.regSystemInit()
|
||||
if self._callQueue:
|
||||
for obj in self._callQueue:
|
||||
try:
|
||||
obj.callObj(*obj.args, **obj.kwargs)
|
||||
except Exception as e:
|
||||
errorPrint("{} call执行异常 {}".format(obj.callObj, e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._callQueue = []
|
||||
return ClientSystem.Update(self)
|
||||
|
||||
def systemInit(self):
|
||||
self._initState = True
|
||||
|
||||
def regSystemInit(self):
|
||||
""" 系统信息注册初始化 """
|
||||
if self._regInitState:
|
||||
return
|
||||
# 加载Before事件
|
||||
for funcObj in RuntimeService._clientLoadBefore:
|
||||
TRY_EXEC_FUN(funcObj)
|
||||
# 因历史原因systemName已废弃 此处仅兼容旧版项目
|
||||
for path, _ in self._systemList:
|
||||
sysObj = None
|
||||
try:
|
||||
sysObj = clientImportModule(path)
|
||||
if sysObj == None:
|
||||
errorPrint("[客户端] 系统文件加载失败(API异常): {}".format(path))
|
||||
continue
|
||||
except Exception as e:
|
||||
errorPrint("[客户端] 系统文件错误: {} ({})".format(path, e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
# if not systemName: systemName = uuid4().hex
|
||||
self._regInitState = True
|
||||
# 加载Finish事件
|
||||
for funcObj in RuntimeService._clientLoadFinish:
|
||||
TRY_EXEC_FUN(funcObj)
|
||||
@@ -1,200 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import mod.server.extraServerApi as serverApi
|
||||
from ...Util import errorPrint, TRY_EXEC_FUN, getObjectPathName
|
||||
from ...IN import RuntimeService
|
||||
from .SharedRes import (
|
||||
CallObjData,
|
||||
EasyListener,
|
||||
SERVER_CALL_EVENT,
|
||||
CLIENT_CALL_EVENT,
|
||||
NAMESPACE,
|
||||
SYSTEMNAME
|
||||
)
|
||||
lambda: "By Zero123"
|
||||
ServerSystem = serverApi.GetServerSystemCls()
|
||||
engineSpaceName, engineSystemName = serverApi.GetEngineNamespace(), serverApi.GetEngineSystemName()
|
||||
|
||||
def serverImportModule(filePath):
|
||||
""" 服务端文件导入 """
|
||||
return serverApi.ImportModule(filePath)
|
||||
|
||||
class LoaderSystem(ServerSystem, EasyListener):
|
||||
""" QuMod加载器系统
|
||||
加载器承担了系统文件的加载以及事件监听 系统通信
|
||||
"""
|
||||
@staticmethod
|
||||
def getSystem():
|
||||
# type: () -> LoaderSystem
|
||||
""" 获取加载器系统 如果未注册将会自动注册并返回 """
|
||||
system = serverApi.GetSystem(NAMESPACE, SYSTEMNAME)
|
||||
if system:
|
||||
return system
|
||||
return serverApi.RegisterSystem(NAMESPACE, SYSTEMNAME, LoaderSystem.__module__ + "." + LoaderSystem.__name__)
|
||||
|
||||
_REG_CALL_FUNCS = {}
|
||||
_REG_STATIC_LISTEN_FUNCS = {}
|
||||
_DY_IMP_CACHE = {}
|
||||
|
||||
@staticmethod
|
||||
def dyImportModule(modulePath):
|
||||
if not modulePath in LoaderSystem._DY_IMP_CACHE:
|
||||
LoaderSystem._DY_IMP_CACHE[modulePath] = serverImportModule(modulePath)
|
||||
return LoaderSystem._DY_IMP_CACHE[modulePath]
|
||||
|
||||
@staticmethod
|
||||
def REG_DESTROY_CALL_FUNC(func=lambda: None):
|
||||
""" 适用于静态函数的注册销毁时回调 """
|
||||
keyName = getObjectPathName(func)
|
||||
if not keyName in LoaderSystem._REG_CALL_FUNCS:
|
||||
# callFunc = lambda: LoaderSystem._REG_CALL_FUNCS[keyName]()
|
||||
LoaderSystem.getSystem().addDestroyCall(func)
|
||||
LoaderSystem._REG_CALL_FUNCS[keyName] = func
|
||||
|
||||
@staticmethod
|
||||
def REG_STATIC_LISTEN_FUNC(eventName="", funcObj=lambda: None):
|
||||
""" 注册静态监听函数 """
|
||||
keyName = getObjectPathName(funcObj)
|
||||
if not keyName in LoaderSystem._REG_STATIC_LISTEN_FUNCS:
|
||||
# callFunc = lambda *args: LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName](*args)
|
||||
LoaderSystem.getSystem().nativeStaticListen(eventName, funcObj)
|
||||
LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName] = funcObj
|
||||
|
||||
def __init__(self, namespace, systemName):
|
||||
ServerSystem.__init__(self, namespace, systemName)
|
||||
EasyListener.__init__(self)
|
||||
RuntimeService._serverStarting = True
|
||||
self.namespace = namespace
|
||||
self.systemName = systemName
|
||||
self.rpcPlayerId = None
|
||||
self._systemList = RuntimeService._serverSystemList
|
||||
self._initState = False
|
||||
self._regInitState = False
|
||||
self._waitTime = 0.0
|
||||
self._onDestroyCall = []
|
||||
self._onDestroyCall_LAST = []
|
||||
""" 后置销毁触发 通常是内部使用确保在用户业务之后执行 """
|
||||
self._initSystemListen()
|
||||
self.systemInit()
|
||||
|
||||
def _systemCallListenerHook(self, args={}):
|
||||
target = "__id__"
|
||||
if target in args:
|
||||
self.rpcPlayerId = args[target]
|
||||
return
|
||||
self.rpcPlayerId = None
|
||||
|
||||
def _initSystemListen(self):
|
||||
self.ListenForEvent(NAMESPACE, SYSTEMNAME, CLIENT_CALL_EVENT, self, self._systemCallListener)
|
||||
|
||||
def _easyListenForEvent(self, eventName="", parent=None, func=lambda: None):
|
||||
return self.ListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
|
||||
|
||||
def _easyUnListenForEvent(self, eventName="", parent=None, func=lambda: None):
|
||||
return self.UnListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
|
||||
|
||||
def sendCall(self, playerId="", apiName="", args=tuple(), kwargs=dict()):
|
||||
""" 向指定玩家客户端请求调用 当playerId声明为*时代表全体玩家 """
|
||||
sendData = self._packageCallArgs(apiName, args, kwargs)
|
||||
if playerId == "*":
|
||||
self.BroadcastToAllClient(SERVER_CALL_EVENT, sendData)
|
||||
return
|
||||
self.NotifyToClient(playerId, SERVER_CALL_EVENT, sendData)
|
||||
|
||||
def sendMultiClientsCall(self, playerListId=[], apiName="", args=tuple(), kwargs=dict()):
|
||||
""" 批量向多个玩家客户端发包相同的调用数据 """
|
||||
sendData = self._packageCallArgs(apiName, args, kwargs)
|
||||
self.NotifyToMultiClients(playerListId, SERVER_CALL_EVENT, sendData)
|
||||
|
||||
def addDestroyCall(self, funObj, doubleCheck=True):
|
||||
""" 添加销毁触发 """
|
||||
if doubleCheck and funObj in self._onDestroyCall:
|
||||
return
|
||||
self._onDestroyCall.append(funObj)
|
||||
|
||||
def removeDestroyCall(self, funObj):
|
||||
""" 移除销毁触发 """
|
||||
if funObj in self._onDestroyCall:
|
||||
self._onDestroyCall.remove(funObj)
|
||||
|
||||
def Destroy(self):
|
||||
# 用户级destroy执行
|
||||
for obj in self._onDestroyCall:
|
||||
TRY_EXEC_FUN(obj)
|
||||
self._onDestroyCall = []
|
||||
# 高权限destroy执行
|
||||
for obj in self._onDestroyCall_LAST:
|
||||
TRY_EXEC_FUN(obj)
|
||||
self._onDestroyCall_LAST = []
|
||||
RuntimeService._serverStarting = False
|
||||
|
||||
def getSystemList(self):
|
||||
# type: () -> list[tuple[str, str | None]]
|
||||
return self._systemList
|
||||
|
||||
def removeCallObjByUid(self, _uid = ""):
|
||||
""" 尝试移除特定uid的callObj 如果存在 """
|
||||
for i, obj in enumerate(self._callQueue):
|
||||
if obj._uid == _uid:
|
||||
del self._callQueue[i]
|
||||
break
|
||||
|
||||
def proxyRegister(self, funcObj):
|
||||
""" 代理注册 """
|
||||
from functools import wraps
|
||||
@wraps(funcObj)
|
||||
def newFun(*args, **kwargs):
|
||||
callObj = CallObjData(funcObj, args, kwargs)
|
||||
self._callQueue.append(callObj)
|
||||
return callObj
|
||||
return newFun
|
||||
|
||||
def unsafeUpdate(self, callObjData):
|
||||
# type: (CallObjData) -> bool
|
||||
""" 不安全的强制刷新 """
|
||||
if callObjData in self._callQueue:
|
||||
self._callQueue.remove(callObjData)
|
||||
callObjData.callObj(*callObjData.args, **callObjData.kwargs)
|
||||
return True
|
||||
return False
|
||||
|
||||
def Update(self):
|
||||
self.regSystemInit()
|
||||
if self._callQueue:
|
||||
for obj in self._callQueue:
|
||||
try:
|
||||
obj.callObj(*obj.args, **obj.kwargs)
|
||||
except Exception as e:
|
||||
errorPrint("{} call执行异常 {}".format(obj.callObj, e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._callQueue = []
|
||||
return ServerSystem.Update(self)
|
||||
|
||||
def systemInit(self):
|
||||
self._initState = True
|
||||
|
||||
def regSystemInit(self):
|
||||
""" 系统信息注册初始化 """
|
||||
if self._regInitState:
|
||||
return
|
||||
# 加载Before事件
|
||||
for funcObj in RuntimeService._serverLoadBefore:
|
||||
TRY_EXEC_FUN(funcObj)
|
||||
# 因历史原因systemName已废弃 此处仅兼容旧版项目
|
||||
for path, _ in self._systemList:
|
||||
sysObj = None
|
||||
try:
|
||||
sysObj = serverImportModule(path)
|
||||
if sysObj == None:
|
||||
errorPrint("[服务端] 系统文件加载失败(API异常): {}".format(path))
|
||||
continue
|
||||
except Exception as e:
|
||||
errorPrint("[服务端] 系统文件错误: {} ({})".format(path, e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
# if not systemName: systemName = uuid4().hex
|
||||
self._regInitState = True
|
||||
# 加载Finish事件
|
||||
for funcObj in RuntimeService._serverLoadFinish:
|
||||
TRY_EXEC_FUN(funcObj)
|
||||
@@ -1,175 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from uuid import uuid4
|
||||
from ...IN import ModDirName
|
||||
from ...Util import QStruct
|
||||
|
||||
class CallObjData:
|
||||
def __init__(self, callObj, args = tuple(), kwargs = {}):
|
||||
self.callObj = callObj
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self._uid = None
|
||||
|
||||
class EmptyContext:
|
||||
pass
|
||||
|
||||
NAMESPACE = "Qu_" + ModDirName
|
||||
SYSTEMNAME = "{}_QLoader_system".format(ModDirName)
|
||||
SERVER_CALL_EVENT = "{}_QServer".format(ModDirName)
|
||||
CLIENT_CALL_EVENT = "{}_QClient".format(ModDirName)
|
||||
|
||||
class EasyListener:
|
||||
def __init__(self):
|
||||
self._callQueue = [] # type: list[CallObjData]
|
||||
self._emptyContext = EmptyContext()
|
||||
self._QCustomAPI = {} # type: dict[str, function]
|
||||
|
||||
def regCustomApi(self, apiName="", func=lambda: None):
|
||||
""" 注册自定义API """
|
||||
self._QCustomAPI[apiName] = func
|
||||
|
||||
def removeCustomApi(self, apiName=""):
|
||||
""" 删除指定API如果存在 """
|
||||
if apiName in self._QCustomAPI:
|
||||
del self._QCustomAPI[apiName]
|
||||
|
||||
def getCustomApi(self, apiName=""):
|
||||
""" 获取自定义API如果存在 """
|
||||
return self._QCustomAPI.get(apiName)
|
||||
|
||||
def localCall(self, apiName="", *args, **kwargs):
|
||||
""" 本地调用 请确保API函数存在注册 否则抛出异常 """
|
||||
return self._QCustomAPI[apiName](*args, **kwargs)
|
||||
|
||||
def _systemCallListener(self, args={}):
|
||||
""" 系统call机制监听器(接收消息处理) """
|
||||
api = args["api"]
|
||||
ag = EasyListener._unPackRefArgs(args["args"])
|
||||
kwargs = EasyListener._unPackRefDictArgs(args["kw"])
|
||||
self._systemCallListenerHook(args)
|
||||
return self.localCall(api, *ag, **kwargs)
|
||||
|
||||
def _systemCallListenerHook(self, _={}):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _unPackRefArgs(data):
|
||||
# type: (list) -> list
|
||||
""" Ref解包Args数据 """
|
||||
for i, v in enumerate(data):
|
||||
if QStruct.isSignData(v):
|
||||
data[i] = QStruct.loadSignData(v).onNetUnPack()
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _unPackRefDictArgs(data):
|
||||
# type: (dict) -> dict
|
||||
""" Ref解包Dict Args数据 """
|
||||
for k, v in data.items():
|
||||
if QStruct.isSignData(v):
|
||||
data[k] = QStruct.loadSignData(v).onNetUnPack()
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _packArgs(data):
|
||||
# type: (tuple | list) -> list
|
||||
""" 打包Args数据 """
|
||||
newDataList = []
|
||||
for v in data:
|
||||
if isinstance(v, QStruct):
|
||||
newDataList.append(v.signDumps())
|
||||
continue
|
||||
newDataList.append(v)
|
||||
return newDataList
|
||||
|
||||
@staticmethod
|
||||
def _packDictArgs(data):
|
||||
# type: (dict) -> dict
|
||||
""" 打包Dict数据 keyName=xxx """
|
||||
newDict = {}
|
||||
for k, v in data.items():
|
||||
if isinstance(v, QStruct):
|
||||
newDict[k] = v.signDumps()
|
||||
continue
|
||||
newDict[k] = v
|
||||
return newDict
|
||||
|
||||
def _packageCallArgs(self, apiName="", args=tuple(), kwargs=dict()):
|
||||
""" 打包API参数(发送消息处理) """
|
||||
return {"api":apiName,"args":EasyListener._packArgs(args),"kw":EasyListener._packDictArgs(kwargs)}
|
||||
|
||||
def mallocRandomMetName(self):
|
||||
""" 动态分配随机方法名 """
|
||||
randomName = ""
|
||||
while not randomName or hasattr(self, randomName):
|
||||
randomName = "Q_{}".format(uuid4().hex)
|
||||
return randomName
|
||||
|
||||
def _allocMethodWithOUTFunction(self, callFunc=lambda *_: None):
|
||||
""" 基于外部函数分配一个映射的内部方法(介于网易Listen必须依赖内部方法 故有了该方法) """
|
||||
newFuncName = self.mallocRandomMetName()
|
||||
newFunc = lambda *args, **kwargs: callFunc(*args, **kwargs)
|
||||
newFunc.__name__ = newFuncName
|
||||
setattr(self, newFuncName, newFunc)
|
||||
return newFunc
|
||||
|
||||
def _delMethod(self, methodFunc=lambda *_: None):
|
||||
""" 对照与_allocMethodWithOUTFunction的反向删除 """
|
||||
try:
|
||||
delattr(self, methodFunc.__name__)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def nativeStaticListen(self, eventName="", callFunc=lambda *_: None):
|
||||
""" 原生静态监听注册 不支持运行时注销 """
|
||||
def _reg():
|
||||
self._easyListenForEvent(eventName, self, self._allocMethodWithOUTFunction(callFunc))
|
||||
self._callQueue.append(CallObjData(_reg))
|
||||
|
||||
def nativeListen(self, eventName="", parent=None, callFunc=lambda *_: None, updateNow=False):
|
||||
# type: (str, object, object, bool) -> CallObjData | None
|
||||
""" 原生动态监听 当updateNow声明为False时将会添加到系统队列安全的等待注册 """
|
||||
if not parent:
|
||||
parent = self._emptyContext
|
||||
newFuncName = "QListen{}_{}".format(id(parent), callFunc.__name__)
|
||||
newFunc = lambda *args: callFunc(*args)
|
||||
newFunc.__name__ = newFuncName
|
||||
if hasattr(self, newFuncName):
|
||||
print("[Error] 请勿在单个可执行对象上监听重复的事件")
|
||||
return None
|
||||
def _reg():
|
||||
setattr(self, newFuncName, newFunc)
|
||||
self._easyListenForEvent(eventName, self, newFunc)
|
||||
waitCallObj = CallObjData(_reg)
|
||||
waitCallObj._uid = newFuncName
|
||||
self._callQueue.append(waitCallObj)
|
||||
if updateNow:
|
||||
self.unsafeUpdate(waitCallObj)
|
||||
return waitCallObj
|
||||
|
||||
def unNativeListen(self, eventName="", parent=None, callFunc=lambda *_: None):
|
||||
# type: (str, object, object) -> None
|
||||
""" 取消特定方法的原生动态监听 """
|
||||
if not parent:
|
||||
parent = self._emptyContext
|
||||
newFuncName = "QListen{}_{}".format(id(parent), callFunc.__name__)
|
||||
if hasattr(self, newFuncName):
|
||||
# 已注册完毕的监听处理
|
||||
self._easyUnListenForEvent(eventName, self, getattr(self, newFuncName))
|
||||
delattr(self, newFuncName)
|
||||
return
|
||||
# 在队列中等待注册的监听处理
|
||||
self.removeCallObjByUid(newFuncName)
|
||||
|
||||
def unsafeUpdate(self, callObjData):
|
||||
# type: (CallObjData) -> bool
|
||||
pass
|
||||
|
||||
def _easyListenForEvent(self, eventName="", parent=None, func=lambda: None):
|
||||
pass
|
||||
|
||||
def _easyUnListenForEvent(self, eventName="", parent=None, func=lambda: None):
|
||||
pass
|
||||
|
||||
def removeCallObjByUid(self, _uid = ""):
|
||||
pass
|
||||
@@ -1,89 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from functools import wraps
|
||||
from time import time
|
||||
import pickle as _pickle
|
||||
|
||||
class SystemSide(object):
|
||||
def __init__(self, Path, SystemName = None):
|
||||
self.SystemName = SystemName # 绑定系统
|
||||
self.Path = Path
|
||||
|
||||
ModDirName = SystemSide.__module__.split(".")[0]
|
||||
|
||||
def errorPrint(charPtr):
|
||||
""" 异常输出 """
|
||||
print("[Error] "+str(charPtr))
|
||||
|
||||
def TRY_EXEC_FUN(funObj, *args, **kwargs):
|
||||
try:
|
||||
return funObj(*args, **kwargs)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print("TRY_EXEC发生异常: {}".format(e))
|
||||
traceback.print_exc()
|
||||
|
||||
def getObjectPathName(_callObj = lambda: None):
|
||||
# type: (object) -> str
|
||||
""" 获取可执行对象的目录名 """
|
||||
return ".".join((_callObj.__module__, _callObj.__name__))
|
||||
|
||||
class QStruct:
|
||||
""" 结构体 用于通用数据模型约定(即不涉及任何API) 应定义在Server/Client以外的通用文件 同理Struct也不应该持有任何涉及端侧API的内容 """
|
||||
_SIGN_FORMAT = "_QSTRUCT[{}]"
|
||||
def dumps(self):
|
||||
""" 序列化对象 """
|
||||
return _pickle.dumps(self)
|
||||
|
||||
def signDumps(self):
|
||||
""" 带有特征签名的序列化 """
|
||||
data = self.dumps()
|
||||
return [QStruct._SIGN_FORMAT.format(hex(hash(data))), data]
|
||||
|
||||
@staticmethod
|
||||
def isSignData(data):
|
||||
""" 校验数据 """
|
||||
if not isinstance(data, list) or len(data) != 2:
|
||||
return False
|
||||
signKey = data[0]
|
||||
if isinstance(signKey, str):
|
||||
dataObj = data[1]
|
||||
return signKey == QStruct._SIGN_FORMAT.format(hex(hash(dataObj)))
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def loads(data):
|
||||
# type: (str) -> QStruct
|
||||
""" 反序列化加载对象 """
|
||||
return _pickle.loads(data)
|
||||
|
||||
@staticmethod
|
||||
def loadSignData(data):
|
||||
# type: (list) -> QStruct
|
||||
""" 反序列化加载Sign对象表(不会校验) """
|
||||
return _pickle.loads(data[1])
|
||||
|
||||
def onNetUnPack(self):
|
||||
return self
|
||||
|
||||
class QRefStruct(QStruct):
|
||||
""" 万能引用 """
|
||||
def __init__(self, refObject):
|
||||
self.ref = refObject
|
||||
|
||||
def onNetUnPack(self):
|
||||
return self.ref
|
||||
|
||||
class QListStruct(QStruct, list):
|
||||
""" List容器结构 """
|
||||
def onNetUnPack(self):
|
||||
return list(self)
|
||||
|
||||
class QDictStruct(QStruct, dict):
|
||||
""" Dict容器结构 """
|
||||
def onNetUnPack(self):
|
||||
return dict(self)
|
||||
|
||||
class QTupleStruct(QStruct, tuple):
|
||||
""" Tuple容器结构 """
|
||||
def onNetUnPack(self):
|
||||
return tuple(self)
|
||||
@@ -1,128 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .QuModLibs.QuMod import *
|
||||
from .Game import (
|
||||
RELOAD_MOD,
|
||||
INIT_RELOAD_TIME,
|
||||
RELOAD_ADDON,
|
||||
RELOAD_WORLD,
|
||||
RELOAD_SHADERS,
|
||||
)
|
||||
from .Config import DEBUG_CONFIG
|
||||
import threading
|
||||
import sys
|
||||
from mod.common.mod import Mod
|
||||
import mod.client.extraClientApi as clientApi
|
||||
import mod.server.extraServerApi as serverApi
|
||||
|
||||
lambda: "By Zero123"
|
||||
|
||||
REF = 0
|
||||
from .Lifecycle import install_output_wrapper
|
||||
|
||||
|
||||
class STD_OUT_WRAPPER(object):
|
||||
def __init__(self, baseIO):
|
||||
self.baseIO = baseIO
|
||||
self.writeLock = threading.Lock()
|
||||
self._buffer = []
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.baseIO, name)
|
||||
|
||||
def write(self, data):
|
||||
with self.writeLock:
|
||||
parts = data.splitlines(True)
|
||||
for part in parts:
|
||||
if part.endswith("\n"):
|
||||
if self._buffer:
|
||||
line = "".join(self._buffer) + part
|
||||
self._buffer = []
|
||||
else:
|
||||
line = part
|
||||
self.baseIO.write("[Python] " + line)
|
||||
else:
|
||||
self._buffer.append(part)
|
||||
|
||||
def close(self):
|
||||
return self.baseIO.close()
|
||||
|
||||
def writelines(self, lines):
|
||||
for line in lines:
|
||||
self.write(line)
|
||||
|
||||
def fileno(self):
|
||||
return self.baseIO.fileno()
|
||||
MOD_NAME = "EmodCliDebug"
|
||||
MOD_VERSION = "1.0.0"
|
||||
SERVER_SYSTEM_NAME = "EmodCliDebugServer"
|
||||
SERVER_SYSTEM_PATH = "DEBUG_ENV_SCRIPT.DebugServerSystem.DebugServerSystem"
|
||||
CLIENT_SYSTEM_NAME = "EmodCliDebugClient"
|
||||
CLIENT_SYSTEM_PATH = "DEBUG_ENV_SCRIPT.DebugClientSystem.DebugClientSystem"
|
||||
|
||||
|
||||
stdout = sys.stdout
|
||||
stderr = sys.stderr
|
||||
install_output_wrapper()
|
||||
|
||||
|
||||
def REST_STDOUT():
|
||||
sys.stdout = stdout
|
||||
sys.stderr = stderr
|
||||
@Mod.Binding(name=MOD_NAME, version=MOD_VERSION)
|
||||
class EmodCliDebugMod(object):
|
||||
@Mod.InitServer()
|
||||
def server_init(self):
|
||||
serverApi.RegisterSystem(
|
||||
MOD_NAME,
|
||||
SERVER_SYSTEM_NAME,
|
||||
SERVER_SYSTEM_PATH,
|
||||
)
|
||||
|
||||
@Mod.InitClient()
|
||||
def client_init(self):
|
||||
clientApi.RegisterSystem(
|
||||
MOD_NAME,
|
||||
CLIENT_SYSTEM_NAME,
|
||||
CLIENT_SYSTEM_PATH,
|
||||
)
|
||||
|
||||
sys.stdout = STD_OUT_WRAPPER(sys.stdout)
|
||||
sys.stderr = STD_OUT_WRAPPER(sys.stderr)
|
||||
@Mod.DestroyServer()
|
||||
def server_destroy(self):
|
||||
pass
|
||||
|
||||
|
||||
@PRE_SERVER_LOADER_HOOK
|
||||
def SERVER_INIT():
|
||||
global REF
|
||||
REF += 1
|
||||
|
||||
def _DESTROY():
|
||||
global REF
|
||||
REF -= 1
|
||||
if REF != 0:
|
||||
return
|
||||
REST_STDOUT()
|
||||
|
||||
from .QuModLibs.Systems.Loader.Server import LoaderSystem
|
||||
|
||||
LoaderSystem.REG_DESTROY_CALL_FUNC(_DESTROY)
|
||||
from . import IPCSystem
|
||||
IPCSystem.ON_SERVER_INIT()
|
||||
|
||||
|
||||
def CLOnKeyPressInGame(args={}):
|
||||
if args["isDown"] != "0":
|
||||
return
|
||||
if args["screenName"] != "hud_screen" and not DEBUG_CONFIG.get(
|
||||
"reload_key_global", False
|
||||
):
|
||||
return
|
||||
key = args["key"]
|
||||
if key == str(DEBUG_CONFIG.get("reload_key", "82")):
|
||||
RELOAD_MOD()
|
||||
elif key == str(DEBUG_CONFIG.get("reload_world_key", "")):
|
||||
RELOAD_WORLD()
|
||||
elif key == str(DEBUG_CONFIG.get("reload_addon_key", "")):
|
||||
RELOAD_ADDON()
|
||||
elif key == str(DEBUG_CONFIG.get("reload_shaders_key", "")):
|
||||
RELOAD_SHADERS()
|
||||
|
||||
|
||||
@PRE_CLIENT_LOADER_HOOK
|
||||
def CLIENT_INIT():
|
||||
global REF
|
||||
REF += 1
|
||||
from . import IPCSystem
|
||||
|
||||
def _DESTROY():
|
||||
global REF
|
||||
REF -= 1
|
||||
IPCSystem.ON_CLIENT_EXIT()
|
||||
if REF != 0:
|
||||
return
|
||||
REST_STDOUT()
|
||||
|
||||
from .QuModLibs.Systems.Loader.Client import LoaderSystem
|
||||
|
||||
LoaderSystem.REG_DESTROY_CALL_FUNC(_DESTROY)
|
||||
|
||||
LoaderSystem.getSystem().nativeStaticListen("OnKeyPressInGame", CLOnKeyPressInGame)
|
||||
IPCSystem.ON_CLIENT_INIT()
|
||||
|
||||
|
||||
try:
|
||||
INIT_RELOAD_TIME()
|
||||
except:
|
||||
pass
|
||||
@Mod.DestroyClient()
|
||||
def client_destroy(self):
|
||||
pass
|
||||
|
||||
3
examples/default/.gitignore
vendored
3
examples/default/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.idea/
|
||||
studio.json
|
||||
.mcs/editorSave.json
|
||||
.mcs/images/
|
||||
.mcs/images/
|
||||
.emod-cli/
|
||||
35
src/boss_engine/copy.rs
Normal file
35
src/boss_engine/copy.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
/// 递归复制项目目录,排除指定的目录名和文件名。用于 fork 的项目复制。
|
||||
pub fn copy_dir_filtered(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
exclude_dirs: &[&str],
|
||||
exclude_files: &[&str],
|
||||
) -> Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let entry_path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let name_str = file_name.to_string_lossy();
|
||||
|
||||
if entry_path.is_dir() {
|
||||
if exclude_dirs.contains(&name_str.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let dest_path = dst.join(&file_name);
|
||||
copy_dir_filtered(&entry_path, &dest_path, exclude_dirs, exclude_files)?;
|
||||
} else if entry_path.is_file() {
|
||||
if exclude_files.contains(&name_str.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let dest_path = dst.join(&file_name);
|
||||
std::fs::copy(&entry_path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
114
src/boss_engine/mod.rs
Normal file
114
src/boss_engine/mod.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
pub mod copy;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{CliError, Result};
|
||||
|
||||
/// 发现项目布局: BP/RP 目录 + scripts package 目录。
|
||||
/// 逻辑与 AbyssBossEngine tests/conftest.py 的自动发现一致。
|
||||
pub fn discover_layout(project_dir: &Path) -> Result<Layout> {
|
||||
let behavior_pack = find_single_dir(project_dir, "behavior_pack_")?;
|
||||
let resource_pack = find_single_dir(project_dir, "resource_pack_")?;
|
||||
let scripts_package = find_scripts_package(&behavior_pack)?;
|
||||
let scripts_package_name = scripts_package
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| {
|
||||
CliError::InvalidData(format!(
|
||||
"scripts package 目录名非 UTF-8: '{}'",
|
||||
scripts_package.display()
|
||||
))
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok(Layout {
|
||||
behavior_pack,
|
||||
resource_pack,
|
||||
scripts_package,
|
||||
scripts_package_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct Layout {
|
||||
pub behavior_pack: PathBuf,
|
||||
pub resource_pack: PathBuf,
|
||||
pub scripts_package: PathBuf,
|
||||
pub scripts_package_name: String,
|
||||
}
|
||||
|
||||
fn find_single_dir(parent: &Path, prefix: &str) -> Result<PathBuf> {
|
||||
let mut matches = Vec::new();
|
||||
for entry in std::fs::read_dir(parent)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with(prefix) {
|
||||
matches.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match matches.len() {
|
||||
0 => Err(CliError::NotFound(format!(
|
||||
"在 '{}' 下未找到 {}* 目录",
|
||||
parent.display(),
|
||||
prefix
|
||||
))),
|
||||
1 => Ok(matches.into_iter().next().unwrap()),
|
||||
_ => Err(CliError::InvalidData(format!(
|
||||
"在 '{}' 下找到多个 {}* 目录",
|
||||
parent.display(),
|
||||
prefix
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_scripts_package(behavior_pack: &Path) -> Result<PathBuf> {
|
||||
let mut matches = Vec::new();
|
||||
for entry in std::fs::read_dir(behavior_pack)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() && path.join("modMain.py").is_file() {
|
||||
matches.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
match matches.len() {
|
||||
0 => Err(CliError::NotFound(format!(
|
||||
"在 '{}' 下未找到含 modMain.py 的 scripts package",
|
||||
behavior_pack.display()
|
||||
))),
|
||||
1 => Ok(matches.into_iter().next().unwrap()),
|
||||
_ => Err(CliError::InvalidData(format!(
|
||||
"在 '{}' 下找到多个含 modMain.py 的目录",
|
||||
behavior_pack.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// PascalCase → snake_case: AbyssBossEngine → abyss_boss_engine
|
||||
pub fn pascal_to_snake(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if ch.is_uppercase() && i > 0 {
|
||||
result.push('_');
|
||||
}
|
||||
result.extend(ch.to_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 首字母小写: AbyssBossEngine → abyssBossEngine
|
||||
pub fn lower_first_char(s: &str) -> Result<String> {
|
||||
let mut chars = s.chars();
|
||||
let first = chars
|
||||
.next()
|
||||
.ok_or_else(|| CliError::InvalidInput("name 不能为空".to_string()))?;
|
||||
Ok(format!(
|
||||
"{}{}",
|
||||
first.to_lowercase().collect::<String>(),
|
||||
chars.as_str()
|
||||
))
|
||||
}
|
||||
486
src/commands/fork_boss_engine.rs
Normal file
486
src/commands/fork_boss_engine.rs
Normal file
@@ -0,0 +1,486 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{env, fs};
|
||||
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::boss_engine::{self, copy};
|
||||
use crate::commands::ForkBossEngineArgs;
|
||||
use crate::error::{CliError, Result};
|
||||
use crate::utils::file;
|
||||
|
||||
const BOSS_ENGINE_PATH_ENV: &str = "ABYSS_BOSS_ENGINE_PATH";
|
||||
const OLD_NAMESPACE: &str = "abyss_boss_engine";
|
||||
|
||||
const EXCLUDE_DIRS: &[&str] = &[
|
||||
".git",
|
||||
"target",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".codewiki",
|
||||
".omo",
|
||||
".idea",
|
||||
".opencode",
|
||||
".agents",
|
||||
".claude",
|
||||
".qoder",
|
||||
"openspec",
|
||||
".emod-cli",
|
||||
];
|
||||
|
||||
const EXCLUDE_FILES: &[&str] = &[".mcdev.json", "AGENTS.md"];
|
||||
|
||||
pub fn execute(args: &ForkBossEngineArgs) {
|
||||
if let Err(e) = run_fork(args) {
|
||||
eprintln!("Error: {}", e);
|
||||
return;
|
||||
}
|
||||
println!("Success: boss engine forked.");
|
||||
}
|
||||
|
||||
fn run_fork(args: &ForkBossEngineArgs) -> Result<()> {
|
||||
validate_project_name(&args.name)?;
|
||||
|
||||
let from_str = resolve_from(&args.from)?;
|
||||
let from_dir = file::find_project_dir(&Some(from_str))?;
|
||||
validate_engine_source(&from_dir)?;
|
||||
|
||||
let src_layout = boss_engine::discover_layout(&from_dir)?;
|
||||
let old_scripts_name = src_layout.scripts_package_name.clone();
|
||||
let old_bp_dir_name = src_layout
|
||||
.behavior_pack
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CliError::InvalidData("BP 目录名非 UTF-8".into()))?
|
||||
.to_string();
|
||||
let old_rp_dir_name = src_layout
|
||||
.resource_pack
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CliError::InvalidData("RP 目录名非 UTF-8".into()))?
|
||||
.to_string();
|
||||
|
||||
let namespace = args
|
||||
.namespace
|
||||
.clone()
|
||||
.unwrap_or_else(|| boss_engine::pascal_to_snake(&args.name));
|
||||
validate_namespace(&namespace)?;
|
||||
let new_scripts_name = format!("{}Scripts", boss_engine::lower_first_char(&args.name)?);
|
||||
|
||||
let output_dir = PathBuf::from(format!("./{}", args.name));
|
||||
if output_dir.exists() {
|
||||
return Err(CliError::InvalidInput(format!(
|
||||
"输出目录已存在: '{}'",
|
||||
output_dir.display()
|
||||
)));
|
||||
}
|
||||
let source_dir = from_dir.canonicalize()?;
|
||||
let output_dir_abs = env::current_dir()?.canonicalize()?.join(&args.name);
|
||||
if output_dir_abs.starts_with(&source_dir) {
|
||||
return Err(CliError::InvalidInput(format!(
|
||||
"输出目录不能位于框架源目录内: '{}'",
|
||||
output_dir_abs.display()
|
||||
)));
|
||||
}
|
||||
|
||||
println!("Forking boss engine:");
|
||||
println!(" from: {}", from_dir.display());
|
||||
println!(" to: {}", output_dir.display());
|
||||
println!(" name: {}", args.name);
|
||||
println!(" ns: {}", namespace);
|
||||
|
||||
copy::copy_dir_filtered(&from_dir, &output_dir, EXCLUDE_DIRS, EXCLUDE_FILES)?;
|
||||
|
||||
let uuids = generate_uuids();
|
||||
update_manifests_and_packs(&output_dir, &uuids)?;
|
||||
|
||||
let new_bp_dir_name = format!("behavior_pack_{}", uuids.bp_short);
|
||||
let new_rp_dir_name = format!("resource_pack_{}", uuids.rp_short);
|
||||
rename_dir(&output_dir, &old_bp_dir_name, &new_bp_dir_name)?;
|
||||
rename_dir(&output_dir, &old_rp_dir_name, &new_rp_dir_name)?;
|
||||
|
||||
let new_bp_dir = output_dir.join(&new_bp_dir_name);
|
||||
rename_dir(&new_bp_dir, &old_scripts_name, &new_scripts_name)?;
|
||||
|
||||
let new_scripts_dir = new_bp_dir.join(&new_scripts_name);
|
||||
|
||||
if args.keep_example {
|
||||
replace_namespace_everywhere(&output_dir, OLD_NAMESPACE, &namespace)?;
|
||||
} else {
|
||||
let new_rp_dir = output_dir.join(&new_rp_dir_name);
|
||||
remove_example_assets(&new_bp_dir, &new_rp_dir, &new_scripts_dir)?;
|
||||
write_clean_modconfig(&new_scripts_dir, &namespace)?;
|
||||
write_clean_server_system(&new_scripts_dir)?;
|
||||
clean_lang_file(&new_rp_dir)?;
|
||||
}
|
||||
|
||||
print_summary(&uuids, &old_scripts_name, &new_scripts_name, &args.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_project_name(name: &str) -> Result<()> {
|
||||
let mut chars = name.chars();
|
||||
let valid = chars
|
||||
.next()
|
||||
.map(|first| first.is_ascii_uppercase())
|
||||
.unwrap_or(false)
|
||||
&& chars.all(|ch| ch.is_ascii_alphanumeric());
|
||||
if !valid {
|
||||
return Err(CliError::InvalidInput(
|
||||
"name 必须是仅含 ASCII 字母和数字的 PascalCase 名称".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_namespace(namespace: &str) -> Result<()> {
|
||||
let mut chars = namespace.chars();
|
||||
let valid = chars
|
||||
.next()
|
||||
.map(|first| first.is_ascii_lowercase())
|
||||
.unwrap_or(false)
|
||||
&& chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_');
|
||||
if !valid {
|
||||
return Err(CliError::InvalidInput(
|
||||
"namespace 必须以小写 ASCII 字母开头,且仅包含小写字母、数字和下划线".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_engine_source(project_dir: &Path) -> Result<()> {
|
||||
let layout = boss_engine::discover_layout(project_dir)?;
|
||||
let marker = layout
|
||||
.scripts_package
|
||||
.join("boss_engine")
|
||||
.join("server")
|
||||
.join("manager.py");
|
||||
if !marker.is_file() {
|
||||
return Err(CliError::InvalidInput(format!(
|
||||
"'{}' 不是有效的 AbyssBossEngine 项目: 缺少 boss_engine/server/manager.py",
|
||||
project_dir.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct NewUuids {
|
||||
bp_header: String,
|
||||
bp_module: String,
|
||||
rp_header: String,
|
||||
rp_module: String,
|
||||
bp_short: String,
|
||||
rp_short: String,
|
||||
}
|
||||
|
||||
fn generate_uuids() -> NewUuids {
|
||||
let bp_header = Uuid::new_v4().to_string();
|
||||
let rp_header = Uuid::new_v4().to_string();
|
||||
NewUuids {
|
||||
bp_short: bp_header.chars().take(8).collect(),
|
||||
rp_short: rp_header.chars().take(8).collect(),
|
||||
bp_module: Uuid::new_v4().to_string(),
|
||||
rp_module: Uuid::new_v4().to_string(),
|
||||
bp_header,
|
||||
rp_header,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_manifests_and_packs(output_dir: &Path, uuids: &NewUuids) -> Result<()> {
|
||||
let bp_dir = find_pack_dir(output_dir, "behavior_pack_")?;
|
||||
let rp_dir = find_pack_dir(output_dir, "resource_pack_")?;
|
||||
|
||||
file::update_json_file(&bp_dir.join("pack_manifest.json"), |json| {
|
||||
json["header"]["uuid"] = uuids.bp_header.clone().into();
|
||||
json["modules"][0]["uuid"] = uuids.bp_module.clone().into();
|
||||
json["dependencies"][0]["uuid"] = uuids.rp_header.clone().into();
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
file::update_json_file(&rp_dir.join("pack_manifest.json"), |json| {
|
||||
json["header"]["uuid"] = uuids.rp_header.clone().into();
|
||||
json["modules"][0]["uuid"] = uuids.rp_module.clone().into();
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
file::update_json_file(&output_dir.join("world_behavior_packs.json"), |json| {
|
||||
json[0]["pack_id"] = uuids.bp_header.clone().into();
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
file::update_json_file(&output_dir.join("world_resource_packs.json"), |json| {
|
||||
json[0]["pack_id"] = uuids.rp_header.clone().into();
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_pack_dir(parent: &Path, prefix: &str) -> Result<PathBuf> {
|
||||
for entry in fs::read_dir(parent)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with(prefix) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CliError::NotFound(format!(
|
||||
"在 '{}' 下未找到 {}* 目录",
|
||||
parent.display(),
|
||||
prefix
|
||||
)))
|
||||
}
|
||||
|
||||
fn rename_dir(parent: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
let old_path = parent.join(old_name);
|
||||
let new_path = parent.join(new_name);
|
||||
if !old_path.exists() {
|
||||
return Err(CliError::NotFound(format!(
|
||||
"待重命名目录不存在: '{}'",
|
||||
old_path.display()
|
||||
)));
|
||||
}
|
||||
fs::rename(&old_path, &new_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── example 处理 ──────────────────────────────────────────
|
||||
|
||||
fn remove_example_assets(bp_dir: &Path, rp_dir: &Path, scripts_dir: &Path) -> Result<()> {
|
||||
let example_prefix = format!("{}_example", OLD_NAMESPACE);
|
||||
|
||||
let mut deleted = 0usize;
|
||||
for root in [bp_dir, rp_dir] {
|
||||
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if file_name.starts_with(&example_prefix) {
|
||||
fs::remove_file(path)?;
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let example_scripts_dir = scripts_dir.join("modServer").join("example");
|
||||
if example_scripts_dir.is_dir() {
|
||||
fs::remove_dir_all(&example_scripts_dir)?;
|
||||
}
|
||||
|
||||
println!(" removed {} example asset files", deleted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_clean_modconfig(scripts_dir: &Path, namespace: &str) -> Result<()> {
|
||||
let content = format!(
|
||||
r#"# -*- coding: utf-8 -*-
|
||||
|
||||
MOD_ID = "{ns}"
|
||||
MOD_VERSION = "1.0.0"
|
||||
SCRIPT_PACKAGE = __name__.split(".")[0]
|
||||
|
||||
PROJECT_NAME = "AiyoBoss_%s" % MOD_ID
|
||||
SERVER_SYSTEM_NAME = "AiyoBossServer_%s" % MOD_ID
|
||||
CLIENT_SYSTEM_NAME = "AiyoBossClient_%s" % MOD_ID
|
||||
|
||||
SERVER_SYSTEM_PATH = "%s.modServer.serverSystem.BossModServerSystem" % SCRIPT_PACKAGE
|
||||
CLIENT_SYSTEM_PATH = "%s.modClient.clientSystem.BossModClientSystem" % SCRIPT_PACKAGE
|
||||
BROKER_SYSTEM_PATH = "%s.boss_engine.client.broker_system.BossClientBroker" % SCRIPT_PACKAGE
|
||||
|
||||
BOSS_REGISTRY_EVENT = "AiyoBossRegistryV1"
|
||||
BGM_SNAPSHOT_EVENT = "AiyoBossBgmSnapshotV1"
|
||||
CLIENT_READY_EVENT = "AiyoBossClientReadyV1"
|
||||
|
||||
MAX_SUMMONS_PER_BOSS = 8
|
||||
SUMMON_TARGET_SYNC_INTERVAL = 0.5
|
||||
"#,
|
||||
ns = namespace
|
||||
);
|
||||
|
||||
let path = scripts_dir.join("modCommon").join("modConfig.py");
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_clean_server_system(scripts_dir: &Path) -> Result<()> {
|
||||
let content = r#"# -*- coding: utf-8 -*-
|
||||
import mod.server.extraServerApi as serverApi
|
||||
|
||||
from ..boss_engine.server import BossManager
|
||||
from ..modCommon import modConfig
|
||||
|
||||
|
||||
ServerSystem = serverApi.GetServerSystemCls()
|
||||
|
||||
|
||||
class BossModServerSystem(ServerSystem):
|
||||
def __init__(self, namespace, system_name):
|
||||
ServerSystem.__init__(self, namespace, system_name)
|
||||
self.boss_manager = BossManager(
|
||||
self,
|
||||
modConfig.MOD_ID,
|
||||
modConfig.PROJECT_NAME,
|
||||
modConfig.CLIENT_SYSTEM_NAME,
|
||||
modConfig.BOSS_REGISTRY_EVENT,
|
||||
modConfig.BGM_SNAPSHOT_EVENT,
|
||||
modConfig.CLIENT_READY_EVENT,
|
||||
modConfig.MAX_SUMMONS_PER_BOSS,
|
||||
modConfig.SUMMON_TARGET_SYNC_INTERVAL,
|
||||
)
|
||||
self.boss_manager.start()
|
||||
print("[AbyssBossEngine][%s] server ready" % modConfig.MOD_ID)
|
||||
|
||||
def Destroy(self):
|
||||
self.boss_manager.destroy()
|
||||
"#;
|
||||
|
||||
let path = scripts_dir.join("modServer").join("serverSystem.py");
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clean_lang_file(rp_dir: &Path) -> Result<()> {
|
||||
let lang_path = rp_dir.join("texts").join("zh_CN.lang");
|
||||
if !lang_path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let content = fs::read_to_string(&lang_path)?;
|
||||
let filtered: String = content
|
||||
.lines()
|
||||
.filter(|line| !line.contains(OLD_NAMESPACE))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(&lang_path, filtered + "\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── keep-example 模式: namespace 全局替换 ──────────────────
|
||||
|
||||
fn replace_namespace_everywhere(output_dir: &Path, old_ns: &str, new_ns: &str) -> Result<()> {
|
||||
let bp_dir = find_pack_dir(output_dir, "behavior_pack_")?;
|
||||
let rp_dir = find_pack_dir(output_dir, "resource_pack_")?;
|
||||
|
||||
let mut replaced_files = 0usize;
|
||||
for root in [&bp_dir, &rp_dir] {
|
||||
replaced_files += replace_in_text_files(root, old_ns, new_ns)?;
|
||||
}
|
||||
|
||||
let mut renamed_files = 0usize;
|
||||
for root in [&bp_dir, &rp_dir] {
|
||||
renamed_files += rename_files_with_namespace(root, old_ns, new_ns)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
" namespace replaced: {} -> {} ({} files updated, {} renamed)",
|
||||
old_ns, new_ns, replaced_files, renamed_files
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_in_text_files(root: &Path, old_ns: &str, new_ns: &str) -> Result<usize> {
|
||||
let mut count = 0usize;
|
||||
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !content.contains(old_ns) {
|
||||
continue;
|
||||
}
|
||||
let updated = content.replace(old_ns, new_ns);
|
||||
fs::write(path, updated)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn rename_files_with_namespace(root: &Path, old_ns: &str, new_ns: &str) -> Result<usize> {
|
||||
let mut to_rename = Vec::new();
|
||||
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if file_name.contains(old_ns) {
|
||||
to_rename.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
for path in &to_rename {
|
||||
let dir = path.parent().unwrap();
|
||||
let old_name = path.file_name().unwrap().to_str().unwrap();
|
||||
let new_name = old_name.replace(old_ns, new_ns);
|
||||
fs::rename(path, dir.join(new_name))?;
|
||||
}
|
||||
|
||||
Ok(to_rename.len())
|
||||
}
|
||||
|
||||
// ── 输出 ──────────────────────────────────────────────────
|
||||
|
||||
fn print_summary(uuids: &NewUuids, old_scripts: &str, new_scripts: &str, project_name: &str) {
|
||||
println!("\nUUID changes:");
|
||||
println!(" BP header: {}", uuids.bp_header);
|
||||
println!(" BP module: {}", uuids.bp_module);
|
||||
println!(" RP header: {}", uuids.rp_header);
|
||||
println!(" RP module: {}", uuids.rp_module);
|
||||
println!("\nScripts package: {} -> {}", old_scripts, new_scripts);
|
||||
println!("\nNext steps:");
|
||||
println!(" cd {}", project_name);
|
||||
println!(" python -m pytest tests -q");
|
||||
}
|
||||
|
||||
fn resolve_from(from: &Option<String>) -> Result<String> {
|
||||
if let Some(path) = from {
|
||||
if !path.is_empty() {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
}
|
||||
match env::var(BOSS_ENGINE_PATH_ENV) {
|
||||
Ok(value) if !value.is_empty() => {
|
||||
println!(" (from env ${}: {})", BOSS_ENGINE_PATH_ENV, value);
|
||||
Ok(value)
|
||||
}
|
||||
_ => Err(CliError::InvalidInput(format!(
|
||||
"未指定框架源路径: 请使用 --from 或设置环境变量 {}",
|
||||
BOSS_ENGINE_PATH_ENV
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{validate_namespace, validate_project_name};
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_project_names_and_namespaces() {
|
||||
assert!(validate_project_name("ShadowBoss2").is_ok());
|
||||
assert!(validate_namespace("shadow_boss2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_paths_and_invalid_namespaces() {
|
||||
assert!(validate_project_name("../ShadowBoss").is_err());
|
||||
assert!(validate_project_name("shadowBoss").is_err());
|
||||
assert!(validate_namespace("ShadowBoss").is_err());
|
||||
assert!(validate_namespace("2shadow_boss").is_err());
|
||||
}
|
||||
}
|
||||
15
src/commands/mcp.rs
Normal file
15
src/commands/mcp.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::commands::McpArgs;
|
||||
|
||||
pub fn execute(args: &McpArgs) {
|
||||
let project_dir = args
|
||||
.path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if let Err(err) = crate::mcp::run_blocking(project_dir) {
|
||||
eprintln!("Error: {}", err);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ pub mod bbmodel;
|
||||
pub mod components;
|
||||
pub mod create;
|
||||
pub mod debug;
|
||||
pub mod fork_boss_engine;
|
||||
pub mod init;
|
||||
pub mod mcp;
|
||||
pub mod release;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -33,8 +35,12 @@ pub enum Commands {
|
||||
Components(ComponentsArgs),
|
||||
/// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload
|
||||
Debug(DebugArgs),
|
||||
/// Run stdio MCP server for the current debug session
|
||||
Mcp(McpArgs),
|
||||
/// Convert Blockbench .bbmodel to NetEase block geometry
|
||||
Bbmodel(BbmodelArgs),
|
||||
/// Fork AbyssBossEngine into a new boss mod project
|
||||
ForkBossEngine(ForkBossEngineArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -118,3 +124,27 @@ pub struct DebugArgs {
|
||||
#[arg(short = 'n', long = "new")]
|
||||
pub new_world: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct McpArgs {
|
||||
/// The path of the project (default: current directory)
|
||||
#[arg(short, long)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ForkBossEngineArgs {
|
||||
/// Path to the AbyssBossEngine source project.
|
||||
/// Falls back to env ABYSS_BOSS_ENGINE_PATH when omitted.
|
||||
#[arg(long)]
|
||||
pub from: Option<String>,
|
||||
/// New project name (PascalCase, e.g. MyShadowBoss)
|
||||
#[arg(short, long)]
|
||||
pub name: String,
|
||||
/// Override the entity namespace (default: snake_case of --name)
|
||||
#[arg(long)]
|
||||
pub namespace: Option<String>,
|
||||
/// Keep example boss as reference (default: remove)
|
||||
#[arg(long)]
|
||||
pub keep_example: bool,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct DebugConfig {
|
||||
pub include_debug_mod: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_hot_reload_mods: bool,
|
||||
#[serde(default)]
|
||||
pub auto_hot_reload_ui: bool,
|
||||
#[serde(default = "default_world_type")]
|
||||
pub world_type: i32,
|
||||
#[serde(default = "default_game_mode")]
|
||||
@@ -200,6 +202,7 @@ fn create_default_config() -> DebugConfig {
|
||||
auto_join_game: true,
|
||||
include_debug_mod: true,
|
||||
auto_hot_reload_mods: true,
|
||||
auto_hot_reload_ui: false,
|
||||
world_type: default_world_type(),
|
||||
game_mode: default_game_mode(),
|
||||
enable_cheats: true,
|
||||
@@ -350,7 +353,7 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use super::{allocate_new_world, resolve_mod_dir, strip_json_comments};
|
||||
use super::{DebugConfig, allocate_new_world, resolve_mod_dir, strip_json_comments};
|
||||
|
||||
struct TempProject {
|
||||
path: PathBuf,
|
||||
@@ -388,6 +391,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_ui_hot_reload_is_opt_in() {
|
||||
let default_config: DebugConfig = serde_json::from_str("{}").unwrap();
|
||||
assert!(!default_config.auto_hot_reload_ui);
|
||||
|
||||
let enabled: DebugConfig = serde_json::from_str(r#"{"auto_hot_reload_ui":true}"#).unwrap();
|
||||
assert!(enabled.auto_hot_reload_ui);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocate_new_world_updates_folder_and_name() {
|
||||
let project = TempProject::new();
|
||||
|
||||
@@ -1,32 +1,101 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{BTreeSet, HashSet},
|
||||
mem,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use std::{fs, time::SystemTime};
|
||||
use std::{collections::HashMap, fs, time::SystemTime};
|
||||
|
||||
use serde_json::json;
|
||||
#[cfg(not(windows))]
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::{
|
||||
addon::{self, PackType},
|
||||
config::ResolvedModDir,
|
||||
ipc::DebugIpcServer,
|
||||
log::{self, ConsoleColor},
|
||||
win,
|
||||
};
|
||||
|
||||
const DEBOUNCE_MS: u64 = 100;
|
||||
const DEBOUNCE_MS: u64 = 150;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct HotReloadOptions {
|
||||
pub python: bool,
|
||||
pub json_ui: bool,
|
||||
}
|
||||
|
||||
impl HotReloadOptions {
|
||||
fn enabled(self) -> bool {
|
||||
self.python || self.json_ui
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
struct PendingReload {
|
||||
python_modules: BTreeSet<String>,
|
||||
json_ui: bool,
|
||||
}
|
||||
|
||||
impl PendingReload {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.python_modules.is_empty() && !self.json_ui
|
||||
}
|
||||
|
||||
fn merge(&mut self, mut other: Self) {
|
||||
self.python_modules.append(&mut other.python_modules);
|
||||
self.json_ui |= other.json_ui;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ReloadState {
|
||||
pending: PendingReload,
|
||||
is_foreground: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum ReloadAction {
|
||||
Python(Vec<String>),
|
||||
JsonUi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct DebouncedPaths {
|
||||
paths: HashSet<PathBuf>,
|
||||
last_event: Option<Instant>,
|
||||
}
|
||||
|
||||
impl DebouncedPaths {
|
||||
fn push(&mut self, path: PathBuf, now: Instant) {
|
||||
self.paths.insert(path);
|
||||
self.last_event = Some(now);
|
||||
}
|
||||
|
||||
fn take_ready(&mut self, now: Instant) -> Vec<PathBuf> {
|
||||
let ready = self
|
||||
.last_event
|
||||
.map(|last| now.duration_since(last) >= Duration::from_millis(DEBOUNCE_MS))
|
||||
.unwrap_or(false);
|
||||
if !ready {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.last_event = None;
|
||||
self.paths.drain().collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HotReloadTask {
|
||||
stop: Arc<AtomicBool>,
|
||||
|
||||
file_thread: Option<JoinHandle<()>>,
|
||||
foreground_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -35,8 +104,13 @@ impl HotReloadTask {
|
||||
pub fn start(
|
||||
process_id: u32,
|
||||
mod_dirs: &[ResolvedModDir],
|
||||
ipc: DebugIpcServer,
|
||||
ipc: Option<DebugIpcServer>,
|
||||
options: HotReloadOptions,
|
||||
) -> Option<Self> {
|
||||
if !options.enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let roots: Vec<PathBuf> = mod_dirs
|
||||
.iter()
|
||||
.filter(|dir| dir.hot_reload)
|
||||
@@ -52,45 +126,24 @@ impl HotReloadTask {
|
||||
}
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let need_update = Arc::new(AtomicBool::new(false));
|
||||
let is_foreground = Arc::new(AtomicBool::new(false));
|
||||
let cached_paths = Arc::new(Mutex::new(HashSet::new()));
|
||||
let state = Arc::new(Mutex::new(ReloadState::default()));
|
||||
|
||||
let file_thread = {
|
||||
let stop = Arc::clone(&stop);
|
||||
let need_update = Arc::clone(&need_update);
|
||||
let is_foreground = Arc::clone(&is_foreground);
|
||||
let cached_paths = Arc::clone(&cached_paths);
|
||||
let state = Arc::clone(&state);
|
||||
let ipc = ipc.clone();
|
||||
let roots = roots.clone();
|
||||
thread::spawn(move || {
|
||||
watch_py_files(roots, stop, need_update, is_foreground, cached_paths, ipc)
|
||||
})
|
||||
thread::spawn(move || watch_files(process_id, roots, stop, state, ipc, options))
|
||||
};
|
||||
|
||||
let foreground_thread = {
|
||||
let stop = Arc::clone(&stop);
|
||||
let need_update = Arc::clone(&need_update);
|
||||
let is_foreground = Arc::clone(&is_foreground);
|
||||
let cached_paths = Arc::clone(&cached_paths);
|
||||
let ipc = ipc.clone();
|
||||
let roots = roots.clone();
|
||||
thread::spawn(move || {
|
||||
watch_foreground(
|
||||
process_id,
|
||||
roots,
|
||||
stop,
|
||||
need_update,
|
||||
is_foreground,
|
||||
cached_paths,
|
||||
ipc,
|
||||
)
|
||||
})
|
||||
let state = Arc::clone(&state);
|
||||
thread::spawn(move || watch_foreground(process_id, stop, state, ipc))
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
stop,
|
||||
|
||||
file_thread: Some(file_thread),
|
||||
foreground_thread: Some(foreground_thread),
|
||||
})
|
||||
@@ -113,32 +166,32 @@ impl Drop for HotReloadTask {
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_py_files(
|
||||
fn watch_files(
|
||||
process_id: u32,
|
||||
roots: Vec<PathBuf>,
|
||||
stop: Arc<AtomicBool>,
|
||||
need_update: Arc<AtomicBool>,
|
||||
is_foreground: Arc<AtomicBool>,
|
||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
ipc: DebugIpcServer,
|
||||
state: Arc<Mutex<ReloadState>>,
|
||||
ipc: Option<DebugIpcServer>,
|
||||
options: HotReloadOptions,
|
||||
) {
|
||||
watch_py_files_native(roots, stop, need_update, is_foreground, cached_paths, ipc);
|
||||
watch_files_native(process_id, roots, stop, state, ipc, options);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn watch_py_files_native(
|
||||
fn watch_files_native(
|
||||
process_id: u32,
|
||||
roots: Vec<PathBuf>,
|
||||
stop: Arc<AtomicBool>,
|
||||
need_update: Arc<AtomicBool>,
|
||||
is_foreground: Arc<AtomicBool>,
|
||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
ipc: DebugIpcServer,
|
||||
state: Arc<Mutex<ReloadState>>,
|
||||
ipc: Option<DebugIpcServer>,
|
||||
options: HotReloadOptions,
|
||||
) {
|
||||
use std::{mem, os::windows::ffi::OsStrExt, ptr, time::Instant};
|
||||
use std::{mem, os::windows::ffi::OsStrExt, ptr};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
|
||||
Storage::FileSystem::{
|
||||
CreateFileW, FILE_ACTION_MODIFIED, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED,
|
||||
FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION,
|
||||
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY,
|
||||
FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION,
|
||||
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
ReadDirectoryChangesW,
|
||||
},
|
||||
@@ -179,7 +232,7 @@ fn watch_py_files_native(
|
||||
item.buffer.as_mut_ptr().cast(),
|
||||
item.buffer.len() as u32,
|
||||
1,
|
||||
FILE_NOTIFY_CHANGE_LAST_WRITE,
|
||||
FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME,
|
||||
&mut bytes_returned,
|
||||
&mut item.overlapped,
|
||||
None,
|
||||
@@ -239,178 +292,236 @@ fn watch_py_files_native(
|
||||
start_watch(item);
|
||||
}
|
||||
|
||||
let mut debounce = HashMap::<PathBuf, Instant>::new();
|
||||
let mut debounced = DebouncedPaths::default();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let handles: Vec<HANDLE> = items.iter().map(|item| item.event).collect();
|
||||
let result =
|
||||
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 100) };
|
||||
if result == WAIT_TIMEOUT {
|
||||
continue;
|
||||
}
|
||||
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 50) };
|
||||
if result == WAIT_FAILED {
|
||||
break;
|
||||
}
|
||||
let index = (result - WAIT_OBJECT_0) as usize;
|
||||
if index >= items.len() {
|
||||
continue;
|
||||
}
|
||||
let item = &mut items[index];
|
||||
let mut bytes = 0u32;
|
||||
let ok = unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) };
|
||||
if ok == 0 || bytes == 0 {
|
||||
start_watch(item);
|
||||
continue;
|
||||
|
||||
if result != WAIT_TIMEOUT {
|
||||
let index = (result - WAIT_OBJECT_0) as usize;
|
||||
if index < items.len() {
|
||||
let item = &mut items[index];
|
||||
let mut bytes = 0u32;
|
||||
let ok =
|
||||
unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) };
|
||||
if ok != 0 && bytes != 0 {
|
||||
let mut offset = 0usize;
|
||||
let now = Instant::now();
|
||||
while offset < bytes as usize {
|
||||
let info = unsafe {
|
||||
&*(item.buffer.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION)
|
||||
};
|
||||
let name_len = (info.FileNameLength / 2) as usize;
|
||||
let name =
|
||||
unsafe { std::slice::from_raw_parts(info.FileName.as_ptr(), name_len) };
|
||||
let path = item.dir.join(String::from_utf16_lossy(name));
|
||||
if is_supported_file_action(info.Action)
|
||||
&& is_tracked_reload_path(&path, &roots, options)
|
||||
{
|
||||
debounced.push(path, now);
|
||||
}
|
||||
if info.NextEntryOffset == 0 {
|
||||
break;
|
||||
}
|
||||
offset += info.NextEntryOffset as usize;
|
||||
}
|
||||
}
|
||||
start_watch(item);
|
||||
}
|
||||
}
|
||||
|
||||
let mut offset = 0usize;
|
||||
while offset < bytes as usize {
|
||||
let info =
|
||||
unsafe { &*(item.buffer.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) };
|
||||
let name_len = (info.FileNameLength / 2) as usize;
|
||||
let name = unsafe { std::slice::from_raw_parts(info.FileName.as_ptr(), name_len) };
|
||||
let path = item.dir.join(String::from_utf16_lossy(name));
|
||||
if info.Action == FILE_ACTION_MODIFIED
|
||||
&& path.extension().and_then(|ext| ext.to_str()) == Some("py")
|
||||
{
|
||||
let now = Instant::now();
|
||||
let should_trigger = debounce
|
||||
.get(&path)
|
||||
.map(|last| now.duration_since(*last) >= Duration::from_millis(DEBOUNCE_MS))
|
||||
.unwrap_or(true);
|
||||
if should_trigger {
|
||||
debounce.insert(path.clone(), now);
|
||||
record_changed_path(
|
||||
&roots,
|
||||
&path,
|
||||
&cached_paths,
|
||||
&need_update,
|
||||
&is_foreground,
|
||||
&ipc,
|
||||
);
|
||||
}
|
||||
}
|
||||
if info.NextEntryOffset == 0 {
|
||||
break;
|
||||
}
|
||||
offset += info.NextEntryOffset as usize;
|
||||
let paths = debounced.take_ready(Instant::now());
|
||||
if !paths.is_empty() {
|
||||
record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref());
|
||||
}
|
||||
start_watch(item);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_supported_file_action(action: u32) -> bool {
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_NEW_NAME,
|
||||
FILE_ACTION_RENAMED_OLD_NAME,
|
||||
};
|
||||
|
||||
matches!(
|
||||
action,
|
||||
FILE_ACTION_ADDED
|
||||
| FILE_ACTION_MODIFIED
|
||||
| FILE_ACTION_REMOVED
|
||||
| FILE_ACTION_RENAMED_OLD_NAME
|
||||
| FILE_ACTION_RENAMED_NEW_NAME
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn watch_py_files_native(
|
||||
fn watch_files_native(
|
||||
process_id: u32,
|
||||
roots: Vec<PathBuf>,
|
||||
stop: Arc<AtomicBool>,
|
||||
need_update: Arc<AtomicBool>,
|
||||
is_foreground: Arc<AtomicBool>,
|
||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
ipc: DebugIpcServer,
|
||||
state: Arc<Mutex<ReloadState>>,
|
||||
ipc: Option<DebugIpcServer>,
|
||||
options: HotReloadOptions,
|
||||
) {
|
||||
let mut last_seen = snapshot_py_files(&roots);
|
||||
let mut debounce = HashMap::<PathBuf, SystemTime>::new();
|
||||
let mut last_seen = snapshot_files(&roots, options);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(DEBOUNCE_MS));
|
||||
let now = SystemTime::now();
|
||||
let current = snapshot_py_files(&roots);
|
||||
let current = snapshot_files(&roots, options);
|
||||
let mut paths = Vec::new();
|
||||
|
||||
for (path, modified) in ¤t {
|
||||
let changed = match last_seen.get(path) {
|
||||
Some(previous) => modified > previous,
|
||||
None => false,
|
||||
};
|
||||
if !changed {
|
||||
continue;
|
||||
if last_seen.get(path) != Some(modified) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
if let Some(last) = debounce.get(path) {
|
||||
if now.duration_since(*last).unwrap_or_default()
|
||||
< Duration::from_millis(DEBOUNCE_MS)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for path in last_seen.keys() {
|
||||
if !current.contains_key(path) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
debounce.insert(path.clone(), now);
|
||||
record_changed_path(
|
||||
&roots,
|
||||
path,
|
||||
&cached_paths,
|
||||
&need_update,
|
||||
&is_foreground,
|
||||
&ipc,
|
||||
);
|
||||
}
|
||||
|
||||
if !paths.is_empty() {
|
||||
record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref());
|
||||
}
|
||||
last_seen = current;
|
||||
}
|
||||
}
|
||||
|
||||
fn record_changed_path(
|
||||
fn record_changed_paths(
|
||||
process_id: u32,
|
||||
roots: &[PathBuf],
|
||||
path: &Path,
|
||||
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
|
||||
need_update: &Arc<AtomicBool>,
|
||||
is_foreground: &Arc<AtomicBool>,
|
||||
ipc: &DebugIpcServer,
|
||||
paths: Vec<PathBuf>,
|
||||
options: HotReloadOptions,
|
||||
state: &Arc<Mutex<ReloadState>>,
|
||||
ipc: Option<&DebugIpcServer>,
|
||||
) {
|
||||
log::print_colored(
|
||||
&format!("[HotReload] Detected change in: {}", path.display()),
|
||||
ConsoleColor::Yellow,
|
||||
);
|
||||
cached_paths.lock().unwrap().insert(path.to_path_buf());
|
||||
need_update.store(true, Ordering::Relaxed);
|
||||
if is_foreground.load(Ordering::Relaxed) {
|
||||
trigger_reload(roots, cached_paths, need_update, ipc);
|
||||
let (batch, tracked_paths) = build_reload_batch(paths, roots, options);
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for path in tracked_paths {
|
||||
log::print_colored(
|
||||
&format!("[HotReload] Detected change in: {}", path.display()),
|
||||
ConsoleColor::Yellow,
|
||||
);
|
||||
}
|
||||
|
||||
let should_trigger = {
|
||||
let mut guard = state.lock().unwrap();
|
||||
guard.pending.merge(batch);
|
||||
guard.is_foreground
|
||||
};
|
||||
if should_trigger {
|
||||
trigger_reload(process_id, state, ipc);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_reload_batch(
|
||||
paths: Vec<PathBuf>,
|
||||
roots: &[PathBuf],
|
||||
options: HotReloadOptions,
|
||||
) -> (PendingReload, Vec<PathBuf>) {
|
||||
let mut batch = PendingReload::default();
|
||||
let mut tracked_paths = Vec::new();
|
||||
|
||||
for path in paths {
|
||||
let mut tracked = false;
|
||||
if options.python {
|
||||
if let Some(module) = py_path_to_module_name(&path, roots) {
|
||||
batch.python_modules.insert(module);
|
||||
tracked = true;
|
||||
}
|
||||
}
|
||||
if options.json_ui && is_json_ui_path(&path, roots) {
|
||||
batch.json_ui = true;
|
||||
tracked = true;
|
||||
}
|
||||
if tracked {
|
||||
tracked_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
(batch, tracked_paths)
|
||||
}
|
||||
|
||||
fn watch_foreground(
|
||||
process_id: u32,
|
||||
roots: Vec<PathBuf>,
|
||||
stop: Arc<AtomicBool>,
|
||||
need_update: Arc<AtomicBool>,
|
||||
is_foreground: Arc<AtomicBool>,
|
||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
ipc: DebugIpcServer,
|
||||
state: Arc<Mutex<ReloadState>>,
|
||||
ipc: Option<DebugIpcServer>,
|
||||
) {
|
||||
let mut last_state = false;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let current = foreground_process_id() == Some(process_id);
|
||||
is_foreground.store(current, Ordering::Relaxed);
|
||||
if current && !last_state && need_update.load(Ordering::Relaxed) {
|
||||
trigger_reload(&roots, &cached_paths, &need_update, &ipc);
|
||||
let should_trigger = {
|
||||
let mut guard = state.lock().unwrap();
|
||||
guard.is_foreground = current;
|
||||
current && !last_state && !guard.pending.is_empty()
|
||||
};
|
||||
if should_trigger {
|
||||
trigger_reload(process_id, &state, ipc.as_ref());
|
||||
}
|
||||
last_state = current;
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_reload(
|
||||
roots: &[PathBuf],
|
||||
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
|
||||
need_update: &Arc<AtomicBool>,
|
||||
ipc: &DebugIpcServer,
|
||||
) {
|
||||
let paths: Vec<PathBuf> = {
|
||||
let mut guard = cached_paths.lock().unwrap();
|
||||
guard.drain().collect()
|
||||
fn trigger_reload(process_id: u32, state: &Arc<Mutex<ReloadState>>, ipc: Option<&DebugIpcServer>) {
|
||||
let batch = {
|
||||
let mut guard = state.lock().unwrap();
|
||||
mem::take(&mut guard.pending)
|
||||
};
|
||||
let modules: Vec<String> = paths
|
||||
.iter()
|
||||
.filter_map(|path| py_path_to_module_name(path, roots))
|
||||
.collect();
|
||||
need_update.store(false, Ordering::Relaxed);
|
||||
if modules.is_empty() {
|
||||
return;
|
||||
|
||||
for action in reload_actions(batch) {
|
||||
match action {
|
||||
ReloadAction::Python(modules) => {
|
||||
log::print_colored(
|
||||
"[HotReload] 检测到 Python 修改,已触发增量热更新。",
|
||||
ConsoleColor::Yellow,
|
||||
);
|
||||
if let Some(ipc) = ipc {
|
||||
let payload = json!(modules).to_string();
|
||||
let _ = ipc.send_message(2, payload.as_bytes());
|
||||
}
|
||||
}
|
||||
ReloadAction::JsonUi => match win::trigger_ui_reload_shortcut(process_id) {
|
||||
Ok(()) => log::print_colored(
|
||||
"[HotReload] JSON UI 修改已触发原生 Ctrl+R 重载。",
|
||||
ConsoleColor::Green,
|
||||
),
|
||||
Err(err) => {
|
||||
log::print_colored(
|
||||
&format!("[HotReload] JSON UI 原生 Ctrl+R 重载失败:{err}"),
|
||||
ConsoleColor::Red,
|
||||
);
|
||||
state.lock().unwrap().pending.json_ui = true;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
log::print_colored(
|
||||
"[HotReload] 检测到修改,已触发热更新。",
|
||||
ConsoleColor::Yellow,
|
||||
);
|
||||
let payload = json!(modules).to_string();
|
||||
let _ = ipc.send_message(2, payload.as_bytes());
|
||||
}
|
||||
|
||||
fn reload_actions(batch: PendingReload) -> Vec<ReloadAction> {
|
||||
let mut actions = Vec::with_capacity(2);
|
||||
if !batch.python_modules.is_empty() {
|
||||
actions.push(ReloadAction::Python(
|
||||
batch.python_modules.into_iter().collect(),
|
||||
));
|
||||
}
|
||||
if batch.json_ui {
|
||||
actions.push(ReloadAction::JsonUi);
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
||||
fn snapshot_files(roots: &[PathBuf], options: HotReloadOptions) -> HashMap<PathBuf, SystemTime> {
|
||||
let mut files = HashMap::new();
|
||||
for root in roots {
|
||||
if !root.is_dir() {
|
||||
@@ -418,7 +529,7 @@ fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
||||
}
|
||||
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("py") {
|
||||
if !path.is_file() || !is_tracked_reload_path(path, roots, options) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(metadata) = fs::metadata(path) {
|
||||
@@ -431,6 +542,53 @@ fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
||||
files
|
||||
}
|
||||
|
||||
fn is_tracked_reload_path(path: &Path, roots: &[PathBuf], options: HotReloadOptions) -> bool {
|
||||
(options.python && py_path_to_module_name(path, roots).is_some())
|
||||
|| (options.json_ui && is_json_ui_path(path, roots))
|
||||
}
|
||||
|
||||
fn is_json_ui_path(file_path: &Path, mod_roots: &[PathBuf]) -> bool {
|
||||
let is_json = file_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("json"))
|
||||
.unwrap_or(false);
|
||||
if !is_json {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(parent) = file_path.parent() else {
|
||||
return false;
|
||||
};
|
||||
for ancestor in parent.ancestors() {
|
||||
let is_ui_dir = ancestor
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.eq_ignore_ascii_case("ui"))
|
||||
.unwrap_or(false);
|
||||
if !is_ui_dir {
|
||||
continue;
|
||||
}
|
||||
let Some(pack_root) = ancestor.parent() else {
|
||||
continue;
|
||||
};
|
||||
let under_tracked_root = mod_roots
|
||||
.iter()
|
||||
.any(|root| same_path(root, pack_root) || pack_root.strip_prefix(root).is_ok());
|
||||
if !under_tracked_root {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
addon::parse_pack_info(pack_root),
|
||||
Ok(Some(info)) if info.pack_type == PackType::Resource
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn py_path_to_module_name(file_path: &Path, mod_roots: &[PathBuf]) -> Option<String> {
|
||||
if file_path.extension().and_then(|ext| ext.to_str()) != Some("py") {
|
||||
return None;
|
||||
@@ -484,30 +642,224 @@ fn foreground_process_id() -> Option<u32> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use super::py_path_to_module_name;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
DEBOUNCE_MS, DebouncedPaths, HotReloadOptions, ReloadAction, build_reload_batch,
|
||||
is_json_ui_path, py_path_to_module_name, reload_actions,
|
||||
};
|
||||
|
||||
struct TempProject {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempProject {
|
||||
fn new() -> Self {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"emod-hot-reload-test-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempProject {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_pack(project: &Path, name: &str, module_type: &str) -> PathBuf {
|
||||
let pack = project.join(name);
|
||||
fs::create_dir_all(&pack).unwrap();
|
||||
let manifest = json!({
|
||||
"header": {
|
||||
"name": name,
|
||||
"uuid": "00000000-0000-0000-0000-000000000001",
|
||||
"version": [1, 0, 0]
|
||||
},
|
||||
"modules": [{
|
||||
"type": module_type,
|
||||
"uuid": "00000000-0000-0000-0000-000000000002",
|
||||
"version": [1, 0, 0]
|
||||
}]
|
||||
});
|
||||
fs::write(
|
||||
pack.join("pack_manifest.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
pack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_py_file_to_module_name_under_manifest() {
|
||||
let root = std::env::temp_dir().join(format!("emod-hot-{}", std::process::id()));
|
||||
let pack = root.join("behavior_pack");
|
||||
let project = TempProject::new();
|
||||
let pack = create_pack(&project.path, "behavior_pack", "data");
|
||||
let module = pack.join("foo/bar.py");
|
||||
fs::create_dir_all(module.parent().unwrap()).unwrap();
|
||||
fs::write(pack.join("manifest.json"), "{}").unwrap();
|
||||
fs::write(&module, "").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
py_path_to_module_name(&module, std::slice::from_ref(&root)),
|
||||
py_path_to_module_name(&module, std::slice::from_ref(&project.path)),
|
||||
Some("foo.bar".to_string())
|
||||
);
|
||||
let outside = root.join("x.py");
|
||||
let outside = project.path.join("x.py");
|
||||
fs::write(&outside, "").unwrap();
|
||||
assert_eq!(
|
||||
py_path_to_module_name(&outside, std::slice::from_ref(&root)),
|
||||
py_path_to_module_name(&outside, std::slice::from_ref(&project.path)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
#[test]
|
||||
fn recognizes_only_resource_pack_json_ui_files() {
|
||||
let project = TempProject::new();
|
||||
let resource = create_pack(&project.path, "resource_pack", "resources");
|
||||
let behavior = create_pack(&project.path, "behavior_pack", "data");
|
||||
let roots = std::slice::from_ref(&project.path);
|
||||
|
||||
let screen = resource.join("ui/screen.json");
|
||||
let nested = resource.join("ui/widgets/_global_variables.json");
|
||||
let defs = resource.join("UI/_ui_defs.JSON");
|
||||
let removed = resource.join("ui/removed.json");
|
||||
let texture_json = resource.join("textures/ui.json");
|
||||
let nested_texture_json = resource.join("textures/ui/icons.json");
|
||||
let mcgui = resource.join("ui/screen.mcgui");
|
||||
let behavior_ui = behavior.join("ui/screen.json");
|
||||
let mcp_state = project.path.join(".emod-cli/debug-mcp.json");
|
||||
fs::create_dir_all(nested.parent().unwrap()).unwrap();
|
||||
fs::create_dir_all(defs.parent().unwrap()).unwrap();
|
||||
fs::write(&screen, "{}").unwrap();
|
||||
fs::write(&nested, "{}").unwrap();
|
||||
fs::write(&defs, "{}").unwrap();
|
||||
fs::write(&removed, "{}").unwrap();
|
||||
fs::remove_file(&removed).unwrap();
|
||||
|
||||
assert!(is_json_ui_path(&screen, roots));
|
||||
assert!(is_json_ui_path(&nested, roots));
|
||||
assert!(is_json_ui_path(&defs, roots));
|
||||
assert!(is_json_ui_path(&removed, roots));
|
||||
assert!(is_json_ui_path(&screen, std::slice::from_ref(&resource)));
|
||||
assert!(!is_json_ui_path(&texture_json, roots));
|
||||
assert!(!is_json_ui_path(&nested_texture_json, roots));
|
||||
assert!(!is_json_ui_path(&mcgui, roots));
|
||||
assert!(!is_json_ui_path(&behavior_ui, roots));
|
||||
assert!(!is_json_ui_path(&mcp_state, roots));
|
||||
|
||||
let (excluded, tracked) = build_reload_batch(
|
||||
vec![
|
||||
texture_json,
|
||||
nested_texture_json,
|
||||
mcgui,
|
||||
behavior_ui,
|
||||
mcp_state,
|
||||
],
|
||||
roots,
|
||||
HotReloadOptions {
|
||||
python: false,
|
||||
json_ui: true,
|
||||
},
|
||||
);
|
||||
assert!(excluded.is_empty());
|
||||
assert!(tracked.is_empty());
|
||||
|
||||
let (disabled, tracked) =
|
||||
build_reload_batch(vec![screen], roots, HotReloadOptions::default());
|
||||
assert!(disabled.is_empty());
|
||||
assert!(tracked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_python_and_json_ui_changes_together() {
|
||||
let project = TempProject::new();
|
||||
let behavior = create_pack(&project.path, "behavior_pack", "data");
|
||||
let resource = create_pack(&project.path, "resource_pack", "resources");
|
||||
let python = behavior.join("scripts/client.py");
|
||||
let screen = resource.join("ui/screen.json");
|
||||
fs::create_dir_all(python.parent().unwrap()).unwrap();
|
||||
fs::create_dir_all(screen.parent().unwrap()).unwrap();
|
||||
fs::write(&python, "").unwrap();
|
||||
fs::write(&screen, "{}").unwrap();
|
||||
|
||||
let (batch, tracked) = build_reload_batch(
|
||||
vec![screen, python],
|
||||
std::slice::from_ref(&project.path),
|
||||
HotReloadOptions {
|
||||
python: true,
|
||||
json_ui: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(tracked.len(), 2);
|
||||
assert_eq!(
|
||||
reload_actions(batch),
|
||||
vec![
|
||||
ReloadAction::Python(vec!["scripts.client".to_string()]),
|
||||
ReloadAction::JsonUi,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_global_trailing_edge_debounce() {
|
||||
let start = Instant::now();
|
||||
let mut paths = DebouncedPaths::default();
|
||||
paths.push(PathBuf::from("ui/screen.json"), start);
|
||||
paths.push(
|
||||
PathBuf::from("ui/screen.json"),
|
||||
start + Duration::from_millis(50),
|
||||
);
|
||||
paths.push(
|
||||
PathBuf::from("ui/_ui_defs.json"),
|
||||
start + Duration::from_millis(100),
|
||||
);
|
||||
|
||||
assert!(
|
||||
paths
|
||||
.take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS - 1))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
paths
|
||||
.take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS))
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
paths
|
||||
.take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS * 2))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_content_and_name_change_actions() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED,
|
||||
FILE_ACTION_RENAMED_NEW_NAME, FILE_ACTION_RENAMED_OLD_NAME,
|
||||
};
|
||||
|
||||
for action in [
|
||||
FILE_ACTION_ADDED,
|
||||
FILE_ACTION_MODIFIED,
|
||||
FILE_ACTION_REMOVED,
|
||||
FILE_ACTION_RENAMED_OLD_NAME,
|
||||
FILE_ACTION_RENAMED_NEW_NAME,
|
||||
] {
|
||||
assert!(super::is_supported_file_action(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,18 @@ use std::{
|
||||
|
||||
use crate::error::{CliError, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DebugIpcServer {
|
||||
inner: Arc<Inner>,
|
||||
owns_lifecycle: bool,
|
||||
}
|
||||
|
||||
impl Clone for DebugIpcServer {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
owns_lifecycle: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
@@ -39,7 +48,10 @@ impl DebugIpcServer {
|
||||
let handle = thread::spawn(move || accept_loop(listener, thread_inner));
|
||||
*inner.thread.lock().unwrap() = Some(handle);
|
||||
|
||||
Ok(Self { inner })
|
||||
Ok(Self {
|
||||
inner,
|
||||
owns_lifecycle: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
@@ -50,12 +62,11 @@ impl DebugIpcServer {
|
||||
let frame = encode_frame(message_type, payload)?;
|
||||
let mut clients = self.inner.clients.lock().unwrap();
|
||||
let mut any = false;
|
||||
clients.retain_mut(|stream| match stream.write(&frame) {
|
||||
Ok(_) => {
|
||||
clients.retain_mut(|stream| match stream.write_all(&frame) {
|
||||
Ok(()) => {
|
||||
any = true;
|
||||
true
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::WouldBlock => true,
|
||||
Err(_) => false,
|
||||
});
|
||||
Ok(any)
|
||||
@@ -81,7 +92,9 @@ impl DebugIpcServer {
|
||||
|
||||
impl Drop for DebugIpcServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
if self.owns_lifecycle {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +112,12 @@ fn accept_loop(listener: TcpListener, inner: Arc<Inner>) {
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let _ = stream.set_nonblocking(true);
|
||||
inner.clients.lock().unwrap().push(stream);
|
||||
if stream
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.is_ok()
|
||||
{
|
||||
inner.clients.lock().unwrap().push(stream);
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
@@ -112,7 +129,7 @@ fn accept_loop(listener: TcpListener, inner: Arc<Inner>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::encode_frame;
|
||||
use super::{DebugIpcServer, encode_frame};
|
||||
|
||||
#[test]
|
||||
fn encodes_big_endian_ipc_frame() {
|
||||
@@ -120,4 +137,15 @@ mod tests {
|
||||
assert_eq!(&frame[..6], &[0, 2, 0, 0, 0, 7]);
|
||||
assert_eq!(&frame[6..], b"[\"a.b\"]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_clone_keeps_server_running() {
|
||||
let server = DebugIpcServer::start().unwrap();
|
||||
let port = server.port();
|
||||
|
||||
drop(server.clone());
|
||||
|
||||
assert_eq!(server.port(), port);
|
||||
server.safe_exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn prepare_world(config: &DebugConfig, linked_packs: &[PackInfo]) -> Result<
|
||||
write_world_pack_manifests(config, linked_packs)
|
||||
}
|
||||
|
||||
pub fn write_dev_config(config: &DebugConfig) -> Result<PathBuf> {
|
||||
pub fn write_dev_config(config: &DebugConfig, linked_packs: &[PackInfo]) -> Result<PathBuf> {
|
||||
let world_dir = world_dir(config);
|
||||
fs::create_dir_all(&world_dir)?;
|
||||
|
||||
@@ -53,9 +53,11 @@ pub fn write_dev_config(config: &DebugConfig) -> Result<PathBuf> {
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
let vip_using_mod: Vec<&str> = linked_packs.iter().map(|pack| pack.uuid.as_str()).collect();
|
||||
let mut dev_config = json!({
|
||||
"world_info": { "level_id": config.world_folder_name },
|
||||
"room_info": {},
|
||||
"vip_using_mod": vip_using_mod,
|
||||
"player_info": {
|
||||
"urs": "",
|
||||
"user_id": 0,
|
||||
|
||||
56
src/debug/mcp.rs
Normal file
56
src/debug/mcp.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::error::{CliError, Result};
|
||||
|
||||
pub const MCP_STATE_DIR: &str = ".emod-cli";
|
||||
pub const MCP_STATE_FILE: &str = "debug-mcp.json";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct BridgeState {
|
||||
pub version: u8,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
pub fn state_file(project_dir: &Path) -> PathBuf {
|
||||
project_dir.join(MCP_STATE_DIR).join(MCP_STATE_FILE)
|
||||
}
|
||||
|
||||
pub fn prepare_state_file(project_dir: &Path) -> Result<PathBuf> {
|
||||
let state_dir = project_dir.join(MCP_STATE_DIR);
|
||||
fs::create_dir_all(&state_dir)?;
|
||||
|
||||
let path = state_dir.join(MCP_STATE_FILE);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(CliError::Io(err)),
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn read_state(project_dir: &Path) -> Result<BridgeState> {
|
||||
let path = state_file(project_dir);
|
||||
let data = fs::read_to_string(path)?;
|
||||
let state: BridgeState = serde_json::from_str(&data)?;
|
||||
if state.version != 1 {
|
||||
return Err(CliError::InvalidData(format!(
|
||||
"unsupported debug MCP bridge state version {}",
|
||||
state.version
|
||||
)));
|
||||
}
|
||||
if state.host.trim().is_empty() {
|
||||
return Err(CliError::InvalidData(
|
||||
"debug MCP bridge state host is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if state.port == 0 {
|
||||
return Err(CliError::InvalidData(
|
||||
"debug MCP bridge state port is zero".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod hotreload;
|
||||
pub mod ipc;
|
||||
pub mod level;
|
||||
pub mod log;
|
||||
pub mod mcp;
|
||||
pub mod nbt;
|
||||
pub mod process;
|
||||
pub mod win;
|
||||
@@ -24,6 +25,11 @@ pub fn run(project_dir: &Path, new_world: bool) -> Result<()> {
|
||||
config::ensure_game_executable(project_dir, &mut config)?;
|
||||
|
||||
let mod_dirs = config.included_mod_dirs(project_dir)?;
|
||||
let mcp_state_path = if config.include_debug_mod && !config::env_is_subprocess_mode() {
|
||||
Some(mcp::prepare_state_file(project_dir)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut linked_packs = Vec::new();
|
||||
|
||||
if !config::env_is_subprocess_mode() {
|
||||
@@ -40,10 +46,15 @@ pub fn run(project_dir: &Path, new_world: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
let config_arg = if config.auto_join_game_effective() && !config::env_is_subprocess_mode() {
|
||||
Some(level::write_dev_config(&config)?)
|
||||
Some(level::write_dev_config(&config, &linked_packs)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
process::launch_game(&config, config_arg.as_deref(), &mod_dirs)
|
||||
process::launch_game(
|
||||
&config,
|
||||
config_arg.as_deref(),
|
||||
&mod_dirs,
|
||||
mcp_state_path.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -457,6 +457,7 @@ mod tests {
|
||||
auto_join_game: true,
|
||||
include_debug_mod: true,
|
||||
auto_hot_reload_mods: true,
|
||||
auto_hot_reload_ui: false,
|
||||
world_type: 1,
|
||||
game_mode: 1,
|
||||
enable_cheats: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::error::{CliError, Result};
|
||||
|
||||
use super::{
|
||||
config::{DebugConfig, ResolvedModDir},
|
||||
hotreload::HotReloadTask,
|
||||
hotreload::{HotReloadOptions, HotReloadTask},
|
||||
ipc::DebugIpcServer,
|
||||
log::{self, LineBuffer},
|
||||
};
|
||||
@@ -40,6 +40,7 @@ pub fn launch_game(
|
||||
config: &DebugConfig,
|
||||
config_arg: Option<&Path>,
|
||||
mod_dirs: &[ResolvedModDir],
|
||||
mcp_state_path: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let enable_ipc = config.auto_hot_reload_mods;
|
||||
let ipc = if enable_ipc {
|
||||
@@ -52,9 +53,14 @@ pub fn launch_game(
|
||||
|
||||
let command = build_command(config, config_arg);
|
||||
let mut command_w = wide_null(OsStr::new(&command));
|
||||
let env_block = ipc
|
||||
.as_ref()
|
||||
.map(|server| build_environment_block(server.port()));
|
||||
let env_block = if ipc.is_some() || mcp_state_path.is_some() || config.auto_hot_reload_ui {
|
||||
Some(build_environment_block(
|
||||
ipc.as_ref().map(|server| server.port()),
|
||||
mcp_state_path,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut security = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
@@ -116,12 +122,15 @@ pub fn launch_game(
|
||||
let stderr_thread =
|
||||
thread::spawn(move || read_pipe(err_read, stderr_filter, log::process_stderr_line));
|
||||
|
||||
let mut hotreload = if config.auto_hot_reload_mods {
|
||||
ipc.clone()
|
||||
.and_then(|server| HotReloadTask::start(pid, mod_dirs, server))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut hotreload = HotReloadTask::start(
|
||||
pid,
|
||||
mod_dirs,
|
||||
ipc.clone(),
|
||||
HotReloadOptions {
|
||||
python: config.auto_hot_reload_mods,
|
||||
json_ui: config.auto_hot_reload_ui,
|
||||
},
|
||||
);
|
||||
|
||||
unsafe { WaitForSingleObject(process_info.hProcess, INFINITE) };
|
||||
|
||||
@@ -147,6 +156,7 @@ pub fn launch_game(
|
||||
_config: &DebugConfig,
|
||||
_config_arg: Option<&Path>,
|
||||
_mod_dirs: &[ResolvedModDir],
|
||||
_mcp_state_path: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
Err(CliError::InvalidInput(
|
||||
"debug launch is only supported on Windows".to_string(),
|
||||
@@ -223,14 +233,29 @@ where
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn build_environment_block(ipc_port: u16) -> Vec<u16> {
|
||||
fn build_environment_block(ipc_port: Option<u16>, mcp_state_path: Option<&Path>) -> Vec<u16> {
|
||||
let mut pairs: Vec<(Vec<u16>, Vec<u16>)> = std::env::vars_os()
|
||||
.filter(|(key, _)| {
|
||||
let key = key.to_string_lossy();
|
||||
!key.eq_ignore_ascii_case("MCDEV_DEBUG_IPC_PORT")
|
||||
&& !key.eq_ignore_ascii_case("MCDEV_MCP_BRIDGE_STATE")
|
||||
})
|
||||
.map(|(key, value)| (key.encode_wide().collect(), value.encode_wide().collect()))
|
||||
.collect();
|
||||
pairs.push((
|
||||
OsStr::new("MCDEV_DEBUG_IPC_PORT").encode_wide().collect(),
|
||||
OsStr::new(&ipc_port.to_string()).encode_wide().collect(),
|
||||
));
|
||||
|
||||
if let Some(port) = ipc_port {
|
||||
pairs.push((
|
||||
OsStr::new("MCDEV_DEBUG_IPC_PORT").encode_wide().collect(),
|
||||
OsStr::new(&port.to_string()).encode_wide().collect(),
|
||||
));
|
||||
}
|
||||
if let Some(path) = mcp_state_path {
|
||||
let state_path = path.to_string_lossy().replace('\\', "/");
|
||||
pairs.push((
|
||||
OsStr::new("MCDEV_MCP_BRIDGE_STATE").encode_wide().collect(),
|
||||
OsStr::new(&state_path).encode_wide().collect(),
|
||||
));
|
||||
}
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut block = Vec::new();
|
||||
|
||||
126
src/debug/win.rs
126
src/debug/win.rs
@@ -10,6 +10,12 @@ use windows_sys::Win32::{
|
||||
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING,
|
||||
},
|
||||
System::IO::DeviceIoControl,
|
||||
UI::{
|
||||
Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW, VK_CONTROL},
|
||||
WindowsAndMessaging::{
|
||||
GetForegroundWindow, GetWindowThreadProcessId, PostMessageW, WM_KEYDOWN, WM_KEYUP,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -148,6 +154,89 @@ pub fn create_junction(target: &Path, link: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn trigger_ui_reload_shortcut(process_id: u32) -> io::Result<()> {
|
||||
let window = unsafe { GetForegroundWindow() };
|
||||
if window.is_null() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"当前没有前台窗口,无法向 Minecraft 发送 Ctrl+R",
|
||||
));
|
||||
}
|
||||
|
||||
let mut foreground_process_id = 0u32;
|
||||
let foreground_thread_id =
|
||||
unsafe { GetWindowThreadProcessId(window, &mut foreground_process_id) };
|
||||
if foreground_thread_id == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"无法读取当前前台窗口的进程 ID",
|
||||
));
|
||||
}
|
||||
if foreground_process_id != process_id {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
format!(
|
||||
"Minecraft 不在前台:期望 PID {process_id},当前前台 PID {foreground_process_id}"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let [ctrl_down, r_down, r_up, ctrl_up] = ui_reload_key_messages();
|
||||
post_key_message(window, ctrl_down)?;
|
||||
if let Err(err) = post_key_message(window, r_down) {
|
||||
let _ = post_key_message(window, ctrl_up);
|
||||
return Err(err);
|
||||
}
|
||||
let r_up_result = post_key_message(window, r_up);
|
||||
let ctrl_up_result = post_key_message(window, ctrl_up);
|
||||
r_up_result?;
|
||||
ctrl_up_result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn ui_reload_key_messages() -> [(u32, u32, bool); 4] {
|
||||
let control = u32::from(VK_CONTROL);
|
||||
[
|
||||
(WM_KEYDOWN, control, false),
|
||||
(WM_KEYDOWN, u32::from(b'R'), false),
|
||||
(WM_KEYUP, u32::from(b'R'), true),
|
||||
(WM_KEYUP, control, true),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn post_key_message(
|
||||
window: *mut std::ffi::c_void,
|
||||
key_message: (u32, u32, bool),
|
||||
) -> io::Result<()> {
|
||||
let (message, key, key_up) = key_message;
|
||||
let posted = unsafe {
|
||||
PostMessageW(
|
||||
window,
|
||||
message,
|
||||
key as usize,
|
||||
key_message_lparam(key, key_up),
|
||||
)
|
||||
};
|
||||
if posted == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn key_message_lparam(key: u32, key_up: bool) -> isize {
|
||||
let scan_code = unsafe { MapVirtualKeyW(key, MAPVK_VK_TO_VSC) };
|
||||
let mut value = 1isize | ((scan_code as isize) << 16);
|
||||
if key_up {
|
||||
value |= (1isize << 30) | (1isize << 31);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(
|
||||
@@ -155,3 +244,40 @@ pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
"debug launch is only supported on Windows",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn trigger_ui_reload_shortcut(_process_id: u32) -> io::Result<()> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"debug launch is only supported on Windows",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use windows_sys::Win32::UI::{
|
||||
Input::KeyboardAndMouse::VK_CONTROL,
|
||||
WindowsAndMessaging::{WM_KEYDOWN, WM_KEYUP},
|
||||
};
|
||||
|
||||
use super::{key_message_lparam, ui_reload_key_messages};
|
||||
|
||||
#[test]
|
||||
fn ui_reload_shortcut_uses_native_ctrl_r_order() {
|
||||
let control = u32::from(VK_CONTROL);
|
||||
assert_eq!(
|
||||
ui_reload_key_messages(),
|
||||
[
|
||||
(WM_KEYDOWN, control, false),
|
||||
(WM_KEYDOWN, u32::from(b'R'), false),
|
||||
(WM_KEYUP, u32::from(b'R'), true),
|
||||
(WM_KEYUP, control, true),
|
||||
]
|
||||
);
|
||||
|
||||
let r_down = key_message_lparam(u32::from(b'R'), false) as usize;
|
||||
let r_up = key_message_lparam(u32::from(b'R'), true) as usize;
|
||||
assert_eq!((r_down >> 30) & 0b11, 0);
|
||||
assert_eq!((r_up >> 30) & 0b11, 0b11);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
mod boss_engine;
|
||||
mod commands;
|
||||
mod debug;
|
||||
mod entity;
|
||||
mod error;
|
||||
mod mcp;
|
||||
mod template;
|
||||
mod utils;
|
||||
|
||||
@@ -16,6 +18,8 @@ fn main() {
|
||||
Commands::Init(args) => commands::init::execute(args),
|
||||
Commands::Components(args) => commands::components::execute(args),
|
||||
Commands::Debug(args) => commands::debug::execute(args),
|
||||
Commands::Mcp(args) => commands::mcp::execute(args),
|
||||
Commands::Bbmodel(args) => commands::bbmodel::execute(args),
|
||||
Commands::ForkBossEngine(args) => commands::fork_boss_engine::execute(args),
|
||||
}
|
||||
}
|
||||
|
||||
269
src/mcp.rs
Normal file
269
src/mcp.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader, ErrorKind, Write},
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use rmcp::{
|
||||
ErrorData as McpError, ServerHandler, ServiceExt,
|
||||
handler::server::{tool::ToolRouter, wrapper::Parameters},
|
||||
model::{CallToolResult, Content, ServerCapabilities, ServerInfo},
|
||||
schemars::{self, JsonSchema},
|
||||
tool, tool_handler, tool_router,
|
||||
transport::stdio,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{debug::mcp as debug_mcp, error::CliError};
|
||||
|
||||
const BRIDGE_NOT_READY: &str = "debug MCP bridge is not ready; start emod-cli debug --path <project> and wait for [MCPBridge] Listening";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DebugMcpServer {
|
||||
project_dir: PathBuf,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct PlaceBlockParams {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub z: i32,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub dimension: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub aux: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub states: Option<Map<String, Value>>,
|
||||
#[serde(default)]
|
||||
pub old_block_handling: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub is_legacy: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub update_neighbors: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum BridgeCallError {
|
||||
NotReady,
|
||||
Message(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BridgeRequest {
|
||||
id: u64,
|
||||
method: &'static str,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BridgeResponse {
|
||||
ok: bool,
|
||||
result: Option<Value>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl DebugMcpServer {
|
||||
fn new(project_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
project_dir,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Place a block in the currently running emod-cli debug world through the injected debug MCP bridge"
|
||||
)]
|
||||
async fn place_block(
|
||||
&self,
|
||||
Parameters(params): Parameters<PlaceBlockParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
validate_place_block_params(¶ms)?;
|
||||
|
||||
match call_bridge(&self.project_dir, ¶ms) {
|
||||
Ok(result) => {
|
||||
let text = serde_json::to_string_pretty(&result).map_err(|err| {
|
||||
McpError::internal_error(format!("failed to encode bridge result: {err}"), None)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
Err(BridgeCallError::NotReady) => Ok(tool_error(BRIDGE_NOT_READY)),
|
||||
Err(BridgeCallError::Message(message)) => Ok(tool_error(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for DebugMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
instructions: Some(
|
||||
"Place blocks in a running emod-cli debug world through the injected debug MCP bridge."
|
||||
.into(),
|
||||
),
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_blocking(project_dir: PathBuf) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()?;
|
||||
|
||||
runtime.block_on(async move {
|
||||
let service = DebugMcpServer::new(project_dir).serve(stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_place_block_params(params: &PlaceBlockParams) -> Result<(), McpError> {
|
||||
if params.name.trim().is_empty() || !params.name.contains(':') {
|
||||
return Err(McpError::invalid_params(
|
||||
"place_block.name must be a non-empty namespaced block id",
|
||||
None,
|
||||
));
|
||||
}
|
||||
if params.aux.is_some() && params.states.is_some() {
|
||||
return Err(McpError::invalid_params(
|
||||
"place_block.aux and place_block.states cannot both be provided",
|
||||
None,
|
||||
));
|
||||
}
|
||||
if let Some(old_block_handling) = params.old_block_handling {
|
||||
if !(0..=2).contains(&old_block_handling) {
|
||||
return Err(McpError::invalid_params(
|
||||
"place_block.old_block_handling must be 0, 1, or 2",
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tool_error(message: impl Into<String>) -> CallToolResult {
|
||||
CallToolResult::error(vec![Content::text(message.into())])
|
||||
}
|
||||
|
||||
fn call_bridge(project_dir: &Path, params: &PlaceBlockParams) -> Result<Value, BridgeCallError> {
|
||||
let state = read_bridge_state(project_dir)?;
|
||||
if state.host != "127.0.0.1" && state.host != "localhost" {
|
||||
return Err(BridgeCallError::Message(
|
||||
CliError::InvalidData(format!(
|
||||
"debug MCP bridge host is not local: {}",
|
||||
state.host
|
||||
))
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut addrs = (state.host.as_str(), state.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to resolve debug MCP bridge: {err}"))
|
||||
})?;
|
||||
let addr = addrs.next().ok_or_else(|| {
|
||||
BridgeCallError::Message("debug MCP bridge address did not resolve".to_string())
|
||||
})?;
|
||||
|
||||
let mut stream = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to connect debug MCP bridge: {err}"))
|
||||
})?;
|
||||
stream.set_read_timeout(Some(IO_TIMEOUT)).map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to set bridge read timeout: {err}"))
|
||||
})?;
|
||||
stream.set_write_timeout(Some(IO_TIMEOUT)).map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to set bridge write timeout: {err}"))
|
||||
})?;
|
||||
|
||||
let request = BridgeRequest {
|
||||
id: 1,
|
||||
method: "place_block",
|
||||
params: bridge_params(params),
|
||||
};
|
||||
serde_json::to_writer(&mut stream, &request).map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to encode bridge request: {err}"))
|
||||
})?;
|
||||
stream.write_all(b"\n").map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to write bridge request: {err}"))
|
||||
})?;
|
||||
stream.flush().map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to flush bridge request: {err}"))
|
||||
})?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
let bytes = reader.read_line(&mut line).map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to read bridge response: {err}"))
|
||||
})?;
|
||||
if bytes == 0 {
|
||||
return Err(BridgeCallError::Message(
|
||||
"debug MCP bridge closed connection without response".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let response: BridgeResponse = serde_json::from_str(line.trim()).map_err(|err| {
|
||||
BridgeCallError::Message(format!("failed to decode bridge response: {err}"))
|
||||
})?;
|
||||
if !response.ok {
|
||||
return Err(BridgeCallError::Message(response.error.unwrap_or_else(
|
||||
|| "debug MCP bridge returned an error".to_string(),
|
||||
)));
|
||||
}
|
||||
response.result.ok_or_else(|| {
|
||||
BridgeCallError::Message("debug MCP bridge response is missing result".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn read_bridge_state(project_dir: &Path) -> Result<debug_mcp::BridgeState, BridgeCallError> {
|
||||
match debug_mcp::read_state(project_dir) {
|
||||
Ok(state) => Ok(state),
|
||||
Err(CliError::Io(err)) if err.kind() == ErrorKind::NotFound => {
|
||||
Err(BridgeCallError::NotReady)
|
||||
}
|
||||
Err(err) => Err(BridgeCallError::Message(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_params(params: &PlaceBlockParams) -> Value {
|
||||
let mut map = Map::new();
|
||||
map.insert("x".to_string(), json!(params.x));
|
||||
map.insert("y".to_string(), json!(params.y));
|
||||
map.insert("z".to_string(), json!(params.z));
|
||||
map.insert(
|
||||
"dimension".to_string(),
|
||||
json!(params.dimension.unwrap_or(0)),
|
||||
);
|
||||
map.insert("name".to_string(), json!(params.name));
|
||||
match ¶ms.states {
|
||||
Some(states) => {
|
||||
map.insert("aux".to_string(), Value::Null);
|
||||
map.insert("states".to_string(), Value::Object(states.clone()));
|
||||
}
|
||||
None => {
|
||||
map.insert("aux".to_string(), json!(params.aux.unwrap_or(0)));
|
||||
map.insert("states".to_string(), Value::Null);
|
||||
}
|
||||
}
|
||||
map.insert(
|
||||
"old_block_handling".to_string(),
|
||||
json!(params.old_block_handling.unwrap_or(0)),
|
||||
);
|
||||
map.insert(
|
||||
"is_legacy".to_string(),
|
||||
json!(params.is_legacy.unwrap_or(true)),
|
||||
);
|
||||
map.insert(
|
||||
"update_neighbors".to_string(),
|
||||
json!(params.update_neighbors.unwrap_or(true)),
|
||||
);
|
||||
Value::Object(map)
|
||||
}
|
||||
Reference in New Issue
Block a user