Compare commits

..
47 Commits
Author SHA1 Message Date
Valentin Popov b1134c5526 docs(runtime): clarify Control load callback order
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 22:52:57 +04:00
Valentin Popov aa6d4b8537 feat(runtime): project load-time Control profile values
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s
2026-10-29 22:51:25 +04:00
Valentin Popov cc5c6414ce feat(runtime): project native Control object classes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 50s
2026-10-29 21:20:45 +04:00
Valentin Popov 6c079d9457 feat(runtime): retain Control owner profiles during loading
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-29 20:55:48 +04:00
Valentin Popov ba7cb7f0f9 feat(runtime): support tag-26 Control resource budgets
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s
2026-10-29 20:26:15 +04:00
Valentin Popov 1d20509b05 feat(runtime): support class-9 and default Control budgets
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-29 19:43:09 +04:00
Valentin Popov d3a0f8c7ff feat(runtime): run tag-15 and tag-19 Control budgets
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 19:05:13 +04:00
Valentin Popov d8e86e4f33 feat(runtime): distribute Control provider amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
2026-10-29 18:24:43 +04:00
Valentin Popov be4031cb54 feat(runtime): propagate Control node stage changes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 17:55:55 +04:00
Valentin Popov 0c31c34b57 feat(runtime): update Control node stages
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-29 17:30:41 +04:00
Valentin Popov 6eb1dcaf70 feat(runtime): publish provider values into model node state
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 17:08:08 +04:00
Valentin Popov acdf635ffb feat(runtime): update live Control provider amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
2026-10-29 16:55:09 +04:00
Valentin Popov f35c9ec1f0 test(runtime): verify randomized Control resource cadence
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 16:35:50 +04:00
Valentin Popov 38ff0ca682 fix(runtime): load robot Control device tables
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 16:26:54 +04:00
Valentin Popov 888a331a6d test(runtime): verify native generator budget consumption
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 16:05:38 +04:00
Valentin Popov e29e3c6bb9 feat(runtime): run resource schedules during world steps
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
2026-10-29 15:58:33 +04:00
Valentin Popov 5600ebb388 test(runtime): verify native Control activation clocks
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 15:49:41 +04:00
Valentin Popov e20b902986 test(runtime): verify active Control resource clock
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-29 15:41:08 +04:00
Valentin Popov 35b9d86637 test(runtime): verify native Control budget updates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 15:36:40 +04:00
Valentin Popov e6408fe912 docs(runtime): trace original Control clock delivery
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-29 15:26:02 +04:00
Valentin Popov 3cec599688 test(runtime): verify native Control node stage transitions
Docs Deploy / Build and Deploy MkDocs (push) Successful in 32s
2026-10-23 14:24:00 +04:00
Valentin Popov d9fe0fcc66 feat(runtime): retain initial Control node amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
2026-10-23 13:45:30 +04:00
Valentin Popov 4e16902661 feat(runtime): project initial Control mesh metadata
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-23 13:20:59 +04:00
Valentin Popov ce8e65c8ae test(runtime): verify native Control provider updates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
2026-10-23 12:32:49 +04:00
Valentin Popov 7c909ea67b feat(runtime): advance loaded mission resources
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-23 12:02:27 +04:00
Valentin Popov 83db371742 feat(runtime): load Behavior profiles for resource queries
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m14s
2026-10-22 15:30:41 +04:00
Valentin Popov 29b81b8723 feat(runtime): load Control node providers for resource queries
Docs Deploy / Build and Deploy MkDocs (push) Failing after 3h0m1s
2026-10-22 14:50:54 +04:00
Valentin Popov b9a4d1b478 feat(runtime): query and update loaded Control resources
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m17s
2026-10-22 14:19:25 +04:00
Valentin Popov 40e4392138 feat(runtime): project merged Control device tables
Docs Deploy / Build and Deploy MkDocs (push) Successful in 56s
2026-10-22 13:55:31 +04:00
Valentin Popov 5c400e7e40 docs(control): explain component device overrides
Docs Deploy / Build and Deploy MkDocs (push) Successful in 2m4s
2026-10-22 12:49:24 +04:00
Valentin Popov ccb5af0e37 feat(assets): sample native resource device coefficient
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m12s
2026-10-22 12:20:42 +04:00
Valentin Popov dcc6459891 feat(runtime): initialize mission ore properties
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m9s
2026-10-22 11:45:23 +04:00
Valentin Popov 374db10daf fix(runtime): model distributor share normalization
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-20 09:06:17 +04:00
Valentin Popov caef8f8e6c feat(runtime): register mission objects with clan distributors
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-20 08:44:10 +04:00
Valentin Popov ecc46ed177 fix(runtime): preserve native mission AI selection word
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-20 08:11:45 +04:00
Valentin Popov e4645b1d62 test(runtime): verify native resource distributor tick
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
2026-10-20 06:35:38 +04:00
Valentin Popov 89a0d1da61 fix(runtime): warm behavior before mission Init
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
2026-10-20 05:15:11 +04:00
Valentin Popov 31fd9d2ea9 fix(runtime): execute the first supported Init event
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-20 04:11:35 +04:00
Valentin Popov dc8be18458 fix(runtime): preserve native mission outcome guards
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
2026-10-20 03:20:20 +04:00
Valentin Popov 65f1298d93 docs(script): clarify mission outcome compatibility limits
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-20 02:00:27 +04:00
Valentin Popov 37db376dd3 docs(script): describe native condition evaluation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-20 01:45:58 +04:00
Valentin Popov bcedda6e93 chore(deps): merge upstream dependency updates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
2026-10-20 01:34:22 +04:00
Valentin Popov a970974eb0 fix(script): preserve scheduler counter increment 2026-10-20 01:31:26 +04:00
Valentin Popov 5a819c9c70 Merge pull request 'chore(deps): update rust crate serde_json to v1.0.154' (!34) from renovate/serde_json-1.x-lockfile into trunk
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Reviewed-on: #34
2026-10-20 01:05:06 +04:00
Valentin Popov e26e36c4ec Merge pull request 'fix(deps): update rust crate rodio to 0.23.0' (!35) from renovate/rodio-0.x into trunk
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Reviewed-on: #35
2026-10-20 01:05:00 +04:00
renovate[bot] 632472650f fix(deps): update rust crate rodio to 0.23.0 2026-10-19 20:58:23 +00:00
renovate[bot] 7a9d8d5245 chore(deps): update rust crate serde_json to v1.0.154 2026-10-19 20:58:19 +00:00
22 changed files with 21410 additions and 410 deletions

No files matched your search

Generated
+24 -112
View File
@@ -39,7 +39,7 @@ dependencies = [
"android-properties", "android-properties",
"bitflags 2.13.2", "bitflags 2.13.2",
"cc", "cc",
"jni 0.22.4", "jni",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -173,12 +173,6 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.5" version = "1.0.5"
@@ -302,14 +296,15 @@ dependencies = [
[[package]] [[package]]
name = "cpal" name = "cpal"
version = "0.17.3" version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [ dependencies = [
"alsa", "alsa",
"block2 0.6.2",
"coreaudio-rs", "coreaudio-rs",
"dasp_sample", "dasp_sample",
"jni 0.21.1", "jni",
"js-sys", "js-sys",
"libc", "libc",
"mach2", "mach2",
@@ -324,10 +319,9 @@ dependencies = [
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys", "web-sys",
"windows", "windows",
"windows-core",
] ]
[[package]] [[package]]
@@ -475,6 +469,7 @@ version = "0.1.0"
name = "fparkan-assets" name = "fparkan-assets"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-binary",
"fparkan-material", "fparkan-material",
"fparkan-mission-format", "fparkan-mission-format",
"fparkan-msh", "fparkan-msh",
@@ -635,6 +630,7 @@ name = "fparkan-runtime"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-assets", "fparkan-assets",
"fparkan-msh",
"fparkan-path", "fparkan-path",
"fparkan-platform", "fparkan-platform",
"fparkan-prototype", "fparkan-prototype",
@@ -754,22 +750,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -903,12 +883,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "mach2" name = "mach2"
version = "0.5.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
@@ -1114,6 +1091,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [ dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -1477,9 +1455,9 @@ dependencies = [
[[package]] [[package]]
name = "rodio" name = "rodio"
version = "0.22.2" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" checksum = "36e3ee0a5ca418bac59a15d5506e498ab138359edbb67257a6b8a3b93d411f39"
dependencies = [ dependencies = [
"cpal", "cpal",
"dasp_sample", "dasp_sample",
@@ -1582,9 +1560,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.152" version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -2067,22 +2045,13 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets",
] ]
[[package]] [[package]]
@@ -2091,7 +2060,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets",
] ]
[[package]] [[package]]
@@ -2103,35 +2072,20 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc",
"windows_i686_gnu 0.52.6", "windows_i686_gnu",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc 0.52.6", "windows_i686_msvc",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc",
] ]
[[package]] [[package]]
@@ -2143,36 +2097,18 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -2185,48 +2121,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
+1 -1
View File
@@ -20,7 +20,7 @@ fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-terrain = { path = "../../crates/fparkan-terrain" } fparkan-terrain = { path = "../../crates/fparkan-terrain" }
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" } fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } fparkan-vfs = { path = "../../crates/fparkan-vfs" }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] } rodio = { version = "0.23.0", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] } winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
+4 -1
View File
@@ -53,7 +53,7 @@ use fparkan_render_vulkan::{
}; };
use fparkan_runtime::{ use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionMode,
MissionObjectDraft, MissionRequest, MissionObjectDraft, MissionRequest,
}; };
use fparkan_terrain::{ use fparkan_terrain::{
@@ -173,6 +173,7 @@ fn load_requested_mission(
) -> Result<fparkan_runtime::LoadedMission, String> { ) -> Result<fparkan_runtime::LoadedMission, String> {
let request = MissionRequest { let request = MissionRequest {
key: args.mission.clone(), key: args.mission.clone(),
mode: MissionMode::SingleMission,
}; };
if args.preview_roots.get() == 1 { if args.preview_roots.get() == 1 {
load_mission_static_preview(engine, request) load_mission_static_preview(engine, request)
@@ -7409,6 +7410,7 @@ mod tests {
type_id: None, type_id: None,
}, },
model_node_count: 1, model_node_count: 1,
native_profile_words: [0; 27],
bindings: vec![], bindings: vec![],
timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow { timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow {
row_index: 0, row_index: 0,
@@ -8090,6 +8092,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: mission.clone(), key: mission.clone(),
mode: MissionMode::SingleMission,
}, },
NonZeroUsize::MAX, NonZeroUsize::MAX,
) )
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-material = { path = "../fparkan-material" } fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" } fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres" }
+684 -1
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Asset manager ports and transactional preparation models. //! Asset manager ports and transactional preparation models.
use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError};
use fparkan_material::{ use fparkan_material::{
decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable, decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND, MAT0_KIND, WEAR_KIND,
@@ -62,6 +63,150 @@ pub struct PreparedMaterialPhase {
pub page_uv_transform: [f32; 4], pub page_uv_transform: [f32; 4],
} }
/// The two Behavior profile values currently used by recovered resource queries.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BehaviorProfileRates {
/// Second raw value blob for `Transfer_Ore_OffBoard`.
pub transfer_ore_off_board_bits: u32,
/// Second raw value blob for `Transfer_Power_Out`.
pub transfer_power_out_bits: u32,
}
/// Error while reading a bounded binary Behavior `.var` profile.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BehaviorProfileVarError {
/// The binary record framing is malformed.
Decode(DecodeError),
/// A bound for one of the two fields used here is not a 32-bit float.
InvalidFloatField {
name: &'static str,
size: u32,
type_index: u32,
},
/// A registered runtime field was absent from the profile.
MissingField { name: &'static str },
}
impl fmt::Display for BehaviorProfileVarError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decode(source) => write!(f, "{source}"),
Self::InvalidFloatField {
name,
size,
type_index,
} => write!(
f,
"field {name} has size {size} and type index {type_index}, expected a 4-byte float"
),
Self::MissingField { name } => write!(f, "required field {name} is missing"),
}
}
}
impl std::error::Error for BehaviorProfileVarError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
Self::InvalidFloatField { .. } | Self::MissingField { .. } => None,
}
}
}
impl From<DecodeError> for BehaviorProfileVarError {
fn from(value: DecodeError) -> Self {
Self::Decode(value)
}
}
/// Reads the two Behavior rate fields from a native binary `.var` profile.
///
/// Native records contain two equally sized value blobs. After binding a
/// registered destination, the original applies the second blob. Field names
/// are matched case-sensitively as C strings (up to the first NUL), and the
/// first occurrence wins. Other fields are consumed but their meanings remain
/// outside this decoder.
///
/// # Errors
///
/// Returns [`BehaviorProfileVarError`] when record framing is truncated,
/// contains trailing bytes, omits a registered field, or declares a selected
/// field that is not a float row.
pub fn decode_behavior_profile_rates(
bytes: &[u8],
) -> Result<BehaviorProfileRates, BehaviorProfileVarError> {
let mut cursor = Cursor::new(bytes);
let count = cursor.read_u32_le()?;
checked_count_bytes(u64::from(count), 28, cursor.remaining() as u64)?;
let mut transfer_ore_off_board_bits = None;
let mut transfer_power_out_bits = None;
for _ in 0..count {
let size = cursor.read_u32_le()?;
let type_index = cursor.read_u32_le()?;
let _flags = cursor.read_u32_le()?;
let field_name = read_lp_bytes(&mut cursor, 64 * 1024)?;
let c_string_name = field_name
.split(|byte| *byte == 0)
.next()
.unwrap_or_default();
let target_name = match c_string_name {
b"Transfer_Ore_OffBoard" => Some("Transfer_Ore_OffBoard"),
b"Transfer_Power_Out" => Some("Transfer_Power_Out"),
_ => None,
};
let _type_name = read_lp_bytes(&mut cursor, 64 * 1024)?;
let value_size = usize::try_from(size).map_err(|_| DecodeError::IntegerOverflow)?;
cursor.read_exact(value_size)?;
let capture_target = target_name.is_some_and(|name| {
if name == "Transfer_Ore_OffBoard" {
transfer_ore_off_board_bits.is_none()
} else {
transfer_power_out_bits.is_none()
}
});
let second_value_bits = if capture_target {
let name = target_name.expect("capture requires a target field");
if size != 4 || type_index != 3 {
return Err(BehaviorProfileVarError::InvalidFloatField {
name,
size,
type_index,
});
}
let value = cursor.read_exact(value_size)?;
Some(u32::from_le_bytes(
value.try_into().expect("validated four-byte float row"),
))
} else {
cursor.read_exact(value_size)?;
None
};
cursor.read_exact(8)?;
if let (Some(name), Some(value_bits)) = (target_name, second_value_bits) {
if name == "Transfer_Ore_OffBoard" {
transfer_ore_off_board_bits = Some(value_bits);
} else {
transfer_power_out_bits = Some(value_bits);
}
}
}
cursor.require_eof()?;
Ok(BehaviorProfileRates {
transfer_ore_off_board_bits: transfer_ore_off_board_bits.ok_or(
BehaviorProfileVarError::MissingField {
name: "Transfer_Ore_OffBoard",
},
)?,
transfer_power_out_bits: transfer_power_out_bits.ok_or(
BehaviorProfileVarError::MissingField {
name: "Transfer_Power_Out",
},
)?,
})
}
type WearValidationCache = type WearValidationCache =
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>; HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
@@ -936,6 +1081,8 @@ pub struct PreparedVisual {
pub struct PreparedComponentControl { pub struct PreparedComponentControl {
/// CTLD resource that supplied these bindings. /// CTLD resource that supplied these bindings.
pub source: ResourceKey, pub source: ResourceKey,
/// The 27 raw native owner-profile words at CTLD bytes 20..128.
pub native_profile_words: [u32; 27],
/// Number of nodes in the component model this control targets. /// Number of nodes in the component model this control targets.
pub model_node_count: usize, pub model_node_count: usize,
/// Enabled source rows in their original order, including duplicate nodes. /// Enabled source rows in their original order, including duplicate nodes.
@@ -1180,6 +1327,19 @@ impl Default for ControlTag21InstanceState {
/// Input or metadata prevented a safe native control sample. /// Input or metadata prevented a safe native control sample.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ControlSamplingError { pub enum ControlSamplingError {
/// This resource-device helper only implements typed-record tag 26.
UnsupportedResourceDeviceTag { actual: u32 },
/// The record's raw provider key has no resolved NDP table.
MissingResourceDeviceNodeData { key: u32 },
/// The record's raw provider key is not a row in its NDP table.
ResourceDeviceNodeDataKeyOutOfRange { key: u32, row_count: usize },
/// The caller did not supply the provider's current type-1 value.
MissingResourceDeviceProviderValue { key: u32 },
/// A coefficient is outside the helper's finite normal-or-zero contract.
InvalidResourceDeviceOperand { operand: &'static str },
/// The native x87 intermediate is outside the range represented by normal
/// f32 values, so the Rust result could differ from the original.
UnsupportedResourceDeviceFloatRange { stage: &'static str },
/// A state object was reused with a different model node count. /// A state object was reused with a different model node count.
StateNodeCountMismatch { expected: usize, actual: usize }, StateNodeCountMismatch { expected: usize, actual: usize },
/// A state object was reused with a different number of tag-21 records. /// A state object was reused with a different number of tag-21 records.
@@ -1216,6 +1376,74 @@ pub enum ControlSamplingError {
NonFiniteSample { record_index: usize }, NonFiniteSample { record_index: usize },
} }
/// Samples Control's tag-26 typed-record query `0x300` for one current state.
///
/// The key in fixed word 1 must select an existing NDP row. The caller supplies
/// that provider's current type-1 value and the object's current multiplier;
/// this function does not invent their runtime state. Fixed word 11 is the
/// coefficient loaded by selector `0x300`. Native code multiplies in the order
/// `provider_now * coefficient * multiplier_now`.
///
/// The source x87 code returns a value with an extended exponent range even
/// though it uses 24-bit precision. This f32 helper therefore accepts only
/// finite normal-or-zero operands and normal-or-exact-zero intermediate and
/// final products. It rejects subnormal or out-of-f32-range products rather
/// than silently underflowing or overflowing differently from x87.
pub fn sample_control_resource_device_selector_300(
record: &PreparedControlTypedRecord,
node_data: Option<&PreparedControlNodeDataTable>,
provider_now: Option<f32>,
multiplier_now: f32,
) -> Result<f32, ControlSamplingError> {
if record.tag != 26 {
return Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: record.tag });
}
let key = record.fixed_words[1];
let Some(node_data) = node_data else {
return Err(ControlSamplingError::MissingResourceDeviceNodeData { key });
};
if !usize::try_from(key).is_ok_and(|key| key < node_data.rows.len()) {
return Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange {
key,
row_count: node_data.rows.len(),
});
}
let provider_now =
provider_now.ok_or(ControlSamplingError::MissingResourceDeviceProviderValue { key })?;
let base = f32::from_bits(record.fixed_words[11]);
for (operand, value) in [
("provider", provider_now),
("base coefficient", base),
("multiplier", multiplier_now),
] {
if !value.is_finite() || (value != 0.0 && !value.is_normal()) {
return Err(ControlSamplingError::InvalidResourceDeviceOperand { operand });
}
}
let provider_times_base =
native_range_compatible_product(provider_now, base, "provider times base")?;
native_range_compatible_product(provider_times_base, multiplier_now, "then multiplier")
}
fn native_range_compatible_product(
lhs: f32,
rhs: f32,
stage: &'static str,
) -> Result<f32, ControlSamplingError> {
let exact = f64::from(lhs) * f64::from(rhs);
let absolute = exact.abs();
let minimum_normal = f64::from(f32::MIN_POSITIVE);
let maximum = f64::from(f32::MAX);
if exact != 0.0 && (absolute < minimum_normal || absolute > maximum) {
return Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { stage });
}
Ok(lhs * rhs)
}
/// Samples per-node animation times using the decoded Control timeline. /// Samples per-node animation times using the decoded Control timeline.
/// ///
/// `native_clock_ms` is the absolute u32 millisecond clock passed to native /// `native_clock_ms` is the absolute u32 millisecond clock passed to native
@@ -3637,7 +3865,8 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> {
)) ))
} }
const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 2, 5, 8, 9, 12, 13, 15, 19, 21, 25, 26]; const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] =
&[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 19, 21, 25, 26, 27];
const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0;
const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC;
const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096;
@@ -3964,6 +4193,19 @@ fn decode_component_control_payload(
return Err(invalid("complete binding table extends past the payload")); return Err(invalid("complete binding table extends past the payload"));
} }
let profile_bytes = payload
.get(HEADER_BYTES..HEADER_BYTES + FIXED_SECTION_BYTES)
.ok_or_else(|| invalid("fixed owner profile extends past the payload"))?;
let mut native_profile_words = [0_u32; FIXED_SECTION_BYTES / 4];
for (index, word) in native_profile_words.iter_mut().enumerate() {
let offset = index * 4;
*word = u32::from_le_bytes(
profile_bytes[offset..offset + 4]
.try_into()
.expect("profile word is exactly four bytes"),
);
}
let mut bindings = Vec::with_capacity(row_count); let mut bindings = Vec::with_capacity(row_count);
let mut timeline_rows = Vec::with_capacity(row_count); let mut timeline_rows = Vec::with_capacity(row_count);
for row_index in 0..row_count { for row_index in 0..row_count {
@@ -4026,6 +4268,7 @@ fn decode_component_control_payload(
Ok(PreparedComponentControl { Ok(PreparedComponentControl {
source, source,
native_profile_words,
model_node_count, model_node_count,
bindings, bindings,
timeline_rows, timeline_rows,
@@ -4141,6 +4384,297 @@ mod tests {
// Preserve the authored CTLD fixture bits; this is not the `f32` value of tau. // Preserve the authored CTLD fixture bits; this is not the `f32` value of tau.
const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3); const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3);
fn behavior_var_row(name: &[u8], type_index: u32, first: u32, second: u32) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&4_u32.to_le_bytes());
bytes.extend_from_slice(&type_index.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&(name.len() as u32).to_le_bytes());
bytes.extend_from_slice(name);
bytes.extend_from_slice(&0_u32.to_le_bytes()); // Native transfer rows use an empty type-name.
bytes.extend_from_slice(&first.to_le_bytes());
bytes.extend_from_slice(&second.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes
}
fn behavior_var(rows: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(rows.len() as u32).to_le_bytes());
for row in rows {
bytes.extend_from_slice(row);
}
bytes
}
#[test]
fn behavior_profile_decoder_uses_first_exact_rows_second_blob_and_float_kind() {
let profile = behavior_var(&[
behavior_var_row(b"transfer_ore_off_board", 3, 0x1111_1111, 0x2222_2222),
behavior_var_row(
b"Transfer_Ore_OffBoard\0legacy-suffix",
3,
0x3333_3333,
0x3f80_0000,
),
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0x4444_4444, 0x4000_0000),
behavior_var_row(b"Transfer_Power_Out", 3, 0x5555_5555, 0x4120_0000),
]);
assert_eq!(
decode_behavior_profile_rates(&profile),
Ok(BehaviorProfileRates {
transfer_ore_off_board_bits: 0x3f80_0000,
transfer_power_out_bits: 0x4120_0000,
})
);
}
#[test]
fn behavior_profile_decoder_rejects_every_truncation_and_trailing_bytes() {
let valid = behavior_var(&[
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000),
behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000),
]);
for end in 0..valid.len() {
assert!(
decode_behavior_profile_rates(&valid[..end]).is_err(),
"prefix ending at byte {end} was accepted"
);
}
let mut with_trailing = valid.clone();
with_trailing.push(0);
assert!(matches!(
decode_behavior_profile_rates(&with_trailing),
Err(BehaviorProfileVarError::Decode(
fparkan_binary::DecodeError::TrailingBytes { .. }
))
));
}
#[test]
fn behavior_profile_decoder_requires_both_registered_float_fields() {
let profile = behavior_var(&[behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000)]);
assert_eq!(
decode_behavior_profile_rates(&profile),
Err(BehaviorProfileVarError::MissingField {
name: "Transfer_Ore_OffBoard"
})
);
let profile = behavior_var(&[
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000),
behavior_var_row(b"Transfer_Power_Out", 1, 0, 0x4120_0000),
]);
assert!(matches!(
decode_behavior_profile_rates(&profile),
Err(BehaviorProfileVarError::InvalidFloatField {
name: "Transfer_Power_Out",
size: 4,
type_index: 1,
})
));
}
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44];
fixed_words[1] = key;
fixed_words[11] = base_bits;
PreparedControlTypedRecord {
tag,
fixed_words,
values: Vec::new(),
string_bytes: Vec::new(),
gate_provider: PreparedControlGateProvider::NotApplicable,
}
}
fn resource_device_node_data_table(row_count: usize) -> PreparedControlNodeDataTable {
PreparedControlNodeDataTable {
source: ResourceKey {
archive: parse_path("parts.rlb").expect("NDP archive"),
name: resource_name(b"resource-device.ndp"),
type_id: None,
},
rows: vec![
PreparedControlNodeDataRow {
raw_flags: 0,
max_volume: 0.0,
mobility_per_volume: 0.0,
effect: PreparedControlResourcePair {
archive: [0; 32],
name: [0; 32],
},
};
row_count
],
}
}
#[test]
fn tag26_selector_300_multiplies_provider_then_fixed_base_then_multiplier() {
let record = resource_device_record(26, 1, 0x3D25_AE4A);
let node_data = resource_device_node_data_table(2);
let provider_now = f32::from_bits(0x3FE6_982F);
let multiplier_now = f32::from_bits(0x4071_5539);
let sampled = sample_control_resource_device_selector_300(
&record,
Some(&node_data),
Some(provider_now),
multiplier_now,
)
.expect("valid tag-26 resource-device sample");
assert_eq!(sampled.to_bits(), 0x3E8C_B02E);
assert_ne!(
sampled.to_bits(),
((provider_now * multiplier_now) * f32::from_bits(record.fixed_words[11])).to_bits(),
"the original selector multiplies the provider by the record base first"
);
}
#[test]
fn tag26_selector_300_requires_its_ndp_row_and_explicit_provider() {
let record = resource_device_record(26, 1, 2.0_f32.to_bits());
let one_row = resource_device_node_data_table(1);
assert_eq!(
sample_control_resource_device_selector_300(&record, Some(&one_row), Some(3.0), 4.0,),
Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange {
key: 1,
row_count: 1,
})
);
let two_rows = resource_device_node_data_table(2);
assert_eq!(
sample_control_resource_device_selector_300(&record, Some(&two_rows), None, 4.0),
Err(ControlSamplingError::MissingResourceDeviceProviderValue { key: 1 })
);
assert_eq!(
sample_control_resource_device_selector_300(&record, None, Some(3.0), 4.0),
Err(ControlSamplingError::MissingResourceDeviceNodeData { key: 1 })
);
}
#[test]
fn tag26_selector_300_rejects_other_tags_and_extended_x87_ranges() {
let node_data = resource_device_node_data_table(1);
let tag21 = resource_device_record(21, 0, 1.0_f32.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(&tag21, Some(&node_data), Some(1.0), 1.0),
Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: 21 })
);
let tag26 = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&tag26,
Some(&node_data),
Some(f32::MIN_POSITIVE),
1.0,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "provider times base",
})
);
}
#[test]
fn tag26_selector_300_preserves_negative_and_signed_zero_values() {
let node_data = resource_device_node_data_table(1);
let record = resource_device_record(26, 0, 0.5_f32.to_bits());
let negative =
sample_control_resource_device_selector_300(&record, Some(&node_data), Some(-2.0), 4.0)
.expect("negative factors are not clamped");
assert_eq!(negative, -4.0);
let negative_zero = sample_control_resource_device_selector_300(
&resource_device_record(26, 0, 2.0_f32.to_bits()),
Some(&node_data),
Some(-0.0),
3.0,
)
.expect("signed zero is an exact native input");
assert_eq!(negative_zero.to_bits(), (-0.0_f32).to_bits());
let positive_zero = sample_control_resource_device_selector_300(
&resource_device_record(26, 0, 2.0_f32.to_bits()),
Some(&node_data),
Some(0.0),
3.0,
)
.expect("positive zero is an exact native input");
assert_eq!(positive_zero.to_bits(), 0.0_f32.to_bits());
}
#[test]
fn tag26_selector_300_rejects_nonfinite_and_subnormal_operands() {
let node_data = resource_device_node_data_table(1);
for (operand, provider_now, multiplier_now, base_bits) in [
("provider", f32::NAN, 1.0, 1.0_f32.to_bits()),
("multiplier", 1.0, f32::INFINITY, 1.0_f32.to_bits()),
("base coefficient", 1.0, 1.0, 1),
] {
let record = resource_device_record(26, 0, base_bits);
assert_eq!(
sample_control_resource_device_selector_300(
&record,
Some(&node_data),
Some(provider_now),
multiplier_now,
),
Err(ControlSamplingError::InvalidResourceDeviceOperand { operand })
);
}
}
#[test]
fn tag26_selector_300_rejects_extended_intermediate_and_final_ranges() {
let node_data = resource_device_node_data_table(1);
let provider = f32::MAX;
let overflowing_intermediate = resource_device_record(26, 0, 2.0_f32.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&overflowing_intermediate,
Some(&node_data),
Some(provider),
0.25,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "provider times base",
})
);
let underflowing_intermediate = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&underflowing_intermediate,
Some(&node_data),
Some(f32::MIN_POSITIVE),
f32::MAX,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "provider times base",
})
);
let subnormal_final = resource_device_record(26, 0, 1.0_f32.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&subnormal_final,
Some(&node_data),
Some(f32::MIN_POSITIVE),
0.5,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "then multiplier",
})
);
}
fn tag21_fullcycle_control() -> PreparedComponentControl { fn tag21_fullcycle_control() -> PreparedComponentControl {
let source = ResourceKey { let source = ResourceKey {
archive: parse_path("parts.rlb").expect("archive"), archive: parse_path("parts.rlb").expect("archive"),
@@ -4322,6 +4856,66 @@ mod tests {
assert_eq!(control.bindings[1].flags, 1); assert_eq!(control.bindings[1].flags, 1);
} }
#[test]
fn component_control_preserves_raw_profile_between_header_and_sections() {
const PROFILE_START: usize = 20;
const PROFILE_WORDS: usize = 27;
const SECTION_BYTES: usize = 156 + 16;
const MATRIX_BYTES: usize = 4;
const ROW_START: usize = PROFILE_START + PROFILE_WORDS * 4 + SECTION_BYTES + MATRIX_BYTES;
const ROW_BYTES: usize = 36;
let source = ResourceKey {
archive: parse_path("controls.rlb").expect("archive"),
name: resource_name(b"profile.ctl"),
type_id: None,
};
let mut payload = vec![0; ROW_START + ROW_BYTES];
payload[0..4].copy_from_slice(&1_u32.to_le_bytes()); // one section
payload[4..8].copy_from_slice(&1_u32.to_le_bytes()); // one node per section
payload[8..12].copy_from_slice(&1_u32.to_le_bytes()); // one binding row
let profile = std::array::from_fn::<_, PROFILE_WORDS, _>(|index| match index {
0 => 0xDEAD_BEEF,
1 => 0x7FC0_1234,
index => 0xA500_0000 | u32::try_from(index).expect("index fits u32"),
});
for (index, word) in profile.iter().enumerate() {
let start = PROFILE_START + index * 4;
payload[start..start + 4].copy_from_slice(&word.to_le_bytes());
}
payload[ROW_START..ROW_START + 4].copy_from_slice(&1_u32.to_le_bytes());
payload[ROW_START + 4..ROW_START + 8].copy_from_slice(&1.25_f32.to_bits().to_le_bytes());
payload[ROW_START + 8..ROW_START + 12].copy_from_slice(&2.5_f32.to_bits().to_le_bytes());
payload[ROW_START + 12..ROW_START + 16].copy_from_slice(&0.75_f32.to_bits().to_le_bytes());
payload[ROW_START + 0x20..ROW_START + 0x24].copy_from_slice(&1_u32.to_le_bytes());
let control =
decode_component_control_payload(&payload, source, 2, 0).expect("profile CTLD");
assert_eq!(control.native_profile_words, profile);
assert_eq!(control.timeline_rows.len(), 1);
assert_eq!(control.timeline_rows[0].node_index, 1);
assert_eq!(
control.timeline_rows[0].frame_a.to_bits(),
1.25_f32.to_bits()
);
assert_eq!(
control.timeline_rows[0].frame_b.to_bits(),
2.5_f32.to_bits()
);
}
#[test]
fn component_control_rejects_truncated_native_profile() {
let source = ResourceKey {
archive: parse_path("controls.rlb").expect("archive"),
name: resource_name(b"truncated-profile.ctl"),
type_id: None,
};
assert!(decode_component_control_payload(&vec![0; 127], source, 0, 0).is_err());
}
#[test] #[test]
fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() { fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() {
let source = ResourceKey { let source = ResourceKey {
@@ -4433,6 +5027,41 @@ mod tests {
); );
} }
#[test]
fn component_control_extended_common_tags_preserve_variable_tails_and_next_record() {
let source = ResourceKey {
archive: parse_path("controls.rlb").unwrap(),
name: resource_name(b"parts.ctl"),
type_id: None,
};
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
payload[12..16].copy_from_slice(&4_u32.to_le_bytes());
for tag in [3_u32, 4, 10, 27] {
let start = payload.len();
append_typed_record(&mut payload, tag, 2);
payload[start + 0xac..start + 0xb0].copy_from_slice(&2_u32.to_le_bytes());
let values = [tag.to_le_bytes(), (tag + 1).to_le_bytes()].concat();
payload.splice(start + 0xb0..start + 0xb0, values);
payload.extend_from_slice(b"fx\0");
}
let control = decode_component_control_payload(&payload, source, 2, 0).unwrap();
assert_eq!(control.typed_records.len(), 4);
for (record, tag) in control.typed_records.iter().zip([3, 4, 10, 27]) {
assert_eq!(record.tag, tag);
assert_eq!(record.values, [tag, tag + 1]);
assert_eq!(record.string_bytes, b"fx");
}
assert_eq!(
control.effect_section,
PreparedControlEffectSection::Parsed(Vec::new())
);
payload.pop();
assert!(
decode_component_control_payload(&payload, control.source, 2, 0).is_err(),
"recognized final record must validate its string terminator"
);
}
#[test] #[test]
fn component_control_malformed_recognized_typed_tail_is_rejected() { fn component_control_malformed_recognized_typed_tail_is_rejected() {
let source = ResourceKey { let source = ResourceKey {
@@ -6164,6 +6793,60 @@ mod tests {
assert_eq!(plan.lightmap_count, 0); assert_eq!(plan.lightmap_count, 0);
} }
#[test]
#[ignore = "requires licensed corpus"]
fn tag26_selector_300_uses_a_real_control_record_and_ndp_key() {
let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
let repository = CachedResourceRepository::new(Arc::clone(&vfs));
let roots = [resource_name(b"UNITS/BUILDS/MINE/min_l_n1.dat")];
let (_graph, prototypes) =
build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
let assets = prepare_mission_assets_with_repository(
&repository,
&[0..prototypes.len()],
&prototypes,
)
.expect("prepared licensed unit controls");
let mut sampled_records = 0;
for control in assets.object_component_controls.iter().flatten().flatten() {
for record in control
.typed_records
.iter()
.filter(|record| record.tag == 26)
{
let node_data = control
.node_data
.as_ref()
.expect("tag-26 control has its explicit NDP table");
assert_eq!(control.source.name.0, b"fr_l_mine.ctl");
assert_eq!(node_data.source.name.0, b"fr_l_mine.ndp");
assert_eq!(node_data.rows.len(), 22);
assert_eq!(record.fixed_words[1], 2);
assert_eq!(record.fixed_words[11], 0x3F80_0000);
let sample = sample_control_resource_device_selector_300(
record,
Some(node_data),
Some(1.0),
1.0,
)
.expect("real tag-26 key selects an NDP row");
assert_eq!(sample.to_bits(), record.fixed_words[11]);
println!(
"control={}: ndp={}: key={} rows={} coefficient=0x{:08x}",
String::from_utf8_lossy(&control.source.name.0),
String::from_utf8_lossy(&node_data.source.name.0),
record.fixed_words[1],
node_data.rows.len(),
record.fixed_words[11],
);
sampled_records += 1;
}
}
assert_eq!(sampled_records, 1);
}
#[test] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() { fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
+9 -3
View File
@@ -140,7 +140,10 @@ pub struct Node {
/// Raw fields of the standard 38-byte node layout that precede slot selection. /// Raw fields of the standard 38-byte node layout that precede slot selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Node38Metadata { pub struct Node38Metadata {
/// Unassigned source link value at byte offset two. /// Raw Node38 header/flag word at byte offset zero.
pub header_flags: u16,
/// Source-local parent word at byte offset two; `0xFFFF` is the native
/// sentinel before component rebasing.
pub parent_or_link_raw: u16, pub parent_or_link_raw: u16,
/// Offset into the type-19 frame map, or `0xFFFF` for no map. /// Offset into the type-19 frame map, or `0xFFFF` for no map.
pub anim_map_start: u16, pub anim_map_start: u16,
@@ -431,6 +434,7 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
} }
let offset = node_index.checked_mul(model.node_stride)?; let offset = node_index.checked_mul(model.node_stride)?;
Some(Node38Metadata { Some(Node38Metadata {
header_flags: read_u16(&model.nodes_raw, offset)?,
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?, parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?, anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
fallback_key: read_u16(&model.nodes_raw, offset + 6)?, fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
@@ -441,8 +445,8 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
/// ///
/// The legacy sampler uses this key when a node has no frame map or its current /// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must /// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before /// assemble the runtime hierarchy separately and apply the component-specific
/// assembling a runtime hierarchy. Its quaternion is converted to match native /// mapping of `parent_or_link_raw`. Its quaternion is converted to match native
/// AniMesh's quaternion-to-matrix convention. /// AniMesh's quaternion-to-matrix convention.
#[must_use] #[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> { pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
@@ -1412,6 +1416,7 @@ mod tests {
#[test] #[test]
fn standard_node_exposes_fallback_pose_and_unassigned_link() { fn standard_node_exposes_fallback_pose_and_unassigned_link() {
let mut node = node38([u16::MAX; 15]); let mut node = node38([u16::MAX; 15]);
node[0..2].copy_from_slice(&0x8201_u16.to_le_bytes());
node[2..4].copy_from_slice(&7_u16.to_le_bytes()); node[2..4].copy_from_slice(&7_u16.to_le_bytes());
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes()); node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
node[6..8].copy_from_slice(&0_u16.to_le_bytes()); node[6..8].copy_from_slice(&0_u16.to_le_bytes());
@@ -1440,6 +1445,7 @@ mod tests {
assert_eq!( assert_eq!(
node38_metadata(&model, NodeId(0)), node38_metadata(&model, NodeId(0)),
Some(Node38Metadata { Some(Node38Metadata {
header_flags: 0x8201,
parent_or_link_raw: 7, parent_or_link_raw: 7,
anim_map_start: u16::MAX, anim_map_start: u16::MAX,
fallback_key: 0, fallback_key: 0,
+1
View File
@@ -7,6 +7,7 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../fparkan-assets" } fparkan-assets = { path = "../fparkan-assets" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" } fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype" }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,164 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class BehaviorWarmStartOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const string ExpectedBehaviorSha256 = "ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99";
private const int WarmStartRva = 0x1adb0;
private const int GetQueueSlotRva = 0x59110;
private const int RandomStateRva = 0x63c1c;
private static uint currentQueueTime;
private static IntPtr queue;
private static bool queuePointerMatched;
private static QueueGetter queueGetter = GetCurrentQueue;
private static QueueTimeGetter queueTimeGetter = ReadQueueTime;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr QueueGetter();
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint QueueTimeGetter(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void WarmStart(IntPtr self);
private static IntPtr GetCurrentQueue() { return queue; }
private static uint ReadQueueTime(IntPtr self) { queuePointerMatched &= self == queue; return currentQueueTime; }
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
string path = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_BEHAVIOR_DLL");
if (String.IsNullOrEmpty(path)) path = @"C:\GOG Games\Parkan - Iron Strategy\Behavior.dll";
if (!File.Exists(path)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_BEHAVIOR_DLL to the installed Behavior.dll."); return 3; }
string hash = Hash(path);
if (hash != ExpectedBehaviorSha256) { Console.Error.WriteLine("Unexpected Behavior.dll SHA-256: " + hash); return 4; }
IntPtr module = LoadLibraryEx(path, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Behavior.dll) failed: " + Marshal.GetLastWin32Error()); return 5; }
IntPtr slot = IntPtr.Add(module, GetQueueSlotRva);
IntPtr oldQueueGetter = Marshal.ReadIntPtr(slot);
int oldRandomValue = Marshal.ReadInt32(IntPtr.Add(module, RandomStateRva));
IntPtr queueVtable = Marshal.AllocHGlobal(0x40);
queue = Marshal.AllocHGlobal(0x80);
IntPtr record = Marshal.AllocHGlobal(0x44);
IntPtr secondRecord = Marshal.AllocHGlobal(0x44);
IntPtr zeroClockRecord = Marshal.AllocHGlobal(0x44);
IntPtr wrappingClockRecord = Marshal.AllocHGlobal(0x44);
uint oldSlotProtection = 0;
bool slotProtectionChanged = false;
try {
Zero(queueVtable, 0x40); Zero(queue, 0x80);
Zero(record, 0x44); Zero(secondRecord, 0x44); Zero(zeroClockRecord, 0x44); Zero(wrappingClockRecord, 0x44);
Marshal.WriteIntPtr(queue, 0, queueVtable);
Marshal.WriteIntPtr(queueVtable, 0x38, Marshal.GetFunctionPointerForDelegate(queueTimeGetter));
// The synthetic queue controls only GetQueue and the +0x38 clock slot.
if (!VirtualProtect(slot, new UIntPtr((uint)IntPtr.Size), 0x40, out oldSlotProtection))
throw new InvalidOperationException("VirtualProtect(GetQueue IAT) failed: " + Marshal.GetLastWin32Error());
slotProtectionChanged = true;
Marshal.WriteIntPtr(slot, Marshal.GetFunctionPointerForDelegate(queueGetter));
WarmStart prepare = (WarmStart)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, WarmStartRva), typeof(WarmStart));
uint rngState = 1;
Expect(unchecked((uint)oldRandomValue) == rngState, "fresh DLL RNG state starts at the statically observed seed");
InitializeRecord(record);
currentQueueTime = 100000;
queuePointerMatched = true;
prepare(record);
Expect(queuePointerMatched, "native clock vslot receives the synthetic queue object");
uint previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(record, currentQueueTime, previousRandomState, rngState, module);
uint savedDeadline = ReadWord(record, 0x14);
for (int offset = 0x30; offset <= 0x40; offset += 4) Marshal.WriteInt32(record, offset, 0x5a5a5a5a);
currentQueueTime = ReadWord(record, 0x10);
queuePointerMatched = true;
prepare(record);
Expect(queuePointerMatched, "repeat native clock vslot receives the synthetic queue object");
Expect(ReadWord(record, 0x14) == savedDeadline, "repeat before deadline preserves the deadline");
Expect(ReadWord(IntPtr.Add(module, RandomStateRva), 0) == rngState, "repeat before deadline consumes no RNG draw");
for (int offset = 0x30; offset <= 0x40; offset += 4)
Expect(ReadWord(record, offset) == 0x5a5a5a5a, "nonzero first-time field skips the warm-preparation clear pass");
InitializeRecord(secondRecord);
currentQueueTime = 100500;
queuePointerMatched = true;
prepare(secondRecord);
Expect(queuePointerMatched, "second record uses the same synthetic queue");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(secondRecord, currentQueueTime, previousRandomState, rngState, module);
InitializeRecord(zeroClockRecord);
currentQueueTime = 0;
queuePointerMatched = true;
prepare(zeroClockRecord);
Expect(queuePointerMatched, "zero-clock record uses the synthetic queue");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(zeroClockRecord, 0, previousRandomState, rngState, module);
for (int offset = 0x30; offset <= 0x40; offset += 4) Marshal.WriteInt32(zeroClockRecord, offset, 0x6b6b6b6b);
queuePointerMatched = true;
prepare(zeroClockRecord);
Expect(queuePointerMatched, "zero-clock record uses the synthetic queue on repeat");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(zeroClockRecord, 0, previousRandomState, rngState, module);
InitializeRecord(wrappingClockRecord);
currentQueueTime = 0xffffff80;
queuePointerMatched = true;
prepare(wrappingClockRecord);
Expect(queuePointerMatched, "wrapping-clock record uses the synthetic queue");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(wrappingClockRecord, currentQueueTime, previousRandomState, rngState, module);
Expect(ReadWord(wrappingClockRecord, 0x14) < currentQueueTime, "deadline addition wraps as a DWORD near UINT32_MAX");
Console.WriteLine("original Behavior.dll warm-start oracle passed (RVA 0x1adb0; shared RNG, zero-clock repeat, and DWORD wrap).");
return 0;
}
finally {
if (slotProtectionChanged) {
Marshal.WriteIntPtr(slot, oldQueueGetter);
uint ignored;
VirtualProtect(slot, new UIntPtr((uint)IntPtr.Size), oldSlotProtection, out ignored);
}
Marshal.WriteInt32(IntPtr.Add(module, RandomStateRva), oldRandomValue);
Marshal.FreeHGlobal(record); Marshal.FreeHGlobal(secondRecord); Marshal.FreeHGlobal(zeroClockRecord); Marshal.FreeHGlobal(wrappingClockRecord);
Marshal.FreeHGlobal(queue); Marshal.FreeHGlobal(queueVtable);
GC.KeepAlive(queueGetter); GC.KeepAlive(queueTimeGetter);
FreeLibrary(module);
}
}
private static uint ReadWord(IntPtr address, int offset) { return unchecked((uint)Marshal.ReadInt32(address, offset)); }
private static uint NextRandomState(uint state) { return unchecked(state * 0x343fd + 0x269ec3); }
private static uint ExpectedDeadline(uint queueTime, uint previousRandomState) {
uint random = (NextRandomState(previousRandomState) >> 16) & 0x7fff;
uint jitter = ((random & 0xff) * 64) >> 8;
return unchecked(queueTime + 3 * 64 + jitter);
}
private static void InitializeRecord(IntPtr record) {
Zero(record, 0x44);
Marshal.WriteInt16(record, 0x18, 3); // Three 64-ms base units.
Marshal.WriteInt16(record, 0x1a, 1); // One 64-ms jitter range.
for (int offset = 0x30; offset <= 0x40; offset += 4)
Marshal.WriteInt32(record, offset, unchecked((int)(0xa0a00000u + (uint)offset)));
}
private static void VerifyFirstPreparation(IntPtr record, uint queueTime, uint previousRandomState, uint randomState, IntPtr module) {
Expect(ReadWord(record, 0x10) == queueTime, "warm preparation stores the queue clock in distributor+0x10");
Expect(ReadWord(record, 0x14) == ExpectedDeadline(queueTime, previousRandomState), "deadline matches base, configured interval, and native RNG jitter");
Expect(ReadWord(IntPtr.Add(module, RandomStateRva), 0) == randomState, "shared RNG advances by exactly one LCG draw");
for (int offset = 0x30; offset <= 0x40; offset += 4)
Expect(ReadWord(record, offset) == 0, "warm preparation clears distributor field +0x" + offset.ToString("x"));
}
private static string Hash(string path) {
using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(path)) {
byte[] digest = sha.ComputeHash(input); var hex = new StringBuilder();
foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString();
}
}
private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original Behavior oracle failed: " + message); }
private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
}
@@ -0,0 +1,158 @@
using System;
using System.IO;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
internal static class Iron3dAnchorOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const uint PageExecuteReadWrite = 0x40;
private const uint MemCommitReserve = 0x3000;
private const uint MemRelease = 0x8000;
private const int CallerRva = 0x392e0;
private const int GameGlobalRva = 0x10a5f8;
private const int CreateSuperAiIatRva = 0xe4010;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeLibrary(IntPtr module);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint allocationType, uint protect);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualFree(IntPtr address, UIntPtr size, uint freeType);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate void CreateAiDelegate(IntPtr record, int clanIndex, uint networkPlayer, int networkFlag);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate IntPtr CreateSuperAiDelegate(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate void FpuWordOperation(ref ushort value);
private static FpuWordOperation ReadControlWord;
private static uint CapturedX;
private static uint CapturedY;
private static ushort CapturedControlWord;
private static CreateSuperAiDelegate CaptureDelegate;
private static IntPtr CaptureCreateSuperAi(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback) {
CapturedX = x;
CapturedY = y;
ReadControlWord(ref CapturedControlWord);
return IntPtr.Zero;
}
private static void Require(bool value, string message) {
if (!value) throw new InvalidOperationException(message);
}
private static void Expect(CreateAiDelegate caller, IntPtr record, FpuWordOperation setControlWord, ushort mode, float x, float y, uint expectedX, uint expectedY, string label) {
ushort requested = mode;
setControlWord(ref requested);
Marshal.WriteInt32(record, 4, BitConverter.ToInt32(BitConverter.GetBytes(x), 0));
Marshal.WriteInt32(record, 8, BitConverter.ToInt32(BitConverter.GetBytes(y), 0));
CapturedX = 0xdeadbeef;
CapturedY = 0xdeadbeef;
CapturedControlWord = 0;
caller(record, 0, 0, 0);
string actual = String.Format(CultureInfo.InvariantCulture, "{0}: cw=0x{1:x4} rc={2}, ({3:R}, {4:R}) -> (0x{5:x8}, 0x{6:x8})", label, CapturedControlWord, (CapturedControlWord >> 10) & 3, x, y, CapturedX, CapturedY);
Console.WriteLine(actual);
Require(CapturedX == expectedX && CapturedY == expectedY, label + " expected (0x" + expectedX.ToString("x8") + ",0x" + expectedY.ToString("x8") + ")");
Require(((CapturedControlWord >> 10) & 3) == ((requested >> 10) & 3), label + " x87 RC changed before the native callback");
}
private static IntPtr BuildInstruction(byte[] bytes) {
IntPtr memory = VirtualAlloc(IntPtr.Zero, new UIntPtr(4096), MemCommitReserve, PageExecuteReadWrite);
if (memory == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error());
Marshal.Copy(bytes, 0, memory, bytes.Length);
return memory;
}
private static void ProtectWrite(IntPtr address, int value, out uint oldProtect) {
if (!VirtualProtect(address, new UIntPtr(4), PageExecuteReadWrite, out oldProtect)) throw new InvalidOperationException("VirtualProtect failed: " + Marshal.GetLastWin32Error());
Marshal.WriteInt32(address, value);
}
private static void RestoreProtection(IntPtr address, uint oldProtect) {
uint ignored;
if (!VirtualProtect(address, new UIntPtr(4), oldProtect, out ignored)) throw new InvalidOperationException("VirtualProtect restore failed: " + Marshal.GetLastWin32Error());
}
private static void Main() {
Require(IntPtr.Size == 4, "This oracle must run as an x86 process");
string dll = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
Require(!String.IsNullOrEmpty(dll) && File.Exists(dll), "Set FPARKAN_ORIGINAL_IRON3D_DLL to the installed iron3d.dll");
string hash;
using (SHA256 sha = SHA256.Create()) using (FileStream stream = File.OpenRead(dll)) hash = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
Require(hash == "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa", "unexpected iron3d.dll hash: " + hash);
IntPtr module = LoadLibraryEx(dll, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx failed: " + Marshal.GetLastWin32Error());
IntPtr readCode = IntPtr.Zero;
IntPtr writeCode = IntPtr.Zero;
IntPtr game = Marshal.AllocHGlobal(0x40);
IntPtr manager = Marshal.AllocHGlobal(0x40);
IntPtr record = Marshal.AllocHGlobal(0x68);
uint oldIatProtection = 0;
int oldGlobal = Marshal.ReadInt32(IntPtr.Add(module, GameGlobalRva));
int oldIat = Marshal.ReadInt32(IntPtr.Add(module, CreateSuperAiIatRva));
bool iatChanged = false;
bool controlWordCaptured = false;
ushort originalControlWord = 0;
FpuWordOperation setControlWord = null;
try {
byte[] readBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x38, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fnstcw [eax]; ret 4
byte[] writeBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x28, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fldcw [eax]; ret 4
readCode = BuildInstruction(readBytes);
writeCode = BuildInstruction(writeBytes);
ReadControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(readCode, typeof(FpuWordOperation));
setControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(writeCode, typeof(FpuWordOperation));
ReadControlWord(ref originalControlWord);
controlWordCaptured = true;
Console.WriteLine("iron3d.dll sha256=" + hash + " loadedBase=0x" + module.ToInt32().ToString("x8") + " defaultCW=0x" + originalControlWord.ToString("x4") + " defaultRC=" + ((originalControlWord >> 10) & 3));
for (int offset = 0; offset < 0x40; offset += 4) { Marshal.WriteInt32(game, offset, 0); Marshal.WriteInt32(manager, offset, 0); }
for (int offset = 0; offset < 0x68; offset += 4) Marshal.WriteInt32(record, offset, 0);
Marshal.WriteInt32(game, 0x1c, manager.ToInt32());
Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), game.ToInt32());
CaptureDelegate = CaptureCreateSuperAi;
IntPtr callbackPointer = Marshal.GetFunctionPointerForDelegate(CaptureDelegate);
IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva);
ProtectWrite(iat, callbackPointer.ToInt32(), out oldIatProtection);
iatChanged = true;
CreateAiDelegate caller = (CreateAiDelegate)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallerRva), typeof(CreateAiDelegate));
ushort nearest = (ushort)(originalControlWord & ~0x0c00);
ushort down = (ushort)((originalControlWord & ~0x0c00) | 0x0400);
ushort up = (ushort)((originalControlWord & ~0x0c00) | 0x0800);
ushort truncate = (ushort)((originalControlWord & ~0x0c00) | 0x0c00);
Expect(caller, record, setControlWord, nearest, 500.2857f, 751.8217f, 500, 752, "Autodemo anchors / nearest-even");
Expect(caller, record, setControlWord, nearest, 500.5f, 501.5f, 500, 502, "nearest tie pair");
Expect(caller, record, setControlWord, down, 500.5f, 501.5f, 500, 501, "down tie pair");
Expect(caller, record, setControlWord, up, 500.5f, 501.5f, 501, 502, "up tie pair");
Expect(caller, record, setControlWord, truncate, 500.5f, 501.5f, 500, 501, "truncate tie pair");
Expect(caller, record, setControlWord, nearest, -0.4f, 10000.4f, 0, 10000, "round-in lower/upper boundary");
Expect(caller, record, setControlWord, nearest, -0.6f, 10000.6f, 0xffffffff, 10001, "round-out lower/upper boundary");
Expect(caller, record, setControlWord, nearest, 4294967296.0f, 4294967808.0f, 0, 512, "low-DWORD wrap boundary");
}
finally {
if (iatChanged) {
IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva);
Marshal.WriteInt32(iat, oldIat);
RestoreProtection(iat, oldIatProtection);
}
Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), oldGlobal);
if (controlWordCaptured) setControlWord(ref originalControlWord);
Marshal.FreeHGlobal(record);
Marshal.FreeHGlobal(manager);
Marshal.FreeHGlobal(game);
if (readCode != IntPtr.Zero) VirtualFree(readCode, UIntPtr.Zero, MemRelease);
if (writeCode != IntPtr.Zero) VirtualFree(writeCode, UIntPtr.Zero, MemRelease);
FreeLibrary(module);
}
}
}
@@ -0,0 +1,429 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class Handler19DispatchOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
private const string ExpectedIron3dSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa";
private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced";
private const int EventLookupRva = 0x11f40;
private const int EventDispatchRva = 0x11f10;
private const int Handler19Rva = 0x0aa38;
private const int AiWarmStartRva = 0x01ae0;
private const int Iron3dClanRefreshRva = 0x39440;
private const int EventStride = 0x14;
private const int InstructionStride = 0x20;
private const int VarSetStride = 0x30;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate int FindEvent(IntPtr vm, IntPtr name);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint DispatchEvent(IntPtr vm, int eventIndex);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint ReadVariable(IntPtr record);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void SetDwordPosition(IntPtr record, uint x, uint y);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void RefreshClan(IntPtr record, uint requestedMode);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void HostInterfaceRefresh(IntPtr host);
private sealed class WarmStartObservation {
public int Calls;
public IntPtr Host;
public IntPtr Record;
public IntPtr Ai;
public IntPtr Cells;
public int EntryMode;
public int AiMode;
public byte Latch;
public uint XCellBeforeDispatch;
}
private static WarmStartObservation CurrentWarmStartObservation;
private static HostInterfaceRefresh WarmStartRecorder = RecordWarmStart;
private static void RecordWarmStart(IntPtr host) {
WarmStartObservation observation = CurrentWarmStartObservation;
observation.Calls++;
observation.Host = host;
observation.EntryMode = Marshal.ReadInt32(observation.Record, 0x18);
observation.AiMode = Marshal.ReadInt32(observation.Ai, 0x3fc);
observation.Latch = Marshal.ReadByte(observation.Ai, 0x8a8);
observation.XCellBeforeDispatch = ReadWord(observation.Cells, 0);
}
private sealed class Instruction {
public readonly uint[] Words;
public readonly uint[] References;
public Instruction(uint[] words, uint[] references) { Words = words; References = references; }
}
private sealed class Event {
public readonly byte[] Name;
public readonly uint EventWord;
public readonly Instruction[] Instructions;
public Event(byte[] name, uint eventWord, Instruction[] instructions) { Name = name; EventWord = eventWord; Instructions = instructions; }
}
private sealed class Package {
public readonly uint HandlerCount;
public readonly Event[] Events;
public Package(uint handlerCount, Event[] events) { HandlerCount = handlerCount; Events = events; }
}
private sealed class Instance : IDisposable {
private readonly List<IntPtr> allocations = new List<IntPtr>();
public IntPtr Ai;
public IntPtr EventTable;
public IntPtr HandlerTable;
public IntPtr VarRecords;
public IntPtr Cells;
public void Dispose() { for (int i = allocations.Count - 1; i >= 0; --i) Marshal.FreeHGlobal(allocations[i]); }
public IntPtr Allocate(int length) {
IntPtr p = Marshal.AllocHGlobal(length);
if (p == IntPtr.Zero) throw new OutOfMemoryException();
Zero(p, length); allocations.Add(p); return p;
}
}
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
string aiPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
string iron3dPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
string scriptPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_DEFAULT_SCR");
if (String.IsNullOrEmpty(aiPath) || !File.Exists(aiPath) || String.IsNullOrEmpty(iron3dPath) || !File.Exists(iron3dPath) || String.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) {
Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL, FPARKAN_ORIGINAL_IRON3D_DLL, and FPARKAN_ORIGINAL_DEFAULT_SCR to the installed GOG files."); return 3;
}
if (Hash(aiPath) != ExpectedAiSha256) { Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + Hash(aiPath)); return 4; }
if (Hash(iron3dPath) != ExpectedIron3dSha256) { Console.Error.WriteLine("Unexpected iron3d.dll SHA-256: " + Hash(iron3dPath)); return 4; }
if (Hash(scriptPath) != ExpectedScriptSha256) { Console.Error.WriteLine("Unexpected default.scr SHA-256: " + Hash(scriptPath)); return 5; }
Package package = ReadPackage(scriptPath);
if (package.HandlerCount != 73 || package.Events.Length != 9) throw new InvalidDataException("Unexpected default.scr header.");
if (package.Events[0].Name.Length != 5 || Encoding.ASCII.GetString(package.Events[0].Name) != "Init\0" || package.Events[0].Instructions.Length != 1)
throw new InvalidDataException("Expected a single-instruction Init event at index 0.");
Instruction init = package.Events[0].Instructions[0];
if (init.Words[0] != 19 || init.Words[1] != 0xffffffff || init.Words[2] != 0xffffffff || init.References.Length != 3 || init.References[0] != 224 || init.References[1] != 225 || init.References[2] != 226)
throw new InvalidDataException("Init instruction no longer matches the recovered Handler(19) shape.");
IntPtr module = LoadLibraryEx(aiPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(ai.dll) failed: " + Marshal.GetLastWin32Error()); return 6; }
try {
FindEvent find = (FindEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventLookupRva), typeof(FindEvent));
DispatchEvent dispatch = (DispatchEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventDispatchRva), typeof(DispatchEvent));
ReadVariable readVariable = (ReadVariable)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x13570), typeof(ReadVariable));
SetDwordPosition setDwordPosition = (SetDwordPosition)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x5c70), typeof(SetDwordPosition));
VerifyEventLookupVectors(package, find, dispatch, module);
RunInstance(package, find, dispatch, readVariable, module, 500.2857f, 752.0f, 0, 500, 752);
RunInstance(package, find, dispatch, readVariable, module, 728.0f, 449.0f, 1, 728, 449);
RunDwordToHandlerRoundTrip(package, find, dispatch, readVariable, setDwordPosition, module);
RunOriginalClanRefreshPath(package, module, iron3dPath);
Console.WriteLine("original ai.dll dispatcher passed for default.scr Init instances and DWORD round-trip; original iron3d clan-refresh to AI-vtable Init path also passed; code bytes were not modified");
return 0;
}
finally { FreeLibrary(module); }
}
private static void RunOriginalClanRefreshPath(Package package, IntPtr aiModule, string iron3dPath) {
IntPtr iron3d = LoadLibraryEx(iron3dPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (iron3d == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx(iron3d.dll) failed: " + Marshal.GetLastWin32Error());
try {
RefreshClan refresh = (RefreshClan)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(iron3d, Iron3dClanRefreshRva), typeof(RefreshClan));
IntPtr method = IntPtr.Add(aiModule, AiWarmStartRva);
RunClanRefreshCase(package, aiModule, refresh, method, 1, 2, 2, "forward requested mode");
RunClanRefreshCase(package, aiModule, refresh, method, 0, 2, 1, "zero flag forces mode one");
RunClanRefreshCase(package, aiModule, refresh, method, 1, 1, 1, "forward mode one");
RunClanRefreshMissingCase(package, aiModule, refresh, method);
RunClanRefreshBusyCase(package, aiModule, refresh, method);
RunClanRefreshNullAiCase(refresh);
}
finally { FreeLibrary(iron3d); }
}
private static void RunClanRefreshCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method, int forceFlag, uint requestedMode, uint expectedMode, string label) {
using (Instance instance = BuildInstance(package, 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) {
IntPtr host = ConfigureClanRefresh(instance, method);
IntPtr record = PrepareClanRecord(instance, forceFlag);
CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells };
refresh(record, requestedMode);
WarmStartObservation observed = CurrentWarmStartObservation;
Expect(Marshal.ReadInt32(record, 0x18) == requestedMode, label + ": Iron3D stores the requested per-clan mode");
Expect(Marshal.ReadInt32(instance.Ai, 0x3fc) == expectedMode, label + ": AI callback receives the forced/forwarded mode");
Expect(observed.Calls == 1 && observed.Host == host, label + ": original AI slot called its host interface first");
Expect(observed.EntryMode == requestedMode && observed.AiMode == expectedMode && observed.Latch == 0 && observed.XCellBeforeDispatch == 0,
label + ": record and AI mode are stored before host callback, and Init dispatch follows it");
Expect(Marshal.ReadByte(instance.Ai, 0x78) == 0, label + ": normal Init dispatch clears the recursion guard");
Expect(Marshal.ReadByte(instance.Ai, 0x8a8) == 1, label + ": successful Init lookup latches after dispatch");
Expect(ReadWord(instance.Cells, 0) == 500 && ReadWord(instance.Cells, 1) == 752 && ReadWord(instance.Cells, 2) == 0,
label + ": original Init handler writes X/Y/clan after host callback");
Console.WriteLine("iron3d 39440 -> ai 01ae0 ({0}): entry_mode={1}, ai_mode={2}, latch={3}", label, requestedMode, expectedMode, Marshal.ReadByte(instance.Ai, 0x8a8));
Marshal.WriteInt32(instance.Cells, 0, unchecked((int)0xfeedface));
refresh(record, requestedMode);
Expect(observed.Calls == 2 && Marshal.ReadInt32(instance.Ai, 0x3fc) == expectedMode, label + ": repeat still reaches interface before latch check");
Expect(ReadWord(instance.Cells, 0) == 0xfeedface, label + ": Init latch suppresses a second event dispatch");
}
}
private static void RunClanRefreshMissingCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method) {
Event[] events = (Event[])package.Events.Clone();
bool replaced = false;
for (int i = 0; i < events.Length; i++) {
if (Encoding.ASCII.GetString(events[i].Name) == "Init\0") {
events[i] = new Event(Encoding.ASCII.GetBytes("Missing\0"), events[i].EventWord, events[i].Instructions);
replaced = true; break;
}
}
Expect(replaced, "missing-path package contains Init to remove");
using (Instance instance = BuildInstance(new Package(package.HandlerCount, events), 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) {
ConfigureClanRefresh(instance, method);
IntPtr record = PrepareClanRecord(instance, 1);
CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells };
refresh(record, 2);
Expect(Marshal.ReadByte(instance.Ai, 0x8a8) == 0 && CurrentWarmStartObservation.Calls == 1, "missing Init leaves the native latch clear after the host callback");
Expect(ReadWord(instance.Cells, 0) == 0 && ReadWord(instance.Cells, 1) == 0 && ReadWord(instance.Cells, 2) == 0, "missing Init has no Handler(19) writes");
}
}
private static void RunClanRefreshBusyCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method) {
using (Instance instance = BuildInstance(package, 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) {
ConfigureClanRefresh(instance, method);
Marshal.WriteByte(instance.Ai, 0x78, 1);
IntPtr record = PrepareClanRecord(instance, 1);
CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells };
refresh(record, 2);
Expect(CurrentWarmStartObservation.Calls == 1 && ReadWord(instance.Cells, 0) == 0, "busy recursion guard suppresses Init dispatch");
Expect(Marshal.ReadByte(instance.Ai, 0x78) == 1 && Marshal.ReadByte(instance.Ai, 0x8a8) == 1, "found Init latches even when the preexisting busy guard suppresses dispatch");
Marshal.WriteByte(instance.Ai, 0x78, 0);
Marshal.WriteInt32(instance.Cells, 0, unchecked((int)0xfeedface));
refresh(record, 2);
Expect(CurrentWarmStartObservation.Calls == 2 && Marshal.ReadByte(instance.Ai, 0x78) == 0 && Marshal.ReadByte(instance.Ai, 0x8a8) == 1,
"clearing the busy guard cannot bypass the already-set Init latch");
Expect(ReadWord(instance.Cells, 0) == 0xfeedface, "busy-path latch suppresses a later Init dispatch after the guard clears");
}
}
private static void RunClanRefreshNullAiCase(RefreshClan refresh) {
IntPtr record = Marshal.AllocHGlobal(0x68);
try {
Zero(record, 0x68);
Marshal.WriteInt32(record, 0x0c, 1);
Marshal.WriteInt32(record, 0x18, -1);
refresh(record, 2);
Expect(Marshal.ReadInt32(record, 0x18) == 2, "null AI still stores the requested record mode");
}
finally { Marshal.FreeHGlobal(record); }
}
private static IntPtr ConfigureClanRefresh(Instance instance, IntPtr method) {
IntPtr vtable = instance.Allocate(0x18);
IntPtr host = instance.Allocate(4);
IntPtr hostVtable = instance.Allocate(0x30);
Marshal.WriteInt32(vtable, 0x14, method.ToInt32());
Marshal.WriteInt32(instance.Ai, 0, vtable.ToInt32());
Marshal.WriteInt32(host, 0, hostVtable.ToInt32());
Marshal.WriteInt32(hostVtable, 0x2c, Marshal.GetFunctionPointerForDelegate(WarmStartRecorder).ToInt32());
Marshal.WriteInt32(instance.Ai, 0x438, host.ToInt32());
Marshal.WriteByte(instance.Ai, 0x78, 0);
Marshal.WriteByte(instance.Ai, 0x8a8, 0);
return host;
}
private static IntPtr PrepareClanRecord(Instance instance, int forceFlag) {
IntPtr record = instance.Allocate(0x68);
Marshal.WriteInt32(record, 0x0c, forceFlag);
Marshal.WriteInt32(record, 0x50, instance.Ai.ToInt32());
return record;
}
private static void VerifyEventLookupVectors(Package package, FindEvent find, DispatchEvent dispatch, IntPtr module) {
var duplicateEvents = new Event[package.Events.Length + 1];
Array.Copy(package.Events, duplicateEvents, package.Events.Length);
duplicateEvents[duplicateEvents.Length - 1] = new Event(
Encoding.ASCII.GetBytes("Init\0"), 0xabcdef01,
new Instruction[] { new Instruction(new uint[] { 19, 0xffffffff, 0xffffffff, 0, 0, 3, 0 }, new uint[] { 224, 225, 226 }) });
var duplicatePackage = new Package(package.HandlerCount, duplicateEvents);
using (Instance instance = BuildInstance(duplicatePackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup chooses first exact duplicate event");
}
var embeddedNulEvents = (Event[])package.Events.Clone();
for (int i = 0; i < embeddedNulEvents.Length; i++) {
if (Encoding.ASCII.GetString(embeddedNulEvents[i].Name) == "Init\0") {
embeddedNulEvents[i] = new Event(Encoding.ASCII.GetBytes("Init\0suffix\0"), embeddedNulEvents[i].EventWord, embeddedNulEvents[i].Instructions);
break;
}
}
var embeddedNulPackage = new Package(package.HandlerCount, embeddedNulEvents);
using (Instance instance = BuildInstance(embeddedNulPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup stops at an embedded NUL and ignores trailing name bytes");
}
var missingEvents = (Event[])package.Events.Clone();
for (int i = 0; i < missingEvents.Length; i++) {
if (Encoding.ASCII.GetString(missingEvents[i].Name) == "Init\0") {
missingEvents[i] = new Event(Encoding.ASCII.GetBytes("NoInit\0"), missingEvents[i].EventWord, missingEvents[i].Instructions);
}
}
var missingPackage = new Package(package.HandlerCount, missingEvents);
using (Instance instance = BuildInstance(missingPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
IntPtr vm = IntPtr.Add(instance.Ai, 8);
int eventIndex = LookupEvent(find, vm, "Init");
Expect(eventIndex == -1, "native lookup reports missing exact event");
Expect((dispatch(vm, eventIndex) & 0xff) == 0, "dispatching missing event performs no callback");
Expect(Marshal.ReadInt32(instance.Cells, 0) == 0 && Marshal.ReadInt32(instance.Cells, 4) == 0 && Marshal.ReadInt32(instance.Cells, 8) == 0,
"missing event makes no varset writes");
}
Console.WriteLine("original event lookup: first duplicate wins; Init\\0suffix also matches; missing Init returns -1 without writes");
}
private static int LookupEvent(FindEvent find, IntPtr vm, string nameText) {
byte[] nameBytes = Encoding.ASCII.GetBytes(nameText + "\0");
IntPtr name = Marshal.AllocHGlobal(nameBytes.Length);
try {
Marshal.Copy(nameBytes, 0, name, nameBytes.Length);
return find(vm, name);
}
finally { Marshal.FreeHGlobal(name); }
}
private static void RunDwordToHandlerRoundTrip(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, SetDwordPosition setDwordPosition, IntPtr module) {
IntPtr position = Marshal.AllocHGlobal(12);
try {
Zero(position, 12);
setDwordPosition(position, UInt32.MaxValue, 10001);
float x = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 1)), 0);
float y = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 2)), 0);
Expect(x == 4294967296.0f && y == 10001.0f, "FUN_10005c70 converts DWORD coordinates to the expected single-precision fields");
Console.WriteLine("FUN_10005c70 input=({0:X8}, {1}) fields=({2:R}, {3:R})", UInt32.MaxValue, 10001, x, y);
RunInstance(package, find, dispatch, readVariable, module, x, y, 2, 0, 10001);
}
finally { Marshal.FreeHGlobal(position); }
}
private static void RunInstance(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, IntPtr module, float x, float y, uint clanId, uint expectedX, uint expectedY) {
using (Instance instance = BuildInstance(package, x, y, clanId, IntPtr.Add(module, Handler19Rva))) {
IntPtr vm = IntPtr.Add(instance.Ai, 8);
IntPtr name = Marshal.AllocHGlobal(5);
try {
Marshal.Copy(Encoding.ASCII.GetBytes("Init\0"), 0, name, 5);
int eventIndex = find(vm, name);
Expect(eventIndex == 0, "original name lookup returns Init index 0");
uint result = dispatch(vm, eventIndex);
uint first = ReadWord(instance.Cells, 0);
uint second = ReadWord(instance.Cells, 1);
uint third = ReadWord(instance.Cells, 2);
uint getterX = readVariable(IntPtr.Add(instance.VarRecords, 224 * VarSetStride));
uint getterY = readVariable(IntPtr.Add(instance.VarRecords, 225 * VarSetStride));
uint getterClan = readVariable(IntPtr.Add(instance.VarRecords, 226 * VarSetStride));
Console.WriteLine("probe clan={0}: cell_words=({1:X8}, {2:X8}, {3:X8}), native_getter=({4:X8}, {5:X8}, {6:X8})", clanId, first, second, third, getterX, getterY, getterClan);
Expect((result & 0xff) == 1, "dispatcher reports completion");
Expect(Marshal.ReadInt32(vm, 0x40) == 0, "current event field +0x48 is Init index");
Expect(Marshal.ReadInt32(vm, 0x44) == 1, "current instruction field +0x4c advanced once");
Expect(Marshal.ReadInt32(vm, 0x6c) == 0, "condition stack depth reset to zero");
Expect(Marshal.ReadInt32(instance.Ai, 0x50) == 0, "handler result slot +0x50 cleared");
Expect(first == expectedX && second == expectedY && third == clanId, "Handler(19) writes the three referenced DWORD cells in order");
Expect(getterX == first && getterY == second && getterClan == third, "native DWORD getter reads the values written by Handler(19)");
Console.WriteLine("clan={0}: Init={1}, instruction={2}, result={3}, stack={4}, result_slot={5}, cell_words=({6:X8}, {7:X8}, {8:X8}), typed_getters=({9:X8}, {10:X8}, {11:X8})",
clanId, eventIndex, Marshal.ReadInt32(vm, 0x44), result, Marshal.ReadInt32(vm, 0x6c), Marshal.ReadInt32(instance.Ai, 0x50), first, second, third, getterX, getterY, getterClan);
}
finally { Marshal.FreeHGlobal(name); }
}
}
private static Instance BuildInstance(Package package, float x, float y, uint clanId, IntPtr handler19) {
var instance = new Instance();
try {
instance.Ai = instance.Allocate(0x8b0);
instance.EventTable = instance.Allocate(package.Events.Length * EventStride);
instance.HandlerTable = instance.Allocate((int)package.HandlerCount * 4);
instance.VarRecords = instance.Allocate(227 * VarSetStride);
instance.Cells = instance.Allocate(3 * 4);
IntPtr vm = IntPtr.Add(instance.Ai, 8);
Marshal.WriteInt32(vm, 0, package.Events.Length);
Marshal.WriteInt32(vm, 4, instance.EventTable.ToInt32());
Marshal.WriteInt32(vm, 8, (int)package.HandlerCount);
Marshal.WriteInt32(vm, 0x0c, instance.HandlerTable.ToInt32());
Marshal.WriteInt32(vm, 0x3c, instance.Ai.ToInt32());
Marshal.WriteInt32(instance.HandlerTable, 19 * 4, handler19.ToInt32());
for (int eventIndex = 0; eventIndex < package.Events.Length; eventIndex++) {
Event sourceEvent = package.Events[eventIndex];
IntPtr name = instance.Allocate(sourceEvent.Name.Length);
Marshal.Copy(sourceEvent.Name, 0, name, sourceEvent.Name.Length);
IntPtr records = sourceEvent.Instructions.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceEvent.Instructions.Length * InstructionStride);
int eventOffset = eventIndex * EventStride;
Marshal.WriteInt32(instance.EventTable, eventOffset, name.ToInt32());
Marshal.WriteInt32(instance.EventTable, eventOffset + 4, sourceEvent.Name.Length - 1);
Marshal.WriteInt32(instance.EventTable, eventOffset + 8, unchecked((int)sourceEvent.EventWord));
Marshal.WriteInt32(instance.EventTable, eventOffset + 0x0c, sourceEvent.Instructions.Length);
Marshal.WriteInt32(instance.EventTable, eventOffset + 0x10, records.ToInt32());
for (int instructionIndex = 0; instructionIndex < sourceEvent.Instructions.Length; instructionIndex++) {
Instruction sourceInstruction = sourceEvent.Instructions[instructionIndex];
IntPtr refs = sourceInstruction.References.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceInstruction.References.Length * 4);
for (int referenceIndex = 0; referenceIndex < sourceInstruction.References.Length; referenceIndex++)
Marshal.WriteInt32(refs, referenceIndex * 4, unchecked((int)sourceInstruction.References[referenceIndex]));
IntPtr instruction = IntPtr.Add(records, instructionIndex * InstructionStride);
for (int word = 0; word < 5; word++) Marshal.WriteInt32(instruction, word * 4, unchecked((int)sourceInstruction.Words[word]));
Marshal.WriteInt32(instruction, 0x14, refs.ToInt32());
Marshal.WriteInt32(instruction, 0x18, sourceInstruction.References.Length);
Marshal.WriteInt32(instruction, 0x1c, unchecked((int)sourceInstruction.Words[6]));
}
}
Marshal.WriteInt32(instance.Ai, 0x7c, unchecked((int)clanId));
Marshal.WriteInt32(instance.Ai, 0x80, BitConverter.ToInt32(BitConverter.GetBytes(x), 0));
Marshal.WriteInt32(instance.Ai, 0x84, BitConverter.ToInt32(BitConverter.GetBytes(y), 0));
// AI+0x18 is the original varset vector object: +4=count and +0xc=record array.
Marshal.WriteInt32(instance.Ai, 0x1c, 227);
Marshal.WriteInt32(instance.Ai, 0x24, instance.VarRecords.ToInt32());
uint[] targets = { 224, 225, 226 };
for (int i = 0; i < targets.Length; i++) {
IntPtr record = IntPtr.Add(instance.VarRecords, checked((int)targets[i] * VarSetStride));
IntPtr cell = IntPtr.Add(instance.Cells, i * 4);
Marshal.WriteInt32(record, 8, 5); // original parser's DWORD type tag
Marshal.WriteInt32(record, 0x20, cell.ToInt32());
}
return instance;
}
catch { instance.Dispose(); throw; }
}
private static Package ReadPackage(string path) {
using (BinaryReader reader = new BinaryReader(File.OpenRead(path), Encoding.ASCII)) {
uint handlerCount = reader.ReadUInt32();
uint eventCount = reader.ReadUInt32();
if (eventCount > 4096 || handlerCount > 4096) throw new InvalidDataException("Unreasonable package header.");
var events = new Event[eventCount];
for (int e = 0; e < events.Length; e++) {
uint nameLength = reader.ReadUInt32();
if (nameLength > 1024) throw new InvalidDataException("Unreasonable event name length.");
byte[] name = reader.ReadBytes(checked((int)nameLength + 1));
if (name.Length != nameLength + 1 || name[name.Length - 1] != 0) throw new InvalidDataException("Bad event name.");
uint eventWord = reader.ReadUInt32();
uint instructionCount = reader.ReadUInt32();
if (instructionCount > 65536) throw new InvalidDataException("Unreasonable event instruction count.");
var instructions = new Instruction[instructionCount];
for (int i = 0; i < instructions.Length; i++) {
uint[] words = new uint[6];
for (int w = 0; w < words.Length; w++) words[w] = reader.ReadUInt32();
if (words[5] > 4096) throw new InvalidDataException("Unreasonable reference count.");
uint[] refs = new uint[words[5]];
for (int r = 0; r < refs.Length; r++) refs[r] = reader.ReadUInt32();
uint trailing = reader.ReadUInt32();
instructions[i] = new Instruction(new uint[] { words[0], words[1], words[2], words[3], words[4], words[5], trailing }, refs);
}
events[e] = new Event(name, eventWord, instructions);
}
if (reader.BaseStream.Position != reader.BaseStream.Length) throw new InvalidDataException("Trailing bytes in default.scr.");
return new Package(handlerCount, events);
}
}
private static uint ReadWord(IntPtr cells, int index) { return unchecked((uint)Marshal.ReadInt32(cells, index * 4)); }
private static string Hash(string path) {
using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(path)) {
byte[] digest = sha.ComputeHash(input); var hex = new StringBuilder();
foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString();
}
}
private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original dispatcher oracle failed: " + message); }
private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
}
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class HostCallbackOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const uint MemCommitReserve = 0x00003000;
private const uint PageExecuteReadWrite = 0x40;
private const int CallbackRva = 0x611d0;
private const int GameGlobalRva = 0x10a5f8;
private const int SoundServerIatRva = 0xe42cc;
private const string ExpectedSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa";
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module);
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint allocationType, uint protect);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualFree(IntPtr address, UIntPtr size, uint freeType);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int HostCallback(int gameMode, int command, int payload);
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
string dllPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
if (String.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) {
Console.Error.WriteLine("Set FPARKAN_ORIGINAL_IRON3D_DLL to the original GOG iron3d.dll."); return 3;
}
string hash;
using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(dllPath)) {
byte[] digest = sha.ComputeHash(input); StringBuilder hex = new StringBuilder();
foreach (byte value in digest) hex.Append(value.ToString("x2")); hash = hex.ToString();
}
if (hash != ExpectedSha256) { Console.Error.WriteLine("Unexpected iron3d.dll SHA-256: " + hash); return 4; }
IntPtr module = LoadLibraryEx(dllPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx failed: " + Marshal.GetLastWin32Error()); return 5; }
IntPtr game = IntPtr.Zero, app = IntPtr.Zero, manager = IntPtr.Zero, sound = IntPtr.Zero, vtable = IntPtr.Zero;
IntPtr callStub = IntPtr.Zero, returnStub = IntPtr.Zero;
IntPtr gameGlobal = IntPtr.Zero; int originalGameGlobal = 0; bool gameGlobalPatched = false;
uint oldProtect = 0; bool iatPatched = false; int originalSoundPointer = 0;
try {
game = Marshal.AllocHGlobal(0x200); app = Marshal.AllocHGlobal(0x200); manager = Marshal.AllocHGlobal(0x40);
sound = Marshal.AllocHGlobal(4); vtable = Marshal.AllocHGlobal(0x14);
IntPtr observations = Marshal.AllocHGlobal(56);
try {
Zero(game, 0x200); Zero(app, 0x200); Zero(manager, 0x40); Zero(sound, 4); Zero(vtable, 0x14); Zero(observations, 56);
Marshal.WriteInt32(game, 4, app.ToInt32()); Marshal.WriteInt32(game, 0x2c, manager.ToInt32());
Marshal.WriteInt32(manager, 0x10, IntPtr.Zero.ToInt32());
Marshal.WriteInt32(sound, vtable.ToInt32());
IntPtr codePage = VirtualAlloc(IntPtr.Zero, new UIntPtr(0x1000), MemCommitReserve, PageExecuteReadWrite);
if (codePage == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error());
callStub = codePage;
byte[] soundMethod = BuildSoundMethod(observations, game, app);
Marshal.Copy(soundMethod, 0, callStub, soundMethod.Length);
Marshal.WriteInt32(vtable, 0x10, callStub.ToInt32());
returnStub = IntPtr.Add(codePage, 0x100);
byte[] returnCode = { 0xb8, 0, 0, 0, 0, 0xc3 };
WriteImm32(returnCode, 1, sound.ToInt32()); Marshal.Copy(returnCode, 0, returnStub, returnCode.Length);
IntPtr iat = IntPtr.Add(module, SoundServerIatRva);
originalSoundPointer = Marshal.ReadInt32(iat);
if (!VirtualProtect(iat, new UIntPtr(4), PageExecuteReadWrite, out oldProtect))
throw new InvalidOperationException("VirtualProtect(IAT) failed: " + Marshal.GetLastWin32Error());
Marshal.WriteInt32(iat, returnStub.ToInt32());
iatPatched = true;
uint ignored;
if (!VirtualProtect(iat, new UIntPtr(4), oldProtect, out ignored))
throw new InvalidOperationException("VirtualProtect(IAT restore protection) failed: " + Marshal.GetLastWin32Error());
gameGlobal = IntPtr.Add(module, GameGlobalRva);
originalGameGlobal = Marshal.ReadInt32(gameGlobal);
Marshal.WriteInt32(gameGlobal, game.ToInt32());
gameGlobalPatched = true;
var callback = (HostCallback)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallbackRva), typeof(HostCallback));
RunAllowedFailure(callback, game, app, observations);
RunFailureRepeat(callback, game, app, observations);
RunOppositeCompletion(callback, game, app, observations);
RunCompletionRepeat(callback, game, app, observations);
RunFailureAfterCompletion(callback, game, app, observations);
RunAutoDemoGate(callback, game, app, observations);
RunNonZeroGameMode(callback, game, app, observations);
RunUnknownPayload(callback, game, app, observations);
Console.WriteLine("native callback oracle passed; audio endpoint is an isolated no-op recorder");
return 0;
}
finally { Marshal.FreeHGlobal(observations); }
}
finally {
if (gameGlobalPatched) Marshal.WriteInt32(gameGlobal, originalGameGlobal);
if (iatPatched) {
IntPtr iat = IntPtr.Add(module, SoundServerIatRva); uint restore;
if (!VirtualProtect(iat, new UIntPtr(4), PageExecuteReadWrite, out restore))
throw new InvalidOperationException("VirtualProtect(IAT cleanup) failed: " + Marshal.GetLastWin32Error());
Marshal.WriteInt32(iat, originalSoundPointer);
uint ignored;
if (!VirtualProtect(iat, new UIntPtr(4), restore, out ignored))
throw new InvalidOperationException("VirtualProtect(IAT cleanup restore protection) failed: " + Marshal.GetLastWin32Error());
}
if (callStub != IntPtr.Zero) VirtualFree(callStub, UIntPtr.Zero, 0x8000);
if (vtable != IntPtr.Zero) Marshal.FreeHGlobal(vtable);
if (sound != IntPtr.Zero) Marshal.FreeHGlobal(sound);
if (manager != IntPtr.Zero) Marshal.FreeHGlobal(manager);
if (app != IntPtr.Zero) Marshal.FreeHGlobal(app);
FreeLibrary(module);
if (game != IntPtr.Zero) Marshal.FreeHGlobal(game);
}
}
private static byte[] BuildSoundMethod(IntPtr observations, IntPtr game, IntPtr app) {
var code = new List<byte>();
code.AddRange(new byte[] { 0x50, 0x52, 0x51, 0x56, 0x57 });
code.Add(0xa1); AddImm32(code, observations.ToInt32()); code.Add(0x40); code.Add(0xa3); AddImm32(code, observations.ToInt32());
code.AddRange(new byte[] { 0x8b, 0x74, 0x24, 0x18, 0x8b, 0x06, 0x8b, 0x56, 0x04, 0x2b, 0xd0, 0x89, 0x15 }); AddImm32(code, observations.ToInt32() + 4);
code.AddRange(new byte[] { 0x83, 0xfa, 0x17, 0x76, 0x09, 0xc6, 0x05 }); AddImm32(code, observations.ToInt32() + 48);
code.AddRange(new byte[] { 0x01, 0xeb, 0x0f, 0xbf }); AddImm32(code, observations.ToInt32() + 12);
code.AddRange(new byte[] { 0x8b, 0xca, 0x8b, 0xf0, 0xfc, 0xf3, 0xa4, 0xc6, 0x07, 0x00 });
code.AddRange(new byte[] { 0x8b, 0x44, 0x24, 0x1c, 0xa3 }); AddImm32(code, observations.ToInt32() + 8);
code.Add(0xb8); AddImm32(code, game.ToInt32());
code.AddRange(new byte[] { 0x8b, 0x40, 0x08, 0xa3 }); AddImm32(code, observations.ToInt32() + 40);
code.Add(0xb8); AddImm32(code, app.ToInt32());
code.AddRange(new byte[] { 0x0f, 0xb6, 0x80, 0x57, 0x01, 0x00, 0x00, 0xa3 }); AddImm32(code, observations.ToInt32() + 44);
code.AddRange(new byte[] { 0x5f, 0x5e, 0x59, 0x5a, 0x58, 0xc2, 0x08, 0x00 });
return code.ToArray();
}
private static void RunAllowedFailure(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 0, 5, 0x7f); int result = callback(0, 0, 0);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0, "failure state writes");
Expect(Marshal.ReadInt32(obs, 0) == 1 && Marshal.ReadInt32(obs, 40) == 5 && Marshal.ReadInt32(obs, 44) == 0x7f, "failure sound before writes");
Console.WriteLine("fail: status=1 mission_flag=0 sound={0}", SoundId(obs));
Expect(SoundId(obs) == "VOICE_MISSION_FAIL" && Marshal.ReadInt32(obs, 8) == 0, "failure voice id and argument");
}
private static void RunFailureRepeat(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Marshal.WriteByte(app, 0x157, 0x7f);
int result = callback(0, 0, 0);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 1, "repeat failure preserves mismatched mission flag");
}
private static void RunOppositeCompletion(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
int result = callback(0, 0, 1); Expect(result == 1 && Marshal.ReadInt32(game, 8) == 0 && Marshal.ReadByte(app, 0x157) == 1 && Marshal.ReadInt32(obs, 0) == 2, "completion after failure");
Expect(Marshal.ReadInt32(obs, 40) == 1 && Marshal.ReadInt32(obs, 44) == 0x7f && SoundId(obs) == "VOICE_MISSION_COMPLETE" && Marshal.ReadInt32(obs, 8) == 0, "completion sound before writes");
}
private static void RunCompletionRepeat(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Marshal.WriteByte(app, 0x157, 0x7f);
int result = callback(0, 0, 1);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 0 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 2, "repeat completion preserves mismatched mission flag");
}
private static void RunFailureAfterCompletion(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
int result = callback(0, 0, 0);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0 && Marshal.ReadInt32(obs, 0) == 3, "failure after completion");
Expect(Marshal.ReadInt32(obs, 40) == 0 && Marshal.ReadInt32(obs, 44) == 0x7f && SoundId(obs) == "VOICE_MISSION_FAIL" && Marshal.ReadInt32(obs, 8) == 0, "reverse transition sound before writes");
Console.WriteLine("fail-after-complete: status=1 mission_flag=0 sound={0}", SoundId(obs));
}
private static void RunAutoDemoGate(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 1, 5, 0x7f); callback(0, 0, 0);
Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "AutoDemo gate suppresses callback");
}
private static void RunNonZeroGameMode(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 0, 5, 0x7f); callback(1, 0, 0);
Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "nonzero callback mode suppressed");
}
private static void RunUnknownPayload(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 0, 5, 0x7f); callback(0, 0, 2);
Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "unknown payload suppressed");
}
private static void Setup(IntPtr game, IntPtr app, IntPtr obs, byte gate, int status, byte missionFlag) {
Marshal.WriteByte(game, 0xe5, gate); Marshal.WriteInt32(game, 8, status); Marshal.WriteByte(app, 0x157, missionFlag);
Zero(obs, 56);
}
private static void Expect(bool value, string description) { if (!value) throw new InvalidOperationException("native oracle assertion failed: " + description); }
private static string SoundId(IntPtr observations) {
int length = Marshal.ReadInt32(observations, 4);
if (Marshal.ReadByte(observations, 48) != 0) throw new InvalidOperationException("native sound identifier exceeded recorder capacity");
if (length < 0 || length > 23) throw new InvalidOperationException("unexpected sound identifier length: " + length);
byte[] bytes = new byte[length]; Marshal.Copy(IntPtr.Add(observations, 12), bytes, 0, length);
return Encoding.ASCII.GetString(bytes);
}
private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
private static void AddImm32(List<byte> code, int value) { byte[] bytes = BitConverter.GetBytes(value); code.AddRange(bytes); }
private static void WriteImm32(byte[] bytes, int offset, int value) { byte[] immediate = BitConverter.GetBytes(value); Buffer.BlockCopy(immediate, 0, bytes, offset, 4); }
}
+426 -12
View File
@@ -2,8 +2,8 @@
//! Lossless, bounded reader for compiled AI `.scr` packages. //! Lossless, bounded reader for compiled AI `.scr` packages.
//! //!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It //! This module preserves the layout proven by the GOG `ai.dll` reader. It
//! deliberately does not assign semantics to instruction words or execute //! deliberately does not implement a general bytecode VM. It contains a
//! bytecode: that requires handler-specific evidence. //! narrow, handler-specific resolver for the confirmed `Init` path.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits}; use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc; use std::sync::Arc;
@@ -151,6 +151,35 @@ pub struct Handler19DwordWrite {
pub value: u32, pub value: u32,
} }
/// Result of resolving the first event whose case-sensitive C-string name is
/// `Init` (bytes through the first NUL) with the supported
/// `Handler(19)` action subset.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Handler19InitResolution {
/// No event has the case-sensitive C-string name `Init`; the native lookup
/// makes no call.
Missing,
/// The first `Init\0` event was walked in source order.
Executed {
/// Zero-based event table index selected by the native name lookup.
event_index: usize,
/// Opaque event word retained from the selected record.
event_word: u32,
/// Ordered writes emitted by every supported instruction in the event.
writes: Vec<Handler19DwordWrite>,
},
/// The first unsupported instruction stopped this restricted resolver.
/// Any earlier local writes are discarded because the event did not finish.
Unsupported {
/// Zero-based event table index selected by the native name lookup.
event_index: usize,
/// Zero-based instruction index within that event.
instruction_index: usize,
/// Unsupported action shape.
error: Handler19ResolveError,
},
}
/// Inputs already expressed at the original `Handler(19)` setter ABI boundary. /// Inputs already expressed at the original `Handler(19)` setter ABI boundary.
/// ///
/// The first two words are results of original x87 `__ftol` calls. Keeping /// The first two words are results of original x87 `__ftol` calls. Keeping
@@ -169,6 +198,21 @@ pub struct Handler19InitInput {
/// Error resolving the three-target `Handler(19)` Init contract. /// Error resolving the three-target `Handler(19)` Init contract.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum Handler19ResolveError { pub enum Handler19ResolveError {
/// The selected action is not the recovered `Handler(19)` slot.
UnsupportedSelector {
/// Recovered selector shape.
found: ScriptDispatchSelector,
},
/// Only action instructions with control word `0xffff_ffff` are supported.
UnsupportedControlWord {
/// Opaque control word from instruction header word one.
found: u32,
},
/// Only instructions with no result-save reference are supported.
UnsupportedResultSave {
/// Opaque variable reference from instruction header word two.
found: u32,
},
/// One of the three required references was absent. /// One of the three required references was absent.
MissingReference { MissingReference {
/// Zero-based required target position. /// Zero-based required target position.
@@ -193,6 +237,17 @@ pub enum Handler19ResolveError {
impl std::fmt::Display for Handler19ResolveError { impl std::fmt::Display for Handler19ResolveError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::UnsupportedSelector { found } => {
write!(formatter, "unsupported script selector {found:?} in Init")
}
Self::UnsupportedControlWord { found } => write!(
formatter,
"unsupported Init control word 0x{found:08x}; expected 0xffffffff"
),
Self::UnsupportedResultSave { found } => write!(
formatter,
"unsupported Init result-save reference 0x{found:08x}; expected 0xffffffff"
),
Self::MissingReference { position } => { Self::MissingReference { position } => {
write!(formatter, "Handler(19) is missing reference {position}") write!(formatter, "Handler(19) is missing reference {position}")
} }
@@ -528,6 +583,73 @@ impl VarSet {
]) ])
} }
/// Resolves only the first event whose case-sensitive C-string name is
/// `Init` (bytes through the first NUL), and its supported
/// `Handler(19)` action instructions in source order.
///
/// A missing event is a normal no-write result, matching the native event
/// lookup. Unsupported selector/control/result-save forms stop at their
/// first instruction and return no partial writes. Malformed handler
/// references and declaration types remain errors.
pub fn resolve_handler19_init(
&self,
package: &ScriptPackage,
input: Handler19InitInput,
) -> Result<Handler19InitResolution, Handler19ResolveError> {
let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
}) else {
return Ok(Handler19InitResolution::Missing);
};
let mut writes = Vec::new();
for (instruction_index, instruction) in event.instructions.iter().enumerate() {
let instruction_writes =
match self.resolve_handler19_init_instruction(instruction, input) {
Ok(writes) => writes,
Err(error @ Handler19ResolveError::UnsupportedSelector { .. })
| Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
| Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
return Ok(Handler19InitResolution::Unsupported {
event_index,
instruction_index,
error,
});
}
Err(error) => return Err(error),
};
writes.extend(instruction_writes);
}
Ok(Handler19InitResolution::Executed {
event_index,
event_word: event.event_word,
writes,
})
}
fn resolve_handler19_init_instruction(
&self,
instruction: &ScriptInstruction,
input: Handler19InitInput,
) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> {
let selector = instruction.dispatch_selector();
if selector != ScriptDispatchSelector::Handler(19) {
return Err(Handler19ResolveError::UnsupportedSelector { found: selector });
}
if instruction.header_words[1] != u32::MAX {
return Err(Handler19ResolveError::UnsupportedControlWord {
found: instruction.header_words[1],
});
}
if instruction.header_words[2] != u32::MAX {
return Err(Handler19ResolveError::UnsupportedResultSave {
found: instruction.header_words[2],
});
}
self.resolve_handler19(instruction, input)
}
fn resolve_handler19_target( fn resolve_handler19_target(
&self, &self,
instruction: &ScriptInstruction, instruction: &ScriptInstruction,
@@ -856,8 +978,10 @@ pub struct Handler2Record {
pub key: Handler2RecordKey, pub key: Handler2RecordKey,
/// Resolved slot 1 scalar. /// Resolved slot 1 scalar.
pub scalar_1: f32, pub scalar_1: f32,
/// Initial and per-refresh counter word from resolved slot 2. /// Current counter word stored at original record offset `+0x24`.
pub counter: u32, pub counter: u32,
/// Original record offset `+0x28`: slot 2 captured when the record was created.
increment: u32,
/// Resolved slot 3 word. /// Resolved slot 3 word.
pub word_3: u32, pub word_3: u32,
/// Resolved slot 6 scalar. /// Resolved slot 6 scalar.
@@ -892,8 +1016,8 @@ impl Handler2RecordScheduler {
/// ///
/// The original compares the three identity words bit-for-bit. On an /// The original compares the three identity words bit-for-bit. On an
/// existing key, it refreshes only when `scalar_1` compares unequal; that /// existing key, it refreshes only when `scalar_1` compares unequal; that
/// update replaces `scalar_1` and `scalar_6`, then adds the record's own /// update replaces `scalar_1` and `scalar_6`, then adds the slot-2 word
/// slot-2 counter word with x86 wrapping arithmetic. /// captured when that record was created, with x86 wrapping arithmetic.
pub fn submit(&mut self, input: Handler2RecordInput) -> Handler2RecordUpdate { pub fn submit(&mut self, input: Handler2RecordInput) -> Handler2RecordUpdate {
let key = Handler2RecordKey::from(input); let key = Handler2RecordKey::from(input);
if let Some((index, record)) = self if let Some((index, record)) = self
@@ -905,7 +1029,7 @@ impl Handler2RecordScheduler {
if !handler_two_scalar_equal(record.scalar_1, input.scalar_1) { if !handler_two_scalar_equal(record.scalar_1, input.scalar_1) {
record.scalar_1 = input.scalar_1; record.scalar_1 = input.scalar_1;
record.scalar_6 = input.scalar_6; record.scalar_6 = input.scalar_6;
record.counter = record.counter.wrapping_add(input.word_2); record.counter = record.counter.wrapping_add(record.increment);
return Handler2RecordUpdate { return Handler2RecordUpdate {
index, index,
created: false, created: false,
@@ -923,6 +1047,7 @@ impl Handler2RecordScheduler {
key, key,
scalar_1: input.scalar_1, scalar_1: input.scalar_1,
counter: input.word_2, counter: input.word_2,
increment: input.word_2,
word_3: input.word_3, word_3: input.word_3,
scalar_6: input.scalar_6, scalar_6: input.scalar_6,
}); });
@@ -1193,10 +1318,11 @@ fn read_instruction(
mod tests { mod tests {
use super::{ use super::{
decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload, decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload,
Handler19DwordWrite, Handler19InitInput, Handler19ResolveError, Handler2RecordInput, Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, Handler8ResolveError, Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset,
ScriptDispatchSelector, ScriptInstruction, VarSetDefault, VarSetError, VarSetType, Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction,
VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand,
GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
}; };
use fparkan_binary::{DecodeError, Limits}; use fparkan_binary::{DecodeError, Limits};
@@ -1254,12 +1380,20 @@ mod tests {
refreshed: true, refreshed: true,
} }
); );
assert_eq!(scheduler.records()[0].counter, 103); assert_eq!(scheduler.records()[0].counter, 8);
assert_eq!( assert_eq!(
scheduler.records()[0].scalar_6.to_bits(), scheduler.records()[0].scalar_6.to_bits(),
12.0_f32.to_bits() 12.0_f32.to_bits()
); );
let refreshed_again = handler_two_input(3.5, -0.0, 3.0, 13.0, 7);
assert!(scheduler.submit(refreshed_again).refreshed);
assert_eq!(scheduler.records()[0].counter, 12);
assert_eq!(
scheduler.records()[0].scalar_6.to_bits(),
13.0_f32.to_bits()
);
let positive_zero_key = handler_two_input(2.5, 0.0, 3.0, 12.0, 1); let positive_zero_key = handler_two_input(2.5, 0.0, 3.0, 12.0, 1);
assert_eq!(scheduler.submit(positive_zero_key).index, 1); assert_eq!(scheduler.submit(positive_zero_key).index, 1);
assert_eq!(scheduler.records().len(), 2); assert_eq!(scheduler.records().len(), 2);
@@ -1272,8 +1406,13 @@ mod tests {
let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 4.0, 2)); let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 4.0, 2));
assert_eq!(update.index, 0); assert_eq!(update.index, 0);
assert!(update.refreshed); assert!(update.refreshed);
assert_eq!(scheduler.records()[0].counter, 1); assert_eq!(scheduler.records()[0].counter, u32::MAX - 1);
assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 4.0_f32.to_bits()); assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 4.0_f32.to_bits());
let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 5.0, 17));
assert!(update.refreshed);
assert_eq!(scheduler.records()[0].counter, u32::MAX - 2);
assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 5.0_f32.to_bits());
} }
#[test] #[test]
@@ -1544,6 +1683,281 @@ STRING( 8, ignored, ignored, ignored)\r\n";
); );
} }
#[test]
fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let action = |references: Vec<u32>| ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references,
};
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![
ScriptEvent {
name_len: 11,
name_raw: b"Init\0suffix\0".to_vec(),
event_word: 0x1234,
instructions: vec![action(vec![0, 1, 2]), action(vec![1, 0, 2])],
},
ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x5678,
instructions: vec![ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
}],
},
],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
let result = varset
.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
)
.expect("valid first Init event");
assert_eq!(
result,
Handler19InitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: vec![
Handler19DwordWrite { index: 0, value: 7 },
Handler19DwordWrite { index: 1, value: 8 },
Handler19DwordWrite { index: 2, value: 9 },
Handler19DwordWrite { index: 1, value: 7 },
Handler19DwordWrite { index: 0, value: 8 },
Handler19DwordWrite { index: 2, value: 9 },
],
}
);
}
#[test]
fn handler_nineteen_init_missing_exact_name_has_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![
ScriptEvent {
name_len: 4,
name_raw: b"init\0".to_vec(),
event_word: 1,
instructions: Vec::new(),
},
ScriptEvent {
name_len: 4,
name_raw: b"INIT\0".to_vec(),
event_word: 2,
instructions: Vec::new(),
},
],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Missing)
);
}
#[test]
fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![
ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x1234,
instructions: Vec::new(),
},
ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x5678,
instructions: vec![ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
}],
},
],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: Vec::new(),
})
);
}
#[test]
fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
instructions: vec![
ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
},
ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
},
ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
},
],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 1,
error: Handler19ResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
);
}
#[test]
fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
instructions: vec![
ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
},
ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
},
],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
error: Handler19ResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
);
}
#[test]
fn handler_nineteen_init_types_unsupported_control_and_result_save() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let input = Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
};
let instruction = |control_word, result_word| ScriptInstruction {
header_words: [19, control_word, result_word, 0, 0, 3, 0],
references: vec![0, 1, 2],
};
let resolve = |instruction| {
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
instructions: vec![instruction],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
varset.resolve_handler19_init(&package, input)
};
assert_eq!(
resolve(instruction(0, u32::MAX)),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
error: Handler19ResolveError::UnsupportedControlWord { found: 0 },
})
);
assert_eq!(
resolve(instruction(u32::MAX, 2)),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
error: Handler19ResolveError::UnsupportedResultSave { found: 2 },
})
);
}
#[test] #[test]
fn handler_fifteen_resolves_exact_typed_target_by_place_operands() { fn handler_fifteen_resolves_exact_typed_target_by_place_operands() {
let varset = parse_varset( let varset = parse_varset(
@@ -0,0 +1,87 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class Handler2NativeOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const int RefreshRecordRva = 0x5070;
private const string ExpectedSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeLibrary(IntPtr module);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate void RefreshRecord(IntPtr record);
private static int Main() {
if (IntPtr.Size != 4) {
Console.Error.WriteLine("Build this oracle with an x86 target; ai.dll is 32-bit.");
return 2;
}
string dllPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
if (String.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) {
Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL to the original GOG ai.dll.");
return 3;
}
string actualSha256;
using (SHA256 sha256 = SHA256.Create())
using (FileStream input = File.OpenRead(dllPath)) {
byte[] digest = sha256.ComputeHash(input);
StringBuilder hex = new StringBuilder(digest.Length * 2);
foreach (byte value in digest) hex.Append(value.ToString("x2"));
actualSha256 = hex.ToString();
}
if (actualSha256 != ExpectedSha256) {
Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + actualSha256);
return 4;
}
IntPtr module = LoadLibraryEx(dllPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) {
Console.Error.WriteLine("LoadLibraryEx failed: " + Marshal.GetLastWin32Error());
return 5;
}
try {
var refresh = (RefreshRecord)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, RefreshRecordRva), typeof(RefreshRecord));
IntPtr record = Marshal.AllocHGlobal(100);
try {
for (int offset = 0; offset < 100; offset += 4) Marshal.WriteInt32(record, offset, 0);
Marshal.WriteInt32(record, 0x24, 4);
Marshal.WriteInt32(record, 0x28, 4);
AssertRefresh(refresh, record, 8);
AssertRefresh(refresh, record, 12);
Marshal.WriteInt32(record, 0x24, unchecked((int)UInt32.MaxValue));
Marshal.WriteInt32(record, 0x28, unchecked((int)UInt32.MaxValue));
AssertRefresh(refresh, record, UInt32.MaxValue - 1);
AssertRefresh(refresh, record, UInt32.MaxValue - 2);
}
finally { Marshal.FreeHGlobal(record); }
}
finally { FreeLibrary(module); }
return 0;
}
private static void AssertRefresh(RefreshRecord refresh, IntPtr record, uint expectedCounter) {
uint savedIncrement = unchecked((uint)Marshal.ReadInt32(record, 0x28));
refresh(record);
uint actualCounter = unchecked((uint)Marshal.ReadInt32(record, 0x24));
uint actualIncrement = unchecked((uint)Marshal.ReadInt32(record, 0x28));
Console.WriteLine("record[+0x24]=0x{0:X8} record[+0x28]=0x{1:X8}",
actualCounter, actualIncrement);
if (actualCounter != expectedCounter || actualIncrement != savedIncrement) {
throw new InvalidOperationException(String.Format(
"Expected +0x24=0x{0:X8}, +0x28 unchanged at 0x{1:X8}.",
expectedCounter, savedIncrement));
}
}
}
+537 -4
View File
@@ -31,9 +31,206 @@ primitives, sound events и reads в `Effect.dll`.
checks. Полная instruction grammar `.scr`, semantics opcodes и serialization checks. Полная instruction grammar `.scr`, semantics opcodes и serialization
state ещё не восстановлены. state ещё не восстановлены.
Для GOG `ai.dll` статически восстановлены пять ветвей evaluator-а 16-byte
записей условий по RVA `0x5180`, включая проверки типов, поиск объектов,
короткие выходы из обхода списков и запись байта завершения. Однако не найден
код-производитель этого массива и контракт его владельца с runtime. Reader
`FUN_10011b20` хранит описатели событий и вложенных записей `.scr` во внутренних
контейнерах с шагом `0x14` и `0x20`; сами дисковые структуры имеют переменную
длину из-за length-prefixed имён и списков ссылок. Связь этих контейнеров с
массивом evaluator-а не доказана. Виртуальные методы,
через которые проходят проверки, остаются внешней границей, а сам evaluator
не сравнивался с работающим оригиналом. Описание этих ветвей — статический
вывод по одному известному `ai.dll`, а не утверждение о совпадении во время
исполнения.
Для начального сценарного события закрыт более узкий путь. В проверенной GOG
сборке `ai.dll` SHA-256
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` функции
`FUN_10011f40` (RVA `0x11f40`) и `FUN_10011f10` (RVA `0x11f10`) выбирают первое
имя-событие C-string `Init` и запускают его инструкции по порядку. Для координат
из TMA найден и проверен ещё один участок: `iron3d.dll` SHA-256
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`,
`FUN_100392e0` (RVA `0x392e0`) выполняет x87 `FISTP qword` и передаёт младшие
DWORD координат пятым и шестым аргументами `CreateSuperAI`. Затем
`ai.dll!FUN_10005c70` (RVA `0x5c70`) преобразует DWORD в `float`, а `Handler(19)`
возвращает его к DWORD усечением.
Две x86-проверки вызвали эти оригинальные DLL-функции на изолированной
синтетической памяти. Caller oracle подтвердил AutoDemo-координаты
`(500.2857, 751.8217) -> (500, 752)`, ties-to-even в nearest-режиме и переход
`-0.6 -> 0xffffffff`. AI oracle проверил оба набора AutoDemo anchor-ов,
дублирующий `Init`, имя `Init\\0suffix\\0`, отсутствие события, а также
нативную связку `FUN_10005c70` → `Handler(19)`: DWORD `(0xffffffff, 10001)` стал
полями `(2^32, 10001.0)` и varset-записями `(0, 10001, 2)`. Эти проверки
подтверждают только изолированные участки ABI.
В одном read-only snapshot запущенной GOG AutoDemo были связаны записи кланов
Iron3D и указатели `GetSuperAI` из `ai.dll` (таблица RVA `0x55398`). Клан 0 имел
исходные float-координаты `(500.2857, 751.8217)` и поля AI
`(+0x80, +0x84, +0x7c) = (500.0, 752.0, 0)`; клан 1 имел `(728.0, 449.0)` и
`(728.0, 449.0, 1)`. Первый набор связывает те же значения в одном процессе и
выбирает ближайшее округление среди направлений округления x87. Синтетические
tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют
control word процесса.
Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview
лицензированной кампании. AutoDemo даёт raw TMA anchor-ы
`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
Rust.
Нативный конструктор показывает предупреждение при координате выше `10000`, но
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и
значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает
только action-форму `Handler(19)` (`control word` и `result reference` равны
`0xffffffff`). Неизвестная инструкция даёт `Unsupported` в preview, не применяя
накопленные частичные записи; при полной загрузке это ошибка. Это ограничение
Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и
типы остаются ошибками в обоих режимах.
Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
`0x39440`). Эта функция передаёт AI callback-у пару явных аргументов через
vtable slot `+0x14`. В `ai.dll!FUN_10001ae0` (RVA `0x1ae0`) аргумент callback-а
записывается в `AI+0x3fc`; затем объект `AI+0x438` получает вызов Behavior
vslot `+0x2c`. В проверенной `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
`FUN_1001adb0` (RVA `0x1adb0`) выполняет начальную подготовку расписания.
Метод ищет первый `Init` только при нулевом latch
`AI+0x8a8`, а при dispatch временно ставит и снимает recursion guard
`AI+0x78`. В этой ветви поэтому известны callback ingress и порядок
warm-start → `Init`, но смысл аргумента callback-а здесь не сопоставлен с Rust
`MissionMode`.
Изолированный x86 oracle вызывает исходные `iron3d.dll!FUN_10039440`,
`ai.dll!FUN_10001ae0` и script dispatcher на синтетических записях и проверяет
режим, guard, latch, Handler(19) writes и повторный вызов. Отдельный oracle
исполняет `Behavior.dll!FUN_1001adb0` с искусственно заданной очередью, сверяя
срок, шаг LCG и очистку полей. Это подтверждает ABI и локальные переходы исходных
функций, но не доказывает, что полная штатная загрузка выбранной миссии в
оригинальном процессе доходит до этой ветви в том же порядке. Метод
`FUN_10001ae0` оставляет latch `+0x8a8` сброшенным, когда `Init` не найдено;
если оно найдено при уже занятом guard `+0x78`, dispatch пропускается, но latch
всё равно устанавливается. Rust пока запускает обработанный поднабор при каждой
полной загрузке и не моделирует аргумент callback-а `AI+0x3fc`, guard `+0x78` или
latch `+0x8a8`.
Rust `load_mission` принимает явный `queue_time_ms`, а не читает оригинальное
время очереди. Новый `Engine` начинает локальный Behavior RNG с seed `1` и
учитывает шаги генератора этой стартовой последовательности; не восстановлены
другие потребители исходного генератора, поэтому равенство полной случайной
последовательности не утверждается. Начальная фаза выполняется после регистрации
объектов в создаваемом мире Rust;
это интеграционный выбор runtime, а не подтверждённая эквивалентность всех
host construction phases. Не проверена другая лицензированная миссия с
непустым `Init`. Вызов `Behavior.dll!FUN_10019e80` (RVA `0x19e80`, SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) исполнялся
на синтетических интерфейсах. Путь членства в реальных distributor-списках
связан с полями TMA и Unit DAT: `World3D!CreateObject` передаёт selector
и class word через дескриптор в Behavior (RVA `0x7e55`, `0x7e62`), а
`Behavior.dll!FUN_10005e60` (RVA `0x5e60`) проверяет высокий бит registration
key, AI и distributor. Selector из первого DWORD после имени ресурса выбирает
слот `GetSuperAI`; таблица этой функции находится в `ai.dll` RVA `0x55398`.
Хеши `MisLoad.dll`, `iron3d.dll`, `ArealMap.dll`, `World3D.dll` и
`Behavior.dll`, использованных для цепочки, перечислены в
[справочнике TMA](../reference/tma.md).
Отдельный режимный бит Behavior нельзя выводить из `MissionMode`. Конструктор
Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode` (RVA
`0x14750`), который читает host-global по RVA `0x795130` в `World3D.dll` (SHA-256
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`). В
обычном пути `stdInitGame` (RVA `0x13e90`, запись режима около `0x14040`)
формируется отдельное host-значение; в наблюдавшемся запуске AutoDemo getter
вернул `4`, и бит `0x2` был снят. Это объясняет соответствующий gate при
создании Behavior, но не устанавливает отображение между host-режимом и Rust
`MissionMode`.
Наблюдение обычного запуска лицензированной GOG AutoDemo подтвердило четыре
добавления с ключами `0x80000003..0x80000006` в два клановых списка. Полная
загрузка Rust сохраняет соответствующие `ObjectHandle` в точном доступном слоте
`GetSuperAI` до стартового `Init`; preview не создаёт членств. Лицензированный
тест связывает эти записи с TMA ID 4–7, selector и Unit DAT class. Это
подтверждает только состав списков, не точную эквивалентность всей загрузки или
регулярного шага Behavior.
Нативный `Behavior.dll!Add` (RVA `0x1aac0`) и `normalize` (RVA `0x1ac90`)
проверены прямыми x86-вызовами на синтетических записях при x87 control word
`0x007f`, измеренном также у игры на входах обоих методов. Rust повторяет
начальные доли и нормализацию отдельными операциями `f32`, а повторную запись
сопоставляет по `ObjectHandle`, меняя только долю существующего участника.
Пять DWORD после доли хранятся сырыми; ненулевое первое слово закрепляет вес,
смысл четырёх следующих не установлен. Лицензированный AutoDemo-тест проверяет
список TMA-объектов и получившиеся начальные доли по кланам, но это не сравнение
живых весов миссии с полями исходных runtime-записей. Не восстановлено соединение
с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick
flow и общий dispatcher событий также остаются открытыми.
Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от
регулярного обмена. В проверенных `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA
`0x8790` выбирает начальные значения по class word, `0xb110` регистрирует
дескрипторы кодов 4/5, `0xb470` пишет только код 5, а `0xb5b0` читает код 4
как дополнительное слово и код 5 как основное. `MisLoad.dll` (SHA-256
`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`) RVA
`0x40b0` выбирает первое свойство TMA с точным именем `CurrentOre` и передаёт
его `raw_value[1]`; setter сохраняет биты напрямую. В AutoDemo snapshot на этих
входах было восемь объектов Unit DAT, у всех raw `CurrentOre` равен нулю;
четыре объекта класса `0x01008000` не вошли в distributor, но остались среди
инициализированных Rust-состояний. Поэтому путь нулевого значения наблюдался
наживую, но ненулевое TMA-значение в живой миссии пока не проверено.
Синтетический x86 oracle вызвал `Behavior.dll!FUN_10008790` напрямую для классов
`0x80000004`, `0x80000008`, `0x01002000`, `0x01004000` и `0x01008000`; перед
вызовом в записи были значения `CurrentOre=123` и `MaximumOre=-1`, так что
проверено их перезаписывание. Тест выставляет `a64=1`, чтобы отключить отложенный
side effect обёртки setter-а. Это подтверждает ветви initializer-а, но не весь
путь его запуска и связи с остальными фазами объекта. Другой изолированный x86
oracle проверяет ненулевое raw-слово `CurrentOre`, только чтение `MaximumOre` и
перестановку строк дескрипторов на синтетических записях свойств.
Не проверен селектор 4 для внешнего config-like ресурса: в наблюдавшемся
AutoDemo его вызова нет, а установленный `Behavior.ini` не содержит
`Mine_MaxOre`, `Storage_MaxOre` или `Transport_MaxOre`. Поэтому Rust пока
воспроизводит доказанные class defaults (500, 4000 и 2000) и не заявляет
поддержку конфигурационного переопределения. Отдельно не доказано, что каждый
Unit DAT создаёт тот же Behavior-компонент во всех host factory paths. Rust
ограничивает новую запись полными TMA-объектами с Unit DAT; preview, прямые
prototype roots без Unit DAT и отложенные spawn после исходной загрузки вне
этой модели. Путь в `ArealMap.dll` (SHA-256
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`) к
`CreateObject` по RVA `0x14fd0`/`0x15290` показывает выбор World3D factory
selector `3` или `4` по старшему биту. Для ветки GOG `AniMesh.dll` (SHA-256
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) RVA
`0x2ff0` показывает одну ветку создания: после QueryInterface для IID `0x204`
выполняется отдельный импорт `GetQueue()`, а на возвращённом объекте вызывается
метод через vtable slot `+0x30`. `CreateBehaviour` вызывается только при
ненулевом результате этого метода и host class `3` или `4`; class `4` получает
адреса полей prototype `+0x140` и `+0x160`. Семантика метода slot `+0x30` не
установлена. Другие factory paths, нулевой результат и spawn после исходной
загрузки остаются за границами подтверждённой ветки.
Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA
`0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`),
начальные поля IGame и проверки режима/состояния. Обработчик выбора миссии
выставляет режим 1 (RVA `0x29142`), а обработчик таймера — режим 3 (RVA `0x70f3`).
Небольшая тестовая x86-программа сравнивает этот обратный вызов с исходной DLL
на синтетических структурах. Пока не восстановлен сквозной путь от выбора и
инициализации миссии к первому событию Handler(30): Rust загружает миссию, но
ещё не выполняет штатный `stdInitGame`, переход состояния `5 -> 4`, запуск и
игровой цикл с полной диспетчеризацией событий. Поэтому
совпадение изолированного обратного вызова не доказывает совместимость живого
сценария.
Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
table с instruction sizes, построить disassembler и сравнить выполнение table с instruction sizes, построить disassembler и сравнить выполнение
коротких scripts с оригиналом. коротких scripts с оригиналом. Для evaluator-а условий отдельно проследить
создание записей `this + 0x34`, выполнить те же входные данные в оригинале и
сверить байт завершения, порядок поиска, ранние выходы и граничный случай
`payload == count`.
## Saves and campaign state ## Saves and campaign state
@@ -50,9 +247,345 @@ CTLD и связанные resources структурно читаются, coun
известны. Не названы все секции, shape types, coefficients и точный contact известны. Не названы все секции, shape types, coefficients и точный contact
solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags. solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags.
Закрывающий эксперимент: трассировать `LoadControlSystem`, ### Класс Control при загрузке Unit DAT
`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать
каждый изменяемый field с созданным shape, contact или реакцией на движение. Для двух подтверждённых полных Unit-DAT путей Rust теперь сохраняет класс,
который передаётся конструктору Control, вместе с ветвью-источником. Лицензированный
`min_l_n1.dat` проходит high-bit route и даёт класс `3`; synthetic-тесты покрывают
оба маршрута, известные FourCC и отсутствующие root metadata. Отдельное
исполнение проверило только чистый getter `Terrain.dll+0x33ae0` (SHA-256 и точный
RVA приведены в технической главе). Остальная цепочка через ArealMap, World3D,
Terrain/AniMesh и создание controller восстановлена по статическим инструкциям,
таблицам и конфигурации. Она не означает исполнения оригинального полного
loader-а, Control activation, движения или периодического tick.
Не моделируются произвольный ненулевой host с неудачным QueryInterface, preview
без полного Unit DAT, отложенный spawn и другие host/factory routes. В частности,
generic failed-query путь native оставляет class `0` и не читает FourCC; это не
эквивалентно canonical no-host lookup корневой registry entry. Остаточные вопросы
об активации и игровом поведении описаны в технической главе.
Одна цепочка Unit DAT → AniMesh descriptor → Control теперь восстановлена
статически в [главе о Behavior и Control](../tomes/06-behavior.md): raw
`parent_or_link` копируется в descriptor. В non-`EXTO` registry path parent ID
разрешается в индекс устройства и сдвигает локальный индекс только когда parent
ID не равен `-1` и IID `0x202` доступен; иначе используется raw локальный индекс.
В этом потоке нулевой raw kind пропускает узел вместе с потомками, поэтому общий
assigned ID нельзя получать из preorder-позиции записи. Полная загрузка миссии
в FParkan строит упорядоченную базовую проекцию таблицы
Control для объектов с Unit DAT: корневые записи добавляются, `EXTO` добавляет
строки с ключами глобальной MSH-иерархии, а `INTO` обновляет первую строку
выбранного индекса либо добавляет список при sentinel `-1`. Проверка полной
загрузки на оригинальном `min_l_n1.dat` получает 11 строк: 10 корневых записей,
четыре замены `INTO` и одну добавленную строку дочернего `EXTO`. Ветви C4/C8 и
добавление `EXTO` подтверждены машинными инструкциями оригинальных модулей, но
полный результат FParkan не сравнивался с таблицей из запущенной игры. Тест на
искусственном дереве также проверяет ID после пропущенного поддерева с raw kind
`0`. Проекция хранит общие поля и происхождение записей; она не исполняет все
обработчики Control.
Control различает добавление устройства и применение одной записи к уже
выбранному устройству. Исполнение оригинальной 32-битной DLL на искусственных
данных подтверждает общие поля переопределения для получателей tags `2`, `3`,
`4`, `5`, `8`, `9`, `10`, `15`, `19`, `21`, `25` и `27`: остаются прежними слот,
ключ, ID компонента, множитель и класс, а коэффициенты копируются из новой
записи. Обёртка tag `2` по RVA `0x29650` дополнительно пересчитывает два кэша
и обнуляет массив состояния map-строк; это выполнено на native DLL, но пока
не моделируется в базовой Rust-проекции. Её callback `0x29940` проверен только
с неизменной пустой парой ресурсов; стрельба не проверена.
Искусственный tag `9`-объект создан
без FX-хоста (`+0x3c == 0`), а запись не содержит строк таблицы map; поэтому
работа FX-хоста здесь не проверялась. В Rust parser-е
`Unsupported { typed_record_index: Some(_) }` означает, что разбор остановился
внутри списка typed records; без индекса остановки unsupported может относиться
к effect-tail после уже разобранного списка. Эти случаи нужно различать.
Ветка default native-фабрики вызывает конструктор по RVA `0x20800`, поэтому
неизвестный Rust parser-у tag не доказывает отказ оригинального loader-а. Подготовка assets
сейчас пропускает Control refs у non-geometric prototype, поэтому отсутствие
подготовленного CTL должно оставаться явным unavailable result, а не
трактоваться как пустой список.
Global Control getters `1/2` и setter `1` проверены в native x86 oracle для
искусственных factory/descriptor pairs `9`, `15` и `19`, а в Rust реализованы
для текущего descriptor tag `19` с factory tags `9`, `15`, `19` и `25`. Для tag
`25` общий getter slot `+0x10` подтверждён чтением vtable, но агрегатный вызов
этого класса native oracle не запускал. Rust API обращается к строкам проекции
конкретного живого `ObjectHandle`; это запрос к уже загруженной таблице, а не
исполнение Control или Behavior tick. Setter `17`, остальные factory tags и
внешние эффекты их методов не включены. Для tag `9` selector `0` использует
отдельный scale `+0x11c`; остальные поддержанные классы используют коэффициент
напрямую. Пустой набор и нулевой знаменатель остаются native no-result, а
неподдержанный factory tag и расчётный диапазон произведения/отношения за
нормальным диапазоном `f32` становятся явной ошибкой Rust.
Проекция полного mission load недоступна для неполного списка typed records,
Control resource у non-geometric prototype и некоторых неразрешённых EXTO node
mappings; preview и объекты без Unit DAT root её не строят. Её соответствие
полной загрузке игры для всех типов компонентов и factory paths ещё не
установлено. Полный Rust load AutoDemo проверяет доступность всех восьми таблиц
и числа строк `14, 12, 15, 13, 12, 12, 11, 11`; это не сравнение с живой
таблицей оригинала. Setter `1` меняет multiplier `+0x4c` выбранных строк, но расписание
и lifetime этих объектов после конструктора и в отложенной загрузке остаются
непроверенными.
Для `Control.dll` (SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) tag-26
getter selector `0x300` восстановлен локально: RVA `0x21d00` читает provider по
ключу `fixed_words[1]`, затем умножает provider, CTLD coefficient `fixed_words[11]`
и поле объекта `+0x4c` именно в таком порядке. Provider getter RVA `0xdc40`
читает type-1 поле `+0x14` строки, которую `FUN_10008b10` (RVA `0x8b10`)
добавляет из NDP в таблицу с шагом `0x2c`; `FUN_1000b240` (RVA `0xb240`)
инициализирует это поле как `1.0`. Начальный Rust snapshot повторяет порядок
NDP строк корневого ресурса и примонтированных `EXTO` (без первой строки
последнего), сохраняя исходные таблицу и строку как provenance. Ключ устройства
сначала меняется по подтверждённому MSH mapping для `EXTO`, затем это готовое
значение применяется напрямую как индекс provider sequence. Длина этой
sequence определяется NDP: она не дополняется по числу узлов модели и ключ не
пересчитывается ещё раз по числу provider rows. `INTO` provider строк не
добавляет. Для примонтированного `EXTO` с пустым NDP невозможно безопасно
применить native skip-first путь, поэтому Rust явно сообщает unavailable;
таблица из одной строки допустима и даёт нулевой вклад. Если обязательный NDP
sidecar недоступен, запрос `0xe00` сообщает это отдельно, оставляя независимые
агрегатные selectors `1/2` доступными.
Поля `parent_index`, Control flags и начальный stage count для NDP-строк
проецируются из AniMesh по глобальному ordinal; точные правила, native ABI
векторы и лицензированная root/`EXTO` fixture описаны в
[техническом подразделе](script-vm.md#начальная-msh-часть-строки-provider).
MSH-часть проверки подтверждает начальный снимок: сборка runtime parent-ов
остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие
изменения в полном Control/animation tick-е не проверялись.
Для provider-строки Rust хранит amount `+0x08`, накопитель `+0x10`, обработанное
количество `+0x0c`, долю `+0x14`, стадию `+0x18` и flags `+0x28` раздельно.
Инициализатор `Control.dll+0xf940` кладёт результат selector `3` в текущее и
обработанное количество; при подтверждённых единичных множителях это raw
`max_volume` NDP. Таблица также хранит capacity aggregate `+0x58c`, текущий
aggregate `+0x590`, dirty `+0x598` и начальный gate `+0x618`.
Прямой API `apply_loaded_mission_control_node_delta` сохраняет native четвёртый
аргумент `0`: меняет amount, долю, флаг истощения, aggregate и dirty, оставляя
`+0x10` нетронутым. Публикация `0x200` отражается в CPU-проекции соответствующего
узла модели по правилу исходного обработчика; стадия и соседние узлы не меняются.
Обработанное количество при этой прямой операции остаётся прежним.
API `distribute_loaded_mission_control_provider_amount` переносит
`Control+0x10ba0`: proportional mode делит добавку по свободной ёмкости, а
вычитание распределяет по текущим количествам; sequential заполняет строки
вперёд, снимает с конца и использует исходный запрос даже если общая ёмкость
округлилась вниз. Row updater вызывается с четвёртым аргументом `1`, поэтому
`+0x10` получает фактическую разницу amount. После распределения обязателен
`0x11130(root, true)`.
Три API работают только с CPU-проекцией и не подключены к GPU/window path или
игровому Control clock. Отдельный
`advance_loaded_mission_control_budget` переносит `0x2d340` для фабричных
callbacks `9`, `15`, `19`, `26` и общей default-пары; category lookup использует
текущий descriptor tag из подтверждённого префикса `0..=34`. Ставки сохраняются
в `+0x5dc/+0x5e0`. Шаг выполняется транзакционно, а неподдержанный
специализированный callback, provider или диапазон числа возвращает ошибку без частичного
изменения. Тест с нулевым знаменателем генератора подтверждает откат после уже
выполненного изменения потребителем. Пустая таблица всё равно записывает в оба поля отрицательный ноль;
неактивный callback не требует provider lookup. Class-9 constructor начинает
шесть значений `+0x98..+0xac` с `1.0` и шесть дельт `+0xb0..+0xc4` с нуля;
`INTO` сохраняет эти runtime-массивы. Gate `+0x618` изначально
выключен; событие `7` задаёт его по правилу `gate = (arg == 2)` и вызывает
`0x2dc20`, только если gate изменился. События Control в Rust пока не
исполняются, поэтому ресурсный шаг вызывается явно и не подключён к часам.
Capacity aggregate выведен при подтверждённых единичных множителях Unit DAT;
изменение масштабов `0x9ee0`/`0x9f90` не реализовано. Из-за округления `+0x590`
может стать больше сохранённого `+0x58c`; распределение сохраняет этот signed
capacity по native формуле, не вводя отдельного запрета. Если итоговый stage walk
нуждается в неизвестном состоянии, depletion lifecycle, FX или host callback,
распределение сообщает ошибку и не публикует ни одной частичной записи.
Отдельный
`update_loaded_mission_control_node_stage` реализует наследование parent ratio
и stage, ceiling-арифметику, CPU visible stage и копию processed amount.
Отдельная `propagate_loaded_mission_control_node_stage` переносит `0x11130`:
после обновления строки она сканирует более поздние provider-строки и обходит
найденные поддеревья в native depth-first порядке. Для родителей
`[-1, 0, 0, 1, 3]` порядок — `1, 3, 4, 2`. Переносит унаследованный Control
ratio/stage, флаги `0x80/0x100` и drain количества; маска `0x140` пропускает
только перезапись flags, не drain и рекурсию. Ухудшение определяется ростом
стадии либо флагом истощения `0x10` уже обновлённого родителя. Отдельный
`update_loaded_mission_control_node_stage` сохраняет одиночную семантику
`0x11220`.
Пропагация клонирует Control provider-таблицу и публикует её только после
успешного обхода. Неизвестная parent-связь в просматриваемом суффиксе,
lifecycle/visibility/FX callback или неподдержанное численное значение дают
ошибку без частичных изменений. `update_effects = true` отклоняется, если
стадия любого обработанного узла меняется и требуются неисполненные FX/host
callbacks. Licensed двухстадийный узел шахты подтверждает переход `0 → 1` через
новый API при доле `0.5`, без FX и window path. Гибель root/critical узла и
неизвестные flags/aggregate также остаются явными ошибками.
Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
factory paths остаются явными границами. Запрос читает текущее type-1 значение.
Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и Behavior `0x1002` с
`1.0` до `0.5` после явного уменьшения количества вдвое; чтение получает
текущее значение provider, который также меняет явный бюджетный шаг.
Native oracle проверил изолированные пути распределения, обновления класса `4`,
стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` на
interleaved parents `[-1, 0, 0, 1, 3]` с callback-порядком `1, 3, 4, 2` и
visibility transition `0x11920` с исходными AniMesh callbacks. Отдельный
бюджетный вектор исполняет `0x2d340` → `0x2dca0` → tag 15 `0x22bb0` →
`0x10ba0`: подтверждены коэффициенты `0.5` и `1`, точные amount/ratio и
приоритет категории перед порядком устройств. Положительный вклад генератора
в этом векторе подставлен mock-ом. Отдельный вектор с исходным генератором
tag `19` (`0x22980`, `0x229a0`, `0x22a60`) проверяет полную и ограниченную
rate, изменение provider и собственного множителя генератора; отрицательные
параметры и нулевой знаменатель не проверены. Новый оригинальный вектор
`0x2d340` смешивает tag `19` и tag `26`: supply `0.125` покрывает половину
demand `0.25`, итоговые множители равны `0.6875` и `0.5`, а выходы —
`0x80000000` и `0x3e800000`. Тот же oracle подтверждает tag-26 selector `0x500`,
общий reader `0x21d50` и сохранение поля `+0xa4` при `INTO`. Rust-тест
повторяет бюджетную пару и разрешает tag `26` в projection INTO guard, оставляя
теги `1`, `17` и `30` явными границами. Native-векторы бюджетного шага
подтверждают текущие ставки `+0x5dc/+0x5e0`, включая значения `0.2`/`0.4` у
потребителя tag 15, пустой результат `-0.0` и невырожденную пару исходных
устройств tag 19/tag 15 при `dt = 0.375`; Rust-тест повторяет поля этой пары
на синтетической таблице. Rust переносит class-9 rate/update и шестизначную
adjustment `0x25a90`, tag-26 rate/update и setter selector `0x500`, общую
default callback-пару `0x21860/0x21890`, а также category map только для current
descriptor tags `0..=34`. Остальные ещё не описанные специализированные
callbacks остаются явными ошибками.
Лицензированный `min_l_n1.dat` содержит исходные factory/current tags
`[19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21]`. Публичный API оставляет всю
таблицу без изменений при не конечном интервале. Для конечного интервала
ignored Rust-тест проходит budget по всем 11 строкам без фильтра. При `dt = 0.5`
обе output rates равны `0x3c23d70a`, первые два множителя tag `19` равны
`0x3f7fd70a`, остальные множители остаются `1.0`, а добавка tag `26` остаётся
нулевой. Эта проверка подтверждает только CPU-проекцию реальной таблицы; она
не запускает native полный mission tick и не подключает Control clock.
Успешные бюджетные native-векторы используют синтетические
NDP/Node38-строки и подтверждают локальные переходы и порядок вызовов, а не
полный Control tick на миссии. Прямые oracle-вызовы `0x11220` для одиночной
строки используют `updateEffects = false`, что исключает ветвь Control FX и
`0x11af0`; в отдельном сценарии класса `9` `0xfca0` возвращается сразу после
смены видимости, поэтому связанное с ним движение не проверено. Хотя у строки
Control выставлен flag `0x102`, слово флагов ANI `+0x14` и производные байты
`+0x111..+0x113` изначально равны нулю. Вызовы AniMesh `0x93c0` и `0xa2a0` обновляют производную геометрию и
видимость, это отдельная непроверенная граница, а не FX callbacks. Распределение
и бюджетный шаг вызывают обязательный `0x11130(root, true)`, но их проверенные
векторы имеют одну строку без смены стадии; они не доказывают исполнение FX или
`0x11af0`. Бюджетная
часть активного `0xbcf0` исполнена на синтетическом хосте класса `10`:
при периоде `500` и нулевом RNG проверены пропуск до deadline и на равенстве
`437.5`, затем ресурсный `dt = 0.5` при времени `500`. На том же ограниченном
хосте без устройств seed `0x12345678` подтверждает новые RNG draw при временах
`500`, `501`, `502`, `503` и ресурсные clocks `0`, `0`, `502`, `502`: deadline
пересчитывается при каждом принятом активном вызове. Проверены также точное
равенство с ненулевым RNG, расход RNG при принудительном `+0xd4` и сохранение
RNG/счётчика в неактивной ветви. Движение и очереди исключены условиями этих
векторов; активация `+0xe0` задана явно. Отдельный вектор
класса `4` исполняет событие `4` через `0x31ae0` → `0x314f0`, проверяя
активность, начальные часы `time - 100`, округление DWORD в `f32` и одну
начальную команду. Геометрические callbacks подставлены явно, таблицы
устройств и shapes пусты; полный загрузочный путь не проверен. Источник времени
и доставка прослежены статически:
World3D RVA `0x6bf0` вычисляет часы из `timeGetTime()` с игровым смещением и
поправкой на паузу, затем выполняет первый обход `6/1`, шаг мировой физики и
второй обход `6/0x1c`. AniMesh RVA `0x1370` передаёт часы Control; общий
обработчик Control RVA `0x7830` численно преобразует unsigned DWORD в `f32`
перед primary slot `+0x58`. Фабрика выбирает RVA `0xcbb0` для класса `9` и
RVA `0xbcf0` для остальных. Это подтверждает статическую цепочку вызовов, но не
её исполнение на миссии: связывание интерфейсов, активация устройств и shapes,
остальные ветви `0xbcf0` и
автоматическое изменение provider через clock и бюджет Rust остаются открытыми. Источник контактного коэффициента прослежен статически,
но не исполнен. Подробные векторы и статические границы приведены в
[техническом подразделе о provider-состоянии Control](script-vm.md#изменение-provider-состояния-control).
Отдельный API `advance_loaded_mission_resources` принимает явное время очереди
и обновляет представленные поля `CurrentOre`, строк распределителя и Control
selector `1`. Тот же расчёт вызывают `step_headless` и `frame` с явным
clock очереди; подготовка ресурсов и мировой шаг завершаются атомарно.
Оконное приложение пока использует static preview. Этот путь не обновляет
Control amount, профили или special object-state gate. Если обязательный
запрос позднего объекта не поддержан, транзакция возвращает ошибку без применения
подготовленных изменений. AutoDemo-тест подтверждает связь настоящих профилей с
этим шагом и проверяет откат при ошибке ресурсов или мировой очереди,
но не полный паритет миссии; отдельный aggregate getter Behavior RVA
`0xa490` в oracle не исполнен.
Behavior selectors `0x1002` и `0x1005` используют значения выбранного
профиля из `behpsp.res`, разобранного до создания нового мира. `0x1002` складывает
только текущие descriptor tag `26` запросы `0xe00`, затем применяет
`Transfer_Ore_OffBoard`; `0x1005` возвращает `Transfer_Power_Out`. Это начальный
снимок профиля и provider-строк. Полная загрузка сохраняет результат у
`ObjectHandle` и возвращает
ошибку, если известный код класса не имеет корректной записи; класс,
отсутствующий в таблице выбора, сохраняет начальные значения конструктора.
Preview и объекты без полного Unit DAT не получают профильное состояние.
Native oracle подтвердил соответствия кода класса выбранной записи, разбор
`.var` и применение второй области данных;
оригинальный aggregate getter Behavior RVA `0xa490` остаётся статически
исследованным, но не исполнен в oracle.
В x86 oracle при x87 control word `0x007f` проверены порядок, signed zero,
отрицательные входы, расширенный промежуточный диапазон и subnormal return.
Rust запрос ограничивает себя normal-or-zero значениями, поэтому явно отклоняет
промежуточные случаи, где результат выходит за поддерживаемый диапазон f32,
даже если позднее умножение вернуло бы его в диапазон. Это ограничение
поддержки Rust snapshot, а не неверный native-результат.
В Unit DAT fixture `UNITS/BUILDS/MINE/min_l_n1.dat` проверена реальная связка
`fr_l_mine.ctl` + `fr_l_mine.ndp`: tag `26`, ключ `2` в локальной NDP таблице
из `22` строк, коэффициент `1.0`. Игнорируемый runtime-тест выполняет полный
загрузочный путь, подтверждает root/`EXTO` размеры `22` и `4` и итоговые `25`
provider rows после пропуска первого `EXTO` row. Он вызывает Rust query по
восстановленному native правилу адресации и получает начальное значение `1.0`;
getter оригинальной DLL здесь не исполняется. Это не запись живого
resource-device значения во время игры.
### Профиль владельца Control из CTLD
Общий проход оригинального `Control.dll!FUN_1000fac0` (RVA `0x0fac0`, SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) проверен
прямым x86-вызовом под control word `0x007f` на synthetic backing. Оригинальный
`AniMesh.dll` query `0x51f0` и методы диапазона component ID `0`
`0x5780/0x57c0` участвуют в векторе; Rust воспроизводит его raw output bits и
отдельные cache, order, clamp и signed-zero случаи.
Для обычной начальной загрузки scale-input тоже закрыт. AniMesh constructor
оставляет `+0x158/+0x15c/+0x160` равными `1.0`; перед Control event `4` World3D
проходит Agent event `0`, AniMesh RVA `0x1a80` вызывает базовый return `0x17df0`
и передаёт event `4/null` модели, FX, CollManager и CollObject. Их проверенные
ветви `0x75b0/0x6fc0`, Effect RVA `0x3d30`, Control RVA `0x1ce00` и `0x1fe00`
не вызывают AniMesh setter `0x14770` и не меняют outer scale. Поэтому FAC0 в
стандартном `LoadAgent` получает `[1, 1, 1]`. Другой маршрут
`AddNewObjectToGame`/mirror к setter и последующие placement/tick остаются
отдельными границами. В synthetic x86-векторе по-прежнему явно заданы `[2,3,5]`;
он не является источником scale для полного load.
Смысл `Slot68 +0x30/+0x34`, tag-27 cache DWORD, owner accumulators `+0x530`,
`+0x534`, `+0x538` и результата mutable word `26` не имеет доказанных единиц
измерения. Неизвестные слова остаются raw; их нельзя подписывать как массу или
площадь. Synthetic x86 vector напрямую записывает scales `[2, 3, 5]` в
`AniMesh +0x158/+0x15c/+0x160`; это не значения обычной миссии. Проверка
стандартных `ArealMap`/`World3D` и AniMesh callback-ветвей до FAC0 подтвердила,
что setter не вызывается, поэтому полный начальный loader использует defaults
конструктора `[1, 1, 1]`. Конкретный отдельный путь World3D `AddNewObjectToGame`
и mirror-варианты передают свой transform-вектор setter-у; это отдельная
граница, как и более поздние placement/tick.
Полный Rust loader теперь вызывает FAC0 при построении
`build_control_device_projection` и публикует результат в
`MissionControlOwnerProfile::load_projection`. Игнорируемый corpus-тест
`full_load_builds_effective_control_device_table_for_mine_unit_dat` на лицензированном
`min_l_n1.dat` проверяет 11 устройств, все 25 соответствий provider ordinal
модели и упорядоченные значения `row +0x20`. Его агрегаты —
`0x470833fc/0x4897e712/0x4d9452cb` для `+0x530/+0x534/+0x538`; исходный и
начальный mutable word `26` равны `0x7f7fffff`. Это подтверждает Rust full-load
результат на оригинальных ресурсах, но не совпадение с полным нативным load
оригинала. Поведение позже при placement/tick и физический смысл raw-полей
остаётся неизвестным. Точные RVA, owner layout и векторы приведены в
[описании изолированного прохода FAC0](script-vm.md#изолированный-проход-fac0).
Остаётся сравнить этот полный mine load с original loader при тех же входных
данных и продолжить трассировку `LoadControlSystem`, `LoadPhysicalModel`,
`CreateCollManager` и collision objects, если потребуется связать profile words
с shapes, contacts или реакцией на движение.
## DirectPlay wire ## DirectPlay wire
+1482 -105
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -60,5 +60,47 @@ u32 property_count
Property properties[property_count] Property properties[property_count]
``` ```
Эти два слова после `resource_name` -- разные входные значения. В оригинале
`MisLoad.dll!FUN_10003900` (RVA `0x3900`) читает первое в смещение `0x54`
нативной записи, если версия секции объектов выше 2; следующее слово попадает
в смещение `0x5c` только при версии выше 3. Getter
`MisLoad.dll!FUN_10001440` (RVA `0x1440`) копирует первое слово в смещение
`0x50` строки, передаваемой хосту. `iron3d.dll!FUN_100a1f30` (RVA `0xA1F30`)
загружает миссию и вызывает `FUN_100a3e40` (RVA `0xA3E40`); тот запрашивает
строку через vtable slot `+0x28` (callsite RVA `0xA3F1A`). Хост передаёт это
значение в создание схемы объекта в ArealMap, откуда оно становится первым
DWORD дескриптора создания World3D. `World3D.dll!CreateObject` (RVA `0x7CB0`)
читает DWORD по RVA `0x7E58`, а callsite RVA `0x7E62` передаёт его через vtable
slot `+0x40` в путь выбора AI в Behavior. Поэтому Rust сохраняет первое слово
как `raw_after_resource: u32`: оригинал использует его как индекс выбора AI,
но соответствие значения типу `OwnerId` в `fparkan-world` не доказано.
Соседнее `identity_or_clan_raw` остаётся отдельным raw-полем; его имя не
доказывает, кому объект принадлежит.
Цепочка проверена на этих сборках (SHA-256): `MisLoad.dll`
`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`,
`iron3d.dll`
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`,
`ArealMap.dll`
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
`World3D.dll`
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` и
`Behavior.dll`
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`.
Таблица `GetSuperAI` в `ai.dll` находится по RVA `0x55398`; SHA-256 файла —
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только `Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только
для доказанных property names и consumers. для доказанных property names и consumers.
Для свойств объекта `MisLoad.dll!FUN_10003ab0` (RVA `0x3ab0`) переставляет
дисковые слова в runtime-запись, а `FUN_100040b0` (RVA `0x40b0`) проходит её
таблицу в порядке runtime-дескрипторов. Поиск имени учитывает длину и байты с
точным регистром и использует первое совпадение. В подтверждённом пути
дисковое `raw_value[1]` становится первым DWORD аргумента setter-а, поэтому
значение передаётся как биты, без промежуточного float-преобразования.
Например, Behavior регистрирует `CurrentOre` под кодом `5`; его setter кладёт
полученные биты в основное слово свойства `0x02000100`. `MaximumOre` с кодом
`4` читает дополнительное слово той же записи, а setter этого дескриптора
ничего не меняет. Подтверждение классов и native getter/setter находится в
[главе о Behavior](../tomes/06-behavior.md#начальные-свойства-запасов-руды).
+28
View File
@@ -346,6 +346,34 @@ end-of-render callbacks. Эти callbacks позволяют объектам б
изменит движение, камеру и сценарные таймеры. Подробности render item и рисков изменит движение, камеру и сценарные таймеры. Подробности render item и рисков
кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md). кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md).
У исходного Control источник времени прослежен статически до World3D.
World3D берёт миллисекунды `timeGetTime()`, учитывает игровое смещение и паузу,
затем передаёт младшие 32 бита результата всем объектам в событии `6/1`.
После этого следуют шаг мировой физики и второй обход объектов с событием
`6/0x1c`; они получают то же значение часов. Поэтому порядок обновлений имеет
значение даже тогда, когда все участники видят одинаковое время.
Объект AniMesh передаёт первое событие последовательно в Behavior, Wizard,
CollObject, Control и PhysicalModel, пропуская отсутствующие интерфейсы, а затем
ещё раз в CollObject с подсобытием `0x1c`. Control численно преобразует
беззнаковые миллисекунды в `f32` и выбирает обработчик по классу объекта.
Это абсолютные часы: длительность шага обработчик вычисляет из сохранённых
значений сам. Таблицы, адреса и различие путей классов приведены в
[техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control).
Статическая цепочка ещё не доказывает исполнение активного Control update на
миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta
API меняет строку и значение соответствующего CPU-узла модели; отдельный
стадийный API обновляет его видимую стадию и обработанное количество в
подтверждённой области. Описанный clock/budget-путь и вывод этого состояния
в окно пока не подключены.
В текущем runtime `step_headless` и `frame` получают время очереди явным
аргументом `u32`. По нему запускается представленный ресурсный планировщик
Behavior, после чего обрабатываются очередь мира и отложенные удаления.
Ресурсные изменения публикуются после успешного мирового шага, поэтому ошибка
не оставляет мир и ресурсы на разных шагах. Это начало расчётного цикла;
отдельное обновление Control, общая VM и движение ещё требуют подключения.
## World3D ## World3D
World3D связывает игровые объекты, события, время, ввод, камеру, сетевые World3D связывает игровые объекты, события, время, ввод, камеру, сетевые
+24 -12
View File
@@ -448,21 +448,28 @@ struct UnitDatRecord112 {
char archive_name[32]; // +0x00 char archive_name[32]; // +0x00
char resource_name[32]; // +0x20 char resource_name[32]; // +0x20
uint32_t kind; // +0x40, в корпусе всегда 1 uint32_t kind; // +0x40, в корпусе всегда 1
int32_t parent_or_link; // +0x44 int32_t parent_or_link; // +0x44, context-dependent local index
char description[32]; // +0x48 char description[32]; // +0x48
uint32_t tail0; // +0x68, opaque uint32_t tail0; // +0x68, opaque
uint32_t tail1; // +0x6C, opaque uint32_t tail1; // +0x6C, immediate_child_count
}; };
#pragma pack(pop) #pragma pack(pop)
``` ```
Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`. Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`.
Поле `parent_or_link` встречается как `-1`, `0`, `1` и другие небольшие индексы `tail1` задаёт `immediate_child_count` — число непосредственных дочерних
и связывает компоненты составного unit; точная предметная классификация ссылки записей при построении дерева Unit DAT; оно не opaque. Поле `parent_or_link`
ещё не закрыта. `description` -- человекочитаемое описание компонента. В Части 2 встречается как `-1`, `0`, `1` и другие небольшие индексы. В подтверждённой
есть поля `description[32]`, полностью заполненные без NUL; это валидная bounded цепочке Unit DAT → AniMesh → Control оно становится локальным индексом. Для
string длиной 32 байта. Требование обязательного terminator применяется только `INTO` индекс задаёт устройство Control относительно первого устройства
к полям, где оно доказано форматом. `tail0` и `tail1` нельзя нормализовать. родительского компонента, а `EXTO` обрабатывается отдельной ветвью. Поэтому
поле нельзя трактовать как универсальный parent ID или одинаковую ссылку для
всех registry types. Детали передачи описаны в [главе о Behavior и Control](06-behavior.md).
`tail0` сохраняется как raw DWORD без установленной семантики. `description` --
человекочитаемое описание компонента. В Части 2 есть поля `description[32]`,
полностью заполненные без NUL; это валидная bounded string длиной 32 байта.
Требование обязательного terminator применяется только к полям, где оно доказано
форматом.
Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records
Части 2. Все соответствуют формуле размера, `kind == 1` и Части 2. Все соответствуют формуле размера, `kind == 1` и
@@ -470,10 +477,15 @@ string длиной 32 байта. Требование обязательног
При построении mission prototype graph FParkan сохраняет для каждого root При построении mission prototype graph FParkan сохраняет для каждого root
упорядоченный список этих raw records. Список передаётся в упорядоченный список этих raw records. Список передаётся в
`MissionObjectDraft` без преобразования `kind`, `parent_or_link`, description `MissionObjectDraft` с исходными `kind`, `parent_or_link`, description и tails.
или tails в предполагаемые роли. Так runtime уже располагает исходными данными Во время полного mission load runtime использует дерево и подготовленные CTL
составного unit для будущих Control/physics/AniMesh consumers, но не выдаёт resources, чтобы построить для такого объекта упорядоченную базовую таблицу
структурное сходство за доказанную семантику. Control: корневые строки добавляются, дочерний `INTO` может обновить выбранную
строку или добавить записи при sentinel `-1`, а `EXTO` добавляет строки с
переведёнными ключами глобальной MSH-иерархии. Результат хранит исходный factory
tag и provenance; неизвестные tag-specific callbacks он не выполняет. Preview
режим эту таблицу не строит, а неполная typed-record list оставляет состояние
явно unavailable.
Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH
dependency также несёт `unit_component_index`. Это индекс исходной записи в dependency также несёт `unit_component_index`. Это индекс исходной записи в
+40
View File
@@ -195,6 +195,46 @@ Property properties[property_count]
большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у
unit-ссылки или содержать stem размещённого прототипа. unit-ссылки или содержать stem размещённого прототипа.
Сразу за строкой ресурса лежат два DWORD, и их нельзя склеивать в одно
«владение» или менять местами. Первый, `raw_after_resource`, оригинал использует
как индекс выбора AI. Второй сохраняется отдельным полем
`identity_or_clan_raw`, но одно его имя ещё не говорит, кому объект принадлежит.
В runtime draft оба значения остаются раздельными:
```rust
MissionObjectDraft {
raw_after_resource: object.raw_after_resource,
identity_or_clan_raw: object.identity_or_clan_raw,
..
}
```
Мы знаем это по пути создания: загрузчик копирует первое слово из TMA-записи в
строку схемы объекта; затем хост передаёт его ArealMap и World3D, а World3D
направляет его в ветку выбора AI. Соответствие этого индекса типу `OwnerId` в
`fparkan-world` не подтверждено. Смещения, условия версий и контрольные суммы исследованных
сборок приведены в [справочнике TMA](../reference/tma.md).
Это слово влияет не только на сценарий. Успешно загруженный пакет клана
публикует его `SuperAI` в таблице `GetSuperAI`; World3D передаёт первый DWORD
дескриптора создания через Behavior, где он выбирает тот же номер таблицы.
Поэтому при создании полного мира можно отнести объект к конкретному клановому
диспетчеру, если его номер лежит в диапазоне `0..63` и сценарий этого клана
загружен. Rust хранит такую связь отдельно от владельца объекта:
```text
TMA raw_after_resource = 1
-> GetSuperAI[1] доступен после загрузки script bundle клана 1
-> запись объекта относится к distributor клана 1
```
Для объектов с Unit DAT используется старший бит DWORD по смещению `+4` от
начала DAT как общий признак, по которому оригинальный World3D создаёт
registration key для Behavior. Это второе 32-битное слово заголовка. Из него
нельзя вывести сторону или тип владельца. В AutoDemo это связывает поля TMA,
class word из Unit DAT и выбранный клановый AI, не меняя `OwnerId` и хеш
игрового состояния.
Свойства хранятся как ordered property bag: Свойства хранятся как ordered property bag:
```text ```text
+899 -26
View File
@@ -151,6 +151,231 @@ handler contracts и runtime traces.
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
пока не назван. пока не назван.
Когда в `.scr` есть событие `Init`, сценарная машина ищет его имя как C-строку:
сравнение учитывает регистр, останавливается на первом NUL, а среди повторов
выбирает первое событие. В проверенном AutoDemo это событие содержит один
`Handler(19)`. Он записывает в переменные клана координаты его основания и номер
клана:
```text
первый Init -> действия по порядку -> ClanBaseX, ClanBaseY, ClanID
```
На пути к этим переменным координаты сначала округляются оригиналом до ближайшего
целого с правилом ties-to-even, затем сохраняется младшее 32-битное слово.
Например, `-0.6` даёт `0xffffffff`. AI хранит это слово как `float`, поэтому
`0xffffffff` становится `2^32`; `Handler(19)` усекает значение и снова берёт
младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
Rust исполняет только подтверждённую форму этого действия. Поддержанные записи
применяются к состоянию лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
оригинального обработчика. Отсутствующее событие не создаёт записей; пустое
событие считается исполненным без записей. Остальные события пока не образуют
исполняемую VM.
### Первый запуск сценария
До регулярной работы Behavior настраивает расписание своего первого шага. Он
берёт текущее 32-битное время игровой очереди и назначает проверку через три
интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта
задержка нужна каждому экземпляру, но случайные числа берутся из общего
генератора, поэтому порядок запуска кланов влияет на их значения. При первой
подготовке controller также очищает несколько внутренних слов. В проверенном
повторном вызове до назначенного срока ненулевое сохранённое время оставило
срок прежним, не потребовало нового случайного числа и не очистило эти слова;
поведение после срока пока неизвестно.
В оригинале вызов идёт через обратный вызов движка: Iron3D сначала создаёт
SuperAI для записей кланов, затем обновляет Behavior и ищет `Init` для
соответствующего AI. Если `Init` не найдено, одноразовая метка остаётся
выключенной. Если оно найдено, диспетчер запускается только при свободной защите
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
подготавливает каждый сценарный пакет и выполняет только подтверждённые действия
`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но
может сохранить подтверждённые записи `Init` в сценарном varset.
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного
входа `AI+0x78`, одноразовую метку `AI+0x8a8` или полное обновление Behavior на игровых
тиках. Распределение ресурсов исполнено на исходной DLL с искусственными
объектами и подключено к Rust `step_headless`: явное время очереди определяет,
какой клан должен выполнить очередной ресурсный шаг.
При полной загрузке Rust уже восстанавливает членство объектов в клановых
списках Behavior и задаёт их начальные доли. Остальные слова записи хранятся
сырыми: семантика только первого из пяти слов хвоста установлена для
нормализации.
Runtime также не подменяет очередь оригинала: вызывающая сторона сама передаёт
время, а локальный генератор Rust пока учитывает только случайные числа
восстановленного стартового шага и ресурсного планировщика.
### Откуда берутся объекты распределителя
Для каждого клана Iron3D создаёт AI независимо от того, найден ли его `.scr`.
Таблица `GetSuperAI` получает указатель только для клана, чей пакет сценария
успешно загружен. У размещённого объекта первое DWORD после имени ресурса
выбирает индекс этой таблицы; второй соседний DWORD для этой цели не используется.
World3D получает индекс через дескриптор создания объекта и передаёт его в
Behavior. Отдельно DWORD по смещению `+4` от начала Unit DAT задаёт class word
объекта. Это второе 32-битное слово файла; его старший бит приводит к созданию
registration key, который проходит защиту Behavior вместе с проверками AI и
distributor.
Для обычной полной загрузки Rust сохраняет по одной записи состояния на клан.
Каждый размещённый объект с известным Unit DAT и установленным старшим битом
получает существующий `ObjectHandle` и попадает в список только при точном
совпадении индекса `raw_after_resource` с доступной записью `GetSuperAI`. Индекс
не перенумеровывается: например, пакет клана 1 остаётся слотом 1 даже если
пакет клана 0 отсутствует. Preview сохраняет свои статические данные, но не
создаёт эти связи.
В лицензированной AutoDemo это даёт четыре членства: TMA объекты 4 и 6 идут
в слот 0, а объекты 5 и 7 — в слот 1. У парных записей первого клана raw
селектор равен 0, у второго — 1; классы из Unit DAT равны `0x80000010` для
`wor_l_n1.dat` и `0x80000002` для `gener01.dat`. Тот же обычный путь в исходном
Behavior добавил четыре ключа `0x80000003..0x80000006` в два distributor-а.
Rust-тест повторно читает лицензированную миссию, связывает результаты с её
TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы
не требовать оригинальных ресурсов от остальных разработчиков.
Список распределителя хранит не только участников, но и долю каждого. При
добавлении двух объектов оба получают по половине общего веса. Если добавить
третий, движок оставляет долю новой записи около одной трети, а оставшийся вес
делит между прежними изменяемыми записями. Помеченная запись сохраняет свой вес,
как закреплённая часть общего набора. Повторное добавление того же объекта не
создаёт вторую строку и оставляет остальные слова записи нетронутыми.
Rust хранит этот небольшой начальный список с долями. Он не смешивает selector
из TMA, владельца мирового объекта и внутренний ключ Behavior: связь в Rust
опирается на уже созданный `ObjectHandle`. Регулярный обмен ресурсами пока
остаётся отдельным этапом, потому что для него нужны интерфейсы игровых объектов.
### Начальные свойства запасов руды
Behavior хранит `CurrentOre` и `MaximumOre` в одной записи свойства с ключом
`0x02000100`: текущее количество лежит в основном float-слове, предел — в
дополнительном DWORD. В TMA это не означает, что оба значения свободно задаются
файлом. При загрузке `MisLoad.dll` ищет первое свойство с точным, чувствительным
к регистру совпадением имени и передаёт его второе raw-слово в setter. В
Behavior дескриптор `CurrentOre` записывает это слово в основное значение;
дескриптор `MaximumOre` читает дополнительное слово, но его setter ничего не
меняет.
Оба дескриптора регистрируются как коды `4` и `5`. Это коды обработчика, а не
позиции в таблице дескрипторов: исходная DLL корректно работает и после
перестановки строк, потому что callback выбирает обработчик по коду строки.
Поэтому начальное состояние можно описать так, сохраняя биты без пересчёта
через `float`:
```text
current = first_exact("CurrentOre").raw_value[1] ?? class_default_current
maximum = class_default_maximum
```
Класс задаёт значения по умолчанию в Behavior RVA `0x8790`:
| Слово класса | `CurrentOre` | `MaximumOre` |
| --- | ---: | ---: |
| `0x80000004` | 0 | 500 |
| `0x80000008` | 0 | 4000 |
| `0x01002000` | 0 | 2000 |
| `0x01004000` | 2000 | 2000 |
| прочие слова в этой ветви | 0 | 0 |
Вызов RVA `0x8790` на синтетической структуре подтвердил эти значения для
классов таблицы, включая `0x01008000`; он проверяет тело native initializer-а,
но не его полный жизненный цикл в игре.
В полной загрузке Rust сохраняет эти два raw-значения рядом с исходным TMA ID
и `ObjectHandle` каждого объекта с Unit DAT; список участников распределителя
для этого не требуется. Это важно для объектов, у которых есть компонент
Behavior, но нет членства в ресурсном distributor. Preview намеренно не создаёт
начальное состояние. Лицензированная AutoDemo показывает восемь объектов с
Unit DAT, включая объекты класса `0x01008000` вне списков distributor; в этом
снимке TMA `CurrentOre` равен нулю у всех восьми. Нативный x86 oracle отдельно
проверяет ненулевое raw-слово, чтение предела, отсутствие записи в `MaximumOre`
и переставленные строки дескрипторов.
Эта модель покрывает именно начальную загрузку полных TMA-объектов. Она не
утверждает, что уже восстановлены все пути создания Behavior, отложенное
создание объектов или внешняя подмена class defaults конфигурацией.
### Регулярная передача ресурсов
Resource Distributor работает со списком зарегистрированных объектов. За один
шаг он сначала проходит список и собирает нужные значения, а затем проходит его
ещё раз и применяет результат. Во втором проходе объект ищется заново, поэтому
его состояние или ответ getter-а может измениться между чтением и записью.
Нулевой временной шаг ничего не делает; некоторые состояния объекта также
пропускают строку ещё до запросов ресурсных интерфейсов.
В синтетическом примере исходного кода два объекта с ключами `41` и `42`
обрабатываются в порядке `41, 42, 41, 42`: производитель сначала сообщает
скорость `4`, затем `1`, а получатель со значением `6` заканчивает шаг со
значением `7`. Это показывает, почему одного результата чтения недостаточно:
алгоритм снова получает величины при применении. Сценарий был запущен прямо из
исходной `Behavior.dll`, но объекты и ответы интерфейсов заданы тестом; это ещё
не воспроизведение реальной миссии.
Для правила долей есть отдельная проверка исходной `Behavior.dll` на синтетических
записях при игровом x87 control word `0x007f`: проверены добавление, повторный
ключ, закреплённая запись, нулевая сумма и округление. Это позволяет Rust
сохранять начальный список, но пока не подтверждает веса реальных объектов в
живой миссии.
Узкая часть регулярного шага уже связана с загруженным состоянием движка.
`advance_loaded_mission_resources(engine, queue_time_ms)` принимает явную
метку времени общей 32-битной очереди. Для каждого видимого Behavior clan он
проверяет срок запуска, выполняет два прохода по списку и сохраняет пять
итогов: доступную энергию и руду, буфер руды, потребление энергии и потребление
руды. В обоих проходах объекты разрешаются заново по исходному TMA ID. Первый
проход измеряет поставки и потребность, второй повторно читает представленные
профильные и Control-значения перед применением выделения. Нативный код также
запрашивает интерфейсы во втором проходе, но динамические getter-callbacks в
этом снимке Rust не воспроизводятся.
Планировщик использует беззнаковое сравнение времени: равенство ненулевому
сроку ещё не запускает шаг, а нулевой срок не задерживает его. Переполнение
вычитания даёт 32-битную длительность. Затем
длительность переводится в секунды с округлением, подтверждённым x87-путём
оригинальной DLL. Нативный oracle проверяет его на длинных интервалах, включая
значения около границы точного целого `f32`, а также на равенстве и строгом
проходе срока. Формула запуска, native vectors и команда воспроизведения
описаны в технической главе [script VM](../appendices/script-vm.md).
Шаг работает только с уже созданными полными объектами миссии и снимками тех
профилей и Control-таблиц, чьи запросы и setters представлены в runtime. Он
обновляет загруженные `CurrentOre`, поля строк распределителя и поддержанный
Control selector `1`; неподдержанное свойство, запрос Control или невозможное
арифметическое значение возвращает ошибку. Обновления нескольких кланов, их
расписания и RNG фиксируются одной транзакцией: если обязательный запрос
позднего клана не разрешён, ранее подготовленные изменения остаются
неприменёнными. Обычные `step_headless(engine, input, queue_time_ms)` и
`frame(engine, queue_time_ms)` запускают этот расчёт до обработки очереди мира.
Они сначала готовят ресурсные изменения, затем выполняют шаг мира и публикуют
ресурсное состояние после его успеха. Ошибка любой стадии сохраняет прежние
ресурсы; ошибка подготовки также не продвигает мировой tick. До срока
планировщика большие таблицы ресурсов не копируются.
Оконное приложение пока запускает static preview и не вызывает этот расчётный
путь. Динамические callbacks, которые переносят количество между Control
provider-строками, ещё не моделируются. Поэтому это
исполняемый загруженный resource step, а не полный симулятор миссии.
Для настоящих ресурсов AutoDemo тест проходит несколько временных точек:
генераторы `ObjectId 6` и `ObjectId 7` читают `Transfer_Power_Out = 10` из
`prof_generator.var`; после шага в 500 мс каждый сообщает 5 единиц доступной
энергии. Это подтверждает связь загруженного профиля с runtime-расчётом, но не
сравнение полной миссии с оригинальной игрой. Native oracle сверяет исходные
планировщик и распределитель GOG `Behavior.dll`; техническая процедура и
проверяемые векторы описаны в [script VM](../appendices/script-vm.md). Остальные ограничения собраны в
[границах знания](../appendices/knowledge-boundaries.md).
Первый часто встречающийся table entry с side effect — `Handler(2)` (176 Первый часто встречающийся table entry с side effect — `Handler(2)` (176
records в corpus). Он берёт active instruction, разрешает семь slots через records в corpus). Он берёт active instruction, разрешает семь slots через
varset, приводит три значения к float по observed kinds `5`/`3`, затем varset, приводит три значения к float по observed kinds `5`/`3`, затем
@@ -181,25 +406,65 @@ IDs и отправляет команды через игровые interfaces,
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
lossless, а доказанные symbol/event tables документируются отдельно. lossless, а доказанные symbol/event tables документируются отдельно.
### Подтверждённый evaluator выражений ### Подтверждённый evaluator записей условий
Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evaluator по VA У сценарного слоя есть не только последовательность инструкций, но и отдельные
`0x10005180` (не dispatcher инструкций `.scr`). Он получает индекс записи, короткие проверки состояния. В GOG `ai.dll` версии `1.00` evaluator таких
ищет её в контейнере `this + 0x34`, переключается по `u32 tag` в offset `+0` проверок расположен по RVA `0x5180` (VA `0x10005180` при image base
и при успешном результате пишет completion byte в `+0x0c`. Из кода доказаны `0x10000000`; SHA-256 сборки —
ровно пять ветвей `tag = 1..5`; `tag = 1..3` требуют `u32` subtype в `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`). Он
`+0x04 == 0`, а `tag = 4..5` — `+0x04 == 1`. Поле payload находится по получает индекс 16-byte записи и смотрит на её tag, subtype и payload. Для tags
`+0x08`. 1–3 допустим subtype `0`, для tags 4–5 — subtype `1`; другие сочетания не
завершают условие. Байт по смещению `+0x0c` — флаг завершения: evaluator ставит
`1`, когда проверка проходит, и оставляет запись как есть, когда не проходит.
Вызывающая функция позже пропускает уже завершённые условия.
Ветки 1--3 делают lookup через object/interface, достижимый от Tag выбирает конкретное правило. Для tag 1 запись завершается, когда поиск
`this + 0x60 + 0x35c`, и используют его virtual slot `+0x1c`. Ветка 2 объекта по payload ничего не нашёл. Для tag 2 объект, наоборот, должен
дополнительно читает virtual slots `+0x10`/`+0x18` возвращённого объекта и существовать: затем игра проверяет одно из его свойств и, в более сложной
разрешает result tags `1`, `0x12`, `0x13`. Ветка 3 сравнивает virtual slot ветви, принимает только три числовых кода результата — `1`, `0x12` и `0x13`.
`+0x44` с текущим object value. Ветки 4--5 используют `payload` как index в Для tag 3 найденное значение сравнивается с первым числом текущего контекста.
контейнере `this + 0x4c`, обходят его child indices и делают тот же lookup; Мы не знаем из одного этого кода, каким игровым словам соответствуют эти поля,
их разные success-guards пока не именуются семантически. Это доказывает поэтому не называем их владельцем, жизнью или приказом.
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами. Теги 4 и 5 обходят дочерние ссылки выбранного списка. Значение `-1` означает
«элемента нет» и пропускается. Тег 4 завершается, только если ни один дочерний
ключ не нашёл объект. Тег 5 тоже пропускает отсутствующий объект, но для
найденного смотрит результат другого виртуального вызова: ноль разрешает
продолжить, а первый ненулевой результат прекращает обход и оставляет условие
незавершённым. Так различаются два алгоритма проверки набора объектов без
предположений о том, почему сценарист выбрал один из них.
У списков обнаружена нетипичная граница. Программа сначала сравнивает индекс
списка с его длиной как unsigned число и отбрасывает только значения строго
больше длины. Равный длине индекс проходит первую проверку, но следующий
проверенный доступ обнаруживает выход за границу и возбуждает C++ exception.
Поэтому в будущем движке это нельзя незаметно заменить на «все неверные
индексы просто дают ложь».
Это статическая реконструкция ветвей, а не доказательство исполнения условия
в работающем оригинале. Сам вызов RVA `0x5180` и внешний object interface не
проверялись динамически; неизвестен и код, который создаёт эти 16-byte записи.
Считыватель `.scr` хранит описатели событий и вложенных записей во внутренних
контейнерах с шагом `0x14` и `0x20` соответственно. На диске же `.scr` использует
переменные поля: имя события задаётся длиной, а вложенная запись содержит
заголовок и список ссылок. Связь этих структур с evaluator-ом не доказана.
Подробные смещения,
виртуальные слоты и границы анализа приведены в
[`приложении о сценарной VM`](../appendices/script-vm.md#evaluator-записей-условий-ai-dll-rva-0x5180)
и [`границах знаний`](../appendices/knowledge-boundaries.md#script-vm).
### Исход миссии и состояние игры
Сообщение сценария об успехе или провале проходит ещё один рубеж — обратный
вызов в игровое приложение. У этой границы есть собственные правила: в режиме
отдельной миссии вызов может записать новый исход, а в автоматической демо-игре
та же ветвь подавляется. Повторный сигнал не обязан исправлять соседний флаг
или сохранённый Rust-исход: оригинал решает по своему слову состояния, а не по
сравнению всех полей между собой. Поэтому загрузка карты не означает, что игра
уже перешла в активный игровой цикл; это отдельный шаг запуска. Технические
смещения, начальное состояние и граница проверки описаны в
[`приложении о сценарной VM`](../appendices/script-vm.md#состояние-обратного-вызова-исхода-миссии).
### TRF и preload-данные ### TRF и preload-данные
@@ -324,10 +589,615 @@ component как ordered raw-string/resource provenance, но не для при
этим строкам смысловых имён до трассировки private update methods. этим строкам смысловых имён до трассировки private update methods.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему draft. Теперь подтверждена связь Unit DAT с descriptor-ом AniMesh, который
Control consumer-у: никакой компонент пока не получает имя `Control` только передаётся в Control: порядок узлов задаётся деревом immediate-child counts,
по `kind`, `parent_or_link` или description; semantic binding появится лишь а `parent_or_link` попадает в descriptor как локальное поле. Его смысл зависит
после trace private update/load methods. от registry type и состояния Control manager-а; в частности, для `INTO` это
локальный индекс устройства, а `EXTO` проходит отдельную ветвь.
### От Unit DAT к Control descriptor
В GOG `ArealMap.dll` (SHA-256
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`)
`FUN_10016a10` (RVA `0x16a10`) читает запись Unit DAT в дерево узлов по
`0x78` байт: перед сырыми полями узла находятся указатель и количество детей,
а байты файла копируются начиная с node `+0x0c`. `FUN_10015290` (RVA
`0x15290`) передаёт каждый непосредственный узел корня в `FUN_10014b90`
(RVA `0x14b90`) с начальным parent ID `0`.
Для узла с ненулевым raw kind `FUN_10014b90` формирует descriptor размером
`0x4c`: две строки ресурса копируются из node `+0x0c` и `+0x2c`, поле
descriptor `+0x40` получает parent ID, `+0x44` — DWORD node `+0x50`, а
`+0x48` начинается со значения `-1`. Из-за смещения raw-полей на `0x0c`
node `+0x50` — это ровно `parent_or_link` по смещению `0x44` в записи Unit
DAT. Затем `ArealMap` посылает owner-у событие `6 / 0x80000020` через vtable
slot `+0x34`.
В `AniMesh.dll` (SHA-256
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`)
dispatcher `FUN_10001370` (RVA `0x1370`) передаёт это событие в
`FUN_10003760` (RVA `0x3760`). Тот выбирает следующий свободный ID, записывает
его в descriptor `+0x48` и сохраняет descriptor в списке компонентов.
Вернувшись после virtual call, `FUN_10014b90` передаёт этот ID как parent ID
рекурсивного обхода детей. Поэтому родительская связь Control descriptor-а
строится по фактически назначенному ID в обходе дерева, а локальный индекс
устройства приходит из `parent_or_link`.
У этого обхода есть ранний выход: если raw kind в node `+0x4c` равен нулю,
узел и его дети пропускаются. AniMesh начинает со служебного root ID `0`, но
из-за пропусков и других записей назначенный ID нельзя в общем случае считать
равным индексу узла или номеру его записи в Unit DAT.
Для registry type `INTO` и других не-`EXTO` значений `FUN_10003760` разрешает
parent ID через Control IID `0x202`, virtual slot `+0x44`, только если parent ID
не равен `-1` и интерфейс IID `0x202` доступен. `Control.dll`
`FUN_1002ecc0` (RVA `0x2ecc0`) возвращает индекс первого устройства, чьё поле
`+0x08` равно этому ID, или `-1`, если совпадения нет; AniMesh прибавляет
возвращённое значение к локальному индексу `+0x44` без отдельной проверки
`-1`. Если parent ID равен `-1` или IID `0x202` недоступен, используется сам
локальный индекс без смещения. Отсутствие устройства с parent ID — отдельный
случай: native path даёт индекс на единицу меньше локального, и Rust-проекции
нужно проверить это поведение явно. `EXTO` проходит отдельную ветвь, поэтому
это правило нельзя распространять на все registry types.
Ветка `EXTO` заполняет другие поля. В `FUN_10003760` RVA `0x3760` и mount
`C4`, и target `C8` сначала равны `-1`. Ветка `EXTO` вычисляет mount и
записывает его только в `C4`; `C8` остаётся `-1`. Поэтому Control добавляет
все typed records такого компонента в конец общей таблицы. Raw
`parent_or_link` здесь выбирает mount-узел, а не строку Control.
`AniMesh.dll` (SHA-256
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`)
собирает глобальную нумерацию MSH-узлов: обычная корневая модель добавляет все
`N` узлов, `EXTO`-дочерняя модель добавляет `N - 1`, начиная со своего узла `1`,
а `INTO`-компонент не добавляет узлов. `FUN_1000a460` (RVA `0xa460`) выполняет
эту загрузку MSH; `FUN_10005780` (RVA `0x5780`) находит первый глобальный узел
с заданным component ID. Если у `EXTO`-модели только один узел, она не добавляет
глобальных узлов, и поиск первого узла возвращает `-1`.
При добавлении `EXTO` Control отдельно переводит ключи CTLD в пространство
глобальной MSH-иерархии: нулевой ключ заменяется mount-узлом `C4`; ненулевой
ключ сдвигается относительно первого глобального узла компонента только при
`C4 != -1`. Например, если компонент начинается с глобального узла `22`, его
исходный ключ `1` становится `22`. Этот ключ выбирает модельный узел, а не
строку Control. Записи корня остаются в исходном виде.
#### Входной класс контроллера
Таблица устройств и класс самого Control-контроллера выбираются разными
частями загрузки. У полного объекта из Unit DAT старший бит флагов корня решает,
каким путём создать host. В ветви с этим битом объект проходит через Terrain;
эта оболочка передаёт AniMesh native-класс Control `3`. Если бит не установлен,
класс выбирает FourCC записи корневого ресурса: `BTLU` даёт `4`, `BULL` — `9`,
`WPNS` — `2`, `STAT` — `10`, а неизвестный FourCC оставляет класс `0`.
Например, `min_l_n1.dat` хранит флаги `0x80000004`, поэтому его проекция
получает класс `3`. Это аргумент конструктора Control, а не номер строки
устройства и не само по себе включение движения или обновлений. Техническая
цепочка и границы проверки описаны в [приложении](../appendices/script-vm.md#входной-класс-control-при-загрузке-unit-dat).
### Как Control добавляет и переопределяет устройства
В `Control.dll` (SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`)
`FUN_10008b10` (RVA `0x8b10`) выбирает путь по полю descriptor `+0xc8`.
Значение `-1` запускает цикл по typed records; фабрика `FUN_1002d4b0` (RVA
`0x2d4b0`) создаёт по устройству на запись и сохраняет их порядок. Иное
значение вызывает `FUN_1002d890` (RVA `0x2d890`) только один раз; цикл по
typed-record count в этой ветви отсутствует. Следовательно, подтверждённый
override path применяет первую typed record и не добавляет новый объект.
Так модуль меняет характеристики уже существующего слота, не увеличивая число
устройств. Неизвестный tag в Rust CTLD parser приводит к `Unsupported` и может
оставить typed-record list неполным. Native factory имеет отдельный default
путь `0x20800`, поэтому такой результат парсера сам по себе не доказывает отказ
оригинального loader-а и не позволяет объявить Rust merge полным.
FParkan строит базовую проекцию этой таблицы во время полного mission load для
объектов с Unit DAT. Корневые typed records добавляются в исходном порядке;
`EXTO` тоже добавляет все свои записи с remapped MSH-ключами; `INTO` обновляет
одну строку по `C8`, а при sentinel `-1` добавляет весь список. Preview таблицу
не создаёт. Проекция хранит исходный factory tag, текущий typed record и их
provenance, но не исполняет все tag-specific callbacks и не заменяет полный
Control runtime. Лицензированный `UNITS/BUILDS/MINE/min_l_n1.dat` подтверждает
10 исходных устройств, четыре `INTO`-override по индексам `0..3` и добавление
одной дочерней записи `EXTO` с ключом `22`, всего 11 устройств. Это результат
Rust full-loader теста на оригинальных ресурсах. Ветви C4/C8 и добавление
`EXTO` подтверждены статически по native ASM; отдельный native full-load oracle
для всей таблицы не запускался.
Профиль владельца читается из отдельного блока каждого CTLD сразу после пяти
слов заголовка: это 27 исходных DWORD, начиная с байта `20`. Control выбирает
для владельца только компонент с native ID `0`; его блок остаётся исходной
копией, а те же 27 слов отдельно копируются в изменяемую память владельца.
Поэтому профиль дочернего компонента, даже если его `INTO` обновляет строку
таблицы `0`, не подменяет профиль корня. В Rust `PreparedComponentControl`
сохраняет эти слова без предположений об их типах, а полная Control-проекция
хранит источник, native ID, исходный массив и отдельную начальную копию. Эти
массивы сначала равны; отсутствие CTLD у компонента с ID `0` остаётся явным
`None`, без дочернего fallback или синтетических нулей. Точные native writes и
открытые вопросы о смысле полей приведены в [техническом разборе загрузки CTLD](../appendices/script-vm.md#профиль-владельца-control-при-загрузке-ctld).
Изолированный x86-вектор подтвердил общий проход `Control.dll!FUN_1000fca0`
(RVA `0x0fac0`): расчёт начинает с исходного profile word 26, а затем пишет
производное значение в изменяемую копию и заполняет три поля-накопителя
владельца, а также `+0x20` каждой строки provider. Для этого выбираются два
сырых слова `Slot68` через `Node38` state 0 / LOD 0, учитываются cache tag 27 и
устройства таблицы.
Смысл слов `Slot68` и итоговых чисел не установлен; мы не называем их массой
или площадью.
Rust повторяет эту формулу изолированным helper-ом, и его тест совпадает с
оригинальным кодом на синтетических векторах при x87 control word `0x007f`.
В обычной цепочке загрузки AniMesh начинает с масштабов `[1, 1, 1]`, а вызванные
до FAC0 обработчики привязки модели, FX и collision-компонентов эти поля не
меняют. Поэтому загрузчик использует подтверждённые значения конструктора, а
не scale из TMA. Теперь `build_control_device_projection` выполняет FAC0 после
сборки provider rows и списка устройств и публикует результат вместе с копией
профиля владельца.
Игнорируемый corpus-тест полного загрузчика на оригинальном
`UNITS/BUILDS/MINE/min_l_n1.dat` подтверждает сопоставление всех 25 provider
rows с начальными узлами и проекцию 11 устройств. Для профиля mine получены
агрегаты `+0x530 = 0x470833fc`, `+0x534 = 0x4897e712` и
`+0x538 = 0x4d9452cb`; тест сверяет также все 25 значений строк. Это результат
Rust loader-а на лицензированных данных, а не сравнение с полным native load
оригинала. Смысл слов `Slot68` и итоговых чисел по-прежнему неизвестен. Точные
входы, вектор и границы приведены в
[техническом разборе загрузки CTLD](../appendices/script-vm.md#изолированный-проход-fac0).
Для tag `19` фабрика создаёт объект с его исходными классом и vtable. Базовый
конструктор `FUN_100218b0` задаёт multiplier `+0x4c = 1.0`. Override через
`FUN_1002d890` вызывает у выбранного объекта vtable slot `+0x1c`, то есть
`FUN_10021d50` с флагом инициализации `0`. Этот вызов не создаёт объект и не
заменяет класс или vtable. Он сохраняет уже существующий ключ `+0x04`; сохраняет
текущее значение multiplier `+0x4c` (которое могло измениться после
конструктора); и копирует коэффициенты из CTLD в блок `+0x54`. Поле `+0x50`
обновляется лишь когда слово записи `+0x18` не равно `-1`. Сопоставленные
записи значений прибавляются к текущим полям объекта `+0x1c/+0x20`, а не
сбрасывают их. Поэтому «переопределение» здесь означает обновить часть данных
того же устройства, с ограничениями конкретного метода.
Native x86 oracle для тех же GOG DLL проверяет переопределение получателей
factory tags `2`, `3`, `4`, `5`, `8`, `9`, `10`, `15`, `19`, `21`, `25` и `27`.
Тест подтверждает сохранение существующего слота, класса/vtable, ключа,
component ID и multiplier `2.5` при копировании новых коэффициентов. Для новых
получателей конструкторы и начальное слово `+0x50` связаны с такими RVA:
| Factory tag | Конструктор | Vtable | Начальное `+0x50` |
| --- | --- | --- | --- |
| `2` | `0x294c0` | `0x3cbd8` | `0` |
| `3`, `21` | `0x20800` | `0x3c448` | `5` |
| `4` | `0x233d0` | `0x3c720` | `0x200` |
| `5` | `0x20800`, затем замена vtable фабрикой | `0x3cdf8` | `5` |
| `8` | `0x24310` | `0x3c800` | `5` |
| `10` | `0x26470` | `0x3ca20` | `0x2000` |
| `27` | `0x218b0` | `0x3c528` | `0` |
У всех строк таблицы multiplier начинается с `1.0`. Tags `3` и `5` проверены
через настоящую фабрику `0x2d4b0`; tag `3` попадает в её default-ветвь.
Общий reader `0x21d50` сохраняет переменный хвост: две map-строки `7` и `9`
и дополнительная строка `fx` возвращают cursor `record + 0xbf`.
Слово CTLD `+0x18 == -1` сохраняет фабричное `+0x50` и при INTO-замене.
Ветвь фабрики `0x2d4b0` направляет factory tags без отдельного случая в
конструктор `0x20800`; его vtable использует общий reader `0x21d50` в slot
`+0x1c`. Поэтому common-field override применяется и к этой default-семье,
включая tag `6`; Rust-тест проверяет его вместе с тегами `12` и `13`. Tags
`1`, `17`, `26` и `30` остаются явными границами: фабрика выбирает для них
отдельные классы, а проверки текущей реализации ещё не покрывают их полное
setter-поведение.
У tag `2` slot `+0x1c` указывает на обёртку `0x29650`. Она вызывает тот же
reader, затем сохраняет selector `1` в `+0x120`, округляет selector `0` через
x87 `FISTP` в `+0x124` и заново обнуляет массив `+0x12c` по накопленному числу
map-строк. Native тест получает `1.25`, округление `3.5 → 4` и четыре нулевых
элемента после повторного override. Rust хранит общую проекцию устройства;
эти оружейные кэши и дальнейшая стрельба в неё пока не входят. Slot `+0x24`
этого класса (`0x29940`) проверен только с неизменной пустой парой ресурсов.
Tag `9` проверяется без FX-хоста (`+0x3c == 0`) и без map-строк; его FX-путь
этим опытом также не подтверждён.
Полный Rust loader на исходном `MISSIONS/Autodemo.00/data.tma` строит доступные
таблицы для всех восьми объектов: четыре робота содержат `14`, `12`, `15`, `13`
устройств, затем два завода по `12` и два генератора по `11`. Это проверка
подготовки таблиц на настоящих ресурсах, а не сравнение полного игрового Control
с запущенным оригиналом.
Статический разбор глобального Control interface показывает aggregate getters
`1/2` (`FUN_1002b410`, RVA `0x2b410`) и setter `1` (`FUN_1002ba30`, RVA
`0x2ba30`) для текущего descriptor tag `19`. Getter `1` складывает ответы
виртуального selector `0x200` в числитель и selector `0` в знаменатель; getter
`2` возвращает сумму selector `0`. Для обычного factory tag `19` эти ответы
равны `coefficient * multiplier` и `coefficient`, то есть отношение сводится к
`sum(coefficient * multiplier) / sum(coefficient)`. У factory tag `9` selector
`0` умножает коэффициент на отдельное поле `+0x11c`, тогда как selector `0x200`
использует коэффициент и multiplier; эта особая база меняет знаменатель.
Обработчик factory tag `9` расположен в Control RVA `0x255c0`, а его конструктор
RVA `0x25460` задаёт `+0x11c = 1.0`. При встрече отрицательного ответа selector
`0` getter `1` сразу возвращает `1.0`, а getter `2` — этот ответ. Если подходящих
записей нет или сумма базы неположительна, getter возвращает неуспех без записи
результата. Setter пишет multiplier `+0x4c` только устройствам с текущим
descriptor tag `19` и положительным selector-0 ответом; пустой набор возвращает
успех. Фильтр использует **текущий descriptor tag**, который может измениться
при `INTO`, а виртуальный вызов остаётся методом исходного factory-класса.
Этот путь проверяется native x86 oracle на оригинальной `Control.dll` указанной
выше: искусственные factory/descriptor пары `9`, `15` и `19` покрывают setter,
отрицательные ответы, округление и тег `9` с scale `0.5`. Для factory tag `25`
его vtable slot `+0x10` статически указывает на общий getter RVA `0x21d00`, но
отдельный aggregate oracle этот класс не запускал. Rust хранит начальный scale
factory `9` и предоставляет `query_loaded_mission_control_resource` для selector
`1`/`2` и `set_loaded_mission_control_multiplier` для setter-а `1`. Вызовы
сначала проверяют живой `ObjectHandle`, затем обращаются к таблице этого объекта
из полной загрузки миссии. Синтетические тесты проверяют границы пустой таблицы,
нулевого знаменателя, неподдержанных factory tags и чисел за диапазоном
нормального `f32`; лицензированный тест сверяет реальные descriptor tags и
получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а.
В этом API пока реализованы только getter selectors `1`/`2` и setter `1`.
Отдельный native setter selector `17`, tag-specific методы прочих factory tags,
их внешние эффекты и вызов из регулярного шага Behavior здесь не моделируются.
Числа должны оставаться конечными и нормальными либо точным нулём; произведения
и отношения за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не
подменяются нулём или `None`. При проверке диапазона Rust оценивает операцию в
`f64`, затем выполняет её в `f32`; это ограниченная проверка диапазона, а не
эмуляция полного x87 accumulator-а.
Отдельная ветка того же GOG `AniMesh.dll` показывает, когда создаётся Behavior
для host object. В `FUN_10002ff0` (RVA `0x2ff0`, SHA-256
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) вызывающая
сторона запрашивает IID `0x204` и сохраняет интерфейс в поле `+0x168`. Затем
отдельный импорт `GetQueue()` возвращает объект очереди; вызов метода через его
vtable slot `+0x30` даёт результат, который проверяется на ненулевое значение.
Только при ненулевом результате host class `3` вызывает
`CreateBehaviour(0, 0, self)` (RVA `0x34bd`); class `4` передаёт в
`CreateBehaviour` адреса полей prototype `+0x140` и `+0x160` (RVA `0x3426`).
Семантика метода slot `+0x30` здесь не установлена. Это подтверждает только
такую ветку и эти два класса; другие фабрики, нулевой результат и отложенные
spawn сюда не входят.
### Один запрос Control для ресурсного устройства
В CTLD запись tag `26` задаёт ресурсный множитель, но текущее значение
поставщика живёт в runtime state Control. GOG `Control.dll` (SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`)
`FUN_10021d00` (RVA `0x21d00`) реализует getter selector `0x300`. Его фиксированное
слово `+0x2c` становится `fixed_words[11]` при копировании записи RVA `0x21d50`;
`fixed_words[1]` — ключ строки NDP. В объекте Control getter использует ключ
`+0x04`, provider interface `+0x44`, множитель `+0x4c` и коэффициент `+0x54`.
Provider interface направляет selector `1` и ключ в Control RVA `0xdc40`, где
читается текущее type-1 значение выбранной строки. Затем selector умножает
сначала provider на коэффициент, после чего на множитель: `provider * base *
multiplier`. Коэффициент приходит из CTLD; NDP связывает ключ с описанной
строкой. Native loader создаёт для этой строки отдельное runtime-состояние: у
`Control.dll` (та же сборка, SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`)
`FUN_1000b240` (RVA `0xb240`) строит 11 слов строки provider и ставит её
selector-1 поле `+0x14` в `1.0`. `FUN_10008b10` (RVA `0x8b10`) выбирает строки
NDP, а `FUN_1000a100` (RVA `0xa100`) добавляет их в общую таблицу с шагом
`0x2c`; getter `FUN_1000dc40` (RVA `0xdc40`) использует ключ устройства как
индекс этой таблицы. Для корня загружаются все строки. `INTO` не добавляет
строк, а примонтированный `EXTO` пропускает свою первую строку. Пустая
таблица такого `EXTO` не содержит строки, которую native loader должен
пропустить, поэтому Rust оставляет provider lookup недоступным; sidecar с одной
строкой даёт пустой вклад. Если mount узел не разрешился, строки этого `EXTO`
в таблицу не попадают. Это отдельная
последовательность provider-строк: её длина берётся из NDP, она не дополняется
до числа узлов MSH и не участвует во второй перенумерации ключа.
Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке
`Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
`FUN_100186b0` (RVA `0x186b0`) перечисляет устройства типа `0x1a`, а
`FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector
`0xe00`. В парной `Control.dll` выше `FUN_1002bb40` (RVA `0x2bb40`) для типа
`0x1a` вызывает selector `0x300`, описанный в этом разделе. Затем Behavior query
`0x1002` (RVA `0xa490`) умножает полученную сумму на поле `+0x58`
`Transfer_Ore_OffBoard` блока `+0x820`; отдельный selector `0x1005` возвращает
`+0x60` `Transfer_Power_Out`. Имена регистрируются в RVA `0x22e20`, а конструктор
RVA `0x22d90` задаёт начальные значения 5.0 и 100.0. Восемнадцать точных class
codes выбирают записи `.var` из `behpsp.res`: например, `0x80000004` выбирает
`prof_mine.var`, `0x80000002` — `prof_generator.var`, а три кода bunker-класса
выбирают `prof_bunker.var`. Таблицу этих соответствий задаёт mapper
`FUN_10008a80` (RVA `0x8a80`) в Behavior.dll SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`.
Установленные соответствия mapper-а:
| Class flags | Запись `behpsp.res` |
| --- | --- |
| `0x80000004` | `prof_mine.var` |
| `0x80000008` | `prof_storage.var` |
| `0x80000002` | `prof_generator.var` |
| `0x80000010` | `prof_plant.var` |
| `0x80000040` | `prof_angar.var` |
| `0x80000200` | `prof_mtp.var` |
| `0x80000400` | `prof_institute.var` |
| `0x80010000`, `0x80020000`, `0x80040000` | `prof_bunker.var` |
| `0x80100000`, `0x80200000` | `prof_tower.var` |
| `0x01002000` | `prof_trn.var` |
| `0x01004000` | `prof_bld.var` |
| `0x01008000` | `prof_war.var` |
| `0x01010000` | `prof_hq.var` |
| `0x01020000` | `prof_hero.var` |
| `0x20000000` | `prof_animal.var` |
У бинарной строки `.var` есть имя, индекс типа, размер и две области данных.
Native parser RVA `0x49fa0` читает запись, binder RVA `0x49090` связывает её с
зарегистрированным полем, а apply-loop RVA `0x49170` копирует в поле именно
вторую область. В профиле используются точные C-string имена
`Transfer_Ore_OffBoard` и `Transfer_Power_Out`; регистр учитывается, первый
совпавший ряд побеждает. Rust ограничивает чтение исходной записи NRes её
границами и сохраняет значения как исходные 32-битные слова у соответствующего
`ObjectHandle`. Разбор профиля для полной миссии завершается до создания нового
мира: отсутствие или повреждение профиля известного класса прерывает загрузку,
не подменяя предыдущую миссию. Если mapper не знает class code, остаются
конструкторские значения 5.0 и 100.0; это не догадка о неизвестном профиле.
Preview не загружает профили.
При вычислении `0x1002` native `FUN_100186b0` (RVA `0x186b0`) добавляет в список
только текущие Control descriptor-и tag `26`, после чего
`FUN_100198e0` (RVA `0x198e0`) суммирует их успешные `0xe00` запросы. FParkan
делает то же для загруженной таблицы в её исходном порядке и затем умножает
сумму на профильный `Transfer_Ore_OffBoard`. Записи tag `5`, `10` и `21` в общей
таблице в этот список не попадают. Запрос `0xe00` проходит по ключу устройства
в исходную provider-таблицу и пока поддержан только для текущего descriptor
tag `26` с factory tag `26`; для него результат равен
`provider * fixed_words[11] * multiplier`. Лицензированный тест загружает
оригинальные CTL/NDP и полную миссию с этим Unit DAT. В установленном
`min_l_n1.dat` class word равен `0x80000004`, поэтому исходный прогон выбирает
`prof_mine.var`. Для проверки другого отображения второй прогон оставляет тот же
TMA и компоненты, но во временном VFS заменяет слово класса заголовка DAT на
`0x80000002`; это тестовая подмена, а не содержимое лицензированного файла.
Профиль mine даёт `Transfer_Power_Out` 0.0 и `Transfer_Ore_OffBoard` 1.0;
оригинальный профиль генератора — 10.0 и 0.0 соответственно. Для исходного
tag-26 ряда получается `1.0` provider × `1.0` coefficient × `1.0` multiplier;
профиль mine умножает эту сумму на `1.0`, поэтому selector `0x1002` возвращает
`1.0`. Эти числа описывают начальное состояние после конструкторов.
Сами запросы читают текущую долю provider; отдельное прямое изменение строки
уже может изменить ответ, хотя полный игровой Control tick ещё не исполняется.
У Control есть и начальные сведения о модели узла, но их таблица не совпадает
с NDP. Sidecar задаёт состав и порядок provider-строк, а поле type-1 `+0x14`
получает начальное значение `1.0` от нативного конструктора. AniMesh отдельно
собирает глобальный список MSH-узлов. При создании строки Control берёт
mesh-состояние по её порядковому номеру в общей таблице provider, а не пытается
угадать пару по локальному номеру строки sidecar-а. Например, после 22
корневых строк номер 22 указывает на первый глобальный узел присоединённой
`EXTO`-модели, который
происходит из её локального узла `1`.
В снимке это необязательное поле: `None` означает, что для данного номера не
удалось получить MSH-узел; исходные запросы к NDP от этого не исчезают. Поэтому
начальное значение и поза модели остаются отдельными частями состояния:
```rust
initial_mesh_state: Option<MissionControlNodeInitialMeshState>
```
Внутри снимка хранятся родительский индекс, переведённые флаги Control и число
начальных стадий. Узлы корневой модели добавляются все, а присоединённая `EXTO`
начинает с локального узла `1`. Это описывает исходную настройку. Отдельный
API уже пересчитывает стадию одного узла, но полный игровой Control tick
пока не исполняется.
На той же начальной стадии Control вычисляет количество строки по NDP
`max_volume`. Для загрузки Unit DAT проверенные множители равны `1.0`, поэтому
количество совпадает с исходным значением NDP. Оригинал начинает с одинакового
текущего и последнего обработанного количества. Rust оставляет исходное число
в `initial_amount_bits`, а текущее и обработанное количество хранит отдельно.
Вместимость при этом берётся из NDP; исходный снимок не заменяет её. В лицензированном `min_l_n1.dat`
проверены, например, root row `0` со значением `1.0` и строка `EXTO` с local
row `1` в общей позиции `22` со значением `6000.0`.
Для одной строки перенесён прямой путь `Control.dll+0x10f30` с выключенным
флагом накопления анимационного изменения. API
`apply_loaded_mission_control_node_delta` принимает живой объект, глобальный
ключ строки и добавку. Например, у строки с максимумом `100` и количеством
`40` добавка `20` даёт количество `60` и долю `0.6`. Переполнение ограничивается
максимумом, отрицательный результат — нулём; при нуле ставится флаг истощения
`0x10`, а пополнение снимает его. Общий итог изменяется именно на разницу
нового и прежнего количества:
```rust
let amount = (previous + delta).clamp(0.0, maximum);
let total = (total + (amount - previous)).max(0.0);
let ratio = amount / maximum;
```
Это три отдельные операции над `f32`, с проверкой поддерживаемого численного
диапазона до записи состояния. Исходные `initial_*` поля сохраняются;
`processed_amount_bits` тоже не меняется, поскольку его обновляет другой
нативный проход — пересчёт стадий. Публикация selector `0x200` теперь применяется
к CPU-состоянию соответствующего узла модели. Результат
`ControlNodeProviderPublication` описывает уже выполненную запись. Пересчёт
стадий и вывод изменённой модели в окно этим API ещё не запускаются.
У AniMesh есть собственное поле значения узла, отдельное от доли Control.
Конструктор `AniMesh.dll+0x123e0` начинает его с `1.0`, а видимую стадию — с
`0`. Callback `+0x5750` при selector `0x200` записывает число в поле `+0x124`
строки модели с шагом `0x130`, не меняя её стадию `+0x1c` и соседние узлы.
Native oracle соединяет исходные `Control+0x10f30` и `AniMesh+0x5750`; между
ними стоит только запись диагностической трассы. Прямая добавка `20` к
количеству `40` из максимума `100` записывает `0.6` и в provider, и в узел
модели. Эти поля нельзя объединять: наследование родительской доли в отдельном
стадийном проходе меняет provider, не повторяя callback `0x200`.
В Rust `mesh_state` хранит это отдельное значение и видимую стадию;
неразрешённый узел прерывает изменение до записи остальных полей. Истощение корня либо узла
с критическим флагом `1` пока возвращает ошибку без изменения состояния:
эта ветвь требует полного lifecycle объекта, а не только нового количества.
Стадия пересчитывается отдельным вызовом
`update_loaded_mission_control_node_stage`. Для трёх состояний доли `1`, `0.5`
и `0.1` выбирают стадии `0`, `1` и `2`. Нулевая доля даёт служебную стадию
Control `3`, но в модель записывается последняя видимая стадия `2`. Поэтому
`current_stage` и `mesh_state.visible_stage` хранятся отдельно. Флаги `4` и `8`
могут брать соответственно долю или стадию родителя; доля самой модели этим
пересчётом не переписывается.
После успешного вызова обработанное количество становится равным текущему,
даже если стадия осталась прежней. Истощённые защищённые узлы сохраняют
нативный ранний выход и не обновляют этот снимок. Неизвестные parent/count,
неподдержанные lifecycle-вызовы, FX и пересчёт геометрии на границе истощения
возвращают ошибку до записи. Аргумент `update_effects` сохраняет нативный смысл:
его нельзя молча отключать при включении полноценного игрового цикла.
На настоящем двухстадийном узле `min_l_n1.dat` тест уменьшает количество вдвое,
явно вызывает стадийный API без FX и получает Control stage `1`, видимую стадию
модели `1` и обновлённое обработанное количество. Это проверка отдельной
операции на ресурсах, а не переключение кадра в игровом окне.
На исходном `min_l_n1.dat` прямое уменьшение количества строки ресурсного
устройства вдвое меняет ответы `0xe00` и Behavior `0x1002` с `1.0` на `0.5`.
Обычный последующий шаг runtime сохраняет это состояние. Это воспроизводимый
тест связки загрузки и запросов, но пока не автоматическое изменение через
clock Control.
После загрузки количество в строках может меняться. Контроллер распределяет
добавку либо пропорционально свободному месту, либо по очереди: при максимумах
`[30, 60, 50]`, текущих значениях `[10, 20, 0]` и добавке `22` первый способ
даёт `[14, 28, 10]`. При вычитании последовательный способ начинает с последней
строки. Пропорциональное вычитание делит запрос по текущим количествам.
У каждой изменившейся строки есть отдельный накопитель изменений количества: он
получает фактически применённую разницу, а не исходный запрос.
Большие суммы округляются в двоичном формате. Поэтому после нескольких
маленьких изменений общий запас иногда оказывается чуть больше первоначальной
ёмкости; оригинал продолжает считать с этой отрицательной разностью, а не
исправляет её отдельным правилом.
У строк есть и связи с родительскими узлами. Нативный `Control.dll` сначала
обновляет запрошенную строку через `0x11220`, затем `0x11130` ищет её детей
среди строк с большим индексом. Каждый найденный ребёнок обновляется вместе со
своим поддеревом, и только потом обход продолжается со следующим соседом.
Например, для родителей `[-1, 0, 0, 1, 3]` порядок строк становится
`1 → 3 → 4 → 2`: сначала весь первый участок дерева, затем его сосед.
Стадия увеличивается, когда доля ресурса уменьшается. Для числа вариантов
`count` внутренняя стадия вычисляется как `count - ceil(f32(count * ratio))`,
а видимое значение ограничивается диапазоном модели. Например, при трёх стадиях доли
`[0.1, 0.4, 0.5, 1, 0]` дали `[2, 1, 1, 0, 3]`; последнее значение — sentinel
истощения, а в модель передаётся индекс не выше `count - 1`. Нативные проверки
подтвердили арифметику и порядок обхода.
После обновления родителя Control сравнивает его старую стадию с новой и
проверяет флаг истощения `0x10`. Если стадия выросла либо строка истощена,
количество каждого ребёнка полностью уменьшается тем же путём, что и обычная
добавка ресурса. Для ребёнка без защитной маски родительские флаги `0xc0`
задают флаг `0x80`; в обычной ветви переход ставит `0x100` при ухудшении и
очищает у ребёнка `0x80`. Маска `0x140` запрещает только изменение этих флагов:
уменьшение количества и рекурсивный вызов всё равно выполняются. Дочерние
стадии пересчитываются и тогда, когда родитель не ухудшился.
Выбранная стадия также задаёт, какие данные MSH попадают в пересчёт видимости.
У одного синтетического узла первый слот давал `[42, 330]`, а слот другой стадии —
`[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном
включении восстановили суммы; переходы с разными слотами — нет. Эти проверки
исполняют исходные DLL на тестовых строках, но не являются запуском Control на
полной миссии.
В Rust `update_loaded_mission_control_node_stage` сохраняет отдельный шаг одной
строки, а `propagate_loaded_mission_control_node_stage` обновляет строку и её
поддерево в том же порядке, что native `0x11130`. Весь снимок provider-таблицы
публикуется только после успешной обработки всех потомков. Если позже по обходу
понадобится неизвестная связь узлов, lifecycle, FX или пересчёт видимости,
операция сообщает об этом и оставляет снимок без изменений. Эта CPU-проекция
ещё не подключена к игровому Control clock или выводу модели в окно.
Прямой путь изменения количества остаётся отдельной операцией.
Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов).
Для точного повтора важен порядок округления. При исходных значениях `[2^24, 2]`
и запросе `-1` оригинал сначала складывает общий итог с изменением, затем
вычитает прежний итог. Из-за округления фактическое изменение получается `-2`.
Rust переносит этот порядок, сохраняет native накопитель каждой строки и
запускает обязательный проход стадий с effects. Если ему нужны ещё не
реализованные FX или lifecycle-вызовы, таблица остаётся без изменений. Эти
операции обновляют только CPU-снимок и пока не подключены к игровым часам Control.
Распределение по бюджету тоже доступно как отдельный CPU-вызов
`advance_loaded_mission_control_budget`. Он сначала собирает положительные
ставки, затем обслуживает категории в исходном порядке и передаёт каждому
устройству его долю. Например, при запасе `40/100`, доле `0.4` и интервале
`0.5` секунды tag 19 может принести `0.1`, а tag 15 запросить `0.2`.
Потребитель получает половину запроса: запас становится `40.1`, доля — `0.401`,
а множитель генератора уменьшается до `0.75`. Хотя генератор может стоять раньше
в массиве, его обновление перечитывает уже изменённую долю после обслуживания
категории tag 15. Этот явный вызов хранит также две выходные ставки `+0x5dc` и
`+0x5e0`, но сам по себе ещё не запускается игровыми часами.
Помимо класса `15` и генератора `19` поддержан класс `9`: он считает ставку
через шесть собственных значений и при распределении обновляет их вместе с
накопленными изменениями. Например, для доли provider `0.4`, интервала `0.5`,
`B = 0.2`, коэффициентов `C0 = 0.75`, `C1 = 0.6`, `C2 = 2` и масштаба
`1.25` исходная ставка имеет биты `0xbeae147b`; после полного выделения
обновлённые шесть значений совпадают с native побитно.
Несколько фабричных классов используют общий callback: ставка равна
`-(dt * B)`, а обновление записывает долю выделения в множитель. К этому
семейству относятся фабричные теги `12`, `13`, `25` и `21`, а также другие
классы, которые исходный диспетчер направляет в фабрику по умолчанию. Текущая
категория читается из descriptor: её native map подтверждён только для тегов
`0..=34`. Неподдержанный специализированный callback, недоступный provider или
неподдержанное число останавливают шаг без частичных изменений.
У фабричного tag `26` ставка складывается из базового значения устройства и
изменяемой добавки. Затем сумма умножается на прошедшее время:
```text
rate = -((B + extra) * dt)
```
Например, при `B = 0.2`, `extra = 0.3` и `dt = 0.5` устройство запрашивает
`0.25`. Если доступный бюджет равен `0.125`, оно получает половину запроса;
устройство сохраняет долю распределения, а сама добавка переживает замену
descriptor через `INTO`. Так разные части Control могут менять базовую запись и
текущее состояние независимо.
Эту добавку записывает исходный selector `0x500`, когда текущий descriptor
тоже относится к tag `26`. Мы восстановили его точку вызова в Behavior, но
смысл передаваемого там значения пока не установлен, поэтому этот Behavior путь
ещё не исполняется. Подтверждённые callbacks tag `26`, их native адреса и
точные проверочные векторы приведены в [техническом приложении](../appendices/script-vm.md#фабричный-tag-26-изменяемая-добавка).
Полный явный CPU-проход по исходной таблице из одиннадцати устройств
`min_l_n1.dat` тоже завершился. Этот результат проверяет вычисление для одной
загруженной таблицы; обновление пока не подключено к игровым часам и не
заменяет полный Control tick.
Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и
передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`,
и значение строки уменьшается на `2`; этот коэффициент не является ставкой
производства из профиля. Источник абсолютных часов и их доставка из World3D
в Control прослежены по инструкциям. Активная бюджетная часть проверена на
искусственном хосте класса `10`; полного тика объекта на миссии ещё нет.
У ресурсного шага отдельные часы. После принятого межкадрового интервала
Control заново разыгрывает срок обновления, даже если предыдущий вызов его
ещё не достиг. Например, с периодом `500` и начальным RNG `0x12345678`
исходная DLL пропустила времена `500` и `501`, затем выполнила ресурсный шаг
на `502`. Выбрать случайный срок один раз и ждать его было бы неверно.
Принудительный шаг тоже расходует RNG; неактивный объект синхронизирует часы
без такого расхода. Это локальные native проверки, а не автоматический Rust
Control tick: запросы Rust `0xe00` и `0x1002` читают текущее состояние строк,
которое меняют явные API одной строки, распределения и бюджетного шага, а
`0x1005` — загруженную ставку профиля.
Точные нативные векторы и открытые границы описаны в [приложении](../appendices/script-vm.md#изменение-provider-состояния-control).
На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит,
но промежуточная степень может выйти за диапазон `f32` и вернуться в него при
следующем умножении. Native oracle проверяет такой порядок для getter-а
`0x300`. Rust запрос сохраняет тот же порядок, но поддерживает только конечные
нормализованные значения или ноль на входе, промежуточном и конечном этапах.
Остальные случаи за этим численным диапазоном он отклоняет; полный x87
accumulator здесь не эмулируется.
Лицензированный тест на Unit DAT `UNITS/BUILDS/MINE/min_l_n1.dat` проходит через
`fr_l_mine.ctl` и `fr_l_mine.ndp`: у найденного tag-26 descriptor ключ равен
`2` в своей записи, raw коэффициент — `0x3f800000`. Корневой NDP содержит 22
строки, sidecar примонтированного `EXTO` — 4; в общую provider-таблицу входит
25 строк после пропуска локальной строки `0`. Тест также сравнивает порядок
всех source-row индексов с порядком строк самих sidecar таблиц. Он вызывает
Rust query по восстановленному native правилу адресации, а не getter оригинальной
DLL на полной миссии. Значение `1.0` подтверждает начальное состояние по native
конструктору, но не наблюдение живого Control tick-а.
### Control system и physical model ### Control system и physical model
@@ -425,13 +1295,16 @@ offsets 119–121. В `fr_l_gener.ctl` найдено 35 строк type 4, вк
`f_gener_ball` с offset 85. `f_gener_ball` с offset 85.
Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`. Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`.
Теги 2 и 5 направляются в общий читатель `+0x21D50`: таблица виртуальных методов Для tags `2`, `3`, `4`, `5`, `8`, `10`, `21` и `27` подтверждён общий читатель
тега 2 (`+0x3CBD8`) вызывает его через обёртку `+0x29650`, а таблица тега 5 `+0x21D50`: таблица виртуальных методов tag `2` (`+0x3CBD8`) вызывает его через
(`+0x3CDF8`) указывает прямо на него в позиции `+0x1C`. Читатель потребляет обёртку `+0x29650`, остальные перечисленные vtable указывают прямо на него в
позиции `+0x1C`. Читатель потребляет
заголовок размером `0xB0` байт со счётчиком `u32` по `+0xAC`, затем `count` заголовок размером `0xB0` байт со счётчиком `u32` по `+0xAC`, затем `count`
значений `u32`, слово длины и, при ненулевой длине, строку с завершающим NUL; значений `u32`, слово длины и, при ненулевой длине, строку с завершающим NUL;
указатель возврата указывает на следующую запись. Поэтому для тегов 2 и 5 подтверждён общий указатель возврата указывает на следующую запись. Rust-тест с tags `3`, `4`,
структурный формат; смысл их полей пока не назначен. `10`, `27` проверяет переход между записями после непустых values и строк,
а также отказ при оборванном завершающем NUL. Это подтверждает структурный
формат, но не назначает смысл всем коэффициентам устройств.
Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки
на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка