fix: correct EXTENDED_OPTIONS_DEFAULTS and read from ROM at runtime
Browse filesThe 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 |
-
//
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
// +
|
| 897 |
-
0xFF, 0xFF, 0x00,
|
| 898 |
-
|
| 899 |
-
// +0x20
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
// +
|
| 903 |
-
0x00, 0x00,
|
| 904 |
-
0x00, 0x00,
|
| 905 |
-
// +
|
| 906 |
-
|
| 907 |
-
0x00, 0x00, 0x00, 0x00, 0x00,
|
| 908 |
-
// +
|
| 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
|
| 346 |
-
// ROM defaults
|
| 347 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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
|