paavohtl Claude Opus 4.6 commited on
Commit
70e6ea1
·
1 Parent(s): eb5a0d8

docs: add configuration system design and implementation plan

Browse files

Design doc and implementation plan for porting WA's configuration
subsystem (Theme I/O, Registry, GameInfo, CrashReportURL) to Rust.
These were used to guide the config.rs implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Cargo.lock CHANGED
@@ -60,6 +60,7 @@ name = "openwa-lib"
60
  version = "0.1.0"
61
  dependencies = [
62
  "openwa-types",
 
63
  ]
64
 
65
  [[package]]
 
60
  version = "0.1.0"
61
  dependencies = [
62
  "openwa-types",
63
+ "windows-sys",
64
  ]
65
 
66
  [[package]]
docs/plans/2026-03-06-configuration-system-design.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Port Configuration System to Rust
2
+
3
+ ## Context
4
+
5
+ The WA configuration system has three subsystems: theme file I/O, Windows Registry management, and game options loading. All are self-contained leaf functions with no graphics or game state dependencies beyond simple struct field writes.
6
+
7
+ ## Scope — 7 Functions
8
+
9
+ ### Theme File I/O (3 functions)
10
+
11
+ | Function | Address | Convention | Params | What it does |
12
+ |----------|---------|------------|--------|-------------|
13
+ | `Theme__GetFileSize` | 0x44BA80 | cdecl | 0 | Opens `data\current.thm`, returns file length (0 if missing) |
14
+ | `Theme__Load` | 0x44BB20 | stdcall | 1 (dest buffer) | Reads entire theme file into buffer; MessageBox on failure |
15
+ | `Theme__Save` | 0x44BBC0 | stdcall | 2 (buffer, size) | Writes buffer to theme file; MessageBox on create failure |
16
+
17
+ Replace MFC CFile with `std::fs`. Replace `AfxMessageBox` with `MessageBoxA` from the `windows` crate.
18
+
19
+ ### Registry Management (2 functions)
20
+
21
+ | Function | Address | Convention | Params | What it does |
22
+ |----------|---------|------------|--------|-------------|
23
+ | `Registry__DeleteKeyRecursive` | 0x4E4D10 | stdcall | 2 (hkey, subkey) | Recursively enumerates and deletes registry subkeys |
24
+ | `Registry__CleanAll` | 0x4C90D0 | stdcall | 1 (struct ptr) | Deletes Data/Options/ExportVideo/VSyncAssist subsections + clears INI |
25
+
26
+ Pure Win32 registry API. Original uses malloc/realloc for key name buffer — Rust `Vec<u8>` replaces this.
27
+
28
+ ### Game Options (2 functions)
29
+
30
+ | Function | Address | Convention | Params | What it does |
31
+ |----------|---------|------------|--------|-------------|
32
+ | `GameInfo__LoadOptions` | 0x460AC0 | stdcall | 1 (GameInfo ptr) | Reads ~12 registry values into GameInfo struct fields |
33
+ | `Options__GetCrashReportURL` | 0x5A63F0 | cdecl | 0 | Reads CrashReportURL from Options registry key |
34
+
35
+ LoadOptions reads from `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\Options` via MFC `GetProfileIntW`. We replace with direct `RegOpenKeyExA` + `RegQueryValueExA`.
36
+
37
+ Key value transformations in LoadOptions:
38
+ - `BackgroundDebrisParallax`: clamp to i16 range, then `<< 16` (fixed-point 16.16)
39
+ - `CameraUnlockMouseSpeed`: clamp to max 0xB504, then square
40
+ - `InfoSpy`: stored as `!= 0` (bool)
41
+ - All others: simple cast to byte or store as u32
42
+
43
+ LoadOptions also copies various globals into the GameInfo struct and formats the speech path via sprintf.
44
+
45
+ ## Dependencies
46
+
47
+ ### `windows` crate
48
+
49
+ Add to `openwa-lib` with features:
50
+ - `Win32_System_Registry` — RegOpenKeyExA, RegQueryValueExA, RegEnumKeyExA, RegDeleteKeyA, RegCloseKey
51
+ - `Win32_UI_WindowsAndMessaging` — MessageBoxA, WriteProfileSectionA
52
+
53
+ Existing raw FFI in `resource.rs` and `rebase.rs` stays as-is for now — migration to `windows` crate is a separate future task.
54
+
55
+ ### Registry helper in openwa-lib
56
+
57
+ `read_profile_int(section: &str, key: &str, default: u32) -> u32`
58
+
59
+ Opens `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\{section}`, reads a DWORD via `RegQueryValueExA`, returns default on any failure. Matches MFC `GetProfileIntW` behavior for the registry-mode path.
60
+
61
+ ## Hook Strategy
62
+
63
+ All 7 functions are full Rust replacements — no trampolines needed. All conventions are plain cdecl or stdcall (confirmed via RET instruction analysis).
64
+
65
+ ## File Layout
66
+
67
+ | File | Change |
68
+ |------|--------|
69
+ | `crates/openwa-types/src/address.rs` | Add 7 address constants |
70
+ | `crates/openwa-lib/Cargo.toml` | Add `windows` crate dependency |
71
+ | `crates/openwa-lib/src/wa/registry.rs` (new) | `read_profile_int`, `delete_key_recursive` |
72
+ | `crates/openwa-lib/src/wa/mod.rs` | Add `pub mod registry;` |
73
+ | `crates/openwa-wormkit/src/replacements/config.rs` (new) | 7 hook functions + `install()` |
74
+ | `crates/openwa-wormkit/src/replacements/mod.rs` | Add `pub mod config;` |
75
+
76
+ ## Global Data References in LoadOptions
77
+
78
+ | Global Address | Description | GameInfo Offset |
79
+ |---------------|-------------|-----------------|
80
+ | 0x0088E282 | Base directory string | +0xF404 (speech path) |
81
+ | 0x0088DFF3 | 64 bytes copied verbatim | +0xF485 |
82
+ | 0x0064DA58 | `"data\land.dat"` string (14 bytes) | +0xDAEC |
83
+ | 0x007C0D38 | Unknown byte | +0xF3A0 |
84
+ | 0x0088E39C..0x0088E3AC | 5 DWORDs | +0xF3B4..+0xF3D0 |
85
+ | 0x0088C374 | Guard flag for conditional copies | — |
86
+ | 0x0088E3B8..0x0088E3C4 | 4 DWORDs (conditional) | +0xF3F4..+0xF400 |
87
+ | 0x0088E390 | DWORD | +0xDAE8 |
88
+ | 0x0088E3B0..0x0088E3B4 | 2 DWORDs | +0xF3D4..+0xF3D8 |
89
+ | 0x0088E400..0x0088E408 | 3 DWORDs | +0xF3C4..+0xF3CC |
90
+ | 0x0088E44C | DWORD | +0xF3E4 |
91
+
92
+ These are read via rebased pointers (`rb(addr)`) and written to the GameInfo struct at the given offsets.
93
+
94
+ ## Verification
95
+
96
+ 1. Build: `cargo build --release -p openwa-wormkit` — clean
97
+ 2. Deploy wkOpenWA.dll to game directory
98
+ 3. Launch game, check OpenWA.log for config hook messages
99
+ 4. Verify game options persist (change Detail Level, restart, confirm it's saved)
100
+ 5. Verify theme loads correctly (visual appearance unchanged)
101
+ 6. Test Registry__CleanAll if reachable (it's called from a "reset all settings" path)
102
+ 7. Check CrashReportURL is read correctly (visible in crash dialog if triggered)
103
+
104
+ ## Decisions
105
+
106
+ - **`windows` crate**: Add now, migrate existing FFI later
107
+ - **Theme error messages**: Use `MessageBoxA` from `windows` crate (replaces AfxMessageBox)
108
+ - **Registry reads**: Direct Win32 API, not MFC GetProfileIntW (Steam always uses registry mode)
109
+ - **GameInfo struct**: Write to raw pointer at known offsets (don't type the full 0xF500-byte struct)
110
+ - **std::fs over CFile**: For theme I/O
docs/plans/2026-03-06-configuration-system-plan.md ADDED
@@ -0,0 +1,877 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Configuration System Port — Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Port 7 WA.exe configuration functions (theme I/O, registry management, game options loading) to Rust.
6
+
7
+ **Architecture:** New `config.rs` replacement module in openwa-wormkit hooks 7 functions. Registry helpers live in openwa-lib for reuse. Uses `windows` crate for Win32 registry/MessageBox APIs, `std::fs` for theme file I/O.
8
+
9
+ **Tech Stack:** Rust, `windows` crate (Win32 registry + UI), MinHook inline hooks, `std::fs`
10
+
11
+ ---
12
+
13
+ ### Task 1: Add `windows` crate dependency to openwa-lib
14
+
15
+ **Files:**
16
+ - Modify: `crates/openwa-lib/Cargo.toml`
17
+
18
+ **Step 1: Add the dependency**
19
+
20
+ ```toml
21
+ [dependencies]
22
+ openwa-types = { path = "../openwa-types" }
23
+
24
+ [target.'cfg(target_os = "windows")'.dependencies]
25
+ windows-sys = { version = "0.59", features = [
26
+ "Win32_System_Registry",
27
+ "Win32_UI_WindowsAndMessaging",
28
+ ] }
29
+ ```
30
+
31
+ Note: Use `windows-sys` (not `windows`) — it's the raw FFI bindings crate, already used by openwa-wormkit. Lower overhead, no COM wrappers.
32
+
33
+ **Step 2: Build to verify**
34
+
35
+ Run: `cargo build --release -p openwa-lib`
36
+ Expected: clean build
37
+
38
+ **Step 3: Commit**
39
+
40
+ ```
41
+ feat: add windows-sys registry + UI features to openwa-lib
42
+ ```
43
+
44
+ ---
45
+
46
+ ### Task 2: Add address constants
47
+
48
+ **Files:**
49
+ - Modify: `crates/openwa-types/src/address.rs`
50
+
51
+ **Step 1: Add constants**
52
+
53
+ Add to the `va` module, in a new `// === Configuration / registry ===` section after the scheme section:
54
+
55
+ ```rust
56
+ // === Configuration / registry ===
57
+
58
+ /// Theme file size check: cdecl() -> u32 (file length or 0)
59
+ pub const THEME_GET_FILE_SIZE: u32 = 0x0044_BA80;
60
+ /// Theme file load: stdcall(dest_buffer)
61
+ pub const THEME_LOAD: u32 = 0x0044_BB20;
62
+ /// Theme file save: stdcall(buffer, size)
63
+ pub const THEME_SAVE: u32 = 0x0044_BBC0;
64
+ /// Recursive registry key deletion: stdcall(hkey, subkey) -> i32
65
+ pub const REGISTRY_DELETE_KEY_RECURSIVE: u32 = 0x004E_4D10;
66
+ /// Registry cleanup — deletes all 4 subsections + clears INI: stdcall(struct_ptr)
67
+ pub const REGISTRY_CLEAN_ALL: u32 = 0x004C_90D0;
68
+ /// Loads game options from registry into GameInfo struct: stdcall(game_info_ptr)
69
+ pub const GAMEINFO_LOAD_OPTIONS: u32 = 0x0046_0AC0;
70
+ /// Reads CrashReportURL from Options registry key: cdecl() -> *const u8
71
+ pub const OPTIONS_GET_CRASH_REPORT_URL: u32 = 0x005A_63F0;
72
+ ```
73
+
74
+ **Step 2: Build to verify**
75
+
76
+ Run: `cargo build --release -p openwa-types`
77
+ Expected: clean build
78
+
79
+ **Step 3: Commit**
80
+
81
+ ```
82
+ feat: add configuration system address constants
83
+ ```
84
+
85
+ ---
86
+
87
+ ### Task 3: Add registry helpers to openwa-lib
88
+
89
+ **Files:**
90
+ - Create: `crates/openwa-lib/src/wa/registry.rs`
91
+ - Modify: `crates/openwa-lib/src/wa/mod.rs`
92
+
93
+ **Step 1: Add `pub mod registry;` to mod.rs**
94
+
95
+ After the existing `pub mod resource;` line:
96
+
97
+ ```rust
98
+ pub mod registry;
99
+ ```
100
+
101
+ **Step 2: Create registry.rs**
102
+
103
+ ```rust
104
+ //! Win32 registry helpers for WA configuration.
105
+
106
+ use windows_sys::Win32::System::Registry::*;
107
+
108
+ const WA_REGISTRY_PATH: &str = "Software\\Team17SoftwareLTD\\WormsArmageddon\\";
109
+
110
+ /// Read an integer value from the WA registry.
111
+ ///
112
+ /// Opens `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\{section}\{key}`
113
+ /// and reads it as a DWORD. Returns `default` on any failure.
114
+ ///
115
+ /// This replaces MFC's `CWinApp::GetProfileIntW` for the registry-mode path.
116
+ pub fn read_profile_int(section: &str, key: &str, default: u32) -> u32 {
117
+ let mut full_path = String::with_capacity(WA_REGISTRY_PATH.len() + section.len() + 1);
118
+ full_path.push_str(WA_REGISTRY_PATH);
119
+ full_path.push_str(section);
120
+ full_path.push('\0');
121
+
122
+ let mut key_buf = String::with_capacity(key.len() + 1);
123
+ key_buf.push_str(key);
124
+ key_buf.push('\0');
125
+
126
+ unsafe {
127
+ let mut hkey: HKEY = 0;
128
+ let status = RegOpenKeyExA(
129
+ HKEY_CURRENT_USER,
130
+ full_path.as_ptr(),
131
+ 0,
132
+ KEY_READ,
133
+ &mut hkey,
134
+ );
135
+ if status != 0 {
136
+ return default;
137
+ }
138
+
139
+ let mut value: u32 = 0;
140
+ let mut value_size: u32 = 4;
141
+ let mut value_type: u32 = 0;
142
+ let status = RegQueryValueExA(
143
+ hkey,
144
+ key_buf.as_ptr(),
145
+ core::ptr::null_mut(),
146
+ &mut value_type,
147
+ &mut value as *mut u32 as *mut u8,
148
+ &mut value_size,
149
+ );
150
+ RegCloseKey(hkey);
151
+
152
+ if status == 0 && value_type == REG_DWORD {
153
+ value
154
+ } else {
155
+ default
156
+ }
157
+ }
158
+ }
159
+
160
+ /// Read a string value from the WA registry.
161
+ ///
162
+ /// Opens `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\{section}\{key}`
163
+ /// and reads it as a REG_SZ string into `buf`. Returns the number of bytes
164
+ /// written (excluding null terminator), or 0 on failure.
165
+ pub fn read_profile_string(section: &str, key: &str, buf: &mut [u8]) -> usize {
166
+ let mut full_path = String::with_capacity(WA_REGISTRY_PATH.len() + section.len() + 1);
167
+ full_path.push_str(WA_REGISTRY_PATH);
168
+ full_path.push_str(section);
169
+ full_path.push('\0');
170
+
171
+ let mut key_buf = String::with_capacity(key.len() + 1);
172
+ key_buf.push_str(key);
173
+ key_buf.push('\0');
174
+
175
+ unsafe {
176
+ let mut hkey: HKEY = 0;
177
+ let status = RegOpenKeyExA(
178
+ HKEY_CURRENT_USER,
179
+ full_path.as_ptr(),
180
+ 0,
181
+ KEY_READ,
182
+ &mut hkey,
183
+ );
184
+ if status != 0 {
185
+ return 0;
186
+ }
187
+
188
+ let mut value_size: u32 = buf.len() as u32;
189
+ let mut value_type: u32 = 0;
190
+ let status = RegQueryValueExA(
191
+ hkey,
192
+ key_buf.as_ptr(),
193
+ core::ptr::null_mut(),
194
+ &mut value_type,
195
+ buf.as_mut_ptr(),
196
+ &mut value_size,
197
+ );
198
+ RegCloseKey(hkey);
199
+
200
+ if status == 0 && value_type == REG_SZ && value_size > 0 {
201
+ // value_size includes null terminator
202
+ (value_size - 1) as usize
203
+ } else {
204
+ 0
205
+ }
206
+ }
207
+ }
208
+
209
+ /// Recursively delete a registry key and all its subkeys.
210
+ ///
211
+ /// Replacement for WA's Registry__DeleteKeyRecursive (0x4E4D10).
212
+ /// Returns 0 on success, Win32 error code on failure.
213
+ pub unsafe fn delete_key_recursive(parent: HKEY, subkey: &str) -> i32 {
214
+ if subkey.is_empty() {
215
+ return 0x3F2; // ERROR_INVALID_PARAMETER equivalent used by original
216
+ }
217
+
218
+ let mut subkey_buf = String::with_capacity(subkey.len() + 1);
219
+ subkey_buf.push_str(subkey);
220
+ subkey_buf.push('\0');
221
+
222
+ let mut hkey: HKEY = 0;
223
+ let status = RegOpenKeyExA(
224
+ parent,
225
+ subkey_buf.as_ptr(),
226
+ 0,
227
+ KEY_READ | KEY_ENUMERATE_SUB_KEYS,
228
+ &mut hkey,
229
+ );
230
+ if status != 0 {
231
+ return status;
232
+ }
233
+
234
+ // Enumerate and recursively delete all child keys
235
+ let mut name_buf = vec![0u8; 256];
236
+ loop {
237
+ let mut name_len = name_buf.len() as u32;
238
+ let status = RegEnumKeyExA(
239
+ hkey,
240
+ 0, // always index 0 since we delete as we go
241
+ name_buf.as_mut_ptr(),
242
+ &mut name_len,
243
+ core::ptr::null_mut(),
244
+ core::ptr::null_mut(),
245
+ core::ptr::null_mut(),
246
+ core::ptr::null_mut(),
247
+ );
248
+
249
+ if status == 0x103 {
250
+ // ERROR_NO_MORE_ITEMS — all children deleted
251
+ break;
252
+ }
253
+ if status != 0 {
254
+ RegCloseKey(hkey);
255
+ return status;
256
+ }
257
+
258
+ if name_len as usize == name_buf.len() {
259
+ // Name buffer too small, grow it
260
+ name_buf.resize(name_buf.len() * 2, 0);
261
+ continue;
262
+ }
263
+
264
+ // Null-terminate and recurse
265
+ name_buf[name_len as usize] = 0;
266
+ let child_name = core::str::from_utf8_unchecked(&name_buf[..name_len as usize]);
267
+ let result = delete_key_recursive(hkey, child_name);
268
+ if result != 0 {
269
+ RegCloseKey(hkey);
270
+ return result;
271
+ }
272
+ }
273
+
274
+ // All children deleted, now delete the key itself
275
+ let status = RegDeleteKeyA(parent, subkey_buf.as_ptr());
276
+ RegCloseKey(hkey);
277
+ status
278
+ }
279
+ ```
280
+
281
+ **Step 3: Build to verify**
282
+
283
+ Run: `cargo build --release -p openwa-lib`
284
+ Expected: clean build
285
+
286
+ **Step 4: Commit**
287
+
288
+ ```
289
+ feat: add Win32 registry helpers to openwa-lib
290
+ ```
291
+
292
+ ---
293
+
294
+ ### Task 4: Create config.rs with Theme hooks
295
+
296
+ **Files:**
297
+ - Create: `crates/openwa-wormkit/src/replacements/config.rs`
298
+ - Modify: `crates/openwa-wormkit/src/replacements/mod.rs`
299
+
300
+ **Step 1: Add `mod config;` to mod.rs and call install**
301
+
302
+ ```rust
303
+ mod config;
304
+ mod frontend;
305
+ mod scheme;
306
+
307
+ pub fn install_all() -> Result<(), String> {
308
+ frontend::install()?;
309
+ scheme::install()?;
310
+ config::install()?;
311
+ Ok(())
312
+ }
313
+ ```
314
+
315
+ **Step 2: Create config.rs with theme functions**
316
+
317
+ ```rust
318
+ //! Configuration system hooks.
319
+ //!
320
+ //! Replaces WA.exe configuration functions with Rust implementations:
321
+ //! - Theme__GetFileSize (0x44BA80): theme file size query
322
+ //! - Theme__Load (0x44BB20): theme file read
323
+ //! - Theme__Save (0x44BBC0): theme file write
324
+ //! - Registry__DeleteKeyRecursive (0x4E4D10): recursive registry deletion
325
+ //! - Registry__CleanAll (0x4C90D0): full registry cleanup
326
+ //! - GameInfo__LoadOptions (0x460AC0): game options from registry
327
+ //! - Options__GetCrashReportURL (0x5A63F0): crash report URL from registry
328
+
329
+ use crate::log_line;
330
+ use openwa_lib::rebase::rb;
331
+ use openwa_types::address::va;
332
+
333
+ const THEME_PATH: &str = "data\\current.thm";
334
+
335
+ // ============================================================
336
+ // Theme__GetFileSize replacement (0x44BA80)
337
+ // ============================================================
338
+
339
+ /// Rust replacement for Theme__GetFileSize.
340
+ /// cdecl() -> u32 (file length, or 0 if missing)
341
+ unsafe extern "cdecl" fn hook_theme_get_file_size() -> u32 {
342
+ match std::fs::metadata(THEME_PATH) {
343
+ Ok(m) => m.len() as u32,
344
+ Err(_) => 0,
345
+ }
346
+ }
347
+
348
+ // ============================================================
349
+ // Theme__Load replacement (0x44BB20)
350
+ // ============================================================
351
+
352
+ /// Rust replacement for Theme__Load.
353
+ /// stdcall(dest_buffer: *mut u8)
354
+ unsafe extern "stdcall" fn hook_theme_load(dest: u32) {
355
+ match std::fs::read(THEME_PATH) {
356
+ Ok(data) => {
357
+ core::ptr::copy_nonoverlapping(data.as_ptr(), dest as *mut u8, data.len());
358
+ }
359
+ Err(_) => {
360
+ show_error_message("ERROR: NO CURRENT.THM FILE FOUND");
361
+ }
362
+ }
363
+ }
364
+
365
+ // ============================================================
366
+ // Theme__Save replacement (0x44BBC0)
367
+ // ============================================================
368
+
369
+ /// Rust replacement for Theme__Save.
370
+ /// stdcall(buffer: *const u8, size: u32)
371
+ unsafe extern "stdcall" fn hook_theme_save(buffer: u32, size: u32) {
372
+ let data = core::slice::from_raw_parts(buffer as *const u8, size as usize);
373
+ if let Err(_) = std::fs::write(THEME_PATH, data) {
374
+ show_error_message("ERROR: Could Not create CURRENT.THM File");
375
+ }
376
+ }
377
+
378
+ /// Show an error message box, matching AfxMessageBox behavior.
379
+ fn show_error_message(msg: &str) {
380
+ use windows_sys::Win32::UI::WindowsAndMessaging::*;
381
+ let mut msg_buf: Vec<u8> = msg.bytes().collect();
382
+ msg_buf.push(0);
383
+ unsafe {
384
+ MessageBoxA(0, msg_buf.as_ptr(), core::ptr::null(), MB_OK);
385
+ }
386
+ }
387
+
388
+ // ============================================================
389
+ // Hook installation
390
+ // ============================================================
391
+
392
+ pub fn install() -> Result<(), String> {
393
+ unsafe {
394
+ let _ = crate::hook::install(
395
+ "Theme__GetFileSize",
396
+ va::THEME_GET_FILE_SIZE,
397
+ hook_theme_get_file_size as *const (),
398
+ )?;
399
+
400
+ let _ = crate::hook::install(
401
+ "Theme__Load",
402
+ va::THEME_LOAD,
403
+ hook_theme_load as *const (),
404
+ )?;
405
+
406
+ let _ = crate::hook::install(
407
+ "Theme__Save",
408
+ va::THEME_SAVE,
409
+ hook_theme_save as *const (),
410
+ )?;
411
+ }
412
+
413
+ Ok(())
414
+ }
415
+ ```
416
+
417
+ **Step 3: Add windows-sys dependency to openwa-wormkit Cargo.toml**
418
+
419
+ The `windows-sys` dep in openwa-wormkit needs the UI feature too. Update the existing entry:
420
+
421
+ ```toml
422
+ [target.'cfg(target_os = "windows")'.dependencies]
423
+ windows-sys = { version = "0.59", features = [
424
+ "Win32_System_Memory",
425
+ "Win32_UI_WindowsAndMessaging",
426
+ ] }
427
+ ```
428
+
429
+ **Step 4: Build to verify**
430
+
431
+ Run: `cargo build --release -p openwa-wormkit`
432
+ Expected: clean build
433
+
434
+ **Step 5: Deploy + test theme**
435
+
436
+ Copy DLL, launch game. Theme should load normally (visual appearance unchanged). Check OpenWA.log for hook messages.
437
+
438
+ **Step 6: Commit**
439
+
440
+ ```
441
+ feat: add Theme file I/O hooks (GetFileSize, Load, Save)
442
+ ```
443
+
444
+ ---
445
+
446
+ ### Task 5: Add Registry hooks
447
+
448
+ **Files:**
449
+ - Modify: `crates/openwa-wormkit/src/replacements/config.rs`
450
+
451
+ **Step 1: Add registry hook functions**
452
+
453
+ Add after the Theme section, before `install()`:
454
+
455
+ ```rust
456
+ // ============================================================
457
+ // Registry__DeleteKeyRecursive replacement (0x4E4D10)
458
+ // ============================================================
459
+
460
+ /// Rust replacement for Registry__DeleteKeyRecursive.
461
+ /// stdcall(hkey: HKEY, subkey: *const u8) -> i32
462
+ unsafe extern "stdcall" fn hook_delete_key_recursive(hkey: u32, subkey: u32) -> i32 {
463
+ use windows_sys::Win32::System::Registry::HKEY;
464
+
465
+ let c_subkey = std::ffi::CStr::from_ptr(subkey as *const i8);
466
+ let subkey_str = c_subkey.to_string_lossy();
467
+
468
+ let _ = log_line(&format!("[Config] DeleteKeyRecursive: {subkey_str}"));
469
+
470
+ let result = openwa_lib::wa::registry::delete_key_recursive(
471
+ hkey as HKEY,
472
+ &subkey_str,
473
+ );
474
+
475
+ let _ = log_line(&format!("[Config] DeleteKeyRecursive result: {result}"));
476
+ result
477
+ }
478
+
479
+ // ============================================================
480
+ // Registry__CleanAll replacement (0x4C90D0)
481
+ // ============================================================
482
+
483
+ /// Rust replacement for Registry__CleanAll.
484
+ /// stdcall(struct_ptr: u32)
485
+ unsafe extern "stdcall" fn hook_registry_clean_all(struct_ptr: u32) {
486
+ use windows_sys::Win32::System::Registry::HKEY_CURRENT_USER;
487
+ use windows_sys::Win32::UI::WindowsAndMessaging::*;
488
+
489
+ let _ = log_line("[Config] CleanAll: deleting registry sections");
490
+
491
+ let sections = [
492
+ "Software\\Team17SoftwareLTD\\WormsArmageddon\\Data",
493
+ "Software\\Team17SoftwareLTD\\WormsArmageddon\\Options",
494
+ "Software\\Team17SoftwareLTD\\WormsArmageddon\\ExportVideo",
495
+ "Software\\Team17SoftwareLTD\\WormsArmageddon\\VSyncAssist",
496
+ ];
497
+
498
+ for section in &sections {
499
+ openwa_lib::wa::registry::delete_key_recursive(HKEY_CURRENT_USER, section);
500
+ }
501
+
502
+ // Clear the NetSettings INI section
503
+ WriteProfileSectionA(b"NetSettings\0".as_ptr(), b"\0".as_ptr());
504
+
505
+ // Set struct_ptr + 0xE0 = 0
506
+ *((struct_ptr + 0xE0) as *mut u8) = 0;
507
+
508
+ let _ = log_line("[Config] CleanAll completed");
509
+ }
510
+ ```
511
+
512
+ **Step 2: Wire up in install()**
513
+
514
+ Add before the closing `Ok(())`:
515
+
516
+ ```rust
517
+ let _ = crate::hook::install(
518
+ "Registry__DeleteKeyRecursive",
519
+ va::REGISTRY_DELETE_KEY_RECURSIVE,
520
+ hook_delete_key_recursive as *const (),
521
+ )?;
522
+
523
+ let _ = crate::hook::install(
524
+ "Registry__CleanAll",
525
+ va::REGISTRY_CLEAN_ALL,
526
+ hook_registry_clean_all as *const (),
527
+ )?;
528
+ ```
529
+
530
+ **Step 3: Build to verify**
531
+
532
+ Run: `cargo build --release -p openwa-wormkit`
533
+ Expected: clean build
534
+
535
+ **Step 4: Commit**
536
+
537
+ ```
538
+ feat: add Registry cleanup hooks (DeleteKeyRecursive, CleanAll)
539
+ ```
540
+
541
+ ---
542
+
543
+ ### Task 6: Add GameInfo__LoadOptions hook
544
+
545
+ **Files:**
546
+ - Modify: `crates/openwa-wormkit/src/replacements/config.rs`
547
+ - Modify: `crates/openwa-types/src/address.rs` (global data addresses)
548
+
549
+ **Step 1: Add global data address constants**
550
+
551
+ Add to `address.rs` in the `// === Global variables (in .data) ===` section:
552
+
553
+ ```rust
554
+ /// Base directory string (null-terminated)
555
+ pub const G_BASE_DIR: u32 = 0x0088_E282;
556
+ /// 64-byte data block copied into GameInfo+0xF485
557
+ pub const G_GAMEINFO_BLOCK_F485: u32 = 0x0088_DFF3;
558
+ /// "data\land.dat" string constant (14 bytes)
559
+ pub const G_LAND_DAT_STRING: u32 = 0x0064_DA58;
560
+ /// Unknown byte read into GameInfo+0xF3A0
561
+ pub const G_CONFIG_BYTE_F3A0: u32 = 0x007C_0D38;
562
+ /// 5 DWORDs: GameInfo offsets +0xF3B4..+0xF3D0
563
+ pub const G_CONFIG_DWORDS_F3B4: u32 = 0x0088_E39C;
564
+ /// Guard flag for conditional config copies
565
+ pub const G_CONFIG_GUARD: u32 = 0x0088_C374;
566
+ /// 4 DWORDs (conditional): GameInfo offsets +0xF3F4..+0xF400
567
+ pub const G_CONFIG_DWORDS_F3F4: u32 = 0x0088_E3B8;
568
+ /// DWORD → GameInfo+0xDAE8
569
+ pub const G_CONFIG_DWORD_DAE8: u32 = 0x0088_E390;
570
+ /// 2 DWORDs → GameInfo+0xF3D4, +0xF3D8
571
+ pub const G_CONFIG_DWORDS_F3D4: u32 = 0x0088_E3B0;
572
+ /// 3 DWORDs → GameInfo+0xF3C4..+0xF3CC
573
+ pub const G_CONFIG_DWORDS_F3C4: u32 = 0x0088_E400;
574
+ /// DWORD → GameInfo+0xF3E4
575
+ pub const G_CONFIG_DWORD_F3E4: u32 = 0x0088_E44C;
576
+ /// Streams directory path buffer
577
+ pub const G_STREAMS_DIR: u32 = 0x0088_AE18;
578
+ /// Random stream indices (16 entries)
579
+ pub const G_STREAM_INDICES: u32 = 0x0088_AE9C;
580
+ /// Stream index end sentinel
581
+ pub const G_STREAM_INDICES_END: u32 = 0x0088_AEDC;
582
+ /// DAT_0088E394 flag for stream volume
583
+ pub const G_STREAM_FLAG: u32 = 0x0088_E394;
584
+ /// Stream volume byte
585
+ pub const G_STREAM_VOLUME: u32 = 0x0088_AEDD;
586
+ ```
587
+
588
+ **Step 2: Add LoadOptions hook function**
589
+
590
+ Add to config.rs before `install()`:
591
+
592
+ ```rust
593
+ // ============================================================
594
+ // GameInfo__LoadOptions replacement (0x460AC0)
595
+ // ============================================================
596
+
597
+ /// Rust replacement for GameInfo__LoadOptions.
598
+ /// stdcall(game_info: u32)
599
+ ///
600
+ /// Reads game options from the Windows registry and copies various globals
601
+ /// into the GameInfo struct at known offsets.
602
+ unsafe extern "stdcall" fn hook_load_options(gi: u32) {
603
+ use openwa_lib::wa::registry::read_profile_int;
604
+
605
+ let _ = log_line("[Config] LoadOptions: loading game options from registry");
606
+
607
+ // Format speech path: "%s\user\speech"
608
+ let base_dir = rb(va::G_BASE_DIR) as *const u8;
609
+ let speech_dest = (gi + 0xF404) as *mut u8;
610
+ let base_str = std::ffi::CStr::from_ptr(base_dir as *const i8);
611
+ let speech_path = format!("{}\\user\\speech\0", base_str.to_string_lossy());
612
+ core::ptr::copy_nonoverlapping(
613
+ speech_path.as_ptr(),
614
+ speech_dest,
615
+ speech_path.len(),
616
+ );
617
+
618
+ // Copy 64 bytes from global 0x88DFF3 → GameInfo+0xF485
619
+ core::ptr::copy_nonoverlapping(
620
+ rb(va::G_GAMEINFO_BLOCK_F485) as *const u8,
621
+ (gi + 0xF485) as *mut u8,
622
+ 64,
623
+ );
624
+
625
+ // Format streams directory and randomize stream indices
626
+ let streams_dest = rb(va::G_STREAMS_DIR) as *mut u8;
627
+ let streams_path = format!("{}\\streams\0", base_str.to_string_lossy());
628
+ core::ptr::copy_nonoverlapping(
629
+ streams_path.as_ptr(),
630
+ streams_dest,
631
+ streams_path.len(),
632
+ );
633
+
634
+ // Randomize stream indices (16 entries, each rand() % 11 + 1)
635
+ let indices = rb(va::G_STREAM_INDICES) as *mut u32;
636
+ let indices_end = rb(va::G_STREAM_INDICES_END) as usize;
637
+ let mut ptr = indices;
638
+ while (ptr as usize) < indices_end {
639
+ extern "cdecl" { fn rand() -> i32; }
640
+ *ptr = (rand() % 11 + 1) as u32;
641
+ ptr = ptr.add(1);
642
+ }
643
+
644
+ // Stream volume: 0x10 if flag set, else 0
645
+ let stream_vol_addr = rb(va::G_STREAM_INDICES_END) as *mut u8;
646
+ *stream_vol_addr = if *(rb(va::G_STREAM_FLAG) as *const u32) != 0 { 0x10 } else { 0 };
647
+ // Secondary volume byte
648
+ *(rb(va::G_STREAM_VOLUME) as *mut u8) = 0x4B;
649
+
650
+ // Copy "data\land.dat" string (14 bytes) → GameInfo+0xDAEC
651
+ core::ptr::copy_nonoverlapping(
652
+ rb(va::G_LAND_DAT_STRING) as *const u8,
653
+ (gi + 0xDAEC) as *mut u8,
654
+ 14,
655
+ );
656
+
657
+ // Copy byte from global → GameInfo+0xF3A0
658
+ *((gi + 0xF3A0) as *mut u8) = *(rb(va::G_CONFIG_BYTE_F3A0) as *const u8);
659
+
660
+ // Read registry values from "Options" section
661
+ let detail = read_profile_int("Options", "DetailLevel", 5);
662
+ *((gi + 0xF3A1) as *mut u8) = detail as u8;
663
+
664
+ // Zero 2 bytes at +0xF3F0
665
+ *((gi + 0xF3F0) as *mut u16) = 0;
666
+
667
+ // Copy 5 DWORDs from globals → GameInfo+0xF3B4..+0xF3D0
668
+ let src = rb(va::G_CONFIG_DWORDS_F3B4) as *const u32;
669
+ for i in 0u32..5 {
670
+ let offset = 0xF3B4 + i * 4;
671
+ *((gi + offset) as *mut u32) = *src.add(i as usize);
672
+ }
673
+
674
+ // Conditional copy: 4 DWORDs if guard == 0
675
+ if *(rb(va::G_CONFIG_GUARD) as *const u32) == 0 {
676
+ let src = rb(va::G_CONFIG_DWORDS_F3F4) as *const u32;
677
+ for i in 0u32..4 {
678
+ let offset = 0xF3F4 + i * 4;
679
+ *((gi + offset) as *mut u32) = *src.add(i as usize);
680
+ }
681
+ }
682
+
683
+ // Single DWORDs from globals
684
+ *((gi + 0xDAE8) as *mut u32) = *(rb(va::G_CONFIG_DWORD_DAE8) as *const u32);
685
+
686
+ let src_d4 = rb(va::G_CONFIG_DWORDS_F3D4) as *const u32;
687
+ *((gi + 0xF3D4) as *mut u32) = *src_d4;
688
+ *((gi + 0xF3D8) as *mut u32) = *src_d4.add(1);
689
+
690
+ // EnergyBar
691
+ let energy = read_profile_int("Options", "EnergyBar", 1);
692
+ *((gi + 0xF3A2) as *mut u8) = energy as u8;
693
+
694
+ // 3 DWORDs from globals → +0xF3C4..+0xF3CC
695
+ let src_c4 = rb(va::G_CONFIG_DWORDS_F3C4) as *const u32;
696
+ for i in 0u32..3 {
697
+ let offset = 0xF3C4 + i * 4;
698
+ *((gi + offset) as *mut u32) = *src_c4.add(i as usize);
699
+ }
700
+
701
+ // Remaining registry values
702
+ let info_trans = read_profile_int("Options", "InfoTransparency", 0);
703
+ *((gi + 0xF3A3) as *mut u8) = info_trans as u8;
704
+
705
+ let info_spy = read_profile_int("Options", "InfoSpy", 1);
706
+ *((gi + 0xF3A4) as *mut u8) = if info_spy != 0 { 1 } else { 0 };
707
+
708
+ let chat_pinned = read_profile_int("Options", "ChatPinned", 0);
709
+ *((gi + 0xF3A5) as *mut u8) = chat_pinned as u8;
710
+
711
+ let chat_lines = read_profile_int("Options", "ChatLines", 0);
712
+ *((gi + 0xF3A8) as *mut u32) = chat_lines;
713
+
714
+ let pinned_lines = read_profile_int("Options", "PinnedChatLines", 0xFFFFFFFF);
715
+ *((gi + 0xF3AC) as *mut u32) = pinned_lines;
716
+
717
+ let home_lock = read_profile_int("Options", "HomeLock", 0);
718
+ *((gi + 0xF3B0) as *mut u8) = home_lock as u8;
719
+
720
+ // BackgroundDebrisParallax: clamp to i16 range, then << 16
721
+ let mut parallax = read_profile_int("Options", "BackgroundDebrisParallax", 0x50);
722
+ let parallax_i32 = parallax as i32;
723
+ if parallax_i32 < -0x8000 || parallax_i32 > 0x7FFF {
724
+ // Clamp to i16 range
725
+ if parallax_i32 < 0 {
726
+ parallax = (-0x8000i32) as u32;
727
+ } else {
728
+ parallax = 0x7FFF;
729
+ }
730
+ }
731
+ *((gi + 0xF3E8) as *mut u32) = parallax << 16;
732
+
733
+ let onomatopoeia = read_profile_int("Options", "TopmostExplosionOnomatopoeia", 0);
734
+ *((gi + 0xF3EC) as *mut u32) = onomatopoeia;
735
+
736
+ let capture_png = read_profile_int("Options", "CaptureTransparentPNGs", 0);
737
+ *((gi + 0xF3DC) as *mut u32) = capture_png;
738
+
739
+ // CameraUnlockMouseSpeed: clamp to max 0xB504, then square
740
+ let mut mouse_speed = read_profile_int("Options", "CameraUnlockMouseSpeed", 0x10);
741
+ if mouse_speed > 0xB504 {
742
+ if (mouse_speed as i32) < 0 {
743
+ mouse_speed = 0; // negative → 0
744
+ } else {
745
+ mouse_speed = 0xB504;
746
+ }
747
+ }
748
+ *((gi + 0xF3E0) as *mut u32) = mouse_speed * mouse_speed;
749
+
750
+ // Final global DWORD
751
+ *((gi + 0xF3E4) as *mut u32) = *(rb(va::G_CONFIG_DWORD_F3E4) as *const u32);
752
+
753
+ let _ = log_line("[Config] LoadOptions completed (Rust)");
754
+ }
755
+ ```
756
+
757
+ **Step 3: Wire up in install()**
758
+
759
+ Add to the unsafe block in `install()`:
760
+
761
+ ```rust
762
+ let _ = crate::hook::install(
763
+ "GameInfo__LoadOptions",
764
+ va::GAMEINFO_LOAD_OPTIONS,
765
+ hook_load_options as *const (),
766
+ )?;
767
+ ```
768
+
769
+ **Step 4: Build to verify**
770
+
771
+ Run: `cargo build --release -p openwa-wormkit`
772
+ Expected: clean build
773
+
774
+ **Step 5: Deploy + test options**
775
+
776
+ Launch game, change Detail Level in options, restart, verify it persists. Check OpenWA.log.
777
+
778
+ **Step 6: Commit**
779
+
780
+ ```
781
+ feat: add GameInfo__LoadOptions hook (registry reads)
782
+ ```
783
+
784
+ ---
785
+
786
+ ### Task 7: Add Options__GetCrashReportURL hook
787
+
788
+ **Files:**
789
+ - Modify: `crates/openwa-wormkit/src/replacements/config.rs`
790
+ - Modify: `crates/openwa-types/src/address.rs`
791
+
792
+ **Step 1: Add global address for the URL buffer**
793
+
794
+ Add to address.rs globals section:
795
+
796
+ ```rust
797
+ /// CrashReportURL static buffer (0x400 bytes)
798
+ pub const G_CRASH_REPORT_URL: u32 = 0x0079_FFD8;
799
+ ```
800
+
801
+ **Step 2: Add hook function**
802
+
803
+ Add to config.rs before `install()`:
804
+
805
+ ```rust
806
+ // ============================================================
807
+ // Options__GetCrashReportURL replacement (0x5A63F0)
808
+ // ============================================================
809
+
810
+ /// Rust replacement for Options__GetCrashReportURL.
811
+ /// cdecl() -> *const u8 (pointer to static buffer, or null)
812
+ unsafe extern "cdecl" fn hook_get_crash_report_url() -> u32 {
813
+ let buf = rb(va::G_CRASH_REPORT_URL) as *mut u8;
814
+ let mut buf_slice = core::slice::from_raw_parts_mut(buf, 0x401);
815
+
816
+ let len = openwa_lib::wa::registry::read_profile_string(
817
+ "Options",
818
+ "CrashReportURL",
819
+ &mut buf_slice[..0x400],
820
+ );
821
+
822
+ if len > 0 {
823
+ // Null-terminate
824
+ *buf.add(len) = 0;
825
+ buf as u32
826
+ } else {
827
+ 0 // null pointer = not found
828
+ }
829
+ }
830
+ ```
831
+
832
+ **Step 3: Wire up in install()**
833
+
834
+ ```rust
835
+ let _ = crate::hook::install(
836
+ "Options__GetCrashReportURL",
837
+ va::OPTIONS_GET_CRASH_REPORT_URL,
838
+ hook_get_crash_report_url as *const (),
839
+ )?;
840
+ ```
841
+
842
+ **Step 4: Build to verify**
843
+
844
+ Run: `cargo build --release -p openwa-wormkit`
845
+ Expected: clean build
846
+
847
+ **Step 5: Commit**
848
+
849
+ ```
850
+ feat: add Options__GetCrashReportURL hook
851
+ ```
852
+
853
+ ---
854
+
855
+ ### Task 8: Full integration test + final commit
856
+
857
+ **Step 1: Build clean**
858
+
859
+ Run: `cargo build --release -p openwa-wormkit`
860
+ Expected: clean build, no warnings
861
+
862
+ **Step 2: Deploy**
863
+
864
+ ```bash
865
+ cp target/i686-pc-windows-msvc/release/openwa_wormkit.dll "I:/games/SteamLibrary/steamapps/common/Worms Armageddon/wkOpenWA.dll"
866
+ ```
867
+
868
+ **Step 3: In-game verification**
869
+
870
+ 1. Launch game → main menu
871
+ 2. Check OpenWA.log for all 7 `[REPLACE]` messages + `[Config] LoadOptions completed`
872
+ 3. Verify theme loads correctly (visual appearance unchanged)
873
+ 4. Go to Options, change Detail Level, accept
874
+ 5. Restart game, verify Detail Level persists
875
+ 6. Verify existing scheme functionality still works (quick game setup)
876
+
877
+ **Step 4: Squash/rebase if desired, or leave incremental commits**