paavohtl Claude Opus 4.6 (1M context) commited on
Commit
1a163b2
Β·
1 Parent(s): cde9dcb

feat: add #[vtable(...)] attribute macro for typed vtable definitions

Browse files

Add a proc macro that transforms sparse vtable slot definitions into
full #[repr(C)] structs with auto-filled gap slots, registry metadata,
calling wrappers, and optional address registration. Key features:

- #[slot(N)] for sparse vtables (gaps auto-filled with usize)
- Sequential slot inference when #[slot] is omitted (fully-typed vtables)
- fn(...) auto-normalized to unsafe extern "thiscall" fn(...)
- Named params (fn(this: *mut T, mode: u32)) flow through to bind wrappers
- bind_XxxVtable! companion macro generates method wrappers on class structs
- VtableInfo/VtableSlotEntry registry with inventory integration
- vtable_replace! macro for type-safe in-place vtable slot patching
- Compile-time size assertion and optional pub const + AddrEntry generation

All 8 vtable structs migrated: PaletteVtable, InputCtrlVtable,
MapViewVtable, DDDisplayVtable (38β†’18 lines), SoundEmitterVTable,
CTaskWormVTable, DisplayBaseVtable, DSSoundVtable.

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

crates/openwa-core/src/address.rs CHANGED
@@ -61,6 +61,15 @@ pub mod va {
61
  pub use crate::task::filter::{CTASK_FILTER_VTABLE, CTASK_FILTER_CTOR};
62
  pub use crate::task::fire::{CTASK_FIRE_VTABLE, CTASK_FIRE_CTOR};
63
 
 
 
 
 
 
 
 
 
 
64
  crate::define_addresses! {
65
  class "DDGameWrapper" {
66
  /// DDGameWrapper vtable
@@ -123,8 +132,7 @@ pub mod va {
123
  }
124
 
125
  class "DSSound" {
126
- /// DSSound vtable
127
- vtable DS_SOUND_VTABLE = 0x0066_AF20;
128
  /// DSSound constructor β€” usercall(EAX=this), plain RET
129
  ctor/Usercall CONSTRUCT_DS_SOUND = 0x0057_3D50;
130
  /// DSSound init buffers β€” usercall(EAX=dssound), plain RET
@@ -144,14 +152,10 @@ pub mod va {
144
  fn/Stdcall DDKEYBOARD_POLL_KEYBOARD_STATE = 0x0057_2290;
145
  }
146
 
147
- class "Palette" {
148
- /// Palette vtable (0x28-byte palette object)
149
- vtable PALETTE_VTABLE_MAYBE = 0x0066_A2E4;
150
- }
151
 
152
  class "DisplayBase" {
153
- /// DisplayBase primary vtable (set by constructor, has _purecall slots)
154
- vtable DISPLAY_BASE_VTABLE = 0x0066_45F8;
155
  /// DisplayBase headless vtable
156
  vtable DISPLAY_BASE_HEADLESS_VTABLE = 0x0066_A0F8;
157
  /// DisplayBase constructor (0x3560-byte object)
@@ -159,8 +163,7 @@ pub mod va {
159
  }
160
 
161
  class "InputCtrl" {
162
- /// Input controller vtable (0x1800-byte object)
163
- vtable INPUT_CTRL_VTABLE = 0x0066_B3FC;
164
  /// Input controller initializer
165
  fn/Usercall INPUT_CTRL_INIT = 0x0058_C0D0;
166
  }
 
61
  pub use crate::task::filter::{CTASK_FILTER_VTABLE, CTASK_FILTER_CTOR};
62
  pub use crate::task::fire::{CTASK_FIRE_VTABLE, CTASK_FIRE_CTOR};
63
 
64
+ // Re-exported from modules using #[vtable(...)] attribute
65
+ pub use crate::display::palette::PALETTE_VTABLE;
66
+ pub use crate::display::dd_display::DD_DISPLAY_VTABLE;
67
+ pub use crate::input::controller::INPUT_CTRL_VTABLE;
68
+ pub use crate::frontend::map_view::MAP_VIEW_VTABLE;
69
+ pub use crate::task::game_task::SOUND_EMITTER_VTABLE;
70
+ pub use crate::display::base::DISPLAY_BASE_VTABLE;
71
+ pub use crate::audio::dssound::DS_SOUND_VTABLE;
72
+
73
  crate::define_addresses! {
74
  class "DDGameWrapper" {
75
  /// DDGameWrapper vtable
 
132
  }
133
 
134
  class "DSSound" {
135
+ // Vtable now defined via #[vtable(...)] in audio/dssound.rs
 
136
  /// DSSound constructor β€” usercall(EAX=this), plain RET
137
  ctor/Usercall CONSTRUCT_DS_SOUND = 0x0057_3D50;
138
  /// DSSound init buffers β€” usercall(EAX=dssound), plain RET
 
152
  fn/Stdcall DDKEYBOARD_POLL_KEYBOARD_STATE = 0x0057_2290;
153
  }
154
 
155
+ // Palette vtable is now defined via #[derive(Vtable)] in display/palette.rs
 
 
 
156
 
157
  class "DisplayBase" {
158
+ // Primary vtable now defined via #[vtable(...)] in display/base.rs
 
159
  /// DisplayBase headless vtable
160
  vtable DISPLAY_BASE_HEADLESS_VTABLE = 0x0066_A0F8;
161
  /// DisplayBase constructor (0x3560-byte object)
 
163
  }
164
 
165
  class "InputCtrl" {
166
+ // Vtable now defined via #[vtable(...)] in input/controller.rs
 
167
  /// Input controller initializer
168
  fn/Usercall INPUT_CTRL_INIT = 0x0058_C0D0;
169
  }
crates/openwa-core/src/audio/dssound.rs CHANGED
@@ -77,61 +77,58 @@ impl ChannelDescriptor {
77
  ///
78
  /// Methods operate on the 8 channel descriptors and 64-entry buffer pool.
79
  /// Trivial slots (stubs, noops, return-constant) are marked.
80
- #[repr(C)]
81
  pub struct DSSoundVtable {
82
- /// Slot 0 (0x573DB0): destructor β€” thiscall(this, flags)
83
- pub destructor: unsafe extern "thiscall" fn(*mut DSSound, u8) -> *mut DSSound,
84
- /// Slot 1 (0x574400): update_channels β€” iterates 8 descs, releases finished buffers
85
- pub update_channels: unsafe extern "thiscall" fn(*mut DSSound),
86
- /// Slot 2 (0x574460): set_volume_params β€” sets status_1/2, adjusts channel volumes
87
- pub set_volume_params: unsafe extern "thiscall" fn(*mut DSSound, u32, i32),
88
- /// Slot 3 (0x574730): play_sound β€” wrapper around core play, returns bool. RET 0x14 = 5 stack params.
89
- pub play_sound: unsafe extern "thiscall" fn(*mut DSSound, u32, u32, u32, u32, u32) -> bool,
90
- /// Slot 4 (0x574770): play_sound_pooled β€” allocates from buffer pool, plays. RET 0x14 = 5 stack params.
91
- pub play_sound_pooled:
92
- unsafe extern "thiscall" fn(*mut DSSound, u32, u32, u32, u32, u32) -> i32,
93
- /// Slot 5 (0x574900): set_pan β€” sets pan on channel (dB lookup)
94
- pub set_pan: unsafe extern "thiscall" fn(*mut DSSound, u32, u32) -> u32,
95
- /// Slot 6 (0x505430): **stub** β€” returns 0
96
- pub stub_6: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
97
- /// Slot 7 (0x574A10): set_master_volume β€” sets volume, adjusts all channels
98
- pub set_master_volume: unsafe extern "thiscall" fn(*mut DSSound, i32) -> u32,
99
- /// Slot 8 (0x574980): set_channel_volume β€” volume on specific channel
100
- pub set_channel_volume: unsafe extern "thiscall" fn(*mut DSSound, i32, i32) -> u32,
101
- /// Slot 9 (0x5747F0): is_channel_finished β€” returns 1 if stopped, 0 if playing
102
- pub is_channel_finished: unsafe extern "thiscall" fn(*mut DSSound, i32) -> u8,
103
- /// Slot 10 (0x574840): stop_channel β€” stops + releases buffer, returns to pool
104
- pub stop_channel: unsafe extern "thiscall" fn(*mut DSSound, i32) -> u32,
105
- /// Slot 11 (0x574AB0): release_finished β€” releases finished buffers, returns count
106
- pub release_finished: unsafe extern "thiscall" fn(*mut DSSound) -> i32,
107
- /// Slot 12 (0x573FF0): load_wav β€” opens WAV file, parses RIFF, creates buffer
108
- pub load_wav: unsafe extern "thiscall" fn(*mut DSSound, i32, *const u8) -> u32,
109
- /// Slot 13 (0x573FD0): is_slot_loaded β€” returns channel_slots[idx] != 0
110
- pub is_slot_loaded: unsafe extern "thiscall" fn(*mut DSSound, i32) -> bool,
111
- /// Slot 14 (0x573D30): sub_destructor β€” sets secondary vtable
112
- pub sub_destructor: unsafe extern "thiscall" fn(*mut DSSound, u8) -> *mut DSSound,
113
- /// Slot 15 (0x4AA060): **noop** β€” returns void
114
- pub noop_15: unsafe extern "thiscall" fn(*mut DSSound),
115
- /// Slot 16 (0x5931C0): **noop** β€” returns void
116
- pub noop_16: unsafe extern "thiscall" fn(*mut DSSound),
117
- /// Slot 17 (0x573D20): **returns_0**
118
- pub returns_0_17: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
119
- /// Slot 18 (0x573D20): **returns_0** (same as 17)
120
- pub returns_0_18: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
121
- /// Slot 19 (0x505430): **stub** β€” returns 0
122
- pub stub_19: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
123
- /// Slot 20 (0x505430): **stub** β€” returns 0
124
- pub stub_20: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
125
- /// Slot 21 (0x571AF0): **returns_0**
126
- pub returns_0_21: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
127
- /// Slot 22 (0x505430): **stub** β€” returns 0
128
- pub stub_22: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
129
- /// Slot 23 (0x4260E0): **returns_1**
130
- pub returns_1_23: unsafe extern "thiscall" fn(*mut DSSound) -> u32,
131
  }
132
 
133
- const _: () = assert!(core::mem::size_of::<DSSoundVtable>() == 24 * 4);
134
-
135
  #[repr(C)]
136
  pub struct DSSound {
137
  /// 0x000: Vtable pointer (0x66AF20)
 
77
  ///
78
  /// Methods operate on the 8 channel descriptors and 64-entry buffer pool.
79
  /// Trivial slots (stubs, noops, return-constant) are marked.
80
+ #[openwa_core::vtable(size = 24, va = 0x0066_AF20, class = "DSSound")]
81
  pub struct DSSoundVtable {
82
+ /// destructor
83
+ pub destructor: fn(this: *mut DSSound, flags: u8) -> *mut DSSound,
84
+ /// update_channels β€” iterates 8 descs, releases finished buffers
85
+ pub update_channels: fn(this: *mut DSSound),
86
+ /// set_volume_params β€” sets status_1/2, adjusts channel volumes
87
+ pub set_volume_params: fn(this: *mut DSSound, status: u32, value: i32),
88
+ /// play_sound β€” wrapper around core play. RET 0x14 = 5 stack params.
89
+ pub play_sound: fn(this: *mut DSSound, slot: u32, flags: u32, volume: u32, pan: u32, freq: u32) -> bool,
90
+ /// play_sound_pooled β€” allocates from buffer pool, plays. RET 0x14.
91
+ pub play_sound_pooled: fn(this: *mut DSSound, slot: u32, flags: u32, volume: u32, pan: u32, freq: u32) -> i32,
92
+ /// set_pan β€” sets pan on channel (dB lookup)
93
+ pub set_pan: fn(this: *mut DSSound, channel: u32, pan: u32) -> u32,
94
+ /// **stub** β€” returns 0
95
+ pub stub_6: fn(this: *mut DSSound) -> u32,
96
+ /// set_master_volume β€” sets volume, adjusts all channels
97
+ pub set_master_volume: fn(this: *mut DSSound, volume: i32) -> u32,
98
+ /// set_channel_volume β€” volume on specific channel
99
+ pub set_channel_volume: fn(this: *mut DSSound, channel: i32, volume: i32) -> u32,
100
+ /// is_channel_finished β€” returns 1 if stopped, 0 if playing
101
+ pub is_channel_finished: fn(this: *mut DSSound, channel: i32) -> u8,
102
+ /// stop_channel β€” stops + releases buffer, returns to pool
103
+ pub stop_channel: fn(this: *mut DSSound, channel: i32) -> u32,
104
+ /// release_finished β€” releases finished buffers, returns count
105
+ pub release_finished: fn(this: *mut DSSound) -> i32,
106
+ /// load_wav β€” opens WAV file, parses RIFF, creates buffer
107
+ pub load_wav: fn(this: *mut DSSound, slot: i32, path: *const u8) -> u32,
108
+ /// is_slot_loaded β€” returns channel_slots[idx] != 0
109
+ pub is_slot_loaded: fn(this: *mut DSSound, slot: i32) -> bool,
110
+ /// sub_destructor β€” sets secondary vtable
111
+ pub sub_destructor: fn(this: *mut DSSound, flags: u8) -> *mut DSSound,
112
+ /// **noop**
113
+ pub noop_15: fn(this: *mut DSSound),
114
+ /// **noop**
115
+ pub noop_16: fn(this: *mut DSSound),
116
+ /// **returns_0**
117
+ pub returns_0_17: fn(this: *mut DSSound) -> u32,
118
+ /// **returns_0** (same as 17)
119
+ pub returns_0_18: fn(this: *mut DSSound) -> u32,
120
+ /// **stub** β€” returns 0
121
+ pub stub_19: fn(this: *mut DSSound) -> u32,
122
+ /// **stub** β€” returns 0
123
+ pub stub_20: fn(this: *mut DSSound) -> u32,
124
+ /// **returns_0**
125
+ pub returns_0_21: fn(this: *mut DSSound) -> u32,
126
+ /// **stub** β€” returns 0
127
+ pub stub_22: fn(this: *mut DSSound) -> u32,
128
+ /// **returns_1**
129
+ pub returns_1_23: fn(this: *mut DSSound) -> u32,
 
130
  }
131
 
 
 
132
  #[repr(C)]
133
  pub struct DSSound {
134
  /// 0x000: Vtable pointer (0x66AF20)
crates/openwa-core/src/display/base.rs CHANGED
@@ -62,47 +62,43 @@ const _: () = assert!(core::mem::size_of::<DisplayBase>() == 0x3560);
62
  ///
63
  /// In headless mode, most slots are no-op stubs. The primary vtable (0x6645F8)
64
  /// has _purecall for the drawing slots; the headless overlay replaces them.
65
- #[repr(C)]
66
  pub struct DisplayBaseVtable {
67
- /// Slot 0: destructor β€” thiscall(this, flags)
68
- pub destructor: unsafe extern "thiscall" fn(*mut DisplayBase, u8) -> *mut DisplayBase,
69
- /// Slot 1: thiscall(this)
70
- pub slot_01: unsafe extern "thiscall" fn(*mut DisplayBase),
71
- /// Slot 2-3: thiscall(this) β€” identical in headless (0x4AA060)
72
- pub slot_02: unsafe extern "thiscall" fn(*mut DisplayBase),
73
- pub slot_03: unsafe extern "thiscall" fn(*mut DisplayBase),
74
- pub slot_04: unsafe extern "thiscall" fn(*mut DisplayBase),
75
- pub slot_05: unsafe extern "thiscall" fn(*mut DisplayBase),
76
- pub slot_06: unsafe extern "thiscall" fn(*mut DisplayBase),
77
- pub slot_07: unsafe extern "thiscall" fn(*mut DisplayBase),
78
- pub slot_08: unsafe extern "thiscall" fn(*mut DisplayBase),
79
- pub slot_09: unsafe extern "thiscall" fn(*mut DisplayBase),
80
- pub slot_10: unsafe extern "thiscall" fn(*mut DisplayBase),
81
- pub slot_11: unsafe extern "thiscall" fn(*mut DisplayBase),
82
- pub slot_12: unsafe extern "thiscall" fn(*mut DisplayBase),
83
- pub slot_13: unsafe extern "thiscall" fn(*mut DisplayBase),
84
- pub slot_14: unsafe extern "thiscall" fn(*mut DisplayBase),
85
- pub slot_15: unsafe extern "thiscall" fn(*mut DisplayBase),
86
- pub slot_16: unsafe extern "thiscall" fn(*mut DisplayBase),
87
- pub slot_17: unsafe extern "thiscall" fn(*mut DisplayBase),
88
- pub slot_18: unsafe extern "thiscall" fn(*mut DisplayBase),
89
- pub slot_19: unsafe extern "thiscall" fn(*mut DisplayBase),
90
- pub slot_20: unsafe extern "thiscall" fn(*mut DisplayBase),
91
- pub slot_21: unsafe extern "thiscall" fn(*mut DisplayBase),
92
- pub slot_22: unsafe extern "thiscall" fn(*mut DisplayBase),
93
- pub slot_23: unsafe extern "thiscall" fn(*mut DisplayBase),
94
- pub slot_24: unsafe extern "thiscall" fn(*mut DisplayBase),
95
- pub slot_25: unsafe extern "thiscall" fn(*mut DisplayBase),
96
- pub slot_26: unsafe extern "thiscall" fn(*mut DisplayBase),
97
- pub slot_27: unsafe extern "thiscall" fn(*mut DisplayBase),
98
- pub slot_28: unsafe extern "thiscall" fn(*mut DisplayBase),
99
- pub slot_29: unsafe extern "thiscall" fn(*mut DisplayBase),
100
- pub slot_30: unsafe extern "thiscall" fn(*mut DisplayBase),
101
- pub slot_31: unsafe extern "thiscall" fn(*mut DisplayBase),
102
  }
103
 
104
- const _: () = assert!(core::mem::size_of::<DisplayBaseVtable>() == 32 * 4);
105
-
106
  // ── Sprite cache sub-objects ──────────────────────────────────────────────
107
 
108
  /// Sprite buffer control block (0x3C bytes, inner).
 
62
  ///
63
  /// In headless mode, most slots are no-op stubs. The primary vtable (0x6645F8)
64
  /// has _purecall for the drawing slots; the headless overlay replaces them.
65
+ #[openwa_core::vtable(size = 32, va = 0x0066_45F8, class = "DisplayBase")]
66
  pub struct DisplayBaseVtable {
67
+ /// destructor
68
+ pub destructor: fn(this: *mut DisplayBase, flags: u8) -> *mut DisplayBase,
69
+ pub slot_01: fn(this: *mut DisplayBase),
70
+ pub slot_02: fn(this: *mut DisplayBase),
71
+ pub slot_03: fn(this: *mut DisplayBase),
72
+ pub slot_04: fn(this: *mut DisplayBase),
73
+ pub slot_05: fn(this: *mut DisplayBase),
74
+ pub slot_06: fn(this: *mut DisplayBase),
75
+ pub slot_07: fn(this: *mut DisplayBase),
76
+ pub slot_08: fn(this: *mut DisplayBase),
77
+ pub slot_09: fn(this: *mut DisplayBase),
78
+ pub slot_10: fn(this: *mut DisplayBase),
79
+ pub slot_11: fn(this: *mut DisplayBase),
80
+ pub slot_12: fn(this: *mut DisplayBase),
81
+ pub slot_13: fn(this: *mut DisplayBase),
82
+ pub slot_14: fn(this: *mut DisplayBase),
83
+ pub slot_15: fn(this: *mut DisplayBase),
84
+ pub slot_16: fn(this: *mut DisplayBase),
85
+ pub slot_17: fn(this: *mut DisplayBase),
86
+ pub slot_18: fn(this: *mut DisplayBase),
87
+ pub slot_19: fn(this: *mut DisplayBase),
88
+ pub slot_20: fn(this: *mut DisplayBase),
89
+ pub slot_21: fn(this: *mut DisplayBase),
90
+ pub slot_22: fn(this: *mut DisplayBase),
91
+ pub slot_23: fn(this: *mut DisplayBase),
92
+ pub slot_24: fn(this: *mut DisplayBase),
93
+ pub slot_25: fn(this: *mut DisplayBase),
94
+ pub slot_26: fn(this: *mut DisplayBase),
95
+ pub slot_27: fn(this: *mut DisplayBase),
96
+ pub slot_28: fn(this: *mut DisplayBase),
97
+ pub slot_29: fn(this: *mut DisplayBase),
98
+ pub slot_30: fn(this: *mut DisplayBase),
99
+ pub slot_31: fn(this: *mut DisplayBase),
 
 
100
  }
101
 
 
 
102
  // ── Sprite cache sub-objects ──────────────────────────────────────────────
103
 
104
  /// Sprite buffer control block (0x3C bytes, inner).
crates/openwa-core/src/display/dd_display.rs CHANGED
@@ -17,146 +17,25 @@ pub struct DDDisplay {
17
 
18
  /// DDDisplay vtable (0x66A218, 38 slots).
19
  ///
20
- /// Only actively-used slots have typed signatures. Unknown slots are `usize`.
21
- /// Follows the DSSoundVtable pattern: all slots defined, unknowns as stubs.
22
- #[repr(C)]
23
  pub struct DDDisplayVtable {
24
- /// Slot 0: destructor
25
- pub _slot_00: usize,
26
- /// Slot 1
27
- pub _slot_01: usize,
28
- /// Slot 2
29
- pub _slot_02: usize,
30
- /// Slot 3
31
- pub _slot_03: usize,
32
- /// Slot 4: set layer color β€” thiscall(this, layer, color)
33
- pub set_layer_color: unsafe extern "thiscall" fn(*mut DDDisplay, i32, i32),
34
- /// Slot 5: set active layer β€” thiscall(this, layer) -> layer context ptr
35
- pub set_active_layer: unsafe extern "thiscall" fn(*mut DDDisplay, i32) -> *mut u8,
36
- /// Slot 6
37
- pub _slot_06: usize,
38
- /// Slot 7
39
- pub _slot_07: usize,
40
- /// Slot 8
41
- pub _slot_08: usize,
42
- /// Slot 9
43
- pub _slot_09: usize,
44
- /// Slot 10
45
- pub _slot_10: usize,
46
- /// Slot 11
47
- pub _slot_11: usize,
48
- /// Slot 12
49
- pub _slot_12: usize,
50
- /// Slot 13
51
- pub _slot_13: usize,
52
- /// Slot 14
53
- pub _slot_14: usize,
54
- /// Slot 15
55
- pub _slot_15: usize,
56
- /// Slot 16
57
- pub _slot_16: usize,
58
- /// Slot 17
59
- pub _slot_17: usize,
60
- /// Slot 18
61
- pub _slot_18: usize,
62
- /// Slot 19
63
- pub _slot_19: usize,
64
- /// Slot 20
65
- pub _slot_20: usize,
66
- /// Slot 21
67
- pub _slot_21: usize,
68
- /// Slot 22
69
- pub _slot_22: usize,
70
- /// Slot 23 (0x5C): set layer visibility β€” thiscall(this, layer, value)
71
- pub set_layer_visibility: unsafe extern "thiscall" fn(*mut DDDisplay, i32, i32),
72
- /// Slot 24
73
- pub _slot_24: usize,
74
- /// Slot 25
75
- pub _slot_25: usize,
76
- /// Slot 26
77
- pub _slot_26: usize,
78
- /// Slot 27
79
- pub _slot_27: usize,
80
- /// Slot 28
81
- pub _slot_28: usize,
82
- /// Slot 29
83
- pub _slot_29: usize,
84
- /// Slot 30
85
- pub _slot_30: usize,
86
- /// Slot 31 (0x7C): load sprite with flag β€” thiscall(this, layer, id, flag, gfx, name), RET 0x14
87
- pub load_sprite: unsafe extern "thiscall" fn(
88
- *mut DDDisplay,
89
- u32,
90
- u32,
91
- u32,
92
- *mut u8,
93
- *const core::ffi::c_char,
94
- ) -> i32,
95
- /// Slot 32
96
- pub _slot_32: usize,
97
- /// Slot 33
98
- pub _slot_33: usize,
99
- /// Slot 34
100
- pub _slot_34: usize,
101
- /// Slot 35
102
- pub _slot_35: usize,
103
- /// Slot 36
104
- pub _slot_36: usize,
105
- /// Slot 37 (0x94): load sprite by layer β€” thiscall(this, layer, id, gfx, name), RET 0x10
106
- pub load_sprite_by_layer: unsafe extern "thiscall" fn(
107
- *mut DDDisplay,
108
- u32,
109
- u32,
110
- *mut u8,
111
- *const core::ffi::c_char,
112
- ) -> i32,
113
  }
114
 
115
- const _: () = assert!(core::mem::size_of::<DDDisplayVtable>() == 38 * 4);
116
-
117
- /// Typed wrappers for DDDisplay vtable calls.
118
- /// All take `*mut DDDisplay` since thiscall passes `this` as mutable.
119
- impl DDDisplay {
120
- /// Vtable slot 5: set active display layer, returns layer context pointer.
121
- #[inline]
122
- pub unsafe fn set_active_layer(this: *mut Self, layer: i32) -> *mut u8 {
123
- ((*(*this).vtable).set_active_layer)(this, layer)
124
- }
125
-
126
- /// Vtable slot 4: set color for a display layer.
127
- #[inline]
128
- pub unsafe fn set_layer_color(this: *mut Self, layer: i32, color: i32) {
129
- ((*(*this).vtable).set_layer_color)(this, layer, color)
130
- }
131
-
132
- /// Vtable slot 23: set layer visibility.
133
- #[inline]
134
- pub unsafe fn set_layer_visibility(this: *mut Self, layer: i32, value: i32) {
135
- ((*(*this).vtable).set_layer_visibility)(this, layer, value)
136
- }
137
-
138
- /// Vtable slot 37: load sprite into a layer by name.
139
- #[inline]
140
- pub unsafe fn load_sprite_by_layer(
141
- this: *mut Self,
142
- layer: u32,
143
- id: u32,
144
- gfx: *mut u8,
145
- name: *const core::ffi::c_char,
146
- ) -> i32 {
147
- ((*(*this).vtable).load_sprite_by_layer)(this, layer, id, gfx, name)
148
- }
149
-
150
- /// Vtable slot 31: load sprite with flag.
151
- #[inline]
152
- pub unsafe fn load_sprite(
153
- this: *mut Self,
154
- layer: u32,
155
- id: u32,
156
- flag: u32,
157
- gfx: *mut u8,
158
- name: *const core::ffi::c_char,
159
- ) -> i32 {
160
- ((*(*this).vtable).load_sprite)(this, layer, id, flag, gfx, name)
161
- }
162
- }
 
17
 
18
  /// DDDisplay vtable (0x66A218, 38 slots).
19
  ///
20
+ /// Only actively-used slots have typed signatures. Unknown slots are auto-filled.
21
+ #[openwa_core::vtable(size = 38, va = 0x0066_A218, class = "DDDisplay")]
 
22
  pub struct DDDisplayVtable {
23
+ /// set layer color
24
+ #[slot(4)]
25
+ pub set_layer_color: fn(this: *mut DDDisplay, layer: i32, color: i32),
26
+ /// set active layer, returns layer context ptr
27
+ #[slot(5)]
28
+ pub set_active_layer: fn(this: *mut DDDisplay, layer: i32) -> *mut u8,
29
+ /// set layer visibility
30
+ #[slot(23)]
31
+ pub set_layer_visibility: fn(this: *mut DDDisplay, layer: i32, value: i32),
32
+ /// load sprite with flag (RET 0x14)
33
+ #[slot(31)]
34
+ pub load_sprite: fn(this: *mut DDDisplay, layer: u32, id: u32, flag: u32, gfx: *mut u8, name: *const core::ffi::c_char) -> i32,
35
+ /// load sprite by layer (RET 0x10)
36
+ #[slot(37)]
37
+ pub load_sprite_by_layer: fn(this: *mut DDDisplay, layer: u32, id: u32, gfx: *mut u8, name: *const core::ffi::c_char) -> i32,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
39
 
40
+ // Generate calling wrappers: DDDisplay::set_layer_color(), etc.
41
+ bind_DDDisplayVtable!(DDDisplay, vtable);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
crates/openwa-core/src/display/palette.rs CHANGED
@@ -1,3 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /// Palette β€” palette management object.
2
  ///
3
  /// Vtable: 0x66A2E4.
@@ -17,23 +34,8 @@ pub struct Palette {
17
 
18
  const _: () = assert!(core::mem::size_of::<Palette>() == 0x28);
19
 
20
- /// Vtable for Palette (0x66A2E4).
21
- ///
22
- /// Slots 2-4 are called by GameEngine__InitHardware after DDGameWrapper construction.
23
- /// Slots 0-1 are unknown.
24
- #[repr(C)]
25
- pub struct PaletteVtable {
26
- /// [0]: Unknown
27
- pub _slot_0: usize,
28
- /// [1]: Unknown
29
- pub _slot_1: usize,
30
- /// [2]: set_mode(this, mode) β€” called with mode=7 during hardware init
31
- pub set_mode: unsafe extern "thiscall" fn(*mut Palette, u32),
32
- /// [3]: init(this) β€” called during hardware init
33
- pub init: unsafe extern "thiscall" fn(*mut Palette),
34
- /// [4]: reset(this) β€” called first during hardware init
35
- pub reset: unsafe extern "thiscall" fn(*mut Palette),
36
- }
37
 
38
  impl Palette {
39
  /// Create a new Palette with inline construction (no native C++ ctor).
@@ -47,17 +49,4 @@ impl Palette {
47
  _unknown_008: [0; 0x20],
48
  }
49
  }
50
-
51
- /// Vtable[4]: Reset palette state.
52
- pub unsafe fn reset(&mut self) {
53
- vcall!(self, reset)
54
- }
55
- /// Vtable[3]: Initialize palette.
56
- pub unsafe fn init(&mut self) {
57
- vcall!(self, init)
58
- }
59
- /// Vtable[2]: Set palette mode.
60
- pub unsafe fn set_mode(&mut self, mode: u32) {
61
- vcall!(self, set_mode, mode)
62
- }
63
  }
 
1
+ /// Vtable for Palette (0x66A2E4).
2
+ ///
3
+ /// Slots 2-4 are called by GameEngine__InitHardware after DDGameWrapper construction.
4
+ /// Slots 0-1 are unknown.
5
+ #[openwa_core::vtable(size = 5, va = 0x0066_A2E4, class = "Palette")]
6
+ pub struct PaletteVtable {
7
+ /// set_mode(this, mode) β€” called with mode=7 during hardware init
8
+ #[slot(2)]
9
+ pub set_mode: fn(this: *mut Palette, mode: u32),
10
+ /// init β€” called during hardware init
11
+ #[slot(3)]
12
+ pub init: fn(this: *mut Palette),
13
+ /// reset β€” called first during hardware init
14
+ #[slot(4)]
15
+ pub reset: fn(this: *mut Palette),
16
+ }
17
+
18
  /// Palette β€” palette management object.
19
  ///
20
  /// Vtable: 0x66A2E4.
 
34
 
35
  const _: () = assert!(core::mem::size_of::<Palette>() == 0x28);
36
 
37
+ // Generate calling wrappers: Palette::set_mode(), Palette::init(), Palette::reset()
38
+ bind_PaletteVtable!(Palette, vtable);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  impl Palette {
41
  /// Create a new Palette with inline construction (no native C++ ctor).
 
49
  _unknown_008: [0; 0x20],
50
  }
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
crates/openwa-core/src/engine/ddgame.rs CHANGED
@@ -494,9 +494,9 @@ pub unsafe fn display_layer_color_init(wrapper: *mut DDGameWrapper) {
494
  };
495
 
496
  let display = (*wrapper).display;
497
- DDDisplay::set_layer_color(display, 1, layer1_color);
498
- DDDisplay::set_layer_color(display, 2, 0x20);
499
- DDDisplay::set_layer_color(display, 3, 0x70);
500
  }
501
 
502
  /// Initialize runtime addresses for the constructor bridges.
@@ -841,16 +841,15 @@ unsafe fn init_graphics_and_resources(
841
  }
842
  let disp = (*wrapper).display;
843
  let gfx_dir = (*wrapper).primary_gfx_dir;
844
- DDDisplay::set_layer_color(disp, 1, 0xFE);
845
- DDDisplay::load_sprite(
846
- disp,
847
  1,
848
  1,
849
  0,
850
  gfx_dir,
851
  rb(va::STR_CDROM_SPR) as *const c_char,
852
  );
853
- DDDisplay::set_layer_visibility(disp, 1, -100);
854
 
855
  // Palette slot range init (raw byte offsets into DisplayBase)
856
  let disp_raw = disp as *mut u8;
@@ -875,7 +874,7 @@ unsafe fn init_graphics_and_resources(
875
  // ── Display vtable slot 5 (offset 0x14) ──
876
  // Original: CALL EAX (vtable[5]), saves return value in ESI for use as
877
  // the `output` parameter in the color-entries GfxResource__Create call below.
878
- let layer_ctx = DDDisplay::set_active_layer((*ddgame).display, 1);
879
 
880
  // ── GfxDir color entries DDGame+0x730C..0x732C ──
881
  // Original logic: if gfx_mode!=0, try GfxResource__Create for colours.img.
@@ -1089,7 +1088,7 @@ unsafe fn init_graphics_and_resources(
1089
  name_buf[5] = b'0' + (i / 10) as u8;
1090
  name_buf[6] = b'0' + (i % 10) as u8;
1091
 
1092
- let layer_ctx = DDDisplay::set_active_layer((*ddgame).display, 1);
1093
 
1094
  let entry = gfx_dir_find_entry(name_buf.as_ptr().cast(), gfx_dir);
1095
 
@@ -1272,14 +1271,13 @@ unsafe fn init_graphics_and_resources(
1272
  );
1273
 
1274
  let disp = (*wrapper).display;
1275
- DDDisplay::set_active_layer(disp, 3);
1276
 
1277
  // back.spr and debris.spr must be loaded unconditionally β€” they're used by
1278
  // GenerateDebrisParticles (0x546F70) for particle effects, which affects
1279
  // the game RNG (DDGame+0x45EC). The original constructor loads them even
1280
  // in headless mode. Skipping them causes replay desync.
1281
- DDDisplay::load_sprite_by_layer(
1282
- disp,
1283
  3,
1284
  0x26D,
1285
  land_layer,
@@ -1289,10 +1287,9 @@ unsafe fn init_graphics_and_resources(
1289
  // GenerateDebrisParticles (0x546F70) for particle effects, which
1290
  // affects the game RNG (DDGame+0x45EC). Skipping it in headless
1291
  // mode causes desync (longbow replay checksum mismatch at frame 1350).
1292
- DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, c"debris.spr".as_ptr());
1293
 
1294
- DDDisplay::load_sprite_by_layer(
1295
- disp,
1296
  2,
1297
  0x26C,
1298
  water_layer,
@@ -1303,7 +1300,7 @@ unsafe fn init_graphics_and_resources(
1303
 
1304
  // ── Gradient image (0x030) ──
1305
  let level_height = (*ddgame).level_height as i32;
1306
- let layer3_ctx = DDDisplay::set_active_layer(disp, 3);
1307
  let s_var1 = *(layer3_ctx.add(0x606) as *const i16);
1308
 
1309
  if s_var1 < 0x61 && level_height == 0x2B8 {
@@ -1316,7 +1313,7 @@ unsafe fn init_graphics_and_resources(
1316
 
1317
  // ── Fill image β†’ fill_pixel (0x7338) ──
1318
  {
1319
- let layer2_ctx = DDDisplay::set_active_layer((*ddgame).display, 2);
1320
  // In the original, fill.img uses piStack_126c which the decompiler
1321
  // shows was set from piVar3 (water_layer from landscape+0xB38).
1322
  let fill_sprite = call_gfx_find_and_load(water_layer, c"fill.img", layer2_ctx);
@@ -1381,9 +1378,9 @@ unsafe fn init_graphics_and_resources(
1381
  // ── Final display layer visibility ──
1382
  {
1383
  let disp = (*wrapper).display;
1384
- DDDisplay::set_layer_visibility(disp, 1, 0);
1385
- DDDisplay::set_layer_visibility(disp, 2, 0);
1386
- DDDisplay::set_layer_visibility(disp, 3, 1);
1387
  }
1388
 
1389
  let _ = crate::log::log_line("[DDGame] init_graphics_and_resources DONE");
 
494
  };
495
 
496
  let display = (*wrapper).display;
497
+ (*display).set_layer_color(1, layer1_color);
498
+ (*display).set_layer_color(2, 0x20);
499
+ (*display).set_layer_color(3, 0x70);
500
  }
501
 
502
  /// Initialize runtime addresses for the constructor bridges.
 
841
  }
842
  let disp = (*wrapper).display;
843
  let gfx_dir = (*wrapper).primary_gfx_dir;
844
+ (*disp).set_layer_color(1, 0xFE);
845
+ (*disp).load_sprite(
 
846
  1,
847
  1,
848
  0,
849
  gfx_dir,
850
  rb(va::STR_CDROM_SPR) as *const c_char,
851
  );
852
+ (*disp).set_layer_visibility(1, -100);
853
 
854
  // Palette slot range init (raw byte offsets into DisplayBase)
855
  let disp_raw = disp as *mut u8;
 
874
  // ── Display vtable slot 5 (offset 0x14) ──
875
  // Original: CALL EAX (vtable[5]), saves return value in ESI for use as
876
  // the `output` parameter in the color-entries GfxResource__Create call below.
877
+ let layer_ctx = (*(*ddgame).display).set_active_layer(1);
878
 
879
  // ── GfxDir color entries DDGame+0x730C..0x732C ──
880
  // Original logic: if gfx_mode!=0, try GfxResource__Create for colours.img.
 
1088
  name_buf[5] = b'0' + (i / 10) as u8;
1089
  name_buf[6] = b'0' + (i % 10) as u8;
1090
 
1091
+ let layer_ctx = (*(*ddgame).display).set_active_layer(1);
1092
 
1093
  let entry = gfx_dir_find_entry(name_buf.as_ptr().cast(), gfx_dir);
1094
 
 
1271
  );
1272
 
1273
  let disp = (*wrapper).display;
1274
+ (*disp).set_active_layer(3);
1275
 
1276
  // back.spr and debris.spr must be loaded unconditionally β€” they're used by
1277
  // GenerateDebrisParticles (0x546F70) for particle effects, which affects
1278
  // the game RNG (DDGame+0x45EC). The original constructor loads them even
1279
  // in headless mode. Skipping them causes replay desync.
1280
+ (*disp).load_sprite_by_layer(
 
1281
  3,
1282
  0x26D,
1283
  land_layer,
 
1287
  // GenerateDebrisParticles (0x546F70) for particle effects, which
1288
  // affects the game RNG (DDGame+0x45EC). Skipping it in headless
1289
  // mode causes desync (longbow replay checksum mismatch at frame 1350).
1290
+ (*disp).load_sprite(3, 0x26E, 0, land_layer, c"debris.spr".as_ptr());
1291
 
1292
+ (*disp).load_sprite_by_layer(
 
1293
  2,
1294
  0x26C,
1295
  water_layer,
 
1300
 
1301
  // ── Gradient image (0x030) ──
1302
  let level_height = (*ddgame).level_height as i32;
1303
+ let layer3_ctx = (*disp).set_active_layer(3);
1304
  let s_var1 = *(layer3_ctx.add(0x606) as *const i16);
1305
 
1306
  if s_var1 < 0x61 && level_height == 0x2B8 {
 
1313
 
1314
  // ── Fill image β†’ fill_pixel (0x7338) ──
1315
  {
1316
+ let layer2_ctx = (*(*ddgame).display).set_active_layer(2);
1317
  // In the original, fill.img uses piStack_126c which the decompiler
1318
  // shows was set from piVar3 (water_layer from landscape+0xB38).
1319
  let fill_sprite = call_gfx_find_and_load(water_layer, c"fill.img", layer2_ctx);
 
1378
  // ── Final display layer visibility ──
1379
  {
1380
  let disp = (*wrapper).display;
1381
+ (*disp).set_layer_visibility(1, 0);
1382
+ (*disp).set_layer_visibility(2, 0);
1383
+ (*disp).set_layer_visibility(3, 1);
1384
  }
1385
 
1386
  let _ = crate::log::log_line("[DDGame] init_graphics_and_resources DONE");
crates/openwa-core/src/frontend/map_view.rs CHANGED
@@ -36,10 +36,9 @@ pub struct MapView {
36
  const _: () = assert!(core::mem::size_of::<MapView>() == 0x29628);
37
 
38
  /// MapView vtable (partial β€” only known slots).
39
- #[repr(C)]
40
  pub struct MapViewVtable {
41
- /// Slot 0: Unknown virtual method.
42
- pub slot_0: usize,
43
- /// Slot 1: Destructor β€” thiscall(this, free_flag). Frees the object if flag & 1.
44
- pub destructor: unsafe extern "thiscall" fn(*mut MapView, i32),
45
  }
 
36
  const _: () = assert!(core::mem::size_of::<MapView>() == 0x29628);
37
 
38
  /// MapView vtable (partial β€” only known slots).
39
+ #[openwa_core::vtable(size = 2, va = 0x0064_FC60, class = "MapView")]
40
  pub struct MapViewVtable {
41
+ /// Destructor β€” thiscall(this, free_flag). Frees the object if flag & 1.
42
+ #[slot(1)]
43
+ pub destructor: fn(this: *mut MapView, free_flag: i32),
 
44
  }
crates/openwa-core/src/input/controller.rs CHANGED
@@ -1,3 +1,13 @@
 
 
 
 
 
 
 
 
 
 
1
  /// InputCtrl β€” input controller subsystem.
2
  ///
3
  /// Initializer: FUN_0058C0D0, usercall(ESI=this) + stdcall(4 params), RET 0x10.
@@ -20,18 +30,12 @@ pub struct InputCtrl {
20
 
21
  const _: () = assert!(core::mem::size_of::<InputCtrl>() == 0x1800);
22
 
23
- /// Vtable for InputCtrl (0x66B3FC).
24
- ///
25
- /// Only slot 0 (destructor) is known β€” called on init failure cleanup.
26
- #[repr(C)]
27
- pub struct InputCtrlVtable {
28
- /// [0]: Destructor(this, flags) β€” scalar deleting destructor
29
- pub destructor: unsafe extern "thiscall" fn(*mut InputCtrl, u32),
30
- }
31
 
32
  impl InputCtrl {
33
  /// Vtable[0]: Destroy and optionally free (flags & 1 = free).
34
  pub unsafe fn destroy(&mut self, flags: u32) {
35
- vcall!(self, destructor, flags)
36
  }
37
  }
 
1
+ /// Vtable for InputCtrl (0x66B3FC).
2
+ ///
3
+ /// Only slot 0 (destructor) is known β€” called on init failure cleanup.
4
+ #[openwa_core::vtable(size = 1, va = 0x0066_B3FC, class = "InputCtrl")]
5
+ pub struct InputCtrlVtable {
6
+ /// Destructor(this, flags) β€” scalar deleting destructor
7
+ #[slot(0)]
8
+ pub destructor: fn(this: *mut InputCtrl, flags: u32),
9
+ }
10
+
11
  /// InputCtrl β€” input controller subsystem.
12
  ///
13
  /// Initializer: FUN_0058C0D0, usercall(ESI=this) + stdcall(4 params), RET 0x10.
 
30
 
31
  const _: () = assert!(core::mem::size_of::<InputCtrl>() == 0x1800);
32
 
33
+ // Generate calling wrappers: InputCtrl::destructor()
34
+ bind_InputCtrlVtable!(InputCtrl, vtable);
 
 
 
 
 
 
35
 
36
  impl InputCtrl {
37
  /// Vtable[0]: Destroy and optionally free (flags & 1 = free).
38
  pub unsafe fn destroy(&mut self, flags: u32) {
39
+ self.destructor(flags);
40
  }
41
  }
crates/openwa-core/src/lib.rs CHANGED
@@ -23,6 +23,7 @@ pub use inventory;
23
 
24
  // Re-export derive macros so users write `use openwa_core::FieldRegistry;`
25
  pub use openwa_derive::FieldRegistry;
 
26
 
27
  #[macro_use]
28
  pub mod macros;
 
23
 
24
  // Re-export derive macros so users write `use openwa_core::FieldRegistry;`
25
  pub use openwa_derive::FieldRegistry;
26
+ pub use openwa_derive::vtable;
27
 
28
  #[macro_use]
29
  pub mod macros;
crates/openwa-core/src/macros.rs CHANGED
@@ -149,6 +149,59 @@ macro_rules! define_addresses {
149
  };
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  /// Call a virtual method through a typed vtable pointer.
153
  ///
154
  /// Assumes `$obj` is a raw pointer to a struct whose first field `vtable`
 
149
  };
150
  }
151
 
152
+ /// Replace specific slots in a WA.exe vtable with Rust function pointers.
153
+ ///
154
+ /// Expands to executable code for use inside `install()` functions.
155
+ /// Uses `patch_vtable` (VirtualProtect) to make .rdata writable.
156
+ ///
157
+ /// # Syntax
158
+ ///
159
+ /// ```ignore
160
+ /// vtable_replace!(PaletteVtable, va::PALETTE_VTABLE, {
161
+ /// slot 2 => my_set_mode, // pure replace
162
+ /// slot 3 [originals::INIT] => my_init, // replace + save original
163
+ /// })?;
164
+ /// ```
165
+ ///
166
+ /// The `[static]` syntax saves the original fn pointer to the given `AtomicU32`
167
+ /// before overwriting. Omit it for pure replacement (no call-through).
168
+ ///
169
+ /// Size is derived from `size_of::<VtableType>() / 4`.
170
+ #[macro_export]
171
+ macro_rules! vtable_replace {
172
+ ($vtable_ty:ty, $va:expr, { $($slot:tt)* }) => {{
173
+ let vtable_ptr = $crate::rebase::rb($va) as *mut u32;
174
+ let slot_count = core::mem::size_of::<$vtable_ty>() / 4;
175
+ unsafe {
176
+ $crate::vtable::patch_vtable(vtable_ptr, slot_count, |vt| {
177
+ $crate::vtable_replace!(@slots vt $($slot)*);
178
+ })
179
+ }
180
+ }};
181
+
182
+ // Slot with original save: slot N [static_path] => replacement,
183
+ (@slots $vt:ident slot $idx:literal [$orig:expr] => $replacement:expr, $($rest:tt)*) => {
184
+ {
185
+ let slot = $vt.add($idx);
186
+ $orig.store(unsafe { *slot }, core::sync::atomic::Ordering::Relaxed);
187
+ unsafe { *slot = $replacement as *const () as u32; }
188
+ }
189
+ $crate::vtable_replace!(@slots $vt $($rest)*);
190
+ };
191
+
192
+ // Slot without original save: slot N => replacement,
193
+ (@slots $vt:ident slot $idx:literal => $replacement:expr, $($rest:tt)*) => {
194
+ {
195
+ let slot = $vt.add($idx);
196
+ unsafe { *slot = $replacement as *const () as u32; }
197
+ }
198
+ $crate::vtable_replace!(@slots $vt $($rest)*);
199
+ };
200
+
201
+ // Base case
202
+ (@slots $vt:ident) => {};
203
+ }
204
+
205
  /// Call a virtual method through a typed vtable pointer.
206
  ///
207
  /// Assumes `$obj` is a raw pointer to a struct whose first field `vtable`
crates/openwa-core/src/registry.rs CHANGED
@@ -308,6 +308,82 @@ pub fn field_at_inherited(struct_name: &str, offset: u32) -> Option<&'static Fie
308
  None
309
  }
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  // =========================================================================
312
  // Live object tracker
313
  // =========================================================================
@@ -602,4 +678,51 @@ mod tests {
602
  assert!(fields.is_some(), "CTask fields not found via vtable");
603
  assert_eq!(fields.unwrap().struct_name, "CTask");
604
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  }
 
308
  None
309
  }
310
 
311
+ // =========================================================================
312
+ // Vtable registry (for #[derive(Vtable)])
313
+ // =========================================================================
314
+
315
+ /// Metadata for a single vtable slot.
316
+ #[derive(Debug, Clone, Copy)]
317
+ pub struct VtableSlotEntry {
318
+ /// Slot index in the vtable (0-based).
319
+ pub index: u32,
320
+ /// Method name (e.g., "set_mode").
321
+ pub name: &'static str,
322
+ /// Brief description from doc comment.
323
+ pub doc: &'static str,
324
+ }
325
+
326
+ /// Metadata for a vtable struct.
327
+ #[derive(Debug)]
328
+ pub struct VtableInfo {
329
+ /// Vtable struct name (e.g., "PaletteVtable").
330
+ pub struct_name: &'static str,
331
+ /// Owning C++ class name (e.g., "Palette"), if specified.
332
+ pub class_name: &'static str,
333
+ /// Ghidra VA of the vtable in .rdata (0 if not specified).
334
+ pub ghidra_va: u32,
335
+ /// Total number of slots in the vtable.
336
+ pub slot_count: u32,
337
+ /// Known (named) slots only.
338
+ pub slots: &'static [VtableSlotEntry],
339
+ }
340
+
341
+ impl VtableInfo {
342
+ /// Look up a slot by method name.
343
+ pub fn slot_by_name(&self, name: &str) -> Option<&VtableSlotEntry> {
344
+ self.slots.iter().find(|s| s.name == name)
345
+ }
346
+
347
+ /// Look up a slot by index.
348
+ pub fn slot_by_index(&self, index: u32) -> Option<&VtableSlotEntry> {
349
+ self.slots.iter().find(|s| s.index == index)
350
+ }
351
+ }
352
+
353
+ /// Trait for vtable structs with auto-generated registry metadata.
354
+ pub trait HasVtableRegistry {
355
+ fn vtable_info() -> &'static VtableInfo;
356
+ }
357
+
358
+ /// A vtable's registry submitted to the global collection via `inventory`.
359
+ pub struct VtableRegistration {
360
+ pub info: &'static VtableInfo,
361
+ }
362
+
363
+ inventory::collect!(VtableRegistration);
364
+
365
+ static VTABLE_MAP: OnceLock<std::collections::HashMap<&'static str, &'static VtableInfo>> =
366
+ OnceLock::new();
367
+
368
+ fn vtable_map() -> &'static std::collections::HashMap<&'static str, &'static VtableInfo> {
369
+ VTABLE_MAP.get_or_init(|| {
370
+ inventory::iter::<VtableRegistration>
371
+ .into_iter()
372
+ .map(|r| (r.info.struct_name, r.info))
373
+ .collect()
374
+ })
375
+ }
376
+
377
+ /// Look up vtable metadata by struct name.
378
+ pub fn vtable_info_for(struct_name: &str) -> Option<&'static VtableInfo> {
379
+ vtable_map().get(struct_name).copied()
380
+ }
381
+
382
+ /// Return all registered vtable info entries.
383
+ pub fn all_vtable_info() -> impl Iterator<Item = &'static VtableInfo> {
384
+ vtable_map().values().copied()
385
+ }
386
+
387
  // =========================================================================
388
  // Live object tracker
389
  // =========================================================================
 
678
  assert!(fields.is_some(), "CTask fields not found via vtable");
679
  assert_eq!(fields.unwrap().struct_name, "CTask");
680
  }
681
+
682
+ #[test]
683
+ fn vtable_registry_palette() {
684
+ // PaletteVtable should be registered via #[vtable(...)] attribute
685
+ let info = vtable_info_for("PaletteVtable");
686
+ assert!(info.is_some(), "PaletteVtable not found in vtable registry");
687
+ let info = info.unwrap();
688
+
689
+ assert_eq!(info.struct_name, "PaletteVtable");
690
+ assert_eq!(info.class_name, "Palette");
691
+ assert_eq!(info.ghidra_va, 0x0066_A2E4);
692
+ assert_eq!(info.slot_count, 5);
693
+ assert_eq!(info.slots.len(), 3); // set_mode, init, reset
694
+
695
+ // Check individual slots
696
+ let set_mode = info.slot_by_name("set_mode");
697
+ assert!(set_mode.is_some(), "set_mode not found");
698
+ assert_eq!(set_mode.unwrap().index, 2);
699
+
700
+ let init = info.slot_by_index(3);
701
+ assert!(init.is_some(), "slot 3 not found");
702
+ assert_eq!(init.unwrap().name, "init");
703
+ }
704
+
705
+ #[test]
706
+ fn vtable_generates_addr_entry() {
707
+ use crate::address::va;
708
+
709
+ // The vtable macro should have generated PALETTE_VTABLE const
710
+ assert_eq!(va::PALETTE_VTABLE, 0x0066_A2E4);
711
+
712
+ // And an AddrEntry in the registry
713
+ let entry = lookup_va_exact(0x0066_A2E4);
714
+ assert!(entry.is_some(), "PALETTE_VTABLE addr entry not found");
715
+ let entry = entry.unwrap();
716
+ assert_eq!(entry.kind, AddrKind::Vtable);
717
+ assert_eq!(entry.class_name, Some("Palette"));
718
+ }
719
+
720
+ #[test]
721
+ fn vtable_size_assertion() {
722
+ // PaletteVtable should be 5 * 4 = 20 bytes
723
+ assert_eq!(
724
+ core::mem::size_of::<crate::display::palette::PaletteVtable>(),
725
+ 5 * core::mem::size_of::<usize>()
726
+ );
727
+ }
728
  }
crates/openwa-core/src/task/game_task.rs CHANGED
@@ -6,8 +6,7 @@ crate::define_addresses! {
6
  class "CGameTask" {
7
  /// CGameTask vtable - extends CTask vtable with 12 more methods
8
  vtable CGAMETASK_VTABLE = 0x0066_41F8;
9
- /// CGameTask sound emitter vtable (embedded sub-object at offset 0xE8)
10
- vtable CGAMETASK_SOUND_EMITTER_VT = 0x0066_9CF8;
11
  /// CGameTask constructor - calls CTask ctor, sets physics defaults
12
  ctor/Stdcall CGAMETASK_CONSTRUCTOR = 0x004F_ED50;
13
  /// CGameTask::vtable0 override
@@ -19,6 +18,9 @@ crate::define_addresses! {
19
  }
20
  }
21
 
 
 
 
22
  /// Game task - extends CTask with physics and gameplay data.
23
  ///
24
  /// PARTIAL: Most fields between 0x30-0x83 and 0x98-0xE7 are unknown.
@@ -81,18 +83,18 @@ const _: () = assert!(core::mem::size_of::<SoundEmitter>() == 0x14);
81
  ///
82
  /// Slots [0]-[4] are the sound emitter's own interface.
83
  /// Slots [5]-[11] are inherited CTask base methods.
84
- #[repr(C)]
85
  pub struct SoundEmitterVTable {
86
- /// [0] 0x546680: GetPosition(this, out_x, out_y) β€” reads pos_x/pos_y via owner
87
- pub get_position: unsafe extern "thiscall" fn(*const SoundEmitter, *mut u32, *mut u32),
88
- /// [1] 0x5466A0: GetPosition2(this, out_x, out_y) β€” reads CGameTask+0x38/0x3C
89
- pub get_position2: unsafe extern "thiscall" fn(*const SoundEmitter, *mut u32, *mut u32),
90
- /// [2] 0x4260E0: Unknown
91
- pub _unknown_2: *const (),
92
- /// [3] 0x546990: Destructor(this, flags)
93
- pub destructor: unsafe extern "thiscall" fn(*mut SoundEmitter, u32) -> *mut SoundEmitter,
94
- /// [4] 0x546760: HandleMessage β€” sound queue manager
95
- pub handle_message: unsafe extern "thiscall" fn(*mut SoundEmitter, u32, u32, u32, u32),
96
- /// [5]-[11]: Inherited CTask base methods
97
- pub _base_methods: [*const (); 7],
98
  }
 
6
  class "CGameTask" {
7
  /// CGameTask vtable - extends CTask vtable with 12 more methods
8
  vtable CGAMETASK_VTABLE = 0x0066_41F8;
9
+ // Sound emitter vtable now defined via #[vtable(...)] on SoundEmitterVTable
 
10
  /// CGameTask constructor - calls CTask ctor, sets physics defaults
11
  ctor/Stdcall CGAMETASK_CONSTRUCTOR = 0x004F_ED50;
12
  /// CGameTask::vtable0 override
 
18
  }
19
  }
20
 
21
+ /// Backward-compatible alias for the SoundEmitter vtable const.
22
+ pub const CGAMETASK_SOUND_EMITTER_VT: u32 = SOUND_EMITTER_VTABLE;
23
+
24
  /// Game task - extends CTask with physics and gameplay data.
25
  ///
26
  /// PARTIAL: Most fields between 0x30-0x83 and 0x98-0xE7 are unknown.
 
83
  ///
84
  /// Slots [0]-[4] are the sound emitter's own interface.
85
  /// Slots [5]-[11] are inherited CTask base methods.
86
+ #[openwa_core::vtable(size = 12, va = 0x0066_9CF8, class = "SoundEmitter")]
87
  pub struct SoundEmitterVTable {
88
+ /// GetPosition(this, out_x, out_y) β€” reads pos_x/pos_y via owner
89
+ #[slot(0)]
90
+ pub get_position: fn(this: *const SoundEmitter, out_x: *mut u32, out_y: *mut u32),
91
+ /// GetPosition2 β€” reads CGameTask+0x38/0x3C
92
+ #[slot(1)]
93
+ pub get_position2: fn(this: *const SoundEmitter, out_x: *mut u32, out_y: *mut u32),
94
+ /// Destructor
95
+ #[slot(3)]
96
+ pub destructor: fn(this: *mut SoundEmitter, flags: u32) -> *mut SoundEmitter,
97
+ /// HandleMessage β€” sound queue manager
98
+ #[slot(4)]
99
+ pub handle_message: fn(this: *mut SoundEmitter, sender: u32, msg_type: u32, size: u32, data: u32),
100
  }
crates/openwa-core/src/task/worm.rs CHANGED
@@ -25,56 +25,56 @@ crate::define_addresses! {
25
  /// 0x38 SetState 0x3C CheckPendingAction 0x40 IsNotOnRope
26
  /// 0x44 inherited 0x48 GetTeamIndex 0x4C inherited
27
  /// ```
28
- #[repr(C)]
29
  pub struct CTaskWormVTable {
30
- /// [0] 0x0050CAA0: WriteReplayState β€” serializes worm state to a replay stream;
31
- /// writes entity type byte 0x12, then packs field groups based on current state
32
- pub write_replay_state: unsafe extern "thiscall" fn(*mut CTaskWorm, *mut u8),
33
- /// [1] 0x0050C7E0: Free β€” calls inner destructor, then `_free(this)` if flags & 1
34
- pub free: unsafe extern "thiscall" fn(*mut CTaskWorm, u8) -> *mut CTaskWorm,
35
- /// [2] 0x00510B40: HandleMessage β€” processes all TaskMessages sent to this worm
36
- pub handle_message:
37
- unsafe extern "thiscall" fn(*mut CTaskWorm, *mut CTask, u32, u32, *const u8),
38
- /// [3] 0x00516780: GetEntityData β€” returns worm data by query code:
39
- /// 0x7D0=(pos_x, pos_y), 0x7D1/0x7D2=collision test, 0x7D4=full state dump
40
- pub get_entity_data: unsafe extern "thiscall" fn(*mut CTaskWorm, u32, u32, *mut u32) -> u32,
41
- /// [4]-[6]: Inherited CTask stubs (not overridden by CTaskWorm)
42
- pub _inherited_4_to_6: [*const (); 3],
43
- /// [7] 0x0050D5D0: OnContactEntity β€” handles physical contact with another entity;
44
- /// worm-on-dying-worm β†’ apply crush damage; projectile close-pass β†’ SetState(0x80)
45
- pub on_contact_entity: unsafe extern "thiscall" fn(*mut CTaskWorm, *mut CGameTask, u32) -> u32,
46
- /// [8] 0x0050D9A0: OnWormPush β€” post-contact worm-worm push impulse;
47
- /// deduplicates via a recent-collision table, then adjusts pos_x or pos_y
48
- pub on_worm_push: unsafe extern "thiscall" fn(*mut CTaskWorm, *mut CGameTask, u32) -> u32,
49
- /// [9] 0x0050D810: OnLandBounce β€” worm lands on terrain; plays thud sound, bounce physics
50
- pub on_land_bounce: unsafe extern "thiscall" fn(*mut CTaskWorm),
51
- /// [10] 0x0050D820: OnLandSlide β€” secondary landing callback; sliding/friction physics
52
- pub on_land_slide: unsafe extern "thiscall" fn(*mut CTaskWorm),
53
- /// [11] 0x0050D570: OnSink β€” worm sinks in water/acid; applies (dx, dy) displacement,
54
- /// transitions to drowning state (0x7F) unless already flying/rope/dead
55
- pub on_sink: unsafe extern "thiscall" fn(*mut CTaskWorm, i32, i32) -> u32,
56
- /// [12]: Inherited (vtable30 from CGameTask β€” not overridden)
57
- pub _inherited_12: *const (),
58
- /// [13] 0x0050D3B0: OnKilled β€” worm death; plays death sound (0x3A),
59
- /// transitions to dead state (0x81 or 0x89) based on game round count
60
- pub on_killed: unsafe extern "thiscall" fn(*mut CTaskWorm),
61
- /// [14] 0x0050E850: SetState β€” worm state machine; handles all state transitions
62
- /// (0x65=idle, 0x67=active turn, 0x7F=drowning, 0x80=hurt, 0x81/0x86=dead, …)
63
- pub set_state: unsafe extern "thiscall" fn(*mut CTaskWorm, u32),
64
- /// [15] 0x00516900: CheckPendingAction β€” if field +0xBC is set, calls SetState(0x73)
65
- pub check_pending_action: unsafe extern "thiscall" fn(*mut CTaskWorm),
66
- /// [16] 0x00516920: IsNotOnRope β€” returns true if worm state != 0x7C (rope-swinging)
67
- pub is_not_on_rope: unsafe extern "thiscall" fn(*const CTaskWorm) -> bool,
68
- /// [17]: Inherited (vtable44 from CGameTask β€” not overridden)
69
- pub _inherited_17: *const (),
70
- /// [18] 0x005168F0: GetTeamIndex β€” returns worm's team index (field +0xFC)
71
- pub get_team_index: unsafe extern "thiscall" fn(*const CTaskWorm) -> u32,
72
- /// [19]: Inherited (vtable4C from CGameTask β€” not overridden)
73
- pub _inherited_19: *const (),
 
 
74
  }
75
 
76
- const _: () = assert!(core::mem::size_of::<CTaskWormVTable>() == 20 * 4);
77
-
78
  /// Worm entity task β€” the primary playable character in WA.
79
  ///
80
  /// Extends CGameTask (0xFC bytes) with worm identity, physics overrides, and
 
25
  /// 0x38 SetState 0x3C CheckPendingAction 0x40 IsNotOnRope
26
  /// 0x44 inherited 0x48 GetTeamIndex 0x4C inherited
27
  /// ```
28
+ #[openwa_core::vtable(size = 20)]
29
  pub struct CTaskWormVTable {
30
+ /// WriteReplayState β€” serializes worm state to a replay stream
31
+ #[slot(0)]
32
+ pub write_replay_state: fn(this: *mut CTaskWorm, stream: *mut u8),
33
+ /// Free β€” calls inner destructor, then `_free(this)` if flags & 1
34
+ #[slot(1)]
35
+ pub free: fn(this: *mut CTaskWorm, flags: u8) -> *mut CTaskWorm,
36
+ /// HandleMessage β€” processes all TaskMessages sent to this worm
37
+ #[slot(2)]
38
+ pub handle_message: fn(this: *mut CTaskWorm, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
39
+ /// GetEntityData β€” returns worm data by query code
40
+ #[slot(3)]
41
+ pub get_entity_data: fn(this: *mut CTaskWorm, query: u32, param: u32, out: *mut u32) -> u32,
42
+ // Slots 4-6: Inherited CTask stubs (auto-filled as usize)
43
+ /// OnContactEntity β€” handles physical contact with another entity
44
+ #[slot(7)]
45
+ pub on_contact_entity: fn(this: *mut CTaskWorm, other: *mut CGameTask, flags: u32) -> u32,
46
+ /// OnWormPush β€” post-contact worm-worm push impulse
47
+ #[slot(8)]
48
+ pub on_worm_push: fn(this: *mut CTaskWorm, other: *mut CGameTask, flags: u32) -> u32,
49
+ /// OnLandBounce β€” worm lands on terrain; plays thud sound, bounce physics
50
+ #[slot(9)]
51
+ pub on_land_bounce: fn(this: *mut CTaskWorm),
52
+ /// OnLandSlide β€” secondary landing callback; sliding/friction physics
53
+ #[slot(10)]
54
+ pub on_land_slide: fn(this: *mut CTaskWorm),
55
+ /// OnSink β€” worm sinks in water/acid; transitions to drowning state
56
+ #[slot(11)]
57
+ pub on_sink: fn(this: *mut CTaskWorm, dx: i32, dy: i32) -> u32,
58
+ // Slot 12: Inherited (auto-filled)
59
+ /// OnKilled β€” worm death; plays death sound, transitions to dead state
60
+ #[slot(13)]
61
+ pub on_killed: fn(this: *mut CTaskWorm),
62
+ /// SetState β€” worm state machine; handles all state transitions
63
+ #[slot(14)]
64
+ pub set_state: fn(this: *mut CTaskWorm, state: u32),
65
+ /// CheckPendingAction β€” if field +0xBC is set, calls SetState(0x73)
66
+ #[slot(15)]
67
+ pub check_pending_action: fn(this: *mut CTaskWorm),
68
+ /// IsNotOnRope β€” returns true if worm state != 0x7C (rope-swinging)
69
+ #[slot(16)]
70
+ pub is_not_on_rope: fn(this: *const CTaskWorm) -> bool,
71
+ // Slot 17: Inherited (auto-filled)
72
+ /// GetTeamIndex β€” returns worm's team index (field +0xFC)
73
+ #[slot(18)]
74
+ pub get_team_index: fn(this: *const CTaskWorm) -> u32,
75
+ // Slot 19: Inherited (auto-filled)
76
  }
77
 
 
 
78
  /// Worm entity task β€” the primary playable character in WA.
79
  ///
80
  /// Extends CGameTask (0xFC bytes) with worm identity, physics overrides, and
crates/openwa-derive/src/lib.rs CHANGED
@@ -25,6 +25,508 @@ use proc_macro::TokenStream;
25
  use quote::quote;
26
  use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  /// Derive `HasFieldRegistry` for a `#[repr(C)]` struct.
29
  ///
30
  /// Generates a static `StructFields` table with one `FieldEntry` per named
 
25
  use quote::quote;
26
  use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
27
 
28
+ // =========================================================================
29
+ // #[derive(Vtable)]
30
+ // =========================================================================
31
+
32
+ /// Derive a typed vtable struct from a sparse slot definition.
33
+ ///
34
+ /// Only declare known methods with `#[slot(N)]`. The macro generates the full
35
+ /// `#[repr(C)]` struct with `usize` gap-fillers for unknown slots, plus:
36
+ ///
37
+ /// - Registry metadata (`HasVtableRegistry` impl + `inventory` registration)
38
+ /// - A companion `bind_{VtableName}!` macro for generating calling wrappers
39
+ /// - Optional `pub const` + `AddrEntry` if `va` is specified
40
+ /// - Compile-time size assertion
41
+ ///
42
+ /// # Attributes
43
+ ///
44
+ /// - `#[vtable(size = N)]` β€” total number of slots (required)
45
+ /// - `#[vtable(va = 0x...)]` β€” Ghidra VA of the vtable in .rdata (optional)
46
+ /// - `#[vtable(class = "Name")]` β€” owning C++ class name (optional)
47
+ ///
48
+ /// # Example
49
+ ///
50
+ /// ```ignore
51
+ /// #[derive(Vtable)]
52
+ /// #[vtable(size = 5, va = 0x0066_A2E4, class = "Palette")]
53
+ /// pub struct PaletteVtable {
54
+ /// #[slot(2)]
55
+ /// pub set_mode: unsafe extern "thiscall" fn(*mut Palette, u32),
56
+ /// #[slot(3)]
57
+ /// pub init: unsafe extern "thiscall" fn(*mut Palette),
58
+ /// #[slot(4)]
59
+ /// pub reset: unsafe extern "thiscall" fn(*mut Palette),
60
+ /// }
61
+ /// ```
62
+ /// Attribute macro that transforms a sparse vtable definition into a full
63
+ /// `#[repr(C)]` struct with gap-fillers, registry metadata, and a binding macro.
64
+ ///
65
+ /// See module docs for usage.
66
+ #[proc_macro_attribute]
67
+ pub fn vtable(attr: TokenStream, item: TokenStream) -> TokenStream {
68
+ let input = parse_macro_input!(item as DeriveInput);
69
+ let attr_tokens = proc_macro2::TokenStream::from(attr);
70
+ match vtable_impl(attr_tokens, input) {
71
+ Ok(ts) => ts.into(),
72
+ Err(e) => e.to_compile_error().into(),
73
+ }
74
+ }
75
+
76
+ fn vtable_impl(
77
+ attr: proc_macro2::TokenStream,
78
+ input: DeriveInput,
79
+ ) -> syn::Result<proc_macro2::TokenStream> {
80
+ let struct_name = &input.ident;
81
+ let struct_name_str = struct_name.to_string();
82
+ let vis = &input.vis;
83
+
84
+ // Parse vtable(size = N, va = 0x..., class = "...") from the attribute args
85
+ let (vtable_size, vtable_va, class_name) = parse_vtable_attr_args(attr, struct_name)?;
86
+
87
+ // Parse fields β€” each must have #[slot(N)]
88
+ let fields = match &input.data {
89
+ Data::Struct(data) => match &data.fields {
90
+ Fields::Named(named) => &named.named,
91
+ _ => {
92
+ return Err(syn::Error::new_spanned(
93
+ struct_name,
94
+ "Vtable requires named fields",
95
+ ));
96
+ }
97
+ },
98
+ _ => {
99
+ return Err(syn::Error::new_spanned(
100
+ struct_name,
101
+ "Vtable can only be used on structs",
102
+ ));
103
+ }
104
+ };
105
+
106
+ // Collect slots. #[slot(N)] is optional β€” if omitted, indices are assigned
107
+ // sequentially. If ANY field has #[slot(N)], all must (mixing is an error).
108
+ let mut slots: Vec<SlotInfo> = Vec::new();
109
+ let mut has_explicit_slot = false;
110
+ let mut has_implicit_slot = false;
111
+
112
+ for (field_idx, field) in fields.iter().enumerate() {
113
+ let ident = field.ident.as_ref().ok_or_else(|| {
114
+ syn::Error::new_spanned(field, "Vtable fields must be named")
115
+ })?;
116
+
117
+ let explicit_index = parse_slot_attr(&field.attrs, ident)?;
118
+
119
+ let slot_index = match explicit_index {
120
+ Some(idx) => {
121
+ has_explicit_slot = true;
122
+ idx
123
+ }
124
+ None => {
125
+ has_implicit_slot = true;
126
+ field_idx as u32
127
+ }
128
+ };
129
+
130
+ if has_explicit_slot && has_implicit_slot {
131
+ return Err(syn::Error::new_spanned(
132
+ ident,
133
+ "cannot mix fields with and without #[slot(N)] β€” use it on all or none",
134
+ ));
135
+ }
136
+
137
+ if slot_index >= vtable_size {
138
+ return Err(syn::Error::new_spanned(
139
+ ident,
140
+ format!("slot index {} >= vtable size {}", slot_index, vtable_size),
141
+ ));
142
+ }
143
+
144
+ // Check for duplicates
145
+ if slots.iter().any(|s| s.index == slot_index) {
146
+ return Err(syn::Error::new_spanned(
147
+ ident,
148
+ format!("duplicate slot index {}", slot_index),
149
+ ));
150
+ }
151
+
152
+ let doc = extract_doc_comment(&field.attrs);
153
+ let kept_attrs: Vec<_> = field
154
+ .attrs
155
+ .iter()
156
+ .filter(|a| !a.path().is_ident("slot"))
157
+ .cloned()
158
+ .collect();
159
+
160
+ // Normalize fn type: plain `fn(...)` becomes `unsafe extern "thiscall" fn(...)`
161
+ let normalized_ty = normalize_vtable_fn_type(&field.ty);
162
+
163
+ slots.push(SlotInfo {
164
+ index: slot_index,
165
+ ident: ident.clone(),
166
+ ty: normalized_ty,
167
+ doc,
168
+ attrs: kept_attrs,
169
+ vis: field.vis.clone(),
170
+ });
171
+ }
172
+
173
+ // Sort by slot index
174
+ slots.sort_by_key(|s| s.index);
175
+
176
+ // Generate struct fields: gap-fillers + typed slots
177
+ let mut struct_fields = Vec::new();
178
+ let mut next_index = 0u32;
179
+
180
+ for slot in &slots {
181
+ // Fill gaps with usize fields
182
+ while next_index < slot.index {
183
+ let gap_name = quote::format_ident!("_slot_{}", next_index);
184
+ struct_fields.push(quote! {
185
+ pub #gap_name: usize
186
+ });
187
+ next_index += 1;
188
+ }
189
+
190
+ // Emit the typed slot
191
+ let ident = &slot.ident;
192
+ let ty = &slot.ty;
193
+ let attrs = &slot.attrs;
194
+ let slot_vis = &slot.vis;
195
+ struct_fields.push(quote! {
196
+ #(#attrs)*
197
+ #slot_vis #ident: #ty
198
+ });
199
+ next_index = slot.index + 1;
200
+ }
201
+
202
+ // Fill trailing gaps
203
+ while next_index < vtable_size {
204
+ let gap_name = quote::format_ident!("_slot_{}", next_index);
205
+ struct_fields.push(quote! {
206
+ pub #gap_name: usize
207
+ });
208
+ next_index += 1;
209
+ }
210
+
211
+ // Generate registry slot entries
212
+ let slot_entries: Vec<_> = slots
213
+ .iter()
214
+ .map(|s| {
215
+ let idx = s.index;
216
+ let name = s.ident.to_string();
217
+ let doc = &s.doc;
218
+ quote! {
219
+ openwa_core::registry::VtableSlotEntry {
220
+ index: #idx,
221
+ name: #name,
222
+ doc: #doc,
223
+ }
224
+ }
225
+ })
226
+ .collect();
227
+ let slot_entry_count = slot_entries.len();
228
+
229
+ let class_name_str = class_name.as_deref().unwrap_or("");
230
+ let va_value = vtable_va.unwrap_or(0);
231
+
232
+ // Generate the info static name
233
+ let info_static = quote::format_ident!("_VTABLE_INFO_{}", struct_name);
234
+
235
+ // Generate optional pub const + AddrEntry for the VA
236
+ let va_items = if let Some(va) = vtable_va {
237
+ // Derive const name: class "Palette" β†’ PALETTE_VTABLE, or struct PaletteVtable β†’ PALETTE_VTABLE
238
+ let const_name_str = if let Some(ref class) = class_name {
239
+ format!("{}_VTABLE", to_screaming_snake(class))
240
+ } else {
241
+ to_screaming_snake(&struct_name_str)
242
+ };
243
+ let const_ident = quote::format_ident!("{}", const_name_str);
244
+ let class_expr = if let Some(ref class) = class_name {
245
+ quote! { Some(#class) }
246
+ } else {
247
+ quote! { None }
248
+ };
249
+ quote! {
250
+ #vis const #const_ident: u32 = #va;
251
+
252
+ openwa_core::inventory::submit! {
253
+ openwa_core::registry::AddrEntry {
254
+ va: #va,
255
+ name: #const_name_str,
256
+ kind: openwa_core::registry::AddrKind::Vtable,
257
+ calling_conv: None,
258
+ class_name: #class_expr,
259
+ doc: "",
260
+ }
261
+ }
262
+ }
263
+ } else {
264
+ quote! {}
265
+ };
266
+
267
+ // Generate the companion bind macro
268
+ let bind_macro_name = quote::format_ident!("bind_{}", struct_name);
269
+ let bind_methods = generate_bind_methods(&slots)?;
270
+
271
+ let vtable_size_usize = vtable_size as usize;
272
+
273
+ let expanded = quote! {
274
+ #[repr(C)]
275
+ #vis struct #struct_name {
276
+ #(#struct_fields),*
277
+ }
278
+
279
+ const _: () = assert!(
280
+ core::mem::size_of::<#struct_name>() == #vtable_size_usize * core::mem::size_of::<usize>(),
281
+ "vtable struct size mismatch"
282
+ );
283
+
284
+ // Vtable registry metadata
285
+ #[doc(hidden)]
286
+ #[allow(non_upper_case_globals)]
287
+ static #info_static: openwa_core::registry::VtableInfo = openwa_core::registry::VtableInfo {
288
+ struct_name: #struct_name_str,
289
+ class_name: #class_name_str,
290
+ ghidra_va: #va_value,
291
+ slot_count: #vtable_size,
292
+ slots: {
293
+ const ENTRIES: [openwa_core::registry::VtableSlotEntry; #slot_entry_count] = [
294
+ #(#slot_entries),*
295
+ ];
296
+ &ENTRIES
297
+ },
298
+ };
299
+
300
+ impl openwa_core::registry::HasVtableRegistry for #struct_name {
301
+ fn vtable_info() -> &'static openwa_core::registry::VtableInfo {
302
+ &#info_static
303
+ }
304
+ }
305
+
306
+ openwa_core::inventory::submit! {
307
+ openwa_core::registry::VtableRegistration {
308
+ info: &#info_static,
309
+ }
310
+ }
311
+
312
+ #va_items
313
+
314
+ /// Bind this vtable's known methods as wrapper methods on a class struct.
315
+ ///
316
+ /// Usage: `bind_VtableName!(ClassName, vtable_field_name);`
317
+ #[macro_export]
318
+ macro_rules! #bind_macro_name {
319
+ ($class:ty, $vtable_field:ident) => {
320
+ impl $class {
321
+ #bind_methods
322
+ }
323
+ };
324
+ }
325
+ };
326
+
327
+ Ok(expanded)
328
+ }
329
+
330
+ /// A parsed vtable slot definition.
331
+ struct SlotInfo {
332
+ index: u32,
333
+ ident: syn::Ident,
334
+ ty: syn::Type,
335
+ doc: String,
336
+ attrs: Vec<syn::Attribute>,
337
+ vis: syn::Visibility,
338
+ }
339
+
340
+ /// Generate the method wrappers for the bind macro.
341
+ ///
342
+ /// For each slot, parses the fn pointer type to extract:
343
+ /// - `this` mutability (*mut β†’ &mut self, *const β†’ &self)
344
+ /// - Remaining parameters (with generated names p0, p1, ...)
345
+ /// - Return type
346
+ fn generate_bind_methods(slots: &[SlotInfo]) -> syn::Result<proc_macro2::TokenStream> {
347
+ let mut methods = Vec::new();
348
+
349
+ for slot in slots {
350
+ let ident = &slot.ident;
351
+ let doc = &slot.doc;
352
+
353
+ // Parse the fn pointer type
354
+ let bare_fn = match &slot.ty {
355
+ syn::Type::BareFn(bf) => bf,
356
+ other => {
357
+ return Err(syn::Error::new_spanned(
358
+ other,
359
+ "vtable slot type must be a bare fn pointer (e.g., unsafe extern \"thiscall\" fn(...))",
360
+ ));
361
+ }
362
+ };
363
+
364
+ // First param is `this` β€” determine mutability
365
+ let inputs = &bare_fn.inputs;
366
+ if inputs.is_empty() {
367
+ return Err(syn::Error::new_spanned(
368
+ bare_fn,
369
+ "vtable fn must have at least a `this` parameter",
370
+ ));
371
+ }
372
+
373
+ let this_is_const = is_const_ptr(&inputs[0].ty);
374
+ let self_param = if this_is_const {
375
+ quote! { &self }
376
+ } else {
377
+ quote! { &mut self }
378
+ };
379
+ let self_cast = if this_is_const {
380
+ quote! { self as *const Self }
381
+ } else {
382
+ quote! { self as *mut Self }
383
+ };
384
+
385
+ // Remaining params
386
+ let mut param_decls = Vec::new();
387
+ let mut param_names = Vec::new();
388
+ for (i, arg) in inputs.iter().skip(1).enumerate() {
389
+ let param_name = if let Some(ref name) = arg.name {
390
+ let (ident, _) = name;
391
+ ident.clone()
392
+ } else {
393
+ quote::format_ident!("p{}", i)
394
+ };
395
+ let param_ty = &arg.ty;
396
+ param_decls.push(quote! { #param_name: #param_ty });
397
+ param_names.push(param_name);
398
+ }
399
+
400
+ // Return type
401
+ let ret_ty = match &bare_fn.output {
402
+ syn::ReturnType::Default => quote! {},
403
+ syn::ReturnType::Type(_, ty) => quote! { -> #ty },
404
+ };
405
+
406
+ methods.push(quote! {
407
+ #[doc = #doc]
408
+ pub unsafe fn #ident(#self_param #(, #param_decls)*) #ret_ty {
409
+ ((*self.$vtable_field).#ident)(#self_cast #(, #param_names)*)
410
+ }
411
+ });
412
+ }
413
+
414
+ Ok(quote! { #(#methods)* })
415
+ }
416
+
417
+ /// Normalize a vtable fn pointer type: if it's a plain `fn(...)` (no `unsafe`,
418
+ /// no explicit ABI), transform it to `unsafe extern "thiscall" fn(...)`.
419
+ ///
420
+ /// Already-qualified types (e.g., `unsafe extern "thiscall" fn(...)`) pass through unchanged.
421
+ fn normalize_vtable_fn_type(ty: &syn::Type) -> syn::Type {
422
+ if let syn::Type::BareFn(bare) = ty {
423
+ let needs_unsafe = bare.unsafety.is_none();
424
+ let needs_abi = bare.abi.is_none();
425
+
426
+ if needs_unsafe || needs_abi {
427
+ let mut normalized = bare.clone();
428
+ if needs_unsafe {
429
+ normalized.unsafety = Some(syn::token::Unsafe::default());
430
+ }
431
+ if needs_abi {
432
+ normalized.abi = Some(syn::Abi {
433
+ extern_token: syn::token::Extern::default(),
434
+ name: Some(syn::LitStr::new("thiscall", proc_macro2::Span::call_site())),
435
+ });
436
+ }
437
+ return syn::Type::BareFn(normalized);
438
+ }
439
+ }
440
+ ty.clone()
441
+ }
442
+
443
+ /// Check if a type is `*const T` (as opposed to `*mut T`).
444
+ fn is_const_ptr(ty: &syn::Type) -> bool {
445
+ if let syn::Type::Ptr(ptr) = ty {
446
+ ptr.const_token.is_some()
447
+ } else {
448
+ false
449
+ }
450
+ }
451
+
452
+ /// Parse the attribute arguments: `size = N, va = 0x..., class = "..."`.
453
+ fn parse_vtable_attr_args(
454
+ attr: proc_macro2::TokenStream,
455
+ span: &syn::Ident,
456
+ ) -> syn::Result<(u32, Option<u32>, Option<String>)> {
457
+ // Parse as a comma-separated list of key = value pairs
458
+ struct VtableArgs {
459
+ size: Option<u32>,
460
+ va: Option<u32>,
461
+ class: Option<String>,
462
+ }
463
+
464
+ let mut args = VtableArgs {
465
+ size: None,
466
+ va: None,
467
+ class: None,
468
+ };
469
+
470
+ // Use syn's parser by wrapping in parens
471
+ let parser = syn::meta::parser(|meta| {
472
+ if meta.path.is_ident("size") {
473
+ let _eq: syn::Token![=] = meta.input.parse()?;
474
+ let lit: syn::LitInt = meta.input.parse()?;
475
+ args.size = Some(lit.base10_parse()?);
476
+ } else if meta.path.is_ident("va") {
477
+ let _eq: syn::Token![=] = meta.input.parse()?;
478
+ let lit: syn::LitInt = meta.input.parse()?;
479
+ args.va = Some(lit.base10_parse()?);
480
+ } else if meta.path.is_ident("class") {
481
+ let _eq: syn::Token![=] = meta.input.parse()?;
482
+ let lit: syn::LitStr = meta.input.parse()?;
483
+ args.class = Some(lit.value());
484
+ } else {
485
+ return Err(meta.error("unknown vtable attribute"));
486
+ }
487
+ Ok(())
488
+ });
489
+
490
+ syn::parse::Parser::parse2(parser, attr)?;
491
+
492
+ let size = args.size.ok_or_else(|| {
493
+ syn::Error::new_spanned(span, "vtable attribute requires `size = N`")
494
+ })?;
495
+
496
+ Ok((size, args.va, args.class))
497
+ }
498
+
499
+ /// Parse #[slot(N)] attribute from a field.
500
+ fn parse_slot_attr(attrs: &[syn::Attribute], _span: &syn::Ident) -> syn::Result<Option<u32>> {
501
+ for attr in attrs {
502
+ if attr.path().is_ident("slot") {
503
+ let index: syn::LitInt = attr.parse_args()?;
504
+ return Ok(Some(index.base10_parse()?));
505
+ }
506
+ }
507
+ Ok(None)
508
+ }
509
+
510
+ /// Convert "PaletteVtable" or "Palette" to "PALETTE_VTABLE" or "PALETTE".
511
+ fn to_screaming_snake(name: &str) -> String {
512
+ let mut result = String::new();
513
+ for (i, ch) in name.chars().enumerate() {
514
+ if ch.is_uppercase() && i > 0 {
515
+ // Don't insert underscore between consecutive uppercase (e.g., "DDGame" β†’ "DD_GAME")
516
+ let prev = name.chars().nth(i - 1).unwrap_or('_');
517
+ if prev.is_lowercase() || prev.is_numeric() {
518
+ result.push('_');
519
+ } else if let Some(next) = name.chars().nth(i + 1) {
520
+ if next.is_lowercase() {
521
+ result.push('_');
522
+ }
523
+ }
524
+ }
525
+ result.push(ch.to_ascii_uppercase());
526
+ }
527
+ result
528
+ }
529
+
530
  /// Derive `HasFieldRegistry` for a `#[repr(C)]` struct.
531
  ///
532
  /// Generates a static `StructFields` table with one `FieldEntry` per named
crates/openwa-wormkit/src/replacements/hardware_init.rs CHANGED
@@ -368,7 +368,7 @@ unsafe extern "cdecl" fn impl_init_hardware(
368
  (*session).keyboard = kb;
369
 
370
  // ── Palette (inline construction) ─────────────────────────────────────
371
- let pal = WABox::from_value(Palette::new(rb(va::PALETTE_VTABLE_MAYBE))).leak();
372
  (*session).palette = pal;
373
 
374
  // ── DSSound ───────────────────────────────────────────────────────────
 
368
  (*session).keyboard = kb;
369
 
370
  // ── Palette (inline construction) ─────────────────────────────────────
371
+ let pal = WABox::from_value(Palette::new(rb(va::PALETTE_VTABLE))).leak();
372
  (*session).palette = pal;
373
 
374
  // ── DSSound ───────────────────────────────────────────────────────────
docs/superpowers/specs/2026-03-25-vtable-derive-design.md ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design: `#[vtable(...)]` System for OpenWA
2
+
3
+ ## Context
4
+
5
+ OpenWA has 7+ manually-written vtable structs (`PaletteVtable`, `DisplayBaseVtable`, `DDDisplayVtable`, etc.) with hand-coded `#[repr(C)]` layouts, manual `impl` wrapper methods, and ad-hoc vtable patching. The existing `#[derive(FieldRegistry)]` macro and `inventory`-based registry provide a proven pattern for auto-generating struct metadata. This design extends that infrastructure to vtables, providing a single definition per vtable that handles introspection, calling, and replacement.
6
+
7
+ **Constraints:**
8
+ - A C++ class can have multiple vtables at different struct offsets (multiple inheritance)
9
+ - The same vtable type can theoretically serve multiple classes
10
+ - All vtable methods are `unsafe extern "thiscall"` (ECX = this)
11
+ - Target is i686-pc-windows-msvc (32-bit, 4-byte pointers/fn pointers)
12
+
13
+ ## Design
14
+
15
+ ### 1. Vtable Definition (`#[vtable(...)]`)
16
+
17
+ **Input syntax** β€” declare only known methods with `#[slot(N)]`:
18
+
19
+ ```rust
20
+ #[openwa_core::vtable(size = 5, va = 0x0066_A2E4, class = "Palette")]
21
+ pub struct PaletteVtable {
22
+ /// set_mode(this, mode) β€” called with mode=7 during hardware init
23
+ #[slot(2)]
24
+ pub set_mode: unsafe extern "thiscall" fn(*mut Palette, u32),
25
+ /// init(this) β€” called during hardware init
26
+ #[slot(3)]
27
+ pub init: unsafe extern "thiscall" fn(*mut Palette),
28
+ /// reset(this) β€” called first during hardware init
29
+ #[slot(4)]
30
+ pub reset: unsafe extern "thiscall" fn(*mut Palette),
31
+ }
32
+ ```
33
+
34
+ **Generated output:**
35
+
36
+ ```rust
37
+ // 1. The actual #[repr(C)] struct with gap-fillers
38
+ #[repr(C)]
39
+ pub struct PaletteVtable {
40
+ pub _slot_0: usize,
41
+ pub _slot_1: usize,
42
+ /// set_mode(this, mode) β€” called with mode=7 during hardware init
43
+ pub set_mode: unsafe extern "thiscall" fn(*mut Palette, u32),
44
+ /// init(this) β€” called during hardware init
45
+ pub init: unsafe extern "thiscall" fn(*mut Palette),
46
+ /// reset(this) β€” called first during hardware init
47
+ pub reset: unsafe extern "thiscall" fn(*mut Palette),
48
+ }
49
+
50
+ // 2. Registry integration (introspection)
51
+ impl HasVtableRegistry for PaletteVtable {
52
+ fn vtable_info() -> &'static VtableInfo {
53
+ static INFO: VtableInfo = VtableInfo {
54
+ struct_name: "PaletteVtable",
55
+ class_name: "Palette",
56
+ ghidra_va: 0x0066_A2E4,
57
+ slot_count: 5,
58
+ slots: &[
59
+ VtableSlotEntry { index: 2, name: "set_mode", doc: "set_mode(this, mode) ..." },
60
+ VtableSlotEntry { index: 3, name: "init", doc: "init(this) ..." },
61
+ VtableSlotEntry { index: 4, name: "reset", doc: "reset(this) ..." },
62
+ ],
63
+ };
64
+ &INFO
65
+ }
66
+ }
67
+
68
+ inventory::submit!(VtableRegistration {
69
+ info: <PaletteVtable as HasVtableRegistry>::vtable_info(),
70
+ });
71
+
72
+ // 3. Address registry entry (replaces define_addresses! for this vtable)
73
+ inventory::submit!(AddrEntry {
74
+ va: 0x0066_A2E4,
75
+ name: "PALETTE_VTABLE",
76
+ kind: AddrKind::Vtable,
77
+ class_name: Some("Palette"),
78
+ calling_convention: None,
79
+ });
80
+
81
+ // 4. Companion binding macro (see Part 2)
82
+ #[macro_export]
83
+ macro_rules! bind_PaletteVtable { ... }
84
+ ```
85
+
86
+ **Proc macro implementation** in `openwa-derive/src/lib.rs`:
87
+ - Parse `#[vtable(...)]` attributes for `size`, `va` (optional), `class` (optional)
88
+ - Parse `#[slot(N)]` on each field to determine position
89
+ - Validate: no duplicate slots, all slots < size, slot indices ascending
90
+ - Generate gap-filler fields `_slot_N: usize` for missing indices
91
+ - Preserve doc comments, visibility, and field types from input
92
+ - Generate compile-time size assertion: `const _: () = assert!(size_of::<VtableName>() == size * 4);`
93
+ - Parse fn pointer types to extract `this` type (`*mut T` or `*const T`), params, and return type
94
+ - Generate `HasVtableRegistry` impl, inventory submissions, and binding macro
95
+ - If `va` is provided: generate `pub const` and `AddrEntry` submission. Name derived from `class` attribute: `"Palette"` β†’ `PALETTE_VTABLE`. If no `class`, derive from struct name: `PaletteVtable` β†’ `PALETTE_VTABLE`
96
+ - If `va` is omitted: no `AddrEntry`, no const β€” vtable exists purely as a type
97
+
98
+ ### 2. Calling Wrappers (`bind_XxxVtable!`)
99
+
100
+ The derive generates a companion macro named `bind_{VtableName}!` that creates method wrappers:
101
+
102
+ ```rust
103
+ // Generated by #[derive(Vtable)] on PaletteVtable:
104
+ #[macro_export]
105
+ macro_rules! bind_PaletteVtable {
106
+ ($class:ty, $vtable_field:ident) => {
107
+ impl $class {
108
+ #[doc = "set_mode(this, mode) β€” called with mode=7 during hardware init"]
109
+ pub unsafe fn set_mode(&mut self, mode: u32) {
110
+ ((*self.$vtable_field).set_mode)(self as *mut Self, mode)
111
+ }
112
+ #[doc = "init(this) β€” called during hardware init"]
113
+ pub unsafe fn init(&mut self) {
114
+ ((*self.$vtable_field).init)(self as *mut Self)
115
+ }
116
+ #[doc = "reset(this) β€” called first during hardware init"]
117
+ pub unsafe fn reset(&mut self) {
118
+ ((*self.$vtable_field).reset)(self as *mut Self)
119
+ }
120
+ }
121
+ };
122
+ }
123
+ ```
124
+
125
+ **Usage:**
126
+ ```rust
127
+ // In palette.rs, after struct Palette { vtable: *const PaletteVtable, ... }
128
+ bind_PaletteVtable!(Palette, vtable);
129
+
130
+ // Now you can write:
131
+ (*pal).set_mode(7); // instead of vcall!(pal, set_mode, 7)
132
+ (*pal).init();
133
+ (*pal).reset();
134
+ ```
135
+
136
+ **Multiple vtables (multiple inheritance):**
137
+ ```rust
138
+ // CGameTask has primary vtable at +0x00
139
+ bind_CGameTaskVtable!(CGameTask, vtable);
140
+
141
+ // SoundEmitter is a sub-object with its own vtable β€” bind on its own struct
142
+ bind_SoundEmitterVTable!(SoundEmitter, vtable);
143
+ ```
144
+
145
+ **Method signature extraction:** The proc macro parses the fn pointer type as `syn::Type::BareFn` to extract:
146
+ - First param is `this` β€” if `*const T`, generates `&self`; if `*mut T`, generates `&mut self`
147
+ - Remaining params become the wrapper's named parameters (e.g., `p0: u32`, `p1: *mut u8`)
148
+ - Return type is preserved (default `()` if absent)
149
+ - The wrapper calls through `self.$vtable_field` and casts `self` to the correct pointer type
150
+
151
+ **Type safety:** The binding macro passes `self as *mut Self` (or `*const Self`) as the `this` argument. The `$class` type passed to the macro must match the `this` pointer type in the vtable fn signatures. If mismatched, Rust's pointer cast rules apply silently β€” the macro does not enforce this, but the mismatch would be visible at the invocation site.
152
+
153
+ **Note on `&mut self`:** This follows the existing hand-written pattern (e.g., `Palette::reset(&mut self)`). Using `&mut self` creates a mutable reference from what is often a raw pointer, which could theoretically violate aliasing rules. This is acceptable for this project since all callers are already in `unsafe` blocks working with raw game memory, and the `vcall!` macro remains available for raw-pointer-only dispatch.
154
+
155
+ ### 3. Vtable Replacement (`vtable_replace!`)
156
+
157
+ A declarative macro that expands to executable code for use inside `install()` functions:
158
+
159
+ ```rust
160
+ pub fn install() -> Result<(), String> {
161
+ unsafe {
162
+ vtable_replace!(PaletteVtable at va::PALETTE_VTABLE {
163
+ slot set_mode => my_set_mode,
164
+ slot init => my_init,
165
+ })?;
166
+ }
167
+ Ok(())
168
+ }
169
+ ```
170
+
171
+ **Expanded code** (for `slot 2 => my_set_mode` β€” pure replace, no original saved):
172
+
173
+ ```rust
174
+ {
175
+ let vtable_ptr = $crate::rebase::rb(va::PALETTE_VTABLE) as *mut u32;
176
+ let slot_count = core::mem::size_of::<PaletteVtable>() / 4;
177
+ $crate::vtable::patch_vtable(vtable_ptr, slot_count, |vt| {
178
+ // Slot 2: pure replace
179
+ let slot = vt.add(2);
180
+ *slot = my_set_mode as *const () as u32;
181
+ })
182
+ }
183
+ ```
184
+
185
+ **Expanded code** (for `slot 3 [originals::INIT] => my_init` β€” replace + save original):
186
+
187
+ ```rust
188
+ {
189
+ let vtable_ptr = $crate::rebase::rb(va::PALETTE_VTABLE) as *mut u32;
190
+ let slot_count = core::mem::size_of::<PaletteVtable>() / 4;
191
+ $crate::vtable::patch_vtable(vtable_ptr, slot_count, |vt| {
192
+ // Slot 3: save original, then replace
193
+ let slot = vt.add(3);
194
+ originals::INIT.store(*slot, core::sync::atomic::Ordering::Relaxed);
195
+ *slot = my_init as *const () as u32;
196
+ })
197
+ }
198
+ ```
199
+
200
+ **Calling the original:** User defines statics in a visible scope and passes them via `[static]` syntax:
201
+
202
+ ```rust
203
+ mod originals {
204
+ pub static INIT: core::sync::atomic::AtomicU32 =
205
+ core::sync::atomic::AtomicU32::new(0);
206
+ }
207
+
208
+ vtable_replace!(PaletteVtable at va::PALETTE_VTABLE {
209
+ slot 2 => my_set_mode, // no original needed
210
+ slot 3 [originals::INIT] => my_init, // save original
211
+ })?;
212
+
213
+ // In the replacement:
214
+ unsafe extern "thiscall" fn my_init(this: *mut Palette) {
215
+ log!("init()");
216
+ let orig: unsafe extern "thiscall" fn(*mut Palette) =
217
+ core::mem::transmute(originals::INIT.load(Ordering::Relaxed));
218
+ orig(this);
219
+ }
220
+ ```
221
+
222
+ The `[static_name]` syntax is optional β€” if omitted, no original is saved (pure replacement, no call-through). The macro does NOT generate inaccessible statics.
223
+
224
+ **Slot index resolution:** Slot indices are specified explicitly in the macro call. This is compile-time, simple, and the indices are directly visible in the vtable definition above.
225
+
226
+ **Size derivation:** The `size` parameter is NOT required β€” the macro computes it as `size_of::<VtableType>() / 4` from the vtable struct type, which already encodes the correct size via `#[vtable(size = N)]`.
227
+
228
+ Revised syntax:
229
+ ```rust
230
+ vtable_replace!(PaletteVtable at va::PALETTE_VTABLE {
231
+ slot 2 => my_set_mode, // pure replace, original discarded
232
+ slot 3 [originals::INIT] => my_init, // replace + save original to static
233
+ })?;
234
+ ```
235
+
236
+ ### 4. New Types in `openwa-core::registry`
237
+
238
+ ```rust
239
+ /// Metadata for a single vtable slot
240
+ pub struct VtableSlotEntry {
241
+ pub index: u32,
242
+ pub name: &'static str,
243
+ pub doc: &'static str,
244
+ }
245
+
246
+ /// Metadata for a vtable struct
247
+ pub struct VtableInfo {
248
+ pub struct_name: &'static str,
249
+ pub class_name: &'static str,
250
+ pub ghidra_va: u32,
251
+ pub slot_count: u32,
252
+ pub slots: &'static [VtableSlotEntry],
253
+ }
254
+
255
+ impl VtableInfo {
256
+ pub fn slot_by_name(&self, name: &str) -> Option<&VtableSlotEntry> { ... }
257
+ pub fn slot_by_index(&self, index: u32) -> Option<&VtableSlotEntry> { ... }
258
+ }
259
+
260
+ /// Trait implemented by #[derive(Vtable)] structs
261
+ pub trait HasVtableRegistry {
262
+ fn vtable_info() -> &'static VtableInfo;
263
+ }
264
+
265
+ /// Inventory collection wrapper
266
+ pub struct VtableRegistration {
267
+ pub info: &'static VtableInfo,
268
+ }
269
+ inventory::collect!(VtableRegistration);
270
+
271
+ /// Query API
272
+ pub fn vtable_info_for(struct_name: &str) -> Option<&'static VtableInfo> { ... }
273
+ pub fn all_vtable_info() -> impl Iterator<Item = &'static VtableInfo> { ... }
274
+ ```
275
+
276
+ ### 5. `vtable::patch_vtable` Helper
277
+
278
+ Move/generalize the existing vtable patching logic from `display.rs` into a shared utility:
279
+
280
+ ```rust
281
+ // openwa-core::vtable (new module)
282
+
283
+ /// Temporarily makes a vtable writable, calls the patcher, restores protection.
284
+ /// Returns Err if VirtualProtect fails.
285
+ pub unsafe fn patch_vtable(
286
+ vtable_ptr: *mut u32,
287
+ slot_count: usize,
288
+ patcher: impl FnOnce(*mut u32),
289
+ ) -> Result<(), String> {
290
+ let size = slot_count * 4;
291
+ let mut old_protect = 0u32;
292
+ if VirtualProtect(vtable_ptr as _, size, PAGE_READWRITE, &mut old_protect) == 0 {
293
+ return Err("VirtualProtect failed".into());
294
+ }
295
+ patcher(vtable_ptr);
296
+ VirtualProtect(vtable_ptr as _, size, old_protect, &mut old_protect);
297
+ Ok(())
298
+ }
299
+ ```
300
+
301
+ This already exists in `display.rs` β€” factor it out.
302
+
303
+ ## File Changes
304
+
305
+ | File | Change |
306
+ |------|--------|
307
+ | `crates/openwa-derive/src/lib.rs` | Add `#[vtable(...)]` proc macro (~150 lines) |
308
+ | `crates/openwa-core/src/registry.rs` | Add `VtableInfo`, `VtableSlotEntry`, `HasVtableRegistry`, `VtableRegistration`, query API (~60 lines) |
309
+ | `crates/openwa-core/src/vtable.rs` | New module: `patch_vtable` helper (factored from display.rs) |
310
+ | `crates/openwa-core/src/macros.rs` | Add `vtable_replace!` macro (~40 lines) |
311
+ | `crates/openwa-core/src/lib.rs` | Add `pub mod vtable;`, re-export `Vtable` derive |
312
+
313
+ ## Migration: PaletteVtable (first target)
314
+
315
+ 1. Rewrite `PaletteVtable` in `display/palette.rs` to use `#[vtable(...)]`
316
+ 2. Add `bind_PaletteVtable!(Palette, vtable)` β€” replaces 3 manual wrapper methods
317
+ 3. Remove the `define_addresses!` entry for `PALETTE_VTABLE_MAYBE` (now in derive attribute)
318
+ 4. Verify `vcall!(pal, set_mode, 7)` still works (struct layout identical)
319
+ 5. Run replay tests
320
+
321
+ ## Migration Order (after Palette)
322
+
323
+ 1. `InputCtrlVtable` (1 slot) β€” trivial
324
+ 2. `MapViewVtable` (2 slots) β€” trivial
325
+ 3. `SoundEmitterVTable` (12 slots, 5 known)
326
+ 4. `DisplayBaseVtable` (32 slots, all typed)
327
+ 5. `DSSoundVtable` (24 slots)
328
+ 6. `DDDisplayVtable` (38 slots)
329
+ 7. `CTaskWormVTable` (20+ slots)
330
+
331
+ ## Verification
332
+
333
+ - `cargo build -p openwa-core` β€” types compile
334
+ - `cargo test -p openwa-core` β€” existing tests pass
335
+ - `cargo build -p openwa-wormkit --release` β€” DLL builds
336
+ - `.\run-tests.ps1` β€” replay tests pass (no desync from layout changes)
337
+ - Validate introspection: `vtable_info_for("PaletteVtable")` returns correct slot metadata