paavohtl Claude Opus 4.6 commited on
Commit
2b4011e
·
1 Parent(s): e3a0527

feat: add typed scheme payload structs + field accessors

Browse files

Add SchemeOptions (36B), WeaponSettings (4B), and ExtendedOptions (110B)
structs with from_bytes/to_bytes for safe typed access to .wsc payload
fields. Include V3 ROM defaults, scheme weapon order table, and 8 new
tests covering typed field access, roundtrips, and version boundaries.

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

crates/openwa-types/src/address.rs CHANGED
@@ -233,6 +233,21 @@ pub mod va {
233
  pub const SCHEME_DETECT_VERSION: u32 = 0x004D_4480;
234
  /// Extracts built-in schemes from PE resources to User\Schemes\ directory
235
  pub const SCHEME_EXTRACT_BUILTINS: u32 = 0x004D_5720;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  // === Lobby / network ===
238
 
 
233
  pub const SCHEME_DETECT_VERSION: u32 = 0x004D_4480;
234
  /// Extracts built-in schemes from PE resources to User\Schemes\ directory
235
  pub const SCHEME_EXTRACT_BUILTINS: u32 = 0x004D_5720;
236
+ /// Copies payload data + V3 defaults into scheme struct: fastcall(?, data, dest, name)
237
+ pub const SCHEME_INIT_FROM_DATA: u32 = 0x004D_5020;
238
+ /// Validates weapon ammo counts against max table (39 weapons): returns 0=ok, 1=over limit
239
+ pub const SCHEME_CHECK_WEAPON_LIMITS: u32 = 0x004D_50E0;
240
+ /// Validates V3 extended options field ranges: returns 0=valid, 1=invalid
241
+ pub const SCHEME_VALIDATE_EXTENDED_OPTIONS: u32 = 0x004D_5110;
242
+ /// Scans User\Schemes\ for {NN} name.wsc files, marks found indices in global array
243
+ pub const SCHEME_SCAN_DIRECTORY: u32 = 0x004D_54E0;
244
+
245
+ // === Scheme data (in .rdata/.data) ===
246
+
247
+ /// V3 extended options defaults (110 bytes), applied to V1/V2 schemes
248
+ pub const SCHEME_V3_DEFAULTS: u32 = 0x0064_9AB8;
249
+ /// Per-weapon max ammo table (39 bytes), used by CheckWeaponLimits
250
+ pub const SCHEME_WEAPON_AMMO_LIMITS: u32 = 0x006A_D130;
251
 
252
  // === Lobby / network ===
253
 
crates/openwa-types/src/scheme.rs CHANGED
@@ -9,12 +9,12 @@
9
  /// Bytes 5+: Payload (size depends on version)
10
  ///
11
  /// Version → payload size:
12
- /// 0x01 → 0xD8 bytes (216)
13
- /// 0x02 → 0x124 bytes (292)
14
- /// other → version_byte as usize + 0x124 bytes
15
  ///
16
- /// Source: Ghidra decompilation of Scheme__Load (0x4D4CD0),
17
- /// Scheme__ParseFile (0x4D44F0), hex analysis of bundled .wsc files.
18
 
19
  /// Magic header bytes for .wsc files.
20
  pub const SCHEME_MAGIC: [u8; 4] = *b"SCHM";
@@ -164,6 +164,715 @@ impl SchemeFile {
164
  }
165
  }
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  /// Error type for file-based scheme operations.
168
  #[derive(Debug)]
169
  pub enum SchemeFileError {
 
9
  /// Bytes 5+: Payload (size depends on version)
10
  ///
11
  /// Version → payload size:
12
+ /// 0x01 → 0xD8 bytes (216): 36 options + 45 weapons × 4
13
+ /// 0x02 → 0x124 bytes (292): V1 + 19 super weapons × 4
14
+ /// 0x03 → 0x192 bytes (402): V2 + 110 bytes extended options
15
  ///
16
+ /// Source: Ghidra decompilation of Scheme__ReadFile (0x4D3890),
17
+ /// Scheme__SaveFile (0x4D44F0), worms2d.info/Game_scheme_file
18
 
19
  /// Magic header bytes for .wsc files.
20
  pub const SCHEME_MAGIC: [u8; 4] = *b"SCHM";
 
164
  }
165
  }
166
 
167
+ // === Payload layout constants ===
168
+
169
+ /// Byte offset within the payload where game options start.
170
+ pub const OPTIONS_OFFSET: usize = 0;
171
+ /// Size of the game options section in the payload.
172
+ pub const OPTIONS_SIZE: usize = 36;
173
+ /// Byte offset within the payload where V1 weapon settings start.
174
+ pub const WEAPONS_V1_OFFSET: usize = OPTIONS_SIZE; // 36
175
+ /// Number of weapons in V1 schemes.
176
+ pub const WEAPONS_V1_COUNT: usize = 45;
177
+ /// Number of super weapons added in V2.
178
+ pub const WEAPONS_V2_COUNT: usize = 19;
179
+ /// Total weapons in V2+ schemes.
180
+ pub const WEAPONS_TOTAL_COUNT: usize = WEAPONS_V1_COUNT + WEAPONS_V2_COUNT; // 64
181
+ /// Bytes per weapon entry.
182
+ pub const WEAPON_ENTRY_SIZE: usize = 4;
183
+ /// Byte offset where V2 super weapons start.
184
+ pub const WEAPONS_V2_OFFSET: usize = WEAPONS_V1_OFFSET + WEAPONS_V1_COUNT * WEAPON_ENTRY_SIZE; // 216 = 0xD8
185
+ /// Byte offset where V3 extended options start.
186
+ pub const EXTENDED_OPTIONS_OFFSET: usize = WEAPONS_V2_OFFSET + WEAPONS_V2_COUNT * WEAPON_ENTRY_SIZE; // 292 = 0x124
187
+ /// Size of the V3 extended options section.
188
+ pub const EXTENDED_OPTIONS_SIZE: usize = 110; // 0x6E
189
+
190
+ /// Per-weapon settings (4 bytes each in the .wsc file).
191
+ ///
192
+ /// Weapon order in the file differs from the runtime `Weapon` enum —
193
+ /// the .wsc uses the "scheme order" defined by the original game UI.
194
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
195
+ pub struct WeaponSettings {
196
+ /// Ammo count. 0 = none, 1-10 = count, 10/0x80+ = infinite.
197
+ pub ammo: u8,
198
+ /// Power level. 0-20 (max varies by weapon).
199
+ pub power: u8,
200
+ /// Turn delay before weapon becomes available. 0 = immediate.
201
+ pub delay: u8,
202
+ /// Crate probability. 0-100 percentage.
203
+ pub crate_probability: u8,
204
+ }
205
+
206
+ impl WeaponSettings {
207
+ pub fn from_bytes(b: &[u8]) -> Self {
208
+ Self {
209
+ ammo: b[0],
210
+ power: b[1],
211
+ delay: b[2],
212
+ crate_probability: b[3],
213
+ }
214
+ }
215
+
216
+ pub fn to_bytes(self) -> [u8; 4] {
217
+ [self.ammo, self.power, self.delay, self.crate_probability]
218
+ }
219
+ }
220
+
221
+ /// Game options (first 36 bytes of payload).
222
+ ///
223
+ /// These are the core game settings displayed in the scheme editor.
224
+ /// All offsets are relative to the start of the payload (file offset 0x05).
225
+ ///
226
+ /// Source: worms2d.info/Game_scheme_file, Ghidra analysis of Scheme__ReadFile.
227
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
228
+ pub struct SchemeOptions {
229
+ /// Seconds between turns (hot-seat delay). Payload +0x00, file 0x05.
230
+ pub hot_seat_delay: u8,
231
+ /// Seconds to retreat after weapon use (grounded). Payload +0x01, file 0x06.
232
+ pub retreat_time: u8,
233
+ /// Seconds to retreat after weapon use (on rope). Payload +0x02, file 0x07.
234
+ pub rope_retreat_time: u8,
235
+ /// Display total round time on screen. Payload +0x03, file 0x08.
236
+ pub display_total_round_time: bool,
237
+ /// Enable automatic replays. Payload +0x04, file 0x09.
238
+ pub automatic_replays: bool,
239
+ /// Fall damage amount at critical velocity. Payload +0x05, file 0x0A.
240
+ pub fall_damage: u8,
241
+ /// Artillery mode (worms can't move). Payload +0x06, file 0x0B.
242
+ pub artillery_mode: bool,
243
+ /// Bounty mode marker. 0x00=unset, 0x5F/0x89=editor magic. Payload +0x07, file 0x0C.
244
+ pub bounty_mode: u8,
245
+ /// Stockpiling mode: 0=Off, 1=On, 2=Anti. Payload +0x08, file 0x0D.
246
+ pub stockpiling: u8,
247
+ /// Worm selection: 0=Sequential, 1=Manual, 2=Random. Payload +0x09, file 0x0E.
248
+ pub worm_select: u8,
249
+ /// Sudden death event: 0=RoundEnds, 1=NuclearStrike, 2=HP→1, 3=Nothing. Payload +0x0A, file 0x0F.
250
+ pub sudden_death_event: u8,
251
+ /// Water rise rate during sudden death. Payload +0x0B, file 0x10.
252
+ pub water_rise_rate: u8,
253
+ /// Weapon crate probability (-100 to 100, signed). Payload +0x0C, file 0x11.
254
+ pub weapon_crate_probability: i8,
255
+ /// Donor cards enabled. Payload +0x0D, file 0x12.
256
+ pub donor_cards: bool,
257
+ /// Health crate probability (-100 to 100, signed). Payload +0x0E, file 0x13.
258
+ pub health_crate_probability: i8,
259
+ /// Energy gained from health crates. Payload +0x0F, file 0x14.
260
+ pub health_crate_energy: u8,
261
+ /// Utility crate probability (-100 to 100, signed). Payload +0x10, file 0x15.
262
+ pub utility_crate_probability: i8,
263
+ /// Hazardous object type bitmask. Payload +0x11, file 0x16.
264
+ pub hazardous_object_types: u8,
265
+ /// Mine fuse delay in seconds. 0x80+ = random. Payload +0x12, file 0x17.
266
+ pub mine_delay: i8,
267
+ /// Dud mines (some mines don't explode). Payload +0x13, file 0x18.
268
+ pub dud_mines: bool,
269
+ /// Manual worm placement at start. Payload +0x14, file 0x19.
270
+ pub manual_worm_placement: bool,
271
+ /// Initial worm energy. 0=instant death. Payload +0x15, file 0x1A.
272
+ pub worm_energy: u8,
273
+ /// Turn time in seconds. Encoding varies by range. Payload +0x16, file 0x1B.
274
+ pub turn_time: u8,
275
+ /// Round time in minutes. 0=immediate SD. Payload +0x17, file 0x1C.
276
+ pub round_time: u8,
277
+ /// Number of round wins required to win match. Payload +0x18, file 0x1D.
278
+ pub number_of_wins: u8,
279
+ /// Blood color: false=pink, true=red. Payload +0x19, file 0x1E.
280
+ pub blood: bool,
281
+ /// Aqua Sheep mode. Payload +0x1A, file 0x1F.
282
+ pub aqua_sheep: bool,
283
+ /// Sheep Heaven mode. Payload +0x1B, file 0x20.
284
+ pub sheep_heaven: bool,
285
+ /// God Worms (infinite health). Payload +0x1C, file 0x21.
286
+ pub god_worms: bool,
287
+ /// Indestructible terrain. Payload +0x1D, file 0x22.
288
+ pub indestructible_land: bool,
289
+ /// Upgraded grenades. Payload +0x1E, file 0x23.
290
+ pub upgraded_grenade: bool,
291
+ /// Upgraded shotgun. Payload +0x1F, file 0x24.
292
+ pub upgraded_shotgun: bool,
293
+ /// Upgraded cluster bombs. Payload +0x20, file 0x25.
294
+ pub upgraded_clusters: bool,
295
+ /// Upgraded longbow. Payload +0x21, file 0x26.
296
+ pub upgraded_longbow: bool,
297
+ /// Team weapons enabled. Payload +0x22, file 0x27.
298
+ pub team_weapons: bool,
299
+ /// Super weapons enabled. Payload +0x23, file 0x28.
300
+ pub super_weapons: bool,
301
+ }
302
+
303
+ impl SchemeOptions {
304
+ /// Read options from the first 36 bytes of a payload slice.
305
+ pub fn from_bytes(b: &[u8]) -> Self {
306
+ Self {
307
+ hot_seat_delay: b[0],
308
+ retreat_time: b[1],
309
+ rope_retreat_time: b[2],
310
+ display_total_round_time: b[3] != 0,
311
+ automatic_replays: b[4] != 0,
312
+ fall_damage: b[5],
313
+ artillery_mode: b[6] != 0,
314
+ bounty_mode: b[7],
315
+ stockpiling: b[8],
316
+ worm_select: b[9],
317
+ sudden_death_event: b[10],
318
+ water_rise_rate: b[11],
319
+ weapon_crate_probability: b[12] as i8,
320
+ donor_cards: b[13] != 0,
321
+ health_crate_probability: b[14] as i8,
322
+ health_crate_energy: b[15],
323
+ utility_crate_probability: b[16] as i8,
324
+ hazardous_object_types: b[17],
325
+ mine_delay: b[18] as i8,
326
+ dud_mines: b[19] != 0,
327
+ manual_worm_placement: b[20] != 0,
328
+ worm_energy: b[21],
329
+ turn_time: b[22],
330
+ round_time: b[23],
331
+ number_of_wins: b[24],
332
+ blood: b[25] != 0,
333
+ aqua_sheep: b[26] != 0,
334
+ sheep_heaven: b[27] != 0,
335
+ god_worms: b[28] != 0,
336
+ indestructible_land: b[29] != 0,
337
+ upgraded_grenade: b[30] != 0,
338
+ upgraded_shotgun: b[31] != 0,
339
+ upgraded_clusters: b[32] != 0,
340
+ upgraded_longbow: b[33] != 0,
341
+ team_weapons: b[34] != 0,
342
+ super_weapons: b[35] != 0,
343
+ }
344
+ }
345
+
346
+ /// Serialize options back to 36 bytes.
347
+ pub fn to_bytes(&self) -> [u8; OPTIONS_SIZE] {
348
+ [
349
+ self.hot_seat_delay,
350
+ self.retreat_time,
351
+ self.rope_retreat_time,
352
+ self.display_total_round_time as u8,
353
+ self.automatic_replays as u8,
354
+ self.fall_damage,
355
+ self.artillery_mode as u8,
356
+ self.bounty_mode,
357
+ self.stockpiling,
358
+ self.worm_select,
359
+ self.sudden_death_event,
360
+ self.water_rise_rate,
361
+ self.weapon_crate_probability as u8,
362
+ self.donor_cards as u8,
363
+ self.health_crate_probability as u8,
364
+ self.health_crate_energy,
365
+ self.utility_crate_probability as u8,
366
+ self.hazardous_object_types,
367
+ self.mine_delay as u8,
368
+ self.dud_mines as u8,
369
+ self.manual_worm_placement as u8,
370
+ self.worm_energy,
371
+ self.turn_time,
372
+ self.round_time,
373
+ self.number_of_wins,
374
+ self.blood as u8,
375
+ self.aqua_sheep as u8,
376
+ self.sheep_heaven as u8,
377
+ self.god_worms as u8,
378
+ self.indestructible_land as u8,
379
+ self.upgraded_grenade as u8,
380
+ self.upgraded_shotgun as u8,
381
+ self.upgraded_clusters as u8,
382
+ self.upgraded_longbow as u8,
383
+ self.team_weapons as u8,
384
+ self.super_weapons as u8,
385
+ ]
386
+ }
387
+ }
388
+
389
+ /// Weapon order in .wsc scheme files (indices 0-63).
390
+ ///
391
+ /// This order differs from the runtime `Weapon` enum. The scheme file uses
392
+ /// the UI panel order established by the original game.
393
+ ///
394
+ /// Source: worms2d.info/Game_scheme_file
395
+ pub const SCHEME_WEAPON_ORDER: [&str; 64] = [
396
+ // V1 weapons (0-44)
397
+ "Bazooka", // 0
398
+ "Homing Missile", // 1
399
+ "Mortar", // 2
400
+ "Grenade", // 3
401
+ "Cluster Bomb", // 4
402
+ "Skunk", // 5
403
+ "Petrol Bomb", // 6
404
+ "Banana Bomb", // 7
405
+ "Handgun", // 8
406
+ "Shotgun", // 9
407
+ "Uzi", // 10
408
+ "Minigun", // 11
409
+ "Longbow", // 12
410
+ "Airstrike", // 13
411
+ "Napalm Strike", // 14
412
+ "Mine", // 15
413
+ "Fire Punch", // 16
414
+ "Dragon Ball", // 17
415
+ "Kamikaze", // 18
416
+ "Prod", // 19
417
+ "Battle Axe", // 20
418
+ "Blowtorch", // 21
419
+ "Pneumatic Drill", // 22
420
+ "Girder", // 23
421
+ "Ninja Rope", // 24
422
+ "Parachute", // 25
423
+ "Bungee", // 26
424
+ "Teleport", // 27
425
+ "Dynamite", // 28
426
+ "Sheep", // 29
427
+ "Baseball Bat", // 30
428
+ "Flame Thrower", // 31
429
+ "Homing Pigeon", // 32
430
+ "Mad Cow", // 33
431
+ "Holy Hand Grenade", // 34
432
+ "Old Woman", // 35
433
+ "Sheep Launcher", // 36
434
+ "Super Sheep", // 37
435
+ "Mole Bomb", // 38
436
+ "Jet Pack", // 39
437
+ "Low Gravity", // 40
438
+ "Laser Sight", // 41
439
+ "Fast Walk", // 42
440
+ "Invisibility", // 43
441
+ "Damage x2", // 44
442
+ // V2 super weapons (45-63)
443
+ "Freeze", // 45
444
+ "Super Banana Bomb", // 46
445
+ "Mine Strike", // 47
446
+ "Girder Starter Pack", // 48
447
+ "Earthquake", // 49
448
+ "Scales of Justice", // 50
449
+ "Ming Vase", // 51
450
+ "Mike's Carpet Bomb", // 52
451
+ "Patsy's Magic Bullet", // 53
452
+ "Indian Nuclear Test", // 54
453
+ "Select Worm", // 55
454
+ "Salvation Army", // 56
455
+ "Mole Squadron", // 57
456
+ "MB Bomb", // 58
457
+ "Concrete Donkey", // 59
458
+ "Suicide Bomber", // 60
459
+ "Sheep Strike", // 61
460
+ "Mail Strike", // 62
461
+ "Armageddon", // 63
462
+ ];
463
+
464
+ /// V3 extended options (110 bytes, payload offset 0x124).
465
+ ///
466
+ /// Present in V3 schemes; for V1/V2, the game fills this region from
467
+ /// ROM defaults at 0x649AB8.
468
+ ///
469
+ /// Many fields use fixed-point 16.16 format (`Fixed`), tri-state (0/1/0x80),
470
+ /// or small enums. See field docs for valid ranges.
471
+ ///
472
+ /// Source: worms2d.info/Game_scheme_file, Ghidra FUN_004d5110 validation.
473
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
474
+ pub struct ExtendedOptions {
475
+ /// Data version (currently 0). Offset +0x00.
476
+ pub data_version: u32,
477
+ /// Constant wind enabled. Offset +0x04.
478
+ pub constant_wind: bool,
479
+ /// Wind strength (signed). Offset +0x05.
480
+ pub wind: i16,
481
+ /// Wind bias. Offset +0x07.
482
+ pub wind_bias: u8,
483
+ /// Gravity (fixed 16.16). Offset +0x08. Default 0xF5C2.
484
+ pub gravity: i32,
485
+ /// Terrain friction (fixed 16.16). Offset +0x0C. Default 0xF5C2.
486
+ pub terrain_friction: i32,
487
+ /// Rope knocking. 0xFF = use default. Offset +0x10.
488
+ pub rope_knocking: u8,
489
+ /// Blood level. 0xFF = use default. Offset +0x11.
490
+ pub blood_level: u8,
491
+ /// Unrestrict rope. Offset +0x12.
492
+ pub unrestrict_rope: bool,
493
+ /// Auto-place worms by ally. Offset +0x13.
494
+ pub auto_place_worms_by_ally: bool,
495
+ /// No-crate probability. 0xFF = ignore. Offset +0x14.
496
+ pub no_crate_probability: u8,
497
+ /// Maximum crate count. Offset +0x15.
498
+ pub max_crate_count: u16,
499
+ /// Sudden death disables worm select. Offset +0x17.
500
+ pub sd_disables_worm_select: bool,
501
+ /// Sudden death worm damage per turn. Offset +0x18.
502
+ pub sd_worm_damage_per_turn: u8,
503
+ /// Phased worms (allied). 0-3. Offset +0x19.
504
+ pub phased_worms_allied: u8,
505
+ /// Phased worms (enemy). 0-3. Offset +0x1A.
506
+ pub phased_worms_enemy: u8,
507
+ /// Circular aim. Offset +0x1B.
508
+ pub circular_aim: bool,
509
+ /// Anti-lock aim. Offset +0x1C.
510
+ pub anti_lock_aim: bool,
511
+ /// Anti-lock power. Offset +0x1D.
512
+ pub anti_lock_power: bool,
513
+ /// Worm selection doesn't end hot seat. Offset +0x1E.
514
+ pub worm_select_no_end_hot_seat: bool,
515
+ /// Worm selection is never cancelled. Offset +0x1F.
516
+ pub worm_select_never_cancelled: bool,
517
+ /// Batty rope. Offset +0x20.
518
+ pub batty_rope: bool,
519
+ /// Rope-roll drops. 0-2. Offset +0x21.
520
+ pub rope_roll_drops: u8,
521
+ /// X-impact loss of control. 0 or 0xFF. Offset +0x22.
522
+ pub x_impact_loss_of_control: u8,
523
+ /// Keep control after bumping head. Offset +0x23.
524
+ pub keep_control_bump_head: bool,
525
+ /// Keep control after skimming. 0-2. Offset +0x24.
526
+ pub keep_control_skimming: u8,
527
+ /// Fall damage triggered by explosions. Offset +0x25.
528
+ pub explosion_fall_damage: bool,
529
+ /// Explosions push all objects. Tri-state: 0/1/0x80. Offset +0x26.
530
+ pub explosions_push_all: u8,
531
+ /// Undetermined crates. Tri-state. Offset +0x27.
532
+ pub undetermined_crates: u8,
533
+ /// Undetermined fuses. Tri-state. Offset +0x28.
534
+ pub undetermined_fuses: u8,
535
+ /// Pause timer while firing. Offset +0x29.
536
+ pub pause_timer_while_firing: bool,
537
+ /// Loss of control doesn't end turn. Offset +0x2A.
538
+ pub loss_of_control_no_end_turn: bool,
539
+ /// Weapon use doesn't end turn. Offset +0x2B.
540
+ pub weapon_use_no_end_turn: bool,
541
+ /// Above option doesn't block any weapons. Offset +0x2C.
542
+ pub weapon_use_no_block: bool,
543
+ /// Pneumatic drill imparts velocity. Tri-state. Offset +0x2D.
544
+ pub drill_imparts_velocity: u8,
545
+ /// Girder radius assist. Offset +0x2E.
546
+ pub girder_radius_assist: bool,
547
+ /// Petrol turn decay (fixed 16.16 fractional part). Offset +0x2F.
548
+ pub petrol_turn_decay: u16,
549
+ /// Petrol touch decay. Offset +0x31.
550
+ pub petrol_touch_decay: u8,
551
+ /// Maximum flamelet count. Offset +0x32.
552
+ pub max_flamelet_count: u16,
553
+ /// Maximum projectile speed (fixed 16.16). Offset +0x34.
554
+ pub max_projectile_speed: i32,
555
+ /// Maximum rope speed (fixed 16.16). Offset +0x38.
556
+ pub max_rope_speed: i32,
557
+ /// Maximum jet pack speed (fixed 16.16). Offset +0x3C.
558
+ pub max_jet_pack_speed: i32,
559
+ /// Game engine speed (fixed 16.16). Offset +0x40.
560
+ pub game_engine_speed: i32,
561
+ /// Indian rope glitch. Tri-state. Offset +0x44.
562
+ pub indian_rope_glitch: u8,
563
+ /// Herd-doubling glitch. Tri-state. Offset +0x45.
564
+ pub herd_doubling_glitch: u8,
565
+ /// Jet pack bungee glitch. Offset +0x46.
566
+ pub jet_pack_bungee_glitch: bool,
567
+ /// Angle cheat glitch. Offset +0x47.
568
+ pub angle_cheat_glitch: bool,
569
+ /// Glide glitch. Offset +0x48.
570
+ pub glide_glitch: bool,
571
+ /// Skipwalking. 0/1/0xFF. Offset +0x49.
572
+ pub skipwalking: i8,
573
+ /// Block roofing. 0-2. Offset +0x4A.
574
+ pub block_roofing: u8,
575
+ /// Floating weapon glitch. Offset +0x4B.
576
+ pub floating_weapon_glitch: bool,
577
+ /// RubberWorm bounciness (fixed 16.16). Offset +0x4C.
578
+ pub rw_bounciness: i32,
579
+ /// RubberWorm air viscosity (fixed 16.16). Offset +0x50.
580
+ pub rw_air_viscosity: i32,
581
+ /// RW air viscosity applies to worms. Offset +0x54.
582
+ pub rw_air_viscosity_worms: bool,
583
+ /// RubberWorm wind influence (fixed 16.16). Offset +0x55.
584
+ pub rw_wind_influence: u32,
585
+ /// RW wind influence applies to worms. Offset +0x59.
586
+ pub rw_wind_influence_worms: bool,
587
+ /// RW gravity type. 0-3. Offset +0x5A.
588
+ pub rw_gravity_type: u8,
589
+ /// RW gravity strength (fixed 16.16). Offset +0x5B.
590
+ pub rw_gravity_strength: i32,
591
+ /// RW crate rate. Offset +0x5F.
592
+ pub rw_crate_rate: u8,
593
+ /// RW crate shower. Offset +0x60.
594
+ pub rw_crate_shower: bool,
595
+ /// RW anti-sink. Offset +0x61.
596
+ pub rw_anti_sink: bool,
597
+ /// RW remember weapons. Offset +0x62.
598
+ pub rw_remember_weapons: bool,
599
+ /// RW extended fuses/herds. Offset +0x63.
600
+ pub rw_extended_fuses: bool,
601
+ /// RW anti-lock aim. Offset +0x64.
602
+ pub rw_anti_lock_aim: bool,
603
+ /// Terrain overlap phasing glitch. Tri-state. Offset +0x65.
604
+ pub terrain_overlap_glitch: u8,
605
+ /// Fractional round timer. Offset +0x66.
606
+ pub fractional_round_timer: bool,
607
+ /// Automatic end-of-turn retreat. Offset +0x67.
608
+ pub auto_retreat: bool,
609
+ /// Health crates cure poison. 0/1/2/0xFF. Offset +0x68.
610
+ pub health_crates_cure_poison: i8,
611
+ /// RW Kaos mod. 0-5. Offset +0x69.
612
+ pub rw_kaos_mod: u8,
613
+ /// Sheep Heaven's Gate bitmask. Offset +0x6A.
614
+ pub sheep_heavens_gate: u8,
615
+ /// Conserve instant utilities. Offset +0x6B.
616
+ pub conserve_instant_utilities: bool,
617
+ /// Expedite instant utilities. Offset +0x6C.
618
+ pub expedite_instant_utilities: bool,
619
+ /// Double time stack limit. Offset +0x6D.
620
+ pub double_time_stack_limit: u8,
621
+ }
622
+
623
+ /// Helper: read a little-endian u16 from a byte slice.
624
+ fn read_u16_le(b: &[u8], offset: usize) -> u16 {
625
+ u16::from_le_bytes([b[offset], b[offset + 1]])
626
+ }
627
+
628
+ /// Helper: read a little-endian i32 from a byte slice.
629
+ fn read_i32_le(b: &[u8], offset: usize) -> i32 {
630
+ i32::from_le_bytes([b[offset], b[offset + 1], b[offset + 2], b[offset + 3]])
631
+ }
632
+
633
+ /// Helper: read a little-endian u32 from a byte slice.
634
+ fn read_u32_le(b: &[u8], offset: usize) -> u32 {
635
+ u32::from_le_bytes([b[offset], b[offset + 1], b[offset + 2], b[offset + 3]])
636
+ }
637
+
638
+ impl ExtendedOptions {
639
+ /// Read extended options from a 110-byte slice (payload offset 0x124).
640
+ pub fn from_bytes(b: &[u8]) -> Self {
641
+ Self {
642
+ data_version: read_u32_le(b, 0x00),
643
+ constant_wind: b[0x04] != 0,
644
+ wind: read_u16_le(b, 0x05) as i16,
645
+ wind_bias: b[0x07],
646
+ gravity: read_i32_le(b, 0x08),
647
+ terrain_friction: read_i32_le(b, 0x0C),
648
+ rope_knocking: b[0x10],
649
+ blood_level: b[0x11],
650
+ unrestrict_rope: b[0x12] != 0,
651
+ auto_place_worms_by_ally: b[0x13] != 0,
652
+ no_crate_probability: b[0x14],
653
+ max_crate_count: read_u16_le(b, 0x15),
654
+ sd_disables_worm_select: b[0x17] != 0,
655
+ sd_worm_damage_per_turn: b[0x18],
656
+ phased_worms_allied: b[0x19],
657
+ phased_worms_enemy: b[0x1A],
658
+ circular_aim: b[0x1B] != 0,
659
+ anti_lock_aim: b[0x1C] != 0,
660
+ anti_lock_power: b[0x1D] != 0,
661
+ worm_select_no_end_hot_seat: b[0x1E] != 0,
662
+ worm_select_never_cancelled: b[0x1F] != 0,
663
+ batty_rope: b[0x20] != 0,
664
+ rope_roll_drops: b[0x21],
665
+ x_impact_loss_of_control: b[0x22],
666
+ keep_control_bump_head: b[0x23] != 0,
667
+ keep_control_skimming: b[0x24],
668
+ explosion_fall_damage: b[0x25] != 0,
669
+ explosions_push_all: b[0x26],
670
+ undetermined_crates: b[0x27],
671
+ undetermined_fuses: b[0x28],
672
+ pause_timer_while_firing: b[0x29] != 0,
673
+ loss_of_control_no_end_turn: b[0x2A] != 0,
674
+ weapon_use_no_end_turn: b[0x2B] != 0,
675
+ weapon_use_no_block: b[0x2C] != 0,
676
+ drill_imparts_velocity: b[0x2D],
677
+ girder_radius_assist: b[0x2E] != 0,
678
+ petrol_turn_decay: read_u16_le(b, 0x2F),
679
+ petrol_touch_decay: b[0x31],
680
+ max_flamelet_count: read_u16_le(b, 0x32),
681
+ max_projectile_speed: read_i32_le(b, 0x34),
682
+ max_rope_speed: read_i32_le(b, 0x38),
683
+ max_jet_pack_speed: read_i32_le(b, 0x3C),
684
+ game_engine_speed: read_i32_le(b, 0x40),
685
+ indian_rope_glitch: b[0x44],
686
+ herd_doubling_glitch: b[0x45],
687
+ jet_pack_bungee_glitch: b[0x46] != 0,
688
+ angle_cheat_glitch: b[0x47] != 0,
689
+ glide_glitch: b[0x48] != 0,
690
+ skipwalking: b[0x49] as i8,
691
+ block_roofing: b[0x4A],
692
+ floating_weapon_glitch: b[0x4B] != 0,
693
+ rw_bounciness: read_i32_le(b, 0x4C),
694
+ rw_air_viscosity: read_i32_le(b, 0x50),
695
+ rw_air_viscosity_worms: b[0x54] != 0,
696
+ rw_wind_influence: read_u32_le(b, 0x55),
697
+ rw_wind_influence_worms: b[0x59] != 0,
698
+ rw_gravity_type: b[0x5A],
699
+ rw_gravity_strength: read_i32_le(b, 0x5B),
700
+ rw_crate_rate: b[0x5F],
701
+ rw_crate_shower: b[0x60] != 0,
702
+ rw_anti_sink: b[0x61] != 0,
703
+ rw_remember_weapons: b[0x62] != 0,
704
+ rw_extended_fuses: b[0x63] != 0,
705
+ rw_anti_lock_aim: b[0x64] != 0,
706
+ terrain_overlap_glitch: b[0x65],
707
+ fractional_round_timer: b[0x66] != 0,
708
+ auto_retreat: b[0x67] != 0,
709
+ health_crates_cure_poison: b[0x68] as i8,
710
+ rw_kaos_mod: b[0x69],
711
+ sheep_heavens_gate: b[0x6A],
712
+ conserve_instant_utilities: b[0x6B] != 0,
713
+ expedite_instant_utilities: b[0x6C] != 0,
714
+ double_time_stack_limit: b[0x6D],
715
+ }
716
+ }
717
+
718
+ /// Serialize extended options to 110 bytes.
719
+ pub fn to_bytes(&self) -> [u8; EXTENDED_OPTIONS_SIZE] {
720
+ let mut b = [0u8; EXTENDED_OPTIONS_SIZE];
721
+ b[0x00..0x04].copy_from_slice(&self.data_version.to_le_bytes());
722
+ b[0x04] = self.constant_wind as u8;
723
+ b[0x05..0x07].copy_from_slice(&self.wind.to_le_bytes());
724
+ b[0x07] = self.wind_bias;
725
+ b[0x08..0x0C].copy_from_slice(&self.gravity.to_le_bytes());
726
+ b[0x0C..0x10].copy_from_slice(&self.terrain_friction.to_le_bytes());
727
+ b[0x10] = self.rope_knocking;
728
+ b[0x11] = self.blood_level;
729
+ b[0x12] = self.unrestrict_rope as u8;
730
+ b[0x13] = self.auto_place_worms_by_ally as u8;
731
+ b[0x14] = self.no_crate_probability;
732
+ b[0x15..0x17].copy_from_slice(&self.max_crate_count.to_le_bytes());
733
+ b[0x17] = self.sd_disables_worm_select as u8;
734
+ b[0x18] = self.sd_worm_damage_per_turn;
735
+ b[0x19] = self.phased_worms_allied;
736
+ b[0x1A] = self.phased_worms_enemy;
737
+ b[0x1B] = self.circular_aim as u8;
738
+ b[0x1C] = self.anti_lock_aim as u8;
739
+ b[0x1D] = self.anti_lock_power as u8;
740
+ b[0x1E] = self.worm_select_no_end_hot_seat as u8;
741
+ b[0x1F] = self.worm_select_never_cancelled as u8;
742
+ b[0x20] = self.batty_rope as u8;
743
+ b[0x21] = self.rope_roll_drops;
744
+ b[0x22] = self.x_impact_loss_of_control;
745
+ b[0x23] = self.keep_control_bump_head as u8;
746
+ b[0x24] = self.keep_control_skimming;
747
+ b[0x25] = self.explosion_fall_damage as u8;
748
+ b[0x26] = self.explosions_push_all;
749
+ b[0x27] = self.undetermined_crates;
750
+ b[0x28] = self.undetermined_fuses;
751
+ b[0x29] = self.pause_timer_while_firing as u8;
752
+ b[0x2A] = self.loss_of_control_no_end_turn as u8;
753
+ b[0x2B] = self.weapon_use_no_end_turn as u8;
754
+ b[0x2C] = self.weapon_use_no_block as u8;
755
+ b[0x2D] = self.drill_imparts_velocity;
756
+ b[0x2E] = self.girder_radius_assist as u8;
757
+ b[0x2F..0x31].copy_from_slice(&self.petrol_turn_decay.to_le_bytes());
758
+ b[0x31] = self.petrol_touch_decay;
759
+ b[0x32..0x34].copy_from_slice(&self.max_flamelet_count.to_le_bytes());
760
+ b[0x34..0x38].copy_from_slice(&self.max_projectile_speed.to_le_bytes());
761
+ b[0x38..0x3C].copy_from_slice(&self.max_rope_speed.to_le_bytes());
762
+ b[0x3C..0x40].copy_from_slice(&self.max_jet_pack_speed.to_le_bytes());
763
+ b[0x40..0x44].copy_from_slice(&self.game_engine_speed.to_le_bytes());
764
+ b[0x44] = self.indian_rope_glitch;
765
+ b[0x45] = self.herd_doubling_glitch;
766
+ b[0x46] = self.jet_pack_bungee_glitch as u8;
767
+ b[0x47] = self.angle_cheat_glitch as u8;
768
+ b[0x48] = self.glide_glitch as u8;
769
+ b[0x49] = self.skipwalking as u8;
770
+ b[0x4A] = self.block_roofing;
771
+ b[0x4B] = self.floating_weapon_glitch as u8;
772
+ b[0x4C..0x50].copy_from_slice(&self.rw_bounciness.to_le_bytes());
773
+ b[0x50..0x54].copy_from_slice(&self.rw_air_viscosity.to_le_bytes());
774
+ b[0x54] = self.rw_air_viscosity_worms as u8;
775
+ b[0x55..0x59].copy_from_slice(&self.rw_wind_influence.to_le_bytes());
776
+ b[0x59] = self.rw_wind_influence_worms as u8;
777
+ b[0x5A] = self.rw_gravity_type;
778
+ b[0x5B..0x5F].copy_from_slice(&self.rw_gravity_strength.to_le_bytes());
779
+ b[0x5F] = self.rw_crate_rate;
780
+ b[0x60] = self.rw_crate_shower as u8;
781
+ b[0x61] = self.rw_anti_sink as u8;
782
+ b[0x62] = self.rw_remember_weapons as u8;
783
+ b[0x63] = self.rw_extended_fuses as u8;
784
+ b[0x64] = self.rw_anti_lock_aim as u8;
785
+ b[0x65] = self.terrain_overlap_glitch;
786
+ b[0x66] = self.fractional_round_timer as u8;
787
+ b[0x67] = self.auto_retreat as u8;
788
+ b[0x68] = self.health_crates_cure_poison as u8;
789
+ b[0x69] = self.rw_kaos_mod;
790
+ b[0x6A] = self.sheep_heavens_gate;
791
+ b[0x6B] = self.conserve_instant_utilities as u8;
792
+ b[0x6C] = self.expedite_instant_utilities as u8;
793
+ b[0x6D] = self.double_time_stack_limit;
794
+ b
795
+ }
796
+ }
797
+
798
+ /// V3 extended options defaults from WA.exe ROM at 0x649AB8 (110 bytes).
799
+ ///
800
+ /// Applied to V1/V2 schemes at struct+0x138 (payload+0x124).
801
+ /// Dumped directly from the binary — tri-state fields use 0x80 = "engine default".
802
+ pub const EXTENDED_OPTIONS_DEFAULTS: [u8; EXTENDED_OPTIONS_SIZE] = [
803
+ // +0x00 +0x04
804
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x0F, // data_version, const_wind, wind=100, bias=15
805
+ 0x70, 0x3D, 0x00, 0x00, 0xC2, 0xF5, 0x00, 0x00, // gravity=0x3D70, friction=0xF5C2
806
+ // +0x10 +0x14
807
+ 0xFF, 0xFF, 0x00, 0x01, 0x05, 0x00, 0x00, 0x00, // rope_knock=FF, blood=FF, unrestrict=0, ally=1, nocrate=5
808
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sd, phased, circular, anti_lock...
809
+ // +0x20 +0x24
810
+ 0x00, 0x00, 0x80, 0x80, 0x80, 0x01, 0x00, 0x00, // batty=0, rope_roll=0, x_impact=80, bump=80, skim=80, expl_fall=1
811
+ 0x00, 0x80, 0x00, 0x32, 0x33, 0x1E, 0xC8, 0x00, // push_all=0, timer=80, loc=0, skim2=50... petrol_decay, touch=200
812
+ // +0x30 +0x34
813
+ 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x10, 0x00, // flamelet=0, proj_speed=0x200000
814
+ 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, // rope_speed=0x100000, jet_speed=0x50000, engine (low)
815
+ // +0x40 +0x44
816
+ 0x80, 0x80, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, // engine (high), rope_glitch, herd, jetbungee, angle, glide, skip
817
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // block, float, rw_bounce...
818
+ // +0x50 +0x54
819
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // rw_air_visc, rw_air_worms, rw_wind...
820
+ 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // rw_wind_worms, rw_grav_type=1, rw_grav_str=0
821
+ // +0x60 +0x64
822
+ 0x00, 0x80, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, // rw_crate_rate=0, shower=0x80, sink=0, remember=0, fuses=1, lock=0, overlap=7
823
+ 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // frac_timer=0, retreat=1, cure_poison=0, kaos=0, sheep_gate=0, conserve=0
824
+ ];
825
+
826
+ // === Typed accessors on SchemeFile ===
827
+
828
+ impl SchemeFile {
829
+ /// Get the game options from the payload.
830
+ pub fn options(&self) -> SchemeOptions {
831
+ SchemeOptions::from_bytes(&self.payload[OPTIONS_OFFSET..])
832
+ }
833
+
834
+ /// Get weapon settings for a weapon by scheme index (0-63).
835
+ ///
836
+ /// Indices 0-44 are V1 weapons, 45-63 are V2 super weapons.
837
+ /// Returns `None` if the index is out of range for this scheme version.
838
+ pub fn weapon(&self, scheme_index: usize) -> Option<WeaponSettings> {
839
+ if scheme_index >= WEAPONS_TOTAL_COUNT {
840
+ return None;
841
+ }
842
+ // V1 schemes only have weapons 0-44
843
+ if scheme_index >= WEAPONS_V1_COUNT && self.version == SchemeVersion::V1 {
844
+ return None;
845
+ }
846
+ let offset = WEAPONS_V1_OFFSET + scheme_index * WEAPON_ENTRY_SIZE;
847
+ Some(WeaponSettings::from_bytes(&self.payload[offset..]))
848
+ }
849
+
850
+ /// Get extended options (V3 only). Returns `None` for V1/V2 schemes.
851
+ pub fn extended_options(&self) -> Option<ExtendedOptions> {
852
+ if self.payload.len() > EXTENDED_OPTIONS_OFFSET {
853
+ Some(ExtendedOptions::from_bytes(
854
+ &self.payload[EXTENDED_OPTIONS_OFFSET..],
855
+ ))
856
+ } else {
857
+ None
858
+ }
859
+ }
860
+
861
+ /// Get extended options, falling back to ROM defaults for V1/V2 schemes.
862
+ pub fn extended_options_or_defaults(&self) -> ExtendedOptions {
863
+ self.extended_options()
864
+ .unwrap_or_else(|| ExtendedOptions::from_bytes(&EXTENDED_OPTIONS_DEFAULTS))
865
+ }
866
+
867
+ /// Number of weapons available in this scheme version.
868
+ pub fn weapon_count(&self) -> usize {
869
+ match self.version {
870
+ SchemeVersion::V1 => WEAPONS_V1_COUNT,
871
+ _ => WEAPONS_TOTAL_COUNT,
872
+ }
873
+ }
874
+ }
875
+
876
  /// Error type for file-based scheme operations.
877
  #[derive(Debug)]
878
  pub enum SchemeFileError {
crates/openwa-types/tests/scheme_parse.rs CHANGED
@@ -1,6 +1,8 @@
1
  use std::path::Path;
2
 
3
- use openwa_types::scheme::{SchemeFile, SchemeVersion, SCHEME_PAYLOAD_V1, SCHEME_PAYLOAD_V2};
 
 
4
 
5
  const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
6
 
@@ -54,6 +56,127 @@ fn roundtrip_shopping() {
54
  assert_eq!(scheme.to_bytes(), original);
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  /// Parse all .wsc files from the game directory if available.
58
  #[test]
59
  fn parse_all_game_schemes() {
 
1
  use std::path::Path;
2
 
3
+ use openwa_types::scheme::{
4
+ SchemeFile, SchemeVersion, SCHEME_PAYLOAD_V1, SCHEME_PAYLOAD_V2, WEAPONS_V1_COUNT,
5
+ };
6
 
7
  const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
8
 
 
56
  assert_eq!(scheme.to_bytes(), original);
57
  }
58
 
59
+ // === Typed field accessor tests ===
60
+
61
+ #[test]
62
+ fn beginner_options() {
63
+ let path = Path::new(FIXTURES).join("beginner.wsc");
64
+ let scheme = SchemeFile::from_file(&path).unwrap();
65
+ let opts = scheme.options();
66
+
67
+ // Verified against hex dump of beginner.wsc payload bytes
68
+ assert_eq!(opts.hot_seat_delay, 10); // 0x0A
69
+ assert_eq!(opts.retreat_time, 5);
70
+ assert_eq!(opts.rope_retreat_time, 5);
71
+ assert_eq!(opts.display_total_round_time, true);
72
+ assert_eq!(opts.automatic_replays, true);
73
+ assert_eq!(opts.fall_damage, 0);
74
+ assert_eq!(opts.artillery_mode, false);
75
+ assert_eq!(opts.bounty_mode, 0);
76
+ assert_eq!(opts.stockpiling, 1); // On
77
+ assert_eq!(opts.worm_select, 1); // Manual
78
+ assert_eq!(opts.sudden_death_event, 3); // Nothing
79
+ assert_eq!(opts.worm_energy, 150); // 0x96
80
+ }
81
+
82
+ #[test]
83
+ fn classic_options() {
84
+ let path = Path::new(FIXTURES).join("classic.wsc");
85
+ let scheme = SchemeFile::from_file(&path).unwrap();
86
+ let opts = scheme.options();
87
+
88
+ // Verified against hex dump: 05 05 05 00 01 01 00 00 00 01 02 ...
89
+ assert_eq!(opts.hot_seat_delay, 5);
90
+ assert_eq!(opts.retreat_time, 5);
91
+ assert_eq!(opts.rope_retreat_time, 5);
92
+ assert_eq!(opts.display_total_round_time, false);
93
+ assert_eq!(opts.automatic_replays, true);
94
+ assert_eq!(opts.fall_damage, 1);
95
+ assert_eq!(opts.artillery_mode, false);
96
+ assert_eq!(opts.stockpiling, 0); // Off
97
+ assert_eq!(opts.worm_select, 1); // Manual
98
+ assert_eq!(opts.worm_energy, 100); // hex: 0x64
99
+ }
100
+
101
+ #[test]
102
+ fn beginner_weapons() {
103
+ let path = Path::new(FIXTURES).join("beginner.wsc");
104
+ let scheme = SchemeFile::from_file(&path).unwrap();
105
+
106
+ // Weapon 0 = Bazooka: at payload offset 36
107
+ // hex bytes at file offset 0x29: 0A 03 00 00
108
+ let bazooka = scheme.weapon(0).unwrap();
109
+ assert_eq!(bazooka.ammo, 10);
110
+ assert_eq!(bazooka.power, 3);
111
+ assert_eq!(bazooka.delay, 0);
112
+ assert_eq!(bazooka.crate_probability, 0);
113
+
114
+ // V2 weapon: index 45 = Freeze, at payload offset 36 + 45*4 = 216
115
+ // Should be available for V2 scheme
116
+ let freeze = scheme.weapon(45);
117
+ assert!(freeze.is_some());
118
+
119
+ // Total weapon count for V2
120
+ assert_eq!(scheme.weapon_count(), 64);
121
+ }
122
+
123
+ #[test]
124
+ fn classic_weapons_v1_limits() {
125
+ let path = Path::new(FIXTURES).join("classic.wsc");
126
+ let scheme = SchemeFile::from_file(&path).unwrap();
127
+
128
+ // V1 has 45 weapons
129
+ assert_eq!(scheme.weapon_count(), WEAPONS_V1_COUNT);
130
+
131
+ // Weapon 0 = Bazooka — classic has 10 ammo too
132
+ let bazooka = scheme.weapon(0).unwrap();
133
+ assert_eq!(bazooka.ammo, 10);
134
+
135
+ // V2 super weapon index should return None for V1 scheme
136
+ assert!(scheme.weapon(45).is_none());
137
+ assert!(scheme.weapon(63).is_none());
138
+ }
139
+
140
+ #[test]
141
+ fn weapon_out_of_range() {
142
+ let path = Path::new(FIXTURES).join("beginner.wsc");
143
+ let scheme = SchemeFile::from_file(&path).unwrap();
144
+ assert!(scheme.weapon(64).is_none());
145
+ assert!(scheme.weapon(100).is_none());
146
+ }
147
+
148
+ #[test]
149
+ fn options_roundtrip() {
150
+ let path = Path::new(FIXTURES).join("beginner.wsc");
151
+ let scheme = SchemeFile::from_file(&path).unwrap();
152
+ let opts = scheme.options();
153
+ let serialized = opts.to_bytes();
154
+ assert_eq!(&serialized[..], &scheme.payload[..36]);
155
+ }
156
+
157
+ #[test]
158
+ fn v1_no_extended_options() {
159
+ let path = Path::new(FIXTURES).join("classic.wsc");
160
+ let scheme = SchemeFile::from_file(&path).unwrap();
161
+ assert!(scheme.extended_options().is_none());
162
+
163
+ // But defaults should still work
164
+ let defaults = scheme.extended_options_or_defaults();
165
+ assert_eq!(defaults.data_version, 0);
166
+ }
167
+
168
+ #[test]
169
+ fn v2_super_weapons_zeroed_region() {
170
+ // In the beginner.wsc V2 file, super weapons after the used ones should exist
171
+ let path = Path::new(FIXTURES).join("beginner.wsc");
172
+ let scheme = SchemeFile::from_file(&path).unwrap();
173
+
174
+ // Armageddon is weapon index 63 (last V2 super weapon)
175
+ let armageddon = scheme.weapon(63).unwrap();
176
+ // It should be parseable (values may be zero)
177
+ let _ = armageddon.ammo;
178
+ }
179
+
180
  /// Parse all .wsc files from the game directory if available.
181
  #[test]
182
  fn parse_all_game_schemes() {
docs/re-notes/configuration.md CHANGED
@@ -80,14 +80,100 @@ Binary format, variable length. Contains visual/audio theme settings.
80
  3. Calls `Frontend__SaveTheme` (0x490E60) with path `"data\current.thm"`
81
  4. Calls `FrontendChangeScreen` to navigate away
82
 
83
- ## Weapon Schemes
84
-
85
- Stored as `.wsc` files under `User\Schemes\`.
86
-
87
- | Function | Address | Path Pattern |
88
- |----------|---------|-------------|
89
- | `Scheme__Load` | 0x4D4CD0 | `User\Schemes\%s.wsc` |
90
- | `Scheme__LoadNumbered` | 0x4D4E00 | `User\Schemes\{%02d} %s.wsc` |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  ## Data Files
93
 
 
80
  3. Calls `Frontend__SaveTheme` (0x490E60) with path `"data\current.thm"`
81
  4. Calls `FrontendChangeScreen` to navigate away
82
 
83
+ ## Weapon Schemes (.wsc)
84
+
85
+ Stored as `.wsc` files under `User\Schemes\`. Full format spec: [worms2d.info/Game_scheme_file](https://worms2d.info/Game_scheme_file).
86
+
87
+ ### Functions
88
+
89
+ | Function | Address | Signature | Notes |
90
+ |----------|---------|-----------|-------|
91
+ | `Scheme__ReadFile` | 0x4D3890 | stdcall(dest, path, flag, out_ptr) → u32, RET 0x10 | Reads .wsc into dest+0x14 |
92
+ | `Scheme__SaveFile` | 0x4D44F0 | thiscall(this, name, flag), RET 0x8 | Writes struct to .wsc |
93
+ | `Scheme__DetectVersion` | 0x4D4480 | Uses ESI (non-standard) → 1/2/3 | Compares V3 extension bytes |
94
+ | `Scheme__FileExists` | 0x4D4CD0 | stdcall(name) → 0/1, RET 0x4 | Path: `User\Schemes\%s.wsc` |
95
+ | `Scheme__FileExistsNumbered` | 0x4D4E00 | stdcall(...) | Path: `User\Schemes\{%02d} %s.wsc` |
96
+ | `Scheme__InitFromData` | 0x4D5020 | fastcall(?, data, dest, name) | Copies payload + V3 defaults |
97
+ | `Scheme__CheckWeaponLimits` | 0x4D50E0 | — → 0/1 | Validates ammo vs max table (39 weapons) |
98
+ | `Scheme__ValidateExtendedOptions` | 0x4D5110 | Uses EAX → 0/1 | V3 field range checks |
99
+ | `Scheme__ScanDirectory` | 0x4D54E0 | — | Finds `{NN} name.wsc` files |
100
+ | `Scheme__ExtractBuiltins` | 0x4D5720 | — | Extracts PE resources (IDs 0x3CA-0x3D6) |
101
+
102
+ ### Data
103
+
104
+ | Symbol | Address | Size | Notes |
105
+ |--------|---------|------|-------|
106
+ | `SCHEME_V3_DEFAULTS` | 0x649AB8 | 110 bytes | Default extended options for V1/V2 |
107
+ | `SCHEME_WEAPON_AMMO_LIMITS` | 0x6AD130 | 39 bytes | Max ammo per weapon (V1 set) |
108
+
109
+ ### File Format
110
+
111
+ Binary, little-endian. Header: `"SCHM"` (4 bytes) + version byte (1 byte) + payload.
112
+
113
+ | Version | Payload | Total | Content |
114
+ |---------|---------|-------|---------|
115
+ | V1 (0x01) | 0xD8 (216) | 221 | 36 options + 45 weapons × 4 |
116
+ | V2 (0x02) | 0x124 (292) | 297 | V1 + 19 super weapons × 4 |
117
+ | V3 (0x03) | 0x192 (402) | 407 | V2 + 110 extended options |
118
+
119
+ ### Payload Layout
120
+
121
+ **Game Options (36 bytes, payload +0x00):**
122
+
123
+ | Offset | Size | Field | Notes |
124
+ |--------|------|-------|-------|
125
+ | +0x00 | 1 | hot_seat_delay | Seconds between turns |
126
+ | +0x01 | 1 | retreat_time | Seconds after weapon use |
127
+ | +0x02 | 1 | rope_retreat_time | Seconds after rope weapon use |
128
+ | +0x03 | 1 | display_total_round_time | bool |
129
+ | +0x04 | 1 | automatic_replays | bool |
130
+ | +0x05 | 1 | fall_damage | Damage at critical velocity |
131
+ | +0x06 | 1 | artillery_mode | bool |
132
+ | +0x07 | 1 | bounty_mode | 0x00/0x5F/0x89 |
133
+ | +0x08 | 1 | stockpiling | 0=Off, 1=On, 2=Anti |
134
+ | +0x09 | 1 | worm_select | 0=Sequential, 1=Manual, 2=Random |
135
+ | +0x0A | 1 | sudden_death_event | 0=RoundEnds, 1=Nuke, 2=HP→1, 3=Nothing |
136
+ | +0x0B | 1 | water_rise_rate | Flooding speed |
137
+ | +0x0C | 1 | weapon_crate_probability | i8, -100 to 100 |
138
+ | +0x0D | 1 | donor_cards | bool |
139
+ | +0x0E | 1 | health_crate_probability | i8 |
140
+ | +0x0F | 1 | health_crate_energy | Energy from health crate |
141
+ | +0x10 | 1 | utility_crate_probability | i8 |
142
+ | +0x11 | 1 | hazardous_object_types | Bitmask |
143
+ | +0x12 | 1 | mine_delay | i8, seconds (0x80+=random) |
144
+ | +0x13 | 1 | dud_mines | bool |
145
+ | +0x14 | 1 | manual_worm_placement | bool |
146
+ | +0x15 | 1 | worm_energy | 0=instant death |
147
+ | +0x16 | 1 | turn_time | Seconds |
148
+ | +0x17 | 1 | round_time | Minutes (0=immediate SD) |
149
+ | +0x18 | 1 | number_of_wins | Rounds to win match |
150
+ | +0x19 | 1 | blood | false=pink, true=red |
151
+ | +0x1A | 1 | aqua_sheep | bool |
152
+ | +0x1B | 1 | sheep_heaven | bool |
153
+ | +0x1C | 1 | god_worms | bool |
154
+ | +0x1D | 1 | indestructible_land | bool |
155
+ | +0x1E | 1 | upgraded_grenade | bool |
156
+ | +0x1F | 1 | upgraded_shotgun | bool |
157
+ | +0x20 | 1 | upgraded_clusters | bool |
158
+ | +0x21 | 1 | upgraded_longbow | bool |
159
+ | +0x22 | 1 | team_weapons | bool |
160
+ | +0x23 | 1 | super_weapons | bool |
161
+
162
+ **Per-Weapon (4 bytes each, payload +0x24):**
163
+
164
+ `[ammo, power, delay, crate_probability]` — 45 weapons (V1), 64 weapons (V2+).
165
+
166
+ **V3 Extended Options (110 bytes, payload +0x124):**
167
+
168
+ Physics (gravity, friction, wind), RubberWorm modifiers, glitch toggles, and gameplay extensions. See `scheme.rs::ExtendedOptions` for full field list. Uses fixed-point 16.16 and tri-state (0/1/0x80) types.
169
+
170
+ ### Callers of Scheme__ReadFile
171
+
172
+ | Address | Context |
173
+ |---------|---------|
174
+ | 0x4A1D18 | Frontend/options dialog |
175
+ | 0x4B4B92 | Multiplayer scheme loading |
176
+ | 0x4CC425 | Mission/campaign loading |
177
 
178
  ## Data Files
179