diff --git a/Cargo.lock b/Cargo.lock index 690d0a4..a41a033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,12 +117,23 @@ dependencies = [ name = "rrt-cli" version = "0.1.0" dependencies = [ + "rrt-fixtures", "rrt-model", + "rrt-runtime", "serde", "serde_json", "sha2", ] +[[package]] +name = "rrt-fixtures" +version = "0.1.0" +dependencies = [ + "rrt-runtime", + "serde", + "serde_json", +] + [[package]] name = "rrt-hook" version = "0.1.0" @@ -141,6 +152,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rrt-runtime" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 9dadc6e..0551609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ members = [ "crates/rrt-model", "crates/rrt-cli", "crates/rrt-hook", + "crates/rrt-runtime", + "crates/rrt-fixtures", ] resolver = "3" diff --git a/artifacts/exports/rt3-1.06/function-map.csv b/artifacts/exports/rt3-1.06/function-map.csv index ab808c9..e1a5e14 100644 --- a/artifacts/exports/rt3-1.06/function-map.csv +++ b/artifacts/exports/rt3-1.06/function-map.csv @@ -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" 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" +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" 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" @@ -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 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 -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 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 @@ -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" 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" +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 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 @@ -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 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 +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 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" diff --git a/crates/rrt-cli/Cargo.toml b/crates/rrt-cli/Cargo.toml index d9bb5fe..2525d47 100644 --- a/crates/rrt-cli/Cargo.toml +++ b/crates/rrt-cli/Cargo.toml @@ -5,7 +5,9 @@ edition.workspace = true license.workspace = true [dependencies] +rrt-fixtures = { path = "../rrt-fixtures" } rrt-model = { path = "../rrt-model" } +rrt-runtime = { path = "../rrt-runtime" } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 5f94c52..4cce1b7 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -1,23 +1,96 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; +use rrt_fixtures::{FixtureValidationReport, load_fixture_document, validate_fixture_document}; use rrt_model::{ BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH, REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS, finance::{FinanceOutcome, FinanceSnapshot}, load_binary_summary, load_function_map, }; +use rrt_runtime::{ + CAMPAIGN_SCENARIO_COUNT, CampaignExeInspectionReport, OBSERVED_CAMPAIGN_SCENARIO_NAMES, + Pk4ExtractionReport, Pk4InspectionReport, RuntimeSnapshotDocument, RuntimeSnapshotSource, + RuntimeSummary, SNAPSHOT_FORMAT_VERSION, SmpClassicPackedProfileBlock, SmpInspectionReport, + SmpRt3105PackedProfileBlock, WinInspectionReport, execute_step_command, extract_pk4_entry_file, + inspect_campaign_exe_file, inspect_pk4_file, inspect_smp_file, inspect_win_file, + load_runtime_snapshot_document, load_runtime_state_import, save_runtime_snapshot_document, + validate_runtime_snapshot_document, +}; use serde::Serialize; use serde_json::Value; use sha2::{Digest, Sha256}; enum Command { - Validate { repo_root: PathBuf }, - FinanceEval { snapshot_path: PathBuf }, - FinanceDiff { left_path: PathBuf, right_path: PathBuf }, + Validate { + repo_root: PathBuf, + }, + FinanceEval { + snapshot_path: PathBuf, + }, + FinanceDiff { + left_path: PathBuf, + right_path: PathBuf, + }, + RuntimeValidateFixture { + fixture_path: PathBuf, + }, + RuntimeSummarizeFixture { + fixture_path: PathBuf, + }, + RuntimeExportFixtureState { + fixture_path: PathBuf, + output_path: PathBuf, + }, + RuntimeSummarizeState { + snapshot_path: PathBuf, + }, + RuntimeImportState { + input_path: PathBuf, + output_path: PathBuf, + }, + RuntimeInspectSmp { + smp_path: PathBuf, + }, + RuntimeInspectPk4 { + pk4_path: PathBuf, + }, + RuntimeInspectWin { + win_path: PathBuf, + }, + RuntimeExtractPk4Entry { + pk4_path: PathBuf, + entry_name: String, + output_path: PathBuf, + }, + RuntimeInspectCampaignExe { + exe_path: PathBuf, + }, + RuntimeCompareClassicProfile { + smp_paths: Vec, + }, + RuntimeCompareRt3105Profile { + smp_paths: Vec, + }, + RuntimeCompareCandidateTable { + smp_paths: Vec, + }, + RuntimeCompareSetupPayloadCore { + smp_paths: Vec, + }, + RuntimeCompareSetupLaunchPayload { + smp_paths: Vec, + }, + RuntimeScanCandidateTableHeaders { + root_path: PathBuf, + }, + RuntimeExportProfileBlock { + smp_path: PathBuf, + output_path: PathBuf, + }, } #[derive(Debug, Serialize)] @@ -34,6 +107,244 @@ struct FinanceDiffReport { differences: Vec, } +#[derive(Debug, Serialize)] +struct RuntimeFixtureSummaryReport { + fixture_id: String, + command_count: usize, + final_summary: RuntimeSummary, + expected_summary_matches: bool, + expected_summary_mismatches: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeStateSummaryReport { + snapshot_id: String, + summary: RuntimeSummary, +} + +#[derive(Debug, Serialize)] +struct RuntimeSmpInspectionOutput { + path: String, + inspection: SmpInspectionReport, +} + +#[derive(Debug, Serialize)] +struct RuntimePk4InspectionOutput { + path: String, + inspection: Pk4InspectionReport, +} + +#[derive(Debug, Serialize)] +struct RuntimeWinInspectionOutput { + path: String, + inspection: WinInspectionReport, +} + +#[derive(Debug, Serialize)] +struct RuntimePk4ExtractionOutput { + path: String, + output_path: String, + extraction: Pk4ExtractionReport, +} + +#[derive(Debug, Serialize)] +struct RuntimeCampaignExeInspectionOutput { + path: String, + inspection: CampaignExeInspectionReport, +} + +#[derive(Debug, Clone, Serialize)] +struct RuntimeClassicProfileSample { + path: String, + profile_family: String, + progress_32dc_offset: usize, + progress_3714_offset: usize, + progress_3715_offset: usize, + packed_profile_offset: usize, + packed_profile_len: usize, + packed_profile_block: SmpClassicPackedProfileBlock, +} + +#[derive(Debug, Clone, Serialize)] +struct RuntimeClassicProfileDifferenceValue { + path: String, + value: Value, +} + +#[derive(Debug, Clone, Serialize)] +struct RuntimeClassicProfileDifference { + field_path: String, + values: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeClassicProfileComparisonReport { + file_count: usize, + matches: bool, + common_profile_family: Option, + samples: Vec, + difference_count: usize, + differences: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct RuntimeRt3105ProfileSample { + path: String, + profile_family: String, + packed_profile_offset: usize, + packed_profile_len: usize, + packed_profile_block: SmpRt3105PackedProfileBlock, +} + +#[derive(Debug, Serialize)] +struct RuntimeRt3105ProfileComparisonReport { + file_count: usize, + matches: bool, + common_profile_family: Option, + samples: Vec, + difference_count: usize, + differences: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeCandidateTableSample { + path: String, + profile_family: String, + source_kind: String, + semantic_family: String, + header_word_0_hex: String, + header_word_1_hex: String, + header_word_2_hex: String, + observed_entry_count: usize, + zero_trailer_entry_count: usize, + nonzero_trailer_entry_count: usize, + zero_trailer_entry_names: Vec, + footer_progress_word_0_hex: String, + footer_progress_word_1_hex: String, + availability_by_name: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct RuntimeCandidateTableComparisonReport { + file_count: usize, + matches: bool, + common_profile_family: Option, + common_semantic_family: Option, + samples: Vec, + difference_count: usize, + differences: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeSetupPayloadCoreSample { + path: String, + file_extension: String, + inferred_profile_family: String, + payload_word_0x14: u16, + payload_word_0x14_hex: String, + payload_byte_0x20: u8, + payload_byte_0x20_hex: String, + marker_bytes_0x2c9_0x2d0_hex: String, + row_category_byte_0x31a: u8, + row_category_byte_0x31a_hex: String, + row_visibility_byte_0x31b: u8, + row_visibility_byte_0x31b_hex: String, + row_visibility_byte_0x31c: u8, + row_visibility_byte_0x31c_hex: String, + row_count_word_0x3ae: u16, + row_count_word_0x3ae_hex: String, + payload_word_0x3b2: u16, + payload_word_0x3b2_hex: String, + payload_word_0x3ba: u16, + payload_word_0x3ba_hex: String, + candidate_header_word_0_hex: Option, + candidate_header_word_1_hex: Option, +} + +#[derive(Debug, Serialize)] +struct RuntimeSetupPayloadCoreComparisonReport { + file_count: usize, + matches: bool, + samples: Vec, + difference_count: usize, + differences: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeSetupLaunchPayloadSample { + path: String, + file_extension: String, + inferred_profile_family: String, + launch_flag_byte_0x22: u8, + launch_flag_byte_0x22_hex: String, + campaign_progress_in_known_range: bool, + campaign_progress_scenario_name: Option, + campaign_progress_page_index: Option, + launch_selector_byte_0x33: u8, + launch_selector_byte_0x33_hex: String, + launch_token_block_0x23_0x32_hex: String, + campaign_selector_values: BTreeMap, + nonzero_campaign_selector_values: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct RuntimeSetupLaunchPayloadComparisonReport { + file_count: usize, + matches: bool, + samples: Vec, + difference_count: usize, + differences: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeCandidateTableHeaderCluster { + header_word_0_hex: String, + header_word_1_hex: String, + file_count: usize, + profile_families: Vec, + source_kinds: Vec, + zero_trailer_count_min: usize, + zero_trailer_count_max: usize, + zero_trailer_count_values: Vec, + distinct_zero_name_set_count: usize, + sample_paths: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeCandidateTableHeaderScanReport { + root_path: String, + file_count: usize, + cluster_count: usize, + skipped_file_count: usize, + clusters: Vec, +} + +#[derive(Debug, Clone)] +struct RuntimeCandidateTableHeaderScanSample { + path: String, + profile_family: String, + source_kind: String, + header_word_0_hex: String, + header_word_1_hex: String, + zero_trailer_entry_count: usize, + zero_trailer_entry_names: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeProfileBlockExportDocument { + source_path: String, + profile_kind: String, + profile_family: String, + payload: Value, +} + +#[derive(Debug, Serialize)] +struct RuntimeProfileBlockExportReport { + output_path: String, + profile_kind: String, + profile_family: String, +} + fn main() { if let Err(err) = real_main() { eprintln!("error: {err}"); @@ -59,36 +370,226 @@ fn real_main() -> Result<(), Box> { } => { run_finance_diff(&left_path, &right_path)?; } + Command::RuntimeValidateFixture { fixture_path } => { + run_runtime_validate_fixture(&fixture_path)?; + } + Command::RuntimeSummarizeFixture { fixture_path } => { + run_runtime_summarize_fixture(&fixture_path)?; + } + Command::RuntimeExportFixtureState { + fixture_path, + output_path, + } => { + run_runtime_export_fixture_state(&fixture_path, &output_path)?; + } + Command::RuntimeSummarizeState { snapshot_path } => { + run_runtime_summarize_state(&snapshot_path)?; + } + Command::RuntimeImportState { + input_path, + output_path, + } => { + run_runtime_import_state(&input_path, &output_path)?; + } + Command::RuntimeInspectSmp { smp_path } => { + run_runtime_inspect_smp(&smp_path)?; + } + Command::RuntimeInspectPk4 { pk4_path } => { + run_runtime_inspect_pk4(&pk4_path)?; + } + Command::RuntimeInspectWin { win_path } => { + run_runtime_inspect_win(&win_path)?; + } + Command::RuntimeExtractPk4Entry { + pk4_path, + entry_name, + output_path, + } => { + run_runtime_extract_pk4_entry(&pk4_path, &entry_name, &output_path)?; + } + Command::RuntimeInspectCampaignExe { exe_path } => { + run_runtime_inspect_campaign_exe(&exe_path)?; + } + Command::RuntimeCompareClassicProfile { smp_paths } => { + run_runtime_compare_classic_profile(&smp_paths)?; + } + Command::RuntimeCompareRt3105Profile { smp_paths } => { + run_runtime_compare_rt3_105_profile(&smp_paths)?; + } + Command::RuntimeCompareCandidateTable { smp_paths } => { + run_runtime_compare_candidate_table(&smp_paths)?; + } + Command::RuntimeCompareSetupPayloadCore { smp_paths } => { + run_runtime_compare_setup_payload_core(&smp_paths)?; + } + Command::RuntimeCompareSetupLaunchPayload { smp_paths } => { + run_runtime_compare_setup_launch_payload(&smp_paths)?; + } + Command::RuntimeScanCandidateTableHeaders { root_path } => { + run_runtime_scan_candidate_table_headers(&root_path)?; + } + Command::RuntimeExportProfileBlock { + smp_path, + output_path, + } => { + run_runtime_export_profile_block(&smp_path, &output_path)?; + } } Ok(()) } fn parse_command() -> Result> { - let mut args = env::args().skip(1); - match (args.next().as_deref(), args.next(), args.next(), args.next()) { - (None, None, None, None) => Ok(Command::Validate { + let args: Vec = env::args().skip(1).collect(); + match args.as_slice() { + [] => Ok(Command::Validate { repo_root: env::current_dir()?, }), - (Some("validate"), None, None, None) => Ok(Command::Validate { + [command] if command == "validate" => Ok(Command::Validate { repo_root: env::current_dir()?, }), - (Some("validate"), Some(path), None, None) => Ok(Command::Validate { + [command, path] if command == "validate" => Ok(Command::Validate { repo_root: PathBuf::from(path), }), - (Some("finance"), Some(subcommand), Some(path), None) if subcommand == "eval" => { + [command, subcommand, path] if command == "finance" && subcommand == "eval" => { Ok(Command::FinanceEval { snapshot_path: PathBuf::from(path), }) } - (Some("finance"), Some(subcommand), Some(left), Some(right)) if subcommand == "diff" => { + [command, subcommand, left, right] if command == "finance" && subcommand == "diff" => { Ok(Command::FinanceDiff { left_path: PathBuf::from(left), right_path: PathBuf::from(right), }) } + [command, subcommand, path] + if command == "runtime" && subcommand == "validate-fixture" => + { + Ok(Command::RuntimeValidateFixture { + fixture_path: PathBuf::from(path), + }) + } + [command, subcommand, path] + if command == "runtime" && subcommand == "summarize-fixture" => + { + Ok(Command::RuntimeSummarizeFixture { + fixture_path: PathBuf::from(path), + }) + } + [command, subcommand, fixture_path, output_path] + if command == "runtime" && subcommand == "export-fixture-state" => + { + Ok(Command::RuntimeExportFixtureState { + fixture_path: PathBuf::from(fixture_path), + output_path: PathBuf::from(output_path), + }) + } + [command, subcommand, path] if command == "runtime" && subcommand == "summarize-state" => { + Ok(Command::RuntimeSummarizeState { + snapshot_path: PathBuf::from(path), + }) + } + [command, subcommand, input_path, output_path] + if command == "runtime" && subcommand == "import-state" => + { + Ok(Command::RuntimeImportState { + input_path: PathBuf::from(input_path), + output_path: PathBuf::from(output_path), + }) + } + [command, subcommand, path] if command == "runtime" && subcommand == "inspect-smp" => { + Ok(Command::RuntimeInspectSmp { + smp_path: PathBuf::from(path), + }) + } + [command, subcommand, path] if command == "runtime" && subcommand == "inspect-pk4" => { + Ok(Command::RuntimeInspectPk4 { + pk4_path: PathBuf::from(path), + }) + } + [command, subcommand, path] if command == "runtime" && subcommand == "inspect-win" => { + Ok(Command::RuntimeInspectWin { + win_path: PathBuf::from(path), + }) + } + [command, subcommand, pk4_path, entry_name, output_path] + if command == "runtime" && subcommand == "extract-pk4-entry" => + { + Ok(Command::RuntimeExtractPk4Entry { + pk4_path: PathBuf::from(pk4_path), + entry_name: entry_name.clone(), + output_path: PathBuf::from(output_path), + }) + } + [command, subcommand, path] + if command == "runtime" && subcommand == "inspect-campaign-exe" => + { + Ok(Command::RuntimeInspectCampaignExe { + exe_path: PathBuf::from(path), + }) + } + [command, subcommand, smp_paths @ ..] + if command == "runtime" + && subcommand == "compare-classic-profile" + && smp_paths.len() >= 2 => + { + Ok(Command::RuntimeCompareClassicProfile { + smp_paths: smp_paths.iter().map(PathBuf::from).collect(), + }) + } + [command, subcommand, smp_paths @ ..] + if command == "runtime" + && subcommand == "compare-105-profile" + && smp_paths.len() >= 2 => + { + Ok(Command::RuntimeCompareRt3105Profile { + smp_paths: smp_paths.iter().map(PathBuf::from).collect(), + }) + } + [command, subcommand, smp_paths @ ..] + if command == "runtime" + && subcommand == "compare-candidate-table" + && smp_paths.len() >= 2 => + { + Ok(Command::RuntimeCompareCandidateTable { + smp_paths: smp_paths.iter().map(PathBuf::from).collect(), + }) + } + [command, subcommand, smp_paths @ ..] + if command == "runtime" + && subcommand == "compare-setup-payload-core" + && smp_paths.len() >= 2 => + { + Ok(Command::RuntimeCompareSetupPayloadCore { + smp_paths: smp_paths.iter().map(PathBuf::from).collect(), + }) + } + [command, subcommand, smp_paths @ ..] + if command == "runtime" + && subcommand == "compare-setup-launch-payload" + && smp_paths.len() >= 2 => + { + Ok(Command::RuntimeCompareSetupLaunchPayload { + smp_paths: smp_paths.iter().map(PathBuf::from).collect(), + }) + } + [command, subcommand, root_path] + if command == "runtime" && subcommand == "scan-candidate-table-headers" => + { + Ok(Command::RuntimeScanCandidateTableHeaders { + root_path: PathBuf::from(root_path), + }) + } + [command, subcommand, smp_path, output_path] + if command == "runtime" && subcommand == "export-profile-block" => + { + Ok(Command::RuntimeExportProfileBlock { + smp_path: PathBuf::from(smp_path), + output_path: PathBuf::from(output_path), + }) + } _ => Err( - "usage: rrt-cli [validate [repo-root] | finance eval | finance diff ]" + "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime scan-candidate-table-headers | runtime export-profile-block ]" .into(), ), } @@ -100,10 +601,7 @@ fn run_finance_eval(snapshot_path: &Path) -> Result<(), Box Result<(), Box> { +fn run_finance_diff(left_path: &Path, right_path: &Path) -> Result<(), Box> { let left = load_finance_outcome(left_path)?; let right = load_finance_outcome(right_path)?; let report = diff_finance_outcomes(&left, &right)?; @@ -111,6 +609,1184 @@ fn run_finance_diff( Ok(()) } +fn run_runtime_validate_fixture(fixture_path: &Path) -> Result<(), Box> { + let fixture = load_fixture_document(fixture_path)?; + let report = validate_fixture_document(&fixture); + print_runtime_validation_report(&report)?; + if !report.valid { + return Err(format!("fixture validation failed for {}", fixture_path.display()).into()); + } + Ok(()) +} + +fn run_runtime_summarize_fixture(fixture_path: &Path) -> Result<(), Box> { + let fixture = load_fixture_document(fixture_path)?; + let validation_report = validate_fixture_document(&fixture); + if !validation_report.valid { + print_runtime_validation_report(&validation_report)?; + return Err(format!("fixture validation failed for {}", fixture_path.display()).into()); + } + + let mut state = fixture.state.clone(); + for command in &fixture.commands { + execute_step_command(&mut state, command)?; + } + + let final_summary = RuntimeSummary::from_state(&state); + let mismatches = fixture.expected_summary.compare(&final_summary); + let report = RuntimeFixtureSummaryReport { + fixture_id: fixture.fixture_id, + command_count: fixture.commands.len(), + expected_summary_matches: mismatches.is_empty(), + expected_summary_mismatches: mismatches.clone(), + final_summary, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + + if !mismatches.is_empty() { + return Err(format!( + "fixture summary mismatched expected output: {}", + mismatches.join("; ") + ) + .into()); + } + + Ok(()) +} + +fn run_runtime_export_fixture_state( + fixture_path: &Path, + output_path: &Path, +) -> Result<(), Box> { + let fixture = load_fixture_document(fixture_path)?; + let validation_report = validate_fixture_document(&fixture); + if !validation_report.valid { + print_runtime_validation_report(&validation_report)?; + return Err(format!("fixture validation failed for {}", fixture_path.display()).into()); + } + + let mut state = fixture.state.clone(); + for command in &fixture.commands { + execute_step_command(&mut state, command)?; + } + + let snapshot = RuntimeSnapshotDocument { + format_version: SNAPSHOT_FORMAT_VERSION, + snapshot_id: format!("{}-final-state", fixture.fixture_id), + source: RuntimeSnapshotSource { + source_fixture_id: Some(fixture.fixture_id.clone()), + description: Some(format!( + "Exported final runtime state for fixture {}", + fixture.fixture_id + )), + }, + state, + }; + save_runtime_snapshot_document(output_path, &snapshot)?; + let summary = snapshot.summary(); + + println!( + "{}", + serde_json::to_string_pretty(&RuntimeStateSummaryReport { + snapshot_id: snapshot.snapshot_id, + summary, + })? + ); + + Ok(()) +} + +fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box> { + let snapshot = load_runtime_snapshot_document(snapshot_path)?; + validate_runtime_snapshot_document(&snapshot) + .map_err(|err| format!("invalid runtime snapshot: {err}"))?; + let summary = snapshot.summary(); + let report = RuntimeStateSummaryReport { + snapshot_id: snapshot.snapshot_id, + summary, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_import_state( + input_path: &Path, + output_path: &Path, +) -> Result<(), Box> { + let import = load_runtime_state_import(input_path)?; + let snapshot = RuntimeSnapshotDocument { + format_version: SNAPSHOT_FORMAT_VERSION, + snapshot_id: format!("{}-snapshot", import.import_id), + source: RuntimeSnapshotSource { + source_fixture_id: None, + description: Some(match import.description { + Some(description) => format!( + "Imported runtime state from {} ({description})", + input_path.display() + ), + None => format!("Imported runtime state from {}", input_path.display()), + }), + }, + state: import.state, + }; + save_runtime_snapshot_document(output_path, &snapshot)?; + let summary = snapshot.summary(); + let report = RuntimeStateSummaryReport { + snapshot_id: snapshot.snapshot_id, + summary, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_inspect_smp(smp_path: &Path) -> Result<(), Box> { + let report = RuntimeSmpInspectionOutput { + path: smp_path.display().to_string(), + inspection: inspect_smp_file(smp_path)?, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_inspect_pk4(pk4_path: &Path) -> Result<(), Box> { + let report = RuntimePk4InspectionOutput { + path: pk4_path.display().to_string(), + inspection: inspect_pk4_file(pk4_path)?, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_inspect_win(win_path: &Path) -> Result<(), Box> { + let report = RuntimeWinInspectionOutput { + path: win_path.display().to_string(), + inspection: inspect_win_file(win_path)?, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_extract_pk4_entry( + pk4_path: &Path, + entry_name: &str, + output_path: &Path, +) -> Result<(), Box> { + let report = RuntimePk4ExtractionOutput { + path: pk4_path.display().to_string(), + output_path: output_path.display().to_string(), + extraction: extract_pk4_entry_file(pk4_path, entry_name, output_path)?, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_inspect_campaign_exe(exe_path: &Path) -> Result<(), Box> { + let report = RuntimeCampaignExeInspectionOutput { + path: exe_path.display().to_string(), + inspection: inspect_campaign_exe_file(exe_path)?, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_compare_classic_profile( + smp_paths: &[PathBuf], +) -> Result<(), Box> { + let samples = smp_paths + .iter() + .map(|path| load_classic_profile_sample(path)) + .collect::, _>>()?; + let common_profile_family = samples + .first() + .map(|sample| sample.profile_family.clone()) + .filter(|family| { + samples + .iter() + .all(|sample| sample.profile_family == *family) + }); + let differences = diff_classic_profile_samples(&samples)?; + let report = RuntimeClassicProfileComparisonReport { + file_count: samples.len(), + matches: differences.is_empty(), + common_profile_family, + difference_count: differences.len(), + differences, + samples, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_compare_rt3_105_profile( + smp_paths: &[PathBuf], +) -> Result<(), Box> { + let samples = smp_paths + .iter() + .map(|path| load_rt3_105_profile_sample(path)) + .collect::, _>>()?; + let common_profile_family = samples + .first() + .map(|sample| sample.profile_family.clone()) + .filter(|family| { + samples + .iter() + .all(|sample| sample.profile_family == *family) + }); + let differences = diff_rt3_105_profile_samples(&samples)?; + let report = RuntimeRt3105ProfileComparisonReport { + file_count: samples.len(), + matches: differences.is_empty(), + common_profile_family, + difference_count: differences.len(), + differences, + samples, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_compare_candidate_table( + smp_paths: &[PathBuf], +) -> Result<(), Box> { + let samples = smp_paths + .iter() + .map(|path| load_candidate_table_sample(path)) + .collect::, _>>()?; + let common_profile_family = samples + .first() + .map(|sample| sample.profile_family.clone()) + .filter(|family| { + samples + .iter() + .all(|sample| sample.profile_family == *family) + }); + let common_semantic_family = samples + .first() + .map(|sample| sample.semantic_family.clone()) + .filter(|family| { + samples + .iter() + .all(|sample| sample.semantic_family == *family) + }); + let differences = diff_candidate_table_samples(&samples)?; + let report = RuntimeCandidateTableComparisonReport { + file_count: samples.len(), + matches: differences.is_empty(), + common_profile_family, + common_semantic_family, + difference_count: differences.len(), + differences, + samples, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_compare_setup_payload_core( + smp_paths: &[PathBuf], +) -> Result<(), Box> { + let samples = smp_paths + .iter() + .map(|path| load_setup_payload_core_sample(path)) + .collect::, _>>()?; + let differences = diff_setup_payload_core_samples(&samples)?; + let report = RuntimeSetupPayloadCoreComparisonReport { + file_count: samples.len(), + matches: differences.is_empty(), + difference_count: differences.len(), + differences, + samples, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_compare_setup_launch_payload( + smp_paths: &[PathBuf], +) -> Result<(), Box> { + let samples = smp_paths + .iter() + .map(|path| load_setup_launch_payload_sample(path)) + .collect::, _>>()?; + let differences = diff_setup_launch_payload_samples(&samples)?; + let report = RuntimeSetupLaunchPayloadComparisonReport { + file_count: samples.len(), + matches: differences.is_empty(), + difference_count: differences.len(), + differences, + samples, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_scan_candidate_table_headers( + root_path: &Path, +) -> Result<(), Box> { + let mut candidate_paths = Vec::new(); + collect_candidate_table_input_paths(root_path, &mut candidate_paths)?; + + let mut samples = Vec::new(); + let mut skipped_file_count = 0usize; + for path in candidate_paths { + match load_candidate_table_header_scan_sample(&path) { + Ok(sample) => samples.push(sample), + Err(_) => skipped_file_count += 1, + } + } + + let mut grouped = + BTreeMap::<(String, String), Vec>::new(); + for sample in samples { + grouped + .entry(( + sample.header_word_0_hex.clone(), + sample.header_word_1_hex.clone(), + )) + .or_default() + .push(sample); + } + + let file_count = grouped.values().map(Vec::len).sum(); + let clusters = grouped + .into_iter() + .map(|((header_word_0_hex, header_word_1_hex), samples)| { + let mut profile_families = samples + .iter() + .map(|sample| sample.profile_family.clone()) + .collect::>() + .into_iter() + .collect::>(); + let mut source_kinds = samples + .iter() + .map(|sample| sample.source_kind.clone()) + .collect::>() + .into_iter() + .collect::>(); + let mut zero_trailer_count_values = samples + .iter() + .map(|sample| sample.zero_trailer_entry_count) + .collect::>() + .into_iter() + .collect::>(); + let distinct_zero_name_set_count = samples + .iter() + .map(|sample| sample.zero_trailer_entry_names.clone()) + .collect::>() + .len(); + let zero_trailer_count_min = samples + .iter() + .map(|sample| sample.zero_trailer_entry_count) + .min() + .unwrap_or(0); + let zero_trailer_count_max = samples + .iter() + .map(|sample| sample.zero_trailer_entry_count) + .max() + .unwrap_or(0); + let sample_paths = samples + .iter() + .take(12) + .map(|sample| sample.path.clone()) + .collect::>(); + profile_families.sort(); + source_kinds.sort(); + zero_trailer_count_values.sort(); + + RuntimeCandidateTableHeaderCluster { + header_word_0_hex, + header_word_1_hex, + file_count: samples.len(), + profile_families, + source_kinds, + zero_trailer_count_min, + zero_trailer_count_max, + zero_trailer_count_values, + distinct_zero_name_set_count, + sample_paths, + } + }) + .collect::>(); + + let report = RuntimeCandidateTableHeaderScanReport { + root_path: root_path.display().to_string(), + file_count, + cluster_count: clusters.len(), + skipped_file_count, + clusters, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_runtime_export_profile_block( + smp_path: &Path, + output_path: &Path, +) -> Result<(), Box> { + let inspection = inspect_smp_file(smp_path)?; + let document = build_profile_block_export_document(smp_path, &inspection)?; + let bytes = serde_json::to_vec_pretty(&document)?; + fs::write(output_path, bytes)?; + let report = RuntimeProfileBlockExportReport { + output_path: output_path.display().to_string(), + profile_kind: document.profile_kind, + profile_family: document.profile_family, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn load_classic_profile_sample( + smp_path: &Path, +) -> Result> { + let inspection = inspect_smp_file(smp_path)?; + let probe = inspection.classic_rehydrate_profile_probe.ok_or_else(|| { + format!( + "{} did not expose a classic rehydrate packed-profile block", + smp_path.display() + ) + })?; + + Ok(RuntimeClassicProfileSample { + path: smp_path.display().to_string(), + profile_family: probe.profile_family, + progress_32dc_offset: probe.progress_32dc_offset, + progress_3714_offset: probe.progress_3714_offset, + progress_3715_offset: probe.progress_3715_offset, + packed_profile_offset: probe.packed_profile_offset, + packed_profile_len: probe.packed_profile_len, + packed_profile_block: probe.packed_profile_block, + }) +} + +fn load_rt3_105_profile_sample( + smp_path: &Path, +) -> Result> { + let inspection = inspect_smp_file(smp_path)?; + let probe = inspection.rt3_105_packed_profile_probe.ok_or_else(|| { + format!( + "{} did not expose an RT3 1.05 packed-profile block", + smp_path.display() + ) + })?; + + Ok(RuntimeRt3105ProfileSample { + path: smp_path.display().to_string(), + profile_family: probe.profile_family, + packed_profile_offset: probe.packed_profile_offset, + packed_profile_len: probe.packed_profile_len, + packed_profile_block: probe.packed_profile_block, + }) +} + +fn load_candidate_table_sample( + smp_path: &Path, +) -> Result> { + let inspection = inspect_smp_file(smp_path)?; + let probe = inspection.rt3_105_save_name_table_probe.ok_or_else(|| { + format!( + "{} did not expose an RT3 1.05 candidate-availability table", + smp_path.display() + ) + })?; + + Ok(RuntimeCandidateTableSample { + path: smp_path.display().to_string(), + profile_family: probe.profile_family, + source_kind: probe.source_kind, + semantic_family: probe.semantic_family, + header_word_0_hex: probe.header_word_0_hex, + header_word_1_hex: probe.header_word_1_hex, + header_word_2_hex: probe.header_word_2_hex, + observed_entry_count: probe.observed_entry_count, + zero_trailer_entry_count: probe.zero_trailer_entry_count, + nonzero_trailer_entry_count: probe.nonzero_trailer_entry_count, + zero_trailer_entry_names: probe.zero_trailer_entry_names, + footer_progress_word_0_hex: probe.footer_progress_word_0_hex, + footer_progress_word_1_hex: probe.footer_progress_word_1_hex, + availability_by_name: probe + .entries + .into_iter() + .map(|entry| (entry.text, entry.availability_dword)) + .collect(), + }) +} + +fn load_setup_payload_core_sample( + smp_path: &Path, +) -> Result> { + let bytes = fs::read(smp_path)?; + let extension = smp_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .unwrap_or_default(); + let inferred_profile_family = + classify_candidate_table_header_profile(Some(extension.clone()), &bytes); + let candidate_header_word_0 = read_u32_le(&bytes, 0x6a70); + let candidate_header_word_1 = read_u32_le(&bytes, 0x6a74); + + Ok(RuntimeSetupPayloadCoreSample { + path: smp_path.display().to_string(), + file_extension: extension, + inferred_profile_family, + payload_word_0x14: read_u16_le(&bytes, 0x14) + .ok_or_else(|| format!("{} missing setup payload word +0x14", smp_path.display()))?, + payload_word_0x14_hex: format!( + "0x{:04x}", + read_u16_le(&bytes, 0x14).ok_or_else(|| format!( + "{} missing setup payload word +0x14", + smp_path.display() + ))? + ), + payload_byte_0x20: bytes + .get(0x20) + .copied() + .ok_or_else(|| format!("{} missing setup payload byte +0x20", smp_path.display()))?, + payload_byte_0x20_hex: format!( + "0x{:02x}", + bytes.get(0x20).copied().ok_or_else(|| format!( + "{} missing setup payload byte +0x20", + smp_path.display() + ))? + ), + marker_bytes_0x2c9_0x2d0_hex: bytes + .get(0x2c9..0x2d1) + .map(hex_encode) + .ok_or_else(|| format!("{} missing setup payload marker bytes", smp_path.display()))?, + row_category_byte_0x31a: bytes + .get(0x31a) + .copied() + .ok_or_else(|| format!("{} missing setup payload byte +0x31a", smp_path.display()))?, + row_category_byte_0x31a_hex: format!( + "0x{:02x}", + bytes.get(0x31a).copied().ok_or_else(|| format!( + "{} missing setup payload byte +0x31a", + smp_path.display() + ))? + ), + row_visibility_byte_0x31b: bytes + .get(0x31b) + .copied() + .ok_or_else(|| format!("{} missing setup payload byte +0x31b", smp_path.display()))?, + row_visibility_byte_0x31b_hex: format!( + "0x{:02x}", + bytes.get(0x31b).copied().ok_or_else(|| format!( + "{} missing setup payload byte +0x31b", + smp_path.display() + ))? + ), + row_visibility_byte_0x31c: bytes + .get(0x31c) + .copied() + .ok_or_else(|| format!("{} missing setup payload byte +0x31c", smp_path.display()))?, + row_visibility_byte_0x31c_hex: format!( + "0x{:02x}", + bytes.get(0x31c).copied().ok_or_else(|| format!( + "{} missing setup payload byte +0x31c", + smp_path.display() + ))? + ), + row_count_word_0x3ae: read_u16_le(&bytes, 0x3ae) + .ok_or_else(|| format!("{} missing setup payload word +0x3ae", smp_path.display()))?, + row_count_word_0x3ae_hex: format!( + "0x{:04x}", + read_u16_le(&bytes, 0x3ae).ok_or_else(|| format!( + "{} missing setup payload word +0x3ae", + smp_path.display() + ))? + ), + payload_word_0x3b2: read_u16_le(&bytes, 0x3b2) + .ok_or_else(|| format!("{} missing setup payload word +0x3b2", smp_path.display()))?, + payload_word_0x3b2_hex: format!( + "0x{:04x}", + read_u16_le(&bytes, 0x3b2).ok_or_else(|| format!( + "{} missing setup payload word +0x3b2", + smp_path.display() + ))? + ), + payload_word_0x3ba: read_u16_le(&bytes, 0x3ba) + .ok_or_else(|| format!("{} missing setup payload word +0x3ba", smp_path.display()))?, + payload_word_0x3ba_hex: format!( + "0x{:04x}", + read_u16_le(&bytes, 0x3ba).ok_or_else(|| format!( + "{} missing setup payload word +0x3ba", + smp_path.display() + ))? + ), + candidate_header_word_0_hex: candidate_header_word_0.map(|value| format!("0x{value:08x}")), + candidate_header_word_1_hex: candidate_header_word_1.map(|value| format!("0x{value:08x}")), + }) +} + +fn load_setup_launch_payload_sample( + smp_path: &Path, +) -> Result> { + let bytes = fs::read(smp_path)?; + let extension = smp_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .unwrap_or_default(); + let inferred_profile_family = + classify_candidate_table_header_profile(Some(extension.clone()), &bytes); + let launch_flag_byte_0x22 = bytes + .get(0x22) + .copied() + .ok_or_else(|| format!("{} missing setup launch byte +0x22", smp_path.display()))?; + let launch_selector_byte_0x33 = bytes + .get(0x33) + .copied() + .ok_or_else(|| format!("{} missing setup launch byte +0x33", smp_path.display()))?; + let token_block = bytes + .get(0x23..0x33) + .ok_or_else(|| format!("{} missing setup launch token block", smp_path.display()))?; + let campaign_progress_in_known_range = + (launch_flag_byte_0x22 as usize) < CAMPAIGN_SCENARIO_COUNT; + let campaign_progress_scenario_name = campaign_progress_in_known_range + .then(|| OBSERVED_CAMPAIGN_SCENARIO_NAMES[launch_flag_byte_0x22 as usize].to_string()); + let campaign_progress_page_index = match launch_flag_byte_0x22 { + 0..=4 => Some(1), + 5..=9 => Some(2), + 10..=12 => Some(3), + 13..=15 => Some(4), + _ => None, + }; + let campaign_selector_values = OBSERVED_CAMPAIGN_SCENARIO_NAMES + .iter() + .enumerate() + .map(|(index, name)| (name.to_string(), token_block[index])) + .collect::>(); + let nonzero_campaign_selector_values = campaign_selector_values + .iter() + .filter_map(|(name, value)| (*value != 0).then_some((name.clone(), *value))) + .collect::>(); + + Ok(RuntimeSetupLaunchPayloadSample { + path: smp_path.display().to_string(), + file_extension: extension, + inferred_profile_family, + launch_flag_byte_0x22, + launch_flag_byte_0x22_hex: format!("0x{launch_flag_byte_0x22:02x}"), + campaign_progress_in_known_range, + campaign_progress_scenario_name, + campaign_progress_page_index, + launch_selector_byte_0x33, + launch_selector_byte_0x33_hex: format!("0x{launch_selector_byte_0x33:02x}"), + launch_token_block_0x23_0x32_hex: hex_encode(token_block), + campaign_selector_values, + nonzero_campaign_selector_values, + }) +} + +fn load_candidate_table_header_scan_sample( + smp_path: &Path, +) -> Result> { + let bytes = fs::read(smp_path)?; + let header_offset = 0x6a70usize; + let entries_offset = 0x6ad1usize; + let block_end_offset = 0x73c0usize; + let entry_stride = 0x22usize; + if bytes.len() < block_end_offset { + return Err(format!( + "{} is too small for the fixed candidate table range", + smp_path.display() + ) + .into()); + } + if !matches_candidate_table_header_bytes(&bytes, header_offset) { + return Err(format!( + "{} does not contain the fixed candidate table header", + smp_path.display() + ) + .into()); + } + + let observed_entry_capacity = read_u32_le(&bytes, header_offset + 0x1c) + .ok_or_else(|| format!("{} is missing candidate table capacity", smp_path.display()))? + as usize; + let observed_entry_count = read_u32_le(&bytes, header_offset + 0x20) + .ok_or_else(|| format!("{} is missing candidate table count", smp_path.display()))? + as usize; + if observed_entry_capacity < observed_entry_count { + return Err(format!( + "{} has invalid candidate table capacity/count {observed_entry_capacity}/{observed_entry_count}", + smp_path.display() + ) + .into()); + } + + let entries_end_offset = entries_offset + .checked_add( + observed_entry_count + .checked_mul(entry_stride) + .ok_or("candidate table length overflow")?, + ) + .ok_or("candidate table end overflow")?; + if entries_end_offset > block_end_offset { + return Err(format!( + "{} candidate table overruns fixed block end", + smp_path.display() + ) + .into()); + } + + let mut zero_trailer_entry_names = Vec::new(); + for index in 0..observed_entry_count { + let offset = entries_offset + index * entry_stride; + let chunk = &bytes[offset..offset + entry_stride]; + let nul_index = chunk + .iter() + .position(|byte| *byte == 0) + .unwrap_or(entry_stride - 4); + let text = std::str::from_utf8(&chunk[..nul_index]).map_err(|_| { + format!( + "{} contains invalid UTF-8 in candidate table", + smp_path.display() + ) + })?; + let availability = read_u32_le(&bytes, offset + entry_stride - 4).ok_or_else(|| { + format!( + "{} is missing candidate availability dword", + smp_path.display() + ) + })?; + if availability == 0 { + zero_trailer_entry_names.push(text.to_string()); + } + } + + let profile_family = classify_candidate_table_header_profile( + smp_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()), + &bytes, + ); + let source_kind = match smp_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("gmp") => "map-fixed-catalog-range", + Some("gms") => "save-fixed-catalog-range", + _ => "fixed-catalog-range", + } + .to_string(); + + Ok(RuntimeCandidateTableHeaderScanSample { + path: smp_path.display().to_string(), + profile_family, + source_kind, + header_word_0_hex: format!( + "0x{:08x}", + read_u32_le(&bytes, header_offset).ok_or("missing candidate header word 0")? + ), + header_word_1_hex: format!( + "0x{:08x}", + read_u32_le(&bytes, header_offset + 4).ok_or("missing candidate header word 1")? + ), + zero_trailer_entry_count: zero_trailer_entry_names.len(), + zero_trailer_entry_names, + }) +} + +fn collect_candidate_table_input_paths( + root_path: &Path, + out: &mut Vec, +) -> Result<(), Box> { + let metadata = match fs::symlink_metadata(root_path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()), + Err(err) => return Err(err.into()), + }; + if metadata.file_type().is_symlink() { + return Ok(()); + } + + if root_path.is_file() { + if root_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "gmp" | "gms")) + { + out.push(root_path.to_path_buf()); + } + return Ok(()); + } + + let entries = match fs::read_dir(root_path) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()), + Err(err) => return Err(err.into()), + }; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_candidate_table_input_paths(&path, out)?; + continue; + } + if path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "gmp" | "gms")) + { + out.push(path); + } + } + + Ok(()) +} + +fn matches_candidate_table_header_bytes(bytes: &[u8], header_offset: usize) -> bool { + matches!( + ( + read_u32_le(bytes, header_offset + 0x08), + read_u32_le(bytes, header_offset + 0x0c), + read_u32_le(bytes, header_offset + 0x10), + read_u32_le(bytes, header_offset + 0x14), + read_u32_le(bytes, header_offset + 0x18), + read_u32_le(bytes, header_offset + 0x1c), + read_u32_le(bytes, header_offset + 0x20), + read_u32_le(bytes, header_offset + 0x24), + read_u32_le(bytes, header_offset + 0x28), + ), + ( + Some(0x0000332e), + Some(0x00000001), + Some(0x00000022), + Some(0x00000002), + Some(0x00000002), + Some(68), + Some(67), + Some(0x00000000), + Some(0x00000001), + ) + ) +} + +fn classify_candidate_table_header_profile(extension: Option, bytes: &[u8]) -> String { + let word_2 = read_u32_le(bytes, 8); + let word_3 = read_u32_le(bytes, 12); + let word_5 = read_u32_le(bytes, 20); + match (extension.as_deref().unwrap_or(""), word_2, word_3, word_5) { + ("gmp", Some(0x00040001), Some(0x00028000), Some(0x00000771)) => { + "rt3-105-map-container-v1".to_string() + } + ("gmp", Some(0x00040001), Some(0x00018000), Some(0x00000746)) => { + "rt3-105-scenario-map-container-v1".to_string() + } + ("gmp", Some(0x0001c001), Some(0x00018000), Some(0x00000754)) => { + "rt3-105-alt-map-container-v1".to_string() + } + ("gms", Some(0x00040001), Some(0x00028000), Some(0x00000771)) => { + "rt3-105-save-container-v1".to_string() + } + ("gms", Some(0x00040001), Some(0x00018000), Some(0x00000746)) => { + "rt3-105-scenario-save-container-v1".to_string() + } + ("gms", Some(0x0001c001), Some(0x00018000), Some(0x00000754)) => { + "rt3-105-alt-save-container-v1".to_string() + } + ("gmp", _, _, _) => "map-fixed-catalog-container-unknown".to_string(), + ("gms", _, _, _) => "save-fixed-catalog-container-unknown".to_string(), + _ => "fixed-catalog-container-unknown".to_string(), + } +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> Option { + let chunk = bytes.get(offset..offset + 4)?; + Some(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) +} + +fn read_u16_le(bytes: &[u8], offset: usize) -> Option { + let chunk = bytes.get(offset..offset + 2)?; + Some(u16::from_le_bytes([chunk[0], chunk[1]])) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut text = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(&mut text, "{byte:02x}"); + } + text +} + +fn diff_classic_profile_samples( + samples: &[RuntimeClassicProfileSample], +) -> Result, Box> { + let labeled_values = samples + .iter() + .map(|sample| { + ( + sample.path.clone(), + serde_json::json!({ + "profile_family": sample.profile_family, + "progress_32dc_offset": sample.progress_32dc_offset, + "progress_3714_offset": sample.progress_3714_offset, + "progress_3715_offset": sample.progress_3715_offset, + "packed_profile_offset": sample.packed_profile_offset, + "packed_profile_len": sample.packed_profile_len, + "packed_profile_block": sample.packed_profile_block, + }), + ) + }) + .collect::>(); + let mut differences = Vec::new(); + collect_json_multi_differences("$", &labeled_values, &mut differences); + Ok(differences) +} + +fn diff_rt3_105_profile_samples( + samples: &[RuntimeRt3105ProfileSample], +) -> Result, Box> { + let labeled_values = samples + .iter() + .map(|sample| { + ( + sample.path.clone(), + serde_json::json!({ + "profile_family": sample.profile_family, + "packed_profile_offset": sample.packed_profile_offset, + "packed_profile_len": sample.packed_profile_len, + "packed_profile_block": sample.packed_profile_block, + }), + ) + }) + .collect::>(); + let mut differences = Vec::new(); + collect_json_multi_differences("$", &labeled_values, &mut differences); + Ok(differences) +} + +fn diff_candidate_table_samples( + samples: &[RuntimeCandidateTableSample], +) -> Result, Box> { + let labeled_values = samples + .iter() + .map(|sample| { + ( + sample.path.clone(), + serde_json::json!({ + "profile_family": sample.profile_family, + "source_kind": sample.source_kind, + "semantic_family": sample.semantic_family, + "header_word_0_hex": sample.header_word_0_hex, + "header_word_1_hex": sample.header_word_1_hex, + "header_word_2_hex": sample.header_word_2_hex, + "observed_entry_count": sample.observed_entry_count, + "zero_trailer_entry_count": sample.zero_trailer_entry_count, + "nonzero_trailer_entry_count": sample.nonzero_trailer_entry_count, + "zero_trailer_entry_names": sample.zero_trailer_entry_names, + "footer_progress_word_0_hex": sample.footer_progress_word_0_hex, + "footer_progress_word_1_hex": sample.footer_progress_word_1_hex, + "availability_by_name": sample.availability_by_name, + }), + ) + }) + .collect::>(); + let mut differences = Vec::new(); + collect_json_multi_differences("$", &labeled_values, &mut differences); + Ok(differences) +} + +fn diff_setup_payload_core_samples( + samples: &[RuntimeSetupPayloadCoreSample], +) -> Result, Box> { + let labeled_values = samples + .iter() + .map(|sample| { + ( + sample.path.clone(), + serde_json::json!({ + "file_extension": sample.file_extension, + "inferred_profile_family": sample.inferred_profile_family, + "payload_word_0x14": sample.payload_word_0x14, + "payload_word_0x14_hex": sample.payload_word_0x14_hex, + "payload_byte_0x20": sample.payload_byte_0x20, + "payload_byte_0x20_hex": sample.payload_byte_0x20_hex, + "marker_bytes_0x2c9_0x2d0_hex": sample.marker_bytes_0x2c9_0x2d0_hex, + "row_category_byte_0x31a": sample.row_category_byte_0x31a, + "row_category_byte_0x31a_hex": sample.row_category_byte_0x31a_hex, + "row_visibility_byte_0x31b": sample.row_visibility_byte_0x31b, + "row_visibility_byte_0x31b_hex": sample.row_visibility_byte_0x31b_hex, + "row_visibility_byte_0x31c": sample.row_visibility_byte_0x31c, + "row_visibility_byte_0x31c_hex": sample.row_visibility_byte_0x31c_hex, + "row_count_word_0x3ae": sample.row_count_word_0x3ae, + "row_count_word_0x3ae_hex": sample.row_count_word_0x3ae_hex, + "payload_word_0x3b2": sample.payload_word_0x3b2, + "payload_word_0x3b2_hex": sample.payload_word_0x3b2_hex, + "payload_word_0x3ba": sample.payload_word_0x3ba, + "payload_word_0x3ba_hex": sample.payload_word_0x3ba_hex, + "candidate_header_word_0_hex": sample.candidate_header_word_0_hex, + "candidate_header_word_1_hex": sample.candidate_header_word_1_hex, + }), + ) + }) + .collect::>(); + let mut differences = Vec::new(); + collect_json_multi_differences("$", &labeled_values, &mut differences); + Ok(differences) +} + +fn diff_setup_launch_payload_samples( + samples: &[RuntimeSetupLaunchPayloadSample], +) -> Result, Box> { + let labeled_values = samples + .iter() + .map(|sample| { + ( + sample.path.clone(), + serde_json::json!({ + "file_extension": sample.file_extension, + "inferred_profile_family": sample.inferred_profile_family, + "launch_flag_byte_0x22": sample.launch_flag_byte_0x22, + "launch_flag_byte_0x22_hex": sample.launch_flag_byte_0x22_hex, + "campaign_progress_in_known_range": sample.campaign_progress_in_known_range, + "campaign_progress_scenario_name": sample.campaign_progress_scenario_name, + "campaign_progress_page_index": sample.campaign_progress_page_index, + "launch_selector_byte_0x33": sample.launch_selector_byte_0x33, + "launch_selector_byte_0x33_hex": sample.launch_selector_byte_0x33_hex, + "launch_token_block_0x23_0x32_hex": sample.launch_token_block_0x23_0x32_hex, + "campaign_selector_values": sample.campaign_selector_values, + "nonzero_campaign_selector_values": sample.nonzero_campaign_selector_values, + }), + ) + }) + .collect::>(); + let mut differences = Vec::new(); + collect_json_multi_differences("$", &labeled_values, &mut differences); + Ok(differences) +} + +fn build_profile_block_export_document( + smp_path: &Path, + inspection: &SmpInspectionReport, +) -> Result> { + if let Some(probe) = &inspection.classic_rehydrate_profile_probe { + return Ok(RuntimeProfileBlockExportDocument { + source_path: smp_path.display().to_string(), + profile_kind: "classic-rehydrate-profile".to_string(), + profile_family: probe.profile_family.clone(), + payload: serde_json::to_value(probe)?, + }); + } + + if let Some(probe) = &inspection.rt3_105_packed_profile_probe { + return Ok(RuntimeProfileBlockExportDocument { + source_path: smp_path.display().to_string(), + profile_kind: "rt3-105-packed-profile".to_string(), + profile_family: probe.profile_family.clone(), + payload: serde_json::to_value(probe)?, + }); + } + + Err(format!( + "{} did not expose an exportable packed-profile block", + smp_path.display() + ) + .into()) +} + +fn collect_json_multi_differences( + path: &str, + labeled_values: &[(String, Value)], + differences: &mut Vec, +) { + if labeled_values.is_empty() { + return; + } + + if labeled_values + .iter() + .all(|(_, value)| matches!(value, Value::Object(_))) + { + let mut keys = BTreeSet::new(); + for (_, value) in labeled_values { + if let Value::Object(map) = value { + keys.extend(map.keys().cloned()); + } + } + + for key in keys { + let next_path = format!("{path}.{key}"); + let nested = labeled_values + .iter() + .map(|(label, value)| { + let nested_value = match value { + Value::Object(map) => map.get(&key).cloned().unwrap_or(Value::Null), + _ => Value::Null, + }; + (label.clone(), nested_value) + }) + .collect::>(); + collect_json_multi_differences(&next_path, &nested, differences); + } + return; + } + + if labeled_values + .iter() + .all(|(_, value)| matches!(value, Value::Array(_))) + { + let max_len = labeled_values + .iter() + .filter_map(|(_, value)| match value { + Value::Array(items) => Some(items.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + for index in 0..max_len { + let next_path = format!("{path}[{index}]"); + let nested = labeled_values + .iter() + .map(|(label, value)| { + let nested_value = match value { + Value::Array(items) => items.get(index).cloned().unwrap_or(Value::Null), + _ => Value::Null, + }; + (label.clone(), nested_value) + }) + .collect::>(); + collect_json_multi_differences(&next_path, &nested, differences); + } + return; + } + + let first = &labeled_values[0].1; + if labeled_values + .iter() + .skip(1) + .all(|(_, value)| value == first) + { + return; + } + + differences.push(RuntimeClassicProfileDifference { + field_path: path.to_string(), + values: labeled_values + .iter() + .map(|(label, value)| RuntimeClassicProfileDifferenceValue { + path: label.clone(), + value: value.clone(), + }) + .collect(), + }); +} + +fn print_runtime_validation_report( + report: &FixtureValidationReport, +) -> Result<(), Box> { + println!("{}", serde_json::to_string_pretty(report)?); + Ok(()) +} + fn load_finance_outcome(path: &Path) -> Result> { let text = fs::read_to_string(path)?; if let Ok(snapshot) = serde_json::from_str::(&text) { @@ -304,7 +1980,10 @@ fn sha256_file(path: &Path) -> Result> { #[cfg(test)] mod tests { use super::*; - use rrt_model::finance::{AnnualFinanceDecision, AnnualFinanceEvaluation, CompanyFinanceState, DebtRestructureSummary}; + use rrt_model::finance::{ + AnnualFinanceDecision, AnnualFinanceEvaluation, CompanyFinanceState, DebtRestructureSummary, + }; + use rrt_runtime::{SmpPackedProfileWordLane, SmpRt3105PackedProfileBlock}; #[test] fn loads_snapshot_as_outcome() { @@ -338,14 +2017,516 @@ mod tests { let report = diff_finance_outcomes(&left, &right).expect("diff should succeed"); assert!(!report.matches); - assert!(report - .differences - .iter() - .any(|entry| entry.path == "$.post_company.current_cash")); - assert!(report - .differences - .iter() - .any(|entry| entry.path == "$.evaluation.debt_restructure.retired_principal")); + assert!( + report + .differences + .iter() + .any(|entry| entry.path == "$.post_company.current_cash") + ); + assert!( + report + .differences + .iter() + .any(|entry| entry.path == "$.evaluation.debt_restructure.retired_principal") + ); + } + + #[test] + fn summarizes_runtime_fixture() { + let fixture = serde_json::json!({ + "format_version": 1, + "fixture_id": "runtime-fixture-test", + "source": { "kind": "synthetic" }, + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 0 + }, + "world_flags": { + "sandbox": false + }, + "companies": [], + "event_runtime_records": [] + }, + "commands": [ + { + "kind": "advance_to", + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 3 + } + } + ], + "expected_summary": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 3 + }, + "world_flag_count": 1, + "company_count": 0, + "event_runtime_record_count": 0, + "total_company_cash": 0 + } + }); + let path = write_temp_json("runtime-fixture", &fixture); + + run_runtime_summarize_fixture(&path).expect("fixture summary should succeed"); + + let _ = fs::remove_file(path); + } + + #[test] + fn exports_and_summarizes_runtime_snapshot() { + let fixture = serde_json::json!({ + "format_version": 1, + "fixture_id": "runtime-export-test", + "source": { "kind": "synthetic" }, + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 0 + }, + "world_flags": {}, + "companies": [], + "event_runtime_records": [] + }, + "commands": [ + { + "kind": "step_count", + "steps": 2 + } + ], + "expected_summary": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 2 + }, + "world_flag_count": 0, + "company_count": 0, + "event_runtime_record_count": 0, + "total_company_cash": 0 + } + }); + let fixture_path = write_temp_json("runtime-export-fixture", &fixture); + let snapshot_path = std::env::temp_dir().join(format!( + "rrt-cli-runtime-export-{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + + run_runtime_export_fixture_state(&fixture_path, &snapshot_path) + .expect("fixture export should succeed"); + run_runtime_summarize_state(&snapshot_path).expect("snapshot summary should succeed"); + + let _ = fs::remove_file(fixture_path); + let _ = fs::remove_file(snapshot_path); + } + + #[test] + fn imports_runtime_state_dump_into_snapshot() { + let dump = serde_json::json!({ + "format_version": 1, + "dump_id": "runtime-dump-test", + "source": { + "description": "test raw runtime dump" + }, + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 9 + }, + "world_flags": {}, + "companies": [], + "event_runtime_records": [], + "service_state": { + "periodic_boundary_calls": 0, + "trigger_dispatch_counts": {}, + "total_event_record_services": 0, + "dirty_rerun_count": 0 + } + } + }); + let input_path = write_temp_json("runtime-dump", &dump); + let output_path = std::env::temp_dir().join(format!( + "rrt-cli-runtime-import-{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + + run_runtime_import_state(&input_path, &output_path).expect("runtime import should succeed"); + run_runtime_summarize_state(&output_path).expect("imported snapshot should summarize"); + + let _ = fs::remove_file(input_path); + let _ = fs::remove_file(output_path); + } + + #[test] + fn diffs_classic_profile_samples_across_multiple_files() { + let sample_a = RuntimeClassicProfileSample { + path: "a.gms".to_string(), + profile_family: "rt3-classic-save-container-v1".to_string(), + progress_32dc_offset: 0x76e8, + progress_3714_offset: 0x76ec, + progress_3715_offset: 0x77f8, + packed_profile_offset: 0x76f0, + packed_profile_len: 0x108, + packed_profile_block: SmpClassicPackedProfileBlock { + relative_len: 0x108, + relative_len_hex: "0x108".to_string(), + leading_word_0: 0x03000000, + leading_word_0_hex: "0x03000000".to_string(), + trailing_zero_word_count_after_leading_word: 3, + map_path_offset: 0x13, + map_path: Some("British Isles.gmp".to_string()), + display_name_offset: 0x46, + display_name: Some("British Isles".to_string()), + profile_byte_0x77: 0, + profile_byte_0x77_hex: "0x00".to_string(), + profile_byte_0x82: 0, + profile_byte_0x82_hex: "0x00".to_string(), + profile_byte_0x97: 0, + profile_byte_0x97_hex: "0x00".to_string(), + profile_byte_0xc5: 0, + profile_byte_0xc5_hex: "0x00".to_string(), + stable_nonzero_words: vec![SmpPackedProfileWordLane { + relative_offset: 0, + relative_offset_hex: "0x00".to_string(), + value: 0x03000000, + value_hex: "0x03000000".to_string(), + }], + }, + }; + let mut sample_b = sample_a.clone(); + sample_b.path = "b.gms".to_string(); + sample_b.packed_profile_block.leading_word_0 = 0x05000000; + sample_b.packed_profile_block.leading_word_0_hex = "0x05000000".to_string(); + sample_b.packed_profile_block.stable_nonzero_words[0].value = 0x05000000; + sample_b.packed_profile_block.stable_nonzero_words[0].value_hex = "0x05000000".to_string(); + + let differences = + diff_classic_profile_samples(&[sample_a, sample_b]).expect("diff should succeed"); + + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.packed_profile_block.leading_word_0") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.packed_profile_block.leading_word_0_hex") + ); + assert!(differences.iter().any( + |entry| entry.field_path == "$.packed_profile_block.stable_nonzero_words[0].value" + )); + } + + #[test] + fn diffs_rt3_105_profile_samples_across_multiple_files() { + let sample_a = RuntimeRt3105ProfileSample { + path: "a.gms".to_string(), + profile_family: "rt3-105-save-container-v1".to_string(), + packed_profile_offset: 0x73c0, + packed_profile_len: 0x108, + packed_profile_block: SmpRt3105PackedProfileBlock { + relative_len: 0x108, + relative_len_hex: "0x108".to_string(), + leading_word_0: 3, + leading_word_0_hex: "0x00000003".to_string(), + trailing_zero_word_count_after_leading_word: 2, + header_flag_word_3: 0x01000000, + header_flag_word_3_hex: "0x01000000".to_string(), + map_path_offset: 0x10, + map_path: Some("Alternate USA.gmp".to_string()), + display_name_offset: 0x43, + display_name: Some("Alternate USA".to_string()), + profile_byte_0x77: 0x07, + profile_byte_0x77_hex: "0x07".to_string(), + profile_byte_0x82: 0x4d, + profile_byte_0x82_hex: "0x4d".to_string(), + profile_byte_0x97: 0x00, + profile_byte_0x97_hex: "0x00".to_string(), + profile_byte_0xc5: 0x00, + profile_byte_0xc5_hex: "0x00".to_string(), + stable_nonzero_words: vec![SmpPackedProfileWordLane { + relative_offset: 0x80, + relative_offset_hex: "0x80".to_string(), + value: 0x364d0000, + value_hex: "0x364d0000".to_string(), + }], + }, + }; + let mut sample_b = sample_a.clone(); + sample_b.path = "b.gms".to_string(); + sample_b.profile_family = "rt3-105-alt-save-container-v1".to_string(); + sample_b.packed_profile_block.map_path = Some("Southern Pacific.gmp".to_string()); + sample_b.packed_profile_block.display_name = Some("Southern Pacific".to_string()); + sample_b.packed_profile_block.leading_word_0 = 5; + sample_b.packed_profile_block.leading_word_0_hex = "0x00000005".to_string(); + sample_b.packed_profile_block.profile_byte_0x82 = 0x90; + sample_b.packed_profile_block.profile_byte_0x82_hex = "0x90".to_string(); + sample_b.packed_profile_block.stable_nonzero_words[0].value = 0x1b900000; + sample_b.packed_profile_block.stable_nonzero_words[0].value_hex = "0x1b900000".to_string(); + + let differences = + diff_rt3_105_profile_samples(&[sample_a, sample_b]).expect("diff should succeed"); + + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.profile_family") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.packed_profile_block.map_path") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.packed_profile_block.profile_byte_0x82") + ); + } + + #[test] + fn diffs_candidate_table_samples_across_multiple_files() { + let mut availability_a = BTreeMap::new(); + availability_a.insert("AutoPlant".to_string(), 1u32); + availability_a.insert("Nuclear Power Plant".to_string(), 0u32); + + let sample_a = RuntimeCandidateTableSample { + path: "a.gmp".to_string(), + profile_family: "rt3-105-map-container-v1".to_string(), + source_kind: "map-fixed-catalog-range".to_string(), + semantic_family: "scenario-named-candidate-availability-table".to_string(), + header_word_0_hex: "0x10000000".to_string(), + header_word_1_hex: "0x00009000".to_string(), + header_word_2_hex: "0x0000332e".to_string(), + observed_entry_count: 67, + zero_trailer_entry_count: 1, + nonzero_trailer_entry_count: 66, + zero_trailer_entry_names: vec!["Nuclear Power Plant".to_string()], + footer_progress_word_0_hex: "0x000032dc".to_string(), + footer_progress_word_1_hex: "0x00003714".to_string(), + availability_by_name: availability_a, + }; + + let mut availability_b = BTreeMap::new(); + availability_b.insert("AutoPlant".to_string(), 0u32); + availability_b.insert("Nuclear Power Plant".to_string(), 0u32); + + let sample_b = RuntimeCandidateTableSample { + path: "b.gmp".to_string(), + profile_family: "rt3-105-scenario-map-container-v1".to_string(), + source_kind: "map-fixed-catalog-range".to_string(), + semantic_family: "scenario-named-candidate-availability-table".to_string(), + header_word_0_hex: "0x00000000".to_string(), + header_word_1_hex: "0x00000000".to_string(), + header_word_2_hex: "0x0000332e".to_string(), + observed_entry_count: 67, + zero_trailer_entry_count: 2, + nonzero_trailer_entry_count: 65, + zero_trailer_entry_names: vec![ + "AutoPlant".to_string(), + "Nuclear Power Plant".to_string(), + ], + footer_progress_word_0_hex: "0x000032dc".to_string(), + footer_progress_word_1_hex: "0x00003714".to_string(), + availability_by_name: availability_b, + }; + + let differences = + diff_candidate_table_samples(&[sample_a, sample_b]).expect("diff should succeed"); + + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.profile_family") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.header_word_0_hex") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.availability_by_name.AutoPlant") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.zero_trailer_entry_names[0]") + ); + } + + #[test] + fn diffs_setup_payload_core_samples_across_multiple_files() { + let sample_a = RuntimeSetupPayloadCoreSample { + path: "a.gmp".to_string(), + file_extension: "gmp".to_string(), + inferred_profile_family: "rt3-105-map-container-v1".to_string(), + payload_word_0x14: 0x0001, + payload_word_0x14_hex: "0x0001".to_string(), + payload_byte_0x20: 0x05, + payload_byte_0x20_hex: "0x05".to_string(), + marker_bytes_0x2c9_0x2d0_hex: "0000000000000000".to_string(), + row_category_byte_0x31a: 0x00, + row_category_byte_0x31a_hex: "0x00".to_string(), + row_visibility_byte_0x31b: 0x00, + row_visibility_byte_0x31b_hex: "0x00".to_string(), + row_visibility_byte_0x31c: 0x00, + row_visibility_byte_0x31c_hex: "0x00".to_string(), + row_count_word_0x3ae: 0x0186, + row_count_word_0x3ae_hex: "0x0186".to_string(), + payload_word_0x3b2: 0x0001, + payload_word_0x3b2_hex: "0x0001".to_string(), + payload_word_0x3ba: 0x0001, + payload_word_0x3ba_hex: "0x0001".to_string(), + candidate_header_word_0_hex: Some("0x10000000".to_string()), + candidate_header_word_1_hex: Some("0x00009000".to_string()), + }; + + let sample_b = RuntimeSetupPayloadCoreSample { + path: "b.gms".to_string(), + file_extension: "gms".to_string(), + inferred_profile_family: "rt3-105-scenario-save-container-v1".to_string(), + payload_word_0x14: 0x0001, + payload_word_0x14_hex: "0x0001".to_string(), + payload_byte_0x20: 0x05, + payload_byte_0x20_hex: "0x05".to_string(), + marker_bytes_0x2c9_0x2d0_hex: "0000000000000000".to_string(), + row_category_byte_0x31a: 0x00, + row_category_byte_0x31a_hex: "0x00".to_string(), + row_visibility_byte_0x31b: 0x00, + row_visibility_byte_0x31b_hex: "0x00".to_string(), + row_visibility_byte_0x31c: 0x00, + row_visibility_byte_0x31c_hex: "0x00".to_string(), + row_count_word_0x3ae: 0x0186, + row_count_word_0x3ae_hex: "0x0186".to_string(), + payload_word_0x3b2: 0x0006, + payload_word_0x3b2_hex: "0x0006".to_string(), + payload_word_0x3ba: 0x0001, + payload_word_0x3ba_hex: "0x0001".to_string(), + candidate_header_word_0_hex: Some("0x00000000".to_string()), + candidate_header_word_1_hex: Some("0x00000000".to_string()), + }; + + let differences = + diff_setup_payload_core_samples(&[sample_a, sample_b]).expect("diff should succeed"); + + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.file_extension") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.inferred_profile_family") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.payload_word_0x3b2") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.candidate_header_word_0_hex") + ); + } + + #[test] + fn diffs_setup_launch_payload_samples_across_multiple_files() { + let sample_a = RuntimeSetupLaunchPayloadSample { + path: "a.gmp".to_string(), + file_extension: "gmp".to_string(), + inferred_profile_family: "rt3-105-map-container-v1".to_string(), + launch_flag_byte_0x22: 0x53, + launch_flag_byte_0x22_hex: "0x53".to_string(), + campaign_progress_in_known_range: false, + campaign_progress_scenario_name: None, + campaign_progress_page_index: None, + launch_selector_byte_0x33: 0x00, + launch_selector_byte_0x33_hex: "0x00".to_string(), + launch_token_block_0x23_0x32_hex: "01311154010000000000000000000000".to_string(), + campaign_selector_values: BTreeMap::from([ + ("Go West!".to_string(), 0x01), + ("Germantown".to_string(), 0x31), + ]), + nonzero_campaign_selector_values: BTreeMap::from([ + ("Go West!".to_string(), 0x01), + ("Germantown".to_string(), 0x31), + ]), + }; + + let sample_b = RuntimeSetupLaunchPayloadSample { + path: "b.gms".to_string(), + file_extension: "gms".to_string(), + inferred_profile_family: "rt3-105-save-container-v1".to_string(), + launch_flag_byte_0x22: 0xae, + launch_flag_byte_0x22_hex: "0xae".to_string(), + campaign_progress_in_known_range: false, + campaign_progress_scenario_name: None, + campaign_progress_page_index: None, + launch_selector_byte_0x33: 0x00, + launch_selector_byte_0x33_hex: "0x00".to_string(), + launch_token_block_0x23_0x32_hex: "01439aae010000000000000000000000".to_string(), + campaign_selector_values: BTreeMap::from([ + ("Go West!".to_string(), 0x01), + ("Germantown".to_string(), 0x43), + ]), + nonzero_campaign_selector_values: BTreeMap::from([ + ("Go West!".to_string(), 0x01), + ("Germantown".to_string(), 0x43), + ]), + }; + + let differences = + diff_setup_launch_payload_samples(&[sample_a, sample_b]).expect("diff should succeed"); + + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.file_extension") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.inferred_profile_family") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.launch_flag_byte_0x22") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.launch_token_block_0x23_0x32_hex") + ); + assert!( + differences + .iter() + .any(|entry| entry.field_path == "$.campaign_selector_values.Germantown") + ); } fn write_temp_json(stem: &str, value: &T) -> PathBuf { diff --git a/crates/rrt-fixtures/Cargo.toml b/crates/rrt-fixtures/Cargo.toml new file mode 100644 index 0000000..aec0817 --- /dev/null +++ b/crates/rrt-fixtures/Cargo.toml @@ -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 diff --git a/crates/rrt-fixtures/src/diff.rs b/crates/rrt-fixtures/src/diff.rs new file mode 100644 index 0000000..f85a4df --- /dev/null +++ b/crates/rrt-fixtures/src/diff.rs @@ -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 { + 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, +) { + 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"); + } +} diff --git a/crates/rrt-fixtures/src/lib.rs b/crates/rrt-fixtures/src/lib.rs new file mode 100644 index 0000000..9476f53 --- /dev/null +++ b/crates/rrt-fixtures/src/lib.rs @@ -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, +}; diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs new file mode 100644 index 0000000..ffc7903 --- /dev/null +++ b/crates/rrt-fixtures/src/load.rs @@ -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> { + 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> { + 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> { + 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> { + 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); + } +} diff --git a/crates/rrt-fixtures/src/normalize.rs b/crates/rrt-fixtures/src/normalize.rs new file mode 100644 index 0000000..0c65b93 --- /dev/null +++ b/crates/rrt-fixtures/src/normalize.rs @@ -0,0 +1,7 @@ +use serde_json::Value; + +use rrt_runtime::RuntimeState; + +pub fn normalize_runtime_state(state: &RuntimeState) -> Result> { + Ok(serde_json::to_value(state)?) +} diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs new file mode 100644 index 0000000..67567ba --- /dev/null +++ b/crates/rrt-fixtures/src/schema.rs @@ -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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ExpectedRuntimeSummary { + #[serde(default)] + pub calendar: Option, + #[serde(default)] + pub world_flag_count: Option, + #[serde(default)] + pub company_count: Option, + #[serde(default)] + pub event_runtime_record_count: Option, + #[serde(default)] + pub total_event_record_service_count: Option, + #[serde(default)] + pub periodic_boundary_call_count: Option, + #[serde(default)] + pub total_trigger_dispatch_count: Option, + #[serde(default)] + pub dirty_rerun_count: Option, + #[serde(default)] + pub total_company_cash: Option, +} + +impl ExpectedRuntimeSummary { + pub fn compare(&self, actual: &RuntimeSummary) -> Vec { + 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, + #[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, + #[serde(default)] + pub state_snapshot_path: Option, + #[serde(default)] + pub commands: Vec, + #[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, +} + +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")); + } +} diff --git a/crates/rrt-hook/src/lib.rs b/crates/rrt-hook/src/lib.rs index 31668db..3563a6f 100644 --- a/crates/rrt-hook/src/lib.rs +++ b/crates/rrt-hook/src/lib.rs @@ -152,36 +152,132 @@ mod windows_hook { static LOG_PATH: &[u8] = b"rrt_hook_attach.log\0"; static ATTACH_MESSAGE: &[u8] = b"rrt-hook: process attach\n"; static FINANCE_CAPTURE_STARTED_MESSAGE: &[u8] = b"rrt-hook: finance capture thread started\n"; - static FINANCE_CAPTURE_SCAN_MESSAGE: &[u8] = - b"rrt-hook: finance capture raw collection scan\n"; + static FINANCE_CAPTURE_SCAN_MESSAGE: &[u8] = b"rrt-hook: finance capture raw collection scan\n"; static FINANCE_CAPTURE_PROBE_DUMP_WRITTEN_MESSAGE: &[u8] = b"rrt-hook: finance collection probe written\n"; static FINANCE_CAPTURE_COMPANY_RESOLVED_MESSAGE: &[u8] = b"rrt-hook: finance capture company resolved\n"; static FINANCE_CAPTURE_PROBE_WRITTEN_MESSAGE: &[u8] = b"rrt-hook: finance probe snapshot written\n"; - static FINANCE_CAPTURE_TIMEOUT_MESSAGE: &[u8] = - b"rrt-hook: finance capture timed out\n"; - static AUTO_LOAD_STARTED_MESSAGE: &[u8] = - b"rrt-hook: auto load hook armed\n"; + static FINANCE_CAPTURE_TIMEOUT_MESSAGE: &[u8] = b"rrt-hook: finance capture timed out\n"; + static AUTO_LOAD_STARTED_MESSAGE: &[u8] = b"rrt-hook: auto load hook armed\n"; static AUTO_LOAD_HOOK_INSTALLED_MESSAGE: &[u8] = - b"rrt-hook: auto load shell-pump hook installed\n"; - static AUTO_LOAD_READY_MESSAGE: &[u8] = - b"rrt-hook: auto load ready gate passed\n"; + b"rrt-hook: auto load shell-state hook installed\n"; + static AUTO_LOAD_PROFILE_DISPATCH_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load startup-dispatch hook installed\n"; + static AUTO_LOAD_RUNTIME_RESET_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load runtime-reset hook installed\n"; + static AUTO_LOAD_ALLOCATOR_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load allocator hook installed\n"; + static AUTO_LOAD_LOAD_SCREEN_SCALAR_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen scalar hook installed\n"; + static AUTO_LOAD_LOAD_SCREEN_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen hook installed\n"; + static AUTO_LOAD_LOAD_SCREEN_MESSAGE_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen message hook installed\n"; + static AUTO_LOAD_RUNTIME_PRIME_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell runtime-prime hook installed\n"; + static AUTO_LOAD_FRAME_CYCLE_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell frame-cycle hook installed\n"; + static AUTO_LOAD_OBJECT_SERVICE_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object-service hook installed\n"; + static AUTO_LOAD_CHILD_SERVICE_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell child-service hook installed\n"; + static AUTO_LOAD_PUBLISH_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell publish hook installed\n"; + static AUTO_LOAD_UNPUBLISH_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell unpublish hook installed\n"; + static AUTO_LOAD_OBJECT_TEARDOWN_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object teardown hook installed\n"; + static AUTO_LOAD_OBJECT_RANGE_REMOVE_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object range-remove hook installed\n"; + static AUTO_LOAD_REMOVE_NODE_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell remove-node hook installed\n"; + static AUTO_LOAD_NODE_VCALL_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell node-vcall hook installed\n"; + static AUTO_LOAD_MODE2_TEARDOWN_HOOK_INSTALLED_MESSAGE: &[u8] = + b"rrt-hook: auto load mode2 teardown hook installed\n"; + static AUTO_LOAD_READY_MESSAGE: &[u8] = b"rrt-hook: auto load ready gate passed\n"; static AUTO_LOAD_DEFERRED_MESSAGE: &[u8] = - b"rrt-hook: auto load restore deferred to later shell-pump turn\n"; - static AUTO_LOAD_CALLING_MESSAGE: &[u8] = - b"rrt-hook: auto load restore calling\n"; + b"rrt-hook: auto load restore deferred to later service turn\n"; + static AUTO_LOAD_CALLING_MESSAGE: &[u8] = b"rrt-hook: auto load restore calling\n"; + static AUTO_LOAD_STAGED_MESSAGE: &[u8] = + b"rrt-hook: auto load restore staged for later transition\n"; + static AUTO_LOAD_READY_COUNT_MESSAGE: &[u8] = b"rrt-hook: auto load ready count\n"; + static AUTO_LOAD_ARMED_TICK_MESSAGE: &[u8] = b"rrt-hook: auto load armed transition tick\n"; static AUTO_LOAD_OWNER_ENTRY_MESSAGE: &[u8] = - b"rrt-hook: auto load larger owner entering\n"; + b"rrt-hook: auto load shell transition entering\n"; static AUTO_LOAD_OWNER_RETURNED_MESSAGE: &[u8] = - b"rrt-hook: auto load larger owner returned\n"; - static AUTO_LOAD_TRIGGERED_MESSAGE: &[u8] = - b"rrt-hook: auto load restore invoked\n"; - static AUTO_LOAD_SUCCESS_MESSAGE: &[u8] = - b"rrt-hook: auto load request reported success\n"; - static AUTO_LOAD_FAILURE_MESSAGE: &[u8] = - b"rrt-hook: auto load request reported failure\n"; + b"rrt-hook: auto load shell transition returned\n"; + static AUTO_LOAD_PROFILE_DISPATCH_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load startup dispatch entering\n"; + static AUTO_LOAD_PROFILE_DISPATCH_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load startup dispatch returned\n"; + static AUTO_LOAD_RUNTIME_RESET_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load runtime reset entering\n"; + static AUTO_LOAD_RUNTIME_RESET_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load runtime reset returned\n"; + static AUTO_LOAD_ALLOCATOR_ENTRY_MESSAGE: &[u8] = b"rrt-hook: auto load allocator entering\n"; + static AUTO_LOAD_ALLOCATOR_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load allocator returned\n"; + static AUTO_LOAD_LOAD_SCREEN_SCALAR_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen scalar entering\n"; + static AUTO_LOAD_LOAD_SCREEN_SCALAR_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen scalar returned\n"; + static AUTO_LOAD_LOAD_SCREEN_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen construct entering\n"; + static AUTO_LOAD_LOAD_SCREEN_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen construct returned\n"; + static AUTO_LOAD_LOAD_SCREEN_MESSAGE_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen message entering\n"; + static AUTO_LOAD_LOAD_SCREEN_MESSAGE_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load load-screen message returned\n"; + static AUTO_LOAD_RUNTIME_PRIME_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell runtime-prime entering\n"; + static AUTO_LOAD_RUNTIME_PRIME_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell runtime-prime returned\n"; + static AUTO_LOAD_FRAME_CYCLE_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell frame-cycle entering\n"; + static AUTO_LOAD_FRAME_CYCLE_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell frame-cycle returned\n"; + static AUTO_LOAD_OBJECT_SERVICE_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object-service entering\n"; + static AUTO_LOAD_OBJECT_SERVICE_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object-service returned\n"; + static AUTO_LOAD_CHILD_SERVICE_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell child-service entering\n"; + static AUTO_LOAD_CHILD_SERVICE_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell child-service returned\n"; + static AUTO_LOAD_PUBLISH_ENTRY_MESSAGE: &[u8] = b"rrt-hook: auto load shell publish entering\n"; + static AUTO_LOAD_PUBLISH_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell publish returned\n"; + static AUTO_LOAD_UNPUBLISH_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell unpublish entering\n"; + static AUTO_LOAD_UNPUBLISH_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell unpublish returned\n"; + static AUTO_LOAD_OBJECT_TEARDOWN_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object teardown entering\n"; + static AUTO_LOAD_OBJECT_TEARDOWN_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object teardown returned\n"; + static AUTO_LOAD_OBJECT_RANGE_REMOVE_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object range-remove entering\n"; + static AUTO_LOAD_OBJECT_RANGE_REMOVE_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell object range-remove returned\n"; + static AUTO_LOAD_REMOVE_NODE_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell remove-node entering\n"; + static AUTO_LOAD_REMOVE_NODE_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell remove-node returned\n"; + static AUTO_LOAD_NODE_VCALL_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load shell node-vcall entering\n"; + static AUTO_LOAD_NODE_VCALL_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load shell node-vcall returned\n"; + static AUTO_LOAD_MODE2_TEARDOWN_ENTRY_MESSAGE: &[u8] = + b"rrt-hook: auto load mode2 teardown entering\n"; + static AUTO_LOAD_MODE2_TEARDOWN_RETURNED_MESSAGE: &[u8] = + b"rrt-hook: auto load mode2 teardown returned\n"; + static AUTO_LOAD_TRIGGERED_MESSAGE: &[u8] = b"rrt-hook: auto load restore invoked\n"; + static AUTO_LOAD_SUCCESS_MESSAGE: &[u8] = b"rrt-hook: auto load request reported success\n"; + static AUTO_LOAD_FAILURE_MESSAGE: &[u8] = b"rrt-hook: auto load request reported failure\n"; static DEBUG_MESSAGE: &[u8] = b"rrt-hook: DllMain process attach\0"; static DIRECT_INPUT8_CREATE_NAME: &[u8] = b"DirectInput8Create\0"; static mut REAL_DINPUT8_CREATE: Option = None; @@ -193,21 +289,72 @@ mod windows_hook { static AUTO_LOAD_ATTEMPTED: AtomicBool = AtomicBool::new(false); static AUTO_LOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false); static AUTO_LOAD_DEFERRED: AtomicBool = AtomicBool::new(false); + static AUTO_LOAD_TRANSITION_ARMED: AtomicBool = AtomicBool::new(false); static AUTO_LOAD_LAST_GATE_MASK: AtomicU32 = AtomicU32::new(u32::MAX); static AUTO_LOAD_READY_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_ARMED_TICK_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_ALLOCATOR_WINDOW_ACTIVE: AtomicBool = AtomicBool::new(false); + static AUTO_LOAD_ALLOCATOR_WINDOW_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_POST_TRANSITION_SERVICE_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_LOAD_SCREEN_MESSAGE_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_SERVICE_ENTRY_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_SERVICE_RETURN_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_RUNTIME_PRIME_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_FRAME_CYCLE_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_OBJECT_SERVICE_LOG_COUNT: AtomicU32 = AtomicU32::new(0); + static AUTO_LOAD_CHILD_SERVICE_LOG_COUNT: AtomicU32 = AtomicU32::new(0); static AUTO_LOAD_SAVE_STEM: OnceLock = OnceLock::new(); - static mut SHELL_PUMP_TRAMPOLINE: usize = 0; + static mut SHELL_STATE_SERVICE_TRAMPOLINE: usize = 0; + static mut PROFILE_STARTUP_DISPATCH_TRAMPOLINE: usize = 0; + static mut RUNTIME_RESET_TRAMPOLINE: usize = 0; + static mut ALLOCATOR_TRAMPOLINE: usize = 0; + static mut LOAD_SCREEN_SCALAR_TRAMPOLINE: usize = 0; + static mut LOAD_SCREEN_CONSTRUCT_TRAMPOLINE: usize = 0; + static mut LOAD_SCREEN_MESSAGE_TRAMPOLINE: usize = 0; + static mut RUNTIME_PRIME_TRAMPOLINE: usize = 0; + static mut FRAME_CYCLE_TRAMPOLINE: usize = 0; + static mut OBJECT_SERVICE_TRAMPOLINE: usize = 0; + static mut CHILD_SERVICE_TRAMPOLINE: usize = 0; + static mut SHELL_PUBLISH_TRAMPOLINE: usize = 0; + static mut SHELL_UNPUBLISH_TRAMPOLINE: usize = 0; + static mut SHELL_OBJECT_TEARDOWN_TRAMPOLINE: usize = 0; + static mut SHELL_OBJECT_RANGE_REMOVE_TRAMPOLINE: usize = 0; + static mut SHELL_REMOVE_NODE_TRAMPOLINE: usize = 0; + static mut SHELL_NODE_VCALL_TRAMPOLINE: usize = 0; + static mut MODE2_TEARDOWN_TRAMPOLINE: usize = 0; const COMPANY_COLLECTION_ADDR: usize = 0x0062be10; const SHELL_CONTROLLER_PTR_ADDR: usize = 0x006d4024; const SHELL_STATE_PTR_ADDR: usize = 0x006cec74; const ACTIVE_MODE_PTR_ADDR: usize = 0x006cec78; - const SHELL_PUMP_ADDR: usize = 0x00483f70; + const SHELL_STATE_SERVICE_ADDR: usize = 0x00482160; + const SHELL_TRANSITION_MODE_ADDR: usize = 0x00482ec0; + const PROFILE_STARTUP_DISPATCH_ADDR: usize = 0x00438890; + const RUNTIME_RESET_ADDR: usize = 0x004336d0; + const STARTUP_RUNTIME_ALLOC_THUNK_ADDR: usize = 0x0053b070; + const LOAD_SCREEN_SET_SCALAR_ADDR: usize = 0x004ea710; + const LOAD_SCREEN_CONSTRUCT_ADDR: usize = 0x004ea620; + const LOAD_SCREEN_HANDLE_MESSAGE_ADDR: usize = 0x004e3a80; + const SHELL_RUNTIME_PRIME_ADDR: usize = 0x00538b60; + const SHELL_FRAME_CYCLE_ADDR: usize = 0x00520620; + const SHELL_OBJECT_SERVICE_ADDR: usize = 0x0053fda0; + const SHELL_CHILD_SERVICE_ADDR: usize = 0x005595d0; + const SHELL_PUBLISH_WINDOW_ADDR: usize = 0x00538e50; + const SHELL_UNPUBLISH_WINDOW_ADDR: usize = 0x005389c0; + const SHELL_OBJECT_TEARDOWN_ADDR: usize = 0x005400c0; + const SHELL_OBJECT_RANGE_REMOVE_ADDR: usize = 0x0053fe00; + const SHELL_REMOVE_NODE_ADDR: usize = 0x0053f860; + const SHELL_NODE_VCALL_ADDR: usize = 0x00540910; + const MODE2_TEARDOWN_ADDR: usize = 0x00502720; const SHELL_STATE_ACTIVE_MODE_OFFSET: usize = 0x08; const SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET: usize = 0x0c; const RUNTIME_PROFILE_PTR_ADDR: usize = 0x006cec7c; + const RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET: usize = 0x01; const RUNTIME_PROFILE_MANUAL_LOAD_PATH_OFFSET: usize = 0x11; const RUNTIME_PROFILE_PENDING_LOAD_BYTE_OFFSET: usize = 0x97; + const SHELL_MODE_STARTUP_LOAD_DISPATCH: u32 = 1; + const STARTUP_SELECTOR_SCENARIO_LOAD: u8 = 3; + const STARTUP_RUNTIME_OBJECT_SIZE: u32 = 0x00046c40; const INDEXED_COLLECTION_FLAT_FLAG_OFFSET: usize = 0x04; const INDEXED_COLLECTION_STRIDE_OFFSET: usize = 0x08; const INDEXED_COLLECTION_ID_BOUND_OFFSET: usize = 0x14; @@ -232,8 +379,8 @@ mod windows_hook { const MAX_CAPTURE_POLL_ATTEMPTS: usize = 120; const CAPTURE_POLL_INTERVAL: Duration = Duration::from_secs(1); - const AUTO_LOAD_READY_POLLS: u32 = 30; - const AUTO_LOAD_DEFER_POLLS: u32 = 5; + const AUTO_LOAD_READY_POLLS: u32 = 1; + const AUTO_LOAD_DEFER_POLLS: u32 = 0; const MEM_COMMIT: u32 = 0x1000; const MEM_RESERVE: u32 = 0x2000; const PAGE_EXECUTE_READWRITE: u32 = 0x40; @@ -301,8 +448,25 @@ mod windows_hook { out: *mut *mut c_void, outer: *mut c_void, ) -> i32; - type ShellPumpFn = unsafe extern "thiscall" fn(*mut u8) -> i32; - type LargerManualLoadOwnerFn = unsafe extern "thiscall" fn(*mut u8, u32, u32); + type ShellStateServiceFn = unsafe extern "thiscall" fn(*mut u8) -> i32; + type ShellTransitionModeFn = unsafe extern "thiscall" fn(*mut u8, u32, u32) -> i32; + type ProfileStartupDispatchFn = unsafe extern "thiscall" fn(*mut u8, u32, u32) -> i32; + type RuntimeResetFn = unsafe extern "thiscall" fn(*mut u8) -> *mut u8; + type StartupRuntimeAllocThunkFn = unsafe extern "cdecl" fn(u32) -> *mut u8; + type LoadScreenSetScalarFn = unsafe extern "thiscall" fn(*mut u8, u32) -> u32; + type LoadScreenConstructFn = unsafe extern "thiscall" fn(*mut u8) -> *mut u8; + type LoadScreenHandleMessageFn = unsafe extern "thiscall" fn(*mut u8, *mut u8) -> i32; + type ShellRuntimePrimeFn = unsafe extern "thiscall" fn(*mut u8) -> i32; + type ShellFrameCycleFn = unsafe extern "thiscall" fn(*mut u8) -> i32; + type ShellObjectServiceFn = unsafe extern "thiscall" fn(*mut u8) -> i32; + type ShellChildServiceFn = unsafe extern "thiscall" fn(*mut u8) -> i32; + type ShellPublishWindowFn = unsafe extern "thiscall" fn(*mut u8, *mut u8, u32) -> i32; + type ShellUnpublishWindowFn = unsafe extern "thiscall" fn(*mut u8, *mut u8) -> i32; + type ShellObjectTeardownFn = unsafe extern "thiscall" fn(*mut u8) -> i32; + type ShellObjectRangeRemoveFn = unsafe extern "thiscall" fn(*mut u8, u32, u32, u32) -> i32; + type ShellRemoveNodeFn = unsafe extern "thiscall" fn(*mut u8, *mut u8) -> i32; + type ShellNodeVcallFn = unsafe extern "thiscall" fn(*mut u8, *mut u8) -> i32; + type Mode2TeardownFn = unsafe extern "thiscall" fn(*mut u8) -> i32; #[unsafe(no_mangle)] pub extern "system" fn DllMain( module: *mut c_void, @@ -385,11 +549,8 @@ mod windows_hook { } let base_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let _ = write_finance_snapshot_bundle( - &base_dir, - "attach_template", - &sample_finance_snapshot(), - ); + let _ = + write_finance_snapshot_bundle(&base_dir, "attach_template", &sample_finance_snapshot()); } fn maybe_start_finance_capture_thread() { @@ -418,8 +579,7 @@ mod windows_hook { } if let Some(snapshot) = unsafe { try_capture_probe_snapshot() } { append_log_message(FINANCE_CAPTURE_COMPANY_RESOLVED_MESSAGE); - if write_finance_snapshot_only(&base_dir, "attach_probe", &snapshot) - .is_ok() + if write_finance_snapshot_only(&base_dir, "attach_probe", &snapshot).is_ok() { append_log_message(FINANCE_CAPTURE_PROBE_WRITTEN_MESSAGE); return; @@ -444,16 +604,101 @@ mod windows_hook { append_log_message(AUTO_LOAD_STARTED_MESSAGE); AUTO_LOAD_THREAD_STARTED.store(true, Ordering::Release); - if unsafe { install_shell_pump_hook() } { + if unsafe { install_shell_state_service_hook() } { append_log_message(AUTO_LOAD_HOOK_INSTALLED_MESSAGE); } else { append_log_message(AUTO_LOAD_FAILURE_MESSAGE); } + if unsafe { install_profile_startup_dispatch_hook() } { + append_log_message(AUTO_LOAD_PROFILE_DISPATCH_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_runtime_reset_hook() } { + append_log_message(AUTO_LOAD_RUNTIME_RESET_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_allocator_hook() } { + append_log_message(AUTO_LOAD_ALLOCATOR_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_load_screen_scalar_hook() } { + append_log_message(AUTO_LOAD_LOAD_SCREEN_SCALAR_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_load_screen_construct_hook() } { + append_log_message(AUTO_LOAD_LOAD_SCREEN_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_load_screen_message_hook() } { + append_log_message(AUTO_LOAD_LOAD_SCREEN_MESSAGE_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_runtime_prime_hook() } { + append_log_message(AUTO_LOAD_RUNTIME_PRIME_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_frame_cycle_hook() } { + append_log_message(AUTO_LOAD_FRAME_CYCLE_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_object_service_hook() } { + append_log_message(AUTO_LOAD_OBJECT_SERVICE_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_child_service_hook() } { + append_log_message(AUTO_LOAD_CHILD_SERVICE_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_shell_publish_hook() } { + append_log_message(AUTO_LOAD_PUBLISH_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_shell_unpublish_hook() } { + append_log_message(AUTO_LOAD_UNPUBLISH_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_shell_object_teardown_hook() } { + append_log_message(AUTO_LOAD_OBJECT_TEARDOWN_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_shell_object_range_remove_hook() } { + append_log_message(AUTO_LOAD_OBJECT_RANGE_REMOVE_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_shell_remove_node_hook() } { + append_log_message(AUTO_LOAD_REMOVE_NODE_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_shell_node_vcall_hook() } { + append_log_message(AUTO_LOAD_NODE_VCALL_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } + if unsafe { install_mode2_teardown_hook() } { + append_log_message(AUTO_LOAD_MODE2_TEARDOWN_HOOK_INSTALLED_MESSAGE); + } else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + } } - fn run_auto_load_worker(save_stem: &str) { + fn run_auto_load_worker() { append_log_message(AUTO_LOAD_CALLING_MESSAGE); - let staged = unsafe { invoke_manual_load_branch(save_stem) }; + let staged = unsafe { invoke_manual_load_branch() }; if staged { append_log_message(AUTO_LOAD_TRIGGERED_MESSAGE); append_log_message(AUTO_LOAD_SUCCESS_MESSAGE); @@ -463,15 +708,33 @@ mod windows_hook { AUTO_LOAD_IN_PROGRESS.store(false, Ordering::Release); } - unsafe fn invoke_manual_load_branch(save_stem: &str) -> bool { + unsafe fn invoke_manual_load_branch() -> bool { + let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) }; + if shell_state.is_null() { + return false; + } + + let shell_transition_mode: ShellTransitionModeFn = + unsafe { mem::transmute(SHELL_TRANSITION_MODE_ADDR) }; + + AUTO_LOAD_ALLOCATOR_WINDOW_LOG_COUNT.store(0, Ordering::Release); + AUTO_LOAD_ALLOCATOR_WINDOW_ACTIVE.store(true, Ordering::Release); + append_log_message(AUTO_LOAD_OWNER_ENTRY_MESSAGE); + let _ = unsafe { shell_transition_mode(shell_state, SHELL_MODE_STARTUP_LOAD_DISPATCH, 0) }; + AUTO_LOAD_ALLOCATOR_WINDOW_ACTIVE.store(false, Ordering::Release); + append_log_message(AUTO_LOAD_OWNER_RETURNED_MESSAGE); + log_post_transition_state(); + + true + } + + unsafe fn stage_manual_load_request(save_stem: &str) -> bool { if save_stem.is_empty() || save_stem.as_bytes().contains(&0) { return false; } - let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) }; let runtime_profile = unsafe { read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8) }; - let active_mode = unsafe { resolve_active_mode_ptr() }; - if shell_state.is_null() || runtime_profile.is_null() || active_mode.is_null() { + if runtime_profile.is_null() { return false; } @@ -480,6 +743,14 @@ mod windows_hook { return false; } + unsafe { + ptr::write_unaligned( + runtime_profile + .add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET) + .cast::(), + STARTUP_SELECTOR_SCENARIO_LOAD, + ) + }; unsafe { ptr::write_unaligned( runtime_profile @@ -488,37 +759,101 @@ mod windows_hook { 0, ) }; - - let larger_owner: LargerManualLoadOwnerFn = - unsafe { mem::transmute(0x00438890usize) }; - - let global_active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) }; - - if global_active_mode.is_null() { - unsafe { - ptr::write_unaligned( - (ACTIVE_MODE_PTR_ADDR as *mut u8).cast::(), - active_mode as usize, - ) - }; - } - append_log_message(AUTO_LOAD_OWNER_ENTRY_MESSAGE); - unsafe { larger_owner(active_mode, 1, 0) }; - append_log_message(AUTO_LOAD_OWNER_RETURNED_MESSAGE); - if global_active_mode.is_null() { - unsafe { - ptr::write_unaligned((ACTIVE_MODE_PTR_ADDR as *mut u8).cast::(), 0) - }; - } - + log_auto_load_launch_state(runtime_profile); true } - unsafe fn write_c_string( - destination: *mut u8, - capacity: usize, - bytes: &[u8], - ) -> Option<()> { + fn log_auto_load_launch_state(runtime_profile: *mut u8) { + let startup_selector = + unsafe { read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) }; + let global_active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) }; + let mode_id = if shell_state.is_null() { + 0 + } else { + unsafe { read_u32(shell_state.add(SHELL_STATE_ACTIVE_MODE_OFFSET)) as usize } + }; + let mut line = String::from("rrt-hook: auto load launch state "); + let _ = write!( + &mut line, + "selector=0x{startup_selector:02x} mode_id=0x{mode_id:08x} global_active_mode=0x{global_active_mode:08x} target_mode=0x{SHELL_MODE_STARTUP_LOAD_DISPATCH:08x}\n", + ); + append_log_line(&line); + } + + fn log_post_transition_state() { + let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) }; + let active_mode_global = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let field_active_mode_object = if shell_state.is_null() { + 0 + } else { + unsafe { read_ptr(shell_state.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) as usize } + }; + let runtime_profile = unsafe { read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8) }; + let startup_selector = if runtime_profile.is_null() { + 0 + } else { + unsafe { + read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) as usize + } + }; + let load_screen_singleton = unsafe { read_ptr(0x006d10b0 as *const u8) }; + let load_screen_scalar = if load_screen_singleton.is_null() { + 0 + } else { + unsafe { read_u32(load_screen_singleton.add(0x78)) } + }; + let mut line = String::from("rrt-hook: auto load post-transition state "); + let _ = write!( + &mut line, + "shell_state=0x{:08x} active_mode_global=0x{active_mode_global:08x} field_active_mode_object=0x{field_active_mode_object:08x} load_screen_singleton=0x{:08x} load_screen_scalar=0x{load_screen_scalar:08x} startup_selector=0x{startup_selector:08x}\n", + shell_state as usize, load_screen_singleton as usize, + ); + append_log_line(&line); + } + + fn log_post_transition_service_state() { + if !AUTO_LOAD_ATTEMPTED.load(Ordering::Acquire) { + return; + } + let count = AUTO_LOAD_POST_TRANSITION_SERVICE_LOG_COUNT.fetch_add(1, Ordering::AcqRel); + if count >= 8 { + return; + } + + let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) }; + let active_mode_global = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let field_active_mode_object = if shell_state.is_null() { + 0 + } else { + unsafe { read_ptr(shell_state.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) as usize } + }; + let runtime_profile = unsafe { read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8) }; + let startup_selector = if runtime_profile.is_null() { + 0 + } else { + unsafe { + read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) as usize + } + }; + let load_screen_singleton = unsafe { read_ptr(0x006d10b0 as *const u8) }; + let load_screen_scalar = if load_screen_singleton.is_null() { + 0 + } else { + unsafe { read_u32(load_screen_singleton.add(0x78)) } + }; + let mut line = String::from("rrt-hook: auto load post-transition service state "); + let _ = write!( + &mut line, + "count=0x{:08x} shell_state=0x{:08x} active_mode_global=0x{active_mode_global:08x} field_active_mode_object=0x{field_active_mode_object:08x} load_screen_singleton=0x{:08x} load_screen_scalar=0x{load_screen_scalar:08x} startup_selector=0x{startup_selector:08x}\n", + count + 1, + shell_state as usize, + load_screen_singleton as usize, + ); + append_log_line(&line); + } + + unsafe fn write_c_string(destination: *mut u8, capacity: usize, bytes: &[u8]) -> Option<()> { if bytes.len() + 1 > capacity { return None; } @@ -574,13 +909,293 @@ mod windows_hook { .unwrap_or(AUTO_LOAD_DEFER_POLLS) } - unsafe extern "fastcall" fn shell_pump_detour(this: *mut u8, _edx: usize) -> i32 { - let trampoline: ShellPumpFn = unsafe { mem::transmute(SHELL_PUMP_TRAMPOLINE) }; + unsafe extern "fastcall" fn shell_state_service_detour(this: *mut u8, _edx: usize) -> i32 { + log_shell_state_service_entry(this); + let trampoline: ShellStateServiceFn = + unsafe { mem::transmute(SHELL_STATE_SERVICE_TRAMPOLINE) }; let result = unsafe { trampoline(this) }; + log_shell_state_service_return(this, result); + log_post_transition_service_state(); maybe_service_auto_load_on_main_thread(); result } + unsafe extern "fastcall" fn profile_startup_dispatch_detour( + this: *mut u8, + _edx: usize, + arg1: u32, + arg2: u32, + ) -> i32 { + log_profile_startup_dispatch_entry(this, arg1, arg2); + append_log_message(AUTO_LOAD_PROFILE_DISPATCH_ENTRY_MESSAGE); + let trampoline: ProfileStartupDispatchFn = + unsafe { mem::transmute(PROFILE_STARTUP_DISPATCH_TRAMPOLINE) }; + let result = unsafe { trampoline(this, arg1, arg2) }; + append_log_message(AUTO_LOAD_PROFILE_DISPATCH_RETURNED_MESSAGE); + log_profile_startup_dispatch_return(this, arg1, arg2, result); + result + } + + unsafe extern "fastcall" fn runtime_reset_detour(this: *mut u8, _edx: usize) -> *mut u8 { + append_log_message(AUTO_LOAD_RUNTIME_RESET_ENTRY_MESSAGE); + log_runtime_reset_entry(this); + let trampoline: RuntimeResetFn = unsafe { mem::transmute(RUNTIME_RESET_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + append_log_message(AUTO_LOAD_RUNTIME_RESET_RETURNED_MESSAGE); + log_runtime_reset_return(this, result); + result + } + + unsafe extern "cdecl" fn allocator_detour(size: u32) -> *mut u8 { + let trace = should_trace_allocator(size); + let count = if trace { + AUTO_LOAD_ALLOCATOR_WINDOW_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1 + } else { + 0 + }; + if trace { + append_log_message(AUTO_LOAD_ALLOCATOR_ENTRY_MESSAGE); + log_allocator_entry(size, count); + } + let original: StartupRuntimeAllocThunkFn = unsafe { mem::transmute(0x005a125dusize) }; + let result = unsafe { original(size) }; + if trace { + append_log_message(AUTO_LOAD_ALLOCATOR_RETURNED_MESSAGE); + log_allocator_return(size, count, result); + } + result + } + + unsafe extern "fastcall" fn load_screen_scalar_detour( + this: *mut u8, + _edx: usize, + value_bits: u32, + ) -> u32 { + append_log_message(AUTO_LOAD_LOAD_SCREEN_SCALAR_ENTRY_MESSAGE); + log_load_screen_scalar_entry(this, value_bits); + let trampoline: LoadScreenSetScalarFn = + unsafe { mem::transmute(LOAD_SCREEN_SCALAR_TRAMPOLINE) }; + let result = unsafe { trampoline(this, value_bits) }; + append_log_message(AUTO_LOAD_LOAD_SCREEN_SCALAR_RETURNED_MESSAGE); + log_load_screen_scalar_return(this, value_bits, result); + result + } + + unsafe extern "fastcall" fn load_screen_construct_detour( + this: *mut u8, + _edx: usize, + ) -> *mut u8 { + append_log_message(AUTO_LOAD_LOAD_SCREEN_ENTRY_MESSAGE); + log_load_screen_construct_entry(this); + let trampoline: LoadScreenConstructFn = + unsafe { mem::transmute(LOAD_SCREEN_CONSTRUCT_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + append_log_message(AUTO_LOAD_LOAD_SCREEN_RETURNED_MESSAGE); + log_load_screen_construct_return(this, result); + result + } + + unsafe extern "fastcall" fn load_screen_message_detour( + this: *mut u8, + _edx: usize, + message: *mut u8, + ) -> i32 { + let trace = should_trace_load_screen_message(this); + let count = if trace { + AUTO_LOAD_LOAD_SCREEN_MESSAGE_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1 + } else { + 0 + }; + if trace { + append_log_message(AUTO_LOAD_LOAD_SCREEN_MESSAGE_ENTRY_MESSAGE); + log_load_screen_message_entry(this, message, count); + } + let trampoline: LoadScreenHandleMessageFn = + unsafe { mem::transmute(LOAD_SCREEN_MESSAGE_TRAMPOLINE) }; + let result = unsafe { trampoline(this, message) }; + if trace { + append_log_message(AUTO_LOAD_LOAD_SCREEN_MESSAGE_RETURNED_MESSAGE); + log_load_screen_message_return(this, message, count, result); + } + result + } + + unsafe extern "fastcall" fn runtime_prime_detour(this: *mut u8, _edx: usize) -> i32 { + let trace = should_trace_runtime_prime(); + let count = if trace { + AUTO_LOAD_RUNTIME_PRIME_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1 + } else { + 0 + }; + if trace { + append_log_message(AUTO_LOAD_RUNTIME_PRIME_ENTRY_MESSAGE); + log_runtime_prime_entry(this, count); + } + let trampoline: ShellRuntimePrimeFn = unsafe { mem::transmute(RUNTIME_PRIME_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + if trace { + append_log_message(AUTO_LOAD_RUNTIME_PRIME_RETURNED_MESSAGE); + log_runtime_prime_return(this, count, result); + } + result + } + + unsafe extern "fastcall" fn frame_cycle_detour(this: *mut u8, _edx: usize) -> i32 { + let trace = should_trace_frame_cycle(); + let count = if trace { + AUTO_LOAD_FRAME_CYCLE_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1 + } else { + 0 + }; + if trace { + append_log_message(AUTO_LOAD_FRAME_CYCLE_ENTRY_MESSAGE); + log_frame_cycle_entry(this, count); + } + let trampoline: ShellFrameCycleFn = unsafe { mem::transmute(FRAME_CYCLE_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + if trace { + append_log_message(AUTO_LOAD_FRAME_CYCLE_RETURNED_MESSAGE); + log_frame_cycle_return(this, count, result); + } + result + } + + unsafe extern "fastcall" fn object_service_detour(this: *mut u8, _edx: usize) -> i32 { + let trace = should_trace_object_service(); + let count = if trace { + AUTO_LOAD_OBJECT_SERVICE_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1 + } else { + 0 + }; + if trace { + append_log_message(AUTO_LOAD_OBJECT_SERVICE_ENTRY_MESSAGE); + log_object_service_entry(this, count); + } + let trampoline: ShellObjectServiceFn = unsafe { mem::transmute(OBJECT_SERVICE_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + if trace { + append_log_message(AUTO_LOAD_OBJECT_SERVICE_RETURNED_MESSAGE); + log_object_service_return(this, count, result); + } + result + } + + unsafe extern "fastcall" fn child_service_detour(this: *mut u8, _edx: usize) -> i32 { + let trace = should_trace_child_service(); + let count = if trace { + AUTO_LOAD_CHILD_SERVICE_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1 + } else { + 0 + }; + if trace { + append_log_message(AUTO_LOAD_CHILD_SERVICE_ENTRY_MESSAGE); + log_child_service_entry(this, count); + } + let trampoline: ShellChildServiceFn = unsafe { mem::transmute(CHILD_SERVICE_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + if trace { + append_log_message(AUTO_LOAD_CHILD_SERVICE_RETURNED_MESSAGE); + log_child_service_return(this, count, result); + } + result + } + + unsafe extern "fastcall" fn shell_publish_detour( + this: *mut u8, + _edx: usize, + object: *mut u8, + flag: u32, + ) -> i32 { + append_log_message(AUTO_LOAD_PUBLISH_ENTRY_MESSAGE); + log_shell_publish_entry(this, object, flag); + let trampoline: ShellPublishWindowFn = unsafe { mem::transmute(SHELL_PUBLISH_TRAMPOLINE) }; + let result = unsafe { trampoline(this, object, flag) }; + append_log_message(AUTO_LOAD_PUBLISH_RETURNED_MESSAGE); + log_shell_publish_return(this, object, flag, result); + result + } + + unsafe extern "fastcall" fn shell_unpublish_detour( + this: *mut u8, + _edx: usize, + object: *mut u8, + ) -> i32 { + append_log_message(AUTO_LOAD_UNPUBLISH_ENTRY_MESSAGE); + log_shell_unpublish_entry(this, object); + let trampoline: ShellUnpublishWindowFn = + unsafe { mem::transmute(SHELL_UNPUBLISH_TRAMPOLINE) }; + let result = unsafe { trampoline(this, object) }; + append_log_message(AUTO_LOAD_UNPUBLISH_RETURNED_MESSAGE); + log_shell_unpublish_return(this, object, result); + result + } + + unsafe extern "fastcall" fn shell_object_range_remove_detour( + this: *mut u8, + _edx: usize, + arg1: u32, + arg2: u32, + arg3: u32, + ) -> i32 { + append_log_message(AUTO_LOAD_OBJECT_RANGE_REMOVE_ENTRY_MESSAGE); + log_shell_object_range_remove_entry(this, arg1, arg2, arg3); + let trampoline: ShellObjectRangeRemoveFn = + unsafe { mem::transmute(SHELL_OBJECT_RANGE_REMOVE_TRAMPOLINE) }; + let result = unsafe { trampoline(this, arg1, arg2, arg3) }; + append_log_message(AUTO_LOAD_OBJECT_RANGE_REMOVE_RETURNED_MESSAGE); + log_shell_object_range_remove_return(this, arg1, arg2, arg3, result); + result + } + + unsafe extern "fastcall" fn shell_object_teardown_detour(this: *mut u8, _edx: usize) -> i32 { + append_log_message(AUTO_LOAD_OBJECT_TEARDOWN_ENTRY_MESSAGE); + log_shell_object_teardown_entry(this); + let trampoline: ShellObjectTeardownFn = + unsafe { mem::transmute(SHELL_OBJECT_TEARDOWN_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + append_log_message(AUTO_LOAD_OBJECT_TEARDOWN_RETURNED_MESSAGE); + log_shell_object_teardown_return(this, result); + result + } + + unsafe extern "fastcall" fn shell_node_vcall_detour( + this: *mut u8, + _edx: usize, + record: *mut u8, + ) -> i32 { + append_log_message(AUTO_LOAD_NODE_VCALL_ENTRY_MESSAGE); + log_shell_node_vcall_entry(this, record); + let trampoline: ShellNodeVcallFn = unsafe { mem::transmute(SHELL_NODE_VCALL_TRAMPOLINE) }; + let result = unsafe { trampoline(this, record) }; + append_log_message(AUTO_LOAD_NODE_VCALL_RETURNED_MESSAGE); + log_shell_node_vcall_return(this, record, result); + result + } + + unsafe extern "fastcall" fn shell_remove_node_detour( + this: *mut u8, + _edx: usize, + node: *mut u8, + ) -> i32 { + append_log_message(AUTO_LOAD_REMOVE_NODE_ENTRY_MESSAGE); + log_shell_remove_node_entry(this, node); + let trampoline: ShellRemoveNodeFn = unsafe { mem::transmute(SHELL_REMOVE_NODE_TRAMPOLINE) }; + let result = unsafe { trampoline(this, node) }; + append_log_message(AUTO_LOAD_REMOVE_NODE_RETURNED_MESSAGE); + log_shell_remove_node_return(this, node, result); + result + } + + unsafe extern "fastcall" fn mode2_teardown_detour(this: *mut u8, _edx: usize) -> i32 { + append_log_message(AUTO_LOAD_MODE2_TEARDOWN_ENTRY_MESSAGE); + log_mode2_teardown_entry(this); + let trampoline: Mode2TeardownFn = unsafe { mem::transmute(MODE2_TEARDOWN_TRAMPOLINE) }; + let result = unsafe { trampoline(this) }; + append_log_message(AUTO_LOAD_MODE2_TEARDOWN_RETURNED_MESSAGE); + log_mode2_teardown_return(this, result); + result + } + fn maybe_service_auto_load_on_main_thread() { if !AUTO_LOAD_HOOK_INSTALLED.load(Ordering::Acquire) || AUTO_LOAD_ATTEMPTED.load(Ordering::Acquire) @@ -604,6 +1219,10 @@ mod windows_hook { AUTO_LOAD_DEFERRED.store(false, Ordering::Release); 0 }; + if ready { + append_log_message(AUTO_LOAD_READY_COUNT_MESSAGE); + log_auto_load_ready_count(ready_count, gate_mask, mode_id); + } let ready_polls = auto_load_ready_polls(); if ready_count < ready_polls { @@ -625,14 +1244,32 @@ mod windows_hook { return; } - let Some(save_stem) = AUTO_LOAD_SAVE_STEM.get() else { + if !AUTO_LOAD_TRANSITION_ARMED.load(Ordering::Acquire) { + let Some(save_stem) = AUTO_LOAD_SAVE_STEM.get() else { + append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + AUTO_LOAD_ATTEMPTED.store(true, Ordering::Release); + return; + }; + if unsafe { stage_manual_load_request(save_stem) } { + AUTO_LOAD_TRANSITION_ARMED.store(true, Ordering::Release); + AUTO_LOAD_ARMED_TICK_COUNT.store(0, Ordering::Release); + append_log_message(AUTO_LOAD_STAGED_MESSAGE); + log_auto_load_armed_tick(); + AUTO_LOAD_ATTEMPTED.store(true, Ordering::Release); + AUTO_LOAD_IN_PROGRESS.store(true, Ordering::Release); + append_log_message(AUTO_LOAD_ARMED_TICK_MESSAGE); + append_log_message(AUTO_LOAD_READY_MESSAGE); + run_auto_load_worker(); + return; + } append_log_message(AUTO_LOAD_FAILURE_MESSAGE); + AUTO_LOAD_ATTEMPTED.store(true, Ordering::Release); return; - }; + } AUTO_LOAD_IN_PROGRESS.store(true, Ordering::Release); append_log_message(AUTO_LOAD_READY_MESSAGE); - run_auto_load_worker(save_stem); + run_auto_load_worker(); } fn log_auto_load_gate_mask(mask: u32) { @@ -649,9 +1286,17 @@ mod windows_hook { } else { unsafe { read_ptr(shell_state.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) as usize } }; + let startup_selector = unsafe { + let runtime_profile = read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8); + if runtime_profile.is_null() { + 0 + } else { + read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) as usize + } + }; let _ = write!( &mut line, - "0x{mask:01x} shell_state={} shell_controller={} active_mode={} global_active_mode=0x{global_active_mode:08x} mode_id=0x{mode_id:08x} field_active_mode_object=0x{field_active_mode_object:08x}\n", + "0x{mask:01x} shell_state={} shell_controller={} active_mode={} global_active_mode=0x{global_active_mode:08x} mode_id=0x{mode_id:08x} startup_selector=0x{startup_selector:08x} field_active_mode_object=0x{field_active_mode_object:08x}\n", (mask & 0x1) != 0, (mask & 0x2) != 0, (mask & 0x4) != 0, @@ -659,6 +1304,1010 @@ mod windows_hook { append_log_line(&line); } + fn log_auto_load_armed_tick() { + let tick_count = AUTO_LOAD_ARMED_TICK_COUNT.fetch_add(1, Ordering::AcqRel) + 1; + let gate_mask = unsafe { runtime_saved_world_restore_gate_mask() }; + let global_active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) }; + let mode_id = if shell_state.is_null() { + 0 + } else { + unsafe { read_u32(shell_state.add(SHELL_STATE_ACTIVE_MODE_OFFSET)) as usize } + }; + let startup_selector = unsafe { + let runtime_profile = read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8); + if runtime_profile.is_null() { + 0 + } else { + read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) as usize + } + }; + let mut line = String::from("rrt-hook: auto load armed tick "); + let _ = write!( + &mut line, + "count=0x{tick_count:08x} gate_mask=0x{gate_mask:01x} mode_id=0x{mode_id:08x} startup_selector=0x{startup_selector:08x} global_active_mode=0x{global_active_mode:08x}\n", + ); + append_log_line(&line); + } + + fn log_auto_load_ready_count(count: u32, gate_mask: u32, mode_id: u32) { + let startup_selector = unsafe { + let runtime_profile = read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8); + if runtime_profile.is_null() { + 0 + } else { + read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) as usize + } + }; + let mut line = String::from("rrt-hook: auto load ready count "); + let _ = write!( + &mut line, + "count=0x{count:08x} gate_mask=0x{gate_mask:01x} mode_id=0x{mode_id:08x} startup_selector=0x{startup_selector:08x}\n", + ); + append_log_line(&line); + } + + fn log_shell_state_service_entry(this: *mut u8) { + let count = AUTO_LOAD_SERVICE_ENTRY_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1; + if count > 8 { + return; + } + let gate_mask = unsafe { runtime_saved_world_restore_gate_mask() }; + let mode_id = unsafe { current_mode_id() }; + let active_mode_global = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let field_active_mode_object = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) as usize } + }; + let field_a0 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0xa0)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell-state service entry "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} gate_mask=0x{gate_mask:01x} mode_id=0x{mode_id:08x} active_mode_global=0x{active_mode_global:08x} field_active_mode_object=0x{field_active_mode_object:08x} field_a0=0x{field_a0:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_shell_state_service_return(this: *mut u8, result: i32) { + let count = AUTO_LOAD_SERVICE_RETURN_LOG_COUNT.fetch_add(1, Ordering::AcqRel) + 1; + if count > 8 { + return; + } + let gate_mask = unsafe { runtime_saved_world_restore_gate_mask() }; + let mode_id = unsafe { current_mode_id() }; + let active_mode_global = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let field_active_mode_object = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) as usize } + }; + let field_a0 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0xa0)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell-state service return "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} result=0x{result:08x} gate_mask=0x{gate_mask:01x} mode_id=0x{mode_id:08x} active_mode_global=0x{active_mode_global:08x} field_active_mode_object=0x{field_active_mode_object:08x} field_a0=0x{field_a0:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_profile_startup_dispatch_entry(this: *mut u8, arg1: u32, arg2: u32) { + let startup_selector = unsafe { + let runtime_profile = read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8); + if runtime_profile.is_null() { + 0 + } else { + read_u8(runtime_profile.add(RUNTIME_PROFILE_STARTUP_SELECTOR_OFFSET)) as usize + } + }; + let mut line = String::from("rrt-hook: auto load startup dispatch entry "); + let _ = write!( + &mut line, + "this=0x{:08x} arg1=0x{arg1:08x} arg2=0x{arg2:08x} selector=0x{startup_selector:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_profile_startup_dispatch_return(this: *mut u8, arg1: u32, arg2: u32, result: i32) { + let active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load startup dispatch return "); + let _ = write!( + &mut line, + "this=0x{:08x} arg1=0x{arg1:08x} arg2=0x{arg2:08x} result=0x{result:08x} active_mode=0x{active_mode:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_runtime_reset_entry(this: *mut u8) { + let field_4cae = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x4cae)) as usize } + }; + let field_4cb2 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x4cb2)) as usize } + }; + let field_66b2 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x66b2)) as usize } + }; + let field_66b6 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x66b6)) as usize } + }; + let mut line = String::from("rrt-hook: auto load runtime reset entry "); + let _ = write!( + &mut line, + "this=0x{:08x} field_4cae=0x{field_4cae:08x} field_4cb2=0x{field_4cb2:08x} field_66b2=0x{field_66b2:08x} field_66b6=0x{field_66b6:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_runtime_reset_return(this: *mut u8, result: *mut u8) { + let field_4cae = if result.is_null() { + 0 + } else { + unsafe { read_u32(result.add(0x4cae)) as usize } + }; + let field_4cb2 = if result.is_null() { + 0 + } else { + unsafe { read_u32(result.add(0x4cb2)) as usize } + }; + let field_66b2 = if result.is_null() { + 0 + } else { + unsafe { read_u32(result.add(0x66b2)) as usize } + }; + let field_66b6 = if result.is_null() { + 0 + } else { + unsafe { read_u32(result.add(0x66b6)) as usize } + }; + let mut line = String::from("rrt-hook: auto load runtime reset return "); + let _ = write!( + &mut line, + "this=0x{:08x} result=0x{:08x} field_4cae=0x{field_4cae:08x} field_4cb2=0x{field_4cb2:08x} field_66b2=0x{field_66b2:08x} field_66b6=0x{field_66b6:08x}\n", + this as usize, result as usize, + ); + append_log_line(&line); + } + + fn should_trace_allocator(size: u32) -> bool { + if size == STARTUP_RUNTIME_OBJECT_SIZE { + return true; + } + AUTO_LOAD_ALLOCATOR_WINDOW_ACTIVE.load(Ordering::Acquire) + && AUTO_LOAD_ALLOCATOR_WINDOW_LOG_COUNT.load(Ordering::Acquire) < 16 + } + + fn log_allocator_entry(size: u32, count: u32) { + let mut line = String::from("rrt-hook: auto load allocator entry "); + let transition_window = AUTO_LOAD_ALLOCATOR_WINDOW_ACTIVE.load(Ordering::Acquire); + let _ = write!( + &mut line, + "count=0x{count:08x} size=0x{size:08x} transition_window={transition_window}\n", + ); + append_log_line(&line); + } + + fn log_allocator_return(size: u32, count: u32, result: *mut u8) { + let mut line = String::from("rrt-hook: auto load allocator return "); + let _ = write!( + &mut line, + "count=0x{count:08x} size=0x{size:08x} result=0x{:08x}\n", + result as usize, + ); + append_log_line(&line); + } + + fn log_load_screen_scalar_entry(this: *mut u8, value_bits: u32) { + let field_78 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x78)) } + }; + let mut line = String::from("rrt-hook: auto load load-screen scalar entry "); + let _ = write!( + &mut line, + "this=0x{:08x} value_bits=0x{value_bits:08x} field_78=0x{field_78:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_load_screen_scalar_return(this: *mut u8, value_bits: u32, result: u32) { + let field_78 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x78)) } + }; + let mut line = String::from("rrt-hook: auto load load-screen scalar return "); + let _ = write!( + &mut line, + "this=0x{:08x} value_bits=0x{value_bits:08x} result=0x{result:08x} field_78=0x{field_78:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_load_screen_construct_entry(this: *mut u8) { + let mut line = String::from("rrt-hook: auto load load-screen construct entry "); + let _ = write!(&mut line, "this=0x{:08x}\n", this as usize); + append_log_line(&line); + } + + fn log_load_screen_construct_return(this: *mut u8, result: *mut u8) { + let singleton = unsafe { read_ptr(0x006d10b0 as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load load-screen construct return "); + let _ = write!( + &mut line, + "this=0x{:08x} result=0x{:08x} singleton=0x{singleton:08x}\n", + this as usize, result as usize, + ); + append_log_line(&line); + if AUTO_LOAD_ALLOCATOR_WINDOW_ACTIVE.load(Ordering::Acquire) { + AUTO_LOAD_ALLOCATOR_WINDOW_LOG_COUNT.store(0, Ordering::Release); + } + } + + fn should_trace_load_screen_message(this: *mut u8) -> bool { + if !AUTO_LOAD_ATTEMPTED.load(Ordering::Acquire) { + return false; + } + let singleton = unsafe { read_ptr(0x006d10b0 as *const u8) }; + if singleton.is_null() || this != singleton { + return false; + } + AUTO_LOAD_LOAD_SCREEN_MESSAGE_LOG_COUNT.load(Ordering::Acquire) < 16 + } + + fn log_load_screen_message_entry(this: *mut u8, message: *mut u8, count: u32) { + let message_id = if message.is_null() { + 0 + } else { + unsafe { read_u32(message) } + }; + let message_arg8 = if message.is_null() { + 0 + } else { + unsafe { read_u32(message.add(0x08)) } + }; + let page = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x78)) } + }; + let page_substate = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x7c)) } + }; + let page_kind = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x80)) } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load load-screen message entry "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} message=0x{:08x} message_id=0x{message_id:08x} message_arg8=0x{message_arg8:08x} page=0x{page:08x} page_substate=0x{page_substate:08x} page_kind=0x{page_kind:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, message as usize, + ); + append_log_line(&line); + } + + fn log_load_screen_message_return(this: *mut u8, message: *mut u8, count: u32, result: i32) { + let page = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x78)) } + }; + let page_substate = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x7c)) } + }; + let page_kind = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x80)) } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load load-screen message return "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} message=0x{:08x} result=0x{result:08x} page=0x{page:08x} page_substate=0x{page_substate:08x} page_kind=0x{page_kind:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + message as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn should_trace_runtime_prime() -> bool { + AUTO_LOAD_RUNTIME_PRIME_LOG_COUNT.load(Ordering::Acquire) < 12 + } + + fn log_runtime_prime_entry(this: *mut u8, count: u32) { + let list_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x04)) as usize } + }; + let field_0c68 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x0c68)) as usize } + }; + let field_001c = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x1c)) as usize } + }; + let load_screen_singleton = unsafe { read_ptr(0x006d10b0 as *const u8) } as usize; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell runtime-prime entry "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} list_head=0x{list_head:08x} field_0c68=0x{field_0c68:02x} field_1c=0x{field_001c:08x} load_screen_singleton=0x{load_screen_singleton:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_runtime_prime_return(this: *mut u8, count: u32, result: i32) { + let list_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x04)) as usize } + }; + let field_001c = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x1c)) as usize } + }; + let load_screen_singleton = unsafe { read_ptr(0x006d10b0 as *const u8) } as usize; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell runtime-prime return "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} result=0x{result:08x} list_head=0x{list_head:08x} field_1c=0x{field_001c:08x} load_screen_singleton=0x{load_screen_singleton:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn should_trace_frame_cycle() -> bool { + AUTO_LOAD_FRAME_CYCLE_LOG_COUNT.load(Ordering::Acquire) < 12 + } + + fn log_frame_cycle_entry(this: *mut u8, count: u32) { + let field_18 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x18)) as usize } + }; + let field_1c = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x1c)) as usize } + }; + let field_20 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x20)) as usize } + }; + let field_28 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x28)) as usize } + }; + let flag_55 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x55)) as usize } + }; + let flag_56 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x56)) as usize } + }; + let field_58 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x58)) as usize } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell frame-cycle entry "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} field_18=0x{field_18:08x} field_1c=0x{field_1c:08x} field_20=0x{field_20:08x} field_28=0x{field_28:08x} flag_55=0x{flag_55:02x} flag_56=0x{flag_56:02x} field_58=0x{field_58:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_frame_cycle_return(this: *mut u8, count: u32, result: i32) { + let flag_55 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x55)) as usize } + }; + let flag_56 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x56)) as usize } + }; + let field_58 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x58)) as usize } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell frame-cycle return "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} result=0x{result:08x} flag_55=0x{flag_55:02x} flag_56=0x{flag_56:02x} field_58=0x{field_58:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn should_trace_object_service() -> bool { + AUTO_LOAD_ATTEMPTED.load(Ordering::Acquire) + && AUTO_LOAD_OBJECT_SERVICE_LOG_COUNT.load(Ordering::Acquire) < 16 + } + + fn log_object_service_entry(this: *mut u8, count: u32) { + let vtable = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this) as usize } + }; + let field_1d = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x1d)) as usize } + }; + let field_5c = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x5c)) as usize } + }; + let child_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x70)) as usize } + }; + let child_tail = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let first_child_vtable = if child_head == 0 { + 0 + } else { + unsafe { read_ptr(child_head as *const u8) as usize } + }; + let first_child_call18 = if first_child_vtable == 0 { + 0 + } else { + unsafe { read_ptr((first_child_vtable + 0x18) as *const u8) as usize } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let load_screen_singleton = unsafe { read_ptr(0x006d10b0 as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell object-service entry "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} vtable=0x{vtable:08x} field_1d=0x{field_1d:02x} field_5c=0x{field_5c:08x} child_head=0x{child_head:08x} child_tail=0x{child_tail:08x} child_call18=0x{first_child_call18:08x} load_screen_singleton=0x{load_screen_singleton:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_object_service_return(this: *mut u8, count: u32, result: i32) { + let field_1d = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x1d)) as usize } + }; + let child_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x70)) as usize } + }; + let child_tail = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell object-service return "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} result=0x{result:08x} field_1d=0x{field_1d:02x} child_head=0x{child_head:08x} child_tail=0x{child_tail:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn should_trace_child_service() -> bool { + AUTO_LOAD_ATTEMPTED.load(Ordering::Acquire) + && AUTO_LOAD_CHILD_SERVICE_LOG_COUNT.load(Ordering::Acquire) < 16 + } + + fn log_child_service_entry(this: *mut u8, count: u32) { + let vtable = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this) as usize } + }; + let field_4b = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x4b)) as usize } + }; + let flag_68 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x68)) as usize } + }; + let flag_6a = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x6a)) as usize } + }; + let field_86 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x86)) as usize } + }; + let field_b0 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0xb0)) as usize } + }; + let field_b8 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0xb8)) as usize } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let load_screen_singleton = unsafe { read_ptr(0x006d10b0 as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell child-service entry "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} vtable=0x{vtable:08x} field_4b=0x{field_4b:08x} flag_68=0x{flag_68:02x} flag_6a=0x{flag_6a:02x} field_86=0x{field_86:08x} field_b0=0x{field_b0:08x} field_b8=0x{field_b8:08x} load_screen_singleton=0x{load_screen_singleton:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_child_service_return(this: *mut u8, count: u32, result: i32) { + let field_b0 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0xb0)) as usize } + }; + let startup_runtime = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell child-service return "); + let _ = write!( + &mut line, + "count=0x{count:08x} this=0x{:08x} result=0x{result:08x} field_b0=0x{field_b0:08x} startup_runtime=0x{startup_runtime:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_shell_publish_entry(this: *mut u8, object: *mut u8, flag: u32) { + let mut line = String::from("rrt-hook: auto load shell publish entry "); + let _ = write!( + &mut line, + "this=0x{:08x} object=0x{:08x} flag=0x{flag:08x}\n", + this as usize, object as usize, + ); + append_log_line(&line); + } + + fn log_shell_publish_return(this: *mut u8, object: *mut u8, flag: u32, result: i32) { + let active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize; + let mut line = String::from("rrt-hook: auto load shell publish return "); + let _ = write!( + &mut line, + "this=0x{:08x} object=0x{:08x} flag=0x{flag:08x} result=0x{result:08x} active_mode=0x{active_mode:08x}\n", + this as usize, + object as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_shell_unpublish_entry(this: *mut u8, object: *mut u8) { + let bundle_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this) as usize } + }; + let bundle_tail = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x04)) as usize } + }; + let object_vtable = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object) as usize } + }; + let object_field_04 = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object.add(0x04)) as usize } + }; + let object_field_08 = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object.add(0x08)) as usize } + }; + let object_field_0c = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object.add(0x0c)) as usize } + }; + let object_prev = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object.add(0x54)) as usize } + }; + let object_next = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object.add(0x58)) as usize } + }; + let object_list_74 = if object.is_null() { + 0 + } else { + unsafe { read_ptr(object.add(0x74)) as usize } + }; + let list_field_00 = if object_list_74 == 0 { + 0 + } else { + unsafe { read_ptr(object_list_74 as *const u8) as usize } + }; + let list_field_04 = if object_list_74 == 0 { + 0 + } else { + unsafe { read_u16((object_list_74 as *const u8).add(0x04)) as usize } + }; + let list_field_4b = if object_list_74 == 0 { + 0 + } else { + unsafe { read_u32((object_list_74 as *const u8).add(0x4b)) as usize } + }; + let list_field_8a = if object_list_74 == 0 { + 0 + } else { + unsafe { read_ptr((object_list_74 as *const u8).add(0x8a)) as usize } + }; + let list_vcall_04 = if list_field_00 == 0 { + 0 + } else { + unsafe { read_ptr((list_field_00 as *const u8).add(0x04)) as usize } + }; + let list2_field_00 = if list_field_8a == 0 { + 0 + } else { + unsafe { read_ptr(list_field_8a as *const u8) as usize } + }; + let list2_field_04 = if list_field_8a == 0 { + 0 + } else { + unsafe { read_u16((list_field_8a as *const u8).add(0x04)) as usize } + }; + let list2_field_4b = if list_field_8a == 0 { + 0 + } else { + unsafe { read_u32((list_field_8a as *const u8).add(0x4b)) as usize } + }; + let list2_field_8a = if list_field_8a == 0 { + 0 + } else { + unsafe { read_ptr((list_field_8a as *const u8).add(0x8a)) as usize } + }; + let list2_vcall_04 = if list2_field_00 == 0 { + 0 + } else { + unsafe { read_ptr((list2_field_00 as *const u8).add(0x04)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell unpublish entry "); + let _ = write!( + &mut line, + "this=0x{:08x} object=0x{:08x} bundle_head=0x{bundle_head:08x} bundle_tail=0x{bundle_tail:08x} object_vtable=0x{object_vtable:08x} field_04=0x{object_field_04:08x} field_08=0x{object_field_08:08x} field_0c=0x{object_field_0c:08x} prev=0x{object_prev:08x} next=0x{object_next:08x} list_74=0x{object_list_74:08x} list_00=0x{list_field_00:08x} list_call4=0x{list_vcall_04:08x} list_04=0x{list_field_04:04x} list_4b=0x{list_field_4b:08x} list_8a=0x{list_field_8a:08x} list2_00=0x{list2_field_00:08x} list2_call4=0x{list2_vcall_04:08x} list2_04=0x{list2_field_04:04x} list2_4b=0x{list2_field_4b:08x} list2_8a=0x{list2_field_8a:08x}\n", + this as usize, object as usize, + ); + append_log_line(&line); + } + + fn log_shell_unpublish_return(this: *mut u8, object: *mut u8, result: i32) { + let mut line = String::from("rrt-hook: auto load shell unpublish return "); + let _ = write!( + &mut line, + "this=0x{:08x} object=0x{:08x} result=0x{result:08x}\n", + this as usize, + object as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_shell_object_teardown_entry(this: *mut u8) { + let object_vtable = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this) as usize } + }; + let field_04 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x04)) as usize } + }; + let field_08 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x08)) as usize } + }; + let field_0c = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x0c)) as usize } + }; + let head_70 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x70)) as usize } + }; + let head_74 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell object teardown entry "); + let _ = write!( + &mut line, + "this=0x{:08x} vtable=0x{object_vtable:08x} field_04=0x{field_04:08x} field_08=0x{field_08:08x} field_0c=0x{field_0c:08x} head_70=0x{head_70:08x} head_74=0x{head_74:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_shell_object_teardown_return(this: *mut u8, result: i32) { + let head_70 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x70)) as usize } + }; + let head_74 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell object teardown return "); + let _ = write!( + &mut line, + "this=0x{:08x} result=0x{result:08x} head_70=0x{head_70:08x} head_74=0x{head_74:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_shell_object_range_remove_entry(this: *mut u8, arg1: u32, arg2: u32, arg3: u32) { + let head_74 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell object range-remove entry "); + let _ = write!( + &mut line, + "this=0x{:08x} arg1=0x{arg1:08x} arg2=0x{arg2:08x} arg3=0x{arg3:08x} head_74=0x{head_74:08x}\n", + this as usize, + ); + append_log_line(&line); + } + + fn log_shell_object_range_remove_return( + this: *mut u8, + arg1: u32, + arg2: u32, + arg3: u32, + result: i32, + ) { + let head_74 = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell object range-remove return "); + let _ = write!( + &mut line, + "this=0x{:08x} arg1=0x{arg1:08x} arg2=0x{arg2:08x} arg3=0x{arg3:08x} result=0x{result:08x} head_74=0x{head_74:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_shell_node_vcall_entry(this: *mut u8, record: *mut u8) { + let node_vtable = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this) as usize } + }; + let node_type = if this.is_null() { + 0 + } else { + unsafe { read_u32(this) as usize } + }; + let node_field_08 = if this.is_null() { + 0 + } else { + unsafe { read_u32(this.add(0x08)) as usize } + }; + let node_field_20 = if this.is_null() { + 0 + } else { + unsafe { read_u8(this.add(0x20)) as usize } + }; + let record_kind = if record.is_null() { + 0 + } else { + unsafe { read_u32(record) as usize } + }; + let record_x = if record.is_null() { + 0 + } else { + unsafe { read_u32(record.add(0x24)) as usize } + }; + let record_y = if record.is_null() { + 0 + } else { + unsafe { read_u32(record.add(0x28)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell node-vcall entry "); + let _ = write!( + &mut line, + "this=0x{:08x} node_vtable=0x{node_vtable:08x} node_type=0x{node_type:08x} node_08=0x{node_field_08:08x} node_20=0x{node_field_20:02x} record=0x{:08x} record_kind=0x{record_kind:08x} record_x=0x{record_x:08x} record_y=0x{record_y:08x}\n", + this as usize, record as usize, + ); + append_log_line(&line); + } + + fn log_shell_node_vcall_return(this: *mut u8, record: *mut u8, result: i32) { + let mut line = String::from("rrt-hook: auto load shell node-vcall return "); + let _ = write!( + &mut line, + "this=0x{:08x} record=0x{:08x} result=0x{result:08x}\n", + this as usize, + record as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_shell_remove_node_entry(this: *mut u8, node: *mut u8) { + let owner_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x70)) as usize } + }; + let owner_tail = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let node_vtable = if node.is_null() { + 0 + } else { + unsafe { read_ptr(node) as usize } + }; + let node_kind = if node.is_null() { + 0 + } else { + unsafe { read_u16(node.add(0x04)) as usize } + }; + let node_owner = if node.is_null() { + 0 + } else { + unsafe { read_u32(node.add(0x4b)) as usize } + }; + let node_prev = if node.is_null() { + 0 + } else { + unsafe { read_ptr(node.add(0x8a)) as usize } + }; + let node_next = if node.is_null() { + 0 + } else { + unsafe { read_ptr(node.add(0x8e)) as usize } + }; + let node_call0 = if node_vtable == 0 { + 0 + } else { + unsafe { read_ptr(node_vtable as *const u8) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell remove-node entry "); + let _ = write!( + &mut line, + "this=0x{:08x} owner_head=0x{owner_head:08x} owner_tail=0x{owner_tail:08x} node=0x{:08x} node_vtable=0x{node_vtable:08x} node_call0=0x{node_call0:08x} node_kind=0x{node_kind:04x} node_owner=0x{node_owner:08x} node_prev=0x{node_prev:08x} node_next=0x{node_next:08x}\n", + this as usize, node as usize, + ); + append_log_line(&line); + } + + fn log_shell_remove_node_return(this: *mut u8, node: *mut u8, result: i32) { + let owner_head = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x70)) as usize } + }; + let owner_tail = if this.is_null() { + 0 + } else { + unsafe { read_ptr(this.add(0x74)) as usize } + }; + let mut line = String::from("rrt-hook: auto load shell remove-node return "); + let _ = write!( + &mut line, + "this=0x{:08x} owner_head=0x{owner_head:08x} owner_tail=0x{owner_tail:08x} node=0x{:08x} result=0x{result:08x}\n", + this as usize, + node as usize, + result = result as u32, + ); + append_log_line(&line); + } + + fn log_mode2_teardown_entry(this: *mut u8) { + let mut line = String::from("rrt-hook: auto load mode2 teardown entry "); + let _ = write!(&mut line, "this=0x{:08x}\n", this as usize); + append_log_line(&line); + } + + fn log_mode2_teardown_return(this: *mut u8, result: i32) { + let mut line = String::from("rrt-hook: auto load mode2 teardown return "); + let _ = write!( + &mut line, + "this=0x{:08x} result=0x{result:08x}\n", + this as usize, + result = result as u32, + ); + append_log_line(&line); + } + unsafe fn resolve_active_mode_ptr() -> *mut u8 { let global_active_mode = unsafe { resolve_global_active_mode_ptr() }; if !global_active_mode.is_null() { @@ -677,10 +2326,306 @@ mod windows_hook { unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } } - unsafe fn install_shell_pump_hook() -> bool { - const STOLEN_LEN: usize = 8; - let target = SHELL_PUMP_ADDR as *mut u8; - let trampoline_size = STOLEN_LEN + 5; + unsafe fn install_shell_state_service_hook() -> bool { + const STOLEN_LEN: usize = 6; + let target = SHELL_STATE_SERVICE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_state_service_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_STATE_SERVICE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_profile_startup_dispatch_hook() -> bool { + const STOLEN_LEN: usize = 16; + let target = PROFILE_STARTUP_DISPATCH_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + profile_startup_dispatch_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { PROFILE_STARTUP_DISPATCH_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_runtime_reset_hook() -> bool { + const STOLEN_LEN: usize = 16; + let target = RUNTIME_RESET_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + runtime_reset_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { RUNTIME_RESET_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_allocator_hook() -> bool { + const STOLEN_LEN: usize = 5; + let target = STARTUP_RUNTIME_ALLOC_THUNK_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour(target, STOLEN_LEN, allocator_detour as *const () as usize) + }; + if trampoline == 0 { + return false; + } + unsafe { ALLOCATOR_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_load_screen_scalar_hook() -> bool { + const STOLEN_LEN: usize = 10; + let target = LOAD_SCREEN_SET_SCALAR_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + load_screen_scalar_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { LOAD_SCREEN_SCALAR_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_load_screen_construct_hook() -> bool { + const STOLEN_LEN: usize = 6; + let target = LOAD_SCREEN_CONSTRUCT_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + load_screen_construct_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { LOAD_SCREEN_CONSTRUCT_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_load_screen_message_hook() -> bool { + const STOLEN_LEN: usize = 25; + let target = LOAD_SCREEN_HANDLE_MESSAGE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + load_screen_message_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { LOAD_SCREEN_MESSAGE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_runtime_prime_hook() -> bool { + const STOLEN_LEN: usize = 12; + let target = SHELL_RUNTIME_PRIME_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + runtime_prime_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { RUNTIME_PRIME_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_frame_cycle_hook() -> bool { + const STOLEN_LEN: usize = 6; + let target = SHELL_FRAME_CYCLE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour(target, STOLEN_LEN, frame_cycle_detour as *const () as usize) + }; + if trampoline == 0 { + return false; + } + unsafe { FRAME_CYCLE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_object_service_hook() -> bool { + const STOLEN_LEN: usize = 6; + let target = SHELL_OBJECT_SERVICE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + object_service_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { OBJECT_SERVICE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_child_service_hook() -> bool { + const STOLEN_LEN: usize = 6; + let target = SHELL_CHILD_SERVICE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + child_service_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { CHILD_SERVICE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_shell_publish_hook() -> bool { + const STOLEN_LEN: usize = 6; + let target = SHELL_PUBLISH_WINDOW_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_publish_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_PUBLISH_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_shell_unpublish_hook() -> bool { + const STOLEN_LEN: usize = 10; + let target = SHELL_UNPUBLISH_WINDOW_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_unpublish_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_UNPUBLISH_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_shell_object_teardown_hook() -> bool { + const STOLEN_LEN: usize = 7; + let target = SHELL_OBJECT_TEARDOWN_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_object_teardown_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_OBJECT_TEARDOWN_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_shell_object_range_remove_hook() -> bool { + const STOLEN_LEN: usize = 12; + let target = SHELL_OBJECT_RANGE_REMOVE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_object_range_remove_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_OBJECT_RANGE_REMOVE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_shell_node_vcall_hook() -> bool { + const STOLEN_LEN: usize = 21; + let target = SHELL_NODE_VCALL_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_node_vcall_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_NODE_VCALL_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_shell_remove_node_hook() -> bool { + const STOLEN_LEN: usize = 10; + let target = SHELL_REMOVE_NODE_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + shell_remove_node_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { SHELL_REMOVE_NODE_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_mode2_teardown_hook() -> bool { + const STOLEN_LEN: usize = 14; + let target = MODE2_TEARDOWN_ADDR as *mut u8; + let trampoline = unsafe { + install_rel32_detour( + target, + STOLEN_LEN, + mode2_teardown_detour as *const () as usize, + ) + }; + if trampoline == 0 { + return false; + } + unsafe { MODE2_TEARDOWN_TRAMPOLINE = trampoline }; + true + } + + unsafe fn install_rel32_detour(target: *mut u8, stolen_len: usize, detour: usize) -> usize { + let trampoline_size = stolen_len + 5; let trampoline = unsafe { VirtualAlloc( ptr::null_mut(), @@ -690,54 +2635,34 @@ mod windows_hook { ) } as *mut u8; if trampoline.is_null() { - return false; + return 0; } - unsafe { ptr::copy_nonoverlapping(target, trampoline, STOLEN_LEN) }; - unsafe { - write_rel32_jump( - trampoline.add(STOLEN_LEN), - target.add(STOLEN_LEN) as usize, - ) - }; + unsafe { ptr::copy_nonoverlapping(target, trampoline, stolen_len) }; + unsafe { write_rel32_jump(trampoline.add(stolen_len), target.add(stolen_len) as usize) }; let mut old_protect = 0_u32; if unsafe { VirtualProtect( target.cast(), - STOLEN_LEN, + stolen_len, PAGE_EXECUTE_READWRITE, &mut old_protect, ) } == 0 { - return false; + return 0; } - unsafe { write_rel32_jump(target, shell_pump_detour as *const () as usize) }; - unsafe { ptr::write(target.add(5), 0x90) }; - unsafe { ptr::write(target.add(6), 0x90) }; - unsafe { ptr::write(target.add(7), 0x90) }; - let mut restore_protect = 0_u32; - let _ = unsafe { - VirtualProtect( - target.cast(), - STOLEN_LEN, - old_protect, - &mut restore_protect, - ) - }; - let _ = unsafe { - FlushInstructionCache( - GetCurrentProcess(), - target.cast(), - STOLEN_LEN, - ) - }; - unsafe { - SHELL_PUMP_TRAMPOLINE = trampoline as usize; + unsafe { write_rel32_jump(target, detour) }; + for offset in 5..stolen_len { + unsafe { ptr::write(target.add(offset), 0x90) }; } - true + let mut restore_protect = 0_u32; + let _ = + unsafe { VirtualProtect(target.cast(), stolen_len, old_protect, &mut restore_protect) }; + let _ = unsafe { FlushInstructionCache(GetCurrentProcess(), target.cast(), stolen_len) }; + trampoline as usize } unsafe fn write_rel32_jump(location: *mut u8, destination: usize) { @@ -756,8 +2681,10 @@ mod windows_hook { for entry_id in 1..=id_bound as usize { if unsafe { indexed_collection_entry_id_is_live(collection, entry_id) } { - let company = unsafe { indexed_collection_resolve_live_entry_by_id(collection, entry_id) }; - if !company.is_null() && unsafe { read_u8(company.add(COMPANY_ACTIVE_OFFSET)) != 0 } { + let company = + unsafe { indexed_collection_resolve_live_entry_by_id(collection, entry_id) }; + if !company.is_null() && unsafe { read_u8(company.add(COMPANY_ACTIVE_OFFSET)) != 0 } + { return Some(company); } } @@ -814,7 +2741,9 @@ mod windows_hook { }, stride: unsafe { read_u32(collection.add(INDEXED_COLLECTION_STRIDE_OFFSET)) }, id_bound, - payload_ptr: unsafe { read_ptr(collection.add(INDEXED_COLLECTION_PAYLOAD_OFFSET)) as usize }, + payload_ptr: unsafe { + read_ptr(collection.add(INDEXED_COLLECTION_PAYLOAD_OFFSET)) as usize + }, tombstone_ptr: unsafe { read_ptr(collection.add(INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET)) as usize }, @@ -907,9 +2836,7 @@ mod windows_hook { } fn year_delta(current_year: u16, past_year: u16) -> u8 { - current_year - .saturating_sub(past_year) - .min(u8::MAX as u16) as u8 + current_year.saturating_sub(past_year).min(u8::MAX as u16) as u8 } unsafe fn indexed_collection_entry_id_is_live(collection: *const u8, entry_id: usize) -> bool { @@ -918,9 +2845,8 @@ mod windows_hook { return false; } - let tombstone_bits = unsafe { - read_ptr(collection.add(INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET)) - }; + let tombstone_bits = + unsafe { read_ptr(collection.add(INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET)) }; if tombstone_bits.is_null() { return true; } @@ -1034,7 +2960,8 @@ mod tests { let paths = write_finance_snapshot_bundle(&dir, "testcase", &sample_finance_snapshot()) .expect("bundle should be written"); - let snapshot_json = fs::read_to_string(&paths.snapshot_path).expect("snapshot should exist"); + let snapshot_json = + fs::read_to_string(&paths.snapshot_path).expect("snapshot should exist"); let outcome_json = fs::read_to_string(&paths.outcome_path).expect("outcome should exist"); assert!(snapshot_json.contains("\"policy\"")); diff --git a/crates/rrt-runtime/Cargo.toml b/crates/rrt-runtime/Cargo.toml new file mode 100644 index 0000000..925f64f --- /dev/null +++ b/crates/rrt-runtime/Cargo.toml @@ -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 diff --git a/crates/rrt-runtime/src/calendar.rs b/crates/rrt-runtime/src/calendar.rs new file mode 100644 index 0000000..eaa399a --- /dev/null +++ b/crates/rrt-runtime/src/calendar.rs @@ -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, + } + ); + } +} diff --git a/crates/rrt-runtime/src/campaign_exe.rs b/crates/rrt-runtime/src/campaign_exe.rs new file mode 100644 index 0000000..64da312 --- /dev/null +++ b/crates/rrt-runtime/src/campaign_exe.rs @@ -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, +} + +#[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, + pub scenarios: Vec, + pub notes: Vec, +} + +#[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, +} + +pub fn inspect_campaign_exe_file( + path: &Path, +) -> Result> { + let bytes = fs::read(path)?; + inspect_campaign_exe_bytes(&bytes) +} + +pub fn inspect_campaign_exe_bytes( + bytes: &[u8], +) -> Result> { + 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::, Box>>()?; + + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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) + ); + } +} diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs new file mode 100644 index 0000000..4b5b506 --- /dev/null +++ b/crates/rrt-runtime/src/import.rs @@ -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, + #[serde(default)] + pub source_binary: Option, +} + +#[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, + 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> { + 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> { + if let Ok(document) = serde_json::from_str::(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()); + } +} diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs new file mode 100644 index 0000000..dac6e60 --- /dev/null +++ b/crates/rrt-runtime/src/lib.rs @@ -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, +}; diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs new file mode 100644 index 0000000..eea01fe --- /dev/null +++ b/crates/rrt-runtime/src/persistence.rs @@ -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, + #[serde(default)] + pub description: Option, +} + +#[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> { + 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> { + 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); + } +} diff --git a/crates/rrt-runtime/src/pk4.rs b/crates/rrt-runtime/src/pk4.rs new file mode 100644 index 0000000..07332f1 --- /dev/null +++ b/crates/rrt-runtime/src/pk4.rs @@ -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, + 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, + pub entries: Vec, +} + +#[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> { + let bytes = fs::read(path)?; + inspect_pk4_bytes(&bytes) +} + +pub fn inspect_pk4_bytes(bytes: &[u8]) -> Result> { + 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> { + 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), Box> { + 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> { + 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 { + 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"); + } +} diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs new file mode 100644 index 0000000..ea5ef1d --- /dev/null +++ b/crates/rrt-runtime/src/runtime.rs @@ -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, + #[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, + #[serde(default)] + pub companies: Vec, + #[serde(default)] + pub event_runtime_records: Vec, + #[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()); + } +} diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs new file mode 100644 index 0000000..b92d736 --- /dev/null +++ b/crates/rrt-runtime/src/smp.rs @@ -0,0 +1,3370 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec; +const PREAMBLE_U32_WORD_COUNT: usize = 16; +const MIN_ASCII_RUN_LEN: usize = 8; +const ASCII_PREVIEW_CHAR_LIMIT: usize = 160; +const TAG_OFFSET_SAMPLE_LIMIT: usize = 8; +const EARLY_ZERO_RUN_THRESHOLD: usize = 16; +const EARLY_PREVIEW_BYTE_LIMIT: usize = 32; +const EARLY_ALIGNED_WORD_WINDOW_COUNT: usize = 8; +const SHARED_SIGNATURE_WORDS_1_TO_7: [u32; 7] = [ + 0x00002ee0, 0x00040001, 0x00028000, 0x00010000, 0x00000771, 0x00000771, 0x00000771, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct KnownTagDefinition { + tag_id: u16, + label: &'static str, + grounded_meaning: &'static str, +} + +const KNOWN_TAG_DEFINITIONS: [KnownTagDefinition; 4] = [ + KnownTagDefinition { + tag_id: 0x2cee, + label: "overlay_mask_plane_primary", + grounded_meaning: "Primary one-byte overlay mask plane restored into world offset +0x1655.", + }, + KnownTagDefinition { + tag_id: 0x2d51, + label: "overlay_mask_plane_secondary", + grounded_meaning: "Secondary one-byte overlay mask plane restored into world offset +0x1659.", + }, + KnownTagDefinition { + tag_id: 0x9471, + label: "sidecar_byte_plane_family_low", + grounded_meaning: "Lower bound of the grounded sidecar byte-plane chunk family.", + }, + KnownTagDefinition { + tag_id: 0x9472, + label: "sidecar_byte_plane_family_high", + grounded_meaning: "Upper bound of the grounded sidecar byte-plane chunk family.", + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpKnownTagHit { + pub tag_id: u16, + pub tag_hex: String, + pub label: String, + pub grounded_meaning: String, + pub hit_count: usize, + pub sample_offsets: Vec, + pub last_offset: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpPreambleWord { + pub index: usize, + pub offset: usize, + pub value_le: u32, + pub value_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpPreamble { + pub byte_len: usize, + pub word_count: usize, + pub words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpAsciiPreview { + pub offset: usize, + pub byte_len: usize, + pub preview: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpSharedHeader { + pub byte_len: usize, + pub root_kind_word: u32, + pub root_kind_word_hex: String, + pub primary_family_tag: u32, + pub primary_family_tag_hex: String, + pub shared_signature_words_1_to_7: Vec, + pub shared_signature_hex_words_1_to_7: Vec, + pub matches_grounded_common_signature: bool, + pub payload_window_words_8_to_9: Vec, + pub payload_window_hex_words_8_to_9: Vec, + pub reserved_words_10_to_14: Vec, + pub reserved_words_10_to_14_all_zero: bool, + pub final_flag_word: u32, + pub final_flag_word_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpHeaderVariantProbe { + pub variant_family: String, + pub variant_evidence: Vec, + pub is_known_family: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpEarlyContentProbe { + pub first_post_text_nonzero_offset: usize, + pub zero_pad_after_text_len: usize, + pub first_post_text_block_len: usize, + pub first_post_text_block_hex: String, + pub trailing_zero_pad_after_first_block_len: usize, + pub secondary_nonzero_offset: Option, + pub secondary_aligned_word_window_offset: Option, + pub secondary_aligned_word_window_words: Vec, + pub secondary_aligned_word_window_hex_words: Vec, + pub secondary_preview_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpSecondaryVariantProbe { + pub aligned_window_offset: usize, + pub words: Vec, + pub hex_words: Vec, + pub variant_family: String, + pub variant_evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpContainerProfile { + pub profile_family: String, + pub profile_evidence: Vec, + pub is_known_profile: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpSaveBootstrapBlock { + pub profile_family: String, + pub aligned_window_offset: usize, + pub leading_word: u32, + pub leading_word_hex: String, + pub anchor_word: u32, + pub anchor_word_hex: String, + pub descriptor_word_2: u32, + pub descriptor_word_2_hex: String, + pub descriptor_word_3: u32, + pub descriptor_word_3_hex: String, + pub descriptor_word_4: u32, + pub descriptor_word_4_hex: String, + pub descriptor_word_5: u32, + pub descriptor_word_5_hex: String, + pub descriptor_word_6: u32, + pub descriptor_word_6_hex: String, + pub descriptor_word_7: u32, + pub descriptor_word_7_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpSaveAnchorRunBlock { + pub profile_family: String, + pub cycle_start_offset: usize, + pub cycle_words: Vec, + pub cycle_hex_words: Vec, + pub full_cycle_count: usize, + pub partial_cycle_word_count: usize, + pub trailer_offset: usize, + pub trailer_words: Vec, + pub trailer_hex_words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRuntimeAnchorCycleBlock { + pub profile_family: String, + pub cycle_start_offset: usize, + pub cycle_words: Vec, + pub cycle_hex_words: Vec, + pub full_cycle_count: usize, + pub partial_cycle_word_count: usize, + pub trailer_offset: usize, + pub trailer_words: Vec, + pub trailer_hex_words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRuntimeTrailerBlock { + pub profile_family: String, + pub trailer_family: String, + pub trailer_evidence: Vec, + pub trailer_offset: usize, + pub prefix_words_0_to_5: Vec, + pub prefix_hex_words_0_to_5: Vec, + pub tag_word_6: u32, + pub tag_word_6_hex: String, + pub tag_chunk_id_u16: u16, + pub tag_chunk_id_hex: String, + pub tag_chunk_id_grounded_alignment: Option, + pub length_word_7: u32, + pub length_word_7_hex: String, + pub length_high_u16: u16, + pub length_high_hex: String, + pub selector_word_8: u32, + pub selector_word_8_hex: String, + pub selector_high_u16: u16, + pub selector_high_hex: String, + pub layout_word_9: u32, + pub layout_word_9_hex: String, + pub descriptor_word_10: u32, + pub descriptor_word_10_hex: String, + pub descriptor_high_u16: u16, + pub descriptor_high_hex: String, + pub descriptor_word_11: u32, + pub descriptor_word_11_hex: String, + pub counter_word_12: u32, + pub counter_word_12_hex: String, + pub offset_word_13: u32, + pub offset_word_13_hex: String, + pub span_word_14: u32, + pub span_word_14_hex: String, + pub mode_word_15: u32, + pub mode_word_15_hex: String, + pub words: Vec, + pub hex_words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRuntimePostSpanProbe { + pub profile_family: String, + pub span_target_offset: usize, + pub next_nonzero_offset: Option, + pub next_aligned_candidate_offset: Option, + pub next_aligned_candidate_words: Vec, + pub next_aligned_candidate_hex_words: Vec, + pub header_candidates: Vec, + pub grounded_progress_hits: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRuntimePostSpanHeaderCandidate { + pub offset: usize, + pub words: Vec, + pub hex_words: Vec, + pub dense_word_count: usize, + pub high_u16_words: Vec, + pub high_hex_words: Vec, + pub grounded_alignments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105PostSpanBridgeProbe { + pub profile_family: String, + pub bridge_family: String, + pub bridge_evidence: Vec, + pub span_target_offset: usize, + pub next_candidate_offset: Option, + pub next_candidate_delta_from_span_target: Option, + pub packed_profile_offset: usize, + pub packed_profile_delta_from_span_target: usize, + pub next_candidate_delta_from_packed_profile: Option, + pub selector_high_u16: u16, + pub selector_high_hex: String, + pub descriptor_high_u16: u16, + pub descriptor_high_hex: String, + pub next_candidate_high_u16_words: Vec, + pub next_candidate_high_hex_words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105SaveBridgePayloadProbe { + pub profile_family: String, + pub bridge_family: String, + pub primary_block_offset: usize, + pub primary_block_len: usize, + pub primary_block_len_hex: String, + pub primary_words: Vec, + pub primary_hex_words: Vec, + pub secondary_block_offset: usize, + pub secondary_block_delta_from_primary: usize, + pub secondary_block_delta_from_primary_hex: String, + pub secondary_block_end_offset: usize, + pub secondary_block_len: usize, + pub secondary_block_len_hex: String, + pub secondary_preview_word_count: usize, + pub secondary_words: Vec, + pub secondary_hex_words: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105SaveNameTableProbe { + pub profile_family: String, + pub source_kind: String, + pub semantic_family: String, + pub semantic_alignment: Vec, + pub header_offset: usize, + pub header_word_0: u32, + pub header_word_0_hex: String, + pub header_word_1: u32, + pub header_word_1_hex: String, + pub header_word_2: u32, + pub header_word_2_hex: String, + pub entry_stride: usize, + pub entry_stride_hex: String, + pub header_prefix_word_count: usize, + pub observed_entry_capacity: usize, + pub observed_entry_count: usize, + pub zero_trailer_entry_count: usize, + pub nonzero_trailer_entry_count: usize, + pub distinct_trailer_words: Vec, + pub distinct_trailer_hex_words: Vec, + pub zero_trailer_entry_names: Vec, + pub entries_offset: usize, + pub entries_end_offset: usize, + pub trailing_footer_hex: String, + pub footer_progress_word_0: u32, + pub footer_progress_word_0_hex: String, + pub footer_progress_word_1: u32, + pub footer_progress_word_1_hex: String, + pub footer_trailing_byte: u8, + pub footer_trailing_byte_hex: String, + pub footer_grounded_alignments: Vec, + pub entries: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105SaveNameTableEntry { + pub index: usize, + pub offset: usize, + pub text: String, + pub availability_dword: u32, + pub availability_dword_hex: String, + pub trailer_word: u32, + pub trailer_word_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpClassicRehydrateProfileProbe { + pub profile_family: String, + pub progress_32dc_offset: usize, + pub progress_3714_offset: usize, + pub progress_3715_offset: usize, + pub packed_profile_offset: usize, + pub packed_profile_len: usize, + pub packed_profile_len_hex: String, + pub packed_profile_block: SmpClassicPackedProfileBlock, + pub ascii_runs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpClassicPackedProfileBlock { + pub relative_len: usize, + pub relative_len_hex: String, + pub leading_word_0: u32, + pub leading_word_0_hex: String, + pub trailing_zero_word_count_after_leading_word: usize, + pub map_path_offset: usize, + pub map_path: Option, + pub display_name_offset: usize, + pub display_name: Option, + pub profile_byte_0x77: u8, + pub profile_byte_0x77_hex: String, + pub profile_byte_0x82: u8, + pub profile_byte_0x82_hex: String, + pub profile_byte_0x97: u8, + pub profile_byte_0x97_hex: String, + pub profile_byte_0xc5: u8, + pub profile_byte_0xc5_hex: String, + pub stable_nonzero_words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105PackedProfileProbe { + pub profile_family: String, + pub packed_profile_offset: usize, + pub packed_profile_len: usize, + pub packed_profile_len_hex: String, + pub packed_profile_block: SmpRt3105PackedProfileBlock, + pub ascii_runs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpRt3105PackedProfileBlock { + pub relative_len: usize, + pub relative_len_hex: String, + pub leading_word_0: u32, + pub leading_word_0_hex: String, + pub trailing_zero_word_count_after_leading_word: usize, + pub header_flag_word_3: u32, + pub header_flag_word_3_hex: String, + pub map_path_offset: usize, + pub map_path: Option, + pub display_name_offset: usize, + pub display_name: Option, + pub profile_byte_0x77: u8, + pub profile_byte_0x77_hex: String, + pub profile_byte_0x82: u8, + pub profile_byte_0x82_hex: String, + pub profile_byte_0x97: u8, + pub profile_byte_0x97_hex: String, + pub profile_byte_0xc5: u8, + pub profile_byte_0xc5_hex: String, + pub stable_nonzero_words: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpPackedProfileWordLane { + pub relative_offset: usize, + pub relative_offset_hex: String, + pub value: u32, + pub value_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmpInspectionReport { + pub inspection_mode: String, + pub file_extension_hint: Option, + pub file_size: usize, + pub sha256: String, + pub preamble: SmpPreamble, + pub shared_header: Option, + pub header_variant_probe: Option, + pub first_ascii_run: Option, + pub early_content_probe: Option, + pub secondary_variant_probe: Option, + pub container_profile: Option, + pub save_bootstrap_block: Option, + pub save_anchor_run_block: Option, + pub runtime_anchor_cycle_block: Option, + pub runtime_trailer_block: Option, + pub runtime_post_span_probe: Option, + pub rt3_105_post_span_bridge_probe: Option, + pub rt3_105_save_bridge_payload_probe: Option, + pub rt3_105_save_name_table_probe: Option, + pub classic_rehydrate_profile_probe: Option, + pub rt3_105_packed_profile_probe: Option, + pub contains_grounded_runtime_tags: bool, + pub known_tag_hits: Vec, + pub notes: Vec, + pub warnings: Vec, +} + +pub fn inspect_smp_file(path: &Path) -> Result> { + let bytes = fs::read(path)?; + Ok(inspect_bundle_bytes( + &bytes, + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()), + )) +} + +pub fn inspect_smp_bytes(bytes: &[u8]) -> SmpInspectionReport { + inspect_bundle_bytes(bytes, None) +} + +fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option) -> SmpInspectionReport { + let known_tag_hits = KNOWN_TAG_DEFINITIONS + .iter() + .filter_map(|definition| { + let offsets = find_u16_le_offsets(bytes, definition.tag_id); + if offsets.is_empty() { + return None; + } + + Some(SmpKnownTagHit { + tag_id: definition.tag_id, + tag_hex: format!("0x{:04x}", definition.tag_id), + label: definition.label.to_string(), + grounded_meaning: definition.grounded_meaning.to_string(), + hit_count: offsets.len(), + sample_offsets: offsets + .iter() + .copied() + .take(TAG_OFFSET_SAMPLE_LIMIT) + .collect(), + last_offset: offsets.last().copied(), + }) + }) + .collect::>(); + + let shared_header = parse_shared_header(bytes); + let header_variant_probe = shared_header.as_ref().map(classify_header_variant_probe); + let first_ascii_run = find_first_ascii_run(bytes); + let early_content_probe = first_ascii_run + .as_ref() + .and_then(|ascii_run| probe_early_content_layout(bytes, ascii_run)); + let secondary_variant_probe = early_content_probe + .as_ref() + .and_then(classify_secondary_variant_probe); + let container_profile = classify_container_profile( + file_extension_hint.as_deref(), + header_variant_probe.as_ref(), + secondary_variant_probe.as_ref(), + ); + let runtime_anchor_cycle_block = parse_runtime_anchor_cycle_block( + bytes, + container_profile.as_ref(), + secondary_variant_probe.as_ref(), + ); + let save_bootstrap_block = + parse_save_bootstrap_block(container_profile.as_ref(), secondary_variant_probe.as_ref()); + let save_anchor_run_block = parse_save_anchor_run_block( + bytes, + container_profile.as_ref(), + save_bootstrap_block.as_ref(), + ); + let runtime_trailer_block = parse_runtime_trailer_block( + container_profile.as_ref(), + runtime_anchor_cycle_block.as_ref(), + ); + let runtime_post_span_probe = + parse_runtime_post_span_probe(bytes, runtime_trailer_block.as_ref()); + let rt3_105_packed_profile_probe = parse_rt3_105_packed_profile_probe( + bytes, + file_extension_hint.as_deref(), + header_variant_probe.as_ref(), + container_profile.as_ref(), + ); + let rt3_105_post_span_bridge_probe = parse_rt3_105_post_span_bridge_probe( + runtime_trailer_block.as_ref(), + runtime_post_span_probe.as_ref(), + rt3_105_packed_profile_probe.as_ref(), + ); + let rt3_105_save_bridge_payload_probe = + parse_rt3_105_save_bridge_payload_probe(bytes, rt3_105_post_span_bridge_probe.as_ref()); + let rt3_105_save_name_table_probe = parse_rt3_105_save_name_table_probe( + bytes, + file_extension_hint.as_deref(), + container_profile.as_ref(), + rt3_105_save_bridge_payload_probe.as_ref(), + ); + let classic_rehydrate_profile_probe = + parse_classic_rehydrate_profile_probe(bytes, runtime_post_span_probe.as_ref()); + let mut warnings = Vec::new(); + if bytes.is_empty() { + warnings + .push("File is empty, so no `.smp` container structure could be observed.".to_string()); + } + + if known_tag_hits.is_empty() { + warnings.push( + "No grounded runtime bundle tags were found in little-endian form. This does not prove the file is invalid." + .to_string(), + ); + } + if shared_header.is_none() && !bytes.is_empty() { + warnings.push( + "File is shorter than the observed 64-byte common RT3 bundle preamble.".to_string(), + ); + } + if let Some(shared_header) = &shared_header { + let header_family_is_known = header_variant_probe + .as_ref() + .map(|probe| probe.is_known_family) + .unwrap_or(false); + if !shared_header.matches_grounded_common_signature && !header_family_is_known { + warnings.push( + "The first 64-byte preamble does not match the currently observed shared RT3 bundle signature." + .to_string(), + ); + } + } + if first_ascii_run.is_some() && early_content_probe.is_none() { + warnings.push( + "Found early text content but could not resolve the next stable nonzero region after its zero padding." + .to_string(), + ); + } + if container_profile + .as_ref() + .is_some_and(|profile| !profile.is_known_profile) + { + warnings.push( + "The current probes did not match any known composite container profile.".to_string(), + ); + } + if known_tag_hits + .iter() + .any(|hit| hit.hit_count > hit.sample_offsets.len()) + { + warnings.push( + "Known-tag offsets are sampled in this report. Large hit counts usually mean byte-pattern noise, not validated chunk boundaries." + .to_string(), + ); + } + warnings.push( + "Inspection scans raw bytes for a small grounded tag set only. It does not validate bundle layout or decode payloads." + .to_string(), + ); + + SmpInspectionReport { + inspection_mode: "grounded-tag-scan-plus-preamble".to_string(), + file_extension_hint, + file_size: bytes.len(), + sha256: sha256_hex(bytes), + preamble: parse_preamble(bytes), + shared_header, + header_variant_probe, + first_ascii_run, + early_content_probe, + secondary_variant_probe, + container_profile, + save_bootstrap_block, + save_anchor_run_block, + runtime_anchor_cycle_block, + runtime_trailer_block, + runtime_post_span_probe, + rt3_105_post_span_bridge_probe, + rt3_105_save_bridge_payload_probe, + rt3_105_save_name_table_probe, + classic_rehydrate_profile_probe, + rt3_105_packed_profile_probe, + contains_grounded_runtime_tags: !known_tag_hits.is_empty(), + known_tag_hits, + notes: vec![ + "Grounded `.smp` runtime tags currently include mask-plane payload ids 0x2cee and 0x2d51.".to_string(), + "Grounded sidecar-byte-plane bundle family currently spans 0x9471..0x9472.".to_string(), + "The shared-header parse is intentionally conservative: it only names common preamble lanes and checks the observed RT3 bundle-family signature.".to_string(), + "The header-variant probe classifies the preamble into one of the currently observed install-era families when possible." + .to_string(), + "The early-content probe resolves the first stable nonzero block after the padded scenario text and then captures the next aligned word window." + .to_string(), + "The secondary-variant probe classifies that aligned word window into one of the currently observed file-family patterns." + .to_string(), + "The container-profile layer combines extension hint, header family, and second-window family into one observed container classification." + .to_string(), + "The save-bootstrap reader currently parses one conservative 8-word descriptor only for known save-container profiles." + .to_string(), + "The save-anchor-run reader follows that descriptor tail into the observed repeated 9-word anchor cycle and captures the first trailer words after the cycle diverges." + .to_string(), + "The runtime-anchor-cycle reader applies the same cycle/trailer scan across the currently known save and sandbox runtime container profiles." + .to_string(), + "The runtime-trailer reader classifies the first 16 words after the cycle divergence into the currently observed runtime trailer families." + .to_string(), + "The runtime post-span probe follows the trailer's high-16 span lane into the later file region and records the next nonzero bytes, the first aligned high-16-dense candidate window, and any grounded progress-id hits found nearby." + .to_string(), + "The RT3 1.05 post-span bridge probe correlates the trailer selector/descriptor lanes with the next candidate region and the later packed-profile block for the currently observed 1.05 save families." + .to_string(), + "The RT3 1.05 common-save bridge payload probe captures the two stable bridge-stage blocks currently observed under the base 1.05 save branch." + .to_string(), + "The RT3 1.05 candidate-availability table probe decodes the fixed-width trailing name table from either the common-save bridge payload or the fixed 0x6a70..0x73c0 source range when that header validates." + .to_string(), + "The classic rehydrate-profile probe recognizes the grounded 0x32dc -> 0x3714 -> 0x3715 progress-id sequence and captures the exact 0x108-byte block between the latter two ids when that pattern appears." + .to_string(), + "The classic packed-profile block reader exposes the stable map-path, display-name, atlas-tracked latch bytes, and the small set of nonzero word lanes observed inside that 0x108-byte block." + .to_string(), + "The RT3 1.05 packed-profile probe recognizes the later string-bearing save block rooted at the first post-header .gmp path and exposes the observed map-path, display-name, atlas-tracked byte lanes, and stable nonzero words." + .to_string(), + format!( + "Restore-side loading of the four sidecar byte planes is only grounded for bundle versions >= 0x{:04x}.", + SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION + ), + ], + warnings, + } +} + +fn parse_preamble(bytes: &[u8]) -> SmpPreamble { + let byte_len = bytes.len().min(PREAMBLE_U32_WORD_COUNT * 4); + let words = bytes[..byte_len] + .chunks_exact(4) + .enumerate() + .map(|(index, chunk)| { + let value_le = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + SmpPreambleWord { + index, + offset: index * 4, + value_le, + value_hex: format!("0x{value_le:08x}"), + } + }) + .collect::>(); + + SmpPreamble { + byte_len, + word_count: words.len(), + words, + } +} + +fn parse_shared_header(bytes: &[u8]) -> Option { + let words = read_preamble_words(bytes)?; + let shared_signature_words_1_to_7 = words[1..=7].to_vec(); + let payload_window_words_8_to_9 = words[8..=9].to_vec(); + let reserved_words_10_to_14 = words[10..=14].to_vec(); + let final_flag_word = words[15]; + + Some(SmpSharedHeader { + byte_len: PREAMBLE_U32_WORD_COUNT * 4, + root_kind_word: words[0], + root_kind_word_hex: format!("0x{:08x}", words[0]), + primary_family_tag: words[1], + primary_family_tag_hex: format!("0x{:08x}", words[1]), + shared_signature_hex_words_1_to_7: shared_signature_words_1_to_7 + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + matches_grounded_common_signature: shared_signature_words_1_to_7 + == SHARED_SIGNATURE_WORDS_1_TO_7, + shared_signature_words_1_to_7, + payload_window_hex_words_8_to_9: payload_window_words_8_to_9 + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + payload_window_words_8_to_9, + reserved_words_10_to_14_all_zero: reserved_words_10_to_14.iter().all(|word| *word == 0), + reserved_words_10_to_14, + final_flag_word, + final_flag_word_hex: format!("0x{final_flag_word:08x}"), + }) +} + +fn classify_header_variant_probe(shared_header: &SmpSharedHeader) -> SmpHeaderVariantProbe { + let words = &shared_header.shared_signature_words_1_to_7; + let root = shared_header.root_kind_word; + let final_flag = shared_header.final_flag_word; + + let (variant_family, evidence, is_known_family) = match (root, words.as_slice(), final_flag) { + ( + 0x00002649, + [ + 0x00002ee0, + 0x00040001, + 0x00028000, + 0x00010000, + 0x00000771, + 0x00000771, + 0x00000771, + ], + 0x00000001, + ) => ( + "rt3-105-gmx-header-v1".to_string(), + vec![ + "root kind word 0x00002649".to_string(), + "1.05 common signature words 1..7".to_string(), + "final flag 0x00000001".to_string(), + ], + true, + ), + ( + 0x000025e5, + [ + 0x00002ee0, + 0x00040001, + 0x00028000, + 0x00010000, + 0x00000771, + 0x00000771, + 0x00000771, + ], + 0x00000000, + ) => ( + "rt3-105-common-header-v1".to_string(), + vec![ + "root kind word 0x000025e5".to_string(), + "1.05 common signature words 1..7".to_string(), + "final flag 0x00000000".to_string(), + ], + true, + ), + ( + 0x000025e5, + [ + 0x00002ee0, + 0x00040001, + 0x00018000, + 0x00010000, + 0x00000746, + 0x00000746, + 0x00000746, + ], + 0x00000000, + ) => ( + "rt3-105-scenario-save-header-v1".to_string(), + vec![ + "root kind word 0x000025e5".to_string(), + "1.05 scenario-save signature words 1..7".to_string(), + "final flag 0x00000000".to_string(), + ], + true, + ), + ( + 0x000025e5, + [ + 0x00002ee0, + 0x0001c001, + 0x00018000, + 0x00010000, + 0x00000754, + 0x00000754, + 0x00000754, + ], + 0x00000000, + ) => ( + "rt3-105-alt-save-header-v1".to_string(), + vec![ + "root kind word 0x000025e5".to_string(), + "1.05 alternate-save signature words 1..7".to_string(), + "final flag 0x00000000".to_string(), + ], + true, + ), + ( + 0x000026ad, + [ + 0x00002ee0, + 0x00014001, + 0x00020000, + 0x00010000, + 0x00000725, + 0x00000725, + 0x00000725, + ], + 0x00000100, + ) => ( + "rt3-classic-gms-header-v1".to_string(), + vec![ + "root kind word 0x000026ad".to_string(), + "classic save signature words 1..7".to_string(), + "final flag 0x00000100".to_string(), + ], + true, + ), + ( + 0x000026ad, + [ + 0x00002ee0, + 0x0001c001, + 0x00018000, + 0x00010000, + 0x00000765, + 0x00000765, + 0x00000765, + ], + 0x00000001, + ) => ( + "rt3-classic-gmx-header-v1".to_string(), + vec![ + "root kind word 0x000026ad".to_string(), + "classic sandbox signature words 1..7".to_string(), + "final flag 0x00000001".to_string(), + ], + true, + ), + (0x000025e5, [0x00002ee0, _, _, 0x00010000, _, _, _], 0x00000000 | 0x00000100) => ( + "rt3-map-header-family".to_string(), + vec![ + "root kind word 0x000025e5".to_string(), + "map-family anchor 0x00002ee0".to_string(), + "word4 0x00010000".to_string(), + ], + true, + ), + _ => ( + "unknown".to_string(), + vec![format!( + "root=0x{root:08x}, words1..7={}, final=0x{final_flag:08x}", + words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect::>() + .join(", ") + )], + false, + ), + }; + + SmpHeaderVariantProbe { + variant_family, + variant_evidence: evidence, + is_known_family, + } +} + +fn read_preamble_words(bytes: &[u8]) -> Option<[u32; PREAMBLE_U32_WORD_COUNT]> { + if bytes.len() < PREAMBLE_U32_WORD_COUNT * 4 { + return None; + } + + let mut words = [0u32; PREAMBLE_U32_WORD_COUNT]; + for (index, chunk) in bytes[..PREAMBLE_U32_WORD_COUNT * 4] + .chunks_exact(4) + .enumerate() + { + words[index] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + Some(words) +} + +fn probe_early_content_layout( + bytes: &[u8], + ascii_run: &SmpAsciiPreview, +) -> Option { + let search_start = ascii_run.offset + ascii_run.byte_len; + let first_post_text_nonzero_offset = find_next_nonzero_offset(bytes, search_start)?; + let zero_pad_after_text_len = first_post_text_nonzero_offset.saturating_sub(search_start); + let first_zero_run_after_block = find_zero_run( + bytes, + first_post_text_nonzero_offset, + EARLY_ZERO_RUN_THRESHOLD, + ) + .unwrap_or(bytes.len()); + let first_post_text_block = &bytes[first_post_text_nonzero_offset..first_zero_run_after_block]; + let secondary_nonzero_offset = find_next_nonzero_offset(bytes, first_zero_run_after_block); + let trailing_zero_pad_after_first_block_len = secondary_nonzero_offset + .map(|offset| offset.saturating_sub(first_zero_run_after_block)) + .unwrap_or_else(|| bytes.len().saturating_sub(first_zero_run_after_block)); + let secondary_aligned_word_window_offset = secondary_nonzero_offset.map(|offset| offset & !0x3); + let secondary_aligned_word_window_words = secondary_aligned_word_window_offset + .map(|offset| read_u32_window(bytes, offset, EARLY_ALIGNED_WORD_WINDOW_COUNT)) + .unwrap_or_default(); + let secondary_preview_hex = secondary_nonzero_offset + .map(|offset| { + hex_encode(&bytes[offset..bytes.len().min(offset + EARLY_PREVIEW_BYTE_LIMIT)]) + }) + .unwrap_or_default(); + + Some(SmpEarlyContentProbe { + first_post_text_nonzero_offset, + zero_pad_after_text_len, + first_post_text_block_len: first_post_text_block.len(), + first_post_text_block_hex: hex_encode(first_post_text_block), + trailing_zero_pad_after_first_block_len, + secondary_nonzero_offset, + secondary_aligned_word_window_offset, + secondary_aligned_word_window_hex_words: secondary_aligned_word_window_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + secondary_aligned_word_window_words, + secondary_preview_hex, + }) +} + +fn classify_secondary_variant_probe( + probe: &SmpEarlyContentProbe, +) -> Option { + let aligned_window_offset = probe.secondary_aligned_word_window_offset?; + let words = probe.secondary_aligned_word_window_words.clone(); + if words.is_empty() { + return None; + } + + let mut evidence = Vec::new(); + let variant_family = match words.as_slice() { + [0x001e0000, 0x86a00100, 0x03000001, 0xf0000100, ..] => { + evidence.push("leading word 0x001e0000".to_string()); + evidence.push("anchor word 0x86a00100".to_string()); + evidence.push("third/fourth words 0x03000001 and 0xf0000100".to_string()); + "rt3-gms-family-v1".to_string() + } + [0x000a0000, 0x49f00100, 0x00000002, 0xa0000000, ..] => { + evidence.push("leading word 0x000a0000".to_string()); + evidence.push("anchor word 0x49f00100".to_string()); + evidence.push("third/fourth words 0x00000002 and 0xa0000000".to_string()); + "rt3-gmx-family-v1".to_string() + } + [0x001c0000, 0x86a00100, 0x00000001, 0xa0000000, ..] => { + evidence.push("leading word 0x001c0000".to_string()); + evidence.push("anchor word 0x86a00100".to_string()); + evidence.push("third/fourth words 0x00000001 and 0xa0000000".to_string()); + "rt3-105-gms-family-v1".to_string() + } + [0x00190000, 0x86a00100, 0x00000001, 0xa0000000, ..] => { + evidence.push("leading word 0x00190000".to_string()); + evidence.push("anchor word 0x86a00100".to_string()); + evidence.push("third/fourth words 0x00000001 and 0xa0000000".to_string()); + "rt3-105-gmx-family-v1".to_string() + } + [0x00130000, 0x86a00100, 0x21000001, 0xa0000100, ..] => { + evidence.push("leading word 0x00130000".to_string()); + evidence.push("anchor word 0x86a00100".to_string()); + evidence.push("third/fourth words 0x21000001 and 0xa0000100".to_string()); + "rt3-105-gms-scenario-family-v1".to_string() + } + [0x00010000, 0x49f00100, 0x00000002, 0xa0000000, ..] => { + evidence.push("leading word 0x00010000".to_string()); + evidence.push("anchor word 0x49f00100".to_string()); + evidence.push("third/fourth words 0x00000002 and 0xa0000000".to_string()); + "rt3-105-gms-alt-family-v1".to_string() + } + [0x86a00100, 0x00000001, 0xa0000000, 0x00000186, ..] => { + evidence.push("window starts directly on 0x86a00100".to_string()); + evidence.push("likely same family with missing leading unaligned word".to_string()); + "rt3-family-unaligned-anchor".to_string() + } + _ => { + evidence.push(format!( + "unrecognized leading words: {}", + words + .iter() + .take(4) + .map(|word| format!("0x{word:08x}")) + .collect::>() + .join(", ") + )); + "unknown".to_string() + } + }; + + Some(SmpSecondaryVariantProbe { + aligned_window_offset, + hex_words: words.iter().map(|word| format!("0x{word:08x}")).collect(), + words, + variant_family, + variant_evidence: evidence, + }) +} + +fn classify_container_profile( + file_extension_hint: Option<&str>, + header_variant_probe: Option<&SmpHeaderVariantProbe>, + secondary_variant_probe: Option<&SmpSecondaryVariantProbe>, +) -> Option { + let header_family = header_variant_probe.map(|probe| probe.variant_family.as_str())?; + let secondary_family = secondary_variant_probe.map(|probe| probe.variant_family.as_str())?; + let extension = file_extension_hint.unwrap_or(""); + + let (profile_family, profile_evidence, is_known_profile) = + match (extension, header_family, secondary_family) { + ("gms", "rt3-classic-gms-header-v1", "rt3-gms-family-v1") => ( + "rt3-classic-save-container-v1".to_string(), + vec![ + "extension .gms".to_string(), + "classic save header family".to_string(), + "classic save secondary window family".to_string(), + ], + true, + ), + ("gmx", "rt3-classic-gmx-header-v1", "rt3-gmx-family-v1") => ( + "rt3-classic-sandbox-container-v1".to_string(), + vec![ + "extension .gmx".to_string(), + "classic sandbox header family".to_string(), + "classic sandbox secondary window family".to_string(), + ], + true, + ), + ("gms", "rt3-105-common-header-v1", "rt3-105-gms-family-v1") => ( + "rt3-105-save-container-v1".to_string(), + vec![ + "extension .gms".to_string(), + "1.05 common header family".to_string(), + "1.05 save secondary window family".to_string(), + ], + true, + ), + ("gms", "rt3-105-scenario-save-header-v1", "rt3-105-gms-scenario-family-v1") => ( + "rt3-105-scenario-save-container-v1".to_string(), + vec![ + "extension .gms".to_string(), + "1.05 scenario-save header family".to_string(), + "1.05 scenario-save secondary window family".to_string(), + ], + true, + ), + ("gms", "rt3-105-alt-save-header-v1", "rt3-105-gms-alt-family-v1") => ( + "rt3-105-alt-save-container-v1".to_string(), + vec![ + "extension .gms".to_string(), + "1.05 alternate-save header family".to_string(), + "1.05 alternate-save secondary window family".to_string(), + ], + true, + ), + ("gmx", "rt3-105-gmx-header-v1", "rt3-105-gmx-family-v1") => ( + "rt3-105-sandbox-container-v1".to_string(), + vec![ + "extension .gmx".to_string(), + "1.05 sandbox header family".to_string(), + "1.05 sandbox secondary window family".to_string(), + ], + true, + ), + ("gmp", "rt3-105-common-header-v1", "rt3-family-unaligned-anchor") => ( + "rt3-105-map-container-v1".to_string(), + vec![ + "extension .gmp".to_string(), + "1.05 common header family".to_string(), + "map-style secondary unaligned anchor".to_string(), + ], + true, + ), + ("gmp", "rt3-105-scenario-save-header-v1", "unknown") => ( + "rt3-105-scenario-map-container-v1".to_string(), + vec![ + "extension .gmp".to_string(), + "1.05 scenario-map header family".to_string(), + "fixed candidate-availability table range present despite unknown early secondary window".to_string(), + ], + true, + ), + ("gmp", "rt3-105-alt-save-header-v1", "unknown") => ( + "rt3-105-alt-map-container-v1".to_string(), + vec![ + "extension .gmp".to_string(), + "1.05 alternate-map header family".to_string(), + "fixed candidate-availability table range present despite unknown early secondary window".to_string(), + ], + true, + ), + ("gmp", "rt3-map-header-family", "rt3-family-unaligned-anchor") => ( + "rt3-map-container-family".to_string(), + vec![ + "extension .gmp".to_string(), + "map header family".to_string(), + "map-style secondary unaligned anchor".to_string(), + ], + true, + ), + (_, header_family, secondary_family) => ( + "unknown".to_string(), + vec![ + format!( + "extension {}", + if extension.is_empty() { + "" + } else { + extension + } + ), + format!("header family {header_family}"), + format!("secondary family {secondary_family}"), + ], + false, + ), + }; + + Some(SmpContainerProfile { + profile_family, + profile_evidence, + is_known_profile, + }) +} + +fn parse_save_bootstrap_block( + container_profile: Option<&SmpContainerProfile>, + secondary_variant_probe: Option<&SmpSecondaryVariantProbe>, +) -> Option { + let profile = container_profile?; + let secondary = secondary_variant_probe?; + let words = &secondary.words; + if words.len() < 8 { + return None; + } + + let supported = matches!( + profile.profile_family.as_str(), + "rt3-classic-save-container-v1" + | "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + ); + if !supported { + return None; + } + + Some(SmpSaveBootstrapBlock { + profile_family: profile.profile_family.clone(), + aligned_window_offset: secondary.aligned_window_offset, + leading_word: words[0], + leading_word_hex: format!("0x{:08x}", words[0]), + anchor_word: words[1], + anchor_word_hex: format!("0x{:08x}", words[1]), + descriptor_word_2: words[2], + descriptor_word_2_hex: format!("0x{:08x}", words[2]), + descriptor_word_3: words[3], + descriptor_word_3_hex: format!("0x{:08x}", words[3]), + descriptor_word_4: words[4], + descriptor_word_4_hex: format!("0x{:08x}", words[4]), + descriptor_word_5: words[5], + descriptor_word_5_hex: format!("0x{:08x}", words[5]), + descriptor_word_6: words[6], + descriptor_word_6_hex: format!("0x{:08x}", words[6]), + descriptor_word_7: words[7], + descriptor_word_7_hex: format!("0x{:08x}", words[7]), + }) +} + +fn parse_runtime_anchor_cycle_block( + bytes: &[u8], + container_profile: Option<&SmpContainerProfile>, + secondary_variant_probe: Option<&SmpSecondaryVariantProbe>, +) -> Option { + let profile = container_profile?; + let secondary = secondary_variant_probe?; + let supported = matches!( + profile.profile_family.as_str(), + "rt3-classic-save-container-v1" + | "rt3-classic-sandbox-container-v1" + | "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + | "rt3-105-sandbox-container-v1" + ); + if !supported { + return None; + } + + let cycle_start_offset = secondary.aligned_window_offset + 0x1c; + let cycle_words = read_u32_window(bytes, cycle_start_offset, 9); + if cycle_words.len() < 9 { + return None; + } + + let mut full_cycle_count = 0usize; + let mut cursor = cycle_start_offset; + while read_u32_window(bytes, cursor, cycle_words.len()) == cycle_words { + full_cycle_count += 1; + cursor += cycle_words.len() * 4; + } + + if full_cycle_count == 0 { + return None; + } + + let mut partial_cycle_word_count = 0usize; + while partial_cycle_word_count < cycle_words.len() { + let offset = cursor + partial_cycle_word_count * 4; + if read_u32_at(bytes, offset) == Some(cycle_words[partial_cycle_word_count]) { + partial_cycle_word_count += 1; + } else { + break; + } + } + + let trailer_offset = cursor + partial_cycle_word_count * 4; + let trailer_words = read_u32_window(bytes, trailer_offset, 16); + + Some(SmpRuntimeAnchorCycleBlock { + profile_family: profile.profile_family.clone(), + cycle_start_offset, + cycle_hex_words: cycle_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + cycle_words, + full_cycle_count, + partial_cycle_word_count, + trailer_offset, + trailer_hex_words: trailer_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + trailer_words, + }) +} + +fn parse_save_anchor_run_block( + bytes: &[u8], + container_profile: Option<&SmpContainerProfile>, + save_bootstrap_block: Option<&SmpSaveBootstrapBlock>, +) -> Option { + let profile = container_profile?; + let bootstrap = save_bootstrap_block?; + let supported = matches!( + profile.profile_family.as_str(), + "rt3-classic-save-container-v1" + | "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + ); + if !supported { + return None; + } + + let cycle_start_offset = bootstrap.aligned_window_offset + 0x1c; + let cycle_words = read_u32_window(bytes, cycle_start_offset, 9); + if cycle_words.len() < 9 { + return None; + } + + let mut full_cycle_count = 0usize; + let mut cursor = cycle_start_offset; + while read_u32_window(bytes, cursor, cycle_words.len()) == cycle_words { + full_cycle_count += 1; + cursor += cycle_words.len() * 4; + } + + if full_cycle_count == 0 { + return None; + } + + let mut partial_cycle_word_count = 0usize; + while partial_cycle_word_count < cycle_words.len() { + let offset = cursor + partial_cycle_word_count * 4; + if read_u32_at(bytes, offset) == Some(cycle_words[partial_cycle_word_count]) { + partial_cycle_word_count += 1; + } else { + break; + } + } + + let trailer_offset = cursor + partial_cycle_word_count * 4; + let trailer_words = read_u32_window(bytes, trailer_offset, 12); + + Some(SmpSaveAnchorRunBlock { + profile_family: profile.profile_family.clone(), + cycle_start_offset, + cycle_hex_words: cycle_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + cycle_words, + full_cycle_count, + partial_cycle_word_count, + trailer_offset, + trailer_hex_words: trailer_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + trailer_words, + }) +} + +fn parse_runtime_trailer_block( + container_profile: Option<&SmpContainerProfile>, + runtime_anchor_cycle_block: Option<&SmpRuntimeAnchorCycleBlock>, +) -> Option { + let profile = container_profile?; + let anchor = runtime_anchor_cycle_block?; + let words = &anchor.trailer_words; + if words.len() < 16 { + return None; + } + + let trailer_family = match profile.profile_family.as_str() { + "rt3-classic-save-container-v1" + if words[..6] + == [ + 0x00020000, 0x00030000, 0x00010000, 0x00010000, 0x00010000, 0x00020000, + ] => + { + "rt3-classic-save-trailer-v1" + } + "rt3-classic-sandbox-container-v1" + if words[..6] + == [ + 0x00010000, 0x00010000, 0x00010000, 0x00010000, 0x00000000, 0x00000000, + ] => + { + "rt3-classic-sandbox-trailer-v1" + } + "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + if words[..6] + == [ + 0x00010000, 0x00010000, 0x00010000, 0x00010000, 0x00000000, 0x00000000, + ] => + { + "rt3-105-save-trailer-v1" + } + "rt3-105-sandbox-container-v1" + if words[..6] + == [ + 0x00010000, 0x00010000, 0x00010000, 0x00010000, 0x00000000, 0x00000000, + ] => + { + "rt3-105-sandbox-trailer-v1" + } + _ => "unknown", + } + .to_string(); + + let tag_chunk_id_u16 = (words[6] >> 16) as u16; + let length_high_u16 = (words[7] >> 16) as u16; + let selector_high_u16 = (words[8] >> 16) as u16; + let descriptor_high_u16 = (words[10] >> 16) as u16; + let tag_chunk_id_grounded_alignment = + classify_runtime_trailer_chunk_id_grounded_alignment(tag_chunk_id_u16).map(str::to_string); + + let mut trailer_evidence = vec![ + format!("container profile {}", profile.profile_family), + format!( + "prefix words {}", + words[..6] + .iter() + .map(|word| format!("0x{word:08x}")) + .collect::>() + .join(", ") + ), + format!("high-16 chunk id 0x{tag_chunk_id_u16:04x} from trailer word 6"), + format!("high-16 span 0x{length_high_u16:04x} from trailer word 7"), + format!("high-16 selector 0x{selector_high_u16:04x} from trailer word 8"), + format!("high-16 descriptor 0x{descriptor_high_u16:04x} from trailer word 10"), + ]; + if let Some(alignment) = &tag_chunk_id_grounded_alignment { + trailer_evidence.push(alignment.clone()); + } + + Some(SmpRuntimeTrailerBlock { + profile_family: profile.profile_family.clone(), + trailer_family, + trailer_evidence, + trailer_offset: anchor.trailer_offset, + prefix_words_0_to_5: words[..6].to_vec(), + prefix_hex_words_0_to_5: words[..6] + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + tag_word_6: words[6], + tag_word_6_hex: format!("0x{:08x}", words[6]), + tag_chunk_id_u16, + tag_chunk_id_hex: format!("0x{tag_chunk_id_u16:04x}"), + tag_chunk_id_grounded_alignment, + length_word_7: words[7], + length_word_7_hex: format!("0x{:08x}", words[7]), + length_high_u16, + length_high_hex: format!("0x{length_high_u16:04x}"), + selector_word_8: words[8], + selector_word_8_hex: format!("0x{:08x}", words[8]), + selector_high_u16, + selector_high_hex: format!("0x{selector_high_u16:04x}"), + layout_word_9: words[9], + layout_word_9_hex: format!("0x{:08x}", words[9]), + descriptor_word_10: words[10], + descriptor_word_10_hex: format!("0x{:08x}", words[10]), + descriptor_high_u16, + descriptor_high_hex: format!("0x{descriptor_high_u16:04x}"), + descriptor_word_11: words[11], + descriptor_word_11_hex: format!("0x{:08x}", words[11]), + counter_word_12: words[12], + counter_word_12_hex: format!("0x{:08x}", words[12]), + offset_word_13: words[13], + offset_word_13_hex: format!("0x{:08x}", words[13]), + span_word_14: words[14], + span_word_14_hex: format!("0x{:08x}", words[14]), + mode_word_15: words[15], + mode_word_15_hex: format!("0x{:08x}", words[15]), + words: words.to_vec(), + hex_words: words.iter().map(|word| format!("0x{word:08x}")).collect(), + }) +} + +fn classify_runtime_trailer_chunk_id_grounded_alignment( + tag_chunk_id_u16: u16, +) -> Option<&'static str> { + match tag_chunk_id_u16 { + 0x2ee1 => Some( + "High-16 chunk id 0x2ee1 matches the disassembly-grounded map-style bundle family already read by shell_setup_load_selected_profile_bundle_into_payload_record.", + ), + _ => None, + } +} + +fn parse_runtime_post_span_probe( + bytes: &[u8], + runtime_trailer_block: Option<&SmpRuntimeTrailerBlock>, +) -> Option { + let trailer = runtime_trailer_block?; + let span_target_offset = trailer.trailer_offset + trailer.length_high_u16 as usize; + let next_nonzero_offset = find_next_nonzero_offset(bytes, span_target_offset); + let header_candidates = + collect_runtime_post_span_header_candidates(bytes, span_target_offset, 0x8000); + let next_aligned_candidate_offset = header_candidates.first().map(|candidate| candidate.offset); + let next_aligned_candidate_words = header_candidates + .first() + .map(|candidate| candidate.words.clone()) + .unwrap_or_default(); + let grounded_progress_hits = + find_grounded_progress_high16_hits(bytes, span_target_offset, 0x8000); + + Some(SmpRuntimePostSpanProbe { + profile_family: trailer.profile_family.clone(), + span_target_offset, + next_nonzero_offset, + next_aligned_candidate_offset, + next_aligned_candidate_hex_words: next_aligned_candidate_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + next_aligned_candidate_words, + header_candidates, + grounded_progress_hits, + }) +} + +fn parse_classic_rehydrate_profile_probe( + bytes: &[u8], + runtime_post_span_probe: Option<&SmpRuntimePostSpanProbe>, +) -> Option { + let post_span = runtime_post_span_probe?; + if post_span.profile_family != "rt3-classic-save-container-v1" { + return None; + } + + let progress_32dc_offset = + parse_grounded_progress_hit_offset(&post_span.grounded_progress_hits, 0x32dc)?; + let progress_3714_offset = + parse_grounded_progress_hit_offset(&post_span.grounded_progress_hits, 0x3714)?; + let progress_3715_offset = + parse_grounded_progress_hit_offset(&post_span.grounded_progress_hits, 0x3715)?; + let packed_profile_offset = progress_3714_offset + 4; + let packed_profile_len = progress_3715_offset.checked_sub(packed_profile_offset)?; + if packed_profile_len != 0x108 { + return None; + } + + let ascii_runs = + collect_ascii_previews_in_range(bytes, packed_profile_offset, progress_3715_offset, 4); + let packed_profile_block = + parse_classic_packed_profile_block(bytes, packed_profile_offset, packed_profile_len)?; + + Some(SmpClassicRehydrateProfileProbe { + profile_family: post_span.profile_family.clone(), + progress_32dc_offset, + progress_3714_offset, + progress_3715_offset, + packed_profile_offset, + packed_profile_len, + packed_profile_len_hex: format!("0x{packed_profile_len:03x}"), + packed_profile_block, + ascii_runs, + }) +} + +fn parse_classic_packed_profile_block( + bytes: &[u8], + packed_profile_offset: usize, + packed_profile_len: usize, +) -> Option { + let block_end = packed_profile_offset.checked_add(packed_profile_len)?; + if block_end > bytes.len() || packed_profile_len != 0x108 { + return None; + } + + let leading_word_0 = read_u32_at(bytes, packed_profile_offset)?; + let trailing_zero_word_count_after_leading_word = (1..4) + .take_while(|index| { + read_u32_at(bytes, packed_profile_offset + (index * 4)).is_some_and(|word| word == 0) + }) + .count(); + let map_path_offset = 0x13; + let display_name_offset = 0x46; + let stable_nonzero_word_offsets = [0x00usize, 0x10, 0x78, 0x7c, 0x84, 0x88]; + let stable_nonzero_words = stable_nonzero_word_offsets + .iter() + .filter_map(|relative_offset| { + let value = read_u32_at(bytes, packed_profile_offset + relative_offset)?; + if value == 0 { + return None; + } + + Some(SmpPackedProfileWordLane { + relative_offset: *relative_offset, + relative_offset_hex: format!("0x{relative_offset:02x}"), + value, + value_hex: format!("0x{value:08x}"), + }) + }) + .collect::>(); + + Some(SmpClassicPackedProfileBlock { + relative_len: packed_profile_len, + relative_len_hex: format!("0x{packed_profile_len:03x}"), + leading_word_0, + leading_word_0_hex: format!("0x{leading_word_0:08x}"), + trailing_zero_word_count_after_leading_word, + map_path_offset, + map_path: read_c_string_in_range(bytes, packed_profile_offset + map_path_offset, block_end), + display_name_offset, + display_name: read_c_string_in_range( + bytes, + packed_profile_offset + display_name_offset, + block_end, + ), + profile_byte_0x77: bytes[packed_profile_offset + 0x77], + profile_byte_0x77_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0x77]), + profile_byte_0x82: bytes[packed_profile_offset + 0x82], + profile_byte_0x82_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0x82]), + profile_byte_0x97: bytes[packed_profile_offset + 0x97], + profile_byte_0x97_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0x97]), + profile_byte_0xc5: bytes[packed_profile_offset + 0xc5], + profile_byte_0xc5_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0xc5]), + stable_nonzero_words, + }) +} + +fn parse_rt3_105_packed_profile_probe( + bytes: &[u8], + file_extension_hint: Option<&str>, + header_variant_probe: Option<&SmpHeaderVariantProbe>, + container_profile: Option<&SmpContainerProfile>, +) -> Option { + let profile_family = if container_profile.is_some_and(|profile| { + matches!( + profile.profile_family.as_str(), + "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + ) + }) { + container_profile + .expect("checked above") + .profile_family + .clone() + } else if file_extension_hint == Some("gms") + && header_variant_probe.is_some_and(|probe| { + matches!( + probe.variant_family.as_str(), + "rt3-105-common-header-v1" + | "rt3-105-scenario-save-header-v1" + | "rt3-105-alt-save-header-v1" + | "rt3-map-header-family" + ) + }) + { + "rt3-105-save-analog-block-inferred".to_string() + } else { + return None; + }; + + if file_extension_hint != Some("gms") { + return None; + } + + let map_path_offset = find_c_string_with_suffix_in_range(bytes, 0x7000, 0x9000, ".gmp")?; + let packed_profile_offset = map_path_offset.checked_sub(0x10)?; + let packed_profile_len = 0x108usize; + let block_end = packed_profile_offset.checked_add(packed_profile_len)?; + if block_end > bytes.len() { + return None; + } + + let packed_profile_block = + parse_rt3_105_packed_profile_block(bytes, packed_profile_offset, packed_profile_len)?; + let ascii_runs = collect_ascii_previews_in_range(bytes, packed_profile_offset, block_end, 4); + + Some(SmpRt3105PackedProfileProbe { + profile_family, + packed_profile_offset, + packed_profile_len, + packed_profile_len_hex: format!("0x{packed_profile_len:03x}"), + packed_profile_block, + ascii_runs, + }) +} + +fn parse_rt3_105_post_span_bridge_probe( + runtime_trailer_block: Option<&SmpRuntimeTrailerBlock>, + runtime_post_span_probe: Option<&SmpRuntimePostSpanProbe>, + rt3_105_packed_profile_probe: Option<&SmpRt3105PackedProfileProbe>, +) -> Option { + let trailer = runtime_trailer_block?; + let post_span = runtime_post_span_probe?; + let packed_profile = rt3_105_packed_profile_probe?; + let supported = matches!( + trailer.profile_family.as_str(), + "rt3-105-save-container-v1" + | "rt3-105-scenario-save-container-v1" + | "rt3-105-alt-save-container-v1" + | "rt3-105-save-analog-block-inferred" + ); + if !supported || trailer.profile_family != post_span.profile_family { + return None; + } + + let next_candidate_high_u16_words = post_span + .header_candidates + .first() + .map(|candidate| candidate.high_u16_words.clone()) + .unwrap_or_default(); + let next_candidate_high_hex_words = next_candidate_high_u16_words + .iter() + .map(|word| format!("0x{word:04x}")) + .collect::>(); + let next_candidate_offset = post_span.next_aligned_candidate_offset; + let next_candidate_delta_from_span_target = + next_candidate_offset.and_then(|offset| offset.checked_sub(post_span.span_target_offset)); + let packed_profile_delta_from_span_target = packed_profile + .packed_profile_offset + .checked_sub(post_span.span_target_offset)?; + let next_candidate_delta_from_packed_profile = next_candidate_offset + .map(|offset| offset as i64 - packed_profile.packed_profile_offset as i64); + + let mut bridge_evidence = vec![ + format!("profile family {}", trailer.profile_family), + format!("selector high {}", trailer.selector_high_hex), + format!("descriptor high {}", trailer.descriptor_high_hex), + format!( + "packed profile sits +0x{packed_profile_delta_from_span_target:x} from span target" + ), + ]; + if let Some(delta) = next_candidate_delta_from_span_target { + bridge_evidence.push(format!("next candidate sits +0x{delta:x} from span target")); + } + if let Some(delta) = next_candidate_delta_from_packed_profile { + bridge_evidence.push(format!( + "next candidate is {delta:+#x} relative to packed profile" + )); + } + + let bridge_family = match ( + trailer.selector_high_u16, + trailer.descriptor_high_u16, + next_candidate_high_u16_words.as_slice(), + ) { + (0x7110, 0x7801 | 0x7401, [0x6200, 0x0000, 0xfff7, 0x5515, ..]) => { + bridge_evidence.push(format!( + "selector/descriptor pair 0x7110 -> 0x{:04x}", + trailer.descriptor_high_u16 + )); + bridge_evidence.push( + "next candidate begins with high-16 lanes 0x6200/0x0000/0xfff7/0x5515" + .to_string(), + ); + "rt3-105-save-post-span-bridge-v1" + } + (0x54cd, 0x5901, [0x1500, 0x0100, 0x4100, 0x0200, ..]) => { + bridge_evidence.push("selector/descriptor pair 0x54cd -> 0x5901".to_string()); + bridge_evidence.push( + "next candidate begins with high-16 lanes 0x1500/0x0100/0x4100/0x0200" + .to_string(), + ); + "rt3-105-alt-save-post-span-bridge-v1" + } + (0x0001, 0x0186, [0x0186, 0x0006, 0x0006, 0x0001, ..]) => { + bridge_evidence.push("selector/descriptor pair 0x0001 -> 0x0186".to_string()); + bridge_evidence.push( + "next candidate remains in the local cycle neighborhood with 0x0186/0x0006/0x0006/0x0001" + .to_string(), + ); + "rt3-105-scenario-post-span-bridge-v1" + } + _ => "unknown", + } + .to_string(); + + Some(SmpRt3105PostSpanBridgeProbe { + profile_family: trailer.profile_family.clone(), + bridge_family, + bridge_evidence, + span_target_offset: post_span.span_target_offset, + next_candidate_offset, + next_candidate_delta_from_span_target, + packed_profile_offset: packed_profile.packed_profile_offset, + packed_profile_delta_from_span_target, + next_candidate_delta_from_packed_profile, + selector_high_u16: trailer.selector_high_u16, + selector_high_hex: trailer.selector_high_hex.clone(), + descriptor_high_u16: trailer.descriptor_high_u16, + descriptor_high_hex: trailer.descriptor_high_hex.clone(), + next_candidate_high_u16_words, + next_candidate_high_hex_words, + }) +} + +fn parse_rt3_105_save_bridge_payload_probe( + bytes: &[u8], + bridge_probe: Option<&SmpRt3105PostSpanBridgeProbe>, +) -> Option { + let bridge = bridge_probe?; + if bridge.bridge_family != "rt3-105-save-post-span-bridge-v1" { + return None; + } + + let primary_block_offset = bridge.next_candidate_offset?; + let primary_block_word_count = 8usize; + let primary_words = read_u32_window(bytes, primary_block_offset, primary_block_word_count); + if primary_words.len() < primary_block_word_count { + return None; + } + + let secondary_block_delta_from_primary = 0x1808usize; + let secondary_block_offset = primary_block_offset + secondary_block_delta_from_primary; + let secondary_block_end_offset = bridge.packed_profile_offset; + let secondary_block_len = secondary_block_end_offset.checked_sub(secondary_block_offset)?; + let secondary_preview_word_count = 32usize; + let secondary_words = + read_u32_window(bytes, secondary_block_offset, secondary_preview_word_count); + if secondary_words.len() < secondary_preview_word_count { + return None; + } + + let primary_signature_matches = primary_words + == [ + 0x62000000, 0x00000000, 0xfff70000, 0x55150000, 0x55550000, 0x00000000, 0xfff70000, + 0x54550000, + ]; + let secondary_prefix_matches = secondary_words.starts_with(&[ + 0x00050000, 0x00050005, 0xfff70000, 0x54540000, 0x545400f9, 0x00f900f9, 0x00f94008, + 0x00001555, + ]); + + let mut evidence = vec![ + "bridge family rt3-105-save-post-span-bridge-v1".to_string(), + format!("primary block offset 0x{primary_block_offset:08x}"), + format!("secondary block offset 0x{secondary_block_offset:08x}"), + format!("secondary block delta from primary 0x{secondary_block_delta_from_primary:x}"), + format!("secondary block end offset 0x{secondary_block_end_offset:08x}"), + format!("secondary block span 0x{secondary_block_len:x} bytes"), + ]; + if primary_signature_matches { + evidence.push( + "primary 8-word bridge block matches the observed 0x6200/0xfff7/0x5515/0x5555 spine" + .to_string(), + ); + } + if secondary_prefix_matches { + evidence.push( + "secondary preview matches the observed 0x0005/0xfff7/0x5454 dense block prefix" + .to_string(), + ); + } + + Some(SmpRt3105SaveBridgePayloadProbe { + profile_family: bridge.profile_family.clone(), + bridge_family: bridge.bridge_family.clone(), + primary_block_offset, + primary_block_len: primary_block_word_count * 4, + primary_block_len_hex: format!("0x{:02x}", primary_block_word_count * 4), + primary_hex_words: primary_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + primary_words, + secondary_block_offset, + secondary_block_delta_from_primary, + secondary_block_delta_from_primary_hex: format!("0x{secondary_block_delta_from_primary:x}"), + secondary_block_end_offset, + secondary_block_len, + secondary_block_len_hex: format!("0x{secondary_block_len:x}"), + secondary_preview_word_count, + secondary_hex_words: secondary_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect(), + secondary_words, + evidence, + }) +} + +fn parse_rt3_105_save_name_table_probe( + bytes: &[u8], + file_extension_hint: Option<&str>, + container_profile: Option<&SmpContainerProfile>, + bridge_payload_probe: Option<&SmpRt3105SaveBridgePayloadProbe>, +) -> Option { + let ( + profile_family, + source_kind, + header_offset, + entries_offset, + block_end_offset, + mut evidence, + ) = if let Some(payload) = bridge_payload_probe { + ( + payload.profile_family.clone(), + "save-bridge-secondary-block".to_string(), + payload.secondary_block_offset + 0x354, + payload.secondary_block_offset + 0x3b5, + payload.secondary_block_end_offset, + vec![ + "common-save bridge payload branch".to_string(), + format!( + "secondary block span 0x{:x}..0x{:x}", + payload.secondary_block_offset, payload.secondary_block_end_offset + ), + ], + ) + } else { + let profile_family = container_profile + .map(|profile| profile.profile_family.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let extension = file_extension_hint.unwrap_or(""); + let source_kind = match extension { + "gmp" => "map-fixed-catalog-range", + "gms" => "save-fixed-catalog-range", + "gmx" => "sandbox-fixed-catalog-range", + _ => "fixed-catalog-range", + } + .to_string(); + ( + profile_family, + source_kind, + 0x6a70, + 0x6ad1, + 0x73c0, + vec![ + "fixed catalog range branch".to_string(), + "using observed shared 1.05 candidate-availability table offsets".to_string(), + ], + ) + }; + let entry_stride = 0x22usize; + if block_end_offset > bytes.len() { + return None; + } + if !matches_candidate_availability_table_header(bytes, header_offset) { + return None; + } + let observed_entry_capacity = read_u32_at(bytes, header_offset + 0x1c)? as usize; + let observed_entry_count = read_u32_at(bytes, header_offset + 0x20)? as usize; + let entries_len = observed_entry_count.checked_mul(entry_stride)?; + let entries_end_offset = entries_offset.checked_add(entries_len)?; + if observed_entry_count == 0 || observed_entry_capacity < observed_entry_count { + return None; + } + if entries_end_offset > block_end_offset || entries_end_offset > bytes.len() { + return None; + } + + let mut entries = Vec::with_capacity(observed_entry_count); + for index in 0..observed_entry_count { + let offset = entries_offset + index * entry_stride; + let chunk = &bytes[offset..offset + entry_stride]; + let nul_index = chunk + .iter() + .position(|byte| *byte == 0) + .unwrap_or(entry_stride); + let text = std::str::from_utf8(&chunk[..nul_index]).ok()?.to_string(); + let trailer_word = read_u32_at(bytes, offset + entry_stride - 4)?; + entries.push(SmpRt3105SaveNameTableEntry { + index, + offset, + text, + availability_dword: trailer_word, + availability_dword_hex: format!("0x{trailer_word:08x}"), + trailer_word, + trailer_word_hex: format!("0x{trailer_word:08x}"), + }); + } + + let zero_trailer_entry_names = entries + .iter() + .filter(|entry| entry.trailer_word == 0) + .map(|entry| entry.text.clone()) + .collect::>(); + let zero_trailer_entry_count = zero_trailer_entry_names.len(); + let nonzero_trailer_entry_count = entries.len().saturating_sub(zero_trailer_entry_count); + let mut distinct_trailer_words = entries + .iter() + .map(|entry| entry.trailer_word) + .collect::>(); + distinct_trailer_words.sort_unstable(); + distinct_trailer_words.dedup(); + let distinct_trailer_hex_words = distinct_trailer_words + .iter() + .map(|word| format!("0x{word:08x}")) + .collect::>(); + let trailing_footer_hex = hex_encode(&bytes[entries_end_offset..block_end_offset]); + let footer = &bytes[entries_end_offset..block_end_offset]; + if footer.len() != 9 { + return None; + } + let footer_progress_word_0 = u32::from_le_bytes([footer[0], footer[1], footer[2], footer[3]]); + let footer_progress_word_1 = u32::from_le_bytes([footer[4], footer[5], footer[6], footer[7]]); + let footer_trailing_byte = footer[8]; + let mut footer_grounded_alignments = Vec::new(); + for value in [footer_progress_word_0, footer_progress_word_1] { + if let Some(alignment) = classify_name_table_footer_progress_alignment(value) { + footer_grounded_alignments.push(alignment.to_string()); + } + } + evidence.extend([ + format!("header offset 0x{header_offset:08x}"), + format!("entries offset 0x{entries_offset:08x}"), + format!("entry stride 0x{entry_stride:x}"), + format!("observed entry capacity {}", observed_entry_capacity), + format!("observed entry count {}", observed_entry_count), + format!("zero-trailer entries {}", zero_trailer_entry_count), + format!( + "trailing footer {} bytes after last entry", + block_end_offset - entries_end_offset + ), + ]); + let semantic_alignment = vec![ + "Matches the grounded scenario-side named candidate-availability table shape under 0x00437743.".to_string(), + "Entry layout matches 0x00434ea0/0x00434f20: name slot at +0x00..+0x1d and availability dword at +0x1e.".to_string(), + "The shared map/save range suggests this catalog is bundled in source map content and later mirrored into scenario state [state+0x66b2].".to_string(), + ]; + + Some(SmpRt3105SaveNameTableProbe { + profile_family, + source_kind, + semantic_family: "scenario-named-candidate-availability-table".to_string(), + semantic_alignment, + header_offset, + header_word_0: read_u32_at(bytes, header_offset)?, + header_word_0_hex: format!("0x{:08x}", read_u32_at(bytes, header_offset)?), + header_word_1: read_u32_at(bytes, header_offset + 4)?, + header_word_1_hex: format!("0x{:08x}", read_u32_at(bytes, header_offset + 4)?), + header_word_2: read_u32_at(bytes, header_offset + 8)?, + header_word_2_hex: format!("0x{:08x}", read_u32_at(bytes, header_offset + 8)?), + entry_stride, + entry_stride_hex: format!("0x{entry_stride:x}"), + header_prefix_word_count: 11, + observed_entry_capacity, + observed_entry_count, + zero_trailer_entry_count, + nonzero_trailer_entry_count, + distinct_trailer_words, + distinct_trailer_hex_words, + zero_trailer_entry_names, + entries_offset, + entries_end_offset, + trailing_footer_hex, + footer_progress_word_0, + footer_progress_word_0_hex: format!("0x{footer_progress_word_0:08x}"), + footer_progress_word_1, + footer_progress_word_1_hex: format!("0x{footer_progress_word_1:08x}"), + footer_trailing_byte, + footer_trailing_byte_hex: format!("0x{footer_trailing_byte:02x}"), + footer_grounded_alignments, + entries, + evidence, + }) +} + +fn matches_candidate_availability_table_header(bytes: &[u8], header_offset: usize) -> bool { + matches!( + ( + read_u32_at(bytes, header_offset + 0x08), + read_u32_at(bytes, header_offset + 0x0c), + read_u32_at(bytes, header_offset + 0x10), + read_u32_at(bytes, header_offset + 0x14), + read_u32_at(bytes, header_offset + 0x18), + read_u32_at(bytes, header_offset + 0x1c), + read_u32_at(bytes, header_offset + 0x20), + read_u32_at(bytes, header_offset + 0x24), + read_u32_at(bytes, header_offset + 0x28), + ), + ( + Some(0x0000332e), + Some(0x00000001), + Some(0x00000022), + Some(0x00000002), + Some(0x00000002), + Some(_), + Some(_), + Some(0x00000000), + Some(0x00000001), + ) + ) +} + +fn classify_name_table_footer_progress_alignment(value: u32) -> Option<&'static str> { + match value { + 0x32dc => Some( + "Footer progress word 0x000032dc matches the grounded late rehydrate progress id 0x32dc.", + ), + 0x3714 => Some( + "Footer progress word 0x00003714 matches the grounded late rehydrate progress id 0x3714.", + ), + _ => None, + } +} + +fn parse_rt3_105_packed_profile_block( + bytes: &[u8], + packed_profile_offset: usize, + packed_profile_len: usize, +) -> Option { + let block_end = packed_profile_offset.checked_add(packed_profile_len)?; + if block_end > bytes.len() || packed_profile_len != 0x108 { + return None; + } + + let leading_word_0 = read_u32_at(bytes, packed_profile_offset)?; + let trailing_zero_word_count_after_leading_word = (1..4) + .take_while(|index| { + read_u32_at(bytes, packed_profile_offset + (index * 4)).is_some_and(|word| word == 0) + }) + .count(); + let header_flag_word_3 = read_u32_at(bytes, packed_profile_offset + 0x0c)?; + let map_path_offset = 0x10usize; + let display_name_offset = 0x43usize; + let stable_nonzero_word_offsets = [0x00usize, 0x0c, 0x78, 0x7c, 0x80, 0x84]; + let stable_nonzero_words = stable_nonzero_word_offsets + .iter() + .filter_map(|relative_offset| { + let value = read_u32_at(bytes, packed_profile_offset + relative_offset)?; + if value == 0 { + return None; + } + + Some(SmpPackedProfileWordLane { + relative_offset: *relative_offset, + relative_offset_hex: format!("0x{relative_offset:02x}"), + value, + value_hex: format!("0x{value:08x}"), + }) + }) + .collect::>(); + + Some(SmpRt3105PackedProfileBlock { + relative_len: packed_profile_len, + relative_len_hex: format!("0x{packed_profile_len:03x}"), + leading_word_0, + leading_word_0_hex: format!("0x{leading_word_0:08x}"), + trailing_zero_word_count_after_leading_word, + header_flag_word_3, + header_flag_word_3_hex: format!("0x{header_flag_word_3:08x}"), + map_path_offset, + map_path: read_c_string_in_range(bytes, packed_profile_offset + map_path_offset, block_end), + display_name_offset, + display_name: read_c_string_in_range( + bytes, + packed_profile_offset + display_name_offset, + block_end, + ), + profile_byte_0x77: bytes[packed_profile_offset + 0x77], + profile_byte_0x77_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0x77]), + profile_byte_0x82: bytes[packed_profile_offset + 0x82], + profile_byte_0x82_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0x82]), + profile_byte_0x97: bytes[packed_profile_offset + 0x97], + profile_byte_0x97_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0x97]), + profile_byte_0xc5: bytes[packed_profile_offset + 0xc5], + profile_byte_0xc5_hex: format!("0x{:02x}", bytes[packed_profile_offset + 0xc5]), + stable_nonzero_words, + }) +} + +fn collect_runtime_post_span_header_candidates( + bytes: &[u8], + start: usize, + search_len: usize, +) -> Vec { + let end = bytes.len().min(start + search_len); + let mut offset = start & !0x3; + let mut candidates = Vec::new(); + + while offset + 16 <= end && candidates.len() < 8 { + if let Some(candidate) = build_runtime_post_span_header_candidate(bytes, offset) { + let mut cluster_end = offset + 4; + while cluster_end + 16 <= end + && build_runtime_post_span_header_candidate(bytes, cluster_end).is_some() + { + cluster_end += 4; + } + candidates.push(candidate); + offset = cluster_end; + } else { + offset += 4; + } + } + + candidates +} + +fn build_runtime_post_span_header_candidate( + bytes: &[u8], + offset: usize, +) -> Option { + let words = read_u32_window(bytes, offset, 4); + if words.len() < 4 { + return None; + } + + let dense_words = words + .iter() + .copied() + .filter(|word| (word & 0xffff) == 0 && (word >> 16) != 0) + .collect::>(); + if dense_words.len() < 3 { + return None; + } + + let high_u16_words = words + .iter() + .map(|word| (word >> 16) as u16) + .collect::>(); + let mut grounded_alignments = Vec::new(); + for high in &high_u16_words { + if let Some(alignment) = classify_runtime_post_span_high16_grounded_alignment(*high) { + if !grounded_alignments + .iter() + .any(|existing| existing == alignment) + { + grounded_alignments.push(alignment.to_string()); + } + } + } + + Some(SmpRuntimePostSpanHeaderCandidate { + offset, + hex_words: words.iter().map(|word| format!("0x{word:08x}")).collect(), + dense_word_count: dense_words.len(), + high_hex_words: high_u16_words + .iter() + .map(|word| format!("0x{word:04x}")) + .collect(), + high_u16_words, + grounded_alignments, + words, + }) +} + +fn classify_runtime_post_span_high16_grounded_alignment(high_u16: u16) -> Option<&'static str> { + match high_u16 { + 0x32dc => Some( + "High-16 value 0x32dc matches the grounded late rehydrate progress id posted during world_entry_transition_and_runtime_bringup.", + ), + 0x3714 => Some( + "High-16 value 0x3714 matches the grounded late rehydrate progress id posted during world_entry_transition_and_runtime_bringup.", + ), + 0x3715 => Some( + "High-16 value 0x3715 matches the grounded late rehydrate progress id posted during world_entry_transition_and_runtime_bringup.", + ), + _ => None, + } +} + +fn find_grounded_progress_high16_hits( + bytes: &[u8], + start: usize, + search_len: usize, +) -> Vec { + let end = bytes.len().min(start + search_len); + let mut hits = Vec::new(); + let mut offset = start & !0x3; + while offset + 4 <= end { + if let Some(word) = read_u32_at(bytes, offset) { + let high = (word >> 16) as u16; + if matches!(high, 0x32dc | 0x3714 | 0x3715) { + hits.push(format!("0x{high:04x}@0x{offset:08x}")); + } + } + offset += 4; + } + hits +} + +fn parse_grounded_progress_hit_offset(hits: &[String], high_u16: u16) -> Option { + let needle = format!("0x{high_u16:04x}@0x"); + let hit = hits.iter().find(|hit| hit.starts_with(&needle))?; + let offset_hex = hit.split("@0x").nth(1)?; + usize::from_str_radix(offset_hex, 16).ok() +} + +fn collect_ascii_previews_in_range( + bytes: &[u8], + start: usize, + end: usize, + min_len: usize, +) -> Vec { + let mut previews = Vec::new(); + let mut run_start = None; + let end = end.min(bytes.len()); + + for index in start..end { + let byte = bytes[index]; + if is_ascii_preview_byte(byte) { + run_start.get_or_insert(index); + continue; + } + + if let Some(current_start) = run_start.take() { + if index - current_start >= min_len { + previews.push(build_ascii_preview(bytes, current_start, index)); + } + } + } + + if let Some(current_start) = run_start { + if end - current_start >= min_len { + previews.push(build_ascii_preview(bytes, current_start, end)); + } + } + + previews +} + +fn find_c_string_with_suffix_in_range( + bytes: &[u8], + start: usize, + end: usize, + suffix: &str, +) -> Option { + let end = end.min(bytes.len()); + let suffix = suffix.as_bytes(); + let mut offset = start.min(end); + + while offset < end { + if !is_ascii_preview_byte(bytes[offset]) { + offset += 1; + continue; + } + + let run_start = offset; + while offset < end && is_ascii_preview_byte(bytes[offset]) { + offset += 1; + } + + let run = &bytes[run_start..offset]; + if run.ends_with(suffix) { + return Some(run_start); + } + } + + None +} + +fn read_c_string_in_range(bytes: &[u8], start: usize, end: usize) -> Option { + if start >= end || start >= bytes.len() { + return None; + } + + let end = end.min(bytes.len()); + let mut cursor = start; + while cursor < end && bytes[cursor] != 0 { + cursor += 1; + } + if cursor == start { + return None; + } + + std::str::from_utf8(&bytes[start..cursor]) + .ok() + .map(ToString::to_string) +} + +fn find_u16_le_offsets(bytes: &[u8], needle: u16) -> Vec { + let pattern = needle.to_le_bytes(); + bytes + .windows(pattern.len()) + .enumerate() + .filter_map(|(offset, window)| (window == pattern).then_some(offset)) + .collect() +} + +fn find_next_nonzero_offset(bytes: &[u8], start: usize) -> Option { + bytes + .iter() + .enumerate() + .skip(start) + .find_map(|(offset, byte)| (*byte != 0).then_some(offset)) +} + +fn find_zero_run(bytes: &[u8], start: usize, min_len: usize) -> Option { + let mut run_start = None; + let mut run_len = 0usize; + + for (offset, byte) in bytes.iter().enumerate().skip(start) { + if *byte == 0 { + run_start.get_or_insert(offset); + run_len += 1; + if run_len >= min_len { + return run_start; + } + } else { + run_start = None; + run_len = 0; + } + } + + None +} + +fn find_first_ascii_run(bytes: &[u8]) -> Option { + let mut start = None; + + for (index, byte) in bytes.iter().copied().enumerate() { + if is_ascii_preview_byte(byte) { + start.get_or_insert(index); + continue; + } + + if let Some(run_start) = start.take() { + if index - run_start >= MIN_ASCII_RUN_LEN { + return Some(build_ascii_preview(bytes, run_start, index)); + } + } + } + + start.and_then(|run_start| { + if bytes.len() - run_start >= MIN_ASCII_RUN_LEN { + Some(build_ascii_preview(bytes, run_start, bytes.len())) + } else { + None + } + }) +} + +fn build_ascii_preview(bytes: &[u8], start: usize, end: usize) -> SmpAsciiPreview { + let byte_len = end - start; + let preview_bytes = &bytes[start..end]; + let preview = String::from_utf8_lossy( + &preview_bytes[..preview_bytes.len().min(ASCII_PREVIEW_CHAR_LIMIT)], + ) + .into_owned(); + + SmpAsciiPreview { + offset: start, + byte_len, + truncated: byte_len > ASCII_PREVIEW_CHAR_LIMIT, + preview, + } +} + +fn is_ascii_preview_byte(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x21..=0x7e) +} + +fn read_u32_window(bytes: &[u8], offset: usize, count: usize) -> Vec { + let mut words = Vec::new(); + let end = bytes.len().min(offset + count * 4); + for chunk in bytes[offset..end].chunks_exact(4) { + words.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + words +} + +fn read_u32_at(bytes: &[u8], offset: usize) -> Option { + let chunk = bytes.get(offset..offset + 4)?; + Some(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!("{digest:x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_grounded_tag_hits_and_offsets() { + let bytes = [ + 0x34, 0x12, 0x00, 0x00, 0xe0, 0x2e, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x00, 0x80, + 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x71, 0x07, 0x00, 0x00, 0x71, 0x07, 0x00, 0x00, + 0x71, 0x07, 0x00, 0x00, 0xaa, 0xbb, 0x00, 0x00, 0xcc, 0xdd, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, b'H', b'e', b'l', + b'l', b'o', b' ', b'R', b'R', b'T', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, 0xde, 0xf0, 0x00, 0x00, 0x00, 0x00, 0xee, 0x2c, 0x11, 0x51, 0x2d, 0x22, + 0x71, 0x94, 0x33, 0x72, 0x94, + ]; + let report = inspect_smp_bytes(&bytes); + + assert!(report.contains_grounded_runtime_tags); + assert_eq!(report.known_tag_hits.len(), 4); + assert_eq!(report.preamble.word_count, 16); + assert_eq!(report.preamble.words[0].value_le, 0x00001234); + let shared_header = report + .shared_header + .as_ref() + .expect("shared header should parse"); + assert!(shared_header.matches_grounded_common_signature); + let header_variant = report + .header_variant_probe + .as_ref() + .expect("header variant probe should exist"); + assert_eq!(header_variant.variant_family, "unknown"); + assert!(!header_variant.is_known_family); + assert_eq!(shared_header.primary_family_tag, 0x00002ee0); + assert_eq!( + shared_header.payload_window_words_8_to_9, + vec![0x0000bbaa, 0x0000ddcc] + ); + assert!(shared_header.reserved_words_10_to_14_all_zero); + assert_eq!(shared_header.final_flag_word, 0); + let ascii_run = report + .first_ascii_run + .as_ref() + .expect("ascii run should exist"); + assert_eq!(ascii_run.offset, 67); + assert_eq!(ascii_run.byte_len, 9); + assert_eq!(ascii_run.preview, "Hello RRT"); + let early_probe = report + .early_content_probe + .as_ref() + .expect("early content probe should exist"); + assert_eq!(early_probe.first_post_text_nonzero_offset, 88); + assert_eq!(early_probe.zero_pad_after_text_len, 12); + assert_eq!(early_probe.first_post_text_block_len, 4); + assert_eq!(early_probe.first_post_text_block_hex, "11223344"); + assert_eq!(early_probe.trailing_zero_pad_after_first_block_len, 16); + assert_eq!(early_probe.secondary_nonzero_offset, Some(108)); + assert_eq!(early_probe.secondary_aligned_word_window_offset, Some(108)); + assert_eq!( + &early_probe.secondary_aligned_word_window_words[..2], + &[0x78563412, 0xf0debc9a] + ); + assert!( + early_probe + .secondary_preview_hex + .starts_with("123456789abcdef0") + ); + let secondary_variant = report + .secondary_variant_probe + .as_ref() + .expect("secondary variant probe should exist"); + assert_eq!(secondary_variant.variant_family, "unknown"); + let container_profile = report + .container_profile + .as_ref() + .expect("container profile should exist"); + assert_eq!(container_profile.profile_family, "unknown"); + assert!(!container_profile.is_known_profile); + assert!(report.save_bootstrap_block.is_none()); + assert!(report.save_anchor_run_block.is_none()); + assert!(report.runtime_anchor_cycle_block.is_none()); + assert!(report.runtime_trailer_block.is_none()); + assert!(report.runtime_post_span_probe.is_none()); + assert!(report.classic_rehydrate_profile_probe.is_none()); + assert_eq!(report.known_tag_hits[0].tag_id, 0x2cee); + assert_eq!(report.known_tag_hits[0].hit_count, 1); + assert_eq!(report.known_tag_hits[0].sample_offsets, vec![120]); + assert_eq!(report.known_tag_hits[1].tag_id, 0x2d51); + assert_eq!(report.known_tag_hits[1].sample_offsets, vec![123]); + assert_eq!(report.known_tag_hits[2].tag_id, 0x9471); + assert_eq!(report.known_tag_hits[2].sample_offsets, vec![126]); + assert_eq!(report.known_tag_hits[3].tag_id, 0x9472); + assert_eq!(report.known_tag_hits[3].sample_offsets, vec![129]); + } + + #[test] + fn warns_when_no_grounded_tags_are_present() { + let report = inspect_smp_bytes(&[0xaa, 0xbb, 0xcc]); + + assert!(!report.contains_grounded_runtime_tags); + assert!(report.known_tag_hits.is_empty()); + assert_eq!(report.preamble.word_count, 0); + assert!(report.shared_header.is_none()); + assert!(report.header_variant_probe.is_none()); + assert!(report.first_ascii_run.is_none()); + assert!(report.early_content_probe.is_none()); + assert!(report.secondary_variant_probe.is_none()); + assert!(report.container_profile.is_none()); + assert!(report.save_bootstrap_block.is_none()); + assert!(report.save_anchor_run_block.is_none()); + assert!(report.runtime_anchor_cycle_block.is_none()); + assert!(report.runtime_trailer_block.is_none()); + assert!(report.runtime_post_span_probe.is_none()); + assert!(report.classic_rehydrate_profile_probe.is_none()); + assert!( + report + .warnings + .iter() + .any(|warning| warning.contains("No grounded runtime bundle tags were found")) + ); + } + + #[test] + fn parses_save_anchor_cycle_and_trailer() { + let cycle_words: [u32; 9] = [ + 0x00000000, 0x0186a000, 0x00000000, 0x86a00000, 0x00000001, 0xa0000000, 0x00000186, + 0x00000000, 0x000186a0, + ]; + let trailer_words: [u32; 3] = [0x00020000, 0x00030000, 0x2ee10000]; + let mut bytes = vec![0u8; 0x1c + (cycle_words.len() * 2 + 2 + trailer_words.len()) * 4]; + + let mut cursor = 0x1c; + for _ in 0..2 { + for word in cycle_words { + bytes[cursor..cursor + 4].copy_from_slice(&word.to_le_bytes()); + cursor += 4; + } + } + for word in &cycle_words[..2] { + bytes[cursor..cursor + 4].copy_from_slice(&(*word).to_le_bytes()); + cursor += 4; + } + for word in trailer_words { + bytes[cursor..cursor + 4].copy_from_slice(&word.to_le_bytes()); + cursor += 4; + } + + let container_profile = SmpContainerProfile { + profile_family: "rt3-classic-save-container-v1".to_string(), + profile_evidence: vec!["test".to_string()], + is_known_profile: true, + }; + let bootstrap = SmpSaveBootstrapBlock { + profile_family: "rt3-classic-save-container-v1".to_string(), + aligned_window_offset: 0, + leading_word: 0, + leading_word_hex: "0x00000000".to_string(), + anchor_word: 0, + anchor_word_hex: "0x00000000".to_string(), + descriptor_word_2: 0, + descriptor_word_2_hex: "0x00000000".to_string(), + descriptor_word_3: 0, + descriptor_word_3_hex: "0x00000000".to_string(), + descriptor_word_4: 0, + descriptor_word_4_hex: "0x00000000".to_string(), + descriptor_word_5: 0, + descriptor_word_5_hex: "0x00000000".to_string(), + descriptor_word_6: 0, + descriptor_word_6_hex: "0x00000000".to_string(), + descriptor_word_7: 0, + descriptor_word_7_hex: "0x00000000".to_string(), + }; + + let parsed = + parse_save_anchor_run_block(&bytes, Some(&container_profile), Some(&bootstrap)) + .expect("cycle block should parse"); + + assert_eq!(parsed.cycle_start_offset, 0x1c); + assert_eq!(parsed.cycle_words, cycle_words); + assert_eq!(parsed.full_cycle_count, 2); + assert_eq!(parsed.partial_cycle_word_count, 2); + assert_eq!( + parsed.trailer_offset, + 0x1c + (cycle_words.len() * 2 + 2) * 4 + ); + assert_eq!(parsed.trailer_words, trailer_words); + } + + #[test] + fn classifies_runtime_trailer_family() { + let runtime_anchor_cycle_block = SmpRuntimeAnchorCycleBlock { + profile_family: "rt3-classic-sandbox-container-v1".to_string(), + cycle_start_offset: 0x33c, + cycle_words: vec![0; 9], + cycle_hex_words: vec!["0x00000000".to_string(); 9], + full_cycle_count: 3, + partial_cycle_word_count: 2, + trailer_offset: 0x3b0, + trailer_words: vec![ + 0x00010000, 0x00010000, 0x00010000, 0x00010000, 0x00000000, 0x00000000, 0x2ee10000, + 0x32c80000, 0x0dcd0000, 0x01010107, 0x26010000, 0x01010107, 0x00010000, 0x0334c68c, + 0x03000000, 0x01000000, + ], + trailer_hex_words: Vec::new(), + }; + let container_profile = SmpContainerProfile { + profile_family: "rt3-classic-sandbox-container-v1".to_string(), + profile_evidence: vec!["test".to_string()], + is_known_profile: true, + }; + + let trailer = parse_runtime_trailer_block( + Some(&container_profile), + Some(&runtime_anchor_cycle_block), + ) + .expect("runtime trailer should parse"); + + assert_eq!(trailer.trailer_family, "rt3-classic-sandbox-trailer-v1"); + assert_eq!(trailer.prefix_words_0_to_5[0], 0x00010000); + assert_eq!(trailer.tag_word_6, 0x2ee10000); + assert_eq!(trailer.tag_chunk_id_u16, 0x2ee1); + assert_eq!(trailer.selector_word_8, 0x0dcd0000); + assert_eq!(trailer.selector_high_u16, 0x0dcd); + assert_eq!(trailer.mode_word_15, 0x01000000); + } + + #[test] + fn probes_runtime_post_span_region() { + let mut bytes = vec![0u8; 0x200]; + bytes[0x90..0x94].copy_from_slice(&0x32dc0000u32.to_le_bytes()); + bytes[0x94..0x98].copy_from_slice(&0x37140000u32.to_le_bytes()); + bytes[0x98..0x9c].copy_from_slice(&0x03000000u32.to_le_bytes()); + bytes[0xa0..0xa4].copy_from_slice(&0x37150000u32.to_le_bytes()); + bytes[0xa4..0xa8].copy_from_slice(&0x00010000u32.to_le_bytes()); + bytes[0xa8..0xac].copy_from_slice(&0x00410000u32.to_le_bytes()); + + let trailer = SmpRuntimeTrailerBlock { + profile_family: "rt3-classic-save-container-v1".to_string(), + trailer_family: "test".to_string(), + trailer_evidence: Vec::new(), + trailer_offset: 0x40, + prefix_words_0_to_5: Vec::new(), + prefix_hex_words_0_to_5: Vec::new(), + tag_word_6: 0x2ee10000, + tag_word_6_hex: "0x2ee10000".to_string(), + tag_chunk_id_u16: 0x2ee1, + tag_chunk_id_hex: "0x2ee1".to_string(), + tag_chunk_id_grounded_alignment: None, + length_word_7: 0x00200000, + length_word_7_hex: "0x00200000".to_string(), + length_high_u16: 0x0020, + length_high_hex: "0x0020".to_string(), + selector_word_8: 0, + selector_word_8_hex: "0x00000000".to_string(), + selector_high_u16: 0, + selector_high_hex: "0x0000".to_string(), + layout_word_9: 0, + layout_word_9_hex: "0x00000000".to_string(), + descriptor_word_10: 0, + descriptor_word_10_hex: "0x00000000".to_string(), + descriptor_high_u16: 0, + descriptor_high_hex: "0x0000".to_string(), + descriptor_word_11: 0, + descriptor_word_11_hex: "0x00000000".to_string(), + counter_word_12: 0, + counter_word_12_hex: "0x00000000".to_string(), + offset_word_13: 0, + offset_word_13_hex: "0x00000000".to_string(), + span_word_14: 0, + span_word_14_hex: "0x00000000".to_string(), + mode_word_15: 0, + mode_word_15_hex: "0x00000000".to_string(), + words: Vec::new(), + hex_words: Vec::new(), + }; + + let probe = parse_runtime_post_span_probe(&bytes, Some(&trailer)) + .expect("post-span probe should parse"); + + assert_eq!(probe.span_target_offset, 0x60); + assert_eq!(probe.next_nonzero_offset, Some(0x92)); + assert_eq!(probe.next_aligned_candidate_offset, Some(0x8c)); + assert_eq!(probe.header_candidates.len(), 1); + assert_eq!(probe.header_candidates[0].dense_word_count, 3); + assert_eq!(probe.header_candidates[0].grounded_alignments.len(), 2); + assert_eq!(probe.grounded_progress_hits[0], "0x32dc@0x00000090"); + } + + #[test] + fn parses_classic_rehydrate_profile_probe() { + let mut bytes = vec![0u8; 0x220]; + bytes[0x90..0x94].copy_from_slice(&0x32dc0000u32.to_le_bytes()); + bytes[0x94..0x98].copy_from_slice(&0x37140000u32.to_le_bytes()); + bytes[0x1a0..0x1a4].copy_from_slice(&0x37150000u32.to_le_bytes()); + bytes[0xab..0xb7].copy_from_slice(b"test-map.gmp"); + bytes[0xde..0xe6].copy_from_slice(b"Test Map"); + + let post_span = SmpRuntimePostSpanProbe { + profile_family: "rt3-classic-save-container-v1".to_string(), + span_target_offset: 0, + next_nonzero_offset: Some(0x92), + next_aligned_candidate_offset: Some(0x8c), + next_aligned_candidate_words: vec![0, 0x32dc0000, 0x37140000, 0x03000000], + next_aligned_candidate_hex_words: vec![], + header_candidates: vec![], + grounded_progress_hits: vec![ + "0x32dc@0x00000090".to_string(), + "0x3714@0x00000094".to_string(), + "0x3715@0x000001a0".to_string(), + ], + }; + + let probe = parse_classic_rehydrate_profile_probe(&bytes, Some(&post_span)) + .expect("classic rehydrate probe should parse"); + + assert_eq!(probe.packed_profile_offset, 0x98); + assert_eq!(probe.packed_profile_len, 0x108); + assert_eq!(probe.ascii_runs[0].preview, "test-map.gmp"); + assert_eq!(probe.packed_profile_block.leading_word_0, 0x00000000); + assert_eq!( + probe.packed_profile_block.map_path.as_deref(), + Some("test-map.gmp") + ); + assert_eq!( + probe.packed_profile_block.display_name.as_deref(), + Some("Test Map") + ); + assert_eq!(probe.packed_profile_block.profile_byte_0x77, 0x00); + assert_eq!(probe.packed_profile_block.profile_byte_0x82, 0x00); + assert_eq!(probe.packed_profile_block.profile_byte_0x97, 0x00); + assert_eq!(probe.packed_profile_block.profile_byte_0xc5, 0x00); + } + + #[test] + fn parses_rt3_105_packed_profile_probe() { + let mut bytes = vec![0u8; 0x9000]; + let block = 0x73c0usize; + bytes[block..block + 4].copy_from_slice(&0x00000003u32.to_le_bytes()); + bytes[block + 0x0c..block + 0x10].copy_from_slice(&0x01000000u32.to_le_bytes()); + bytes[block + 0x10..block + 0x1d].copy_from_slice(b"test-105.gmp\0"); + bytes[block + 0x43..block + 0x4c].copy_from_slice(b"Test 105\0"); + bytes[block + 0x77] = 0x07; + bytes[block + 0x82] = 0x4d; + bytes[block + 0x84..block + 0x88].copy_from_slice(&0x65010000u32.to_le_bytes()); + + let header_variant_probe = SmpHeaderVariantProbe { + variant_family: "rt3-105-common-header-v1".to_string(), + variant_evidence: vec![], + is_known_family: true, + }; + let probe = parse_rt3_105_packed_profile_probe( + &bytes, + Some("gms"), + Some(&header_variant_probe), + None, + ) + .expect("1.05 packed profile probe should parse"); + + assert_eq!(probe.profile_family, "rt3-105-save-analog-block-inferred"); + assert_eq!(probe.packed_profile_offset, 0x73c0); + assert_eq!(probe.packed_profile_len, 0x108); + assert_eq!( + probe.packed_profile_block.map_path.as_deref(), + Some("test-105.gmp") + ); + assert_eq!( + probe.packed_profile_block.display_name.as_deref(), + Some("Test 105") + ); + assert_eq!(probe.packed_profile_block.profile_byte_0x77, 0x07); + assert_eq!(probe.packed_profile_block.profile_byte_0x82, 0x4d); + assert_eq!(probe.packed_profile_block.profile_byte_0x97, 0x00); + assert_eq!(probe.packed_profile_block.profile_byte_0xc5, 0x00); + } + + #[test] + fn classifies_rt3_105_post_span_bridge_variants() { + let base_trailer = SmpRuntimeTrailerBlock { + profile_family: "rt3-105-save-container-v1".to_string(), + trailer_family: "rt3-105-save-trailer-v1".to_string(), + trailer_evidence: vec![], + trailer_offset: 944, + prefix_words_0_to_5: vec![], + prefix_hex_words_0_to_5: vec![], + tag_word_6: 0, + tag_word_6_hex: String::new(), + tag_chunk_id_u16: 0x2ee1, + tag_chunk_id_hex: "0x2ee1".to_string(), + tag_chunk_id_grounded_alignment: None, + length_word_7: 0x32c8_0000, + length_word_7_hex: "0x32c80000".to_string(), + length_high_u16: 0x32c8, + length_high_hex: "0x32c8".to_string(), + selector_word_8: 0x7110_0000, + selector_word_8_hex: "0x71100000".to_string(), + selector_high_u16: 0x7110, + selector_high_hex: "0x7110".to_string(), + layout_word_9: 0, + layout_word_9_hex: String::new(), + descriptor_word_10: 0x7801_0000, + descriptor_word_10_hex: "0x78010000".to_string(), + descriptor_high_u16: 0x7801, + descriptor_high_hex: "0x7801".to_string(), + descriptor_word_11: 0, + descriptor_word_11_hex: String::new(), + counter_word_12: 0, + counter_word_12_hex: String::new(), + offset_word_13: 0, + offset_word_13_hex: String::new(), + span_word_14: 0, + span_word_14_hex: String::new(), + mode_word_15: 0, + mode_word_15_hex: String::new(), + words: vec![], + hex_words: vec![], + }; + let base_post_span = SmpRuntimePostSpanProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + span_target_offset: 13944, + next_nonzero_offset: Some(14795), + next_aligned_candidate_offset: Some(20244), + next_aligned_candidate_words: vec![], + next_aligned_candidate_hex_words: vec![], + header_candidates: vec![SmpRuntimePostSpanHeaderCandidate { + offset: 20244, + words: vec![], + hex_words: vec![], + dense_word_count: 3, + high_u16_words: vec![0x6200, 0x0000, 0xfff7, 0x5515], + high_hex_words: vec![], + grounded_alignments: vec![], + }], + grounded_progress_hits: vec![], + }; + let base_profile = SmpRt3105PackedProfileProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + packed_profile_offset: 29632, + packed_profile_len: 0x108, + packed_profile_len_hex: "0x108".to_string(), + packed_profile_block: SmpRt3105PackedProfileBlock { + relative_len: 0x108, + relative_len_hex: "0x108".to_string(), + leading_word_0: 3, + leading_word_0_hex: "0x00000003".to_string(), + trailing_zero_word_count_after_leading_word: 2, + header_flag_word_3: 0x0100_0000, + header_flag_word_3_hex: "0x01000000".to_string(), + map_path_offset: 0x10, + map_path: Some("Alternate USA.gmp".to_string()), + display_name_offset: 0x43, + display_name: Some("Alternate USA".to_string()), + profile_byte_0x77: 0x07, + profile_byte_0x77_hex: "0x07".to_string(), + profile_byte_0x82: 0x4d, + profile_byte_0x82_hex: "0x4d".to_string(), + profile_byte_0x97: 0, + profile_byte_0x97_hex: "0x00".to_string(), + profile_byte_0xc5: 0, + profile_byte_0xc5_hex: "0x00".to_string(), + stable_nonzero_words: vec![], + }, + ascii_runs: vec![], + }; + let base_bridge = parse_rt3_105_post_span_bridge_probe( + Some(&base_trailer), + Some(&base_post_span), + Some(&base_profile), + ) + .expect("base bridge should parse"); + assert_eq!( + base_bridge.bridge_family, + "rt3-105-save-post-span-bridge-v1" + ); + assert_eq!(base_bridge.packed_profile_delta_from_span_target, 15688); + assert_eq!( + base_bridge.next_candidate_delta_from_packed_profile, + Some(-9388) + ); + let base_variant_trailer = SmpRuntimeTrailerBlock { + descriptor_word_10: 0x7401_0000, + descriptor_word_10_hex: "0x74010000".to_string(), + descriptor_high_u16: 0x7401, + descriptor_high_hex: "0x7401".to_string(), + ..base_trailer.clone() + }; + let base_variant_bridge = parse_rt3_105_post_span_bridge_probe( + Some(&base_variant_trailer), + Some(&base_post_span), + Some(&base_profile), + ) + .expect("base bridge variant should parse"); + assert_eq!( + base_variant_bridge.bridge_family, + "rt3-105-save-post-span-bridge-v1" + ); + + let alt_trailer = SmpRuntimeTrailerBlock { + profile_family: "rt3-105-alt-save-container-v1".to_string(), + selector_word_8: 0x54cd_0000, + selector_word_8_hex: "0x54cd0000".to_string(), + selector_high_u16: 0x54cd, + selector_high_hex: "0x54cd".to_string(), + descriptor_word_10: 0x5901_0000, + descriptor_word_10_hex: "0x59010000".to_string(), + descriptor_high_u16: 0x5901, + descriptor_high_hex: "0x5901".to_string(), + ..base_trailer.clone() + }; + let alt_post_span = SmpRuntimePostSpanProbe { + profile_family: "rt3-105-alt-save-container-v1".to_string(), + next_aligned_candidate_offset: Some(29892), + header_candidates: vec![SmpRuntimePostSpanHeaderCandidate { + offset: 29892, + words: vec![], + hex_words: vec![], + dense_word_count: 3, + high_u16_words: vec![0x1500, 0x0100, 0x4100, 0x0200], + high_hex_words: vec![], + grounded_alignments: vec![], + }], + ..base_post_span.clone() + }; + let alt_profile = SmpRt3105PackedProfileProbe { + profile_family: "rt3-105-alt-save-container-v1".to_string(), + packed_profile_block: SmpRt3105PackedProfileBlock { + map_path: Some("Spanish Mainline.gmp".to_string()), + display_name: Some("Spanish Mainline".to_string()), + profile_byte_0x82: 0xa3, + profile_byte_0x82_hex: "0xa3".to_string(), + ..base_profile.packed_profile_block.clone() + }, + ..base_profile.clone() + }; + let alt_bridge = parse_rt3_105_post_span_bridge_probe( + Some(&alt_trailer), + Some(&alt_post_span), + Some(&alt_profile), + ) + .expect("alt bridge should parse"); + assert_eq!( + alt_bridge.bridge_family, + "rt3-105-alt-save-post-span-bridge-v1" + ); + assert_eq!( + alt_bridge.next_candidate_delta_from_packed_profile, + Some(260) + ); + + let scenario_trailer = SmpRuntimeTrailerBlock { + profile_family: "rt3-105-scenario-save-container-v1".to_string(), + trailer_family: "unknown".to_string(), + trailer_offset: 864, + length_word_7: 0, + length_word_7_hex: "0x00000000".to_string(), + length_high_u16: 0, + length_high_hex: "0x0000".to_string(), + selector_word_8: 0x0001_86a0, + selector_word_8_hex: "0x000186a0".to_string(), + selector_high_u16: 0x0001, + selector_high_hex: "0x0001".to_string(), + descriptor_word_10: 0x0186_a000, + descriptor_word_10_hex: "0x0186a000".to_string(), + descriptor_high_u16: 0x0186, + descriptor_high_hex: "0x0186".to_string(), + ..base_trailer.clone() + }; + let scenario_post_span = SmpRuntimePostSpanProbe { + profile_family: "rt3-105-scenario-save-container-v1".to_string(), + span_target_offset: 864, + next_aligned_candidate_offset: Some(940), + header_candidates: vec![SmpRuntimePostSpanHeaderCandidate { + offset: 940, + words: vec![], + hex_words: vec![], + dense_word_count: 3, + high_u16_words: vec![0x0186, 0x0006, 0x0006, 0x0001], + high_hex_words: vec![], + grounded_alignments: vec![], + }], + ..base_post_span.clone() + }; + let scenario_profile = SmpRt3105PackedProfileProbe { + profile_family: "rt3-105-scenario-save-container-v1".to_string(), + packed_profile_block: SmpRt3105PackedProfileBlock { + map_path: Some("Southern Pacific.gmp".to_string()), + display_name: Some("Southern Pacific".to_string()), + profile_byte_0x82: 0x90, + profile_byte_0x82_hex: "0x90".to_string(), + ..base_profile.packed_profile_block.clone() + }, + ..base_profile.clone() + }; + let scenario_bridge = parse_rt3_105_post_span_bridge_probe( + Some(&scenario_trailer), + Some(&scenario_post_span), + Some(&scenario_profile), + ) + .expect("scenario bridge should parse"); + assert_eq!( + scenario_bridge.bridge_family, + "rt3-105-scenario-post-span-bridge-v1" + ); + assert_eq!( + scenario_bridge.next_candidate_delta_from_packed_profile, + Some(-28692) + ); + } + + #[test] + fn parses_rt3_105_save_bridge_payload_probe() { + let mut bytes = vec![0u8; 0x7000]; + let primary = 0x4f14usize; + let secondary = 0x671cusize; + let primary_words: [u32; 8] = [ + 0x62000000, 0x00000000, 0xfff70000, 0x55150000, 0x55550000, 0x00000000, 0xfff70000, + 0x54550000, + ]; + for (index, word) in primary_words.iter().enumerate() { + bytes[primary + index * 4..primary + (index + 1) * 4] + .copy_from_slice(&(*word).to_le_bytes()); + } + + let secondary_words: [u32; 8] = [ + 0x00050000, 0x00050005, 0xfff70000, 0x54540000, 0x545400f9, 0x00f900f9, 0x00f94008, + 0x00001555, + ]; + for (index, word) in secondary_words.iter().enumerate() { + bytes[secondary + index * 4..secondary + (index + 1) * 4] + .copy_from_slice(&(*word).to_le_bytes()); + } + + let bridge_probe = SmpRt3105PostSpanBridgeProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + bridge_family: "rt3-105-save-post-span-bridge-v1".to_string(), + bridge_evidence: vec![], + span_target_offset: 0x3678, + next_candidate_offset: Some(primary), + next_candidate_delta_from_span_target: Some(primary - 0x3678), + packed_profile_offset: 0x73c0, + packed_profile_delta_from_span_target: 0x3d48, + next_candidate_delta_from_packed_profile: Some(primary as i64 - 0x73c0), + selector_high_u16: 0x7110, + selector_high_hex: "0x7110".to_string(), + descriptor_high_u16: 0x7801, + descriptor_high_hex: "0x7801".to_string(), + next_candidate_high_u16_words: vec![0x6200, 0x0000, 0xfff7, 0x5515], + next_candidate_high_hex_words: vec![], + }; + + let probe = parse_rt3_105_save_bridge_payload_probe(&bytes, Some(&bridge_probe)) + .expect("save bridge payload probe should parse"); + + assert_eq!(probe.primary_block_offset, primary); + assert_eq!(probe.primary_block_len, 0x20); + assert_eq!(probe.secondary_block_offset, secondary); + assert_eq!(probe.secondary_block_delta_from_primary, 0x1808); + assert_eq!(probe.secondary_block_end_offset, 0x73c0); + assert_eq!(probe.secondary_block_len, 0xca4); + assert_eq!(probe.primary_words[..4], primary_words[..4]); + assert_eq!(probe.secondary_words[..8], secondary_words[..8]); + } + + #[test] + fn parses_rt3_105_save_name_table_probe() { + let mut bytes = vec![0u8; 0x7400]; + let secondary = 0x671cusize; + let header = secondary + 0x354; + let entries = secondary + 0x3b5; + let stride = 0x22usize; + let names = ["AluminumMill", "Nuclear Power Plant", "Bakery"]; + + bytes[header..header + 4].copy_from_slice(&0x10000000u32.to_le_bytes()); + bytes[header + 4..header + 8].copy_from_slice(&0x00009000u32.to_le_bytes()); + bytes[header + 8..header + 12].copy_from_slice(&0x0000332eu32.to_le_bytes()); + bytes[header + 0x1c..header + 0x20].copy_from_slice(&4u32.to_le_bytes()); + bytes[header + 0x20..header + 0x24].copy_from_slice(&(names.len() as u32).to_le_bytes()); + bytes[header + 12..header + 16].copy_from_slice(&1u32.to_le_bytes()); + bytes[header + 16..header + 20].copy_from_slice(&0x22u32.to_le_bytes()); + bytes[header + 20..header + 24].copy_from_slice(&2u32.to_le_bytes()); + bytes[header + 24..header + 28].copy_from_slice(&2u32.to_le_bytes()); + bytes[header + 0x28..header + 0x2c].copy_from_slice(&1u32.to_le_bytes()); + + for (index, name) in names.iter().enumerate() { + let off = entries + index * stride; + let raw = &mut bytes[off..off + stride]; + raw[..name.len()].copy_from_slice(name.as_bytes()); + let trailer = if *name == "Nuclear Power Plant" { + 0u32 + } else { + 1u32 + }; + raw[stride - 4..stride].copy_from_slice(&trailer.to_le_bytes()); + } + let footer = entries + names.len() * stride; + bytes[footer..footer + 4].copy_from_slice(&0x32dcu32.to_le_bytes()); + bytes[footer + 4..footer + 8].copy_from_slice(&0x3714u32.to_le_bytes()); + bytes[footer + 8] = 0x00; + + let payload = SmpRt3105SaveBridgePayloadProbe { + profile_family: "rt3-105-save-container-v1".to_string(), + bridge_family: "rt3-105-save-post-span-bridge-v1".to_string(), + primary_block_offset: 0x4f14, + primary_block_len: 0x20, + primary_block_len_hex: "0x20".to_string(), + primary_words: vec![], + primary_hex_words: vec![], + secondary_block_offset: secondary, + secondary_block_delta_from_primary: 0x1808, + secondary_block_delta_from_primary_hex: "0x1808".to_string(), + secondary_block_end_offset: footer + 9, + secondary_block_len: footer + 9 - secondary, + secondary_block_len_hex: format!("0x{:x}", footer + 9 - secondary), + secondary_preview_word_count: 32, + secondary_words: vec![], + secondary_hex_words: vec![], + evidence: vec![], + }; + + let probe = parse_rt3_105_save_name_table_probe( + &bytes, + Some("gms"), + Some(&SmpContainerProfile { + profile_family: "rt3-105-save-container-v1".to_string(), + profile_evidence: vec![], + is_known_profile: true, + }), + Some(&payload), + ) + .expect("save name table probe should parse"); + + assert_eq!(probe.source_kind, "save-bridge-secondary-block"); + assert_eq!( + probe.semantic_family, + "scenario-named-candidate-availability-table" + ); + assert_eq!(probe.header_offset, header); + assert_eq!(probe.entry_stride, stride); + assert_eq!(probe.observed_entry_capacity, 4); + assert_eq!(probe.observed_entry_count, names.len()); + assert_eq!(probe.entries[0].text, "AluminumMill"); + assert_eq!(probe.entries[0].availability_dword, 1); + assert_eq!(probe.entries[2].text, "Bakery"); + assert_eq!(probe.zero_trailer_entry_count, 1); + assert_eq!( + probe.zero_trailer_entry_names, + vec!["Nuclear Power Plant".to_string()] + ); + assert_eq!(probe.trailing_footer_hex, "dc3200001437000000"); + assert_eq!(probe.footer_progress_word_0, 0x32dc); + assert_eq!(probe.footer_progress_word_1, 0x3714); + assert_eq!(probe.footer_trailing_byte, 0x00); + } + + #[test] + fn parses_rt3_105_map_name_table_probe_from_fixed_offsets() { + let mut bytes = vec![0u8; 0x7400]; + let header = 0x6a70usize; + let entries = 0x6ad1usize; + let stride = 0x22usize; + let observed_entry_count = 67usize; + + bytes[header..header + 4].copy_from_slice(&0x00000000u32.to_le_bytes()); + bytes[header + 4..header + 8].copy_from_slice(&0x00000000u32.to_le_bytes()); + bytes[header + 8..header + 12].copy_from_slice(&0x0000332eu32.to_le_bytes()); + bytes[header + 12..header + 16].copy_from_slice(&1u32.to_le_bytes()); + bytes[header + 16..header + 20].copy_from_slice(&0x22u32.to_le_bytes()); + bytes[header + 20..header + 24].copy_from_slice(&2u32.to_le_bytes()); + bytes[header + 24..header + 28].copy_from_slice(&2u32.to_le_bytes()); + bytes[header + 0x1c..header + 0x20].copy_from_slice(&0x44u32.to_le_bytes()); + bytes[header + 0x20..header + 0x24] + .copy_from_slice(&(observed_entry_count as u32).to_le_bytes()); + bytes[header + 0x28..header + 0x2c].copy_from_slice(&1u32.to_le_bytes()); + + for index in 0..observed_entry_count { + let name = match index { + 0 => "AutoPlant".to_string(), + 1 => "Nuclear Power Plant".to_string(), + 66 => "Warehouse11".to_string(), + _ => format!("Entry{index:02}"), + }; + let off = entries + index * stride; + let raw = &mut bytes[off..off + stride]; + raw[..name.len()].copy_from_slice(name.as_bytes()); + let trailer = if name == "Nuclear Power Plant" { + 0u32 + } else { + 1u32 + }; + raw[stride - 4..stride].copy_from_slice(&trailer.to_le_bytes()); + } + + let footer = entries + observed_entry_count * stride; + bytes[footer..footer + 4].copy_from_slice(&0x32dcu32.to_le_bytes()); + bytes[footer + 4..footer + 8].copy_from_slice(&0x3714u32.to_le_bytes()); + bytes[footer + 8] = 0x00; + + let probe = parse_rt3_105_save_name_table_probe( + &bytes, + Some("gmp"), + Some(&SmpContainerProfile { + profile_family: "rt3-105-map-container-v1".to_string(), + profile_evidence: vec![], + is_known_profile: true, + }), + None, + ) + .expect("map name table probe should parse"); + + assert_eq!(probe.profile_family, "rt3-105-map-container-v1"); + assert_eq!(probe.source_kind, "map-fixed-catalog-range"); + assert_eq!(probe.header_offset, header); + assert_eq!(probe.entries_offset, entries); + assert_eq!(probe.observed_entry_count, observed_entry_count); + assert_eq!(probe.entries[0].text, "AutoPlant"); + assert_eq!(probe.entries[66].text, "Warehouse11"); + assert_eq!( + probe.zero_trailer_entry_names, + vec!["Nuclear Power Plant".to_string()] + ); + assert_eq!(probe.footer_progress_word_0, 0x32dc); + assert_eq!(probe.footer_progress_word_1, 0x3714); + } + + #[test] + fn classifies_rt3_105_alt_save_container_profile() { + let shared_header = SmpSharedHeader { + byte_len: 64, + root_kind_word: 0x000025e5, + root_kind_word_hex: "0x000025e5".to_string(), + primary_family_tag: 0x00002ee0, + primary_family_tag_hex: "0x00002ee0".to_string(), + shared_signature_words_1_to_7: vec![ + 0x00002ee0, 0x0001c001, 0x00018000, 0x00010000, 0x00000754, 0x00000754, 0x00000754, + ], + shared_signature_hex_words_1_to_7: vec![ + "0x00002ee0".to_string(), + "0x0001c001".to_string(), + "0x00018000".to_string(), + "0x00010000".to_string(), + "0x00000754".to_string(), + "0x00000754".to_string(), + "0x00000754".to_string(), + ], + matches_grounded_common_signature: false, + payload_window_words_8_to_9: vec![0x007a5978, 0x007a9022], + payload_window_hex_words_8_to_9: vec![ + "0x007a5978".to_string(), + "0x007a9022".to_string(), + ], + reserved_words_10_to_14: vec![0; 5], + reserved_words_10_to_14_all_zero: true, + final_flag_word: 0, + final_flag_word_hex: "0x00000000".to_string(), + }; + let early_content_probe = SmpEarlyContentProbe { + first_post_text_nonzero_offset: 722, + zero_pad_after_text_len: 431, + first_post_text_block_len: 35, + first_post_text_block_hex: + "0101010000010000000000000100000000000000010000000000000000010100000001".to_string(), + trailing_zero_pad_after_first_block_len: 45, + secondary_nonzero_offset: Some(802), + secondary_aligned_word_window_offset: Some(800), + secondary_aligned_word_window_words: vec![ + 0x00010000, 0x49f00100, 0x00000002, 0xa0000000, 0x00000186, 0x00000000, 0x000186a0, + 0x00000000, + ], + secondary_aligned_word_window_hex_words: vec![ + "0x00010000".to_string(), + "0x49f00100".to_string(), + "0x00000002".to_string(), + "0xa0000000".to_string(), + "0x00000186".to_string(), + "0x00000000".to_string(), + "0x000186a0".to_string(), + "0x00000000".to_string(), + ], + secondary_preview_hex: + "01000001f04902000000000000a08601000000000000a08601000000000000a0".to_string(), + }; + + let header_variant = classify_header_variant_probe(&shared_header); + let secondary_variant = + classify_secondary_variant_probe(&early_content_probe).expect("secondary probe"); + let container_profile = classify_container_profile( + Some("gms"), + Some(&header_variant), + Some(&secondary_variant), + ) + .expect("container profile"); + + assert_eq!(header_variant.variant_family, "rt3-105-alt-save-header-v1"); + assert_eq!( + secondary_variant.variant_family, + "rt3-105-gms-alt-family-v1" + ); + assert_eq!( + container_profile.profile_family, + "rt3-105-alt-save-container-v1" + ); + assert!(container_profile.is_known_profile); + } + + #[test] + fn classifies_rt3_105_map_container_profiles_from_header_families() { + let scenario_profile = classify_container_profile( + Some("gmp"), + Some(&SmpHeaderVariantProbe { + variant_family: "rt3-105-scenario-save-header-v1".to_string(), + variant_evidence: vec![], + is_known_family: true, + }), + Some(&SmpSecondaryVariantProbe { + aligned_window_offset: 0, + words: vec![1, 0, 0, 0], + hex_words: vec![], + variant_family: "unknown".to_string(), + variant_evidence: vec![], + }), + ) + .expect("scenario map profile"); + + let alt_profile = classify_container_profile( + Some("gmp"), + Some(&SmpHeaderVariantProbe { + variant_family: "rt3-105-alt-save-header-v1".to_string(), + variant_evidence: vec![], + is_known_family: true, + }), + Some(&SmpSecondaryVariantProbe { + aligned_window_offset: 0, + words: vec![0x49f00100, 2, 0xa0000000, 0x186], + hex_words: vec![], + variant_family: "unknown".to_string(), + variant_evidence: vec![], + }), + ) + .expect("alt map profile"); + + assert_eq!( + scenario_profile.profile_family, + "rt3-105-scenario-map-container-v1" + ); + assert!(scenario_profile.is_known_profile); + assert_eq!(alt_profile.profile_family, "rt3-105-alt-map-container-v1"); + assert!(alt_profile.is_known_profile); + } +} diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs new file mode 100644 index 0000000..0055c77 --- /dev/null +++ b/crates/rrt-runtime/src/step.rs @@ -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, + pub serviced_record_ids: Vec, + 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, + pub service_events: Vec, +} + +pub fn execute_step_command( + state: &mut RuntimeState, + command: &StepCommand, +) -> Result { + 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, +) -> Result { + 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, +) -> u64 { + for _ in 0..steps { + step_once(state, boundary_events); + } + steps.into() +} + +fn step_once(state: &mut RuntimeState, boundary_events: &mut Vec) { + 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) { + 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, +) { + 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) + ); + } +} diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs new file mode 100644 index 0000000..ea3ef29 --- /dev/null +++ b/crates/rrt-runtime/src/summary.rs @@ -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(), + } + } +} diff --git a/crates/rrt-runtime/src/win.rs b/crates/rrt-runtime/src/win.rs new file mode 100644 index 0000000..b7bf405 --- /dev/null +++ b/crates/rrt-runtime/src/win.rs @@ -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, + pub delta_from_previous_hex: Option, + pub prelude_words: Vec, + 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, + pub preceding_named_record_offset_hex: Option, + pub following_named_record_name: Option, + pub following_named_record_offset_hex: Option, + 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, + pub matches_observed_common_signature: bool, + pub common_resource_record_prelude_prefix_words: Option>, + pub name_len_matches_prelude_word_3_plus_nul_count: usize, + pub inline_root_resource_name: Option, + pub inline_root_resource_offset: Option, + pub inline_root_resource_offset_hex: Option, + pub imb_reference_count: usize, + pub unique_imb_reference_count: usize, + pub unique_imb_references: Vec, + pub dominant_reference_deltas: Vec, + pub resource_selector_records: Vec, + pub anonymous_selector_records: Vec, + pub first_resource_record_samples: Vec, + pub first_imb_references: Vec, + pub notes: Vec, +} + +pub fn inspect_win_file(path: &Path) -> Result> { + let bytes = fs::read(path)?; + inspect_win_bytes(&bytes) +} + +pub fn inspect_win_bytes(bytes: &[u8]) -> Result> { + 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::>(); + + 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 { + 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 { + 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 { + let mut counts = std::collections::BTreeMap::::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::>(); + 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 { + 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 { + 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> { + let first = samples.first()?; + if first.prelude_words.len() < 3 { + return None; + } + let prefix = first.prelude_words[..3] + .iter() + .map(|word| word.value) + .collect::>(); + if samples.iter().all(|sample| { + sample.prelude_words.len() >= 3 + && sample.prelude_words[..3] + .iter() + .map(|word| word.value) + .collect::>() + == prefix + }) { + return Some( + prefix + .into_iter() + .map(|value| format!("0x{value:08x}")) + .collect(), + ); + } + None +} + +fn parse_imb_reference_at(bytes: &[u8], offset: usize) -> Option { + 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 { + 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 { + 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()); + } +} diff --git a/docs/README.md b/docs/README.md index 1c168d7..0ef3327 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ project is already mature. - `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. - `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 @@ -62,17 +63,19 @@ Current local tool status: ## 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 -- name and connect the major loop roots and gateways for startup, shell/UI, frame or presentation, - simulation, map/scenario load, input, save/load, and multiplayer/network -- use `export_startup_map.py` and `export_analysis_context.py` to widen breadth around candidate loop - dispatchers before doing deep leaf naming -- keep the pending-template and multiplayer transport dossiers available, but treat them as targeted - deep-dive tools once a missing atlas edge needs branch-specific grounding -- stand up the Rust workspace so artifacts can be validated in code and a minimal hook DLL can be - built as soon as the 32-bit linker is present +- preserve the atlas and function map as the source of subsystem boundaries while avoiding further + shell-first implementation bets +- stand up a bottom-up runtime core that can load state, execute deterministic world work, and dump + normalized diffs without depending on the shell controller or presentation path +- use `rrt-hook` primarily as an optional capture tool for fixtures and state probes, not as the + first execution environment +- choose early rewrite targets from the lower simulation, event-service, and persistence boundaries + before attempting shell, input, or presentation replacement +- write milestone-scoped implementation notes in `docs/runtime-rehost-plan.md` before expanding the + workspace crates Regenerate the initial exports with: diff --git a/docs/control-loop-atlas.md b/docs/control-loop-atlas.md index 63d5a33..e6a6441 100644 --- a/docs/control-loop-atlas.md +++ b/docs/control-loop-atlas.md @@ -142,17 +142,69 @@ transition. 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 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`, - mode `3` to `Video.win`, mode `4` to `LoadScreen.win`, mode `5` to `Multiplayer.win`, mode `6` - to `Credits.win`, and mode `7` to `Campaign.win`. The strongest current load-side owner inside - that table remains the mode-`4` branch around `0x4830ca`, which publishes the new active mode - object into `0x006cec78` and then calls `shell_active_mode_run_profile_startup_and_load_dispatch` - as `thiscall(active_mode, 1, 0)`. The caller split above that owner is tighter now too: + switch: its ABI is now grounded as a `thiscall` with two stack arguments because the body reads + the requested mode from `[esp+0x0c]` and returns with `ret 8`. The grounded world-entry + load-screen call shape is `(4, 0)`, not a one-arg mode switch. The second stack argument is now + tighter too: current local evidence reads it as an old-active-mode teardown flag, because the + `0x482fc6..0x482fff` branch only runs when it is nonzero and then releases the prior active-mode + 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)` 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 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 + 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 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 @@ -222,6 +274,37 @@ transition. 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 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 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 @@ -265,7 +348,33 @@ transition. 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 `[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 around `0x422320..0x423d30`, whose current grounded helpers now include `0x422320`, `0x4228b0`, `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 `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 - only then issues shell request `0x0cc`. Only `0x0e81` also sets `[0x006cec7c+0x82] = 1`, which - is currently the strongest sandbox-side anchor beneath the later `.gmx` load family. + only then issues shell request `0x0cc`. The file-side staging bytes in that bridge are now + 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 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 @@ -793,6 +989,15 @@ transition. 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 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 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 @@ -1952,6 +2157,106 @@ transition. bytes `[world+0x66de]` and `[world+0x66f2]`, restores the selected year/profile lane through `[profile+0x77]` into `[world+0x05/+0x09/+0x15]` through `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 rebuilds the dependent selected-year bucket floats after the packed year changes; and then 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.` 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 - 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 `map_editor_panel_select_active_section` `0x004ce070` and `map_editor_panel_dispatch_active_section_message` `0x004cf700`, which switch among the grounded diff --git a/docs/debug-load-workflow.md b/docs/debug-load-workflow.md index 2c34099..bc2c3e7 100644 --- a/docs/debug-load-workflow.md +++ b/docs/debug-load-workflow.md @@ -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?" -## 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` -- `rrt-hook: auto load restore calling` +The hook-side state machine is now stable up to the handoff into `shell_transition_mode`: -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 -- RT3 later re-enters shell object traversal in a phase where one list entry is still invalid +- no `shell_active_mode_run_profile_startup_and_load_dispatch` `0x00438890` entry -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 @@ -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`. +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: - `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` - `0x004390cb` - `0x00445ac0` - - `0x0053fea6` -- but only `0x0053fea6` actually fired in the captured run +- older runs that also broke on `0x0053fea6` stopped too early on that shell-side crash site +- 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: @@ -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: - [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) +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. Both scripts rebuild `rrt-hook`, copy `dinput8.dll` into the Wine RT3 directory, and launch RT3 under `winedbg`. diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md new file mode 100644 index 0000000..81bb3b1 --- /dev/null +++ b/docs/runtime-rehost-plan.md @@ -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 ` 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 ` +- `rrt-cli runtime summarize-fixture ` +- `rrt-cli runtime diff-state ` + +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. diff --git a/fixtures/runtime/minimal-world-raw-state.json b/fixtures/runtime/minimal-world-raw-state.json new file mode 100644 index 0000000..ae7073e --- /dev/null +++ b/fixtures/runtime/minimal-world-raw-state.json @@ -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 + } + } +} diff --git a/fixtures/runtime/minimal-world-step-from-snapshot.json b/fixtures/runtime/minimal-world-step-from-snapshot.json new file mode 100644 index 0000000..1597bb6 --- /dev/null +++ b/fixtures/runtime/minimal-world-step-from-snapshot.json @@ -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 + } +} diff --git a/fixtures/runtime/minimal-world-step-smoke-final-state.json b/fixtures/runtime/minimal-world-step-smoke-final-state.json new file mode 100644 index 0000000..6c0cc70 --- /dev/null +++ b/fixtures/runtime/minimal-world-step-smoke-final-state.json @@ -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 + } + } +} diff --git a/fixtures/runtime/minimal-world-step-smoke.json b/fixtures/runtime/minimal-world-step-smoke.json new file mode 100644 index 0000000..df41f17 --- /dev/null +++ b/fixtures/runtime/minimal-world-step-smoke.json @@ -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 + } +} diff --git a/fixtures/runtime/periodic-boundary-service-smoke.json b/fixtures/runtime/periodic-boundary-service-smoke.json new file mode 100644 index 0000000..f297d66 --- /dev/null +++ b/fixtures/runtime/periodic-boundary-service-smoke.json @@ -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 + } +} diff --git a/tools/run_hook_auto_load_winedbg.sh b/tools/run_hook_auto_load_winedbg.sh index f2a0df5..06c0b10 100755 --- a/tools/run_hook_auto_load_winedbg.sh +++ b/tools/run_hook_auto_load_winedbg.sh @@ -16,7 +16,7 @@ export WINEPREFIX="$repo_root/rt3_wineprefix" export WINEDLLOVERRIDES="dinput8=n,b" 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 cmd+=(--file "$cmd_file") fi diff --git a/tools/run_hook_auto_load_winedbg_compare.sh b/tools/run_hook_auto_load_winedbg_compare.sh new file mode 100755 index 0000000..fcb4afe --- /dev/null +++ b/tools/run_hook_auto_load_winedbg_compare.sh @@ -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}" diff --git a/tools/winedbg_auto_load_compare.cmd b/tools/winedbg_auto_load_compare.cmd index 1ff7ed6..363f5f5 100644 --- a/tools/winedbg_auto_load_compare.cmd +++ b/tools/winedbg_auto_load_compare.cmd @@ -1,43 +1,25 @@ break *0x00438890 break *0x004390cb break *0x00445ac0 -break *0x0053fea6 -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 -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 +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 print/x *(unsigned int*)($esp) diff --git a/tools/winedbg_auto_load_crash.cmd b/tools/winedbg_auto_load_crash.cmd new file mode 100644 index 0000000..793c6a5 --- /dev/null +++ b/tools/winedbg_auto_load_crash.cmd @@ -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