paavohtl Claude Opus 4.6 (1M context) commited on
Commit
b8e4ca1
·
1 Parent(s): 2835c9a

fix: correct EXTENDED_OPTIONS_DEFAULTS and read from ROM at runtime

Browse files

The hand-transcribed EXTENDED_OPTIONS_DEFAULTS constant had bytes
shifted from offset 0x13 onwards, causing game_engine_speed to default
to 0x01018080 (257x) instead of 0x00010000 (1.0x). This made all
.wsc scheme files play at unlocked frame rate.

Two fixes:
1. write_scheme_to_dest now reads defaults from WA's ROM at runtime
via rb(SCHEME_V3_DEFAULTS), making it impossible to diverge
2. Replaced the constant with byte-exact ROM dump (verified at runtime)

Also accept variable-length V3 scheme files (payload between 0x124
and 0x192 bytes), padding missing extended options with defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

crates/openwa-core/src/game/scheme.rs CHANGED
@@ -134,6 +134,32 @@ impl SchemeFile {
134
  let expected_payload = version.payload_size();
135
  let actual_payload = data.len() - SCHEME_HEADER_SIZE;
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  if actual_payload != expected_payload {
138
  return Err(SchemeError::PayloadMismatch {
139
  expected: expected_payload,
@@ -890,27 +916,22 @@ impl ExtendedOptions {
890
  /// Applied to V1/V2 schemes at struct+0x138 (payload+0x124).
891
  /// Dumped directly from the binary — tri-state fields use 0x80 = "engine default".
892
  pub const EXTENDED_OPTIONS_DEFAULTS: [u8; EXTENDED_OPTIONS_SIZE] = [
893
- // +0x00 +0x04
894
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x0F, // data_version, const_wind, wind=100, bias=15
895
- 0x70, 0x3D, 0x00, 0x00, 0xC2, 0xF5, 0x00, 0x00, // gravity=0x3D70, friction=0xF5C2
896
- // +0x10 +0x14
897
- 0xFF, 0xFF, 0x00, 0x01, 0x05, 0x00, 0x00, 0x00, // rope_knock=FF, blood=FF, unrestrict=0, ally=1, nocrate=5
898
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sd, phased, circular, anti_lock...
899
- // +0x20 +0x24
900
- 0x00, 0x00, 0x80, 0x80, 0x80, 0x01, 0x00, 0x00, // batty=0, rope_roll=0, x_impact=80, bump=80, skim=80, expl_fall=1
901
- 0x00, 0x80, 0x00, 0x32, 0x33, 0x1E, 0xC8, 0x00, // push_all=0, timer=80, loc=0, skim2=50... petrol_decay, touch=200
902
- // +0x30 +0x34
903
- 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x10, 0x00, // flamelet=0, proj_speed=0x200000
904
- 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, // rope_speed=0x100000, jet_speed=0x50000, engine (low)
905
- // +0x40 +0x44
906
- 0x80, 0x80, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, // engine (high), rope_glitch, herd, jetbungee, angle, glide, skip
907
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // block, float, rw_bounce...
908
- // +0x50 +0x54
909
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // rw_air_visc, rw_air_worms, rw_wind...
910
- 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // rw_wind_worms, rw_grav_type=1, rw_grav_str=0
911
- // +0x60 +0x64
912
- 0x00, 0x80, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, // rw_crate_rate=0, shower=0x80, sink=0, remember=0, fuses=1, lock=0, overlap=7
913
- 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // frac_timer=0, retreat=1, cure_poison=0, kaos=0, sheep_gate=0, conserve=0
914
  ];
915
 
916
  // === Typed accessors on SchemeFile ===
@@ -1069,4 +1090,50 @@ mod tests {
1069
  Err(SchemeError::PayloadMismatch { expected: 0xD8, got: 10 })
1070
  ));
1071
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
  }
 
134
  let expected_payload = version.payload_size();
135
  let actual_payload = data.len() - SCHEME_HEADER_SIZE;
136
 
137
+ // V3 schemes are saved with variable length: only extended options bytes
138
+ // that differ from defaults are included. The original game pre-fills the
139
+ // extended region with SCHEME_V3_DEFAULTS then reads the file on top, so
140
+ // short V3 files get defaults for the missing tail. We replicate this by
141
+ // accepting any size between V2 (0x124) and V3 (0x192) and padding.
142
+ if version == SchemeVersion::V3 {
143
+ if actual_payload < SCHEME_PAYLOAD_V2 || actual_payload > SCHEME_PAYLOAD_V3 {
144
+ return Err(SchemeError::PayloadMismatch {
145
+ expected: expected_payload,
146
+ got: actual_payload,
147
+ });
148
+ }
149
+ let file_payload = &data[SCHEME_HEADER_SIZE..];
150
+ let mut payload = vec![0u8; SCHEME_PAYLOAD_V3];
151
+ // Copy file data (V2 portion + whatever extended bytes are present)
152
+ payload[..actual_payload].copy_from_slice(file_payload);
153
+ // Fill remaining extended options with defaults
154
+ if actual_payload < SCHEME_PAYLOAD_V3 {
155
+ let defaults_start = actual_payload.saturating_sub(SCHEME_PAYLOAD_V2);
156
+ payload[actual_payload..SCHEME_PAYLOAD_V3].copy_from_slice(
157
+ &EXTENDED_OPTIONS_DEFAULTS[defaults_start..],
158
+ );
159
+ }
160
+ return Ok(SchemeFile { version, payload });
161
+ }
162
+
163
  if actual_payload != expected_payload {
164
  return Err(SchemeError::PayloadMismatch {
165
  expected: expected_payload,
 
916
  /// Applied to V1/V2 schemes at struct+0x138 (payload+0x124).
917
  /// Dumped directly from the binary — tri-state fields use 0x80 = "engine default".
918
  pub const EXTENDED_OPTIONS_DEFAULTS: [u8; EXTENDED_OPTIONS_SIZE] = [
919
+ // Byte-exact copy of WA.exe ROM at 0x649AB8 (110 bytes).
920
+ // Dumped at runtime to avoid transcription errors.
921
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x0F, // +0x00
922
+ 0x70, 0x3D, 0x00, 0x00, 0xC2, 0xF5, 0x00, 0x00, // +0x08
923
+ 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0x05, 0x00, 0x01, // +0x10
924
+ 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +0x18
925
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, // +0x20
926
+ 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x32, // +0x28
927
+ 0x33, 0x1E, 0xC8, 0x00, 0x00, 0x00, 0x20, 0x00, // +0x30
928
+ 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x05, 0x00, // +0x38
929
+ 0x00, 0x00, 0x01, 0x00, 0x80, 0x80, 0x01, 0x01, // +0x40
930
+ 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // +0x48
931
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +0x50
932
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, // +0x58
933
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, // +0x60
934
+ 0x01, 0x00, 0x07, 0x00, 0x00, 0x01, // +0x68
 
 
 
 
 
935
  ];
936
 
937
  // === Typed accessors on SchemeFile ===
 
1090
  Err(SchemeError::PayloadMismatch { expected: 0xD8, got: 10 })
1091
  ));
1092
  }
1093
+
1094
+ #[test]
1095
+ fn v3_variable_length_padded_with_defaults() {
1096
+ // V3 scheme with only V2-length payload (no extended options in file)
1097
+ let mut data = Vec::new();
1098
+ data.extend_from_slice(b"SCHM");
1099
+ data.push(0x03);
1100
+ data.extend_from_slice(&[0xAA; SCHEME_PAYLOAD_V2]); // V2 portion filled with 0xAA
1101
+ // No extended options bytes — should be padded with defaults
1102
+
1103
+ let scheme = SchemeFile::from_bytes(&data).expect("should accept short V3");
1104
+ assert_eq!(scheme.version, SchemeVersion::V3);
1105
+ assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V3);
1106
+ // V2 portion should be file data
1107
+ assert_eq!(scheme.payload[0], 0xAA);
1108
+ assert_eq!(scheme.payload[SCHEME_PAYLOAD_V2 - 1], 0xAA);
1109
+ // Extended portion should be defaults
1110
+ assert_eq!(
1111
+ &scheme.payload[SCHEME_PAYLOAD_V2..],
1112
+ &EXTENDED_OPTIONS_DEFAULTS[..]
1113
+ );
1114
+ }
1115
+
1116
+ #[test]
1117
+ fn v3_full_length_accepted() {
1118
+ // V3 scheme with full 0x192 payload
1119
+ let mut data = Vec::new();
1120
+ data.extend_from_slice(b"SCHM");
1121
+ data.push(0x03);
1122
+ data.extend_from_slice(&[0; SCHEME_PAYLOAD_V3]);
1123
+
1124
+ let scheme = SchemeFile::from_bytes(&data).expect("should accept full V3");
1125
+ assert_eq!(scheme.version, SchemeVersion::V3);
1126
+ assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V3);
1127
+ }
1128
+
1129
+ #[test]
1130
+ fn v3_too_short_rejected() {
1131
+ // V3 with less than V2-length payload should be rejected
1132
+ let mut data = Vec::new();
1133
+ data.extend_from_slice(b"SCHM");
1134
+ data.push(0x03);
1135
+ data.extend_from_slice(&[0; 0x100]); // less than V2 (0x124)
1136
+
1137
+ assert!(SchemeFile::from_bytes(&data).is_err());
1138
+ }
1139
  }
crates/openwa-core/tests/scheme_parse.rs CHANGED
@@ -342,8 +342,7 @@ fn validate_game_engine_speed_range() {
342
  }
343
 
344
  #[test]
345
- fn validate_rom_defaults_fail_sheep_gate() {
346
- // ROM defaults have sheep_heavens_gate=0, which is intentionally invalid.
347
- // WA uses defaults as a fallback when V3 validation fails, but never validates them.
348
- assert!(!ExtendedOptions::validate_bytes(&EXTENDED_OPTIONS_DEFAULTS));
349
  }
 
342
  }
343
 
344
  #[test]
345
+ fn validate_rom_defaults_pass() {
346
+ // ROM defaults (byte-exact from WA.exe at 0x649AB8) must pass validation.
347
+ assert!(ExtendedOptions::validate_bytes(&EXTENDED_OPTIONS_DEFAULTS));
 
348
  }
crates/openwa-wormkit/src/replacements/scheme.rs CHANGED
@@ -52,6 +52,11 @@ const SCHEME_WEAPON_CHECK_COUNT: usize = 39;
52
  /// MFC string resource ID for default scheme name.
53
  const STRING_RES_DEFAULT_NAME: u32 = 0x0E;
54
 
 
 
 
 
 
55
  /// Write a parsed SchemeFile into the WA dest struct at the correct offsets.
56
  ///
57
  /// Replicates the memory writes from Scheme__ReadFile:
@@ -60,6 +65,7 @@ const STRING_RES_DEFAULT_NAME: u32 = 0x0E;
60
  /// - V3: payload(0x192), with validation fallback to ROM defaults
61
  unsafe fn write_scheme_to_dest(scheme: &SchemeFile, dest: u32) {
62
  let payload_ptr = (dest as usize + DEST_PAYLOAD) as *mut u8;
 
63
 
64
  match scheme.version {
65
  SchemeVersion::V1 => {
@@ -75,9 +81,9 @@ unsafe fn write_scheme_to_dest(scheme: &SchemeFile, dest: u32) {
75
  0,
76
  SUPER_WEAPONS_SIZE,
77
  );
78
- // Copy V3 defaults for extended options (110 bytes)
79
  core::ptr::copy_nonoverlapping(
80
- EXTENDED_OPTIONS_DEFAULTS.as_ptr(),
81
  payload_ptr.add(PAYLOAD_EXTENDED),
82
  EXTENDED_OPTIONS_SIZE,
83
  );
@@ -89,9 +95,9 @@ unsafe fn write_scheme_to_dest(scheme: &SchemeFile, dest: u32) {
89
  payload_ptr,
90
  SCHEME_PAYLOAD_V2,
91
  );
92
- // Copy V3 defaults for extended options (110 bytes)
93
  core::ptr::copy_nonoverlapping(
94
- EXTENDED_OPTIONS_DEFAULTS.as_ptr(),
95
  payload_ptr.add(PAYLOAD_EXTENDED),
96
  EXTENDED_OPTIONS_SIZE,
97
  );
@@ -103,11 +109,11 @@ unsafe fn write_scheme_to_dest(scheme: &SchemeFile, dest: u32) {
103
  payload_ptr,
104
  SCHEME_PAYLOAD_V3,
105
  );
106
- // Validate extended options; if invalid, replace with defaults
107
  let ext_bytes = &scheme.payload[PAYLOAD_EXTENDED..];
108
  if !ExtendedOptions::validate_bytes(ext_bytes) {
109
  core::ptr::copy_nonoverlapping(
110
- EXTENDED_OPTIONS_DEFAULTS.as_ptr(),
111
  payload_ptr.add(PAYLOAD_EXTENDED),
112
  EXTENDED_OPTIONS_SIZE,
113
  );
@@ -161,7 +167,7 @@ unsafe extern "stdcall" fn hook_scheme_read_file(
161
  *((dest_struct as usize + DEST_FLAG) as *mut u8) = 0;
162
 
163
  let _ = log_line(&format!(
164
- "[Scheme] ReadFile OK (Rust): {name} -> {:?}, dest=0x{dest_struct:08X}",
165
  scheme.version
166
  ));
167
  0
 
52
  /// MFC string resource ID for default scheme name.
53
  const STRING_RES_DEFAULT_NAME: u32 = 0x0E;
54
 
55
+ /// Read the V3 extended options defaults from WA.exe ROM (110 bytes at 0x649AB8).
56
+ unsafe fn rom_ext_defaults() -> *const u8 {
57
+ rb(va::SCHEME_V3_DEFAULTS) as *const u8
58
+ }
59
+
60
  /// Write a parsed SchemeFile into the WA dest struct at the correct offsets.
61
  ///
62
  /// Replicates the memory writes from Scheme__ReadFile:
 
65
  /// - V3: payload(0x192), with validation fallback to ROM defaults
66
  unsafe fn write_scheme_to_dest(scheme: &SchemeFile, dest: u32) {
67
  let payload_ptr = (dest as usize + DEST_PAYLOAD) as *mut u8;
68
+ let rom_defaults = rom_ext_defaults();
69
 
70
  match scheme.version {
71
  SchemeVersion::V1 => {
 
81
  0,
82
  SUPER_WEAPONS_SIZE,
83
  );
84
+ // Copy V3 defaults from ROM for extended options (110 bytes)
85
  core::ptr::copy_nonoverlapping(
86
+ rom_defaults,
87
  payload_ptr.add(PAYLOAD_EXTENDED),
88
  EXTENDED_OPTIONS_SIZE,
89
  );
 
95
  payload_ptr,
96
  SCHEME_PAYLOAD_V2,
97
  );
98
+ // Copy V3 defaults from ROM for extended options (110 bytes)
99
  core::ptr::copy_nonoverlapping(
100
+ rom_defaults,
101
  payload_ptr.add(PAYLOAD_EXTENDED),
102
  EXTENDED_OPTIONS_SIZE,
103
  );
 
109
  payload_ptr,
110
  SCHEME_PAYLOAD_V3,
111
  );
112
+ // Validate extended options; if invalid, replace with ROM defaults
113
  let ext_bytes = &scheme.payload[PAYLOAD_EXTENDED..];
114
  if !ExtendedOptions::validate_bytes(ext_bytes) {
115
  core::ptr::copy_nonoverlapping(
116
+ rom_defaults,
117
  payload_ptr.add(PAYLOAD_EXTENDED),
118
  EXTENDED_OPTIONS_SIZE,
119
  );
 
167
  *((dest_struct as usize + DEST_FLAG) as *mut u8) = 0;
168
 
169
  let _ = log_line(&format!(
170
+ "[Scheme] ReadFile OK: {name} -> {:?}, dest=0x{dest_struct:08X}",
171
  scheme.version
172
  ));
173
  0