please pardon the drive-by contribution, i was curious to see how claude/fable fared on this repo. it wouldn't compile on macOS so i threw fable at it, and then had it fix a few other bugs/add a few features. seems quite playable at least for ten minutes or so on the v1.9 shareware DOOM WAD and authentic to the original experience, great job! without further ado, here's claude/fable:
Fixes and additions from running room on macOS (aarch64)
The changes below were made while getting the port to build and run on
macOS / Apple Silicon (baseline: commit 904acac). They fall into two
groups: bug fixes (build failures, a crash, and two allocator issues) and
features (mouse support with cursor capture, WASD defaults). A combined
diff for all of it is at the end.
Bug fixes
1. macOS link failure: __errno_location (m_misc.rs)
The crate did not link on macOS: __errno_location is a glibc-private
symbol; Apple's libc calls it __error.
Fix: drop the extern entirely and read errno portably via
std::io::Error::last_os_error().raw_os_error().
2. macOS link failure: glibc stdout (i_scale.rs)
Same category: the C stdout stream symbol is named stdout in glibc but
__stdoutp on macOS, so the extern "C" declaration failed to link.
Fix: #[cfg_attr(target_os = "macos", link_name = "__stdoutp")] on the
declaration.
3. Z_Malloc allocation cursor diverges from the C original (z_zone.rs)
When the scan hits an in-use, non-purgeable block, vanilla does
base = rover = rover->next; — both pointers move past the blocking
block. The port did base = rover; rover = rover->next;, leaving base
pointing at the unpurgeable block. The candidate region then starts on a
block the allocator can never free, diverging from vanilla and potentially
failing allocations the C version would satisfy.
Fix: advance rover first and assign base = rover, matching the C
statement's semantics.
4. Hardcoded 2000-block walk cap in Z_FreeTags / Z_CheckHeapQuiet (z_zone.rs)
The port adds a defensive walk cap (not in the C original) to avoid hanging
on a corrupted/cyclic block list — but it was a hardcoded 2000, and real
zones exceed that. A session playing the registered DOOM.WAD logged:
[Z_FreeTags] lowtag=5 hightag=6 walked=2491 freed=282
With the old cap, Z_FreeTags would have stopped at block 2000 and silently
leaked PU_LEVEL allocations on every level transition until the zone
filled up.
Fix: compute the cap from the zone itself — a valid list can never hold
more blocks than headers physically fit in the zone
(zone.size / size_of::<memblock_t>() + 1); anything past that is a cycle.
5. Panic in the wall renderer: finetangent overflow (r_segs.rs)
Crash during normal play (registered DOOM.WAD):
thread 'main' panicked at room/src/doom/r_segs.rs:506:38:
index out of bounds: the len is 4096 but the index is 8189
R_RenderSegLoop computes a fine-angle index that can reach 8191, but
finetangent[] has only FINEANGLES/2 = 4096 entries (the existing
& FINEMASK mask, 0x1FFF, does not constrain it below 4096). This is a
well-known vanilla quirk: the original C (vendor/doomgeneric/r_segs.c:257)
indexes finetangent[angle] unchecked, and out-of-range reads silently land
in finesine[], which tables.c declares immediately after finetangent[].
Harmless garbage in C; a hard panic with Rust's bounds-checked arrays.
Fix: reproduce the vanilla out-of-bounds lookup explicitly — indices
≥ 4096 read finesine[idx - 4096]. This matches the byte-exact behavior of
the original executable (same approach the port already uses for the
G_CheckSpot teleport-fog overflow) rather than inventing new clamping
behavior. Rendering-only value (texture column offset), so no demo-sync
impact.
Features
6. Mouse support with cursor capture (platform/mod.rs, i_input.rs, main.rs, tests/demo_playthrough.rs)
The doomgeneric C reference has no mouse hook, so one was added end to end:
DG_GetMouse (platform/mod.rs): new platform export polled once per
tic, reporting a Doom button bitmask (bit 0 = fire, bit 1 = strafe,
bit 2 = forward) and relative motion. Raw device deltas are accumulated
between polls; fractional remainders carry over so slow movements aren't
lost to integer truncation. Button changes are reported even without
motion.
I_GetEvent (i_input.rs): posts one ev_mouse event per tic, with
chocolate-doom-style acceleration (threshold 10, factor 2.0 — chocolate's
defaults) and the classic sign convention (positive data3 = forward,
hence a Y flip).
- Window layer (
main.rs): the cursor is grabbed (locked and hidden)
while playing; Esc releases it, and a click inside the window or regaining
focus re-grabs it (the recapturing click is swallowed so it doesn't fire
in-game). Held buttons and pending motion are cleared on release so the
engine doesn't keep firing or turning from stale input. The window now
starts borderless-fullscreen.
tests/demo_playthrough.rs: no-op DG_GetMouse stub so the headless
test harness still links.
7. WASD movement defaults (m_controls.rs)
Default bindings moved from vanilla's arrows/comma/period to w/a/s/d
(arrow keys still turn), following Crispy Doom's modern defaults. Existing
.cfg files override these as before, but this does change out-of-the-box
behavior — easy to drop if you'd rather keep vanilla defaults.
Testing
cargo test --release on macOS aarch64: 801 tests pass, including the
shareware demo playthroughs (so the allocator and renderer changes don't
desync game logic).
- Manual play of the registered
DOOM.WAD with mouse capture, music
(SC-55 soundfont via rustysynth), and level transitions exercising
Z_FreeTags beyond the old 2000-block cap.
Combined diff
Against commit 904acac:
diff --git a/room/src/doom/i_input.rs b/room/src/doom/i_input.rs
index a5be888..099e9be 100644
--- a/room/src/doom/i_input.rs
+++ b/room/src/doom/i_input.rs
@@ -34,6 +34,11 @@ extern "C" {
/// Fills `*pressed` with 1 for keydown / 0 for keyup and `*key` with the
/// Doom key code. Returns non-zero while events are available.
fn DG_GetKey(pressed: *mut c_int, key: *mut u8) -> c_int;
+ /// Platform-layer mouse poll (Rust addition; the doomgeneric C
+ /// reference has no mouse support). Fills `*buttons` with the Doom
+ /// button bitmask and `*dx`/`*dy` with relative motion since the
+ /// last poll. Returns non-zero when there is anything to report.
+ fn DG_GetMouse(buttons: *mut c_int, dx: *mut c_int, dy: *mut c_int) -> c_int;
/// Doom event sink, defined in `d_main.c`.
fn D_PostEvent(ev: *const event_t);
}
@@ -72,6 +77,27 @@ static SHIFTXFORM: [u8; 128] = [
b'Q', b'R', b'S', b'T', b'U', b'V', b'W', b'X', b'Y', b'Z', b'{', b'|', b'}', b'~', 127,
];
+/// Mouse acceleration factor applied above [`MOUSE_THRESHOLD`].
+/// Matches chocolate-doom's `mouse_acceleration` default; the vendored
+/// `i_input.c` only retains the (commented-out) call sites.
+const MOUSE_ACCELERATION: f64 = 2.0;
+/// Motion (per poll) below this many units is passed through unscaled.
+/// Matches chocolate-doom's `mouse_threshold` default.
+const MOUSE_THRESHOLD: c_int = 10;
+
+/// Applies chocolate-doom style acceleration to a relative mouse delta:
+/// motion beyond [`MOUSE_THRESHOLD`] is scaled by [`MOUSE_ACCELERATION`],
+/// symmetrically for negative values.
+fn AccelerateMouse(val: c_int) -> c_int {
+ if val < 0 {
+ -AccelerateMouse(-val)
+ } else if val > MOUSE_THRESHOLD {
+ ((val - MOUSE_THRESHOLD) as f64 * MOUSE_ACCELERATION) as c_int + MOUSE_THRESHOLD
+ } else {
+ val
+ }
+}
+
/// Translates a raw platform key code to a Doom key code.
///
/// Identity function: `DG_GetKey` already returns Doom key codes, so no
@@ -167,6 +193,25 @@ pub extern "C" fn I_GetEvent() {
break;
}
}
+
+ // Mouse: one ev_mouse event per tic carrying the current button
+ // mask and relative motion. Sign convention matches the classic
+ // ports' i_input.c: positive data2 turns right, positive data3
+ // moves forward (hence the Y-axis flip; the OS reports
+ // positive-down).
+ let mut buttons: c_int = 0;
+ let mut dx: c_int = 0;
+ let mut dy: c_int = 0;
+ if DG_GetMouse(&mut buttons, &mut dx, &mut dy) != 0 {
+ let event = event_t {
+ type_: 2, // ev_mouse
+ data1: buttons,
+ data2: AccelerateMouse(dx),
+ data3: -AccelerateMouse(dy),
+ data4: 0,
+ };
+ D_PostEvent(&event);
+ }
}
}
diff --git a/room/src/doom/i_scale.rs b/room/src/doom/i_scale.rs
index ebf625d..57e827b 100644
--- a/room/src/doom/i_scale.rs
+++ b/room/src/doom/i_scale.rs
@@ -60,7 +60,9 @@ use std::ptr;
extern "C" {
/// libc `stdout` stream, referenced for `fflush` during the
- /// "Generating lookup tables.." progress prints.
+ /// "Generating lookup tables.." progress prints. Named `stdout` in
+ /// glibc but `__stdoutp` on macOS.
+ #[cfg_attr(target_os = "macos", link_name = "__stdoutp")]
static mut stdout: *mut libc::FILE;
}
diff --git a/room/src/doom/m_controls.rs b/room/src/doom/m_controls.rs
index aa2172e..4ef0818 100644
--- a/room/src/doom/m_controls.rs
+++ b/room/src/doom/m_controls.rs
@@ -20,7 +20,7 @@ use super::doomkeys::{
KEY_BACKSPACE, KEY_DEL, KEY_DOWNARROW, KEY_END, KEY_ENTER, KEY_EQUALS, KEY_ESCAPE, KEY_F1,
KEY_F10, KEY_F11, KEY_F12, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9,
KEY_HOME, KEY_INS, KEY_LEFTARROW, KEY_MINUS, KEY_PAUSE, KEY_PGDN, KEY_PGUP, KEY_RALT,
- KEY_RIGHTARROW, KEY_RSHIFT, KEY_STRAFE_L, KEY_STRAFE_R, KEY_TAB, KEY_UPARROW, KEY_USE,
+ KEY_RIGHTARROW, KEY_RSHIFT, KEY_TAB, KEY_UPARROW, KEY_USE,
};
//
@@ -33,18 +33,19 @@ pub static mut key_right: c_int = KEY_RIGHTARROW as c_int;
/// Turn left (default: left arrow).
#[no_mangle]
pub static mut key_left: c_int = KEY_LEFTARROW as c_int;
-/// Move forward (default: up arrow).
+/// Move forward (default: `w`; the C original uses the up arrow, this
+/// port follows Crispy Doom's modern WASD defaults instead).
#[no_mangle]
-pub static mut key_up: c_int = KEY_UPARROW as c_int;
-/// Move backward (default: down arrow).
+pub static mut key_up: c_int = b'w' as c_int;
+/// Move backward (default: `s`; C original: down arrow).
#[no_mangle]
-pub static mut key_down: c_int = KEY_DOWNARROW as c_int;
-/// Strafe left (default: comma `KEY_STRAFE_L`).
+pub static mut key_down: c_int = b's' as c_int;
+/// Strafe left (default: `a`; C original: comma `KEY_STRAFE_L`).
#[no_mangle]
-pub static mut key_strafeleft: c_int = KEY_STRAFE_L as c_int;
-/// Strafe right (default: period `KEY_STRAFE_R`).
+pub static mut key_strafeleft: c_int = b'a' as c_int;
+/// Strafe right (default: `d`; C original: period `KEY_STRAFE_R`).
#[no_mangle]
-pub static mut key_straferight: c_int = KEY_STRAFE_R as c_int;
+pub static mut key_straferight: c_int = b'd' as c_int;
/// Fire current weapon (default: `KEY_FIRE`, the right Ctrl).
#[no_mangle]
pub static mut key_fire: c_int = super::doomkeys::KEY_FIRE as c_int;
diff --git a/room/src/doom/m_misc.rs b/room/src/doom/m_misc.rs
index fc63e86..46c42fb 100644
--- a/room/src/doom/m_misc.rs
+++ b/room/src/doom/m_misc.rs
@@ -62,8 +62,6 @@ extern "C" {
fn free(ptr: *mut c_void);
/// libc `strdup`: allocate a malloc'd copy of a null-terminated string.
fn strdup(s: *const c_char) -> *mut c_char;
- /// libc per-thread errno location, used by [`errno`].
- fn __errno_location() -> *mut c_int;
/// libc `mkdir`: create `path` with the given permission bits.
fn mkdir(path: *const c_char, mode: u32) -> c_int;
/// libc `toupper`: convert an ASCII character to upper case.
@@ -102,7 +100,8 @@ use crate::doom::z_zone::Z_Malloc;
/// caller must not retain the returned `int` across operations that may
/// reset `errno`.
unsafe fn errno() -> c_int {
- *__errno_location()
+ // Portable across glibc (__errno_location) and macOS (__error).
+ std::io::Error::last_os_error().raw_os_error().unwrap_or(0) as c_int
}
/// Create the directory at `path` with permissions `0o755`.
diff --git a/room/src/doom/r_segs.rs b/room/src/doom/r_segs.rs
index c27872e..3b58ec0 100644
--- a/room/src/doom/r_segs.rs
+++ b/room/src/doom/r_segs.rs
@@ -502,8 +502,17 @@ unsafe fn R_RenderSegLoop() {
if segtextured != 0 {
let angle =
(rw_centerangle.wrapping_add(xtoviewangle[rw_x as usize])) >> ANGLETOFINESHIFT;
- texturecolumn =
- rw_offset - FixedMul(tables::finetangent[angle as usize & FINEMASK], rw_distance);
+ // Vanilla indexes finetangent[] unchecked; angles in 4096..=8191
+ // read past its 4096 entries into finesine[], declared directly
+ // after it in tables.c. Reproduce that out-of-bounds lookup
+ // exactly instead of panicking.
+ let fine_idx = angle as usize & FINEMASK;
+ let tangent = if fine_idx < tables::FINEANGLES / 2 {
+ tables::finetangent[fine_idx]
+ } else {
+ tables::finesine[fine_idx - tables::FINEANGLES / 2]
+ };
+ texturecolumn = rw_offset - FixedMul(tangent, rw_distance);
let mut index = (rw_scale as u32) >> LIGHTSCALESHIFT;
if index >= MAXLIGHTSCALE as u32 {
diff --git a/room/src/doom/z_zone.rs b/room/src/doom/z_zone.rs
index e6c562c..ef2e608 100644
--- a/room/src/doom/z_zone.rs
+++ b/room/src/doom/z_zone.rs
@@ -231,8 +231,10 @@ pub unsafe extern "C" fn Z_Malloc(size: c_int, tag: c_int, user: *mut c_void) ->
if (*rover).tag != PU_FREE {
if (*rover).tag < PU_PURGELEVEL {
- base = rover;
+ // hit a block that can't be purged, so move base past it
+ // (C: `base = rover = rover->next;`)
rover = (*rover).next;
+ base = rover;
} else {
base = (*base).prev;
let rover_ptr = rover as *mut u8;
@@ -296,9 +298,10 @@ pub unsafe extern "C" fn Z_Malloc(size: c_int, tag: c_int, user: *mut c_void) ->
/// whose tag falls in `[lowtag, hightag]`. Called during level shutdown
/// to mass-release `PU_LEVEL` allocations.
///
-/// The Rust port adds a 2000-block walk cap and stderr trace
-/// (`[Z_FreeTags] ...`) not present in the C original; useful for
-/// catching list corruption when porting bugs creep in.
+/// The Rust port adds a walk cap (the physical maximum number of
+/// blocks that fit in the zone) and stderr trace (`[Z_FreeTags] ...`)
+/// not present in the C original; useful for catching list corruption
+/// when porting bugs creep in.
///
/// # Safety
/// - `mainzone` must be initialised. All blocks in the active list must
@@ -311,8 +314,11 @@ pub unsafe extern "C" fn Z_FreeTags(lowtag: c_int, hightag: c_int) {
let mut block = (*zone).blocklist.next;
let mut freed = 0;
let mut walked = 0;
+ // A valid list can never hold more blocks than headers fit in the
+ // zone; anything past that is a cycle.
+ let max_blocks = (*zone).size as usize / std::mem::size_of::<memblock_t>() + 1;
- while block != sentinel && walked < 2000 {
+ while block != sentinel && walked < max_blocks {
walked += 1;
let next = (*block).next;
@@ -443,8 +449,9 @@ pub unsafe extern "C" fn Z_CheckHeap() {
/// Check heap integrity without aborting. Returns true if valid.
///
/// Rust-only addition (no C counterpart): used by `Z_CheckHeapAfter`
-/// and `Z_CheckHeap` for non-fatal diagnostics. Caps the walk at 2000
-/// blocks to avoid hanging on a cyclic free-list bug.
+/// and `Z_CheckHeap` for non-fatal diagnostics. Caps the walk at the
+/// physical maximum block count to avoid hanging on a cyclic
+/// free-list bug.
///
/// # Safety
/// - Reads `mainzone` and its linked block list; the caller must ensure
@@ -460,8 +467,9 @@ pub unsafe extern "C" fn Z_CheckHeapQuiet() -> bool {
let mut block = (*zone).blocklist.next;
let mut valid = true;
let mut count = 0;
+ let max_blocks = (*zone).size as usize / std::mem::size_of::<memblock_t>() + 1;
- while block != sentinel && count < 2000 {
+ while block != sentinel && count < max_blocks {
let size = (*block).size;
if size <= 0 || size > 10_000_000 {
eprintln!(
@@ -512,7 +520,7 @@ pub unsafe extern "C" fn Z_CheckHeapQuiet() -> bool {
block = (*block).next;
count += 1;
}
- if count >= 2000 {
+ if count >= max_blocks {
eprintln!("Z_CheckHeapQuiet: too many blocks, possible loop!");
valid = false;
}
diff --git a/room/src/main.rs b/room/src/main.rs
index 45f577b..9f9e38a 100644
--- a/room/src/main.rs
+++ b/room/src/main.rs
@@ -51,16 +51,16 @@ use std::ffi::{c_char, c_int, CString};
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
-use winit::event::{ElementState, WindowEvent};
+use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
-use winit::keyboard::PhysicalKey;
-use winit::window::{Window, WindowId};
+use winit::keyboard::{KeyCode, PhysicalKey};
+use winit::window::{CursorGrabMode, Fullscreen, Window, WindowId};
use room::doom::doomgeneric::{DOOMGENERIC_RESX, DOOMGENERIC_RESY};
use gpu::GpuState;
use platform::keys::to_doom_key;
-use platform::{GPU, KEY_QUEUE, QUIT_REQUESTED, WINDOW};
+use platform::{GPU, KEY_QUEUE, MOUSE_BUTTONS, MOUSE_DELTA, QUIT_REQUESTED, WINDOW};
// ---------------------------------------------------------------------------
// App – the winit ApplicationHandler
@@ -85,6 +85,11 @@ struct App {
/// has been executed, so that subsequent `about_to_wait` calls use
/// `doomgeneric_Tick` instead.
doom_initialized: bool,
+
+ /// `true` while the OS cursor is grabbed (locked and hidden) so the
+ /// mouse controls the game. Released with Esc or on focus loss;
+ /// re-acquired on focus gain or a click inside the window.
+ mouse_captured: bool,
}
/// Manual [`Send`] impl: the raw pointers in `argv` reference `CString` data
@@ -119,6 +124,39 @@ impl App {
args,
argv,
doom_initialized: false,
+ mouse_captured: false,
+ }
+ }
+
+ /// Grab (lock and hide) or release the OS cursor.
+ ///
+ /// On release, held mouse buttons and pending motion are cleared so
+ /// the engine does not keep firing or turning from stale input.
+ fn set_mouse_capture(&mut self, capture: bool) {
+ let window = WINDOW.with_borrow(|w| w.clone());
+ let Some(window) = window else { return };
+
+ if capture {
+ // Locked keeps the cursor in place (macOS, Wayland); fall
+ // back to Confined for platforms that only support that.
+ match window
+ .set_cursor_grab(CursorGrabMode::Locked)
+ .or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
+ {
+ Ok(()) => {
+ window.set_cursor_visible(false);
+ self.mouse_captured = true;
+ }
+ Err(e) => log::warn!("Failed to capture cursor: {e}"),
+ }
+ } else {
+ if let Err(e) = window.set_cursor_grab(CursorGrabMode::None) {
+ log::warn!("Failed to release cursor: {e}");
+ }
+ window.set_cursor_visible(true);
+ self.mouse_captured = false;
+ MOUSE_BUTTONS.with(|b| b.set(0));
+ MOUSE_DELTA.with(|d| d.set((0.0, 0.0)));
}
}
}
@@ -143,6 +181,7 @@ impl ApplicationHandler for App {
DOOMGENERIC_RESX as u32,
DOOMGENERIC_RESY as u32,
))
+ .with_fullscreen(Some(Fullscreen::Borderless(None)))
.with_resizable(false);
let window = match event_loop.create_window(window_attrs) {
@@ -161,6 +200,7 @@ impl ApplicationHandler for App {
WINDOW.with_borrow_mut(|w| *w = Some(window));
platform::init_start_time();
log::info!("GPU initialised");
+ self.set_mouse_capture(true);
}
Err(e) => {
log::error!("Failed to initialise GPU: {e}");
@@ -196,17 +236,70 @@ impl ApplicationHandler for App {
return;
}
+ let pressed = event.state == ElementState::Pressed;
+
+ // Esc releases the cursor (and still reaches Doom,
+ // where it opens the menu).
+ if code == KeyCode::Escape && pressed && self.mouse_captured {
+ self.set_mouse_capture(false);
+ }
+
if let Some(doom_key) = to_doom_key(code) {
- let pressed = event.state == ElementState::Pressed;
KEY_QUEUE.with_borrow_mut(|q| q.push_back((pressed, doom_key)));
}
}
}
+ WindowEvent::Focused(focused) => {
+ // Recapture the cursor when the window regains focus;
+ // drop the grab (and any held input) when it loses it.
+ self.set_mouse_capture(focused);
+ }
+ WindowEvent::MouseInput { state, button, .. } => {
+ let pressed = state == ElementState::Pressed;
+
+ // A click while released (e.g. after Esc) recaptures the
+ // cursor; swallow that click so it doesn't fire in-game.
+ if !self.mouse_captured {
+ if pressed {
+ self.set_mouse_capture(true);
+ }
+ return;
+ }
+
+ let bit: u8 = match button {
+ MouseButton::Left => 1 << 0, // fire
+ MouseButton::Right => 1 << 1, // strafe
+ MouseButton::Middle => 1 << 2, // forward
+ _ => return,
+ };
+ MOUSE_BUTTONS.with(|b| {
+ let mask = b.get();
+ b.set(if pressed { mask | bit } else { mask & !bit });
+ });
+ }
_ => {}
}
}
+ /// Called for raw device events. Used for relative mouse motion,
+ /// which keeps flowing at the screen edge (unlike `CursorMoved`).
+ fn device_event(
+ &mut self,
+ _event_loop: &ActiveEventLoop,
+ _device_id: DeviceId,
+ event: DeviceEvent,
+ ) {
+ if let DeviceEvent::MouseMotion { delta: (dx, dy) } = event {
+ if self.mouse_captured {
+ MOUSE_DELTA.with(|d| {
+ let (ax, ay) = d.get();
+ d.set((ax + dx, ay + dy));
+ });
+ }
+ }
+ }
+
/// Called when all pending events have been processed.
///
/// This is the main "game loop" hook: on the first call, the Doom engine
diff --git a/room/src/platform/mod.rs b/room/src/platform/mod.rs
index eb09319..c05d69a 100644
--- a/room/src/platform/mod.rs
+++ b/room/src/platform/mod.rs
@@ -10,6 +10,7 @@
//! | `DG_SleepMs` | Sleep for a number of milliseconds |
//! | `DG_GetTicksMs` | Return elapsed milliseconds since startup |
//! | `DG_GetKey` | Pop the next keyboard event from the queue |
+//! | `DG_GetMouse` | Poll accumulated mouse buttons and motion |
//! | `DG_SetWindowTitle` | Update the OS window title |
//!
//! All functions are exported with `#[no_mangle]` so the C linker can
@@ -57,6 +58,19 @@ thread_local! {
pub(crate) static KEY_QUEUE: RefCell<VecDeque<(bool, u8)>> =
const { RefCell::new(VecDeque::new()) };
+ // Accumulated relative mouse motion (raw device deltas) since the
+ // last `DG_GetMouse` poll. Fractional remainders are carried over
+ // so slow movements are not lost to integer truncation.
+ pub(crate) static MOUSE_DELTA: Cell<(f64, f64)> = const { Cell::new((0.0, 0.0)) };
+
+ // Currently held mouse buttons as a Doom bitmask
+ // (bit 0 = left/fire, bit 1 = right/strafe, bit 2 = middle/forward).
+ pub(crate) static MOUSE_BUTTONS: Cell<u8> = const { Cell::new(0) };
+
+ // Button mask last reported to the engine, so `DG_GetMouse` can emit
+ // an event on button changes even without motion.
+ static MOUSE_LAST_BUTTONS: Cell<u8> = const { Cell::new(0) };
+
// Absolute time of programme launch, used by `DG_GetTicksMs`.
static START_TIME: Cell<Option<Instant>> = const { Cell::new(None) };
@@ -173,6 +187,46 @@ pub extern "C" fn DG_GetKey(pressed: *mut i32, doom_key: *mut u8) -> i32 {
})
}
+/// Poll accumulated mouse state.
+///
+/// Returns `1` and fills the out-parameters when there is new mouse
+/// input to report (non-zero motion since the last poll, or a change in
+/// the held-button mask); returns `0` otherwise. `buttons` receives the
+/// *current* Doom button bitmask (bit 0 = fire, bit 1 = strafe,
+/// bit 2 = forward), `dx`/`dy` the integral motion since the last poll
+/// (positive `dx` = right, positive `dy` = down, as delivered by the OS).
+///
+/// Called by `I_GetEvent` (i_input.rs) once per game tic, mirroring how
+/// `DG_GetKey` feeds keyboard events.
+///
+/// # Safety
+///
+/// `buttons`, `dx` and `dy` must be valid, non-null pointers to writable
+/// memory for their respective types.
+#[no_mangle]
+pub extern "C" fn DG_GetMouse(buttons: *mut i32, dx: *mut i32, dy: *mut i32) -> i32 {
+ let (fx, fy) = MOUSE_DELTA.with(|d| d.get());
+ // Report whole pixels; carry the fractional remainder forward.
+ let ix = fx.trunc();
+ let iy = fy.trunc();
+ MOUSE_DELTA.with(|d| d.set((fx - ix, fy - iy)));
+
+ let held = MOUSE_BUTTONS.with(|b| b.get());
+ let last = MOUSE_LAST_BUTTONS.with(|b| b.replace(held));
+
+ if ix == 0.0 && iy == 0.0 && held == last {
+ return 0;
+ }
+
+ // SAFETY: caller guarantees the pointers are valid.
+ unsafe {
+ *buttons = i32::from(held);
+ *dx = ix as i32;
+ *dy = iy as i32;
+ }
+ 1
+}
+
/// Update the OS window title.
///
/// Called by the engine when it discovers the game description from the
diff --git a/room/tests/demo_playthrough.rs b/room/tests/demo_playthrough.rs
index e1498bc..3bf8682 100644
--- a/room/tests/demo_playthrough.rs
+++ b/room/tests/demo_playthrough.rs
@@ -71,6 +71,11 @@ pub extern "C" fn DG_GetKey(_pressed: *mut i32, _doom_key: *mut u8) -> i32 {
0
}
+#[no_mangle]
+pub extern "C" fn DG_GetMouse(_buttons: *mut i32, _dx: *mut i32, _dy: *mut i32) -> i32 {
+ 0
+}
+
#[no_mangle]
pub extern "C" fn DG_SetWindowTitle(_title: *const c_char) {}
please pardon the drive-by contribution, i was curious to see how claude/fable fared on this repo. it wouldn't compile on macOS so i threw fable at it, and then had it fix a few other bugs/add a few features. seems quite playable at least for ten minutes or so on the v1.9 shareware DOOM WAD and authentic to the original experience, great job! without further ado, here's claude/fable:
Fixes and additions from running room on macOS (aarch64)
The changes below were made while getting the port to build and run on
macOS / Apple Silicon (baseline: commit
904acac). They fall into twogroups: bug fixes (build failures, a crash, and two allocator issues) and
features (mouse support with cursor capture, WASD defaults). A combined
diff for all of it is at the end.
Bug fixes
1. macOS link failure:
__errno_location(m_misc.rs)The crate did not link on macOS:
__errno_locationis a glibc-privatesymbol; Apple's libc calls it
__error.Fix: drop the extern entirely and read errno portably via
std::io::Error::last_os_error().raw_os_error().2. macOS link failure: glibc
stdout(i_scale.rs)Same category: the C
stdoutstream symbol is namedstdoutin glibc but__stdoutpon macOS, so theextern "C"declaration failed to link.Fix:
#[cfg_attr(target_os = "macos", link_name = "__stdoutp")]on thedeclaration.
3.
Z_Mallocallocation cursor diverges from the C original (z_zone.rs)When the scan hits an in-use, non-purgeable block, vanilla does
base = rover = rover->next;— both pointers move past the blockingblock. The port did
base = rover; rover = rover->next;, leavingbasepointing at the unpurgeable block. The candidate region then starts on a
block the allocator can never free, diverging from vanilla and potentially
failing allocations the C version would satisfy.
Fix: advance
roverfirst and assignbase = rover, matching the Cstatement's semantics.
4. Hardcoded 2000-block walk cap in
Z_FreeTags/Z_CheckHeapQuiet(z_zone.rs)The port adds a defensive walk cap (not in the C original) to avoid hanging
on a corrupted/cyclic block list — but it was a hardcoded
2000, and realzones exceed that. A session playing the registered
DOOM.WADlogged:With the old cap,
Z_FreeTagswould have stopped at block 2000 and silentlyleaked
PU_LEVELallocations on every level transition until the zonefilled up.
Fix: compute the cap from the zone itself — a valid list can never hold
more blocks than headers physically fit in the zone
(
zone.size / size_of::<memblock_t>() + 1); anything past that is a cycle.5. Panic in the wall renderer:
finetangentoverflow (r_segs.rs)Crash during normal play (registered
DOOM.WAD):R_RenderSegLoopcomputes a fine-angle index that can reach 8191, butfinetangent[]has onlyFINEANGLES/2 = 4096entries (the existing& FINEMASKmask, 0x1FFF, does not constrain it below 4096). This is awell-known vanilla quirk: the original C (
vendor/doomgeneric/r_segs.c:257)indexes
finetangent[angle]unchecked, and out-of-range reads silently landin
finesine[], whichtables.cdeclares immediately afterfinetangent[].Harmless garbage in C; a hard panic with Rust's bounds-checked arrays.
Fix: reproduce the vanilla out-of-bounds lookup explicitly — indices
≥ 4096 read
finesine[idx - 4096]. This matches the byte-exact behavior ofthe original executable (same approach the port already uses for the
G_CheckSpotteleport-fog overflow) rather than inventing new clampingbehavior. Rendering-only value (texture column offset), so no demo-sync
impact.
Features
6. Mouse support with cursor capture (
platform/mod.rs,i_input.rs,main.rs,tests/demo_playthrough.rs)The doomgeneric C reference has no mouse hook, so one was added end to end:
DG_GetMouse(platform/mod.rs): new platform export polled once pertic, reporting a Doom button bitmask (bit 0 = fire, bit 1 = strafe,
bit 2 = forward) and relative motion. Raw device deltas are accumulated
between polls; fractional remainders carry over so slow movements aren't
lost to integer truncation. Button changes are reported even without
motion.
I_GetEvent(i_input.rs): posts oneev_mouseevent per tic, withchocolate-doom-style acceleration (threshold 10, factor 2.0 — chocolate's
defaults) and the classic sign convention (positive
data3= forward,hence a Y flip).
main.rs): the cursor is grabbed (locked and hidden)while playing; Esc releases it, and a click inside the window or regaining
focus re-grabs it (the recapturing click is swallowed so it doesn't fire
in-game). Held buttons and pending motion are cleared on release so the
engine doesn't keep firing or turning from stale input. The window now
starts borderless-fullscreen.
tests/demo_playthrough.rs: no-opDG_GetMousestub so the headlesstest harness still links.
7. WASD movement defaults (
m_controls.rs)Default bindings moved from vanilla's arrows/comma/period to
w/a/s/d(arrow keys still turn), following Crispy Doom's modern defaults. Existing
.cfgfiles override these as before, but this does change out-of-the-boxbehavior — easy to drop if you'd rather keep vanilla defaults.
Testing
cargo test --releaseon macOS aarch64: 801 tests pass, including theshareware demo playthroughs (so the allocator and renderer changes don't
desync game logic).
DOOM.WADwith mouse capture, music(SC-55 soundfont via rustysynth), and level transitions exercising
Z_FreeTagsbeyond the old 2000-block cap.Combined diff
Against commit
904acac: