feat: add .wsc scheme file parser + Scheme__ReadFile hook
Browse filesAdd SchemeFile type to openwa-types with from_bytes/to_bytes for the
SCHM container format (v1: 0xD8 payload, v2: 0x124 payload). Tested
against all 18 bundled scheme files with round-trip verification.
RE discoveries: Scheme__Load (0x4D4CD0) is actually just a file-exists
check, Scheme__ParseFile (0x4D44F0) is a save function. The real reader
is Scheme__ReadFile (0x4D3890), stdcall with 4 params. Hook confirms it
fires when selecting schemes in the multiplayer setup dialog.
8 new Ghidra labels: scheme functions + CFile Read/Write/Open/Close.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- crates/openwa-types/src/address.rs +16 -0
- crates/openwa-types/src/lib.rs +1 -0
- crates/openwa-types/src/scheme.rs +270 -0
- crates/openwa-types/tests/fixtures/beginner.wsc +3 -0
- crates/openwa-types/tests/fixtures/classic.wsc +3 -0
- crates/openwa-types/tests/fixtures/shopping.wsc +3 -0
- crates/openwa-types/tests/scheme_parse.rs +86 -0
- crates/openwa-wormkit/src/replacements/mod.rs +2 -0
- crates/openwa-wormkit/src/replacements/scheme.rs +82 -0
crates/openwa-types/src/address.rs
CHANGED
|
@@ -218,6 +218,22 @@ pub mod va {
|
|
| 218 |
pub const FRONTEND_LOBBY_HOST_CTOR: u32 = 0x004B_0160;
|
| 219 |
pub const FRONTEND_LOBBY_GAME_START_CTOR: u32 = 0x004B_DBE0;
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
// === Lobby / network ===
|
| 222 |
|
| 223 |
pub const LOBBY_HOST_COMMANDS: u32 = 0x004B_9B00;
|
|
|
|
| 218 |
pub const FRONTEND_LOBBY_HOST_CTOR: u32 = 0x004B_0160;
|
| 219 |
pub const FRONTEND_LOBBY_GAME_START_CTOR: u32 = 0x004B_DBE0;
|
| 220 |
|
| 221 |
+
// === Scheme file operations ===
|
| 222 |
+
|
| 223 |
+
/// Reads .wsc file into scheme struct: stdcall(dest, path, flag, out_ptr), RET 0x10
|
| 224 |
+
/// Opens modeRead|typeBinary, reads header + version-dependent payload to dest+0x14
|
| 225 |
+
pub const SCHEME_READ_FILE: u32 = 0x004D_3890;
|
| 226 |
+
/// Checks if scheme file exists: stdcall(name) → 0=not found, 1=found, RET 0x4
|
| 227 |
+
pub const SCHEME_FILE_EXISTS: u32 = 0x004D_4CD0;
|
| 228 |
+
/// Saves scheme struct to .wsc file: thiscall(this, name, flag), RET 0x8
|
| 229 |
+
pub const SCHEME_SAVE_FILE: u32 = 0x004D_44F0;
|
| 230 |
+
/// Variant file-exists check for numbered schemes ({%02d} Name.wsc)
|
| 231 |
+
pub const SCHEME_FILE_EXISTS_NUMBERED: u32 = 0x004D_4E00;
|
| 232 |
+
/// Version detection: compares patterns to determine v1/v2/v3
|
| 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 |
|
| 239 |
pub const LOBBY_HOST_COMMANDS: u32 = 0x004B_9B00;
|
crates/openwa-types/src/lib.rs
CHANGED
|
@@ -11,4 +11,5 @@ pub mod landscape;
|
|
| 11 |
pub mod dssound;
|
| 12 |
pub mod opengl;
|
| 13 |
pub mod frontend;
|
|
|
|
| 14 |
pub mod address;
|
|
|
|
| 11 |
pub mod dssound;
|
| 12 |
pub mod opengl;
|
| 13 |
pub mod frontend;
|
| 14 |
+
pub mod scheme;
|
| 15 |
pub mod address;
|
crates/openwa-types/src/scheme.rs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Scheme file (.wsc) parser for Worms Armageddon.
|
| 2 |
+
///
|
| 3 |
+
/// .wsc files store game settings (turn time, wind, per-weapon ammo/delay, etc.).
|
| 4 |
+
/// Located in `User\Schemes\*.wsc`.
|
| 5 |
+
///
|
| 6 |
+
/// Binary format:
|
| 7 |
+
/// Bytes 0-3: Magic "SCHM" (0x5343484D)
|
| 8 |
+
/// Byte 4: Version byte
|
| 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";
|
| 21 |
+
|
| 22 |
+
/// Size of the file header (magic + version byte).
|
| 23 |
+
pub const SCHEME_HEADER_SIZE: usize = 5;
|
| 24 |
+
|
| 25 |
+
/// Payload size for version 1 schemes.
|
| 26 |
+
pub const SCHEME_PAYLOAD_V1: usize = 0xD8;
|
| 27 |
+
|
| 28 |
+
/// Payload size for version 2 schemes.
|
| 29 |
+
pub const SCHEME_PAYLOAD_V2: usize = 0x124;
|
| 30 |
+
|
| 31 |
+
/// Scheme file version.
|
| 32 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 33 |
+
pub enum SchemeVersion {
|
| 34 |
+
/// Version 1: 0xD8 byte payload (total file: 221 bytes)
|
| 35 |
+
V1,
|
| 36 |
+
/// Version 2: 0x124 byte payload (total file: 297 bytes)
|
| 37 |
+
V2,
|
| 38 |
+
/// Extended: payload = version_byte + 0x124 bytes
|
| 39 |
+
Extended(u8),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
impl SchemeVersion {
|
| 43 |
+
/// Raw version byte as stored in the file.
|
| 44 |
+
pub fn to_byte(self) -> u8 {
|
| 45 |
+
match self {
|
| 46 |
+
SchemeVersion::V1 => 1,
|
| 47 |
+
SchemeVersion::V2 => 2,
|
| 48 |
+
SchemeVersion::Extended(v) => v,
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/// Parse version from the raw byte.
|
| 53 |
+
pub fn from_byte(b: u8) -> Self {
|
| 54 |
+
match b {
|
| 55 |
+
1 => SchemeVersion::V1,
|
| 56 |
+
2 => SchemeVersion::V2,
|
| 57 |
+
v => SchemeVersion::Extended(v),
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/// Expected payload size for this version.
|
| 62 |
+
pub fn payload_size(self) -> usize {
|
| 63 |
+
match self {
|
| 64 |
+
SchemeVersion::V1 => SCHEME_PAYLOAD_V1,
|
| 65 |
+
SchemeVersion::V2 => SCHEME_PAYLOAD_V2,
|
| 66 |
+
SchemeVersion::Extended(v) => v as usize + SCHEME_PAYLOAD_V2,
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/// Errors from parsing a .wsc scheme file.
|
| 72 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 73 |
+
pub enum SchemeError {
|
| 74 |
+
/// File is too short to contain the header.
|
| 75 |
+
TooShort { len: usize },
|
| 76 |
+
/// Magic bytes don't match "SCHM".
|
| 77 |
+
BadMagic([u8; 4]),
|
| 78 |
+
/// Payload size doesn't match what the version byte expects.
|
| 79 |
+
PayloadMismatch { expected: usize, got: usize },
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
impl core::fmt::Display for SchemeError {
|
| 83 |
+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
| 84 |
+
match self {
|
| 85 |
+
SchemeError::TooShort { len } => {
|
| 86 |
+
write!(f, "file too short ({len} bytes, need at least {SCHEME_HEADER_SIZE})")
|
| 87 |
+
}
|
| 88 |
+
SchemeError::BadMagic(m) => {
|
| 89 |
+
write!(f, "bad magic: {:02X} {:02X} {:02X} {:02X} (expected SCHM)", m[0], m[1], m[2], m[3])
|
| 90 |
+
}
|
| 91 |
+
SchemeError::PayloadMismatch { expected, got } => {
|
| 92 |
+
write!(f, "payload size mismatch: expected {expected}, got {got}")
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/// A parsed .wsc scheme file.
|
| 99 |
+
///
|
| 100 |
+
/// The payload bytes are initially opaque. Typed field accessors will be
|
| 101 |
+
/// added incrementally as we map individual byte offsets through RE.
|
| 102 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 103 |
+
pub struct SchemeFile {
|
| 104 |
+
pub version: SchemeVersion,
|
| 105 |
+
/// Raw payload bytes (game options + per-weapon settings).
|
| 106 |
+
/// Length matches `version.payload_size()`.
|
| 107 |
+
pub payload: Vec<u8>,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
impl SchemeFile {
|
| 111 |
+
/// Parse a scheme file from raw bytes (entire file contents).
|
| 112 |
+
pub fn from_bytes(data: &[u8]) -> Result<Self, SchemeError> {
|
| 113 |
+
if data.len() < SCHEME_HEADER_SIZE {
|
| 114 |
+
return Err(SchemeError::TooShort { len: data.len() });
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
let magic: [u8; 4] = data[0..4].try_into().unwrap();
|
| 118 |
+
if magic != SCHEME_MAGIC {
|
| 119 |
+
return Err(SchemeError::BadMagic(magic));
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
let version = SchemeVersion::from_byte(data[4]);
|
| 123 |
+
let expected_payload = version.payload_size();
|
| 124 |
+
let actual_payload = data.len() - SCHEME_HEADER_SIZE;
|
| 125 |
+
|
| 126 |
+
if actual_payload != expected_payload {
|
| 127 |
+
return Err(SchemeError::PayloadMismatch {
|
| 128 |
+
expected: expected_payload,
|
| 129 |
+
got: actual_payload,
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
Ok(SchemeFile {
|
| 134 |
+
version,
|
| 135 |
+
payload: data[SCHEME_HEADER_SIZE..].to_vec(),
|
| 136 |
+
})
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/// Serialize back to .wsc format.
|
| 140 |
+
pub fn to_bytes(&self) -> Vec<u8> {
|
| 141 |
+
let mut buf = Vec::with_capacity(SCHEME_HEADER_SIZE + self.payload.len());
|
| 142 |
+
buf.extend_from_slice(&SCHEME_MAGIC);
|
| 143 |
+
buf.push(self.version.to_byte());
|
| 144 |
+
buf.extend_from_slice(&self.payload);
|
| 145 |
+
buf
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/// Total file size (header + payload).
|
| 149 |
+
pub fn file_size(&self) -> usize {
|
| 150 |
+
SCHEME_HEADER_SIZE + self.payload.len()
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
impl SchemeFile {
|
| 155 |
+
/// Load a scheme file from disk.
|
| 156 |
+
pub fn from_file(path: &std::path::Path) -> Result<Self, SchemeFileError> {
|
| 157 |
+
let data = std::fs::read(path).map_err(SchemeFileError::Io)?;
|
| 158 |
+
Self::from_bytes(&data).map_err(SchemeFileError::Parse)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/// Save a scheme file to disk.
|
| 162 |
+
pub fn to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
|
| 163 |
+
std::fs::write(path, self.to_bytes())
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/// Error type for file-based scheme operations.
|
| 168 |
+
#[derive(Debug)]
|
| 169 |
+
pub enum SchemeFileError {
|
| 170 |
+
Io(std::io::Error),
|
| 171 |
+
Parse(SchemeError),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
impl core::fmt::Display for SchemeFileError {
|
| 175 |
+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
| 176 |
+
match self {
|
| 177 |
+
SchemeFileError::Io(e) => write!(f, "I/O error: {e}"),
|
| 178 |
+
SchemeFileError::Parse(e) => write!(f, "parse error: {e}"),
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
#[cfg(test)]
|
| 184 |
+
mod tests {
|
| 185 |
+
use super::*;
|
| 186 |
+
|
| 187 |
+
#[test]
|
| 188 |
+
fn version_roundtrip() {
|
| 189 |
+
for v in [SchemeVersion::V1, SchemeVersion::V2, SchemeVersion::Extended(5)] {
|
| 190 |
+
assert_eq!(SchemeVersion::from_byte(v.to_byte()), v);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
#[test]
|
| 195 |
+
fn payload_sizes() {
|
| 196 |
+
assert_eq!(SchemeVersion::V1.payload_size(), 0xD8);
|
| 197 |
+
assert_eq!(SchemeVersion::V2.payload_size(), 0x124);
|
| 198 |
+
assert_eq!(SchemeVersion::Extended(3).payload_size(), 3 + 0x124);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
#[test]
|
| 202 |
+
fn parse_v1_synthetic() {
|
| 203 |
+
let mut data = Vec::new();
|
| 204 |
+
data.extend_from_slice(b"SCHM");
|
| 205 |
+
data.push(0x01);
|
| 206 |
+
data.extend_from_slice(&[0xAA; SCHEME_PAYLOAD_V1]);
|
| 207 |
+
assert_eq!(data.len(), 221);
|
| 208 |
+
|
| 209 |
+
let scheme = SchemeFile::from_bytes(&data).unwrap();
|
| 210 |
+
assert_eq!(scheme.version, SchemeVersion::V1);
|
| 211 |
+
assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V1);
|
| 212 |
+
assert!(scheme.payload.iter().all(|&b| b == 0xAA));
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
#[test]
|
| 216 |
+
fn parse_v2_synthetic() {
|
| 217 |
+
let mut data = Vec::new();
|
| 218 |
+
data.extend_from_slice(b"SCHM");
|
| 219 |
+
data.push(0x02);
|
| 220 |
+
data.extend_from_slice(&[0xBB; SCHEME_PAYLOAD_V2]);
|
| 221 |
+
assert_eq!(data.len(), 297);
|
| 222 |
+
|
| 223 |
+
let scheme = SchemeFile::from_bytes(&data).unwrap();
|
| 224 |
+
assert_eq!(scheme.version, SchemeVersion::V2);
|
| 225 |
+
assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V2);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
#[test]
|
| 229 |
+
fn roundtrip_synthetic() {
|
| 230 |
+
let mut data = Vec::new();
|
| 231 |
+
data.extend_from_slice(b"SCHM");
|
| 232 |
+
data.push(0x01);
|
| 233 |
+
data.extend_from_slice(&[0x42; SCHEME_PAYLOAD_V1]);
|
| 234 |
+
|
| 235 |
+
let scheme = SchemeFile::from_bytes(&data).unwrap();
|
| 236 |
+
assert_eq!(scheme.to_bytes(), data);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
#[test]
|
| 240 |
+
fn error_too_short() {
|
| 241 |
+
assert_eq!(
|
| 242 |
+
SchemeFile::from_bytes(b"SCH"),
|
| 243 |
+
Err(SchemeError::TooShort { len: 3 })
|
| 244 |
+
);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
#[test]
|
| 248 |
+
fn error_bad_magic() {
|
| 249 |
+
let mut data = vec![b'N', b'O', b'P', b'E', 0x01];
|
| 250 |
+
data.extend_from_slice(&[0; SCHEME_PAYLOAD_V1]);
|
| 251 |
+
assert!(matches!(
|
| 252 |
+
SchemeFile::from_bytes(&data),
|
| 253 |
+
Err(SchemeError::BadMagic([b'N', b'O', b'P', b'E']))
|
| 254 |
+
));
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
#[test]
|
| 258 |
+
fn error_payload_mismatch() {
|
| 259 |
+
// v1 header but only 10 bytes of payload
|
| 260 |
+
let mut data = Vec::new();
|
| 261 |
+
data.extend_from_slice(b"SCHM");
|
| 262 |
+
data.push(0x01);
|
| 263 |
+
data.extend_from_slice(&[0; 10]);
|
| 264 |
+
|
| 265 |
+
assert!(matches!(
|
| 266 |
+
SchemeFile::from_bytes(&data),
|
| 267 |
+
Err(SchemeError::PayloadMismatch { expected: 0xD8, got: 10 })
|
| 268 |
+
));
|
| 269 |
+
}
|
| 270 |
+
}
|
crates/openwa-types/tests/fixtures/beginner.wsc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:632057e77927f0a3fd506963015795de622244aabea78d70bd3e28bab888a10f
|
| 3 |
+
size 297
|
crates/openwa-types/tests/fixtures/classic.wsc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:12980f196d85c7acc0284d34935c998a01296ca4b28439de83d5cf2d5820f6cb
|
| 3 |
+
size 221
|
crates/openwa-types/tests/fixtures/shopping.wsc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ea83352929ad768d506238f32ea58da4ab6ac210a751db99b404002fb3617fd0
|
| 3 |
+
size 221
|
crates/openwa-types/tests/scheme_parse.rs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 7 |
+
#[test]
|
| 8 |
+
fn parse_beginner_v2() {
|
| 9 |
+
let path = Path::new(FIXTURES).join("beginner.wsc");
|
| 10 |
+
let scheme = SchemeFile::from_file(&path).expect("failed to parse beginner.wsc");
|
| 11 |
+
assert_eq!(scheme.version, SchemeVersion::V2);
|
| 12 |
+
assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V2);
|
| 13 |
+
assert_eq!(scheme.file_size(), 297);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
#[test]
|
| 17 |
+
fn parse_classic_v1() {
|
| 18 |
+
let path = Path::new(FIXTURES).join("classic.wsc");
|
| 19 |
+
let scheme = SchemeFile::from_file(&path).expect("failed to parse classic.wsc");
|
| 20 |
+
assert_eq!(scheme.version, SchemeVersion::V1);
|
| 21 |
+
assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V1);
|
| 22 |
+
assert_eq!(scheme.file_size(), 221);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
#[test]
|
| 26 |
+
fn parse_shopping_v1() {
|
| 27 |
+
let path = Path::new(FIXTURES).join("shopping.wsc");
|
| 28 |
+
let scheme = SchemeFile::from_file(&path).expect("failed to parse shopping.wsc");
|
| 29 |
+
assert_eq!(scheme.version, SchemeVersion::V1);
|
| 30 |
+
assert_eq!(scheme.payload.len(), SCHEME_PAYLOAD_V1);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
#[test]
|
| 34 |
+
fn roundtrip_beginner() {
|
| 35 |
+
let path = Path::new(FIXTURES).join("beginner.wsc");
|
| 36 |
+
let original = std::fs::read(&path).unwrap();
|
| 37 |
+
let scheme = SchemeFile::from_bytes(&original).unwrap();
|
| 38 |
+
assert_eq!(scheme.to_bytes(), original);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#[test]
|
| 42 |
+
fn roundtrip_classic() {
|
| 43 |
+
let path = Path::new(FIXTURES).join("classic.wsc");
|
| 44 |
+
let original = std::fs::read(&path).unwrap();
|
| 45 |
+
let scheme = SchemeFile::from_bytes(&original).unwrap();
|
| 46 |
+
assert_eq!(scheme.to_bytes(), original);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
#[test]
|
| 50 |
+
fn roundtrip_shopping() {
|
| 51 |
+
let path = Path::new(FIXTURES).join("shopping.wsc");
|
| 52 |
+
let original = std::fs::read(&path).unwrap();
|
| 53 |
+
let scheme = SchemeFile::from_bytes(&original).unwrap();
|
| 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() {
|
| 60 |
+
let schemes_dir = Path::new("I:/games/SteamLibrary/steamapps/common/Worms Armageddon/User/Schemes");
|
| 61 |
+
if !schemes_dir.exists() {
|
| 62 |
+
eprintln!("Skipping: game schemes directory not found");
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
let mut count = 0;
|
| 67 |
+
for entry in std::fs::read_dir(schemes_dir).unwrap() {
|
| 68 |
+
let entry = entry.unwrap();
|
| 69 |
+
let path = entry.path();
|
| 70 |
+
if path.extension().map_or(false, |e| e == "wsc") {
|
| 71 |
+
let scheme = SchemeFile::from_file(&path)
|
| 72 |
+
.unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
|
| 73 |
+
|
| 74 |
+
// Roundtrip check
|
| 75 |
+
let original = std::fs::read(&path).unwrap();
|
| 76 |
+
assert_eq!(
|
| 77 |
+
scheme.to_bytes(),
|
| 78 |
+
original,
|
| 79 |
+
"roundtrip failed for {}",
|
| 80 |
+
path.display()
|
| 81 |
+
);
|
| 82 |
+
count += 1;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
eprintln!("Successfully parsed and round-tripped {count} scheme files");
|
| 86 |
+
}
|
crates/openwa-wormkit/src/replacements/mod.rs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
mod frontend;
|
|
|
|
| 2 |
|
| 3 |
pub fn install_all() -> Result<(), String> {
|
| 4 |
frontend::install()?;
|
|
|
|
| 5 |
Ok(())
|
| 6 |
}
|
|
|
|
| 1 |
mod frontend;
|
| 2 |
+
mod scheme;
|
| 3 |
|
| 4 |
pub fn install_all() -> Result<(), String> {
|
| 5 |
frontend::install()?;
|
| 6 |
+
scheme::install()?;
|
| 7 |
Ok(())
|
| 8 |
}
|
crates/openwa-wormkit/src/replacements/scheme.rs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Scheme file (.wsc) hook.
|
| 2 |
+
//!
|
| 3 |
+
//! Hooks Scheme__ReadFile (0x4D3890) to log when schemes are loaded.
|
| 4 |
+
//! Currently a passthrough — calls the original and logs the result.
|
| 5 |
+
//! Future: replace the file I/O with our Rust SchemeFile parser.
|
| 6 |
+
|
| 7 |
+
use std::ffi::c_void;
|
| 8 |
+
use std::sync::atomic::{AtomicU32, Ordering};
|
| 9 |
+
|
| 10 |
+
use minhook::MinHook;
|
| 11 |
+
|
| 12 |
+
use crate::log_line;
|
| 13 |
+
use crate::rebase::rb;
|
| 14 |
+
use openwa_types::address::va;
|
| 15 |
+
|
| 16 |
+
/// Trampoline to original Scheme__ReadFile.
|
| 17 |
+
static ORIG_SCHEME_READ_FILE: AtomicU32 = AtomicU32::new(0);
|
| 18 |
+
|
| 19 |
+
/// Hook for Scheme__ReadFile (0x4D3890).
|
| 20 |
+
/// stdcall(dest_struct, file_path, flag, out_ptr) -> u32, RET 0x10
|
| 21 |
+
///
|
| 22 |
+
/// - dest_struct: pointer to destination scheme struct (payload written to +0x14)
|
| 23 |
+
/// - file_path: scheme file path string
|
| 24 |
+
/// - flag: boolean flag (passed to error display)
|
| 25 |
+
/// - out_ptr: optional output byte pointer
|
| 26 |
+
/// - returns: 0 on success, 0xFFFFFFFF on failure
|
| 27 |
+
unsafe extern "stdcall" fn hook_scheme_read_file(
|
| 28 |
+
dest_struct: u32,
|
| 29 |
+
file_path: u32,
|
| 30 |
+
flag: u32,
|
| 31 |
+
out_ptr: u32,
|
| 32 |
+
) -> u32 {
|
| 33 |
+
// Read the scheme name from the file_path pointer
|
| 34 |
+
let name = if file_path != 0 {
|
| 35 |
+
let cstr = std::ffi::CStr::from_ptr(file_path as *const i8);
|
| 36 |
+
cstr.to_string_lossy().into_owned()
|
| 37 |
+
} else {
|
| 38 |
+
"<null>".to_string()
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
// Call original
|
| 42 |
+
let orig: unsafe extern "stdcall" fn(u32, u32, u32, u32) -> u32 =
|
| 43 |
+
core::mem::transmute(ORIG_SCHEME_READ_FILE.load(Ordering::Relaxed));
|
| 44 |
+
let result = orig(dest_struct, file_path, flag, out_ptr);
|
| 45 |
+
|
| 46 |
+
// Log the result
|
| 47 |
+
if result == 0 {
|
| 48 |
+
// Read the version byte from dest_struct+0x14 (first 5 bytes are the header)
|
| 49 |
+
// Actually the header is consumed during read; the payload starts at +0x14
|
| 50 |
+
let _ = log_line(&format!(
|
| 51 |
+
"[Scheme] Loaded: {name} -> dest=0x{dest_struct:08X}"
|
| 52 |
+
));
|
| 53 |
+
} else {
|
| 54 |
+
let _ = log_line(&format!(
|
| 55 |
+
"[Scheme] FAILED to load: {name} (result=0x{result:08X})"
|
| 56 |
+
));
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
result
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
pub fn install() -> Result<(), String> {
|
| 63 |
+
unsafe {
|
| 64 |
+
let target = rb(va::SCHEME_READ_FILE) as *mut c_void;
|
| 65 |
+
let detour = hook_scheme_read_file as *const () as *mut c_void;
|
| 66 |
+
|
| 67 |
+
let trampoline = MinHook::create_hook(target, detour)
|
| 68 |
+
.map_err(|e| format!("MinHook create_hook failed for Scheme__ReadFile: {e}"))?;
|
| 69 |
+
|
| 70 |
+
MinHook::enable_hook(target)
|
| 71 |
+
.map_err(|e| format!("MinHook enable_hook failed for Scheme__ReadFile: {e}"))?;
|
| 72 |
+
|
| 73 |
+
ORIG_SCHEME_READ_FILE.store(trampoline as u32, Ordering::Relaxed);
|
| 74 |
+
|
| 75 |
+
let _ = log_line(&format!(
|
| 76 |
+
" [HOOK] Scheme__ReadFile: target 0x{:08X}, trampoline 0x{:08X}",
|
| 77 |
+
target as u32, trampoline as u32
|
| 78 |
+
));
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
Ok(())
|
| 82 |
+
}
|