Add headless runtime tooling and Campaign.win analysis
This commit is contained in:
parent
57bf0666e0
commit
27172e3786
37 changed files with 11867 additions and 302 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -117,12 +117,23 @@ dependencies = [
|
||||||
name = "rrt-cli"
|
name = "rrt-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"rrt-fixtures",
|
||||||
"rrt-model",
|
"rrt-model",
|
||||||
|
"rrt-runtime",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rrt-fixtures"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rrt-runtime",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rrt-hook"
|
name = "rrt-hook"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -141,6 +152,15 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rrt-runtime"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ members = [
|
||||||
"crates/rrt-model",
|
"crates/rrt-model",
|
||||||
"crates/rrt-cli",
|
"crates/rrt-cli",
|
||||||
"crates/rrt-hook",
|
"crates/rrt-hook",
|
||||||
|
"crates/rrt-runtime",
|
||||||
|
"crates/rrt-fixtures",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ address,size,name,subsystem,calling_convention,prototype_status,source_tool,conf
|
||||||
0x00431b20,399,world_apply_compact_runtime_effect_record_to_resolved_targets,map,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Dispatches one linked compact runtime-effect record against the caller-resolved target context. The record token at `[*record+0x00]` is translated through the static table at `0x0061039c`; when the translated class lies in the `0x1f7..0x265` range the helper immediately re-enters `world_try_place_random_structure_batch_from_compact_record` `0x00430270`. Otherwise it jumps through the local class table at `0x004320b4/0x004320fc` into a mixed effect family that now includes: shell-state modifier branches over `0x006cec78`, signed scalar adjustments on resolved company/profile/territory targets through the `0x004d6611/0x004d6617` numeric readers, territory-access writes through `company_set_territory_access_rights_byte` `0x00424030`, selected-profile updates through `0x00434890/0x004348c0`, and several collection-side erase or follow-on branches. Current grounded caller is the outer loop at `0x00432317`, which walks linked compact records via `[record+0x24]` and supplies optional resolved company, chairman-profile, and adjacent owner context before each dispatch. This is therefore the current safest read for the wider compact runtime-effect dispatcher above the separate world-side structure-batch placement branch rather than as a placement-only helper.","objdump + local disassembly + caller correlation + effect-dispatch correlation"
|
0x00431b20,399,world_apply_compact_runtime_effect_record_to_resolved_targets,map,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Dispatches one linked compact runtime-effect record against the caller-resolved target context. The record token at `[*record+0x00]` is translated through the static table at `0x0061039c`; when the translated class lies in the `0x1f7..0x265` range the helper immediately re-enters `world_try_place_random_structure_batch_from_compact_record` `0x00430270`. Otherwise it jumps through the local class table at `0x004320b4/0x004320fc` into a mixed effect family that now includes: shell-state modifier branches over `0x006cec78`, signed scalar adjustments on resolved company/profile/territory targets through the `0x004d6611/0x004d6617` numeric readers, territory-access writes through `company_set_territory_access_rights_byte` `0x00424030`, selected-profile updates through `0x00434890/0x004348c0`, and several collection-side erase or follow-on branches. Current grounded caller is the outer loop at `0x00432317`, which walks linked compact records via `[record+0x24]` and supplies optional resolved company, chairman-profile, and adjacent owner context before each dispatch. This is therefore the current safest read for the wider compact runtime-effect dispatcher above the separate world-side structure-batch placement branch rather than as a placement-only helper.","objdump + local disassembly + caller correlation + effect-dispatch correlation"
|
||||||
0x004323a0,842,scenario_runtime_effect_record_service_and_dispatch_linked_compact_effects,scenario,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Per-record service pass over one live runtime-effect record in the scenario event collection. The helper first enforces several activation gates over the record's local control bytes and shell-side preview state, including one one-shot latch at `[this+0x81f]`, mode byte `[this+0x7ef]`, optional preview-policy byte `[this+0x7f4]`, and shell-side state at `0x006cec78/0x006cec74`. Once active it formats the optional status line through shell news helper `0x004554e0`, derives a target-availability bitmask through `0x0042d700`, resolves optional company/chairman/territory target pools, and then walks the linked compact effect chain rooted at `[this+0x00]`. Each linked record is dispatched through `world_apply_compact_runtime_effect_record_to_resolved_targets` `0x00431b20`, while fallback branches synthesize follow-on runtime-effect records through `scenario_runtime_effect_record_build_followon_effect_from_compact_record_and_targets` `0x00430b50`. When any effect fires it may refresh company share-price caches through `company_compute_public_support_adjusted_share_price_scalar` `0x00424fd0`, and records with nonzero `[this+0x7f5]` set the one-shot latch `[this+0x81f]`. Current grounded caller is the collection-wide service loop `scenario_event_collection_service_runtime_effect_records_for_trigger_kind` `0x00432f40`. This is therefore the current safest read for the runtime-effect record service and linked-effect dispatcher rather than a low-level target iterator.","objdump + local disassembly + caller correlation + runtime-effect-service correlation"
|
0x004323a0,842,scenario_runtime_effect_record_service_and_dispatch_linked_compact_effects,scenario,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Per-record service pass over one live runtime-effect record in the scenario event collection. The helper first enforces several activation gates over the record's local control bytes and shell-side preview state, including one one-shot latch at `[this+0x81f]`, mode byte `[this+0x7ef]`, optional preview-policy byte `[this+0x7f4]`, and shell-side state at `0x006cec78/0x006cec74`. Once active it formats the optional status line through shell news helper `0x004554e0`, derives a target-availability bitmask through `0x0042d700`, resolves optional company/chairman/territory target pools, and then walks the linked compact effect chain rooted at `[this+0x00]`. Each linked record is dispatched through `world_apply_compact_runtime_effect_record_to_resolved_targets` `0x00431b20`, while fallback branches synthesize follow-on runtime-effect records through `scenario_runtime_effect_record_build_followon_effect_from_compact_record_and_targets` `0x00430b50`. When any effect fires it may refresh company share-price caches through `company_compute_public_support_adjusted_share_price_scalar` `0x00424fd0`, and records with nonzero `[this+0x7f5]` set the one-shot latch `[this+0x81f]`. Current grounded caller is the collection-wide service loop `scenario_event_collection_service_runtime_effect_records_for_trigger_kind` `0x00432f40`. This is therefore the current safest read for the runtime-effect record service and linked-effect dispatcher rather than a low-level target iterator.","objdump + local disassembly + caller correlation + runtime-effect-service correlation"
|
||||||
0x00433130,169,scenario_event_collection_refresh_runtime_records_from_packed_state,scenario,thiscall,inferred,objdump + caller xrefs + local disassembly,3,"Collection-wide runtime materialization pass over the live event collection at `0x0062be18`. The helper stages one small packed header read from the caller-supplied state or stream object, walks every live event record in the collection through `indexed_collection_slot_count` `0x00517cf0`, `indexed_collection_get_nth_live_entry_id` `0x00518380`, and `indexed_collection_resolve_live_entry_by_id` `0x00518140`, and re-enters `scenario_event_refresh_runtime_record_from_packed_state` `0x0042db20` on each resolved record. When the sweep completes it clears the collection-side reentrancy or dirty latch at `[this+0x88]`. Current grounded callers are the `Setting up Players and Companies...` `319` lane inside `world_entry_transition_and_runtime_bringup` `0x00443a50` and one neighboring world-build path at `0x00448020`, so this now reads as the event-side runtime refresh pass beneath post-load world setup rather than an anonymous collection walk.","objdump + caller xrefs + local disassembly + event-collection correlation + post-load-pipeline correlation"
|
0x00433130,169,scenario_event_collection_refresh_runtime_records_from_packed_state,scenario,thiscall,inferred,objdump + caller xrefs + local disassembly,3,"Collection-wide runtime materialization pass over the live event collection at `0x0062be18`. The helper stages one small packed header read from the caller-supplied state or stream object, walks every live event record in the collection through `indexed_collection_slot_count` `0x00517cf0`, `indexed_collection_get_nth_live_entry_id` `0x00518380`, and `indexed_collection_resolve_live_entry_by_id` `0x00518140`, and re-enters `scenario_event_refresh_runtime_record_from_packed_state` `0x0042db20` on each resolved record. When the sweep completes it clears the collection-side reentrancy or dirty latch at `[this+0x88]`. Current grounded callers are the `Setting up Players and Companies...` `319` lane inside `world_entry_transition_and_runtime_bringup` `0x00443a50` and one neighboring world-build path at `0x00448020`, so this now reads as the event-side runtime refresh pass beneath post-load world setup rather than an anonymous collection walk.","objdump + caller xrefs + local disassembly + event-collection correlation + post-load-pipeline correlation"
|
||||||
|
0x004336d0,95,world_runtime_reset_startup_dispatch_state_bands,map,thiscall,inferred,objdump + caller xrefs + local disassembly,3,"Small runtime-object zero-init helper immediately above `shell_active_mode_run_profile_startup_and_load_dispatch` `0x00438890`. The helper clears a bounded startup-owned state band on the caller object, including `[+0x4cae]`, `[+0x4cb2]`, `[+0x46a80..+0x46aa0]`, `[+0x66b2]`, `[+0x66b6]`, `[+0x46c34]`, and `[+0x66ae]`, then returns the same pointer. Current grounded callers are the mode-`4` `LoadScreen.win` lane inside `shell_transition_mode` at `0x004830b6` and the multiplayer preview launch lane at `0x0046b8c9`, both of which then publish the returned object into `0x006cec78` and immediately call `0x00438890`. This is therefore the safest current read for the pre-dispatch runtime reset helper rather than another world-release path.","objdump + caller xrefs + local disassembly + pre-dispatch-state correlation"
|
||||||
0x00432ea0,103,scenario_event_collection_allocate_runtime_effect_record_from_compact_payload,scenario,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Allocates and initializes one live runtime-effect record in the scenario event collection at `0x0062be18` from a compact payload source. The helper allocates one temporary `0x88f` payload object, inserts a new collection entry through the generic collection allocator path, resolves the inserted live entry, and then initializes that entry from the caller-supplied compact payload through `0x0042d670` before freeing the temporary object. Current grounded callers are `scenario_runtime_effect_record_build_followon_effect_from_compact_record_and_targets` `0x00430b50` and the shell-side branch at `0x004db9f1`, where the returned live entry id is stored back into the caller object. This is therefore the current safest read for the scenario event collection's runtime-effect allocator rather than a generic collection clone helper.","objdump + local disassembly + caller correlation + runtime-effect-allocation correlation"
|
0x00432ea0,103,scenario_event_collection_allocate_runtime_effect_record_from_compact_payload,scenario,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Allocates and initializes one live runtime-effect record in the scenario event collection at `0x0062be18` from a compact payload source. The helper allocates one temporary `0x88f` payload object, inserts a new collection entry through the generic collection allocator path, resolves the inserted live entry, and then initializes that entry from the caller-supplied compact payload through `0x0042d670` before freeing the temporary object. Current grounded callers are `scenario_runtime_effect_record_build_followon_effect_from_compact_record_and_targets` `0x00430b50` and the shell-side branch at `0x004db9f1`, where the returned live entry id is stored back into the caller object. This is therefore the current safest read for the scenario event collection's runtime-effect allocator rather than a generic collection clone helper.","objdump + local disassembly + caller correlation + runtime-effect-allocation correlation"
|
||||||
0x00432f40,267,scenario_event_collection_service_runtime_effect_records_for_trigger_kind,scenario,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Collection-wide service loop over the live scenario event collection at `0x0062be18` for one caller-selected trigger kind byte. The helper first rejects fast-forward and editor-map gates through `0x006cec78+0x46c38`, `[0x006cec7c+0x82]`, and `[0x006cec74+0x68]` unless the trigger kind is `9`, then walks every live runtime-effect record through `indexed_collection_slot_count` `0x00517cf0`, `indexed_collection_get_nth_live_entry_id` `0x00518380`, and `indexed_collection_resolve_live_entry_by_id` `0x00518140`. Each resolved record is serviced through `scenario_runtime_effect_record_service_and_dispatch_linked_compact_effects` `0x004323a0` with the selected trigger kind and optional text sink. When any record fires, the helper refreshes every active company's cached share price through `company_compute_public_support_adjusted_share_price_scalar` `0x00424fd0`; when the collection dirty latch at `[this+0x88]` is raised it clears that latch and immediately reruns the whole pass with trigger kind `0x0a`. The caller split is now tighter too: recurring simulation maintenance drives kinds `1`, `0`, `3`, and `2` through `0x0040a276`, `0x0040a55f`, `0x0040a6cb`, and `0x0040a7a3`, while the neighboring route-style follow-on at `0x0040a91f` drives kinds `5` and `4` through `0x0040a930` and `0x0040a9ac`; world or startup-side company creation branches at `0x00407682`, `0x0047d293`, `0x0047d42b`, and `0x0047d6de` drive kind `7`; the kind-`6` branch is now tighter too, covering the placed-structure post-create tail at `0x0040f69e`, the build-version-gated company-startup or roster-refresh tail at `0x00428406`, and the route-entry post-change sweep at `0x004a3eae`; the kind-`8` world-entry one-shot gate now sits inside `world_entry_transition_and_runtime_bringup` `0x00443a50`, where it fires after the post-load company or route setup passes and then clears shell-profile latch `[0x006cec7c+0x97]`; and the `LoadScreen.win` briefing page at `0x004e520b` drives kind `9`. This is therefore the current safest read for the scenario event collection's collection-wide runtime-effect service loop rather than a generic text-query helper.","objdump + local disassembly + caller correlation + collection-service correlation + trigger-kind callsite decode"
|
0x00432f40,267,scenario_event_collection_service_runtime_effect_records_for_trigger_kind,scenario,thiscall,inferred,objdump + local disassembly + caller correlation,3,"Collection-wide service loop over the live scenario event collection at `0x0062be18` for one caller-selected trigger kind byte. The helper first rejects fast-forward and editor-map gates through `0x006cec78+0x46c38`, `[0x006cec7c+0x82]`, and `[0x006cec74+0x68]` unless the trigger kind is `9`, then walks every live runtime-effect record through `indexed_collection_slot_count` `0x00517cf0`, `indexed_collection_get_nth_live_entry_id` `0x00518380`, and `indexed_collection_resolve_live_entry_by_id` `0x00518140`. Each resolved record is serviced through `scenario_runtime_effect_record_service_and_dispatch_linked_compact_effects` `0x004323a0` with the selected trigger kind and optional text sink. When any record fires, the helper refreshes every active company's cached share price through `company_compute_public_support_adjusted_share_price_scalar` `0x00424fd0`; when the collection dirty latch at `[this+0x88]` is raised it clears that latch and immediately reruns the whole pass with trigger kind `0x0a`. The caller split is now tighter too: recurring simulation maintenance drives kinds `1`, `0`, `3`, and `2` through `0x0040a276`, `0x0040a55f`, `0x0040a6cb`, and `0x0040a7a3`, while the neighboring route-style follow-on at `0x0040a91f` drives kinds `5` and `4` through `0x0040a930` and `0x0040a9ac`; world or startup-side company creation branches at `0x00407682`, `0x0047d293`, `0x0047d42b`, and `0x0047d6de` drive kind `7`; the kind-`6` branch is now tighter too, covering the placed-structure post-create tail at `0x0040f69e`, the build-version-gated company-startup or roster-refresh tail at `0x00428406`, and the route-entry post-change sweep at `0x004a3eae`; the kind-`8` world-entry one-shot gate now sits inside `world_entry_transition_and_runtime_bringup` `0x00443a50`, where it fires after the post-load company or route setup passes and then clears shell-profile latch `[0x006cec7c+0x97]`; and the `LoadScreen.win` briefing page at `0x004e520b` drives kind `9`. This is therefore the current safest read for the scenario event collection's collection-wide runtime-effect service loop rather than a generic text-query helper.","objdump + local disassembly + caller correlation + collection-service correlation + trigger-kind callsite decode"
|
||||||
0x00433bd0,546,world_refresh_selected_year_bucket_scalar_band,simulation,thiscall,inferred,objdump + local disassembly + caller inspection,3,"Shared selected-year companion beneath `world_set_selected_year_and_refresh_calendar_presentation_state` `0x00409e80`. The helper reads the packed world year at `[this+0x0d]`, bins it against the threshold table at `0x005f3978/0x005f3980`, derives one interpolated bucket fraction when the current year falls inside a nontrivial range, and writes the resulting float band into `[this+0x65]`, `[this+0x69]`, `[this+0x6d]`, and `[this+0x4ca2]` after one build-version-sensitive clamp through `0x00482e00`. Current grounded callers are the year-step path in `simulation_service_periodic_boundary_work` around `0x0040a123`, the post-fast-forward setup tail around `0x00437168`, and the later staged-profile rehydrate band inside `world_entry_transition_and_runtime_bringup` `0x00443a50`, so this is the safest current read for the shared year-bucket scalar rebuild helper rather than a world-entry-only follow-on.","objdump + local disassembly + caller inspection + year-bucket-table correlation + world-entry correlation"
|
0x00433bd0,546,world_refresh_selected_year_bucket_scalar_band,simulation,thiscall,inferred,objdump + local disassembly + caller inspection,3,"Shared selected-year companion beneath `world_set_selected_year_and_refresh_calendar_presentation_state` `0x00409e80`. The helper reads the packed world year at `[this+0x0d]`, bins it against the threshold table at `0x005f3978/0x005f3980`, derives one interpolated bucket fraction when the current year falls inside a nontrivial range, and writes the resulting float band into `[this+0x65]`, `[this+0x69]`, `[this+0x6d]`, and `[this+0x4ca2]` after one build-version-sensitive clamp through `0x00482e00`. Current grounded callers are the year-step path in `simulation_service_periodic_boundary_work` around `0x0040a123`, the post-fast-forward setup tail around `0x00437168`, and the later staged-profile rehydrate band inside `world_entry_transition_and_runtime_bringup` `0x00443a50`, so this is the safest current read for the shared year-bucket scalar rebuild helper rather than a world-entry-only follow-on.","objdump + local disassembly + caller inspection + year-bucket-table correlation + world-entry correlation"
|
||||||
|
|
@ -738,7 +739,7 @@ address,size,name,subsystem,calling_convention,prototype_status,source_tool,conf
|
||||||
0x00481fd0,348,bootstrap_scan_autorun_media,bootstrap,cdecl,inferred,ghidra-headless,4,Scans drive letters for RT3 autorun marker files rt3d1.txt and rt3d2.txt using GetDriveTypeA and open or close helpers before deeper shell init.,ghidra + rizin
|
0x00481fd0,348,bootstrap_scan_autorun_media,bootstrap,cdecl,inferred,ghidra-headless,4,Scans drive letters for RT3 autorun marker files rt3d1.txt and rt3d2.txt using GetDriveTypeA and open or close helpers before deeper shell init.,ghidra + rizin
|
||||||
0x00482160,101,shell_state_service_active_mode_frame,shell,thiscall,inferred,objdump + analysis-context,4,Acts as the broader shell-state service pass around one active-mode update on the shell state rooted at 0x006cec74. The helper increments nested-service depth at [this+0x64] optionally notifies the active mode object at 0x006cec78 through 0x0051f940 and 0x00434050 primes the shell runtime at 0x006d401c through 0x00538b60 conditionally services the Multiplayer preview-dataset object at 0x006cd8d8 through 0x00469720 and then dispatches shell_service_frame_cycle on the global shell controller at 0x006d4024 before decrementing the depth counter.,objdump + analysis-context + caller xrefs
|
0x00482160,101,shell_state_service_active_mode_frame,shell,thiscall,inferred,objdump + analysis-context,4,Acts as the broader shell-state service pass around one active-mode update on the shell state rooted at 0x006cec74. The helper increments nested-service depth at [this+0x64] optionally notifies the active mode object at 0x006cec78 through 0x0051f940 and 0x00434050 primes the shell runtime at 0x006d401c through 0x00538b60 conditionally services the Multiplayer preview-dataset object at 0x006cd8d8 through 0x00469720 and then dispatches shell_service_frame_cycle on the global shell controller at 0x006d4024 before decrementing the depth counter.,objdump + analysis-context + caller xrefs
|
||||||
0x004821d0,1019,shell_recompute_layout_slots,bootstrap,thiscall,inferred,ghidra-headless,4,Recomputes the shell layout-slot table after resolution or related display selectors change; derives normalized coordinates from static float tables updates 144 slot entries through the shell bundle child at [0x006d4024+0x18] and then commits the refreshed state.,ghidra + rizin
|
0x004821d0,1019,shell_recompute_layout_slots,bootstrap,thiscall,inferred,ghidra-headless,4,Recomputes the shell layout-slot table after resolution or related display selectors change; derives normalized coordinates from static float tables updates 144 slot entries through the shell bundle child at [0x006d4024+0x18] and then commits the refreshed state.,ghidra + rizin
|
||||||
0x00482ec0,1359,shell_transition_mode,bootstrap,thiscall,inferred,ghidra-headless + objdump,4,"Switches the shell state's active mode at `[this+0x08]`, tears down any prior mode object, selects one of seven mode-specific handlers, updates globals like `0x006cec78`, and then notifies the shell bundle through `0x00538e50`. The constructor jump table at `0x48342c` is now mostly grounded as a real mode map rather than raw branch addresses: mode `1` enters the `Game.win` family through `shell_game_window_construct` `0x004dfbe0`, mode `2` enters `Setup.win` through `shell_setup_window_construct` `0x00504010`, mode `3` enters `Video.win` through `shell_video_window_construct` `0x005174e0`, mode `4` enters `LoadScreen.win` through `shell_load_screen_window_construct` `0x004ea620`, mode `5` enters `Multiplayer.win` through `multiplayer_window_init_globals` `0x004efe80`, mode `6` enters `Credits.win` through `shell_credits_window_construct` `0x004c7fc0`, and mode `7` enters `Campaign.win` through `shell_campaign_window_construct` `0x004b8e60`. The clearest load-side lane remains mode `4`: it publishes the new active-mode object into `0x006cec78` and then calls `shell_active_mode_run_profile_startup_and_load_dispatch` `0x00438890` with stack args `(1, 0)`. The paired old-mode teardown side is now tighter too: mode `1` tears down through `shell_game_window_destroy` `0x004dfd70`, mode `3` through `shell_video_window_destroy` `0x00517570`, mode `4` through `shell_load_screen_window_destroy` `0x004ea730`, mode `6` through `shell_credits_window_destroy` `0x004c7bc0`, and mode `7` through `shell_campaign_window_destroy` `0x004b8dc0`. Current grounded callers remain bootstrap shell bring-up at `0x004840e0` and the world-entry side at `0x00443a50`.","ghidra + rizin + objdump + branch-disassembly correlation + jump-table decode + constructor/destructor correlation"
|
0x00482ec0,1359,shell_transition_mode,bootstrap,thiscall,inferred,ghidra-headless + objdump,4,"Switches the shell state's active mode at `[this+0x08]`, tears down any prior mode object, selects one of seven mode-specific handlers, updates globals like `0x006cec78`, and then notifies the shell bundle through `0x00538e50`. The calling convention is tighter now too: the function is a `thiscall` with two stack arguments, confirmed both by the entry-side stack read from `[esp+0x0c]` and by the `ret 8` epilogue at `0x48340c`. The grounded world-entry load-screen call shape at `0x443adf..0x443ae3` is therefore `(mode=4, arg2=0)`, not a one-arg mode switch. The second stack argument is tighter now too: current local evidence reads it as an old-active-mode teardown flag rather than a second mode id. The branch at `0x482fc6..0x482fff` only runs when that second argument is nonzero, and in that case it releases the old global active-mode object through `world_runtime_release_global_services` `0x00434300`, `0x00433730`, the common free path `0x0053b080`, and then clears `0x006cec78`. The caller split matches that read: the world-entry load-screen transition uses `(4, 0)` as the plain `LoadScreen.win` arm, while the later world-entry reactivation branch at `0x444c3b..0x444c44` enters mode `1` as `(1, esi)` after `0x4834e0` and `0x44ce60`, making the nonzero second argument the strongest current fit for 'tear down the prior active gameplay world while switching modes'. The constructor jump table at `0x48342c` is now grounded as a real mode map rather than raw branch addresses: mode `1` enters the startup-dispatch arm at `0x483012`, mode `2` enters `Setup.win` through `shell_setup_window_construct` `0x00504010`, mode `3` enters `Video.win` through `shell_video_window_construct` `0x005174e0`, mode `4` enters the plain `LoadScreen.win` arm at `0x4832e5` through `shell_load_screen_window_construct` `0x004ea620`, mode `5` enters `Multiplayer.win` through `multiplayer_window_init_globals` `0x004efe80`, mode `6` enters `Credits.win` through `shell_credits_window_construct` `0x004c7fc0`, and mode `7` enters `Campaign.win` through `shell_campaign_window_construct` `0x004b8e60`. The startup side is correspondingly tighter now too: mode `1` first constructs and publishes one transient `LoadScreen.win` object through `0x004ea620` and `0x00538e50`, then sets `[load_screen+0x78]` through `0x004ea710`, allocates the separate startup-runtime object through `0x0053b070(0x46c40)`, clears its startup-owned bands through `world_runtime_reset_startup_dispatch_state_bands` `0x004336d0`, publishes that second object into `0x006cec78`, and only then calls `shell_active_mode_run_profile_startup_and_load_dispatch` `0x00438890` with stack args `(1, 0)`. After that straight-line call it immediately unpublishes the shell-window object again through `0x005389c0([0x006d401c], [this+0x0c])` before later mode-specific destroy paths continue. Mode `4`, by contrast, only constructs and publishes `LoadScreen.win` and does not own the startup-runtime allocation or `0x00438890` callsite. The paired old-mode teardown side is now tighter too: mode `1` tears down through `shell_game_window_destroy` `0x004dfd70`, mode `3` through `shell_video_window_destroy` `0x00517570`, mode `4` through `shell_load_screen_window_destroy` `0x004ea730`, mode `6` through `shell_credits_window_destroy` `0x004c7bc0`, and mode `7` through `shell_campaign_window_destroy` `0x004b8dc0`. Current live hook probes now show the old hook-side crash is gone: on the hook-driven path `shell_transition_mode(4, 0)` returns cleanly, and the inner old-object unpublish, `0x005400c0 -> 0x0053fe00 -> 0x0053f860` removal sweep, mode-`2` teardown helper `0x00502720`, `LoadScreen.win` construct, and shell publish all return. The corrected jump-table decode now explains the remaining runtime gap too: the current plain-run logs still do not show a trusted `0x00438890` entry because the hook has been entering mode `4`, not the mode-`1` startup-dispatch arm that statically owns that callsite. Current grounded callers remain bootstrap shell bring-up at `0x004840e0` and the world-entry side at `0x00443a50`.","ghidra + rizin + objdump + branch-disassembly correlation + jump-table decode + constructor/destructor correlation + epilogue and call-shape verification + caller-behavior correlation + live-hook probe correlation + pre-dispatch-runtime-object split"
|
||||||
0x005a2d64,101,crt_init_exit_handlers,startup,cdecl,inferred,ghidra-headless,3,Initializes on-exit tables and registers atexit handling before control reaches application startup.,ghidra + rizin
|
0x005a2d64,101,crt_init_exit_handlers,startup,cdecl,inferred,ghidra-headless,3,Initializes on-exit tables and registers atexit handling before control reaches application startup.,ghidra + rizin
|
||||||
0x005a30f2,34,__amsg_exit,startup,cdecl,inferred,ghidra-headless,4,CRT fatal-exit helper that forwards startup failures into __exit.,ghidra + rizin
|
0x005a30f2,34,__amsg_exit,startup,cdecl,inferred,ghidra-headless,4,CRT fatal-exit helper that forwards startup failures into __exit.,ghidra + rizin
|
||||||
0x005a3117,36,crt_fast_error_exit,startup,cdecl,inferred,ghidra-headless,4,Startup error path that optionally emits the CRT banner then formats the failure and terminates through ___crtExitProcess.,ghidra + rizin
|
0x005a3117,36,crt_fast_error_exit,startup,cdecl,inferred,ghidra-headless,4,Startup error path that optionally emits the CRT banner then formats the failure and terminates through ___crtExitProcess.,ghidra + rizin
|
||||||
|
|
@ -820,6 +821,7 @@ address,size,name,subsystem,calling_convention,prototype_status,source_tool,conf
|
||||||
0x00534e90,48,world_secondary_raster_query_cell_marked_bit,map,thiscall,inferred,objdump + caller inspection + raster-cell correlation,3,"Tiny reusable bit query over the same 3-byte secondary-raster cell family rooted at `[this+0x165d]` with row stride `[this+0x15dd]`. The helper resolves one cell from caller-supplied world-grid coordinates, reads the 16-bit companion word from that cell record, and returns bit `9` as a boolean marked-state flag. Current grounded callers include the secondary-grid overlay-cache service `world_service_secondary_grid_marked_cell_overlay_cache` `0x0044c670`, neighboring world scan or mutation branches at `0x004499a6`, `0x00449bdf`, and `0x00450f69`, plus one shell-side or editor-adjacent caller at `0x004a0e72`, which makes this the safest current name for the shared secondary-raster marked-bit predicate.","objdump + caller inspection + raster-cell correlation + secondary-grid overlay correlation"
|
0x00534e90,48,world_secondary_raster_query_cell_marked_bit,map,thiscall,inferred,objdump + caller inspection + raster-cell correlation,3,"Tiny reusable bit query over the same 3-byte secondary-raster cell family rooted at `[this+0x165d]` with row stride `[this+0x15dd]`. The helper resolves one cell from caller-supplied world-grid coordinates, reads the 16-bit companion word from that cell record, and returns bit `9` as a boolean marked-state flag. Current grounded callers include the secondary-grid overlay-cache service `world_service_secondary_grid_marked_cell_overlay_cache` `0x0044c670`, neighboring world scan or mutation branches at `0x004499a6`, `0x00449bdf`, and `0x00450f69`, plus one shell-side or editor-adjacent caller at `0x004a0e72`, which makes this the safest current name for the shared secondary-raster marked-bit predicate.","objdump + caller inspection + raster-cell correlation + secondary-grid overlay correlation"
|
||||||
0x00534ec0,56,world_secondary_raster_query_cell_class_in_set_2_4_5,map,thiscall,inferred,objdump + caller inspection + raster-cell correlation,3,"Tiny reusable class-subset predicate over the same 3-byte secondary-raster family rooted at `[this+0x165d]` with row stride `[this+0x15dd]`. The helper resolves one caller-selected world-grid cell, masks the low three class bits from the first byte, and returns `1` only when that class is `2`, `4`, or `5`, else `0`. Current grounded callers include neighboring world-side setup, scan, and presentation branches around `0x00446418`, `0x00449c88`, `0x0044bdfc`, `0x0044dd78`, `0x0044ec51`, `0x0044f30e`, `0x0044f445`, `0x00450f7a`, and `0x004fa86c`, plus serializer-adjacent paths under `0x00446240`, so this is the safest current read for the shared secondary-raster class-subset predicate rather than a more player-facing terrain label.","objdump + caller inspection + raster-cell correlation + serializer correlation"
|
0x00534ec0,56,world_secondary_raster_query_cell_class_in_set_2_4_5,map,thiscall,inferred,objdump + caller inspection + raster-cell correlation,3,"Tiny reusable class-subset predicate over the same 3-byte secondary-raster family rooted at `[this+0x165d]` with row stride `[this+0x15dd]`. The helper resolves one caller-selected world-grid cell, masks the low three class bits from the first byte, and returns `1` only when that class is `2`, `4`, or `5`, else `0`. Current grounded callers include neighboring world-side setup, scan, and presentation branches around `0x00446418`, `0x00449c88`, `0x0044bdfc`, `0x0044dd78`, `0x0044ec51`, `0x0044f30e`, `0x0044f445`, `0x00450f7a`, and `0x004fa86c`, plus serializer-adjacent paths under `0x00446240`, so this is the safest current read for the shared secondary-raster class-subset predicate rather than a more player-facing terrain label.","objdump + caller inspection + raster-cell correlation + serializer correlation"
|
||||||
0x00534f00,52,world_secondary_raster_query_cell_class_in_set_3_5,map,thiscall,inferred,objdump + caller inspection + raster-cell correlation,3,"Tiny reusable class-subset predicate over the same 3-byte secondary-raster family rooted at `[this+0x165d]` with row stride `[this+0x15dd]`. The helper resolves one caller-selected world-grid cell, masks the low three class bits from the first byte, and returns `1` only when that class is `3` or `5`, else `0`. Current grounded callers include neighboring world-side setup, serializer, and presentation branches around `0x00446240`, `0x00448cbf`, `0x0044dbcd`, `0x0044ec3d`, `0x0044ede9`, `0x0044ee0a`, `0x0044ef7a`, `0x0044f131`, `0x0044f1b5`, `0x0045105e`, `0x0047a569`, and `0x004df627`, so this is the safest current read for the shared secondary-raster class-subset predicate rather than a more player-facing terrain label.","objdump + caller inspection + raster-cell correlation + serializer correlation"
|
0x00534f00,52,world_secondary_raster_query_cell_class_in_set_3_5,map,thiscall,inferred,objdump + caller inspection + raster-cell correlation,3,"Tiny reusable class-subset predicate over the same 3-byte secondary-raster family rooted at `[this+0x165d]` with row stride `[this+0x15dd]`. The helper resolves one caller-selected world-grid cell, masks the low three class bits from the first byte, and returns `1` only when that class is `3` or `5`, else `0`. Current grounded callers include neighboring world-side setup, serializer, and presentation branches around `0x00446240`, `0x00448cbf`, `0x0044dbcd`, `0x0044ec3d`, `0x0044ede9`, `0x0044ee0a`, `0x0044ef7a`, `0x0044f131`, `0x0044f1b5`, `0x0045105e`, `0x0047a569`, and `0x004df627`, so this is the safest current read for the shared secondary-raster class-subset predicate rather than a more player-facing terrain label.","objdump + caller inspection + raster-cell correlation + serializer correlation"
|
||||||
|
0x0053fda0,96,shell_service_one_object_child_queue_and_deferred_state,shell,thiscall,inferred,objdump + analysis-context,4,Services one shell object beneath shell_runtime_prime. The helper optionally notifies the global shell controller at 0x006d4024 through 0x0051f950 when [this+0x5c] is nonnull then walks the child or service-node list rooted at [this+0x70]. For each child it conditionally signals deferred work through shell_signal_deferred_work_item_shutdown 0x0051f1d0 and dispatches the child through vtable slot +0x18. If byte [this+0x1d] is set the tail path also jumps through 0x0051f930 on the same shell controller. Current static context places it under shell_runtime_prime 0x00538b60 on the null-0x006cec78 mode-4 service path.,objdump + analysis-context + caller context
|
||||||
0x00539fb0,924,shell_emit_geographic_label_frame_vertex24_records,bootstrap,thiscall,inferred,ghidra-headless,4,Expands the current geographic-label item into cached frame vertex24 records inside the caller buffer. The helper patches packed alpha into up to sixteen prebuilt 0x18-byte records copies additional 24-byte frame blocks from fixed item offsets and returns the emitted vertex count for the label border or backing geometry.,ghidra + rizin + llvm-objdump
|
0x00539fb0,924,shell_emit_geographic_label_frame_vertex24_records,bootstrap,thiscall,inferred,ghidra-headless,4,Expands the current geographic-label item into cached frame vertex24 records inside the caller buffer. The helper patches packed alpha into up to sixteen prebuilt 0x18-byte records copies additional 24-byte frame blocks from fixed item offsets and returns the emitted vertex count for the label border or backing geometry.,ghidra + rizin + llvm-objdump
|
||||||
0x0053a440,14,shell_set_geographic_label_item_alpha,bootstrap,thiscall,inferred,ghidra-headless,4,Stores an 8-bit alpha input into the high-byte color field at [this+0x5b] for the current geographic-label item before frame or text emission.,ghidra + rizin + llvm-objdump
|
0x0053a440,14,shell_set_geographic_label_item_alpha,bootstrap,thiscall,inferred,ghidra-headless,4,Stores an 8-bit alpha input into the high-byte color field at [this+0x5b] for the current geographic-label item before frame or text emission.,ghidra + rizin + llvm-objdump
|
||||||
0x0053a960,723,shell_emit_geographic_label_text_span,bootstrap,thiscall,inferred,ghidra-headless,4,Builds and emits one geographic-label text span for the current cell item. The helper calls the item vtable at +0x10 to materialize a null-terminated display string up to 0x12c bytes computes placement from item float fields and shell service state checks visibility through the shell bundle and forwards the resolved text payload into the presentation path through 0x005519f0. The item family aligns with gpdLabelDB and 2DLabel.imb rather than the parallel city assets.,ghidra + rizin + llvm-objdump
|
0x0053a960,723,shell_emit_geographic_label_text_span,bootstrap,thiscall,inferred,ghidra-headless,4,Builds and emits one geographic-label text span for the current cell item. The helper calls the item vtable at +0x10 to materialize a null-terminated display string up to 0x12c bytes computes placement from item float fields and shell service state checks visibility through the shell bundle and forwards the resolved text payload into the presentation path through 0x005519f0. The item family aligns with gpdLabelDB and 2DLabel.imb rather than the parallel city assets.,ghidra + rizin + llvm-objdump
|
||||||
|
|
@ -886,6 +888,7 @@ address,size,name,subsystem,calling_convention,prototype_status,source_tool,conf
|
||||||
0x00557010,159,intrusive_queue_clear_owned_nodes,support,cdecl,inferred,ghidra-headless,3,Specialized intrusive-queue clear path for containers with an auxiliary owner or context pointer at [this+0x14]. It iterates the owned node chain through the queue iterator helpers releases nested payload state unlinks each node and decrements the queue count before returning to the outer container clear helper.,ghidra + rizin + llvm-objdump
|
0x00557010,159,intrusive_queue_clear_owned_nodes,support,cdecl,inferred,ghidra-headless,3,Specialized intrusive-queue clear path for containers with an auxiliary owner or context pointer at [this+0x14]. It iterates the owned node chain through the queue iterator helpers releases nested payload state unlinks each node and decrements the queue count before returning to the outer container clear helper.,ghidra + rizin + llvm-objdump
|
||||||
0x005570b0,80,intrusive_queue_clear_and_release,support,cdecl,inferred,ghidra-headless,4,Clears and releases every node in one intrusive queue container. When [this+0x14] is present it routes through intrusive_queue_clear_owned_nodes; otherwise it walks the linked nodes directly releases them zeroes the head iterator and tail slots and resets the queued-node count at [this+0x0c].,ghidra + rizin + llvm-objdump
|
0x005570b0,80,intrusive_queue_clear_and_release,support,cdecl,inferred,ghidra-headless,4,Clears and releases every node in one intrusive queue container. When [this+0x14] is present it routes through intrusive_queue_clear_owned_nodes; otherwise it walks the linked nodes directly releases them zeroes the head iterator and tail slots and resets the queued-node count at [this+0x0c].,ghidra + rizin + llvm-objdump
|
||||||
0x00559520,166,surface_init_rgba_pixel_buffer,support,thiscall,inferred,ghidra-headless,3,Initializes or refreshes a small 0xec-byte RGBA pixel-buffer object from caller-supplied image data and dimensions. On first use it allocates the backing object through 0x0053b070 stores width and height at [this+0xa2] and [this+0xa6] seeds the inner surface through 0x00543980 and 0x00541970 then copies width*height*4 bytes from the source buffer before finalizing through 0x005438d0. Current callers use it from the PaintTerrain preview path and the Multiplayer.win map-preview branch.,ghidra + rizin + llvm-objdump + strings
|
0x00559520,166,surface_init_rgba_pixel_buffer,support,thiscall,inferred,ghidra-headless,3,Initializes or refreshes a small 0xec-byte RGBA pixel-buffer object from caller-supplied image data and dimensions. On first use it allocates the backing object through 0x0053b070 stores width and height at [this+0xa2] and [this+0xa6] seeds the inner surface through 0x00543980 and 0x00541970 then copies width*height*4 bytes from the source buffer before finalizing through 0x005438d0. Current callers use it from the PaintTerrain preview path and the Multiplayer.win map-preview branch.,ghidra + rizin + llvm-objdump + strings
|
||||||
|
0x005595d0,1632,shell_child_control_service_presentation_and_overlay_pass,shell,thiscall,inferred,objdump + runtime probe correlation,4,"Services one registered shell child-control beneath the object walker `shell_service_one_object_child_queue_and_deferred_state` `0x0053fda0`. The helper is first gated by `0x00558670`, which checks child flags `[this+0x68/+0x6a]`, parent pointer `[this+0x86]`, and shell controller byte `[0x006d4024+0x57]`. Once admitted, the body fans out by style field `[this+0xb0]` and spends most of its work in presentation helpers including `0x54f710`, `0x54f9f0`, `0x54fdd0`, `0x53de00`, and `0x552560`, using child-local geometry floats and the owning window pointer at `[this+0x86]` to emit overlay or control visuals. Current live hook probes on the frozen mode-4 auto-load path show the early `LoadScreen.win` children all point back to the same parent through `[child+0x86]`, typically carry `flag_68 = 0x03` and `flag_6a = 0x03`, and return `4`, while later siblings with `flag_68 = 0x00` return `0`; in all traced cases `0x006cec78` stays `0`, so this now reads as a presentation-side child service owner rather than the missing startup-runtime promotion lane.","objdump + runtime probe correlation + child-service gating correlation"
|
||||||
0x00565110,600,shell_rebuild_layout_state_and_optional_texture_report,shell,thiscall,inferred,ghidra-headless,3,Rebuilds the active shell layout-state branch when the current mode requires a deeper reset and optionally publishes the texture budget report through 0x00527650. The routine checks the current layout mode through 0x00545e00 tears down and recreates layout-state services through 0x0055dd80 and 0x0055e2b0 optionally notifies global shell services and when the caller flag is set emits the report then commits one layout refresh step through 0x00545d60.,ghidra + rizin + llvm-objdump + strings
|
0x00565110,600,shell_rebuild_layout_state_and_optional_texture_report,shell,thiscall,inferred,ghidra-headless,3,Rebuilds the active shell layout-state branch when the current mode requires a deeper reset and optionally publishes the texture budget report through 0x00527650. The routine checks the current layout mode through 0x00545e00 tears down and recreates layout-state services through 0x0055dd80 and 0x0055e2b0 optionally notifies global shell services and when the caller flag is set emits the report then commits one layout refresh step through 0x00545d60.,ghidra + rizin + llvm-objdump + strings
|
||||||
0x0058bc90,23,multiplayer_gamespy_route_set_extended_payload_callback,shell,thiscall,inferred,objdump,3,"Tiny callback-slot helper for one GameSpy-style route object. It stores the caller callback pointer in `[route+0xa0]`, or in the default route singleton at `0x00629948` when `ecx` is null. The current grounded transport-side caller is multiplayer_transport_try_connect_status_route, which patches the status route's validated extended-payload callback slot to `0x597330` after route construction.",objdump
|
0x0058bc90,23,multiplayer_gamespy_route_set_extended_payload_callback,shell,thiscall,inferred,objdump,3,"Tiny callback-slot helper for one GameSpy-style route object. It stores the caller callback pointer in `[route+0xa0]`, or in the default route singleton at `0x00629948` when `ecx` is null. The current grounded transport-side caller is multiplayer_transport_try_connect_status_route, which patches the status route's validated extended-payload callback slot to `0x597330` after route construction.",objdump
|
||||||
0x0058c9b0,404,multiplayer_gamespy_route_construct_and_seed_callback_vector,shell,thiscall,inferred,objdump,3,"Constructs or replaces one 0x108-byte GameSpy-style route object and seeds its callback vector. The helper allocates the route object when the caller owner slot is non-null, copies two caller strings into the local route buffers at `+0x04` and `+0x44`, stores the supplied callback table across `[route+0x88]` through `[route+0x9c]`, records the owner context at `[route+0x104]`, initializes the route cookie state and recent-cookie ring, and explicitly zeroes the secondary callback slots `[route+0xa0]`, `[route+0xa4]`, and `[route+0xd4]` before any later patch-up. Current grounded callers are multiplayer_transport_try_connect_status_route and multiplayer_transport_try_connect_live_route through the wrapper at `0x58cc40` or the constructor directly.","objdump"
|
0x0058c9b0,404,multiplayer_gamespy_route_construct_and_seed_callback_vector,shell,thiscall,inferred,objdump,3,"Constructs or replaces one 0x108-byte GameSpy-style route object and seeds its callback vector. The helper allocates the route object when the caller owner slot is non-null, copies two caller strings into the local route buffers at `+0x04` and `+0x44`, stores the supplied callback table across `[route+0x88]` through `[route+0x9c]`, records the owner context at `[route+0x104]`, initializes the route cookie state and recent-cookie ring, and explicitly zeroes the secondary callback slots `[route+0xa0]`, `[route+0xa4]`, and `[route+0xd4]` before any later patch-up. Current grounded callers are multiplayer_transport_try_connect_status_route and multiplayer_transport_try_connect_live_route through the wrapper at `0x58cc40` or the constructor directly.","objdump"
|
||||||
|
|
|
||||||
|
Can't render this file because it is too large.
|
|
|
@ -5,7 +5,9 @@ edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
rrt-fixtures = { path = "../rrt-fixtures" }
|
||||||
rrt-model = { path = "../rrt-model" }
|
rrt-model = { path = "../rrt-model" }
|
||||||
|
rrt-runtime = { path = "../rrt-runtime" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
10
crates/rrt-fixtures/Cargo.toml
Normal file
10
crates/rrt-fixtures/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[package]
|
||||||
|
name = "rrt-fixtures"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rrt-runtime = { path = "../rrt-runtime" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
82
crates/rrt-fixtures/src/diff.rs
Normal file
82
crates/rrt-fixtures/src/diff.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct JsonDiffEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub left: Value,
|
||||||
|
pub right: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff_json_values(left: &Value, right: &Value) -> Vec<JsonDiffEntry> {
|
||||||
|
let mut differences = Vec::new();
|
||||||
|
collect_json_differences("$", left, right, &mut differences);
|
||||||
|
differences
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_json_differences(
|
||||||
|
path: &str,
|
||||||
|
left: &Value,
|
||||||
|
right: &Value,
|
||||||
|
differences: &mut Vec<JsonDiffEntry>,
|
||||||
|
) {
|
||||||
|
match (left, right) {
|
||||||
|
(Value::Object(left_map), Value::Object(right_map)) => {
|
||||||
|
let mut keys = BTreeSet::new();
|
||||||
|
keys.extend(left_map.keys().cloned());
|
||||||
|
keys.extend(right_map.keys().cloned());
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
let next_path = format!("{path}.{key}");
|
||||||
|
match (left_map.get(&key), right_map.get(&key)) {
|
||||||
|
(Some(left_value), Some(right_value)) => {
|
||||||
|
collect_json_differences(&next_path, left_value, right_value, differences);
|
||||||
|
}
|
||||||
|
(left_value, right_value) => differences.push(JsonDiffEntry {
|
||||||
|
path: next_path,
|
||||||
|
left: left_value.cloned().unwrap_or(Value::Null),
|
||||||
|
right: right_value.cloned().unwrap_or(Value::Null),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Value::Array(left_items), Value::Array(right_items)) => {
|
||||||
|
let max_len = left_items.len().max(right_items.len());
|
||||||
|
for index in 0..max_len {
|
||||||
|
let next_path = format!("{path}[{index}]");
|
||||||
|
match (left_items.get(index), right_items.get(index)) {
|
||||||
|
(Some(left_value), Some(right_value)) => {
|
||||||
|
collect_json_differences(&next_path, left_value, right_value, differences);
|
||||||
|
}
|
||||||
|
(left_value, right_value) => differences.push(JsonDiffEntry {
|
||||||
|
path: next_path,
|
||||||
|
left: left_value.cloned().unwrap_or(Value::Null),
|
||||||
|
right: right_value.cloned().unwrap_or(Value::Null),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if left != right => differences.push(JsonDiffEntry {
|
||||||
|
path: path.to_string(),
|
||||||
|
left: left.clone(),
|
||||||
|
right: right.clone(),
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diffs_nested_json_values() {
|
||||||
|
let left = serde_json::json!({ "a": { "b": 1 } });
|
||||||
|
let right = serde_json::json!({ "a": { "b": 2 } });
|
||||||
|
let diff = diff_json_values(&left, &right);
|
||||||
|
assert_eq!(diff.len(), 1);
|
||||||
|
assert_eq!(diff[0].path, "$.a.b");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/rrt-fixtures/src/lib.rs
Normal file
12
crates/rrt-fixtures/src/lib.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
pub mod diff;
|
||||||
|
pub mod load;
|
||||||
|
pub mod normalize;
|
||||||
|
pub mod schema;
|
||||||
|
|
||||||
|
pub use diff::{JsonDiffEntry, diff_json_values};
|
||||||
|
pub use load::{load_fixture_document, load_fixture_document_from_str};
|
||||||
|
pub use normalize::normalize_runtime_state;
|
||||||
|
pub use schema::{
|
||||||
|
ExpectedRuntimeSummary, FIXTURE_FORMAT_VERSION, FixtureDocument, FixtureSource,
|
||||||
|
FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument, validate_fixture_document,
|
||||||
|
};
|
||||||
162
crates/rrt-fixtures/src/load.rs
Normal file
162
crates/rrt-fixtures/src/load.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use rrt_runtime::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
|
||||||
|
|
||||||
|
use crate::{FixtureDocument, FixtureStateOrigin, RawFixtureDocument};
|
||||||
|
|
||||||
|
pub fn load_fixture_document(path: &Path) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
load_fixture_document_from_str_with_base(&text, base_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_fixture_document_from_str(
|
||||||
|
text: &str,
|
||||||
|
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||||
|
load_fixture_document_from_str_with_base(text, Path::new("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_fixture_document_from_str_with_base(
|
||||||
|
text: &str,
|
||||||
|
base_dir: &Path,
|
||||||
|
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||||
|
let raw: RawFixtureDocument = serde_json::from_str(text)?;
|
||||||
|
resolve_raw_fixture_document(raw, base_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_raw_fixture_document(
|
||||||
|
raw: RawFixtureDocument,
|
||||||
|
base_dir: &Path,
|
||||||
|
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||||
|
let state = match (&raw.state, &raw.state_snapshot_path) {
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Err(
|
||||||
|
"fixture must not specify both inline state and state_snapshot_path".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Err("fixture must specify either inline state or state_snapshot_path".into());
|
||||||
|
}
|
||||||
|
(Some(state), None) => state.clone(),
|
||||||
|
(None, Some(snapshot_path)) => {
|
||||||
|
let snapshot_path = resolve_snapshot_path(base_dir, snapshot_path);
|
||||||
|
let snapshot = load_runtime_snapshot_document(&snapshot_path)?;
|
||||||
|
validate_runtime_snapshot_document(&snapshot).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"invalid runtime snapshot {}: {err}",
|
||||||
|
snapshot_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
snapshot.state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let state_origin = match raw.state_snapshot_path {
|
||||||
|
Some(snapshot_path) => FixtureStateOrigin::SnapshotPath(snapshot_path),
|
||||||
|
None => FixtureStateOrigin::Inline,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FixtureDocument {
|
||||||
|
format_version: raw.format_version,
|
||||||
|
fixture_id: raw.fixture_id,
|
||||||
|
source: raw.source,
|
||||||
|
state,
|
||||||
|
state_origin,
|
||||||
|
commands: raw.commands,
|
||||||
|
expected_summary: raw.expected_summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_snapshot_path(base_dir: &Path, snapshot_path: &str) -> PathBuf {
|
||||||
|
let candidate = PathBuf::from(snapshot_path);
|
||||||
|
if candidate.is_absolute() {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
base_dir.join(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::FixtureStateOrigin;
|
||||||
|
use rrt_runtime::{
|
||||||
|
CalendarPoint, RuntimeServiceState, RuntimeSnapshotDocument, RuntimeSnapshotSource,
|
||||||
|
RuntimeState, SNAPSHOT_FORMAT_VERSION, save_runtime_snapshot_document,
|
||||||
|
};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_fixture_from_relative_snapshot_path() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("system time should be after epoch")
|
||||||
|
.as_nanos();
|
||||||
|
let fixture_dir = std::env::temp_dir().join(format!("rrt-fixture-load-{nonce}"));
|
||||||
|
std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created");
|
||||||
|
|
||||||
|
let snapshot_path = fixture_dir.join("state.json");
|
||||||
|
let snapshot = RuntimeSnapshotDocument {
|
||||||
|
format_version: SNAPSHOT_FORMAT_VERSION,
|
||||||
|
snapshot_id: "snapshot-backed-fixture-state".to_string(),
|
||||||
|
source: RuntimeSnapshotSource {
|
||||||
|
source_fixture_id: Some("snapshot-backed-fixture".to_string()),
|
||||||
|
description: Some("test snapshot".to_string()),
|
||||||
|
},
|
||||||
|
state: RuntimeState {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 5,
|
||||||
|
},
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
companies: Vec::new(),
|
||||||
|
event_runtime_records: Vec::new(),
|
||||||
|
service_state: RuntimeServiceState::default(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
save_runtime_snapshot_document(&snapshot_path, &snapshot).expect("snapshot should save");
|
||||||
|
|
||||||
|
let fixture_json = r#"
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"fixture_id": "snapshot-backed-fixture",
|
||||||
|
"source": {
|
||||||
|
"kind": "captured-runtime"
|
||||||
|
},
|
||||||
|
"state_snapshot_path": "state.json",
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"kind": "step_count",
|
||||||
|
"steps": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected_summary": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 6
|
||||||
|
},
|
||||||
|
"world_flag_count": 0,
|
||||||
|
"company_count": 0,
|
||||||
|
"event_runtime_record_count": 0,
|
||||||
|
"total_company_cash": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir)
|
||||||
|
.expect("snapshot-backed fixture should load");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fixture.state_origin,
|
||||||
|
FixtureStateOrigin::SnapshotPath("state.json".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.state.calendar.tick_slot, 5);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(snapshot_path);
|
||||||
|
let _ = std::fs::remove_dir(fixture_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/rrt-fixtures/src/normalize.rs
Normal file
7
crates/rrt-fixtures/src/normalize.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use rrt_runtime::RuntimeState;
|
||||||
|
|
||||||
|
pub fn normalize_runtime_state(state: &RuntimeState) -> Result<Value, Box<dyn std::error::Error>> {
|
||||||
|
Ok(serde_json::to_value(state)?)
|
||||||
|
}
|
||||||
277
crates/rrt-fixtures/src/schema.rs
Normal file
277
crates/rrt-fixtures/src/schema.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand};
|
||||||
|
|
||||||
|
pub const FIXTURE_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct FixtureSource {
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ExpectedRuntimeSummary {
|
||||||
|
#[serde(default)]
|
||||||
|
pub calendar: Option<rrt_runtime::CalendarPoint>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub world_flag_count: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub company_count: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub event_runtime_record_count: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_event_record_service_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub periodic_boundary_call_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_trigger_dispatch_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dirty_rerun_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_company_cash: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExpectedRuntimeSummary {
|
||||||
|
pub fn compare(&self, actual: &RuntimeSummary) -> Vec<String> {
|
||||||
|
let mut mismatches = Vec::new();
|
||||||
|
|
||||||
|
if let Some(calendar) = self.calendar {
|
||||||
|
if actual.calendar != calendar {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"calendar mismatch: expected {:?}, got {:?}",
|
||||||
|
calendar, actual.calendar
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.world_flag_count {
|
||||||
|
if actual.world_flag_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"world_flag_count mismatch: expected {count}, got {}",
|
||||||
|
actual.world_flag_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.company_count {
|
||||||
|
if actual.company_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"company_count mismatch: expected {count}, got {}",
|
||||||
|
actual.company_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.event_runtime_record_count {
|
||||||
|
if actual.event_runtime_record_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"event_runtime_record_count mismatch: expected {count}, got {}",
|
||||||
|
actual.event_runtime_record_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.total_event_record_service_count {
|
||||||
|
if actual.total_event_record_service_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"total_event_record_service_count mismatch: expected {count}, got {}",
|
||||||
|
actual.total_event_record_service_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.periodic_boundary_call_count {
|
||||||
|
if actual.periodic_boundary_call_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"periodic_boundary_call_count mismatch: expected {count}, got {}",
|
||||||
|
actual.periodic_boundary_call_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.total_trigger_dispatch_count {
|
||||||
|
if actual.total_trigger_dispatch_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"total_trigger_dispatch_count mismatch: expected {count}, got {}",
|
||||||
|
actual.total_trigger_dispatch_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(count) = self.dirty_rerun_count {
|
||||||
|
if actual.dirty_rerun_count != count {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"dirty_rerun_count mismatch: expected {count}, got {}",
|
||||||
|
actual.dirty_rerun_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(total) = self.total_company_cash {
|
||||||
|
if actual.total_company_cash != total {
|
||||||
|
mismatches.push(format!(
|
||||||
|
"total_company_cash mismatch: expected {total}, got {}",
|
||||||
|
actual.total_company_cash
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mismatches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FixtureDocument {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub fixture_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: FixtureSource,
|
||||||
|
pub state: RuntimeState,
|
||||||
|
pub state_origin: FixtureStateOrigin,
|
||||||
|
#[serde(default)]
|
||||||
|
pub commands: Vec<StepCommand>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expected_summary: ExpectedRuntimeSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum FixtureStateOrigin {
|
||||||
|
Inline,
|
||||||
|
SnapshotPath(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RawFixtureDocument {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub fixture_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: FixtureSource,
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: Option<RuntimeState>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub state_snapshot_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub commands: Vec<StepCommand>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expected_summary: ExpectedRuntimeSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FixtureValidationReport {
|
||||||
|
pub fixture_id: String,
|
||||||
|
pub valid: bool,
|
||||||
|
pub issue_count: usize,
|
||||||
|
pub issues: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_fixture_document(document: &FixtureDocument) -> FixtureValidationReport {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
|
||||||
|
if document.format_version != FIXTURE_FORMAT_VERSION {
|
||||||
|
issues.push(format!(
|
||||||
|
"unsupported format_version {} (expected {})",
|
||||||
|
document.format_version, FIXTURE_FORMAT_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if document.fixture_id.trim().is_empty() {
|
||||||
|
issues.push("fixture_id must not be empty".to_string());
|
||||||
|
}
|
||||||
|
if document.source.kind.trim().is_empty() {
|
||||||
|
issues.push("source.kind must not be empty".to_string());
|
||||||
|
}
|
||||||
|
if document.commands.is_empty() {
|
||||||
|
issues.push("fixture must contain at least one command".to_string());
|
||||||
|
}
|
||||||
|
if let Err(err) = document.state.validate() {
|
||||||
|
issues.push(format!("invalid runtime state: {err}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, command) in document.commands.iter().enumerate() {
|
||||||
|
if let Err(err) = command.validate() {
|
||||||
|
issues.push(format!("invalid command at index {index}: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FixtureValidationReport {
|
||||||
|
fixture_id: document.fixture_id.clone(),
|
||||||
|
valid: issues.is_empty(),
|
||||||
|
issue_count: issues.len(),
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::load_fixture_document_from_str;
|
||||||
|
|
||||||
|
const FIXTURE_JSON: &str = r#"
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"fixture_id": "minimal-world-step-smoke",
|
||||||
|
"source": {
|
||||||
|
"kind": "synthetic",
|
||||||
|
"description": "basic milestone parser smoke fixture"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 0
|
||||||
|
},
|
||||||
|
"world_flags": {
|
||||||
|
"sandbox": false
|
||||||
|
},
|
||||||
|
"companies": [
|
||||||
|
{
|
||||||
|
"company_id": 1,
|
||||||
|
"current_cash": 250000,
|
||||||
|
"debt": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_runtime_records": [],
|
||||||
|
"service_state": {
|
||||||
|
"periodic_boundary_calls": 0,
|
||||||
|
"trigger_dispatch_counts": {},
|
||||||
|
"total_event_record_services": 0,
|
||||||
|
"dirty_rerun_count": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"kind": "advance_to",
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected_summary": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 2
|
||||||
|
},
|
||||||
|
"world_flag_count": 1,
|
||||||
|
"company_count": 1,
|
||||||
|
"event_runtime_record_count": 0,
|
||||||
|
"total_company_cash": 250000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_and_validates_fixture() {
|
||||||
|
let fixture = load_fixture_document_from_str(FIXTURE_JSON).expect("fixture should parse");
|
||||||
|
let report = validate_fixture_document(&fixture);
|
||||||
|
assert!(report.valid, "report should be valid: {:?}", report.issues);
|
||||||
|
assert_eq!(fixture.state_origin, FixtureStateOrigin::Inline);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compares_expected_summary() {
|
||||||
|
let fixture = load_fixture_document_from_str(FIXTURE_JSON).expect("fixture should parse");
|
||||||
|
let summary = RuntimeSummary::from_state(&fixture.state);
|
||||||
|
let mismatches = fixture.expected_summary.compare(&summary);
|
||||||
|
assert_eq!(mismatches.len(), 1);
|
||||||
|
assert!(mismatches[0].contains("calendar mismatch"));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
10
crates/rrt-runtime/Cargo.toml
Normal file
10
crates/rrt-runtime/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[package]
|
||||||
|
name = "rrt-runtime"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
115
crates/rrt-runtime/src/calendar.rs
Normal file
115
crates/rrt-runtime/src/calendar.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const MONTH_SLOTS_PER_YEAR: u32 = 12;
|
||||||
|
pub const PHASE_SLOTS_PER_MONTH: u32 = 28;
|
||||||
|
pub const TICKS_PER_PHASE: u32 = 180;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarPoint {
|
||||||
|
pub year: u32,
|
||||||
|
pub month_slot: u32,
|
||||||
|
pub phase_slot: u32,
|
||||||
|
pub tick_slot: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarPoint {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.month_slot >= MONTH_SLOTS_PER_YEAR {
|
||||||
|
return Err(format!(
|
||||||
|
"month_slot {} is out of range 0..{}",
|
||||||
|
self.month_slot,
|
||||||
|
MONTH_SLOTS_PER_YEAR - 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.phase_slot >= PHASE_SLOTS_PER_MONTH {
|
||||||
|
return Err(format!(
|
||||||
|
"phase_slot {} is out of range 0..{}",
|
||||||
|
self.phase_slot,
|
||||||
|
PHASE_SLOTS_PER_MONTH - 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.tick_slot >= TICKS_PER_PHASE {
|
||||||
|
return Err(format!(
|
||||||
|
"tick_slot {} is out of range 0..{}",
|
||||||
|
self.tick_slot,
|
||||||
|
TICKS_PER_PHASE - 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step_forward(&mut self) -> BoundaryEventKind {
|
||||||
|
self.tick_slot += 1;
|
||||||
|
if self.tick_slot < TICKS_PER_PHASE {
|
||||||
|
return BoundaryEventKind::Tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tick_slot = 0;
|
||||||
|
self.phase_slot += 1;
|
||||||
|
if self.phase_slot < PHASE_SLOTS_PER_MONTH {
|
||||||
|
return BoundaryEventKind::PhaseRollover;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.phase_slot = 0;
|
||||||
|
self.month_slot += 1;
|
||||||
|
if self.month_slot < MONTH_SLOTS_PER_YEAR {
|
||||||
|
return BoundaryEventKind::MonthRollover;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.month_slot = 0;
|
||||||
|
self.year += 1;
|
||||||
|
BoundaryEventKind::YearRollover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BoundaryEventKind {
|
||||||
|
Tick,
|
||||||
|
PhaseRollover,
|
||||||
|
MonthRollover,
|
||||||
|
YearRollover,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_calendar_bounds() {
|
||||||
|
let point = CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 0,
|
||||||
|
};
|
||||||
|
assert!(point.validate().is_ok());
|
||||||
|
|
||||||
|
let invalid = CalendarPoint {
|
||||||
|
month_slot: MONTH_SLOTS_PER_YEAR,
|
||||||
|
..point
|
||||||
|
};
|
||||||
|
assert!(invalid.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steps_across_year_boundary() {
|
||||||
|
let mut point = CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: MONTH_SLOTS_PER_YEAR - 1,
|
||||||
|
phase_slot: PHASE_SLOTS_PER_MONTH - 1,
|
||||||
|
tick_slot: TICKS_PER_PHASE - 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = point.step_forward();
|
||||||
|
assert_eq!(event, BoundaryEventKind::YearRollover);
|
||||||
|
assert_eq!(
|
||||||
|
point,
|
||||||
|
CalendarPoint {
|
||||||
|
year: 1831,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
367
crates/rrt-runtime/src/campaign_exe.rs
Normal file
367
crates/rrt-runtime/src/campaign_exe.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const CAMPAIGN_SCENARIO_TABLE_VA: u32 = 0x0062_1cf0;
|
||||||
|
pub const CAMPAIGN_SCENARIO_COUNT: usize = 16;
|
||||||
|
const CAMPAIGN_SAVE_FORMAT_VA: u32 = 0x005d_1a78;
|
||||||
|
const CAMPAIGN_PROGRESS_CONTROL_BASE_ID: u16 = 0x0c372;
|
||||||
|
const CAMPAIGN_SELECTOR_CONTROL_BASE_ID: u16 = 0x0c382;
|
||||||
|
const CAMPAIGN_SELECTOR_CONTROL_COUNT: usize = 16;
|
||||||
|
pub const OBSERVED_CAMPAIGN_SCENARIO_NAMES: [&str; CAMPAIGN_SCENARIO_COUNT] = [
|
||||||
|
"Go West!",
|
||||||
|
"Germantown",
|
||||||
|
"Central Pacific",
|
||||||
|
"Texas Tea",
|
||||||
|
"War Effort",
|
||||||
|
"State of Germany",
|
||||||
|
"Britain",
|
||||||
|
"Crossing the Alps",
|
||||||
|
"Third Republic",
|
||||||
|
"Orient Express",
|
||||||
|
"Argentina Opens Up",
|
||||||
|
"Rhodes Unfinished",
|
||||||
|
"Japan Trembles",
|
||||||
|
"Greenland Growing",
|
||||||
|
"Dutchlantis",
|
||||||
|
"California Island",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CampaignPageBand {
|
||||||
|
pub page_index: usize,
|
||||||
|
pub progress_start_inclusive: u8,
|
||||||
|
pub progress_end_inclusive: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CampaignScenarioEntry {
|
||||||
|
pub index: usize,
|
||||||
|
pub pointer_va: u32,
|
||||||
|
pub pointer_va_hex: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CampaignExeInspectionReport {
|
||||||
|
pub image_base: u32,
|
||||||
|
pub image_base_hex: String,
|
||||||
|
pub campaign_scenario_table_va: u32,
|
||||||
|
pub campaign_scenario_table_va_hex: String,
|
||||||
|
pub campaign_scenario_count: usize,
|
||||||
|
pub campaign_save_format_va: u32,
|
||||||
|
pub campaign_save_format_va_hex: String,
|
||||||
|
pub campaign_save_format_string: String,
|
||||||
|
pub campaign_progress_control_base_id: u16,
|
||||||
|
pub campaign_progress_control_base_id_hex: String,
|
||||||
|
pub campaign_selector_control_base_id: u16,
|
||||||
|
pub campaign_selector_control_base_id_hex: String,
|
||||||
|
pub campaign_selector_control_count: usize,
|
||||||
|
pub campaign_page_bands: Vec<CampaignPageBand>,
|
||||||
|
pub scenarios: Vec<CampaignScenarioEntry>,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct PeSection {
|
||||||
|
virtual_address: u32,
|
||||||
|
virtual_size: u32,
|
||||||
|
raw_data_pointer: u32,
|
||||||
|
raw_data_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct PeView {
|
||||||
|
image_base: u32,
|
||||||
|
sections: Vec<PeSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_campaign_exe_file(
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<CampaignExeInspectionReport, Box<dyn std::error::Error>> {
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
inspect_campaign_exe_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_campaign_exe_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<CampaignExeInspectionReport, Box<dyn std::error::Error>> {
|
||||||
|
let view = parse_pe_view(bytes)?;
|
||||||
|
|
||||||
|
let scenarios = (0..CAMPAIGN_SCENARIO_COUNT)
|
||||||
|
.map(|index| {
|
||||||
|
let pointer_va = read_u32_at_va(
|
||||||
|
bytes,
|
||||||
|
&view,
|
||||||
|
CAMPAIGN_SCENARIO_TABLE_VA + (index as u32 * 4),
|
||||||
|
)?;
|
||||||
|
let name = read_c_string_at_va(bytes, &view, pointer_va)?;
|
||||||
|
Ok(CampaignScenarioEntry {
|
||||||
|
index,
|
||||||
|
pointer_va,
|
||||||
|
pointer_va_hex: format!("0x{pointer_va:08x}"),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
|
||||||
|
|
||||||
|
let campaign_save_format_string = read_c_string_at_va(bytes, &view, CAMPAIGN_SAVE_FORMAT_VA)?;
|
||||||
|
|
||||||
|
Ok(CampaignExeInspectionReport {
|
||||||
|
image_base: view.image_base,
|
||||||
|
image_base_hex: format!("0x{:08x}", view.image_base),
|
||||||
|
campaign_scenario_table_va: CAMPAIGN_SCENARIO_TABLE_VA,
|
||||||
|
campaign_scenario_table_va_hex: format!("0x{CAMPAIGN_SCENARIO_TABLE_VA:08x}"),
|
||||||
|
campaign_scenario_count: CAMPAIGN_SCENARIO_COUNT,
|
||||||
|
campaign_save_format_va: CAMPAIGN_SAVE_FORMAT_VA,
|
||||||
|
campaign_save_format_va_hex: format!("0x{CAMPAIGN_SAVE_FORMAT_VA:08x}"),
|
||||||
|
campaign_save_format_string,
|
||||||
|
campaign_progress_control_base_id: CAMPAIGN_PROGRESS_CONTROL_BASE_ID,
|
||||||
|
campaign_progress_control_base_id_hex: format!(
|
||||||
|
"0x{CAMPAIGN_PROGRESS_CONTROL_BASE_ID:04x}"
|
||||||
|
),
|
||||||
|
campaign_selector_control_base_id: CAMPAIGN_SELECTOR_CONTROL_BASE_ID,
|
||||||
|
campaign_selector_control_base_id_hex: format!(
|
||||||
|
"0x{CAMPAIGN_SELECTOR_CONTROL_BASE_ID:04x}"
|
||||||
|
),
|
||||||
|
campaign_selector_control_count: CAMPAIGN_SELECTOR_CONTROL_COUNT,
|
||||||
|
campaign_page_bands: vec![
|
||||||
|
CampaignPageBand {
|
||||||
|
page_index: 1,
|
||||||
|
progress_start_inclusive: 0,
|
||||||
|
progress_end_inclusive: Some(4),
|
||||||
|
},
|
||||||
|
CampaignPageBand {
|
||||||
|
page_index: 2,
|
||||||
|
progress_start_inclusive: 5,
|
||||||
|
progress_end_inclusive: Some(9),
|
||||||
|
},
|
||||||
|
CampaignPageBand {
|
||||||
|
page_index: 3,
|
||||||
|
progress_start_inclusive: 10,
|
||||||
|
progress_end_inclusive: Some(12),
|
||||||
|
},
|
||||||
|
CampaignPageBand {
|
||||||
|
page_index: 4,
|
||||||
|
progress_start_inclusive: 13,
|
||||||
|
progress_end_inclusive: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scenarios,
|
||||||
|
notes: vec![
|
||||||
|
"Campaign.win mirrors [profile+0xc4] into control 0xc372 + progress.".to_string(),
|
||||||
|
"Campaign.win mirrors the full sixteen-byte band [profile+0xc6..+0xd5] into controls 0xc382..0xc391.".to_string(),
|
||||||
|
"The observed page-band thresholds come from direct RT3.exe disassembly at 0x004b8d49..0x004b8d69.".to_string(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pe_view(bytes: &[u8]) -> Result<PeView, Box<dyn std::error::Error>> {
|
||||||
|
let pe_header_offset =
|
||||||
|
read_u32_le(bytes, 0x3c).ok_or("missing DOS e_lfanew for PE header")? as usize;
|
||||||
|
let signature = bytes
|
||||||
|
.get(pe_header_offset..pe_header_offset + 4)
|
||||||
|
.ok_or("truncated PE signature")?;
|
||||||
|
if signature != b"PE\0\0" {
|
||||||
|
return Err("invalid PE signature".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_header_offset = pe_header_offset + 4;
|
||||||
|
let number_of_sections =
|
||||||
|
read_u16_le(bytes, file_header_offset + 2).ok_or("missing PE section count")? as usize;
|
||||||
|
let size_of_optional_header =
|
||||||
|
read_u16_le(bytes, file_header_offset + 16).ok_or("missing optional header size")? as usize;
|
||||||
|
|
||||||
|
let optional_header_offset = file_header_offset + 20;
|
||||||
|
let optional_magic =
|
||||||
|
read_u16_le(bytes, optional_header_offset).ok_or("missing optional header magic")?;
|
||||||
|
if optional_magic != 0x10b {
|
||||||
|
return Err(format!("unsupported PE optional-header magic 0x{optional_magic:04x}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_base =
|
||||||
|
read_u32_le(bytes, optional_header_offset + 28).ok_or("missing PE image base")?;
|
||||||
|
let section_table_offset = optional_header_offset + size_of_optional_header;
|
||||||
|
|
||||||
|
let mut sections = Vec::with_capacity(number_of_sections);
|
||||||
|
for index in 0..number_of_sections {
|
||||||
|
let section_offset = section_table_offset + index * 40;
|
||||||
|
let virtual_size =
|
||||||
|
read_u32_le(bytes, section_offset + 8).ok_or("truncated section virtual size")?;
|
||||||
|
let virtual_address =
|
||||||
|
read_u32_le(bytes, section_offset + 12).ok_or("truncated section RVA")?;
|
||||||
|
let raw_data_size =
|
||||||
|
read_u32_le(bytes, section_offset + 16).ok_or("truncated section raw size")?;
|
||||||
|
let raw_data_pointer =
|
||||||
|
read_u32_le(bytes, section_offset + 20).ok_or("truncated section raw pointer")?;
|
||||||
|
sections.push(PeSection {
|
||||||
|
virtual_address,
|
||||||
|
virtual_size,
|
||||||
|
raw_data_pointer,
|
||||||
|
raw_data_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PeView {
|
||||||
|
image_base,
|
||||||
|
sections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32_at_va(bytes: &[u8], view: &PeView, va: u32) -> Result<u32, Box<dyn std::error::Error>> {
|
||||||
|
let file_offset = map_va_to_file_offset(view, va)?;
|
||||||
|
read_u32_le(bytes, file_offset).ok_or_else(|| format!("truncated u32 at VA 0x{va:08x}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_c_string_at_va(
|
||||||
|
bytes: &[u8],
|
||||||
|
view: &PeView,
|
||||||
|
va: u32,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let start = map_va_to_file_offset(view, va)?;
|
||||||
|
let slice = bytes
|
||||||
|
.get(start..)
|
||||||
|
.ok_or_else(|| format!("VA 0x{va:08x} mapped outside file"))?;
|
||||||
|
let end = slice
|
||||||
|
.iter()
|
||||||
|
.position(|&byte| byte == 0)
|
||||||
|
.ok_or_else(|| format!("unterminated C string at VA 0x{va:08x}"))?;
|
||||||
|
let value = String::from_utf8(slice[..end].to_vec())?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_va_to_file_offset(view: &PeView, va: u32) -> Result<usize, Box<dyn std::error::Error>> {
|
||||||
|
let rva = va
|
||||||
|
.checked_sub(view.image_base)
|
||||||
|
.ok_or_else(|| format!("VA 0x{va:08x} below image base 0x{:08x}", view.image_base))?;
|
||||||
|
|
||||||
|
for section in &view.sections {
|
||||||
|
let span = section.virtual_size.max(section.raw_data_size);
|
||||||
|
let section_end = section
|
||||||
|
.virtual_address
|
||||||
|
.checked_add(span)
|
||||||
|
.ok_or("section RVA range overflow")?;
|
||||||
|
if rva >= section.virtual_address && rva < section_end {
|
||||||
|
let delta = rva - section.virtual_address;
|
||||||
|
let file_offset = section
|
||||||
|
.raw_data_pointer
|
||||||
|
.checked_add(delta)
|
||||||
|
.ok_or("section file offset overflow")?;
|
||||||
|
return Ok(file_offset as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("VA 0x{va:08x} did not map into any PE section").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16_le(bytes: &[u8], offset: usize) -> Option<u16> {
|
||||||
|
let slice = bytes.get(offset..offset + 2)?;
|
||||||
|
Some(u16::from_le_bytes([slice[0], slice[1]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let slice = bytes.get(offset..offset + 4)?;
|
||||||
|
Some(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
CAMPAIGN_SAVE_FORMAT_VA, CAMPAIGN_SCENARIO_COUNT, CAMPAIGN_SCENARIO_TABLE_VA,
|
||||||
|
inspect_campaign_exe_bytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn build_test_pe() -> Vec<u8> {
|
||||||
|
let image_base = 0x0040_0000u32;
|
||||||
|
let section_rva = 0x001c_0000u32;
|
||||||
|
let section_raw = 0x0000_0200u32;
|
||||||
|
let section_size = 0x0007_0000usize;
|
||||||
|
let mut bytes = vec![0u8; section_raw as usize + section_size];
|
||||||
|
|
||||||
|
bytes[0..2].copy_from_slice(b"MZ");
|
||||||
|
bytes[0x3c..0x40].copy_from_slice(&(0x80u32).to_le_bytes());
|
||||||
|
bytes[0x80..0x84].copy_from_slice(b"PE\0\0");
|
||||||
|
|
||||||
|
let file_header = 0x84usize;
|
||||||
|
bytes[file_header + 2..file_header + 4].copy_from_slice(&(1u16).to_le_bytes());
|
||||||
|
bytes[file_header + 16..file_header + 18].copy_from_slice(&(0xe0u16).to_le_bytes());
|
||||||
|
|
||||||
|
let optional_header = file_header + 20;
|
||||||
|
bytes[optional_header..optional_header + 2].copy_from_slice(&(0x10bu16).to_le_bytes());
|
||||||
|
bytes[optional_header + 28..optional_header + 32]
|
||||||
|
.copy_from_slice(&image_base.to_le_bytes());
|
||||||
|
|
||||||
|
let section_header = optional_header + 0xe0;
|
||||||
|
bytes[section_header..section_header + 5].copy_from_slice(b".data");
|
||||||
|
bytes[section_header + 8..section_header + 12]
|
||||||
|
.copy_from_slice(&(section_size as u32).to_le_bytes());
|
||||||
|
bytes[section_header + 12..section_header + 16].copy_from_slice(§ion_rva.to_le_bytes());
|
||||||
|
bytes[section_header + 16..section_header + 20]
|
||||||
|
.copy_from_slice(&(section_size as u32).to_le_bytes());
|
||||||
|
bytes[section_header + 20..section_header + 24].copy_from_slice(§ion_raw.to_le_bytes());
|
||||||
|
|
||||||
|
let scenario_table_file =
|
||||||
|
(CAMPAIGN_SCENARIO_TABLE_VA - image_base - section_rva + section_raw) as usize;
|
||||||
|
let format_file =
|
||||||
|
(CAMPAIGN_SAVE_FORMAT_VA - image_base - section_rva + section_raw) as usize;
|
||||||
|
|
||||||
|
let scenario_names = [
|
||||||
|
"Go West!",
|
||||||
|
"Germantown",
|
||||||
|
"Central Pacific",
|
||||||
|
"Texas Tea",
|
||||||
|
"War Effort",
|
||||||
|
"State of Germany",
|
||||||
|
"Britain",
|
||||||
|
"Crossing the Alps",
|
||||||
|
"Third Republic",
|
||||||
|
"Orient Express",
|
||||||
|
"Argentina Opens Up",
|
||||||
|
"Rhodes Unfinished",
|
||||||
|
"Japan Trembles",
|
||||||
|
"Greenland Growing",
|
||||||
|
"Dutchlantis",
|
||||||
|
"California Island",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut string_cursor = scenario_table_file + CAMPAIGN_SCENARIO_COUNT * 4;
|
||||||
|
for (index, name) in scenario_names.iter().enumerate() {
|
||||||
|
let pointer_va =
|
||||||
|
image_base + section_rva + (string_cursor - section_raw as usize) as u32;
|
||||||
|
bytes[scenario_table_file + index * 4..scenario_table_file + (index + 1) * 4]
|
||||||
|
.copy_from_slice(&pointer_va.to_le_bytes());
|
||||||
|
bytes[string_cursor..string_cursor + name.len()].copy_from_slice(name.as_bytes());
|
||||||
|
bytes[string_cursor + name.len()] = 0;
|
||||||
|
string_cursor += name.len() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let format = b"%s%02d.gmc\0";
|
||||||
|
bytes[format_file..format_file + format.len()].copy_from_slice(format);
|
||||||
|
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspects_campaign_exe_tables_from_synthetic_pe() {
|
||||||
|
let bytes = build_test_pe();
|
||||||
|
let report = inspect_campaign_exe_bytes(&bytes).expect("campaign exe inspection");
|
||||||
|
|
||||||
|
assert_eq!(report.campaign_scenario_count, 16);
|
||||||
|
assert_eq!(report.campaign_save_format_string, "%s%02d.gmc");
|
||||||
|
assert_eq!(
|
||||||
|
report.scenarios.first().map(|entry| entry.name.as_str()),
|
||||||
|
Some("Go West!")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.scenarios.last().map(|entry| entry.name.as_str()),
|
||||||
|
Some("California Island")
|
||||||
|
);
|
||||||
|
assert_eq!(report.campaign_page_bands.len(), 4);
|
||||||
|
assert_eq!(report.campaign_page_bands[1].progress_start_inclusive, 5);
|
||||||
|
assert_eq!(
|
||||||
|
report.campaign_page_bands[1].progress_end_inclusive,
|
||||||
|
Some(9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/rrt-runtime/src/import.rs
Normal file
133
crates/rrt-runtime/src/import.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::RuntimeState;
|
||||||
|
|
||||||
|
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct RuntimeStateDumpSource {
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_binary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeStateDumpDocument {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub dump_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: RuntimeStateDumpSource,
|
||||||
|
pub state: RuntimeState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeStateImport {
|
||||||
|
pub import_id: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub state: RuntimeState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_runtime_state_dump_document(
|
||||||
|
document: &RuntimeStateDumpDocument,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if document.format_version != STATE_DUMP_FORMAT_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported state dump format_version {} (expected {})",
|
||||||
|
document.format_version, STATE_DUMP_FORMAT_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if document.dump_id.trim().is_empty() {
|
||||||
|
return Err("dump_id must not be empty".to_string());
|
||||||
|
}
|
||||||
|
document.state.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_runtime_state_import(
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
load_runtime_state_import_from_str(
|
||||||
|
&text,
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.unwrap_or("runtime-state"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_runtime_state_import_from_str(
|
||||||
|
text: &str,
|
||||||
|
fallback_id: &str,
|
||||||
|
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
|
||||||
|
if let Ok(document) = serde_json::from_str::<RuntimeStateDumpDocument>(text) {
|
||||||
|
validate_runtime_state_dump_document(&document)
|
||||||
|
.map_err(|err| format!("invalid runtime state dump document: {err}"))?;
|
||||||
|
return Ok(RuntimeStateImport {
|
||||||
|
import_id: document.dump_id,
|
||||||
|
description: document.source.description,
|
||||||
|
state: document.state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: RuntimeState = serde_json::from_str(text)?;
|
||||||
|
state
|
||||||
|
.validate()
|
||||||
|
.map_err(|err| format!("invalid runtime state: {err}"))?;
|
||||||
|
Ok(RuntimeStateImport {
|
||||||
|
import_id: fallback_id.to_string(),
|
||||||
|
description: None,
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{CalendarPoint, RuntimeServiceState};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn state() -> RuntimeState {
|
||||||
|
RuntimeState {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 0,
|
||||||
|
},
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
companies: Vec::new(),
|
||||||
|
event_runtime_records: Vec::new(),
|
||||||
|
service_state: RuntimeServiceState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_dump_document() {
|
||||||
|
let text = serde_json::to_string(&RuntimeStateDumpDocument {
|
||||||
|
format_version: STATE_DUMP_FORMAT_VERSION,
|
||||||
|
dump_id: "dump-smoke".to_string(),
|
||||||
|
source: RuntimeStateDumpSource {
|
||||||
|
description: Some("test dump".to_string()),
|
||||||
|
source_binary: None,
|
||||||
|
},
|
||||||
|
state: state(),
|
||||||
|
})
|
||||||
|
.expect("dump should serialize");
|
||||||
|
|
||||||
|
let import =
|
||||||
|
load_runtime_state_import_from_str(&text, "fallback").expect("dump should load");
|
||||||
|
assert_eq!(import.import_id, "dump-smoke");
|
||||||
|
assert_eq!(import.description.as_deref(), Some("test dump"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_bare_runtime_state() {
|
||||||
|
let text = serde_json::to_string(&state()).expect("state should serialize");
|
||||||
|
let import =
|
||||||
|
load_runtime_state_import_from_str(&text, "fallback").expect("state should load");
|
||||||
|
assert_eq!(import.import_id, "fallback");
|
||||||
|
assert!(import.description.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/rrt-runtime/src/lib.rs
Normal file
47
crates/rrt-runtime/src/lib.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod campaign_exe;
|
||||||
|
pub mod import;
|
||||||
|
pub mod persistence;
|
||||||
|
pub mod pk4;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod smp;
|
||||||
|
pub mod step;
|
||||||
|
pub mod summary;
|
||||||
|
pub mod win;
|
||||||
|
|
||||||
|
pub use calendar::{CalendarPoint, MONTH_SLOTS_PER_YEAR, PHASE_SLOTS_PER_MONTH, TICKS_PER_PHASE};
|
||||||
|
pub use campaign_exe::{
|
||||||
|
CAMPAIGN_SCENARIO_COUNT, CampaignExeInspectionReport, CampaignPageBand, CampaignScenarioEntry,
|
||||||
|
OBSERVED_CAMPAIGN_SCENARIO_NAMES, inspect_campaign_exe_bytes, inspect_campaign_exe_file,
|
||||||
|
};
|
||||||
|
pub use import::{
|
||||||
|
RuntimeStateDumpDocument, RuntimeStateDumpSource, RuntimeStateImport,
|
||||||
|
STATE_DUMP_FORMAT_VERSION, load_runtime_state_import, validate_runtime_state_dump_document,
|
||||||
|
};
|
||||||
|
pub use persistence::{
|
||||||
|
RuntimeSnapshotDocument, RuntimeSnapshotSource, SNAPSHOT_FORMAT_VERSION,
|
||||||
|
load_runtime_snapshot_document, save_runtime_snapshot_document,
|
||||||
|
validate_runtime_snapshot_document,
|
||||||
|
};
|
||||||
|
pub use pk4::{
|
||||||
|
PK4_DIRECTORY_ENTRY_STRIDE, PK4_MAGIC, Pk4Entry, Pk4ExtractionReport, Pk4InspectionReport,
|
||||||
|
extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file,
|
||||||
|
};
|
||||||
|
pub use runtime::{RuntimeCompany, RuntimeEventRecord, RuntimeServiceState, RuntimeState};
|
||||||
|
pub use smp::{
|
||||||
|
SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION, SmpAsciiPreview, SmpClassicPackedProfileBlock,
|
||||||
|
SmpClassicRehydrateProfileProbe, SmpContainerProfile, SmpEarlyContentProbe,
|
||||||
|
SmpHeaderVariantProbe, SmpInspectionReport, SmpKnownTagHit, SmpPackedProfileWordLane,
|
||||||
|
SmpPreamble, SmpPreambleWord, SmpRt3105PackedProfileBlock, SmpRt3105PackedProfileProbe,
|
||||||
|
SmpRt3105PostSpanBridgeProbe, SmpRt3105SaveBridgePayloadProbe, SmpRt3105SaveNameTableEntry,
|
||||||
|
SmpRt3105SaveNameTableProbe, SmpRuntimeAnchorCycleBlock, SmpRuntimePostSpanHeaderCandidate,
|
||||||
|
SmpRuntimePostSpanProbe, SmpRuntimeTrailerBlock, SmpSaveAnchorRunBlock, SmpSaveBootstrapBlock,
|
||||||
|
SmpSecondaryVariantProbe, SmpSharedHeader, inspect_smp_bytes, inspect_smp_file,
|
||||||
|
};
|
||||||
|
pub use step::{BoundaryEvent, ServiceEvent, StepCommand, StepResult, execute_step_command};
|
||||||
|
pub use summary::RuntimeSummary;
|
||||||
|
pub use win::{
|
||||||
|
WinAnonymousSelectorRecord, WinHeaderWord, WinInspectionReport, WinReferenceDeltaFrequency,
|
||||||
|
WinResourceRecordSample, WinResourceReference, WinResourceSelectorRecord, inspect_win_bytes,
|
||||||
|
inspect_win_file,
|
||||||
|
};
|
||||||
111
crates/rrt-runtime/src/persistence.rs
Normal file
111
crates/rrt-runtime/src/persistence.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{RuntimeState, RuntimeSummary};
|
||||||
|
|
||||||
|
pub const SNAPSHOT_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct RuntimeSnapshotSource {
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_fixture_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeSnapshotDocument {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub snapshot_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: RuntimeSnapshotSource,
|
||||||
|
pub state: RuntimeState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeSnapshotDocument {
|
||||||
|
pub fn summary(&self) -> RuntimeSummary {
|
||||||
|
RuntimeSummary::from_state(&self.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_runtime_snapshot_document(
|
||||||
|
document: &RuntimeSnapshotDocument,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if document.format_version != SNAPSHOT_FORMAT_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported snapshot format_version {} (expected {})",
|
||||||
|
document.format_version, SNAPSHOT_FORMAT_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if document.snapshot_id.trim().is_empty() {
|
||||||
|
return Err("snapshot_id must not be empty".to_string());
|
||||||
|
}
|
||||||
|
document.state.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_runtime_snapshot_document(
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<RuntimeSnapshotDocument, Box<dyn std::error::Error>> {
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
Ok(serde_json::from_str(&text)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_runtime_snapshot_document(
|
||||||
|
path: &Path,
|
||||||
|
document: &RuntimeSnapshotDocument,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
validate_runtime_snapshot_document(document)
|
||||||
|
.map_err(|err| format!("invalid runtime snapshot document: {err}"))?;
|
||||||
|
let bytes = serde_json::to_vec_pretty(document)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{CalendarPoint, RuntimeServiceState};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn snapshot() -> RuntimeSnapshotDocument {
|
||||||
|
RuntimeSnapshotDocument {
|
||||||
|
format_version: SNAPSHOT_FORMAT_VERSION,
|
||||||
|
snapshot_id: "snapshot-smoke".to_string(),
|
||||||
|
source: RuntimeSnapshotSource {
|
||||||
|
source_fixture_id: Some("fixture-smoke".to_string()),
|
||||||
|
description: Some("test snapshot".to_string()),
|
||||||
|
},
|
||||||
|
state: RuntimeState {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 0,
|
||||||
|
},
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
companies: Vec::new(),
|
||||||
|
event_runtime_records: Vec::new(),
|
||||||
|
service_state: RuntimeServiceState::default(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_snapshot_document() {
|
||||||
|
let document = snapshot();
|
||||||
|
assert!(validate_runtime_snapshot_document(&document).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrips_snapshot_json() {
|
||||||
|
let document = snapshot();
|
||||||
|
let value = serde_json::to_string_pretty(&document).expect("snapshot should serialize");
|
||||||
|
let reparsed: RuntimeSnapshotDocument =
|
||||||
|
serde_json::from_str(&value).expect("snapshot should deserialize");
|
||||||
|
assert_eq!(document, reparsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
313
crates/rrt-runtime/src/pk4.rs
Normal file
313
crates/rrt-runtime/src/pk4.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const PK4_MAGIC: u32 = 0x0000_03eb;
|
||||||
|
pub const PK4_DIRECTORY_ENTRY_STRIDE: usize = 0x4a;
|
||||||
|
pub const PK4_DIRECTORY_METADATA_LEN: usize = 13;
|
||||||
|
pub const PK4_DIRECTORY_NAME_LEN: usize = PK4_DIRECTORY_ENTRY_STRIDE - PK4_DIRECTORY_METADATA_LEN;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Pk4Entry {
|
||||||
|
pub index: usize,
|
||||||
|
pub directory_offset: usize,
|
||||||
|
pub directory_offset_hex: String,
|
||||||
|
pub crc32: u32,
|
||||||
|
pub crc32_hex: String,
|
||||||
|
pub payload_len: u32,
|
||||||
|
pub payload_len_hex: String,
|
||||||
|
pub payload_offset: u32,
|
||||||
|
pub payload_offset_hex: String,
|
||||||
|
pub payload_absolute_offset: usize,
|
||||||
|
pub payload_absolute_offset_hex: String,
|
||||||
|
pub payload_end_offset: usize,
|
||||||
|
pub payload_end_offset_hex: String,
|
||||||
|
pub flag: u8,
|
||||||
|
pub flag_hex: String,
|
||||||
|
pub extension: Option<String>,
|
||||||
|
pub payload_signature_ascii: String,
|
||||||
|
pub payload_signature_hex: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Pk4InspectionReport {
|
||||||
|
pub magic: u32,
|
||||||
|
pub magic_hex: String,
|
||||||
|
pub entry_count: usize,
|
||||||
|
pub directory_entry_stride: usize,
|
||||||
|
pub directory_len: usize,
|
||||||
|
pub directory_len_hex: String,
|
||||||
|
pub payload_base_offset: usize,
|
||||||
|
pub payload_base_offset_hex: String,
|
||||||
|
pub file_size: usize,
|
||||||
|
pub payloads_are_contiguous: bool,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
pub entries: Vec<Pk4Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Pk4ExtractionReport {
|
||||||
|
pub matched_entry_name: String,
|
||||||
|
pub case_insensitive_match: bool,
|
||||||
|
pub extracted_len: usize,
|
||||||
|
pub extracted_len_hex: String,
|
||||||
|
pub entry: Pk4Entry,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_pk4_file(path: &Path) -> Result<Pk4InspectionReport, Box<dyn std::error::Error>> {
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
inspect_pk4_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_pk4_bytes(bytes: &[u8]) -> Result<Pk4InspectionReport, Box<dyn std::error::Error>> {
|
||||||
|
let magic = read_u32_le(bytes, 0).ok_or("truncated pk4 magic")?;
|
||||||
|
let entry_count = read_u32_le(bytes, 4).ok_or("truncated pk4 entry count")? as usize;
|
||||||
|
let directory_len = entry_count
|
||||||
|
.checked_mul(PK4_DIRECTORY_ENTRY_STRIDE)
|
||||||
|
.ok_or("pk4 directory length overflow")?;
|
||||||
|
let payload_base_offset = 8usize
|
||||||
|
.checked_add(directory_len)
|
||||||
|
.ok_or("pk4 payload base overflow")?;
|
||||||
|
if payload_base_offset > bytes.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"pk4 directory extends past end of file: payload base 0x{payload_base_offset:x}, file size 0x{:x}",
|
||||||
|
bytes.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::with_capacity(entry_count);
|
||||||
|
for index in 0..entry_count {
|
||||||
|
let directory_offset = 8 + index * PK4_DIRECTORY_ENTRY_STRIDE;
|
||||||
|
let directory_entry = bytes
|
||||||
|
.get(directory_offset..directory_offset + PK4_DIRECTORY_ENTRY_STRIDE)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"truncated pk4 directory entry {} at offset 0x{directory_offset:x}",
|
||||||
|
index
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let crc32 = read_u32_le(directory_entry, 0).ok_or("truncated pk4 entry crc32")?;
|
||||||
|
let payload_len = read_u32_le(directory_entry, 4).ok_or("truncated pk4 entry length")?;
|
||||||
|
let payload_offset =
|
||||||
|
read_u32_le(directory_entry, 8).ok_or("truncated pk4 entry payload offset")?;
|
||||||
|
let flag = directory_entry[12];
|
||||||
|
let name = parse_name(&directory_entry[13..])?;
|
||||||
|
let payload_absolute_offset = payload_base_offset
|
||||||
|
.checked_add(payload_offset as usize)
|
||||||
|
.ok_or_else(|| format!("pk4 payload offset overflow for entry {name}"))?;
|
||||||
|
let payload_end_offset = payload_absolute_offset
|
||||||
|
.checked_add(payload_len as usize)
|
||||||
|
.ok_or_else(|| format!("pk4 payload end overflow for entry {name}"))?;
|
||||||
|
let payload = bytes.get(payload_absolute_offset..payload_end_offset).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"pk4 payload for entry {name} extends past end of file: 0x{payload_absolute_offset:x}..0x{payload_end_offset:x} > 0x{:x}",
|
||||||
|
bytes.len()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
entries.push(Pk4Entry {
|
||||||
|
index,
|
||||||
|
directory_offset,
|
||||||
|
directory_offset_hex: format!("0x{directory_offset:04x}"),
|
||||||
|
crc32,
|
||||||
|
crc32_hex: format!("0x{crc32:08x}"),
|
||||||
|
payload_len,
|
||||||
|
payload_len_hex: format!("0x{payload_len:08x}"),
|
||||||
|
payload_offset,
|
||||||
|
payload_offset_hex: format!("0x{payload_offset:08x}"),
|
||||||
|
payload_absolute_offset,
|
||||||
|
payload_absolute_offset_hex: format!("0x{payload_absolute_offset:08x}"),
|
||||||
|
payload_end_offset,
|
||||||
|
payload_end_offset_hex: format!("0x{payload_end_offset:08x}"),
|
||||||
|
flag,
|
||||||
|
flag_hex: format!("0x{flag:02x}"),
|
||||||
|
extension: Path::new(&name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.map(|extension| extension.to_ascii_lowercase()),
|
||||||
|
payload_signature_ascii: ascii_preview(payload, 8),
|
||||||
|
payload_signature_hex: hex_preview(payload, 8),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloads_are_contiguous = entries
|
||||||
|
.windows(2)
|
||||||
|
.all(|window| window[0].payload_end_offset == window[1].payload_absolute_offset);
|
||||||
|
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
if magic == PK4_MAGIC {
|
||||||
|
notes.push(
|
||||||
|
"Header magic matches the observed RT3 pack4 container family (0x03eb).".to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notes.push(format!(
|
||||||
|
"Header magic 0x{magic:08x} differs from the observed RT3 pack4 container family 0x{PK4_MAGIC:08x}."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
notes.push(format!(
|
||||||
|
"Payload base is derived as 8 + entry_count * 0x{PK4_DIRECTORY_ENTRY_STRIDE:02x}."
|
||||||
|
));
|
||||||
|
if payloads_are_contiguous {
|
||||||
|
notes.push(
|
||||||
|
"Entry payload offsets form one contiguous packed data region in directory order."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Pk4InspectionReport {
|
||||||
|
magic,
|
||||||
|
magic_hex: format!("0x{magic:08x}"),
|
||||||
|
entry_count,
|
||||||
|
directory_entry_stride: PK4_DIRECTORY_ENTRY_STRIDE,
|
||||||
|
directory_len,
|
||||||
|
directory_len_hex: format!("0x{directory_len:08x}"),
|
||||||
|
payload_base_offset,
|
||||||
|
payload_base_offset_hex: format!("0x{payload_base_offset:08x}"),
|
||||||
|
file_size: bytes.len(),
|
||||||
|
payloads_are_contiguous,
|
||||||
|
notes,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_pk4_entry_file(
|
||||||
|
pk4_path: &Path,
|
||||||
|
entry_name: &str,
|
||||||
|
output_path: &Path,
|
||||||
|
) -> Result<Pk4ExtractionReport, Box<dyn std::error::Error>> {
|
||||||
|
let bytes = fs::read(pk4_path)?;
|
||||||
|
let (report, payload) = extract_pk4_entry_bytes(&bytes, entry_name)?;
|
||||||
|
fs::write(output_path, payload)?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_pk4_entry_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
entry_name: &str,
|
||||||
|
) -> Result<(Pk4ExtractionReport, Vec<u8>), Box<dyn std::error::Error>> {
|
||||||
|
let inspection = inspect_pk4_bytes(bytes)?;
|
||||||
|
let (entry, case_insensitive_match) = find_entry(&inspection.entries, entry_name)
|
||||||
|
.ok_or_else(|| format!("pk4 entry not found: {entry_name}"))?;
|
||||||
|
let payload = bytes[entry.payload_absolute_offset..entry.payload_end_offset].to_vec();
|
||||||
|
let report = Pk4ExtractionReport {
|
||||||
|
matched_entry_name: entry.name.clone(),
|
||||||
|
case_insensitive_match,
|
||||||
|
extracted_len: payload.len(),
|
||||||
|
extracted_len_hex: format!("0x{:08x}", payload.len()),
|
||||||
|
entry: entry.clone(),
|
||||||
|
};
|
||||||
|
Ok((report, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_entry<'a>(entries: &'a [Pk4Entry], requested_name: &str) -> Option<(&'a Pk4Entry, bool)> {
|
||||||
|
if let Some(entry) = entries.iter().find(|entry| entry.name == requested_name) {
|
||||||
|
return Some((entry, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_lower = requested_name.to_ascii_lowercase();
|
||||||
|
let mut matches = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.name.to_ascii_lowercase() == requested_lower);
|
||||||
|
let first = matches.next()?;
|
||||||
|
if matches.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((first, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_name(bytes: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let raw = bytes
|
||||||
|
.split(|byte| *byte == 0)
|
||||||
|
.next()
|
||||||
|
.ok_or("missing pk4 entry name")?;
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err("empty pk4 entry name".into());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8(raw.to_vec())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ascii_preview(bytes: &[u8], limit: usize) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|byte| match byte {
|
||||||
|
b' '..=b'~' => *byte as char,
|
||||||
|
_ => '.',
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_preview(bytes: &[u8], limit: usize) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
for byte in bytes.iter().take(limit) {
|
||||||
|
output.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let slice = bytes.get(offset..offset + 4)?;
|
||||||
|
Some(u32::from_le_bytes(slice.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn build_entry(
|
||||||
|
crc32: u32,
|
||||||
|
payload_len: u32,
|
||||||
|
payload_offset: u32,
|
||||||
|
name: &str,
|
||||||
|
) -> [u8; PK4_DIRECTORY_ENTRY_STRIDE] {
|
||||||
|
let mut entry = [0u8; PK4_DIRECTORY_ENTRY_STRIDE];
|
||||||
|
entry[0..4].copy_from_slice(&crc32.to_le_bytes());
|
||||||
|
entry[4..8].copy_from_slice(&payload_len.to_le_bytes());
|
||||||
|
entry[8..12].copy_from_slice(&payload_offset.to_le_bytes());
|
||||||
|
let name_bytes = name.as_bytes();
|
||||||
|
entry[13..13 + name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspects_synthetic_pk4_bytes() {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(&PK4_MAGIC.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&(2u32).to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&build_entry(0x11223344, 5, 0, "alpha.txt"));
|
||||||
|
bytes.extend_from_slice(&build_entry(0x55667788, 4, 5, "beta.dds"));
|
||||||
|
bytes.extend_from_slice(b"helloDDS!");
|
||||||
|
|
||||||
|
let report = inspect_pk4_bytes(&bytes).expect("pk4 inspection should succeed");
|
||||||
|
assert_eq!(report.entry_count, 2);
|
||||||
|
assert_eq!(
|
||||||
|
report.payload_base_offset,
|
||||||
|
8 + 2 * PK4_DIRECTORY_ENTRY_STRIDE
|
||||||
|
);
|
||||||
|
assert!(report.payloads_are_contiguous);
|
||||||
|
assert_eq!(report.entries[0].name, "alpha.txt");
|
||||||
|
assert_eq!(report.entries[0].payload_signature_ascii, "hello");
|
||||||
|
assert_eq!(report.entries[1].name, "beta.dds");
|
||||||
|
assert_eq!(report.entries[1].payload_signature_ascii, "DDS!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_case_insensitive_entry_match() {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(&PK4_MAGIC.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&(1u32).to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&build_entry(0x11223344, 5, 0, "Campaign.win"));
|
||||||
|
bytes.extend_from_slice(b"HELLO");
|
||||||
|
|
||||||
|
let (report, payload) =
|
||||||
|
extract_pk4_entry_bytes(&bytes, "campaign.win").expect("pk4 extraction should succeed");
|
||||||
|
assert!(report.case_insensitive_match);
|
||||||
|
assert_eq!(report.matched_entry_name, "Campaign.win");
|
||||||
|
assert_eq!(payload, b"HELLO");
|
||||||
|
}
|
||||||
|
}
|
||||||
110
crates/rrt-runtime/src/runtime.rs
Normal file
110
crates/rrt-runtime/src/runtime.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::CalendarPoint;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeCompany {
|
||||||
|
pub company_id: u32,
|
||||||
|
pub current_cash: i64,
|
||||||
|
pub debt: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeEventRecord {
|
||||||
|
pub record_id: u32,
|
||||||
|
pub trigger_kind: u8,
|
||||||
|
pub active: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub service_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub marks_collection_dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeServiceState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub periodic_boundary_calls: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trigger_dispatch_counts: BTreeMap<u8, u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_event_record_services: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dirty_rerun_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeState {
|
||||||
|
pub calendar: CalendarPoint,
|
||||||
|
#[serde(default)]
|
||||||
|
pub world_flags: BTreeMap<String, bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub companies: Vec<RuntimeCompany>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub event_runtime_records: Vec<RuntimeEventRecord>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub service_state: RuntimeServiceState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeState {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
self.calendar.validate()?;
|
||||||
|
|
||||||
|
let mut seen_company_ids = BTreeSet::new();
|
||||||
|
for company in &self.companies {
|
||||||
|
if !seen_company_ids.insert(company.company_id) {
|
||||||
|
return Err(format!("duplicate company_id {}", company.company_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen_record_ids = BTreeSet::new();
|
||||||
|
for record in &self.event_runtime_records {
|
||||||
|
if !seen_record_ids.insert(record.record_id) {
|
||||||
|
return Err(format!("duplicate record_id {}", record.record_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in self.world_flags.keys() {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err("world_flags contains an empty key".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_company_ids() {
|
||||||
|
let state = RuntimeState {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 0,
|
||||||
|
},
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
companies: vec![
|
||||||
|
RuntimeCompany {
|
||||||
|
company_id: 1,
|
||||||
|
current_cash: 100,
|
||||||
|
debt: 0,
|
||||||
|
},
|
||||||
|
RuntimeCompany {
|
||||||
|
company_id: 1,
|
||||||
|
current_cash: 200,
|
||||||
|
debt: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
event_runtime_records: Vec::new(),
|
||||||
|
service_state: RuntimeServiceState::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
3370
crates/rrt-runtime/src/smp.rs
Normal file
3370
crates/rrt-runtime/src/smp.rs
Normal file
File diff suppressed because it is too large
Load diff
304
crates/rrt-runtime/src/step.rs
Normal file
304
crates/rrt-runtime/src/step.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{RuntimeState, RuntimeSummary, calendar::BoundaryEventKind};
|
||||||
|
|
||||||
|
const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum StepCommand {
|
||||||
|
AdvanceTo { calendar: crate::CalendarPoint },
|
||||||
|
StepCount { steps: u32 },
|
||||||
|
ServiceTriggerKind { trigger_kind: u8 },
|
||||||
|
ServicePeriodicBoundary,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StepCommand {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
match self {
|
||||||
|
Self::AdvanceTo { calendar } => calendar.validate(),
|
||||||
|
Self::StepCount { steps } => {
|
||||||
|
if *steps == 0 {
|
||||||
|
return Err("step_count command requires steps > 0".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Self::ServiceTriggerKind { .. } | Self::ServicePeriodicBoundary => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BoundaryEvent {
|
||||||
|
pub kind: String,
|
||||||
|
pub calendar: crate::CalendarPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceEvent {
|
||||||
|
pub kind: String,
|
||||||
|
pub trigger_kind: Option<u8>,
|
||||||
|
pub serviced_record_ids: Vec<u32>,
|
||||||
|
pub dirty_rerun: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StepResult {
|
||||||
|
pub initial_summary: RuntimeSummary,
|
||||||
|
pub final_summary: RuntimeSummary,
|
||||||
|
pub steps_executed: u64,
|
||||||
|
pub boundary_events: Vec<BoundaryEvent>,
|
||||||
|
pub service_events: Vec<ServiceEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_step_command(
|
||||||
|
state: &mut RuntimeState,
|
||||||
|
command: &StepCommand,
|
||||||
|
) -> Result<StepResult, String> {
|
||||||
|
state.validate()?;
|
||||||
|
command.validate()?;
|
||||||
|
|
||||||
|
let initial_summary = RuntimeSummary::from_state(state);
|
||||||
|
let mut boundary_events = Vec::new();
|
||||||
|
let mut service_events = Vec::new();
|
||||||
|
let steps_executed = match command {
|
||||||
|
StepCommand::AdvanceTo { calendar } => {
|
||||||
|
advance_to_target_calendar_point(state, *calendar, &mut boundary_events)?
|
||||||
|
}
|
||||||
|
StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events),
|
||||||
|
StepCommand::ServiceTriggerKind { trigger_kind } => {
|
||||||
|
service_trigger_kind(state, *trigger_kind, &mut service_events);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
StepCommand::ServicePeriodicBoundary => {
|
||||||
|
service_periodic_boundary(state, &mut service_events);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let final_summary = RuntimeSummary::from_state(state);
|
||||||
|
|
||||||
|
Ok(StepResult {
|
||||||
|
initial_summary,
|
||||||
|
final_summary,
|
||||||
|
steps_executed,
|
||||||
|
boundary_events,
|
||||||
|
service_events,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_to_target_calendar_point(
|
||||||
|
state: &mut RuntimeState,
|
||||||
|
target: crate::CalendarPoint,
|
||||||
|
boundary_events: &mut Vec<BoundaryEvent>,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
target.validate()?;
|
||||||
|
if target < state.calendar {
|
||||||
|
return Err(format!(
|
||||||
|
"advance_to target {:?} is earlier than current calendar {:?}",
|
||||||
|
target, state.calendar
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut steps = 0_u64;
|
||||||
|
while state.calendar < target {
|
||||||
|
step_once(state, boundary_events);
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
Ok(steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_count(
|
||||||
|
state: &mut RuntimeState,
|
||||||
|
steps: u32,
|
||||||
|
boundary_events: &mut Vec<BoundaryEvent>,
|
||||||
|
) -> u64 {
|
||||||
|
for _ in 0..steps {
|
||||||
|
step_once(state, boundary_events);
|
||||||
|
}
|
||||||
|
steps.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_once(state: &mut RuntimeState, boundary_events: &mut Vec<BoundaryEvent>) {
|
||||||
|
let boundary = state.calendar.step_forward();
|
||||||
|
if boundary != BoundaryEventKind::Tick {
|
||||||
|
boundary_events.push(BoundaryEvent {
|
||||||
|
kind: boundary_kind_label(boundary).to_string(),
|
||||||
|
calendar: state.calendar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boundary_kind_label(boundary: BoundaryEventKind) -> &'static str {
|
||||||
|
match boundary {
|
||||||
|
BoundaryEventKind::Tick => "tick",
|
||||||
|
BoundaryEventKind::PhaseRollover => "phase_rollover",
|
||||||
|
BoundaryEventKind::MonthRollover => "month_rollover",
|
||||||
|
BoundaryEventKind::YearRollover => "year_rollover",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_periodic_boundary(state: &mut RuntimeState, service_events: &mut Vec<ServiceEvent>) {
|
||||||
|
state.service_state.periodic_boundary_calls += 1;
|
||||||
|
|
||||||
|
for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER {
|
||||||
|
service_trigger_kind(state, trigger_kind, service_events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_trigger_kind(
|
||||||
|
state: &mut RuntimeState,
|
||||||
|
trigger_kind: u8,
|
||||||
|
service_events: &mut Vec<ServiceEvent>,
|
||||||
|
) {
|
||||||
|
let mut serviced_record_ids = Vec::new();
|
||||||
|
let mut dirty_rerun = false;
|
||||||
|
|
||||||
|
*state
|
||||||
|
.service_state
|
||||||
|
.trigger_dispatch_counts
|
||||||
|
.entry(trigger_kind)
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
|
||||||
|
for record in &mut state.event_runtime_records {
|
||||||
|
if record.active && record.trigger_kind == trigger_kind {
|
||||||
|
record.service_count += 1;
|
||||||
|
serviced_record_ids.push(record.record_id);
|
||||||
|
state.service_state.total_event_record_services += 1;
|
||||||
|
if trigger_kind != 0x0a && record.marks_collection_dirty {
|
||||||
|
dirty_rerun = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service_events.push(ServiceEvent {
|
||||||
|
kind: "trigger_dispatch".to_string(),
|
||||||
|
trigger_kind: Some(trigger_kind),
|
||||||
|
serviced_record_ids,
|
||||||
|
dirty_rerun,
|
||||||
|
});
|
||||||
|
|
||||||
|
if dirty_rerun {
|
||||||
|
state.service_state.dirty_rerun_count += 1;
|
||||||
|
service_trigger_kind(state, 0x0a, service_events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{CalendarPoint, RuntimeCompany, RuntimeEventRecord, RuntimeServiceState};
|
||||||
|
|
||||||
|
fn state() -> RuntimeState {
|
||||||
|
RuntimeState {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 0,
|
||||||
|
},
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
companies: vec![RuntimeCompany {
|
||||||
|
company_id: 1,
|
||||||
|
current_cash: 10,
|
||||||
|
debt: 0,
|
||||||
|
}],
|
||||||
|
event_runtime_records: Vec::new(),
|
||||||
|
service_state: RuntimeServiceState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advances_to_target() {
|
||||||
|
let mut state = state();
|
||||||
|
let result = execute_step_command(
|
||||||
|
&mut state,
|
||||||
|
&StepCommand::AdvanceTo {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("advance_to should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.steps_executed, 5);
|
||||||
|
assert_eq!(state.calendar.tick_slot, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_backward_target() {
|
||||||
|
let mut state = state();
|
||||||
|
state.calendar.tick_slot = 3;
|
||||||
|
|
||||||
|
let result = execute_step_command(
|
||||||
|
&mut state,
|
||||||
|
&StepCommand::AdvanceTo {
|
||||||
|
calendar: CalendarPoint {
|
||||||
|
year: 1830,
|
||||||
|
month_slot: 0,
|
||||||
|
phase_slot: 0,
|
||||||
|
tick_slot: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn services_periodic_trigger_order_and_dirty_rerun() {
|
||||||
|
let mut state = RuntimeState {
|
||||||
|
event_runtime_records: vec![
|
||||||
|
RuntimeEventRecord {
|
||||||
|
record_id: 1,
|
||||||
|
trigger_kind: 1,
|
||||||
|
active: true,
|
||||||
|
service_count: 0,
|
||||||
|
marks_collection_dirty: true,
|
||||||
|
},
|
||||||
|
RuntimeEventRecord {
|
||||||
|
record_id: 2,
|
||||||
|
trigger_kind: 4,
|
||||||
|
active: true,
|
||||||
|
service_count: 0,
|
||||||
|
marks_collection_dirty: false,
|
||||||
|
},
|
||||||
|
RuntimeEventRecord {
|
||||||
|
record_id: 3,
|
||||||
|
trigger_kind: 0x0a,
|
||||||
|
active: true,
|
||||||
|
service_count: 0,
|
||||||
|
marks_collection_dirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..state()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = execute_step_command(&mut state, &StepCommand::ServicePeriodicBoundary)
|
||||||
|
.expect("periodic boundary service should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.steps_executed, 0);
|
||||||
|
assert_eq!(state.service_state.periodic_boundary_calls, 1);
|
||||||
|
assert_eq!(state.service_state.total_event_record_services, 3);
|
||||||
|
assert_eq!(state.service_state.dirty_rerun_count, 1);
|
||||||
|
assert_eq!(state.event_runtime_records[0].service_count, 1);
|
||||||
|
assert_eq!(state.event_runtime_records[1].service_count, 1);
|
||||||
|
assert_eq!(state.event_runtime_records[2].service_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
state.service_state.trigger_dispatch_counts.get(&1),
|
||||||
|
Some(&1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.service_state.trigger_dispatch_counts.get(&4),
|
||||||
|
Some(&1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.service_state.trigger_dispatch_counts.get(&0x0a),
|
||||||
|
Some(&1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/rrt-runtime/src/summary.rs
Normal file
40
crates/rrt-runtime/src/summary.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{CalendarPoint, RuntimeState};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeSummary {
|
||||||
|
pub calendar: CalendarPoint,
|
||||||
|
pub world_flag_count: usize,
|
||||||
|
pub company_count: usize,
|
||||||
|
pub event_runtime_record_count: usize,
|
||||||
|
pub total_event_record_service_count: u64,
|
||||||
|
pub periodic_boundary_call_count: u64,
|
||||||
|
pub total_trigger_dispatch_count: u64,
|
||||||
|
pub dirty_rerun_count: u64,
|
||||||
|
pub total_company_cash: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeSummary {
|
||||||
|
pub fn from_state(state: &RuntimeState) -> Self {
|
||||||
|
Self {
|
||||||
|
calendar: state.calendar,
|
||||||
|
world_flag_count: state.world_flags.len(),
|
||||||
|
company_count: state.companies.len(),
|
||||||
|
event_runtime_record_count: state.event_runtime_records.len(),
|
||||||
|
total_event_record_service_count: state.service_state.total_event_record_services,
|
||||||
|
periodic_boundary_call_count: state.service_state.periodic_boundary_calls,
|
||||||
|
total_trigger_dispatch_count: state
|
||||||
|
.service_state
|
||||||
|
.trigger_dispatch_counts
|
||||||
|
.values()
|
||||||
|
.sum(),
|
||||||
|
dirty_rerun_count: state.service_state.dirty_rerun_count,
|
||||||
|
total_company_cash: state
|
||||||
|
.companies
|
||||||
|
.iter()
|
||||||
|
.map(|company| company.current_cash)
|
||||||
|
.sum(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
547
crates/rrt-runtime/src/win.rs
Normal file
547
crates/rrt-runtime/src/win.rs
Normal file
|
|
@ -0,0 +1,547 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const WIN_COMMON_HEADER_LEN: usize = 0x50;
|
||||||
|
const WIN_INLINE_RESOURCE_OFFSET: usize = 0x50;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinHeaderWord {
|
||||||
|
pub offset: usize,
|
||||||
|
pub offset_hex: String,
|
||||||
|
pub value: u32,
|
||||||
|
pub value_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinResourceReference {
|
||||||
|
pub offset: usize,
|
||||||
|
pub offset_hex: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinReferenceDeltaFrequency {
|
||||||
|
pub delta: usize,
|
||||||
|
pub delta_hex: String,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinResourceRecordSample {
|
||||||
|
pub offset: usize,
|
||||||
|
pub offset_hex: String,
|
||||||
|
pub name: String,
|
||||||
|
pub delta_from_previous: Option<usize>,
|
||||||
|
pub delta_from_previous_hex: Option<String>,
|
||||||
|
pub prelude_words: Vec<WinHeaderWord>,
|
||||||
|
pub post_name_word_0: u32,
|
||||||
|
pub post_name_word_0_hex: String,
|
||||||
|
pub post_name_word_0_high_u16: u16,
|
||||||
|
pub post_name_word_0_high_u16_hex: String,
|
||||||
|
pub post_name_word_0_low_u16: u16,
|
||||||
|
pub post_name_word_0_low_u16_hex: String,
|
||||||
|
pub post_name_word_1: u32,
|
||||||
|
pub post_name_word_1_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinResourceSelectorRecord {
|
||||||
|
pub offset: usize,
|
||||||
|
pub offset_hex: String,
|
||||||
|
pub name: String,
|
||||||
|
pub post_name_word_0: u32,
|
||||||
|
pub post_name_word_0_hex: String,
|
||||||
|
pub selector_high_u16: u16,
|
||||||
|
pub selector_high_u16_hex: String,
|
||||||
|
pub selector_low_u16: u16,
|
||||||
|
pub selector_low_u16_hex: String,
|
||||||
|
pub post_name_word_1: u32,
|
||||||
|
pub post_name_word_1_hex: String,
|
||||||
|
pub post_name_word_1_high_u16: u16,
|
||||||
|
pub post_name_word_1_high_u16_hex: String,
|
||||||
|
pub post_name_word_1_middle_u16: u16,
|
||||||
|
pub post_name_word_1_middle_u16_hex: String,
|
||||||
|
pub post_name_word_1_low_u16: u16,
|
||||||
|
pub post_name_word_1_low_u16_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinAnonymousSelectorRecord {
|
||||||
|
pub record_offset: usize,
|
||||||
|
pub record_offset_hex: String,
|
||||||
|
pub preceding_named_record_name: Option<String>,
|
||||||
|
pub preceding_named_record_offset_hex: Option<String>,
|
||||||
|
pub following_named_record_name: Option<String>,
|
||||||
|
pub following_named_record_offset_hex: Option<String>,
|
||||||
|
pub selector_word_0: u32,
|
||||||
|
pub selector_word_0_hex: String,
|
||||||
|
pub selector_word_0_high_u16: u16,
|
||||||
|
pub selector_word_0_high_u16_hex: String,
|
||||||
|
pub selector_word_0_low_u16: u16,
|
||||||
|
pub selector_word_0_low_u16_hex: String,
|
||||||
|
pub selector_word_1: u32,
|
||||||
|
pub selector_word_1_hex: String,
|
||||||
|
pub selector_word_1_middle_u16: u16,
|
||||||
|
pub selector_word_1_middle_u16_hex: String,
|
||||||
|
pub body_word_0: u32,
|
||||||
|
pub body_word_0_hex: String,
|
||||||
|
pub body_word_1: u32,
|
||||||
|
pub body_word_1_hex: String,
|
||||||
|
pub body_word_2: u32,
|
||||||
|
pub body_word_2_hex: String,
|
||||||
|
pub body_word_3: u32,
|
||||||
|
pub body_word_3_hex: String,
|
||||||
|
pub footer_word_0: u32,
|
||||||
|
pub footer_word_0_hex: String,
|
||||||
|
pub footer_word_1: u32,
|
||||||
|
pub footer_word_1_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WinInspectionReport {
|
||||||
|
pub file_size: usize,
|
||||||
|
pub common_header_len: usize,
|
||||||
|
pub common_header_len_hex: String,
|
||||||
|
pub shared_header_words: Vec<WinHeaderWord>,
|
||||||
|
pub matches_observed_common_signature: bool,
|
||||||
|
pub common_resource_record_prelude_prefix_words: Option<Vec<String>>,
|
||||||
|
pub name_len_matches_prelude_word_3_plus_nul_count: usize,
|
||||||
|
pub inline_root_resource_name: Option<String>,
|
||||||
|
pub inline_root_resource_offset: Option<usize>,
|
||||||
|
pub inline_root_resource_offset_hex: Option<String>,
|
||||||
|
pub imb_reference_count: usize,
|
||||||
|
pub unique_imb_reference_count: usize,
|
||||||
|
pub unique_imb_references: Vec<String>,
|
||||||
|
pub dominant_reference_deltas: Vec<WinReferenceDeltaFrequency>,
|
||||||
|
pub resource_selector_records: Vec<WinResourceSelectorRecord>,
|
||||||
|
pub anonymous_selector_records: Vec<WinAnonymousSelectorRecord>,
|
||||||
|
pub first_resource_record_samples: Vec<WinResourceRecordSample>,
|
||||||
|
pub first_imb_references: Vec<WinResourceReference>,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_win_file(path: &Path) -> Result<WinInspectionReport, Box<dyn std::error::Error>> {
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
inspect_win_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_win_bytes(bytes: &[u8]) -> Result<WinInspectionReport, Box<dyn std::error::Error>> {
|
||||||
|
if bytes.len() < WIN_COMMON_HEADER_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"window resource is too short for the observed common header: {} < 0x{WIN_COMMON_HEADER_LEN:x}",
|
||||||
|
bytes.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_offsets = [
|
||||||
|
0x00usize, 0x04, 0x08, 0x0c, 0x10, 0x14, 0x18, 0x1c, 0x20, 0x24, 0x28, 0x2c, 0x30, 0x34,
|
||||||
|
0x38, 0x3c, 0x40, 0x44, 0x48, 0x4c,
|
||||||
|
];
|
||||||
|
let shared_header_words = header_offsets
|
||||||
|
.iter()
|
||||||
|
.map(|offset| {
|
||||||
|
let value = read_u32_le(bytes, *offset).expect("validated common header length");
|
||||||
|
WinHeaderWord {
|
||||||
|
offset: *offset,
|
||||||
|
offset_hex: format!("0x{offset:02x}"),
|
||||||
|
value,
|
||||||
|
value_hex: format!("0x{value:08x}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let matches_observed_common_signature = read_u32_le(bytes, 0x00) == Some(0x0000_07d0)
|
||||||
|
&& read_u32_le(bytes, 0x04) == Some(0)
|
||||||
|
&& read_u32_le(bytes, 0x08) == Some(0)
|
||||||
|
&& read_u32_le(bytes, 0x0c) == Some(0x8000_0000)
|
||||||
|
&& read_u32_le(bytes, 0x10) == Some(0x8000_003f)
|
||||||
|
&& read_u32_le(bytes, 0x14) == Some(0x0000_003f)
|
||||||
|
&& read_u32_le(bytes, 0x34) == Some(0x0007_d100)
|
||||||
|
&& read_u32_le(bytes, 0x38) == Some(0x0007_d200)
|
||||||
|
&& read_u32_le(bytes, 0x40) == Some(0x000b_b800)
|
||||||
|
&& read_u32_le(bytes, 0x48) == Some(0x000b_b900);
|
||||||
|
|
||||||
|
let inline_root_resource_name = parse_inline_ascii_name(bytes, WIN_INLINE_RESOURCE_OFFSET);
|
||||||
|
let inline_root_resource_offset = inline_root_resource_name
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| WIN_INLINE_RESOURCE_OFFSET + 1);
|
||||||
|
let inline_root_resource_offset_hex =
|
||||||
|
inline_root_resource_offset.map(|offset| format!("0x{offset:04x}"));
|
||||||
|
|
||||||
|
let all_imb_references = collect_imb_references(bytes);
|
||||||
|
let resource_record_samples = build_resource_record_samples(bytes, &all_imb_references);
|
||||||
|
let resource_selector_records = build_resource_selector_records(&resource_record_samples);
|
||||||
|
let anonymous_selector_records = collect_anonymous_selector_records(bytes, &all_imb_references);
|
||||||
|
let common_resource_record_prelude_prefix_words =
|
||||||
|
shared_prelude_prefix_hex(&resource_record_samples);
|
||||||
|
let name_len_matches_prelude_word_3_plus_nul_count = resource_record_samples
|
||||||
|
.iter()
|
||||||
|
.filter(|sample| {
|
||||||
|
sample.prelude_words.len() == 4
|
||||||
|
&& sample.prelude_words[3].value == (sample.name.len() as u32 + 1)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let mut unique_imb_references = Vec::new();
|
||||||
|
for reference in &all_imb_references {
|
||||||
|
if !unique_imb_references.contains(&reference.name) {
|
||||||
|
unique_imb_references.push(reference.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
if matches_observed_common_signature {
|
||||||
|
notes.push(
|
||||||
|
"Header matches the observed shared .win signature seen in Campaign.win, CompanyDetail.win, and setup.win."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notes.push(
|
||||||
|
"Header diverges from the currently observed shared .win signature; treat field meanings as provisional."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if inline_root_resource_name.is_some() {
|
||||||
|
notes.push(
|
||||||
|
"The blob carries an inline root .imb resource name immediately after the common 0x50-byte header."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notes.push(
|
||||||
|
"No inline root .imb resource name appears at 0x50; this window likely starts directly with control records."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notes.push(
|
||||||
|
"Embedded .imb strings are reported as resource references with selector lanes; this inspector still does not decode full control record semantics."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(WinInspectionReport {
|
||||||
|
file_size: bytes.len(),
|
||||||
|
common_header_len: WIN_COMMON_HEADER_LEN,
|
||||||
|
common_header_len_hex: format!("0x{WIN_COMMON_HEADER_LEN:02x}"),
|
||||||
|
shared_header_words,
|
||||||
|
matches_observed_common_signature,
|
||||||
|
common_resource_record_prelude_prefix_words,
|
||||||
|
name_len_matches_prelude_word_3_plus_nul_count,
|
||||||
|
inline_root_resource_name,
|
||||||
|
inline_root_resource_offset,
|
||||||
|
inline_root_resource_offset_hex,
|
||||||
|
imb_reference_count: all_imb_references.len(),
|
||||||
|
unique_imb_reference_count: unique_imb_references.len(),
|
||||||
|
unique_imb_references,
|
||||||
|
dominant_reference_deltas: build_delta_histogram(&resource_record_samples),
|
||||||
|
resource_selector_records,
|
||||||
|
anonymous_selector_records,
|
||||||
|
first_resource_record_samples: resource_record_samples.into_iter().take(32).collect(),
|
||||||
|
first_imb_references: all_imb_references.into_iter().take(32).collect(),
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_imb_references(bytes: &[u8]) -> Vec<WinResourceReference> {
|
||||||
|
let mut references = Vec::new();
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < bytes.len() {
|
||||||
|
if let Some(name) = parse_imb_reference_at(bytes, offset) {
|
||||||
|
references.push(WinResourceReference {
|
||||||
|
offset,
|
||||||
|
offset_hex: format!("0x{offset:04x}"),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
references
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resource_record_samples(
|
||||||
|
bytes: &[u8],
|
||||||
|
references: &[WinResourceReference],
|
||||||
|
) -> Vec<WinResourceRecordSample> {
|
||||||
|
let mut samples = Vec::with_capacity(references.len());
|
||||||
|
for (index, reference) in references.iter().enumerate() {
|
||||||
|
let previous_offset = index
|
||||||
|
.checked_sub(1)
|
||||||
|
.and_then(|previous| references.get(previous))
|
||||||
|
.map(|previous| previous.offset);
|
||||||
|
let delta_from_previous = previous_offset.map(|previous| reference.offset - previous);
|
||||||
|
let delta_from_previous_hex = delta_from_previous.map(|delta| format!("0x{delta:x}"));
|
||||||
|
|
||||||
|
let prelude_words = if reference.offset >= 16 {
|
||||||
|
(0..4)
|
||||||
|
.map(|index| {
|
||||||
|
let offset = reference.offset - 16 + index * 4;
|
||||||
|
let value = read_u32_le(bytes, offset).unwrap_or(0);
|
||||||
|
WinHeaderWord {
|
||||||
|
offset,
|
||||||
|
offset_hex: format!("0x{offset:04x}"),
|
||||||
|
value,
|
||||||
|
value_hex: format!("0x{value:08x}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let name_end = reference.offset + reference.name.len();
|
||||||
|
let post_name_word_0 = read_u32_le(bytes, name_end + 1).unwrap_or(0);
|
||||||
|
let post_name_word_1 = read_u32_le(bytes, name_end + 5).unwrap_or(0);
|
||||||
|
let post_name_word_0_high_u16 = ((post_name_word_0 >> 16) & 0xffff) as u16;
|
||||||
|
let post_name_word_0_low_u16 = (post_name_word_0 & 0xffff) as u16;
|
||||||
|
|
||||||
|
samples.push(WinResourceRecordSample {
|
||||||
|
offset: reference.offset,
|
||||||
|
offset_hex: reference.offset_hex.clone(),
|
||||||
|
name: reference.name.clone(),
|
||||||
|
delta_from_previous,
|
||||||
|
delta_from_previous_hex,
|
||||||
|
prelude_words,
|
||||||
|
post_name_word_0,
|
||||||
|
post_name_word_0_hex: format!("0x{post_name_word_0:08x}"),
|
||||||
|
post_name_word_0_high_u16,
|
||||||
|
post_name_word_0_high_u16_hex: format!("0x{post_name_word_0_high_u16:04x}"),
|
||||||
|
post_name_word_0_low_u16,
|
||||||
|
post_name_word_0_low_u16_hex: format!("0x{post_name_word_0_low_u16:04x}"),
|
||||||
|
post_name_word_1,
|
||||||
|
post_name_word_1_hex: format!("0x{post_name_word_1:08x}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
samples
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_delta_histogram(samples: &[WinResourceRecordSample]) -> Vec<WinReferenceDeltaFrequency> {
|
||||||
|
let mut counts = std::collections::BTreeMap::<usize, usize>::new();
|
||||||
|
for sample in samples {
|
||||||
|
if let Some(delta) = sample.delta_from_previous {
|
||||||
|
*counts.entry(delta).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut frequencies = counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(delta, count)| WinReferenceDeltaFrequency {
|
||||||
|
delta,
|
||||||
|
delta_hex: format!("0x{delta:x}"),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
frequencies.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.count
|
||||||
|
.cmp(&left.count)
|
||||||
|
.then_with(|| left.delta.cmp(&right.delta))
|
||||||
|
});
|
||||||
|
frequencies.truncate(12);
|
||||||
|
frequencies
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resource_selector_records(
|
||||||
|
samples: &[WinResourceRecordSample],
|
||||||
|
) -> Vec<WinResourceSelectorRecord> {
|
||||||
|
samples
|
||||||
|
.iter()
|
||||||
|
.map(|sample| {
|
||||||
|
let post_name_word_1_high_u16 = ((sample.post_name_word_1 >> 16) & 0xffff) as u16;
|
||||||
|
let post_name_word_1_middle_u16 = ((sample.post_name_word_1 >> 8) & 0xffff) as u16;
|
||||||
|
let post_name_word_1_low_u16 = (sample.post_name_word_1 & 0xffff) as u16;
|
||||||
|
WinResourceSelectorRecord {
|
||||||
|
offset: sample.offset,
|
||||||
|
offset_hex: sample.offset_hex.clone(),
|
||||||
|
name: sample.name.clone(),
|
||||||
|
post_name_word_0: sample.post_name_word_0,
|
||||||
|
post_name_word_0_hex: sample.post_name_word_0_hex.clone(),
|
||||||
|
selector_high_u16: sample.post_name_word_0_high_u16,
|
||||||
|
selector_high_u16_hex: sample.post_name_word_0_high_u16_hex.clone(),
|
||||||
|
selector_low_u16: sample.post_name_word_0_low_u16,
|
||||||
|
selector_low_u16_hex: sample.post_name_word_0_low_u16_hex.clone(),
|
||||||
|
post_name_word_1: sample.post_name_word_1,
|
||||||
|
post_name_word_1_hex: sample.post_name_word_1_hex.clone(),
|
||||||
|
post_name_word_1_high_u16,
|
||||||
|
post_name_word_1_high_u16_hex: format!("0x{post_name_word_1_high_u16:04x}"),
|
||||||
|
post_name_word_1_middle_u16,
|
||||||
|
post_name_word_1_middle_u16_hex: format!("0x{post_name_word_1_middle_u16:04x}"),
|
||||||
|
post_name_word_1_low_u16,
|
||||||
|
post_name_word_1_low_u16_hex: format!("0x{post_name_word_1_low_u16:04x}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_anonymous_selector_records(
|
||||||
|
bytes: &[u8],
|
||||||
|
references: &[WinResourceReference],
|
||||||
|
) -> Vec<WinAnonymousSelectorRecord> {
|
||||||
|
const PRELUDE: [u8; 12] = [
|
||||||
|
0xb8, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb9, 0x0b, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut records = Vec::new();
|
||||||
|
let mut start = 0usize;
|
||||||
|
while let Some(relative) = bytes
|
||||||
|
.get(start..)
|
||||||
|
.and_then(|slice| slice.windows(PRELUDE.len()).position(|window| window == PRELUDE))
|
||||||
|
{
|
||||||
|
let record_offset = start + relative;
|
||||||
|
let name_len = read_u32_le(bytes, record_offset + PRELUDE.len()).unwrap_or(0);
|
||||||
|
if name_len == 0 {
|
||||||
|
let selector_word_0 = read_u32_le(bytes, record_offset + 0x10).unwrap_or(0);
|
||||||
|
let selector_word_0_low_u16 = (selector_word_0 & 0xffff) as u16;
|
||||||
|
if (0xc352..=0xc39b).contains(&selector_word_0_low_u16) {
|
||||||
|
let preceding_named_record =
|
||||||
|
references.iter().rev().find(|reference| reference.offset < record_offset);
|
||||||
|
let following_named_record =
|
||||||
|
references.iter().find(|reference| reference.offset > record_offset);
|
||||||
|
let selector_word_1 = read_u32_le(bytes, record_offset + 0x14).unwrap_or(0);
|
||||||
|
let selector_word_0_high_u16 = ((selector_word_0 >> 16) & 0xffff) as u16;
|
||||||
|
let selector_word_1_middle_u16 = ((selector_word_1 >> 8) & 0xffff) as u16;
|
||||||
|
let body_word_0 = read_u32_le(bytes, record_offset + 0x18).unwrap_or(0);
|
||||||
|
let body_word_1 = read_u32_le(bytes, record_offset + 0x1c).unwrap_or(0);
|
||||||
|
let body_word_2 = read_u32_le(bytes, record_offset + 0x20).unwrap_or(0);
|
||||||
|
let body_word_3 = read_u32_le(bytes, record_offset + 0x24).unwrap_or(0);
|
||||||
|
let footer_word_0 = read_u32_le(bytes, record_offset + 0x98).unwrap_or(0);
|
||||||
|
let footer_word_1 = read_u32_le(bytes, record_offset + 0x9c).unwrap_or(0);
|
||||||
|
records.push(WinAnonymousSelectorRecord {
|
||||||
|
record_offset,
|
||||||
|
record_offset_hex: format!("0x{record_offset:04x}"),
|
||||||
|
preceding_named_record_name: preceding_named_record
|
||||||
|
.map(|record| record.name.clone()),
|
||||||
|
preceding_named_record_offset_hex: preceding_named_record
|
||||||
|
.map(|record| record.offset_hex.clone()),
|
||||||
|
following_named_record_name: following_named_record
|
||||||
|
.map(|record| record.name.clone()),
|
||||||
|
following_named_record_offset_hex: following_named_record
|
||||||
|
.map(|record| record.offset_hex.clone()),
|
||||||
|
selector_word_0,
|
||||||
|
selector_word_0_hex: format!("0x{selector_word_0:08x}"),
|
||||||
|
selector_word_0_high_u16,
|
||||||
|
selector_word_0_high_u16_hex: format!("0x{selector_word_0_high_u16:04x}"),
|
||||||
|
selector_word_0_low_u16,
|
||||||
|
selector_word_0_low_u16_hex: format!("0x{selector_word_0_low_u16:04x}"),
|
||||||
|
selector_word_1,
|
||||||
|
selector_word_1_hex: format!("0x{selector_word_1:08x}"),
|
||||||
|
selector_word_1_middle_u16,
|
||||||
|
selector_word_1_middle_u16_hex: format!("0x{selector_word_1_middle_u16:04x}"),
|
||||||
|
body_word_0,
|
||||||
|
body_word_0_hex: format!("0x{body_word_0:08x}"),
|
||||||
|
body_word_1,
|
||||||
|
body_word_1_hex: format!("0x{body_word_1:08x}"),
|
||||||
|
body_word_2,
|
||||||
|
body_word_2_hex: format!("0x{body_word_2:08x}"),
|
||||||
|
body_word_3,
|
||||||
|
body_word_3_hex: format!("0x{body_word_3:08x}"),
|
||||||
|
footer_word_0,
|
||||||
|
footer_word_0_hex: format!("0x{footer_word_0:08x}"),
|
||||||
|
footer_word_1,
|
||||||
|
footer_word_1_hex: format!("0x{footer_word_1:08x}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = record_offset + 1;
|
||||||
|
}
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_prelude_prefix_hex(samples: &[WinResourceRecordSample]) -> Option<Vec<String>> {
|
||||||
|
let first = samples.first()?;
|
||||||
|
if first.prelude_words.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let prefix = first.prelude_words[..3]
|
||||||
|
.iter()
|
||||||
|
.map(|word| word.value)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if samples.iter().all(|sample| {
|
||||||
|
sample.prelude_words.len() >= 3
|
||||||
|
&& sample.prelude_words[..3]
|
||||||
|
.iter()
|
||||||
|
.map(|word| word.value)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
== prefix
|
||||||
|
}) {
|
||||||
|
return Some(
|
||||||
|
prefix
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| format!("0x{value:08x}"))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_imb_reference_at(bytes: &[u8], offset: usize) -> Option<String> {
|
||||||
|
if offset > 0 {
|
||||||
|
let previous = *bytes.get(offset - 1)?;
|
||||||
|
if previous != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let slice = bytes.get(offset..)?;
|
||||||
|
let nul = slice.iter().position(|byte| *byte == 0)?;
|
||||||
|
let candidate = slice.get(..nul)?;
|
||||||
|
if candidate.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = std::str::from_utf8(candidate).ok()?;
|
||||||
|
if !value.ends_with(".imb") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b' '))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_inline_ascii_name(bytes: &[u8], offset: usize) -> Option<String> {
|
||||||
|
let prefix = *bytes.get(offset)?;
|
||||||
|
if prefix != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
parse_imb_reference_at(bytes, offset + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let slice = bytes.get(offset..offset + 4)?;
|
||||||
|
Some(u32::from_le_bytes(slice.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspects_synthetic_window_blob() {
|
||||||
|
let mut bytes = vec![0u8; 0x90];
|
||||||
|
bytes[0x00..0x04].copy_from_slice(&0x0000_07d0u32.to_le_bytes());
|
||||||
|
bytes[0x0c..0x10].copy_from_slice(&0x8000_0000u32.to_le_bytes());
|
||||||
|
bytes[0x10..0x14].copy_from_slice(&0x8000_003fu32.to_le_bytes());
|
||||||
|
bytes[0x14..0x18].copy_from_slice(&0x0000_003fu32.to_le_bytes());
|
||||||
|
bytes[0x34..0x38].copy_from_slice(&0x0007_d100u32.to_le_bytes());
|
||||||
|
bytes[0x38..0x3c].copy_from_slice(&0x0007_d200u32.to_le_bytes());
|
||||||
|
bytes[0x40..0x44].copy_from_slice(&0x000b_b800u32.to_le_bytes());
|
||||||
|
bytes[0x48..0x4c].copy_from_slice(&0x000b_b900u32.to_le_bytes());
|
||||||
|
bytes[0x50] = 0;
|
||||||
|
bytes[0x51..0x51 + "Root.imb".len()].copy_from_slice(b"Root.imb");
|
||||||
|
bytes[0x59] = 0;
|
||||||
|
bytes.extend_from_slice(b"\0Button.imb\0");
|
||||||
|
|
||||||
|
let report = inspect_win_bytes(&bytes).expect("inspection should succeed");
|
||||||
|
assert!(report.matches_observed_common_signature);
|
||||||
|
assert_eq!(
|
||||||
|
report.inline_root_resource_name.as_deref(),
|
||||||
|
Some("Root.imb")
|
||||||
|
);
|
||||||
|
assert_eq!(report.imb_reference_count, 2);
|
||||||
|
assert_eq!(report.unique_imb_reference_count, 2);
|
||||||
|
assert_eq!(report.resource_selector_records.len(), 2);
|
||||||
|
assert_eq!(report.resource_selector_records[0].name, "Root.imb");
|
||||||
|
assert!(report.anonymous_selector_records.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ project is already mature.
|
||||||
- `re-workflow.md`: how to analyze the binary, record findings, and export reusable artifacts.
|
- `re-workflow.md`: how to analyze the binary, record findings, and export reusable artifacts.
|
||||||
- `function-map.md`: canonical schema and conventions for function-by-function mapping.
|
- `function-map.md`: canonical schema and conventions for function-by-function mapping.
|
||||||
- `control-loop-atlas.md`: curated atlas of top-level loops, gateways, and subsystem handoffs.
|
- `control-loop-atlas.md`: curated atlas of top-level loops, gateways, and subsystem handoffs.
|
||||||
|
- `runtime-rehost-plan.md`: bottom-up runtime replacement plan and milestone breakdown.
|
||||||
|
|
||||||
## Repo Conventions
|
## Repo Conventions
|
||||||
|
|
||||||
|
|
@ -62,17 +63,19 @@ Current local tool status:
|
||||||
|
|
||||||
## Next Focus
|
## Next Focus
|
||||||
|
|
||||||
The next milestone is breadth first. The highest-value passes are:
|
The atlas milestone is broad enough that the next implementation focus shifts downward into runtime
|
||||||
|
rehosting. The highest-value next passes are:
|
||||||
|
|
||||||
- promote `docs/control-loop-atlas.md` into the primary human-readable artifact for high-level flow
|
- preserve the atlas and function map as the source of subsystem boundaries while avoiding further
|
||||||
- name and connect the major loop roots and gateways for startup, shell/UI, frame or presentation,
|
shell-first implementation bets
|
||||||
simulation, map/scenario load, input, save/load, and multiplayer/network
|
- stand up a bottom-up runtime core that can load state, execute deterministic world work, and dump
|
||||||
- use `export_startup_map.py` and `export_analysis_context.py` to widen breadth around candidate loop
|
normalized diffs without depending on the shell controller or presentation path
|
||||||
dispatchers before doing deep leaf naming
|
- use `rrt-hook` primarily as an optional capture tool for fixtures and state probes, not as the
|
||||||
- keep the pending-template and multiplayer transport dossiers available, but treat them as targeted
|
first execution environment
|
||||||
deep-dive tools once a missing atlas edge needs branch-specific grounding
|
- choose early rewrite targets from the lower simulation, event-service, and persistence boundaries
|
||||||
- stand up the Rust workspace so artifacts can be validated in code and a minimal hook DLL can be
|
before attempting shell, input, or presentation replacement
|
||||||
built as soon as the 32-bit linker is present
|
- write milestone-scoped implementation notes in `docs/runtime-rehost-plan.md` before expanding the
|
||||||
|
workspace crates
|
||||||
|
|
||||||
Regenerate the initial exports with:
|
Regenerate the initial exports with:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,17 +142,69 @@ transition.
|
||||||
by the Multiplayer preview dataset object at `0x006cd8d8`, and only falls back to the normal
|
by the Multiplayer preview dataset object at `0x006cd8d8`, and only falls back to the normal
|
||||||
reference-bundle path when that dataset object is absent. The shell-side mode owner above those
|
reference-bundle path when that dataset object is absent. The shell-side mode owner above those
|
||||||
file coordinators is clearer now too. `shell_transition_mode` no longer reads like a generic mode
|
file coordinators is clearer now too. `shell_transition_mode` no longer reads like a generic mode
|
||||||
switch: its constructor jump table now resolves mode `1` to `Game.win`, mode `2` to `Setup.win`,
|
switch: its ABI is now grounded as a `thiscall` with two stack arguments because the body reads
|
||||||
mode `3` to `Video.win`, mode `4` to `LoadScreen.win`, mode `5` to `Multiplayer.win`, mode `6`
|
the requested mode from `[esp+0x0c]` and returns with `ret 8`. The grounded world-entry
|
||||||
to `Credits.win`, and mode `7` to `Campaign.win`. The strongest current load-side owner inside
|
load-screen call shape is `(4, 0)`, not a one-arg mode switch. The second stack argument is now
|
||||||
that table remains the mode-`4` branch around `0x4830ca`, which publishes the new active mode
|
tighter too: current local evidence reads it as an old-active-mode teardown flag, because the
|
||||||
object into `0x006cec78` and then calls `shell_active_mode_run_profile_startup_and_load_dispatch`
|
`0x482fc6..0x482fff` branch only runs when it is nonzero and then releases the prior active-mode
|
||||||
as `thiscall(active_mode, 1, 0)`. The caller split above that owner is tighter now too:
|
world through `0x434300`, `0x433730`, and the common free path before clearing `0x006cec78`.
|
||||||
|
The later world-entry reactivation branch correspondingly uses `(1, esi)` rather than `(1, 0)`.
|
||||||
|
The current live hook probes now push the remaining auto-load gap much later too: on the
|
||||||
|
hook-driven path `shell_transition_mode(4, 0)` returns cleanly, and the full old-mode teardown
|
||||||
|
stack under `0x5389c0` now returns too, including `0x5400c0`, the `0x53fe00 -> 0x53f860`
|
||||||
|
remove-node sweep over `[object+0x74]`, and the nearby mode-`2` teardown helper `0x00502720`.
|
||||||
|
The same live run now also reaches and returns from `shell_load_screen_window_construct`
|
||||||
|
`0x004ea620` and the immediate shell publish through `0x00538e50`.
|
||||||
|
Its constructor jump table is tighter now too: mode `1` is not the direct `Game.win`
|
||||||
|
constructor, but the startup-dispatch arm rooted at `0x483012`; mode `2` enters `Setup.win`,
|
||||||
|
mode `3` enters `Video.win`, mode `4` enters the plain `LoadScreen.win` branch at `0x4832e5`,
|
||||||
|
mode `5` enters `Multiplayer.win`, mode `6` enters `Credits.win`, and mode `7` enters
|
||||||
|
`Campaign.win`. The important static correction is that the startup-runtime slice
|
||||||
|
`0x004ea710 -> 0x0053b070(0x46c40) -> 0x004336d0 -> 0x00438890` belongs to mode `1`, not mode
|
||||||
|
`4`. Mode `4` only constructs and publishes the `LoadScreen.win` object through `0x004ea620`
|
||||||
|
and `0x00538e50`. The older hook-driven `(4, 0)` path therefore was not mysteriously skipping
|
||||||
|
the startup-runtime object; it was entering the wrong jump-table arm for that work. The caller
|
||||||
|
split above that owner is tighter now too:
|
||||||
`world_entry_transition_and_runtime_bringup` reaches the same owner at `0x443b57` with `(0, 0)`
|
`world_entry_transition_and_runtime_bringup` reaches the same owner at `0x443b57` with `(0, 0)`
|
||||||
after dismissing the current shell detail panel and servicing `0x4834e0(0, 0)`, while the
|
after dismissing the current shell detail panel and servicing `0x4834e0(0, 0)`, while the
|
||||||
saved-runtime path at `0x446d7f` does the same before immediately building the `.smp` bundle
|
saved-runtime path at `0x446d7f` does the same before immediately building the `.smp` bundle
|
||||||
payloads through `0x530c80/0x531150/0x531360`. That makes the `LoadScreen.win` startup lane the
|
payloads through `0x530c80/0x531150/0x531360`. That makes the `LoadScreen.win` startup lane the
|
||||||
only currently grounded caller that enters `0x438890` with `(1, 0)` instead of `(0, 0)`. The
|
only currently grounded caller that enters `0x438890` with `(1, 0)` instead of `(0, 0)`. The
|
||||||
|
remaining runtime uncertainty is narrower now too: the plain-run logs still show the plain
|
||||||
|
`LoadScreen.win` state under `(4, 0)`, and the corrected allocator-window run now reinforces the
|
||||||
|
same read because the post-construct allocator stream stays empty instead of showing the expected
|
||||||
|
`0x46c40` startup-runtime allocation. The first lower allocator probe on `0x005a125d` was not
|
||||||
|
trustworthy because that shared cdecl body sits behind the thunk and the initial hook used the
|
||||||
|
wrong entry shape, and the first direct thunk hook was also not trustworthy because a copied
|
||||||
|
relative-`jmp` thunk cannot be replayed through an ordinary trampoline. But the later corrected
|
||||||
|
thunk run plus the jump-table decode now agree: the next meaningful hook-driven test is mode
|
||||||
|
`1`, not mode `4`.
|
||||||
|
`mode_id = 2`, and it never advanced into `ready gate passed`, staging, or transition. So that
|
||||||
|
run did not actually exercise the `0x0053b070 -> 0x004336d0 -> 0x00438890` subchain at all.
|
||||||
|
The next runtime pass now lowers the ready-poll defaults to `1` and `0` and adds an explicit
|
||||||
|
ready-count log so the mode-`4` startup lane either stages immediately or shows exactly how far
|
||||||
|
the gate gets. That adjustment worked on the next run: the hook now stages and completes the
|
||||||
|
`shell_transition_mode` path again, with `LoadScreen.win` construction and publish returning
|
||||||
|
cleanly. But the post-publish startup subchain is still unresolved: there is still no trusted
|
||||||
|
`0x46c40` allocator hit, no direct `0x004336d0` entry, and no direct `0x00438890` entry. So
|
||||||
|
the next clean runtime boundary is the tiny `LoadScreen.win` scalar setter at `0x004ea710`,
|
||||||
|
which sits immediately before the `0x0053b070` allocation in the static mode-`4` branch. The
|
||||||
|
immediate next runtime check is even more concrete than the helper hook, though: inspect the
|
||||||
|
state that `0x004ea710` should leave behind. The hook now logs the post-transition
|
||||||
|
`LoadScreen.win` singleton, its field `[+0x78]`, `0x006cec78`, the shell state's `[+0x0c]`
|
||||||
|
active-mode object field, and the startup selector. If `0x004ea710` really ran on the mode-`4`
|
||||||
|
branch, `[LoadScreen.win+0x78]` should no longer be zero after `shell_transition_mode` returns.
|
||||||
|
The latest run answered that directly: after transition return, `field_active_mode_object` is
|
||||||
|
still the `LoadScreen.win` singleton, `0x006cec78` is still null, `[LoadScreen.win+0x78]` is
|
||||||
|
still zero, and the startup selector is still `3`. So the current best read is that RT3 is
|
||||||
|
still parked in the plain `LoadScreen.win` state at transition return rather than having entered
|
||||||
|
the separate runtime-object path yet. That shifts the best next runtime boundary from “deeper
|
||||||
|
inside `shell_transition_mode`” to “what later active-mode service tick, if any, promotes the
|
||||||
|
load-screen object into the startup-dispatch path.” The next run now logs the first few
|
||||||
|
shell-state service ticks after auto-load is attempted with that same state tuple
|
||||||
|
(`0x006cec78`, `[shell_state+0x0c]`, `0x006d10b0`, `[LoadScreen.win+0x78]`, selector), so the
|
||||||
|
next question is very narrow: does one later service tick finally promote the plain
|
||||||
|
`LoadScreen.win` state into the startup-runtime object path, or does it stay frozen as-is? The
|
||||||
internal selector split in `0x438890` is tighter now too: `[0x006cec7c+0x01]` is a separate
|
internal selector split in `0x438890` is tighter now too: `[0x006cec7c+0x01]` is a separate
|
||||||
seven-way startup selector, not the shell mode id. Values `1` and `7` load `Tutorial_2.gmp` and
|
seven-way startup selector, not the shell mode id. Values `1` and `7` load `Tutorial_2.gmp` and
|
||||||
`Tutorial_1.gmp`, values `3/5/6` collapse into the same profile-seeded file-load lane through
|
`Tutorial_1.gmp`, values `3/5/6` collapse into the same profile-seeded file-load lane through
|
||||||
|
|
@ -222,6 +274,37 @@ transition.
|
||||||
sandbox-backed `new` list while mode `12` is its `load` sibling. Combined with the builder at
|
sandbox-backed `new` list while mode `12` is its `load` sibling. Combined with the builder at
|
||||||
`0x4333f0`, that shows only submodes `4` and `10` using the alternate localized-stem list-label
|
`0x4333f0`, that shows only submodes `4` and `10` using the alternate localized-stem list-label
|
||||||
path; `6`, `9`, `12`, and `14` keep the direct filename-normalization lane.
|
path; `6`, `9`, `12`, and `14` keep the direct filename-normalization lane.
|
||||||
|
The `rt3_2WIN.PK4` extraction path is grounded a bit more cleanly now too: current `pack4`
|
||||||
|
samples use a shared `0x03eb` header, a fixed `0x4a`-byte directory entry stride, and a payload
|
||||||
|
base at `8 + entry_count * 0x4a`. In the checked UI bundle the entry table is contiguous and
|
||||||
|
`Campaign.win` extracts cleanly at directory index `3` with payload length `0x306a`.
|
||||||
|
The extracted `.win` payload family now has one narrow shared shape too: `Campaign.win`,
|
||||||
|
`CompanyDetail.win`, and `setup.win` all share the same first `0x50` bytes at offsets
|
||||||
|
`0x00`, `0x0c`, `0x10`, `0x14`, `0x34`, `0x38`, `0x40`, and `0x48`, while
|
||||||
|
`CompanyDetail.win` and `setup.win` also carry an inline root `.imb` name immediately at
|
||||||
|
`0x51`. Current resource scans show `Campaign.win` embedding the `litCamp*/Ribbon*` family,
|
||||||
|
`CompanyDetail.win` embedding mainly `CompanyDetail.imb`, `GameWindow.imb`, and `Portrait.imb`,
|
||||||
|
and `setup.win` embedding the broader `Setup_Background/Buttons/New_Game/Load/Sandbox` art
|
||||||
|
families. The control records between those strings are still undecoded, but the resource-record
|
||||||
|
shell itself is tighter now: all checked `.win` samples use the same three-word prelude prefix
|
||||||
|
`0x0bb8, 0x0, 0x0bb9`, and the fourth prelude word matches `resource_name_len + 1`. The next
|
||||||
|
word after the terminating NUL then behaves like a per-record selector lane. In `setup.win`
|
||||||
|
the dominant `Setup_Buttons.imb` family alternates between `0x00040000` and the incrementing
|
||||||
|
`0x00010c1c..0x00010c86` series with dominant inter-record strides `0xb7/0xdb`; in
|
||||||
|
`Campaign.win` the `litCamp*/Ribbon*` family carries the incrementing `0x0004c372..0x0004c38e`
|
||||||
|
selector block with dominant `0x158/0x159` and `0xb2/0xb3` strides. That campaign lane is now
|
||||||
|
directly aligned to the executable-side control ids too: the low 16 bits of
|
||||||
|
`litCamp1..16` map exactly to `0xc372..0xc381`, and the low 16 bits of `Ribbon1..16` map
|
||||||
|
exactly to `0xc382..0xc391`, matching the `Campaign.win` progress and selector control bases
|
||||||
|
already grounded from `RT3.exe`. The fuller selector export now tightens the auxiliary families
|
||||||
|
too: `litArrows.imb` covers `0xc36c..0xc371` exactly and `litExits.imb` covers
|
||||||
|
`0xc397..0xc39a` exactly, matching the constructor-side six-control arrow strip and four-control
|
||||||
|
exit strip. The second post-name dword is tighter now too: its middle 16-bit lane groups those
|
||||||
|
same `Campaign.win` records under `0xc368`, `0xc369`, `0xc36a`, and `0xc36b`, which matches the
|
||||||
|
four page-strip controls and cleanly buckets the first five campaign entries, the next five, the
|
||||||
|
next three, and the final three under the same page families. There are still no `.imb`
|
||||||
|
selector records for `0xc393..0xc396` or `0xc39b`, which makes those message-side page-write
|
||||||
|
controls look more like structural buttons than art-backed repeated resource records.
|
||||||
The grouped `3/4/5` family is narrower now too: `0x0ce5` is no longer part of it, because that
|
The grouped `3/4/5` family is narrower now too: `0x0ce5` is no longer part of it, because that
|
||||||
control writes selector `3` and then selects submode `9` as the `Load Map` sibling. The nearby
|
control writes selector `3` and then selects submode `9` as the `Load Map` sibling. The nearby
|
||||||
`0x0dcb` branch instead conditionally selects submode `5` or `3`, which keeps `3` as the
|
`0x0dcb` branch instead conditionally selects submode `5` or `3`, which keeps `3` as the
|
||||||
|
|
@ -265,7 +348,33 @@ transition.
|
||||||
shape. Only after that does `0x502220` copy payload fields `+0x14/+0x3b2/+0x3ba/+0x20` into the
|
shape. Only after that does `0x502220` copy payload fields `+0x14/+0x3b2/+0x3ba/+0x20` into the
|
||||||
staged runtime profile through `0x47be50`, which in turn normalizes the payload category bytes at
|
staged runtime profile through `0x47be50`, which in turn normalizes the payload category bytes at
|
||||||
`[payload+0x31a + row*9]` and the local marker-slot bytes at `[payload+0x2c9..]` through
|
`[payload+0x31a + row*9]` and the local marker-slot bytes at `[payload+0x2c9..]` through
|
||||||
`0x47bc80`. The adjacent region-side worker family is tighter in a negative way too: the setup
|
`0x47bc80`. The copied-field consumer split is tighter now too: payload `+0x14` becomes staged
|
||||||
|
profile `[0x006cec7c+0x77]`, which is the visible setup scalar later formatted into controls
|
||||||
|
`0x0e84` and `0x0d4f` and adjusted by the `0x0d49/0x0d4a` pair; payload `+0x3b2` becomes
|
||||||
|
`[0x006cec7c+0x79]`, the live-row threshold that the payload-row draw callback uses to choose
|
||||||
|
style slot `0x18` versus `0x19`; and payload `+0x3ba` becomes `[0x006cec7c+0x7b]`, the current
|
||||||
|
scroll or row-index lane adjusted by the `0x0ce6/0x0ce7` pair. So the known setup-side payload
|
||||||
|
consumers still stop well before the later candidate table, but the early copied lanes are no
|
||||||
|
longer anonymous. The adjacent region-side worker family is tighter in a negative way too: the setup
|
||||||
|
payload path now gives one useful upper bound on the newer candidate-availability source block
|
||||||
|
too. The map-style setup loader is definitely pulling chunk families `0x0004/0x2ee0/0x2ee1`
|
||||||
|
into one large `0x100f2`-byte payload record, and the fixed `0x6a70..0x73c0` candidate table
|
||||||
|
clearly lives inside the same broad file family; but the grounded setup-side consumers we can
|
||||||
|
actually name after that load still only touch earlier payload offsets such as `+0x14`,
|
||||||
|
`+0x20`, `+0x2c9`, `+0x31a`, `+0x3ae`, `+0x3b2`, and `+0x3ba`. So the current evidence is
|
||||||
|
strong enough to say the candidate table is probably housed inside the setup payload family, but
|
||||||
|
not yet strong enough to name one setup-side consumer that reads the `0x6a70` header or the
|
||||||
|
fixed-width entries directly. The newer fixed-offset compare pass tightens that lower setup slice
|
||||||
|
too: across the checked map/save pairs `Alternate USA.gmp -> Autosave.gms`,
|
||||||
|
`Southern Pacific.gmp -> p.gms`, and `Spanish Mainline.gmp -> g.gms`, the known setup payload
|
||||||
|
lanes `+0x14` and `+0x3b2` are preserved map-to-save on the same scenario-family split as the
|
||||||
|
later candidate-table headers (`0x0771/0x0001`, `0x0746/0x0006`, `0x0754/0x0001`), while
|
||||||
|
`+0x3ae` stays fixed at `0x0186` and `+0x3ba` stays fixed at `1` across all six files. By
|
||||||
|
contrast, `+0x20` does not survive as one shared source value (`0xd3 -> 0xb4`,
|
||||||
|
`0x6f -> 0x65`, `0xe3 -> 0x78` across those same pairs). So the current best read is that the
|
||||||
|
setup payload mixes preserved scenario metadata and later save-variant state well before the
|
||||||
|
`0x6a70` candidate table, rather than acting as one uniformly copied prelude.
|
||||||
|
The adjacent region-side worker family is tighter in a negative way too: the setup
|
||||||
payload loader is now clearly separate from the broader region-building and placement cluster
|
payload loader is now clearly separate from the broader region-building and placement cluster
|
||||||
around `0x422320..0x423d30`, whose current grounded helpers now include `0x422320`, `0x4228b0`,
|
around `0x422320..0x423d30`, whose current grounded helpers now include `0x422320`, `0x4228b0`,
|
||||||
`0x422900`, `0x422a70`, `0x422be0`, `0x422ee0`, `0x4234e0`, `0x4235c0`, and
|
`0x422900`, `0x422a70`, `0x422be0`, `0x422ee0`, `0x4234e0`, `0x4235c0`, and
|
||||||
|
|
@ -295,8 +404,95 @@ transition.
|
||||||
selector-`5` siblings, while the neighboring selector-`3` validated lane is at least shared by
|
selector-`5` siblings, while the neighboring selector-`3` validated lane is at least shared by
|
||||||
`0x0de9` and `0x0df3`; the shared bridge at `0x4425d0` then forces `[0x006cec7c+0xc5] = 1`,
|
`0x0de9` and `0x0df3`; the shared bridge at `0x4425d0` then forces `[0x006cec7c+0xc5] = 1`,
|
||||||
mirrors payload bytes into `[profile+0xc4]`, `[profile+0x7d]`, and `[profile+0xc6..+0xd5]`, and
|
mirrors payload bytes into `[profile+0xc4]`, `[profile+0x7d]`, and `[profile+0xc6..+0xd5]`, and
|
||||||
only then issues shell request `0x0cc`. Only `0x0e81` also sets `[0x006cec7c+0x82] = 1`, which
|
only then issues shell request `0x0cc`. The file-side staging bytes in that bridge are now
|
||||||
is currently the strongest sandbox-side anchor beneath the later `.gmx` load family.
|
tighter too: across the checked `Alternate USA`, `Southern Pacific`, and `Spanish Mainline`
|
||||||
|
map/save pairs, payload `+0x33` stays `0`, but payload `+0x22` and token block `+0x23..+0x32`
|
||||||
|
do not preserve the earlier map-to-save pairing seen at `+0x14/+0x3b2`. Instead they split by
|
||||||
|
the finer file family, with examples `Alternate USA.gmp = 0x53 / 0131115401...` versus
|
||||||
|
`Autosave.gms = 0xae / 01439aae01...`, `Southern Pacific.gmp = 0xeb / 00edeeeb...` versus
|
||||||
|
`p.gms = 0x21 / 0100892101...`, and `Spanish Mainline.gmp = 0x5b / 0044f05b...` versus
|
||||||
|
`g.gms = 0x7a / 0022907a...`. So the validated launch bridge now looks more like a file-family
|
||||||
|
staging lane than a simple copy-forward of the earlier setup summary fields. The destination-side
|
||||||
|
consumers are tighter now too: `[profile+0xc4]` is no longer just an unnamed staged byte, but the
|
||||||
|
campaign-progress slot later consumed by the numbered `%s%02d.gmc` save helper `0x00517c70`;
|
||||||
|
`[profile+0x7d]` is the same compact option byte mirrored back into the `Setup.win` option
|
||||||
|
controls `0x0ce9..0x0ced`; and the campaign-side selector block is no longer only a one-byte
|
||||||
|
anchor. Direct `RT3.exe` disassembly of the campaign-side dispatcher at `0x004b89c0` now shows
|
||||||
|
the exact mirror shape: when the local campaign-page selector `[window+0x78] <= 4`, the helper
|
||||||
|
writes one highlighted progress control `0x0c372 + [profile+0xc4]` and then mirrors the full
|
||||||
|
sixteen-byte band `[profile+0xc6..+0xd5]` into controls `0x0c382..0x0c391` through repeated
|
||||||
|
`0x540120` calls. The same body also treats `[profile+0xc4]` as an index into the fixed
|
||||||
|
sixteen-entry scenario-name table at `0x00621cf0`, whose observed entries include `Go West!`,
|
||||||
|
`Germantown`, `Central Pacific`, `Texas Tea`, `War Effort`, `State of Germany`, `Britain`,
|
||||||
|
`Crossing the Alps`, `Third Republic`, `Orient Express`, `Argentina Opens Up`,
|
||||||
|
`Rhodes Unfinished`, `Japan Trembles`, `Greenland Growing`, `Dutchlantis`, and
|
||||||
|
`California Island`. On the launch-side branch the same helper writes startup selector `6`,
|
||||||
|
copies the resolved campaign filename into `[profile+0x11]`, forces `[profile+0xc5] = 1`, and
|
||||||
|
then issues shell request `0x0cc`. The lower refresh tail at `0x004b8d49..0x004b8d69` also
|
||||||
|
shows the observed page-band split over the same progress byte: values `< 5` pair with campaign
|
||||||
|
page `1`, values `5..9` with page `2`, values `10..12` with page `3`, and values `>= 13` with
|
||||||
|
page `4`. So `[profile+0xc6..+0xd5]` is no longer a generic opaque span at all, but the staged
|
||||||
|
sixteen-byte per-scenario campaign selector or unlock band consumed directly by `Campaign.win`.
|
||||||
|
The message-dispatch side is tighter now too. The local classifier at `0x004b91d8` routes the
|
||||||
|
control range `0x0c352..0x0c39b` through five concrete case classes: `0x0c352..0x0c361` enter
|
||||||
|
the shared selector or launch branch, `0x0c362..0x0c367` force local page `1`,
|
||||||
|
`0x0c368..0x0c392` force local page `2`, `0x0c393..0x0c396` force local page `0`, and
|
||||||
|
`0x0c39b` alone forces local page `3`. That matches the constructor and refresh shape: the
|
||||||
|
sixteen scenario selector controls live at `0x0c352..0x0c361`, the four page-strip controls
|
||||||
|
live at `0x0c368..0x0c36b`, the six-arrow auxiliary strip lives at `0x0c36c..0x0c371`, the
|
||||||
|
progress band lives at `0x0c372..0x0c381`, the ribbon or selector band lives at
|
||||||
|
`0x0c382..0x0c391`, the string or voice target lane lives at `0x0c392`, the four exit controls
|
||||||
|
live at `0x0c397..0x0c39a`, and `0x0c39b` remains the one-off special action control that
|
||||||
|
spawns or dismisses the campaign-side companion object. The `Campaign.win` blob now exposes that
|
||||||
|
structural split directly too: alongside the art-backed `0x0004c3xx` records it carries
|
||||||
|
anonymous zero-name records with the same `0x0bb8 / 0 / 0x0bb9` prelude but selector words in
|
||||||
|
the `0x0010c3xx` family. Current local extraction shows exactly
|
||||||
|
`0x0010c352..0x0010c361`, `0x0010c362..0x0010c367`, `0x0010c393..0x0010c396`, and one
|
||||||
|
`0x0010c39b` record, which is the same non-`.imb` band the message dispatcher treats as the
|
||||||
|
structural selector and page-write family. Their second selector word then buckets those records
|
||||||
|
under the same page-strip ids as the art-backed records: `0xc368` for
|
||||||
|
`0xc352..0xc356`, `0xc362`, `0xc393`, and `0xc39b`; `0xc369` for `0xc357..0xc35b`,
|
||||||
|
`0xc363`, and `0xc394`; `0xc36a` for `0xc35c..0xc35e`, `0xc365..0xc366`, and `0xc395`; and
|
||||||
|
`0xc36b` for `0xc367`, `0xc35f..0xc361`, and `0xc396`. One final anonymous record at
|
||||||
|
`0x0002c392` has no page bucket at all, which fits the already-grounded read of `0xc392` as the
|
||||||
|
one-off string or voice target lane rather than a normal page-cell control. The anonymous body
|
||||||
|
layout is only partially named, but one file-side distinction is already stable: the ordinary
|
||||||
|
structural selector/page records all carry footer words `0xd3000000` and `0xd2000007` at
|
||||||
|
relative `+0x98/+0x9c`, while the lone `0xc392` record carries `0x00000000/0x00000000` there.
|
||||||
|
So `0xc392` is no longer just “outside the page buckets”; it is also the only current
|
||||||
|
anonymous-record outlier in that trailing structural footer pair. The structural selector side is
|
||||||
|
tighter now too: `0xc352..0xc361` partition exactly as `5 + 5 + 3 + 3` across page buckets
|
||||||
|
`0xc368..0xc36b`, matching the observed campaign page bands for scenario progress values `< 5`,
|
||||||
|
`5..9`, `10..12`, and `>= 13`. That makes the anonymous selector records the file-side mirror of
|
||||||
|
the same campaign page split, not a separate unrelated control family. The file-order adjacency
|
||||||
|
is tighter in the same way: `0xc352..0xc361` each sit immediately after one `RibbonN.imb`
|
||||||
|
record and before the next `litCamp` record, which makes them the strongest current file-side fit
|
||||||
|
for the structural selector or click-target siblings of the visible campaign ribbon controls.
|
||||||
|
The auxiliary anonymous bands line up the same way: `0xc362..0xc367` sit inside the local
|
||||||
|
`litArrows.imb` clusters, `0xc393..0xc396` sit inside the `litExits.imb` clusters, and
|
||||||
|
`0xc39b` sits once between the first arrow and first exit group. So the current best read is
|
||||||
|
that those anonymous records are the structural hitbox or action siblings of the visible arrow,
|
||||||
|
exit, and selector art families, not an unrelated hidden control layer. The generic record
|
||||||
|
cadence is tighter now too: in the current `Campaign.win` dump the ordinary anonymous structural
|
||||||
|
records all span `0x0a7` bytes, while the named art-backed records cluster at `0x0b1`, `0x0b2`,
|
||||||
|
and `0x0b3`. The only two visible outliers are the leading `0x0080c351` record at `0x0041`
|
||||||
|
with span `0x0a3`, and the trailing `0x0002c392` record at `0x2fb9` with span `0x0b1`. That
|
||||||
|
reinforces the current read that `0xc351` and `0xc392` are one-off structural controls flanking
|
||||||
|
the main selector or page family rather than ordinary repeated art-backed records.
|
||||||
|
The setup-launch corpus now tightens one corrective caveat there too: when the checked
|
||||||
|
`Alternate USA`, `Southern Pacific`, and `Spanish Mainline` `.gmp/.gms` files are decoded with
|
||||||
|
that same campaign-side interpretation, payload byte `+0x22` is always outside the known
|
||||||
|
campaign progress range `0..15` (`0x53`, `0xeb`, `0x5b`, `0xae`, `0x21`, `0x7a`), so the
|
||||||
|
ordinary validated setup lane is not simply staging a normal campaign-screen state. The paired
|
||||||
|
token block `+0x23..+0x32` is still structurally compatible with the campaign selector band, but
|
||||||
|
in the checked files it only populates a small recurring subset of the sixteen scenario lanes,
|
||||||
|
chiefly `Go West!`, `Germantown`, `Central Pacific`, `Texas Tea`, and `War Effort`, with
|
||||||
|
scenario-family-specific byte values rather than a simple `0/1` unlock bitmap. That makes the
|
||||||
|
setup-side staging bridge look more like one shared destination layout being reused for a broader
|
||||||
|
launch token family, not a proof that ordinary setup-launched `.gmp/.gms` content is already in
|
||||||
|
campaign-screen form.
|
||||||
|
Only `0x0e81` also sets `[0x006cec7c+0x82] = 1`, which is currently the strongest sandbox-side
|
||||||
|
anchor beneath the later `.gmx` load family.
|
||||||
That launch band is slightly tighter now too: `0x0dca` is the grayscale-map picker branch above
|
That launch band is slightly tighter now too: `0x0dca` is the grayscale-map picker branch above
|
||||||
the `TGA Files (*.tga)` / `.\Data\GrayscaleMaps` helper at `0x004eb0b0`, not another ordinary
|
the `TGA Files (*.tga)` / `.\Data\GrayscaleMaps` helper at `0x004eb0b0`, not another ordinary
|
||||||
save-file validator. That launch band is tighter in a second way too: `0x0ddf` is not a lobby
|
save-file validator. That launch band is tighter in a second way too: `0x0ddf` is not a lobby
|
||||||
|
|
@ -793,6 +989,15 @@ transition.
|
||||||
earlier hidden prepass is tighter now too: `0x00437743` is the scenario-side named
|
earlier hidden prepass is tighter now too: `0x00437743` is the scenario-side named
|
||||||
candidate-availability seeding pass over the live candidate pool `0x0062b268`, feeding the
|
candidate-availability seeding pass over the live candidate pool `0x0062b268`, feeding the
|
||||||
override collection at `[state+0x66b2]` through `0x00434f20` before any visible banner is posted.
|
override collection at `[state+0x66b2]` through `0x00434f20` before any visible banner is posted.
|
||||||
|
That grounded write path is narrower than the static file evidence, though: the startup reset
|
||||||
|
helper `world_runtime_reset_startup_dispatch_state_bands` `0x004336d0` explicitly clears
|
||||||
|
`[state+0x66b2]` before the dispatch path begins, and the current exported write-side callers
|
||||||
|
we can name after that reset are still just the live-pool preseed `0x00437743`, the sibling
|
||||||
|
startup lane `0x00436ad7`, and the editor-side `Industry (Overall)` toggle handler
|
||||||
|
`0x004cf430` through `0x00434f20`. So while the fixed `0x6a70..0x73c0` candidate-availability
|
||||||
|
block is now well grounded as bundled map/save source data, a direct bulk-import path from that
|
||||||
|
block into the live runtime collection `[state+0x66b2]` is still not grounded by the current
|
||||||
|
disassembly notes.
|
||||||
That candidate-side table now has a grounded fixed record layout too: each entry is a `0x22`-byte
|
That candidate-side table now has a grounded fixed record layout too: each entry is a `0x22`-byte
|
||||||
blob with a zero-terminated candidate-name slot at `[entry+0x00..+0x1d]` and one trailing
|
blob with a zero-terminated candidate-name slot at `[entry+0x00..+0x1d]` and one trailing
|
||||||
availability dword at `[entry+0x1e]`, read through `0x00434ea0` and mirrored later into
|
availability dword at `[entry+0x1e]`, read through `0x00434ea0` and mirrored later into
|
||||||
|
|
@ -1952,6 +2157,106 @@ transition.
|
||||||
bytes `[world+0x66de]` and `[world+0x66f2]`, restores the selected year/profile lane through
|
bytes `[world+0x66de]` and `[world+0x66f2]`, restores the selected year/profile lane through
|
||||||
`[profile+0x77]` into `[world+0x05/+0x09/+0x15]` through
|
`[profile+0x77]` into `[world+0x05/+0x09/+0x15]` through
|
||||||
`world_set_selected_year_and_refresh_calendar_presentation_state` `0x00409e80`; that restore now
|
`world_set_selected_year_and_refresh_calendar_presentation_state` `0x00409e80`; that restore now
|
||||||
|
also has one concrete file-side correlation in the classic `.gms` family: local save inspection
|
||||||
|
now consistently finds `0x32dc` at `0x76e8`, `0x3714` at `0x76ec`, and `0x3715` at `0x77f8` in
|
||||||
|
`Autosave.gms`, `kk.gms`, and `hh.gms`, leaving one exact `0x108`-byte span from `0x76f0` to
|
||||||
|
`0x77f8` between `0x3714` and `0x3715`. That span already carries staged-profile-looking payload
|
||||||
|
text such as `British Isles.gmp`, so the current static-file evidence now supports the atlas-side
|
||||||
|
`0x108` packed-profile note for the classic save family even though the exact field layout inside
|
||||||
|
that block is still unresolved. The same classic corpus is tighter now too: inside that
|
||||||
|
`0x108` span the map-path C string begins at relative offset `0x13`, the display-name C string
|
||||||
|
begins at `0x46`, the block is otherwise almost entirely zeroed, and the three local samples are
|
||||||
|
byte-identical except for the leading dword at `+0x00` (`3` in `Autosave.gms` and `hh.gms`,
|
||||||
|
`5` in `kk.gms`). The currently atlas-tracked bytes `[profile+0x77]`, `[profile+0x82]`,
|
||||||
|
`[profile+0x97]`, and `[profile+0xc5]` are all `0` in that classic sample set, so the current
|
||||||
|
file-side evidence grounds the block boundaries and the embedded strings but does not yet show
|
||||||
|
live examples of those branch-driving latches being set. One 1.05-era file-side analogue is now
|
||||||
|
visible too, but only as an inference from repeated save structure rather than a disassembly-side
|
||||||
|
field map: local `.gms` files in `rt3_105/Saved Games` carry one compact string-bearing block at
|
||||||
|
`0x73c0` with the same broad shape as the classic profile slab, including a leading dword at
|
||||||
|
`+0x00`, one map-path string at `+0x10`, one display-name string at `+0x43`, and a small
|
||||||
|
nonzero tail around `+0x76..+0x88`. In that 1.05 corpus the analogue bytes at relative `+0x77`
|
||||||
|
and `+0x82` are now nonzero in every checked sample (`Autosave.gms`/`nom.gms` show `0x07` and
|
||||||
|
`0x4d`; `p.gms`/`q.gms` show `0x07` and `0x90`; `g.gms` shows `0x07` and `0xa3`), while
|
||||||
|
relative `+0x97` and `+0xc5` remain `0`. The compared 1.05 save set is tighter now too:
|
||||||
|
`Autosave.gms` and `nom.gms` cluster together on `Alternate USA.gmp` with `+0x82 = 0x4d`,
|
||||||
|
`g.gms` carries `Spanish Mainline.gmp` with `+0x82 = 0xa3`, and `p.gms`/`q.gms` cluster on
|
||||||
|
`Southern Pacific.gmp` with `+0x82 = 0x90`; across all five files the same inferred analogue
|
||||||
|
lane at `+0x77` stays fixed at `0x07`, while the same map- or scenario-sensitive tail word at
|
||||||
|
`+0x80` tracks those `0x4d/0xa3/0x90` byte lanes (`0x364d0000`, `0x29a30000`, `0x1b900000`).
|
||||||
|
The leading dword at `+0x00` also splits the same corpus, with `Autosave.gms` alone at `3` and
|
||||||
|
the other four checked 1.05 saves at `5`. That is enough to say the wider save corpus does
|
||||||
|
contain nonzero candidates for two of the atlas-tracked profile lanes, and that one of them
|
||||||
|
varies coherently with the loaded scenario family, but not yet enough to claim that the 1.05
|
||||||
|
block reuses the exact same semantic field assignments as the classic one. The loader-side
|
||||||
|
family split is tighter now too: `p.gms` and `q.gms` no longer live under a generic fallback;
|
||||||
|
their save headers now classify as one explicit `rt3-105-scenario-save` branch with preamble
|
||||||
|
words `0x00040001/0x00018000/0x00000746` and the early secondary window
|
||||||
|
`0x00130000/0x86a00100/0x21000001/0xa0000100`, while `g.gms` now classifies as a second
|
||||||
|
explicit `rt3-105-alt-save` branch with the different preamble lane
|
||||||
|
`0x0001c001/.../0x00000754` and early window
|
||||||
|
`0x00010000/0x49f00100/0x00000002/0xa0000000`. That branch now carries the same bootstrap,
|
||||||
|
anchor-cycle, named 1.05 trailer, and narrow profile-block extraction path as the other 1.05
|
||||||
|
saves. The bridge just below that trailer is now explicit too: the common 1.05 save branch
|
||||||
|
carries selector/descriptor `0x7110 -> 0x7801` in `Autosave.gms` and `0x7110 -> 0x7401` in
|
||||||
|
`nom.gms`, and both still reach the same first later candidate at
|
||||||
|
`span_target + 0x189c`, well before the packed profile at `span_target + 0x3d48`; the
|
||||||
|
`rt3-105-alt-save` branch instead carries `0x54cd -> 0x5901` and its first later candidate
|
||||||
|
lands at `packed_profile + 0x104`, essentially on the profile tail; the scenario-save branch
|
||||||
|
still diverges locally with `0x0001 -> 0x0186` and never enters that later `0x32c8`-spanned
|
||||||
|
bridge at all. The common-branch bridge payload is narrower now too: both checked base saves
|
||||||
|
expose the same 0x20-byte primary block at `0x4f14` followed by the same denser secondary block
|
||||||
|
at `0x671c`, `0x1808` bytes later, and that secondary block now appears to run intact up to the
|
||||||
|
packed-profile start at `0x73c0` for a total observed span of `0xca4` bytes. The trailing slice
|
||||||
|
of that secondary block is now typed one level further: a small header at `secondary+0x354`
|
||||||
|
carries the observed stride `0x22`, capacity `0x44`, and count `0x43`, followed by a fixed-width
|
||||||
|
67-entry name table starting at `secondary+0x3b5` and running through names like
|
||||||
|
`AluminumMill`, `AutoPlant`, `Bakery`, `Port00..11`, and `Warehouse00..11`, with a short footer
|
||||||
|
(`dc3200001437000000`) after the last entry. The trailing per-entry word is now surfaced too:
|
||||||
|
most entries carry `0x00000001`, while the currently observed zero-trailer subset is
|
||||||
|
`Nuclear Power Plant`, `Recycling Plant`, and `Uranium Mine`. That footer is tighter now too:
|
||||||
|
it parses directly as `0x32dc`, `0x3714`, and one trailing zero byte, so the shared
|
||||||
|
map/save catalog currently ends on the same two grounded late-rehydrate progress ids that the
|
||||||
|
classic staged-profile band already exposed. The strongest structural read is therefore that the
|
||||||
|
entire `0x6a70..0x73c0` catalog region is shared verbatim between `Alternate USA.gmp` and the
|
||||||
|
derived `Autosave.gms`, not rebuilt independently during save. Combined with the earlier
|
||||||
|
grounded record-layout work under `0x00437743`, `0x00434ea0`, and `0x00434f20`, the current
|
||||||
|
safest semantic read is that this shared catalog is the bundled source form of the scenario-side
|
||||||
|
named candidate-availability table later mirrored into `[state+0x66b2]`, with each entry's
|
||||||
|
trailing dword now reading as the same availability override bit later copied into
|
||||||
|
`[candidate+0x7ac]`. The loader-side coverage is tighter now too: the same table parser now
|
||||||
|
attaches both to the common-save bridge payload and directly to the fixed source range in
|
||||||
|
`.gmp` files and the non-common `rt3-105-scenario-save` / `rt3-105-alt-save` branches. That
|
||||||
|
makes the scenario variation explicit instead of anecdotal. `Alternate USA` keeps only three
|
||||||
|
zero-availability names in this table (`Nuclear Power Plant`, `Recycling Plant`, `Uranium
|
||||||
|
Mine`), `Southern Pacific` widens the zero set to twelve (`AutoPlant`, `Chemical Plant`,
|
||||||
|
`Electric Plant`, `Farm Rubber`, `FarmRice`, `FarmSugar`, `Nuclear Power Plant`, `Plastics
|
||||||
|
Factory`, `Recycling Plant`, `Tire Factory`, `Toy Factory`, `Uranium Mine`), and `Spanish
|
||||||
|
Mainline` widens it again to forty-two, including `Bauxite Mine`, `Logging Camp`, `Oil Well`,
|
||||||
|
`Port00`, and the `Warehouse00..11` run while also flipping `Recycling Plant` back to
|
||||||
|
available. The header lanes just ahead of the table vary coherently with those scenario
|
||||||
|
branches too: `Alternate USA` carries `header_word_0 = 0x10000000`, `Southern Pacific`
|
||||||
|
carries `0x00000000`, and `Spanish Mainline` carries `0xcdcdcdcd`, while the structural
|
||||||
|
fields from `header_word_2` onward remain stable (`0x332e`, `0x1`, `0x22`, `0x44`, `0x43`)
|
||||||
|
and the 9-byte footer still decodes as `0x32dc`, `0x3714`, `0x00` in all three checked maps.
|
||||||
|
A wider corpus scan over the visible `.gmp`/`.gms` files makes those two anonymous header
|
||||||
|
lanes less mysterious too: the parser currently sees only three stable
|
||||||
|
`(header_word_0, header_word_1)` pairs across 79 files with this table shape, namely
|
||||||
|
`(0x00000000, 0x00000000)`, `(0x10000000, 0x00009000)`, and
|
||||||
|
`(0xcdcdcdcd, 0xcdcdcdcd)`. The zero-availability count varies widely underneath the first and
|
||||||
|
third pairs (`0..56` under the zero pair, `14..67` under the `0xcdcdcdcd` pair), so those two
|
||||||
|
lanes no longer look like counts or direct availability payload; the safest current read is
|
||||||
|
that they are coarse scenario-family or source-template markers above the stable
|
||||||
|
`0x332e/0x22/0x44/0x43` table header, with `0xcdcdcdcd` still plausibly acting as one reused
|
||||||
|
filler or sentinel lane rather than a meaningful numeric threshold. Current exported
|
||||||
|
disassembly notes still do not ground one direct loader-side or editor-side consumer of
|
||||||
|
`header_word_0` or `header_word_1` themselves, so that family-marker read remains an
|
||||||
|
inference from corpus structure rather than a named field assignment.
|
||||||
|
The new loader-side compare command makes the save-copy claim sharper too: for the checked
|
||||||
|
pairs `Alternate USA.gmp -> Autosave.gms`, `Southern Pacific.gmp -> p.gms`, and
|
||||||
|
`Spanish Mainline.gmp -> g.gms`, the parsed candidate-availability table contents now match
|
||||||
|
exactly entry-for-entry, with the only reported differences being the outer container family
|
||||||
|
(`map` vs `save`) and source-kind path (`map-fixed-catalog-range` vs the save-side branch).
|
||||||
has the explicit companion `world_refresh_selected_year_bucket_scalar_band` `0x00433bd0`, which
|
has the explicit companion `world_refresh_selected_year_bucket_scalar_band` `0x00433bd0`, which
|
||||||
rebuilds the dependent selected-year bucket floats after the packed year changes; and then
|
rebuilds the dependent selected-year bucket floats after the packed year changes; and then
|
||||||
rehydrates the named locomotive availability collection at `[world+0x66b6]` through
|
rehydrates the named locomotive availability collection at `[world+0x66b6]` through
|
||||||
|
|
@ -2407,7 +2712,57 @@ transition.
|
||||||
loop; inside sandbox it raises localized id `3899` `The ledger is not available in sandbox mode.`
|
loop; inside sandbox it raises localized id `3899` `The ledger is not available in sandbox mode.`
|
||||||
instead. That narrows the remaining `LoadScreen.win` gap again: `TRAIN DETAIL` and
|
instead. That narrows the remaining `LoadScreen.win` gap again: `TRAIN DETAIL` and
|
||||||
`STATION DETAIL` now read as dormant title-table entries unless some still-unrecovered nonstandard
|
`STATION DETAIL` now read as dormant title-table entries unless some still-unrecovered nonstandard
|
||||||
selector reaches them.
|
selector reaches them. The live auto-load boundary is tighter now too: hook-driven
|
||||||
|
`shell_transition_mode(4, 0)` now completes old-mode teardown, reconstructs and republishes
|
||||||
|
`LoadScreen.win`, and returns cleanly, but the later post-transition service ticks still keep
|
||||||
|
`[0x006cec78] = 0`, `[shell_state+0x0c]` on the same `LoadScreen.win` singleton, and
|
||||||
|
`[LoadScreen.win+0x78] = 0` through at least counts `2..8`. So the next runtime edge is no
|
||||||
|
longer the old mode-`4` teardown or publish band; it is the `LoadScreen.win` message owner
|
||||||
|
`0x004e3a80` itself, because later service alone is not promoting the plain load screen into the
|
||||||
|
separate startup-runtime object path. One later runtime probe did not actually reach that edge:
|
||||||
|
the `0x004e3a80` hook installed, but the run never produced any ready-count, staging,
|
||||||
|
transition, post-transition, or load-screen-message lines, only ordinary shell node-vcall
|
||||||
|
traffic. So that result is now treated as a gate-or-cadence miss rather than as evidence against
|
||||||
|
the `LoadScreen.win` message path itself. The newer shell-state service trace tightens it again:
|
||||||
|
on a successful staged run the later service ticks do execute in `mode_id = 4`, but the
|
||||||
|
`0x004e3a80` message hook still stays silent while the state remains frozen in the plain
|
||||||
|
`LoadScreen.win` shape. So the next runtime boundary is now the shell-runtime prime call
|
||||||
|
`0x00538b60` beneath `shell_state_service_active_mode_frame` `0x00482160`, not the message owner
|
||||||
|
alone. The first direct `0x00538b60` probe run is not trustworthy yet, though: it stopped
|
||||||
|
immediately after the first shell-state service-entry line, before any ready-count or
|
||||||
|
runtime-prime entry logs. So that result is currently treated as probe validation work, not as a
|
||||||
|
real boundary move inside RT3. The static service branch is conditional too: `0x00482160` only
|
||||||
|
enters `0x00538b60` when `[shell_state+0xa0] == 0`, so silence from the runtime-prime hook does
|
||||||
|
not yet prove the shell stops before that call unless the service-entry logs also show the `+0xa0`
|
||||||
|
gate open. The newer run now closes that condition: `[shell_state+0xa0]` is `0`, and the
|
||||||
|
`0x00538b60` runtime-prime hook enters and returns cleanly. The newer run closes the next owner
|
||||||
|
too: `0x00520620` `shell_service_frame_cycle` also enters and returns cleanly on the same frozen
|
||||||
|
mode-`4` path, and the logged fields match its generic branch rather than a startup-promotion
|
||||||
|
lane (`[+0x1c] = 0`, `[+0x28] = 0`, `flag_56 = 0`, `[+0x58]` pulsed then cleared, and
|
||||||
|
`0x006cec78` stayed `0`). So the next runtime boundary under the same shell-state service pass is
|
||||||
|
now one level deeper: the per-object service walker `0x0053fda0` beneath `0x00538b60`. The newer
|
||||||
|
run closes that owner too: it enters and returns cleanly while servicing the `LoadScreen.win`
|
||||||
|
object itself, with `field_1d = 1`, `field_5c = 1`, and a stable child list under
|
||||||
|
`[obj+0x70/+0x74]`, and its first child-service vcall target at slot `+0x18` stays
|
||||||
|
`0x005595d0`. Since `0x006cec78` still stays `0` through those clean object-service passes, the
|
||||||
|
next runtime boundary is now the child-service target `0x005595d0`, not the higher object
|
||||||
|
walker. The newer child-service run narrows that again: the first sixteen `0x005595d0` calls are
|
||||||
|
stable child lanes under the same `LoadScreen.win` parent, with `[child+0x86]` pointing back to
|
||||||
|
the load-screen object, `field_b0 = 0`, and a split where earlier children carry
|
||||||
|
`flag_68 = 0x03` and return `4` while later siblings carry `flag_68 = 0x00` and return `0`. The
|
||||||
|
static body matches that read too: `0x005595d0` is gated by `0x00558670` and then spends most of
|
||||||
|
its work in draw or overlay helpers `0x54f710`, `0x54f9f0`, `0x54fdd0`, `0x53de00`, and
|
||||||
|
`0x552560`, so it now reads as another presentation-side service lane rather than the missing
|
||||||
|
startup-runtime promotion. The widened allocator-window trace then reconciled the runtime with
|
||||||
|
the static mode-`4` branch one step further: the first transition-window allocation is `0x7c`,
|
||||||
|
which matches the static pre-construct `0x48302a -> 0x53b070` alloc exactly, and the later
|
||||||
|
`0x111/0x84/0x3a/0x25...` allocations all occur before `LoadScreen.win` construct returns, so
|
||||||
|
they now read as constructor-side child or control setup. That means the allocator probe did not
|
||||||
|
disprove the still-silent startup-runtime slice; it simply exhausted its log budget inside the
|
||||||
|
constructor before the post-construct block. The later reset-at-return run is now the decisive
|
||||||
|
one: after `LoadScreen.win` construct returns there are still no further allocator hits before
|
||||||
|
publish and transition return, which matches the corrected jump-table decode because mode `4`
|
||||||
|
does not own the `0x46c40 -> 0x4336d0 -> 0x438890` startup-runtime path.
|
||||||
- Editor breadth: the broader map-editor page owner is now bounded through
|
- Editor breadth: the broader map-editor page owner is now bounded through
|
||||||
`map_editor_panel_select_active_section` `0x004ce070` and
|
`map_editor_panel_select_active_section` `0x004ce070` and
|
||||||
`map_editor_panel_dispatch_active_section_message` `0x004cf700`, which switch among the grounded
|
`map_editor_panel_dispatch_active_section_message` `0x004cf700`, which switch among the grounded
|
||||||
|
|
|
||||||
|
|
@ -69,27 +69,321 @@ Compared to the successful manual path:
|
||||||
|
|
||||||
So the hook is no longer missing the coordinator entry shape. The remaining question is no longer "can we reach `0x00445ac0`?" but "does the live non-debugger call return successfully and trigger the actual restore transition?"
|
So the hook is no longer missing the coordinator entry shape. The remaining question is no longer "can we reach `0x00445ac0`?" but "does the live non-debugger call return successfully and trigger the actual restore transition?"
|
||||||
|
|
||||||
## Latest Live Crash
|
## Latest Plain-Run Narrowing
|
||||||
|
|
||||||
The latest non-debugger auto-load run now reaches:
|
The current non-debugger auto-load path no longer looks like the original shell-side crash at
|
||||||
|
`0x0053fea6`.
|
||||||
|
|
||||||
- `rrt-hook: auto load ready gate passed`
|
The hook-side state machine is now stable up to the handoff into `shell_transition_mode`:
|
||||||
- `rrt-hook: auto load restore calling`
|
|
||||||
|
|
||||||
and then crashes at:
|
- `rrt-hook: auto load shell transition entering`
|
||||||
|
- `rrt-hook: auto load shell unpublish entering`
|
||||||
|
- `rrt-hook: auto load shell unpublish entry this=0x029b3a08 object=0x026d7b88`
|
||||||
|
|
||||||
- `0x0053fea6`
|
So the old hook-side gating and bad-call-shape problems are no longer the blocker.
|
||||||
|
|
||||||
The local disassembly around `0x0053fe90` shows a shell-side list traversal over `[this+0x74]` that walks linked entries and calls a virtual method on each. The crash instruction at `0x0053fea6` dereferences one traversed entry:
|
The current runtime probes now push the remaining stall much later than the original old-mode
|
||||||
|
teardown inside `shell_transition_mode`:
|
||||||
|
|
||||||
- `mov eax, DWORD PTR [esi]`
|
- `shell_transition_mode` enters
|
||||||
|
- old shell-window unpublish at `0x005389c0` enters with:
|
||||||
|
- shell bundle `this = 0x029b3a08`
|
||||||
|
- old object `object = 0x026d7b88`
|
||||||
|
- the inner wrapper `0x005400c0(object)` returns
|
||||||
|
- the full `0x53fe00 -> 0x53f860` remove-node sweep over `[object+0x74]` returns and clears
|
||||||
|
`[object+0x70/+0x74]`
|
||||||
|
- `shell_unpublish` itself then returns cleanly
|
||||||
|
- the nearby mode-`2` teardown helper `0x00502720` returns
|
||||||
|
- `shell_load_screen_window_construct` `0x004ea620` returns
|
||||||
|
- the immediate shell publish through `0x00538e50` returns
|
||||||
|
- `shell_transition_mode` itself returns cleanly
|
||||||
|
|
||||||
That strongly suggests the current hook is invoking the restore from the right call shape but on the wrong shell-pump turn. The active hypothesis is now timing or re-entrancy:
|
At the same time, one later load-side probe still does **not** fire:
|
||||||
|
|
||||||
- the hook detects readiness and fires restore on the same shell-pump turn
|
- no `shell_active_mode_run_profile_startup_and_load_dispatch` `0x00438890` entry
|
||||||
- RT3 later re-enters shell object traversal in a phase where one list entry is still invalid
|
|
||||||
|
|
||||||
So the next experiment is to defer the actual restore by additional ready shell-pump turns instead of firing on the first ready turn.
|
So the current live stall is now best read as:
|
||||||
|
|
||||||
|
- after the old-object unpublish path at `0x005389c0`
|
||||||
|
- after the inner `0x5400c0 -> 0x53fe00 -> 0x53f860` teardown sweep
|
||||||
|
- after the nearby mode-`2` teardown helper `0x00502720`
|
||||||
|
- after the mode-`4` `LoadScreen.win` constructor and immediate shell publish
|
||||||
|
- but still before any trusted runtime evidence that `0x00438890` has entered
|
||||||
|
|
||||||
|
The richer plain-run snapshots now tighten the old-object state too:
|
||||||
|
|
||||||
|
- the old object is still the expected `Setup.win` instance with vtable `0x005d1664`
|
||||||
|
- the shell bundle head and tail both point to that same object
|
||||||
|
- `[object+0x54]` and `[object+0x58]` are both null, so the outer unlink state is consistent
|
||||||
|
- `[object+0x74]` is non-null and the first two linked nodes recovered from `+0x8a` also look
|
||||||
|
structurally sane:
|
||||||
|
- first node `0x02a74470`: vtable `0x005dd870`, type `0xea72`, owner-ish field `0x02a067b8`,
|
||||||
|
next `0x02a04b38`
|
||||||
|
- second node `0x02a04b38`: vtable `0x005dd870`, type `0xea71`, owner-ish field `0x02a067b8`,
|
||||||
|
next `0x02a03e38`
|
||||||
|
|
||||||
|
So the remaining leading hypothesis is no longer "the list head is already garbage." The later
|
||||||
|
shared node vcall target `0x540910` is healthy in general and does not fire on the failing
|
||||||
|
transition path. The newer direct probes narrow it even further: the failing transition still does
|
||||||
|
not reach `0x53fe00` or `0x53f860`. That pushes the current boundary into the tiny wrapper layer
|
||||||
|
between `shell_unpublish` entry and the `0x53fe00` call, with `0x5400c0(object)` now the next
|
||||||
|
useful direct probe.
|
||||||
|
|
||||||
|
The latest plain Wine log also ends with a matching crash:
|
||||||
|
|
||||||
|
- `wine: Unhandled page fault on read access to 02E11000 at address 02E11000`
|
||||||
|
|
||||||
|
Static disassembly sharpened the remaining boundary one step further, but the newer jump-table
|
||||||
|
decode changes the interpretation materially. The startup-runtime slice
|
||||||
|
|
||||||
|
- `0x004ea710`
|
||||||
|
- `0x0053b070(0x46c40)`
|
||||||
|
- `0x004336d0`
|
||||||
|
- `0x00438890`
|
||||||
|
|
||||||
|
is not owned by mode `4`. It is owned by jump-table entry `1` at `0x483012`. Jump-table entry `4`
|
||||||
|
lands at `0x4832e5` instead and only constructs and publishes a plain `LoadScreen.win` object
|
||||||
|
through `0x004ea620` and `0x00538e50`.
|
||||||
|
|
||||||
|
So the next useful probe is no longer the mode-`4` branch’s pre-dispatch runtime-object helper,
|
||||||
|
because mode `4` does not own that startup-runtime path at all. The next useful test is the real
|
||||||
|
startup-dispatch entrypoint: `shell_transition_mode(1, 0)`.
|
||||||
|
|
||||||
|
The latest plain runs tightened that correction one more step:
|
||||||
|
|
||||||
|
- the direct `0x004336d0` runtime-reset probe still does **not** fire
|
||||||
|
- the direct `0x00438890` startup-dispatch probe still does **not** fire
|
||||||
|
- but `shell_transition_mode`, `LoadScreen.win` construction, and the immediate shell publish all
|
||||||
|
still return cleanly
|
||||||
|
|
||||||
|
That no longer means the post-construct startup slice is mysteriously skipped inside mode `4`.
|
||||||
|
Instead, it matches the corrected static decode exactly: the hook has been entering the plain
|
||||||
|
load-screen branch rather than the startup-runtime branch.
|
||||||
|
|
||||||
|
The next best runtime target is therefore no longer another allocator cut under mode `4`. It is a
|
||||||
|
direct test of `shell_transition_mode(1, 0)`, which is the jump-table arm that statically owns the
|
||||||
|
startup-runtime allocation and `0x00438890` dispatch.
|
||||||
|
|
||||||
|
## Current Pause Point
|
||||||
|
|
||||||
|
Current recorded stop point:
|
||||||
|
|
||||||
|
- the old hook-side crash and teardown corruption are resolved
|
||||||
|
- the static jump-table decode at `0x48342c` shows the hook had been entering the wrong arm
|
||||||
|
- `shell_transition_mode(4, 0)` is only the plain `LoadScreen.win` branch
|
||||||
|
- `shell_transition_mode(1, 0)` is the startup-dispatch branch that owns:
|
||||||
|
- `0x004ea710`
|
||||||
|
- `0x0053b070(0x46c40)`
|
||||||
|
- `0x004336d0`
|
||||||
|
- `0x00438890`
|
||||||
|
|
||||||
|
So the next live experiment, when this work resumes, should start from the corrected mode-`1`
|
||||||
|
transition path rather than adding more probes under mode `4`.
|
||||||
|
|
||||||
|
Two corrective notes from the allocator probe passes:
|
||||||
|
|
||||||
|
- the first allocator experiment at `0x005a125d` was not trustworthy, because that shared cdecl
|
||||||
|
body sits behind the `0x0053b070` thunk and the initial hook used the wrong entry shape and
|
||||||
|
split its first internal `call`
|
||||||
|
- the first direct thunk hook on `0x0053b070` was also not trustworthy as implemented, because a
|
||||||
|
copied relative-`jmp` thunk cannot be replayed through an ordinary trampoline
|
||||||
|
|
||||||
|
The next trustworthy allocator boundary is still the exact mode-`4`-branch thunk at `0x0053b070`,
|
||||||
|
but only with a detour that calls the original target `0x005a125d` directly instead of executing
|
||||||
|
the copied thunk bytes.
|
||||||
|
|
||||||
|
The latest filtered run exposed a more basic gating issue too: the log only reached one
|
||||||
|
`gate mask 0x7` line with `mode_id = 2`, and it never advanced into `ready gate passed`, staging,
|
||||||
|
or transition. So that run did not actually exercise the load-screen startup subchain; it mostly
|
||||||
|
recorded ordinary shell-node activity plus one late ready-state observation. The old default gate
|
||||||
|
of `30` ready polls plus `5` deferred polls was therefore too conservative for this workflow. The
|
||||||
|
next run now lowers those defaults to `1` and `0`, and adds an explicit ready-count log so the
|
||||||
|
trace should either stage immediately or show exactly how far the gate gets.
|
||||||
|
|
||||||
|
That gate adjustment worked on the next run: the hook now reaches `ready count`, stages selector
|
||||||
|
`3`, enters `shell_transition_mode`, returns from the `LoadScreen.win` construct and publish
|
||||||
|
helpers, and reports success again. But the allocator side is still unresolved:
|
||||||
|
|
||||||
|
- there is still no trusted `0x46c40` allocator hit from `0x0053b070`
|
||||||
|
- there is still no direct `0x004336d0` runtime-reset entry
|
||||||
|
- there is still no direct `0x00438890` startup-dispatch entry
|
||||||
|
|
||||||
|
So the next clean post-publish boundary is the tiny scalar setter at `0x004ea710`, which is the
|
||||||
|
last straightforward callsite in the static mode-`4` branch immediately before the `0x0053b070`
|
||||||
|
allocation.
|
||||||
|
|
||||||
|
The immediate next runtime check is even more concrete than that helper hook, though: inspect the
|
||||||
|
state that `0x004ea710` should leave behind. Right after `shell_transition_mode` returns, the hook
|
||||||
|
now logs:
|
||||||
|
|
||||||
|
- `0x006d10b0` (`LoadScreen.win` singleton)
|
||||||
|
- `[LoadScreen.win+0x78]`
|
||||||
|
- `0x006cec78`
|
||||||
|
- `[0x006cec74+0x0c]`
|
||||||
|
- `[0x006cec7c+0x01]`
|
||||||
|
|
||||||
|
If `0x004ea710` really ran on the mode-`4` branch, `[LoadScreen.win+0x78]` should no longer be
|
||||||
|
zero after transition return.
|
||||||
|
|
||||||
|
The latest run answered that question directly:
|
||||||
|
|
||||||
|
- `shell_transition_mode` still returns cleanly
|
||||||
|
- `field_active_mode_object` is still the `LoadScreen.win` singleton
|
||||||
|
- `0x006cec78` is still null
|
||||||
|
- `[LoadScreen.win+0x78]` is still `0`
|
||||||
|
- startup selector remains `3`
|
||||||
|
|
||||||
|
So the strongest current read is no longer “the helper hooks might be missing a straight-line call.”
|
||||||
|
At transition return, RT3 still looks like it is parked in the plain `LoadScreen.win` state rather
|
||||||
|
than having entered the separate runtime-object path at all. The next useful runtime cut is
|
||||||
|
therefore not deeper inside `shell_transition_mode`, but on the later active-mode service cadence:
|
||||||
|
does a subsequent service tick on the `LoadScreen.win` object populate `[+0x78]` or promote
|
||||||
|
`0x006cec78` into the startup-dispatch object on a later frame?
|
||||||
|
|
||||||
|
The next run now logs the first few shell-state service ticks after auto-load is attempted with the
|
||||||
|
same state tuple:
|
||||||
|
|
||||||
|
- `0x006cec78`
|
||||||
|
- `[0x006cec74+0x0c]`
|
||||||
|
- `0x006d10b0`
|
||||||
|
- `[LoadScreen.win+0x78]`
|
||||||
|
- startup selector
|
||||||
|
|
||||||
|
So the next question is very narrow: does that tuple stay frozen in the plain `LoadScreen.win`
|
||||||
|
shape, or does one later service tick finally promote it into the startup-runtime object path?
|
||||||
|
|
||||||
|
The latest service-tick run makes that boundary stronger still:
|
||||||
|
|
||||||
|
- the first later shell-state service ticks `count=2..8` all keep the same frozen state
|
||||||
|
- `0x006cec78` stays `0`
|
||||||
|
- `[shell_state+0x0c]` stays the `LoadScreen.win` singleton
|
||||||
|
- `[LoadScreen.win+0x78]` stays `0`
|
||||||
|
|
||||||
|
So the active-mode service pass itself is not promoting the plain load screen into the startup
|
||||||
|
runtime object during those first later frames. The next best runtime boundary is now the
|
||||||
|
`LoadScreen.win` message owner `0x004e3a80`, because that is the remaining live owner most likely
|
||||||
|
to receive the trigger that seeds page id `[this+0x78]`, allocates the `0x46c40` startup runtime,
|
||||||
|
and later publishes `0x006cec78`.
|
||||||
|
|
||||||
|
One later run did not reach that boundary at all:
|
||||||
|
|
||||||
|
- the new `0x004e3a80` hook installed successfully
|
||||||
|
- but there were no `ready count`, staging, transition, post-transition, or load-screen-message
|
||||||
|
lines anywhere in the log
|
||||||
|
- the trace only showed ordinary shell node-vcall traffic before the window was closed
|
||||||
|
|
||||||
|
So that run is best treated as "auto-load path not exercised", not as evidence that the
|
||||||
|
`LoadScreen.win` message owner stayed silent after a successful transition. The next useful runtime
|
||||||
|
check is therefore one step earlier again: add a small first-few-calls trace on
|
||||||
|
`shell_state_service_active_mode_frame` itself so we can confirm whether that detour is firing on
|
||||||
|
the run at all and what mode id and gate mask it sees before the auto-load gate would stage.
|
||||||
|
|
||||||
|
That newer service-entry trace now confirms the full cadence:
|
||||||
|
|
||||||
|
- the service detour is firing
|
||||||
|
- the gate does stage and transition on counts `1 -> 2`
|
||||||
|
- the transition returns cleanly
|
||||||
|
- later service ticks run with `mode_id = 4`
|
||||||
|
|
||||||
|
At the same time, the next two probes are now bounded as negative results on that successful path:
|
||||||
|
|
||||||
|
- the `LoadScreen.win` message hook at `0x004e3a80` stayed completely silent
|
||||||
|
- the plain post-transition state still stays frozen with:
|
||||||
|
- `0x006cec78 = 0`
|
||||||
|
- `field_active_mode_object = LoadScreen.win`
|
||||||
|
- `[LoadScreen.win+0x78] = 0`
|
||||||
|
|
||||||
|
So the next best boundary is no longer the message owner itself. It is the shell-runtime prime call
|
||||||
|
at `0x00538b60`, because `0x00482160` still takes that branch on the null-`0x006cec78` service
|
||||||
|
path before the later frame-cycle owner `0x00520620`.
|
||||||
|
|
||||||
|
The first `0x00538b60` probe run is not trustworthy yet, though:
|
||||||
|
|
||||||
|
- the hook installed
|
||||||
|
- but the log stopped immediately after the first
|
||||||
|
`shell-state service entry count=1 ... gate_mask=0x7 mode_id=2 ...`
|
||||||
|
- there were no ready-count lines, no transition lines, and no runtime-prime entry lines
|
||||||
|
|
||||||
|
So that result currently reads as "the new runtime-prime instrumentation likely interrupted the
|
||||||
|
first service pass" rather than as a real RT3 boundary shift. The next corrective step is to log
|
||||||
|
the matching shell-state service return and to trace the first few `0x00538b60` calls even before
|
||||||
|
`AUTO_LOAD_ATTEMPTED` becomes true. That will tell us whether the first service pass actually
|
||||||
|
returns and whether the runtime-prime hook is firing at all.
|
||||||
|
|
||||||
|
The static branch under `0x00482160` also adds one more caution: `0x00538b60` is conditional, not
|
||||||
|
unconditional. The service pass only enters it when the shell runtime at `0x006d401c` is live and
|
||||||
|
`[shell_state+0xa0] == 0`. So a silent `0x00538b60` probe does not yet prove the shell is frozen
|
||||||
|
before the runtime-prime call; it may simply mean the `+0xa0` gate stayed nonzero on that service
|
||||||
|
tick. The next service-entry logs therefore need to include `[shell_state+0xa0]` before we treat
|
||||||
|
runtime-prime silence as meaningful.
|
||||||
|
|
||||||
|
The newer run closes that conditional question:
|
||||||
|
|
||||||
|
- `[shell_state+0xa0]` is `0` on the first traced service call
|
||||||
|
- `0x00538b60` is therefore eligible
|
||||||
|
- the runtime-prime probe now shows it entering and returning cleanly on that same service tick
|
||||||
|
|
||||||
|
The later run closes the next owner too:
|
||||||
|
|
||||||
|
- `0x00520620` `shell_service_frame_cycle` also enters and returns cleanly on the same frozen
|
||||||
|
mode-`4` path
|
||||||
|
- the logged state matches the generic frame-service branch:
|
||||||
|
- `[+0x1c] = 0`
|
||||||
|
- `[+0x28] = 0`
|
||||||
|
- `flag_56 = 0`
|
||||||
|
- `[+0x58]` is pulsed and then cleared back to `0`
|
||||||
|
- `0x006cec78` stays `0`
|
||||||
|
|
||||||
|
The newer run closes that owner too:
|
||||||
|
|
||||||
|
- `0x0053fda0` enters and returns cleanly on the frozen mode-`4` path
|
||||||
|
- it is actively servicing the `LoadScreen.win` object itself
|
||||||
|
- the serviced object keeps `field_1d = 1`, `field_5c = 1`, and a stable child list
|
||||||
|
- the first child vcall target at `+0x18` stays `0x005595d0`
|
||||||
|
- `0x006cec78` still stays `0`
|
||||||
|
|
||||||
|
So the next live boundary is now the child-service target itself at `0x005595d0`, not the higher
|
||||||
|
object walker.
|
||||||
|
|
||||||
|
The child-service run narrows that again. The first sixteen `0x005595d0` calls under the serviced
|
||||||
|
`LoadScreen.win` object are stable, presentation-heavy child lanes:
|
||||||
|
|
||||||
|
- every child points back to the same parent through `[child+0x86] = LoadScreen.win`
|
||||||
|
- the early children have `flag_68 = 0x03`, `flag_6a = 0x03`, and return `4`
|
||||||
|
- the later siblings have `flag_68 = 0x00`, `flag_6a = 0x03`, and return `0`
|
||||||
|
- `field_b0` stays `0`
|
||||||
|
- `0x006cec78` still stays `0`
|
||||||
|
|
||||||
|
Static disassembly matches that read: `0x005595d0` is gated by `0x00558670` and then spends most
|
||||||
|
of its body in draw or overlay helpers like `0x54f710`, `0x54f9f0`, `0x54fdd0`, `0x53de00`, and
|
||||||
|
`0x552560`. So this is a presentation-side child service path, not the missing startup-runtime
|
||||||
|
promotion.
|
||||||
|
|
||||||
|
That moved the next useful runtime target back to the transition-time allocator lane, but the
|
||||||
|
later jump-table decode changes what that means. The widened `0x0053b070` window below is now
|
||||||
|
best read as evidence for the plain mode-`4` `LoadScreen.win` arm, not as evidence for the
|
||||||
|
startup-runtime arm.
|
||||||
|
|
||||||
|
The next widened allocator run immediately paid off, but in a narrower way than expected:
|
||||||
|
|
||||||
|
- the first traced transition-window allocation is `0x7c`, which matches the static pre-construct
|
||||||
|
`0x48302a -> 0x53b070` call exactly
|
||||||
|
- the following `0x111`, `0x84`, `0x3a`, and repeated `0x25` allocations all happen before
|
||||||
|
`LoadScreen.win` construct returns, so they now read as constructor-side child or control setup
|
||||||
|
- that means the allocator probe was not disproving the `0x46c40` startup-runtime slice yet; it
|
||||||
|
was simply exhausting its 16-entry log budget inside the constructor before the later
|
||||||
|
post-construct block
|
||||||
|
|
||||||
|
The corrected follow-up run with that reset is now the decisive one: after `LoadScreen.win`
|
||||||
|
construct returns, there are still no further allocator hits before publish and transition return.
|
||||||
|
That matches the corrected jump-table decode cleanly, because mode `4` does not own the
|
||||||
|
`0x46c40 -> 0x4336d0 -> 0x438890` path at all.
|
||||||
|
|
||||||
|
The first corrected thunk run also showed one practical problem: the probe became too noisy to be
|
||||||
|
useful as a boundary marker, because `0x0053b070` is used widely outside the load-screen path.
|
||||||
|
That still mattered, because it showed the hook-driven transition was taking the same `0x7c`
|
||||||
|
constructor-side allocation as the plain mode-`4` branch rather than the startup-runtime
|
||||||
|
allocation size `0x46c40`.
|
||||||
|
|
||||||
## Manual Owner Tail
|
## Manual Owner Tail
|
||||||
|
|
||||||
|
|
@ -134,6 +428,17 @@ The surrounding mode map is tighter now too:
|
||||||
|
|
||||||
That makes `0x00438890(active_mode, 1, 0)` the strongest current RT3-native entry candidate for reproducing the successful manual load branch, because it owns the internal dispatch that later reaches `0x004390cb`.
|
That makes `0x00438890(active_mode, 1, 0)` the strongest current RT3-native entry candidate for reproducing the successful manual load branch, because it owns the internal dispatch that later reaches `0x004390cb`.
|
||||||
|
|
||||||
|
The containing shell-mode switcher ABI is tighter now too:
|
||||||
|
|
||||||
|
- `0x00482ec0` is not a one-arg mode switch
|
||||||
|
- it is a `thiscall` with two stack arguments
|
||||||
|
- the grounded world-entry load-screen call shape at `0x443adf..0x443ae3` is `(4, 0)`
|
||||||
|
- the function confirms that shape itself by reading the requested mode from `[esp+0x0c]` and
|
||||||
|
returning with `ret 8`
|
||||||
|
- the second stack argument is now best read as an old-active-mode teardown flag, because the
|
||||||
|
`0x482fc6..0x482fff` branch only runs when it is nonzero and then releases the old active-mode
|
||||||
|
object through `0x00434300`, `0x00433730`, `0x0053b080`, and finally clears `0x006cec78`
|
||||||
|
|
||||||
Current static xrefs also tighten the broader ownership split:
|
Current static xrefs also tighten the broader ownership split:
|
||||||
|
|
||||||
- `0x00443b57` calls `0x00438890` from the world-entry side, but with `(0, 0)` after dismissing the current shell detail panel and servicing `0x4834e0(0, 0)`
|
- `0x00443b57` calls `0x00438890` from the world-entry side, but with `(0, 0)` after dismissing the current shell detail panel and servicing `0x4834e0(0, 0)`
|
||||||
|
|
@ -186,10 +491,17 @@ The scripted auto-load debugger run is now useful without manual interaction:
|
||||||
- `0x00438890`
|
- `0x00438890`
|
||||||
- `0x004390cb`
|
- `0x004390cb`
|
||||||
- `0x00445ac0`
|
- `0x00445ac0`
|
||||||
- `0x0053fea6`
|
- older runs that also broke on `0x0053fea6` stopped too early on that shell-side crash site
|
||||||
- but only `0x0053fea6` actually fired in the captured run
|
- the default scripted compare flow now keeps only the owner-chain breakpoints above the real load lane
|
||||||
|
|
||||||
So the current non-interactive path is good enough to gather repeatable crash-side state, but it also tells us that the current auto-load code path is still not obviously traversing the larger-owner breakpoints under `winedbg`. The next step is therefore more hook-side logging around the `0x00438890` call itself rather than more manual debugger work.
|
So the current non-interactive path is still good enough to gather repeatable crash-side state, but
|
||||||
|
on this display setup the owner-chain compare flow is also vulnerable to early X11 death:
|
||||||
|
|
||||||
|
- `XF86VidModeClientNotLocal`
|
||||||
|
- process termination before the RT3 owner breakpoints fire
|
||||||
|
|
||||||
|
That means the current plain-run hook probes are more reliable than `winedbg` for narrowing the
|
||||||
|
live stall inside `shell_transition_mode`.
|
||||||
|
|
||||||
The latest static pivot also means the next reverse-engineering step does not require a live run:
|
The latest static pivot also means the next reverse-engineering step does not require a live run:
|
||||||
|
|
||||||
|
|
@ -256,8 +568,34 @@ RRT_WINEDBG_LOG=/tmp/rt3-manual-load-winedbg.log tools/run_rt3_winedbg.sh
|
||||||
Ready-made debugger command files are also provided:
|
Ready-made debugger command files are also provided:
|
||||||
|
|
||||||
- [winedbg_manual_load_445ac0.cmd](/home/jan/projects/rrt/tools/winedbg_manual_load_445ac0.cmd)
|
- [winedbg_manual_load_445ac0.cmd](/home/jan/projects/rrt/tools/winedbg_manual_load_445ac0.cmd)
|
||||||
|
- [winedbg_auto_load_crash.cmd](/home/jan/projects/rrt/tools/winedbg_auto_load_crash.cmd)
|
||||||
- [winedbg_auto_load_compare.cmd](/home/jan/projects/rrt/tools/winedbg_auto_load_compare.cmd)
|
- [winedbg_auto_load_compare.cmd](/home/jan/projects/rrt/tools/winedbg_auto_load_compare.cmd)
|
||||||
|
|
||||||
|
The default auto-load debugger run is now crash-first. It does not set RT3 owner breakpoints.
|
||||||
|
Instead, it:
|
||||||
|
|
||||||
|
- continues immediately
|
||||||
|
- lets `winedbg` stop on the first exception
|
||||||
|
- dumps registers
|
||||||
|
- dumps the top four stack dwords
|
||||||
|
- prints a backtrace
|
||||||
|
|
||||||
|
Use that default when the hook is already known to stage and return from `shell_transition_mode`,
|
||||||
|
and the current question is the downstream crash site.
|
||||||
|
|
||||||
|
If you specifically want the earlier owner-chain compare flow, override the command file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RRT_WINEDBG_CMD_FILE=/home/jan/projects/rrt/tools/winedbg_auto_load_compare.cmd \
|
||||||
|
tools/run_hook_auto_load_winedbg.sh hh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the shorter wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/run_hook_auto_load_winedbg_compare.sh hh
|
||||||
|
```
|
||||||
|
|
||||||
If you do not use `RRT_WINEDBG_CMD_FILE`, you can still open those files and paste their contents into the debugger manually.
|
If you do not use `RRT_WINEDBG_CMD_FILE`, you can still open those files and paste their contents into the debugger manually.
|
||||||
|
|
||||||
Both scripts rebuild `rrt-hook`, copy `dinput8.dll` into the Wine RT3 directory, and launch RT3 under `winedbg`.
|
Both scripts rebuild `rrt-hook`, copy `dinput8.dll` into the Wine RT3 directory, and launch RT3 under `winedbg`.
|
||||||
|
|
|
||||||
512
docs/runtime-rehost-plan.md
Normal file
512
docs/runtime-rehost-plan.md
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
# Runtime Rehost Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the shell-dependent execution path with a bottom-up runtime that can be tested headlessly,
|
||||||
|
grown incrementally, and later support one or more replacement frontends.
|
||||||
|
|
||||||
|
This plan assumes the current shell and presentation path remain unreliable for the near term. We
|
||||||
|
therefore treat shell recovery as a later adapter problem rather than as the primary execution
|
||||||
|
milestone.
|
||||||
|
|
||||||
|
## Why This Boundary
|
||||||
|
|
||||||
|
Current static analysis points to one important constraint: the existing gameplay cadence is still
|
||||||
|
nested under shell-owned frame and controller ownership rather than under one clean detached
|
||||||
|
gameplay loop.
|
||||||
|
|
||||||
|
- `simulation_frame_accumulate_and_step_world` is still called from the shell-owned cadence and
|
||||||
|
still performs shell-window and presentation-adjacent servicing.
|
||||||
|
- `shell_service_frame_cycle` owns frame refresh, deferred work, cursor updates, and one-time
|
||||||
|
window visibility work.
|
||||||
|
- the world-view input path and ordinary controller input path still flow through shell-owned
|
||||||
|
objects and globals.
|
||||||
|
|
||||||
|
That makes shell-first stabilization a poor rewrite target. The better target is the lower runtime:
|
||||||
|
calendar stepping, periodic world maintenance, scenario event service, runtime persistence, and the
|
||||||
|
stateful collections beneath them.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The runtime rehost should be split into four layers.
|
||||||
|
|
||||||
|
### 1. `rrt-runtime`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- pure runtime state
|
||||||
|
- deterministic stepping
|
||||||
|
- scenario and company maintenance
|
||||||
|
- persistence and normalization boundaries
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- no controller-window ownership
|
||||||
|
- no presentation refresh
|
||||||
|
- no shell globals in the public model
|
||||||
|
- no dependency on message pumps or platform input
|
||||||
|
|
||||||
|
### 2. `rrt-fixtures`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- fixture schemas
|
||||||
|
- captured-state loading
|
||||||
|
- golden summaries and diff helpers
|
||||||
|
- normalization helpers for comparing original-runtime outputs against rehosted outputs
|
||||||
|
|
||||||
|
### 3. `rrt-cli`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- headless runtime driver
|
||||||
|
- fixture execution commands
|
||||||
|
- state diff and round-trip tools
|
||||||
|
|
||||||
|
This should become the first practical execution surface for the new runtime.
|
||||||
|
|
||||||
|
### 4. Future adapter layers
|
||||||
|
|
||||||
|
Possible later crates:
|
||||||
|
|
||||||
|
- `rrt-shell-adapter`
|
||||||
|
- `rrt-ui`
|
||||||
|
- `rrt-hook` capture bridges
|
||||||
|
|
||||||
|
These should adapt to `rrt-runtime`, not own the core simulation model.
|
||||||
|
|
||||||
|
## Rewrite Principles
|
||||||
|
|
||||||
|
1. Prefer state-in, state-out functions over shell-owned coordinators.
|
||||||
|
2. Choose narrow vertical slices that can be verified with fixtures.
|
||||||
|
3. Treat persistence boundaries as assets, because they give us reproducible test inputs.
|
||||||
|
4. Normalize state aggressively before diffing so comparisons stay stable.
|
||||||
|
5. Do not rehost shell or input code until the lower runtime already has a trustworthy step API.
|
||||||
|
|
||||||
|
## Candidate Boundaries
|
||||||
|
|
||||||
|
Good early targets:
|
||||||
|
|
||||||
|
- calendar-point arithmetic and step quantization helpers
|
||||||
|
- `simulation_advance_to_target_calendar_point`
|
||||||
|
- `simulation_service_periodic_boundary_work`
|
||||||
|
- `scenario_event_collection_service_runtime_effect_records_for_trigger_kind`
|
||||||
|
- `world_load_saved_runtime_state_bundle`
|
||||||
|
- `world_runtime_serialize_smp_bundle`
|
||||||
|
- company and placed-structure refresh helpers that look like collection or state transforms
|
||||||
|
|
||||||
|
Poor early targets:
|
||||||
|
|
||||||
|
- `simulation_frame_accumulate_and_step_world`
|
||||||
|
- `world_entry_transition_and_runtime_bringup`
|
||||||
|
- `shell_controller_window_message_dispatch`
|
||||||
|
- world-view camera and cursor service helpers
|
||||||
|
- shell window constructors or message handlers
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### Milestone 0: Scaffolding
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- create the workspace shape for bottom-up runtime work
|
||||||
|
- define fixture formats and CLI entrypoints
|
||||||
|
- make the first headless command runnable even before the runtime is featureful
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
|
||||||
|
- new crate `crates/rrt-runtime`
|
||||||
|
- new crate `crates/rrt-fixtures`
|
||||||
|
- `rrt-cli` subcommands for runtime and fixture work
|
||||||
|
- initial fixture file format checked into the repo
|
||||||
|
- baseline docs for state normalization and comparison policy
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- `cargo run -p rrt-cli -- runtime validate-fixture <path>` works
|
||||||
|
- one sample fixture parses and normalizes successfully
|
||||||
|
- the new crates build in the workspace
|
||||||
|
|
||||||
|
### Milestone 1: Deterministic Step Kernel
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- stand up a minimal runtime state and one deterministic stepping API
|
||||||
|
- prove that a world can advance without any shell or presentation owner
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
|
||||||
|
- calendar-point representation in Rust
|
||||||
|
- reduced `RuntimeState` model sufficient for stepping
|
||||||
|
- one `advance_to_target_calendar_point` execution path
|
||||||
|
- deterministic smoke fixtures
|
||||||
|
- human-readable state diff output
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- one minimal fixture can advance to a target point and produce stable repeated output
|
||||||
|
- the same fixture can run for N steps with identical results across repeated runs
|
||||||
|
- state summaries cover the calendar tuple and a small set of world counters
|
||||||
|
|
||||||
|
### Milestone 2: Periodic Service Kernel
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- add recurring maintenance and trigger dispatch on top of the first step kernel
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
|
||||||
|
- periodic boundary service modes
|
||||||
|
- trigger-kind dispatch scaffolding
|
||||||
|
- the first runtime-effect service path behind a stable API
|
||||||
|
- fixture coverage for one or two trigger kinds
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- one fixture can execute periodic maintenance without shell state
|
||||||
|
- trigger-kind-specific effects can be observed in a normalized diff
|
||||||
|
|
||||||
|
### Milestone 3: Persistence Boundary
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- load and save enough runtime state to support realistic fixtures
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
|
||||||
|
- serializer and loader support for a narrow `.smp` subset or an equivalent normalized fixture view
|
||||||
|
- round-trip tests
|
||||||
|
- versioned normalization rules
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- one captured runtime fixture can be round-tripped with stable normalized output
|
||||||
|
|
||||||
|
### Milestone 4: Domain Expansion
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- add the minimum missing subsystems needed by failing fixtures
|
||||||
|
|
||||||
|
Likely order:
|
||||||
|
|
||||||
|
- company maintenance
|
||||||
|
- scenario event service breadth
|
||||||
|
- placed-structure local-runtime refresh
|
||||||
|
- candidate and cargo-service tables
|
||||||
|
- locomotive availability refresh
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- representative fixtures from multiple subsystems can step and diff without shell ownership
|
||||||
|
|
||||||
|
### Milestone 5: Adapter and Frontend Re-entry
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- connect the runtime core to external control surfaces
|
||||||
|
|
||||||
|
Possible outputs:
|
||||||
|
|
||||||
|
- shell-compatibility adapter
|
||||||
|
- replacement CLI workflows
|
||||||
|
- replacement UI or tool surfaces
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- external commands operate by calling runtime APIs rather than by reaching into shell-owned state
|
||||||
|
|
||||||
|
## Fixture Strategy
|
||||||
|
|
||||||
|
We should maintain three fixture classes from the start.
|
||||||
|
|
||||||
|
### `minimal-world`
|
||||||
|
|
||||||
|
Small synthetic fixtures for deterministic kernel testing.
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
|
||||||
|
- calendar stepping
|
||||||
|
- periodic maintenance
|
||||||
|
- invariants and smoke tests
|
||||||
|
|
||||||
|
### `captured-runtime`
|
||||||
|
|
||||||
|
Fixtures captured from the original process or from serialized runtime state.
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
|
||||||
|
- parity checks
|
||||||
|
- subsystem-specific debugging
|
||||||
|
- rehosted function validation
|
||||||
|
|
||||||
|
### `roundtrip-save`
|
||||||
|
|
||||||
|
Persistence-oriented fixtures built around real save data or normalized equivalents.
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
|
||||||
|
- serializer validation
|
||||||
|
- normalization rules
|
||||||
|
- regression tests
|
||||||
|
|
||||||
|
Each fixture should contain:
|
||||||
|
|
||||||
|
- metadata
|
||||||
|
- format version
|
||||||
|
- source provenance
|
||||||
|
- input state
|
||||||
|
- command list
|
||||||
|
- expected summary
|
||||||
|
- optional expected normalized full state
|
||||||
|
|
||||||
|
## Normalization Policy
|
||||||
|
|
||||||
|
Runtime diffs will be noisy unless we define a normalization layer early.
|
||||||
|
|
||||||
|
Normalize away:
|
||||||
|
|
||||||
|
- pointer addresses
|
||||||
|
- allocation order
|
||||||
|
- container iteration order when semantically unordered
|
||||||
|
- shell-only dirty flags or presentation counters
|
||||||
|
- timestamps that are not semantically relevant to the tested behavior
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
|
||||||
|
- calendar tuple
|
||||||
|
- company and world ids
|
||||||
|
- cash, debt, and game-speed-related runtime fields when semantically relevant
|
||||||
|
- collection contents and semantic counts
|
||||||
|
- trigger-side effects
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
### Hidden shell coupling
|
||||||
|
|
||||||
|
Some lower functions still touch shell globals indirectly. We should isolate those reads quickly and
|
||||||
|
replace them with explicit runtime inputs where possible.
|
||||||
|
|
||||||
|
### Fixture incompleteness
|
||||||
|
|
||||||
|
Captured state that omits one manager table can make deterministic functions appear unstable. The
|
||||||
|
fixture format should make missing dependencies obvious.
|
||||||
|
|
||||||
|
### Over-scoped early rewrites
|
||||||
|
|
||||||
|
The first two milestones should remain deliberately small. Do not pull in company UI, world-view
|
||||||
|
camera work, or shell windows just because their names are nearby in the call graph.
|
||||||
|
|
||||||
|
## Milestone 0 Specifics
|
||||||
|
|
||||||
|
This milestone is mostly repo shaping and interface definition.
|
||||||
|
|
||||||
|
### Proposed crate layout
|
||||||
|
|
||||||
|
`crates/rrt-runtime`
|
||||||
|
|
||||||
|
- `src/lib.rs`
|
||||||
|
- `src/calendar.rs`
|
||||||
|
- `src/runtime.rs`
|
||||||
|
- `src/step.rs`
|
||||||
|
- `src/summary.rs`
|
||||||
|
|
||||||
|
`crates/rrt-fixtures`
|
||||||
|
|
||||||
|
- `src/lib.rs`
|
||||||
|
- `src/schema.rs`
|
||||||
|
- `src/load.rs`
|
||||||
|
- `src/normalize.rs`
|
||||||
|
- `src/diff.rs`
|
||||||
|
|
||||||
|
### Proposed first public types
|
||||||
|
|
||||||
|
In `rrt-runtime`:
|
||||||
|
|
||||||
|
- `CalendarPoint`
|
||||||
|
- `RuntimeState`
|
||||||
|
- `StepCommand`
|
||||||
|
- `StepResult`
|
||||||
|
- `RuntimeSummary`
|
||||||
|
|
||||||
|
In `rrt-fixtures`:
|
||||||
|
|
||||||
|
- `FixtureDocument`
|
||||||
|
- `FixtureCommand`
|
||||||
|
- `ExpectedSummary`
|
||||||
|
- `NormalizedState`
|
||||||
|
|
||||||
|
### Proposed CLI surface
|
||||||
|
|
||||||
|
Add a `runtime` command family to `rrt-cli`.
|
||||||
|
|
||||||
|
Initial commands:
|
||||||
|
|
||||||
|
- `rrt-cli runtime validate-fixture <fixture.json>`
|
||||||
|
- `rrt-cli runtime summarize-fixture <fixture.json>`
|
||||||
|
- `rrt-cli runtime diff-state <left.json> <right.json>`
|
||||||
|
|
||||||
|
These commands do not require a complete runtime implementation yet. They only need to parse,
|
||||||
|
normalize, and summarize.
|
||||||
|
|
||||||
|
### Proposed fixture shape
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"fixture_id": "minimal-world-step-smoke",
|
||||||
|
"source": {
|
||||||
|
"kind": "synthetic"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 0
|
||||||
|
},
|
||||||
|
"world_flags": {},
|
||||||
|
"companies": [],
|
||||||
|
"event_runtime_records": []
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"kind": "advance_to",
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 1,
|
||||||
|
"tick_slot": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected_summary": {
|
||||||
|
"calendar_year": 1830
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Milestone 0 task list
|
||||||
|
|
||||||
|
1. Add the two new workspace crates.
|
||||||
|
2. Add serde-backed fixture schema types.
|
||||||
|
3. Add a small summary model for runtime fixtures.
|
||||||
|
4. Extend `rrt-cli` parsing with the `runtime` command family.
|
||||||
|
5. Check in one synthetic fixture under a new tracked fixtures directory.
|
||||||
|
6. Add tests for fixture parsing and normalization.
|
||||||
|
|
||||||
|
## Milestone 1 Specifics
|
||||||
|
|
||||||
|
This milestone is the first real execution step.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
Implement only enough runtime to support:
|
||||||
|
|
||||||
|
- calendar-point representation
|
||||||
|
- comparison and ordering
|
||||||
|
- `advance_to` over a reduced world state
|
||||||
|
- summary and diff output
|
||||||
|
|
||||||
|
Do not yet model:
|
||||||
|
|
||||||
|
- shell-facing frame accumulators
|
||||||
|
- input
|
||||||
|
- camera or cursor state
|
||||||
|
- shell windows
|
||||||
|
- full company, site, or cargo behavior
|
||||||
|
|
||||||
|
### Proposed runtime model
|
||||||
|
|
||||||
|
`CalendarPoint`
|
||||||
|
|
||||||
|
- year
|
||||||
|
- month_slot
|
||||||
|
- phase_slot
|
||||||
|
- tick_slot
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
|
||||||
|
- ordering and comparison
|
||||||
|
- step forward one minimal unit
|
||||||
|
- convert to and from normalized serialized form
|
||||||
|
|
||||||
|
`RuntimeState`
|
||||||
|
|
||||||
|
- current calendar point
|
||||||
|
- selected year or absolute calendar scalar if needed
|
||||||
|
- minimal world counters
|
||||||
|
- optional minimal event-service scratch state
|
||||||
|
|
||||||
|
`StepCommand`
|
||||||
|
|
||||||
|
- `AdvanceTo(CalendarPoint)`
|
||||||
|
- `StepCount(u32)`
|
||||||
|
|
||||||
|
`StepResult`
|
||||||
|
|
||||||
|
- start summary
|
||||||
|
- end summary
|
||||||
|
- steps_executed
|
||||||
|
- boundary_events
|
||||||
|
|
||||||
|
### Milestone 1 execution API
|
||||||
|
|
||||||
|
Suggested Rust signature:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn execute_step_command(
|
||||||
|
state: &mut RuntimeState,
|
||||||
|
command: StepCommand,
|
||||||
|
) -> StepResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally this should call a narrow helper shaped like:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn advance_to_target_calendar_point(
|
||||||
|
state: &mut RuntimeState,
|
||||||
|
target: CalendarPoint,
|
||||||
|
) -> StepResult
|
||||||
|
```
|
||||||
|
|
||||||
|
### Milestone 1 fixture set
|
||||||
|
|
||||||
|
Add at least these fixtures:
|
||||||
|
|
||||||
|
1. `minimal-world-advance-one-phase`
|
||||||
|
2. `minimal-world-advance-multiple-phases`
|
||||||
|
3. `minimal-world-step-count`
|
||||||
|
4. `minimal-world-repeatability`
|
||||||
|
|
||||||
|
### Milestone 1 verification
|
||||||
|
|
||||||
|
Required checks:
|
||||||
|
|
||||||
|
- repeated runs produce byte-identical normalized summaries
|
||||||
|
- target calendar point is reached exactly
|
||||||
|
- step count matches expected traversal
|
||||||
|
- backward targets fail cleanly or no-op according to chosen policy
|
||||||
|
|
||||||
|
### Milestone 1 task list
|
||||||
|
|
||||||
|
1. Implement `CalendarPoint`.
|
||||||
|
2. Implement reduced `RuntimeState`.
|
||||||
|
3. Implement `advance_to_target_calendar_point`.
|
||||||
|
4. Implement CLI execution for one fixture command list.
|
||||||
|
5. Emit normalized summaries after execution.
|
||||||
|
6. Add deterministic regression tests for the initial fixtures.
|
||||||
|
|
||||||
|
## Immediate Next Actions
|
||||||
|
|
||||||
|
After this document lands, the recommended first implementation sequence is:
|
||||||
|
|
||||||
|
1. add `rrt-runtime` and `rrt-fixtures`
|
||||||
|
2. extend `rrt-cli` with `runtime validate-fixture`
|
||||||
|
3. add one synthetic fixture
|
||||||
|
4. implement `CalendarPoint`
|
||||||
|
5. implement one narrow `advance_to` path
|
||||||
|
|
||||||
|
That sequence gives the project a headless execution backbone without needing shell recovery first.
|
||||||
32
fixtures/runtime/minimal-world-raw-state.json
Normal file
32
fixtures/runtime/minimal-world-raw-state.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"dump_id": "minimal-world-raw-state",
|
||||||
|
"source": {
|
||||||
|
"description": "Raw runtime state dump equivalent to the minimal world smoke setup."
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 0
|
||||||
|
},
|
||||||
|
"world_flags": {
|
||||||
|
"sandbox": false
|
||||||
|
},
|
||||||
|
"companies": [
|
||||||
|
{
|
||||||
|
"company_id": 1,
|
||||||
|
"current_cash": 250000,
|
||||||
|
"debt": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_runtime_records": [],
|
||||||
|
"service_state": {
|
||||||
|
"periodic_boundary_calls": 0,
|
||||||
|
"trigger_dispatch_counts": {},
|
||||||
|
"total_event_record_services": 0,
|
||||||
|
"dirty_rerun_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
fixtures/runtime/minimal-world-step-from-snapshot.json
Normal file
31
fixtures/runtime/minimal-world-step-from-snapshot.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"fixture_id": "minimal-world-step-from-snapshot",
|
||||||
|
"source": {
|
||||||
|
"kind": "captured-runtime",
|
||||||
|
"description": "Fixture backed by a normalized runtime snapshot rather than inline state."
|
||||||
|
},
|
||||||
|
"state_snapshot_path": "minimal-world-step-smoke-final-state.json",
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"kind": "step_count",
|
||||||
|
"steps": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected_summary": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 6
|
||||||
|
},
|
||||||
|
"world_flag_count": 1,
|
||||||
|
"company_count": 1,
|
||||||
|
"event_runtime_record_count": 0,
|
||||||
|
"total_event_record_service_count": 0,
|
||||||
|
"periodic_boundary_call_count": 0,
|
||||||
|
"total_trigger_dispatch_count": 0,
|
||||||
|
"dirty_rerun_count": 0,
|
||||||
|
"total_company_cash": 250000
|
||||||
|
}
|
||||||
|
}
|
||||||
33
fixtures/runtime/minimal-world-step-smoke-final-state.json
Normal file
33
fixtures/runtime/minimal-world-step-smoke-final-state.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"snapshot_id": "minimal-world-step-smoke-final-state",
|
||||||
|
"source": {
|
||||||
|
"source_fixture_id": "minimal-world-step-smoke",
|
||||||
|
"description": "Normalized final runtime state exported from the minimal-world-step-smoke fixture."
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 5
|
||||||
|
},
|
||||||
|
"world_flags": {
|
||||||
|
"sandbox": false
|
||||||
|
},
|
||||||
|
"companies": [
|
||||||
|
{
|
||||||
|
"company_id": 1,
|
||||||
|
"current_cash": 250000,
|
||||||
|
"debt": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_runtime_records": [],
|
||||||
|
"service_state": {
|
||||||
|
"periodic_boundary_calls": 0,
|
||||||
|
"trigger_dispatch_counts": {},
|
||||||
|
"total_event_record_services": 0,
|
||||||
|
"dirty_rerun_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
fixtures/runtime/minimal-world-step-smoke.json
Normal file
54
fixtures/runtime/minimal-world-step-smoke.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"fixture_id": "minimal-world-step-smoke",
|
||||||
|
"source": {
|
||||||
|
"kind": "synthetic",
|
||||||
|
"description": "Synthetic milestone 0/1 smoke fixture for headless runtime parsing and stepping."
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 0
|
||||||
|
},
|
||||||
|
"world_flags": {
|
||||||
|
"sandbox": false
|
||||||
|
},
|
||||||
|
"companies": [
|
||||||
|
{
|
||||||
|
"company_id": 1,
|
||||||
|
"current_cash": 250000,
|
||||||
|
"debt": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_runtime_records": []
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"kind": "advance_to",
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "step_count",
|
||||||
|
"steps": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected_summary": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 5
|
||||||
|
},
|
||||||
|
"world_flag_count": 1,
|
||||||
|
"company_count": 1,
|
||||||
|
"event_runtime_record_count": 0,
|
||||||
|
"total_company_cash": 250000
|
||||||
|
}
|
||||||
|
}
|
||||||
59
fixtures/runtime/periodic-boundary-service-smoke.json
Normal file
59
fixtures/runtime/periodic-boundary-service-smoke.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"fixture_id": "periodic-boundary-service-smoke",
|
||||||
|
"source": {
|
||||||
|
"kind": "synthetic",
|
||||||
|
"description": "Synthetic milestone 2 fixture covering periodic trigger order and dirty rerun dispatch."
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 0
|
||||||
|
},
|
||||||
|
"world_flags": {
|
||||||
|
"sandbox": false
|
||||||
|
},
|
||||||
|
"companies": [],
|
||||||
|
"event_runtime_records": [
|
||||||
|
{
|
||||||
|
"record_id": 1,
|
||||||
|
"trigger_kind": 1,
|
||||||
|
"active": true,
|
||||||
|
"marks_collection_dirty": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": 2,
|
||||||
|
"trigger_kind": 4,
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_id": 3,
|
||||||
|
"trigger_kind": 10,
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"kind": "service_periodic_boundary"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expected_summary": {
|
||||||
|
"calendar": {
|
||||||
|
"year": 1830,
|
||||||
|
"month_slot": 0,
|
||||||
|
"phase_slot": 0,
|
||||||
|
"tick_slot": 0
|
||||||
|
},
|
||||||
|
"world_flag_count": 1,
|
||||||
|
"company_count": 0,
|
||||||
|
"event_runtime_record_count": 3,
|
||||||
|
"total_event_record_service_count": 3,
|
||||||
|
"periodic_boundary_call_count": 1,
|
||||||
|
"total_trigger_dispatch_count": 7,
|
||||||
|
"dirty_rerun_count": 1,
|
||||||
|
"total_company_cash": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ export WINEPREFIX="$repo_root/rt3_wineprefix"
|
||||||
export WINEDLLOVERRIDES="dinput8=n,b"
|
export WINEDLLOVERRIDES="dinput8=n,b"
|
||||||
|
|
||||||
cmd=(/opt/wine-stable/bin/winedbg)
|
cmd=(/opt/wine-stable/bin/winedbg)
|
||||||
cmd_file="${RRT_WINEDBG_CMD_FILE:-$repo_root/tools/winedbg_auto_load_compare.cmd}"
|
cmd_file="${RRT_WINEDBG_CMD_FILE:-$repo_root/tools/winedbg_auto_load_crash.cmd}"
|
||||||
if [[ -n "$cmd_file" ]]; then
|
if [[ -n "$cmd_file" ]]; then
|
||||||
cmd+=(--file "$cmd_file")
|
cmd+=(--file "$cmd_file")
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
7
tools/run_hook_auto_load_winedbg_compare.sh
Executable file
7
tools/run_hook_auto_load_winedbg_compare.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
export RRT_WINEDBG_CMD_FILE="${RRT_WINEDBG_CMD_FILE:-$repo_root/tools/winedbg_auto_load_compare.cmd}"
|
||||||
|
|
||||||
|
exec "$repo_root/tools/run_hook_auto_load_winedbg.sh" "${1:-hh}"
|
||||||
|
|
@ -1,43 +1,25 @@
|
||||||
break *0x00438890
|
break *0x00438890
|
||||||
break *0x004390cb
|
break *0x004390cb
|
||||||
break *0x00445ac0
|
break *0x00445ac0
|
||||||
break *0x0053fea6
|
cont
|
||||||
cont
|
info reg
|
||||||
info reg
|
print/x *(unsigned int*)($esp)
|
||||||
print/x *(unsigned int*)($esp)
|
print/x *(unsigned int*)($esp+4)
|
||||||
print/x *(unsigned int*)($esp+4)
|
print/x *(unsigned int*)($esp+8)
|
||||||
print/x *(unsigned int*)($esp+8)
|
print/x *(unsigned int*)($esp+12)
|
||||||
print/x *(unsigned int*)($esp+12)
|
print/x *(unsigned int*)0x006cec74
|
||||||
print/x *(unsigned int*)0x006cec74
|
print/x *(unsigned int*)0x006cec7c
|
||||||
print/x *(unsigned int*)0x006cec7c
|
print/x *(unsigned int*)0x006cec78
|
||||||
print/x *(unsigned int*)0x006cec78
|
print/x *(unsigned int*)0x006ce9b8
|
||||||
print/x *(unsigned int*)0x006ce9b8
|
print/x *(unsigned int*)0x006ce9bc
|
||||||
print/x *(unsigned int*)0x006ce9bc
|
print/x *(unsigned int*)0x006ce9c0
|
||||||
print/x *(unsigned int*)0x006ce9c0
|
print/x *(unsigned int*)0x006ce9c4
|
||||||
print/x *(unsigned int*)0x006ce9c4
|
print/x *(unsigned int*)0x006d1270
|
||||||
print/x *(unsigned int*)0x006d1270
|
print/x *(unsigned int*)0x006d1274
|
||||||
print/x *(unsigned int*)0x006d1274
|
print/x *(unsigned int*)0x006d1278
|
||||||
print/x *(unsigned int*)0x006d1278
|
print/x *(unsigned int*)0x006d127c
|
||||||
print/x *(unsigned int*)0x006d127c
|
bt
|
||||||
bt
|
cont
|
||||||
cont
|
|
||||||
info reg
|
|
||||||
print/x *(unsigned int*)($esp)
|
|
||||||
print/x *(unsigned int*)($esp+4)
|
|
||||||
print/x *(unsigned int*)($esp+8)
|
|
||||||
print/x *(unsigned int*)($esp+12)
|
|
||||||
print/x *(unsigned int*)0x006cec74
|
|
||||||
print/x *(unsigned int*)0x006cec7c
|
|
||||||
print/x *(unsigned int*)0x006cec78
|
|
||||||
print/x *(unsigned int*)0x006ce9b8
|
|
||||||
print/x *(unsigned int*)0x006ce9bc
|
|
||||||
print/x *(unsigned int*)0x006ce9c0
|
|
||||||
print/x *(unsigned int*)0x006ce9c4
|
|
||||||
print/x *(unsigned int*)0x006d1270
|
|
||||||
print/x *(unsigned int*)0x006d1274
|
|
||||||
print/x *(unsigned int*)0x006d1278
|
|
||||||
print/x *(unsigned int*)0x006d127c
|
|
||||||
bt
|
|
||||||
cont
|
cont
|
||||||
info reg
|
info reg
|
||||||
print/x *(unsigned int*)($esp)
|
print/x *(unsigned int*)($esp)
|
||||||
|
|
|
||||||
7
tools/winedbg_auto_load_crash.cmd
Normal file
7
tools/winedbg_auto_load_crash.cmd
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
cont
|
||||||
|
info reg
|
||||||
|
print/x *(unsigned int*)($esp)
|
||||||
|
print/x *(unsigned int*)($esp+4)
|
||||||
|
print/x *(unsigned int*)($esp+8)
|
||||||
|
print/x *(unsigned int*)($esp+12)
|
||||||
|
bt
|
||||||
Loading…
Add table
Add a link
Reference in a new issue