From dfa40fd55a8b29a1b2d816781da4cf637f99336b Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 12:10:14 -0400 Subject: [PATCH 1/2] fbdoom: preserve framebuffer game runtime --- .gitattributes | 1 + Formula/fbdoom.rb | 208 ++++++++ README.md | 1 + .../0001-fix-I_InitInput-signature.patch | 13 + patches/fbdoom/0002-add-mice-input.patch | 105 ++++ patches/fbdoom/0003-add-sound-support.patch | 325 ++++++++++++ .../0004-music-support-vendor-fixups.patch | 206 ++++++++ patches/fbdoom/0005-add-music-support.patch | 479 ++++++++++++++++++ ...x-G_CheckDemoStatus-atexit-signature.patch | 25 + .../0007-use-home-for-save-directory.patch | 14 + .../fbdoom/0008-exit-after-fatal-error.patch | 13 + 11 files changed, 1390 insertions(+) create mode 100644 .gitattributes create mode 100644 Formula/fbdoom.rb create mode 100644 patches/fbdoom/0001-fix-I_InitInput-signature.patch create mode 100644 patches/fbdoom/0002-add-mice-input.patch create mode 100644 patches/fbdoom/0003-add-sound-support.patch create mode 100644 patches/fbdoom/0004-music-support-vendor-fixups.patch create mode 100644 patches/fbdoom/0005-add-music-support.patch create mode 100644 patches/fbdoom/0006-fix-G_CheckDemoStatus-atexit-signature.patch create mode 100644 patches/fbdoom/0007-use-home-for-save-directory.patch create mode 100644 patches/fbdoom/0008-exit-after-fatal-error.patch diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..62f9de6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +patches/fbdoom/000[1-7]-*.patch -whitespace diff --git a/Formula/fbdoom.rb b/Formula/fbdoom.rb new file mode 100644 index 0000000..5acd810 --- /dev/null +++ b/Formula/fbdoom.rb @@ -0,0 +1,208 @@ +require (Tap.fetch("automattic", "kandelo-homebrew").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class Fbdoom < Formula + include KandeloFormulaSupport + + desc "Framebuffer-native Doom engine for Kandelo" + homepage "https://github.com/maximevince/fbDOOM" + url "https://github.com/maximevince/fbDOOM/archive/17280163bc95e5d954d2efaa0633489b763b4cd1.tar.gz" + version "0.1.0" + sha256 "77f57cee68fed438dffdba96f6070b8975c16652a63ddf4fb967994e5585a38a" + license "GPL-2.0-or-later" + + depends_on "wabt" => [:build, :test] + skip_clean "bin/fbdoom" + + resource "chocolate-doom" do + url "https://github.com/chocolate-doom/chocolate-doom/archive/35fb1372d10756ca27eca05665bd8a7cebc71c05.tar.gz" + sha256 "dc62c13cab469e19e0ad295b2dd7e460263c637a39c51d3771e96dabb08ecab2" + end + + resource "doom-shareware-test" do + url "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad" + sha256 "1d7d43be501e67d927e415e0b8f3e29c3bf33075e859721816f652a526cac771" + end + + SOURCE_DATE_EPOCH = "1775830255".freeze + + def install + kandelo_require_arch!("wasm32") + vendor_music_sources + apply_kandelo_patches + + artifact = buildpath/"fbdoom/fbdoom" + kandelo_wasm_build do |root| + source_identity = "/usr/src/fbdoom-#{version}" + path_flags = [ + "-ffile-prefix-map=#{buildpath}=#{source_identity}", + "-fdebug-prefix-map=#{buildpath}=#{source_identity}", + "-fmacro-prefix-map=#{buildpath}=#{source_identity}", + "-ffile-prefix-map=#{root}=/usr/src/kandelo", + "-fdebug-prefix-map=#{root}=/usr/src/kandelo", + "-fmacro-prefix-map=#{root}=/usr/src/kandelo", + ] + cflags = [ + "-O2", + "-DNORMALUNIX", + "-DLINUX", + "-D_DEFAULT_SOURCE", + "-Iopl", + *path_flags, + ].join(" ") + + ENV["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH + system "make", "-C", "fbdoom", "clean" + system "make", "-C", "fbdoom", "-j#{ENV.make_jobs}", + "CC=#{kandelo_cc(root)}", + "LD=#{kandelo_cc(root)}", + "CFLAGS=#{cflags}", + "LDFLAGS=-Wl,--gc-sections", + "LIBS=-lm", + "NOSDL=1" + + validate_artifact!(artifact, root) + end + + kandelo_install_bin(buildpath/"fbdoom", "fbdoom", "fbdoom") + end + + def vendor_music_sources + resource("chocolate-doom").stage do + opl_dir = buildpath/"fbdoom/opl" + opl_dir.mkpath + %w[opl.c opl.h opl3.c opl3.h opl_internal.h opl_queue.c opl_queue.h].each do |name| + cp "opl/#{name}", opl_dir/name + end + %w[mus2mid.c mus2mid.h midifile.c midifile.h].each do |name| + cp "src/#{name}", buildpath/"fbdoom"/name + end + end + end + + def apply_kandelo_patches + tap_root = Pathname(__dir__).parent + patches = (tap_root/"patches/fbdoom").glob("*.patch").sort + odie "fbDOOM patch set is incomplete" if patches.length != 8 + + script = <<~SH + set -euo pipefail + for patch in "$@"; do + git apply --check "$patch" + git apply "$patch" + done + SH + system kandelo_host_tool("bash"), "-c", script, "bash", *patches + end + + def validate_artifact!(artifact, root) + expected_abi = (Pathname(root)/"crates/shared/src/lib.rs").read[ + /^pub const ABI_VERSION: u32 = (\d+);$/, + 1, + ] + odie "could not read Kandelo ABI version" if expected_abi.nil? + + host_dist = Pathname(root)/"host/dist" + rm_r host_dist if host_dist.exist? + abi_probe = <<~JS + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + const { extractAbiVersion } = await import(pathToFileURL(process.argv[1]).href); + const bytes = readFileSync(process.argv[2]); + const program = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const abi = extractAbiVersion(program); + if (abi === null) process.exit(2); + process.stdout.write(String(abi)); + JS + artifact_abi = cd(root) do + Utils.safe_popen_read( + "node", "--import", "tsx/esm", "--input-type=module", "--eval", abi_probe, + Pathname(root)/"host/src/constants.ts", artifact + ).strip + end + odie "fbDOOM ABI #{artifact_abi} does not match Kandelo ABI #{expected_abi}" if artifact_abi != expected_abi + + guards = Pathname(root)/"scripts/wasm-artifact-guards.sh" + system "bash", "-c", <<~SH + set -euo pipefail + . #{guards.to_s.shellescape} + wasm_require_no_legacy_asyncify #{artifact.to_s.shellescape} + wasm_require_fork_instrumentation_if_needed #{artifact.to_s.shellescape} + unexpected_env_imports=$(wasm-objdump -x #{artifact.to_s.shellescape} | + awk '/<- env[.]/ { sub(/^.*<- env[.]/, ""); print $1 }' | + grep -Ev '^(__channel_base|memory|setjmp|longjmp)$' || true) + if [ -n "$unexpected_env_imports" ]; then + echo "ERROR: fbDOOM contains unresolved non-ABI env imports" >&2 + echo "$unexpected_env_imports" >&2 + exit 1 + fi + SH + + binary = artifact.binread + { + "formula build path" => buildpath.to_s, + "formula Cellar path" => prefix.to_s, + "Kandelo checkout path" => root.to_s, + "Nix store path" => "/nix/store/", + "temporary build path" => "/private/tmp/", + }.each do |description, marker| + odie "fbDOOM embeds #{description}: #{marker}" if binary.include?(marker) + end + odie "fbDOOM embeds a builder home path" if binary.match?(%r{/Users/[^/]+/}) + end + + test do + assert_equal "\0asm".b, File.binread(bin/"fbdoom", 4) + + # The shareware data is staged only for this test and is never installed + # in the keg or bottle. It is the same immutable IWAD used by Kandelo's + # Doom browser demo. + resource("doom-shareware-test").stage testpath + wad = testpath/"doom1.wad" + assert_path_exists wad + + # The timedemo loads and renders demo1 through Kandelo's Node host, then + # reaches fbDOOM's timing-report failure exit instead of its old NOSDL + # infinite loop. That path also covers the patched atexit callback + # signature instead of treating --version as game execution evidence. + node_output = kandelo_run_pty_wasm( + bin/"fbdoom", ["-iwad", "/doom1.wad", "-timedemo", "demo1"], + inputs: [], + env: { + "HOME" => "/tmp", + "KERNEL_CWD" => "/", + "TERM" => "xterm-256color", + "TIMEOUT" => "120000", + }, + guest_files: { "/doom1.wad" => wad }, + expected_status: 1 + ) + refute_includes node_output, "process timed out after" + assert_match(/timed \d+ gametics in \d+ realtics \([0-9.]+ fps\)/, node_output) + + browser_output = kandelo_run_framebuffer_wasm( + bin/"fbdoom", + argv: ["-iwad", "/doom1.wad"], + guest_files: { "/doom1.wad" => wad }, + min_writes: 2, + min_nonblank_pixels: 100_000, + timeout_ms: 45_000, + ) + assert_match( + /^kandelo-framebuffer-ok + \s+binds=\d+ + \s+writes=\d+ + \s+bytes=\d+ + \s+size=\d+x\d+ + \s+format=\S+ + \s+nonblank=\d+ + \s+screenshot-bytes=\d+$/x, + browser_output, + ) + + binary = File.binread(bin/"fbdoom") + refute_includes binary, prefix.to_s + refute_includes binary, "/nix/store/" + refute_match %r{/private/tmp/[^/]+/}, binary + refute_match %r{/Users/[^/]+/}, binary + end +end diff --git a/README.md b/README.md index 1d2c102..71f37a6 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Current migration controls and pilots include: Kandelo's musl `catopen` and `catgets` implementation. - `ctags`, Universal Ctags' maintained tag generator, `readtags` query client, and optscript interpreter with complete C and C++ workflows. +- `fbdoom`, the framebuffer-native Doom engine tested with a non-bundled shareware IWAD. The SDK is not yet a Homebrew dependency. Trusted builds supply an `HOMEBREW_KANDELO_ROOT` checkout containing the SDK, sysroot, kernel, and Node diff --git a/patches/fbdoom/0001-fix-I_InitInput-signature.patch b/patches/fbdoom/0001-fix-I_InitInput-signature.patch new file mode 100644 index 0000000..601886c --- /dev/null +++ b/patches/fbdoom/0001-fix-I_InitInput-signature.patch @@ -0,0 +1,13 @@ +diff --git a/fbdoom/i_video_fbdev.c b/fbdoom/i_video_fbdev.c +index c6e5925..a27b8ce 100644 +--- a/fbdoom/i_video_fbdev.c ++++ b/fbdoom/i_video_fbdev.c +@@ -202,7 +202,7 @@ void I_InitGraphics (void) + + screenvisible = true; + +- extern int I_InitInput(void); ++ extern void I_InitInput(void); + I_InitInput(); + } + diff --git a/patches/fbdoom/0002-add-mice-input.patch b/patches/fbdoom/0002-add-mice-input.patch new file mode 100644 index 0000000..e9afb7d --- /dev/null +++ b/patches/fbdoom/0002-add-mice-input.patch @@ -0,0 +1,105 @@ +diff --git a/fbdoom/i_input_tty.c b/fbdoom/i_input_tty.c +index 95e1b60..0fe7586 100644 +--- a/fbdoom/i_input_tty.c ++++ b/fbdoom/i_input_tty.c +@@ -255,6 +255,28 @@ static int old_mode = -1; + static struct termios old_term; + static int kb = -1; /* keyboard file descriptor */ + ++/* /dev/input/mice file descriptor + last-known button bitmask. The host ++ canvas listeners push PS/2 packets into the kernel queue; we drain ++ them per-tick from I_GetEvent. */ ++static int mice_fd = -1; ++static unsigned int mouse_buttons = 0; ++ ++static void mice_init(void) ++{ ++ mice_fd = open("/dev/input/mice", O_RDONLY | O_NONBLOCK); ++ if (mice_fd < 0) { ++ printf("Mouse: /dev/input/mice unavailable; mouse disabled.\n"); ++ } ++} ++ ++static void mice_shutdown(void) ++{ ++ if (mice_fd >= 0) { ++ close(mice_fd); ++ mice_fd = -1; ++ } ++} ++ + void kbd_shutdown(void) + { + /* Shut down nicely. */ +@@ -271,6 +293,8 @@ void kbd_shutdown(void) + if (kb > 3) + close(kb); + ++ mice_shutdown(); ++ + exit(0); + } + +@@ -426,6 +450,43 @@ static void UpdateShiftStatus(int pressed, unsigned char key) + } + + ++/* Drain queued PS/2 packets from /dev/input/mice and post one coalesced ++ ev_mouse per input poll. DOOM stores mouse motion as a per-tic delta, so ++ posting one event per packet lets later packets in the same tic overwrite ++ earlier motion and makes fast pointer-lock movement feel muted. ++ ++ PS/2 byte0 carries L/R/M button bits and sign/overflow flags; bytes 1..2 ++ are signed dx/dy with positive-up semantics for dy. The host inverts ++ browser deltaY before injecting, so dy is already in PS/2 (and DOOM) sign ++ convention. */ ++static void I_PollMice(void) ++{ ++ unsigned char pkt[3]; ++ event_t event; ++ int n; ++ int dx = 0; ++ int dy = 0; ++ unsigned int buttons = mouse_buttons; ++ ++ if (mice_fd < 0) return; ++ ++ while ((n = read(mice_fd, pkt, 3)) == 3) ++ { ++ buttons = pkt[0] & 0x07; ++ dx += (signed char)pkt[1]; ++ dy += (signed char)pkt[2]; ++ } ++ ++ if (dx == 0 && dy == 0 && buttons == mouse_buttons) return; ++ ++ mouse_buttons = buttons; ++ event.type = ev_mouse; ++ event.data1 = mouse_buttons; ++ event.data2 = dx; ++ event.data3 = dy; ++ D_PostEvent(&event); ++} ++ + void I_GetEvent(void) + { + event_t event; +@@ -433,7 +494,9 @@ void I_GetEvent(void) + unsigned char key; + + // put event-grabbing stuff in here +- ++ ++ I_PollMice(); ++ + while (kbd_read(&pressed, &key)) + { + if (key == 0x0E) { +@@ -494,6 +557,7 @@ void I_GetEvent(void) + void I_InitInput(void) + { + kbd_init(); ++ mice_init(); + + //UpdateFocus(); + } diff --git a/patches/fbdoom/0003-add-sound-support.patch b/patches/fbdoom/0003-add-sound-support.patch new file mode 100644 index 0000000..a89bcd9 --- /dev/null +++ b/patches/fbdoom/0003-add-sound-support.patch @@ -0,0 +1,325 @@ +diff --git a/fbdoom/Makefile b/fbdoom/Makefile +index fb32c7b..b566875 100644 +--- a/fbdoom/Makefile ++++ b/fbdoom/Makefile +@@ -18,6 +18,7 @@ endif + #CFLAGS+=-fsanitize=address + OBJS+=$(OBJDIR)/i_video_fbdev.o + OBJS+=$(OBJDIR)/i_input_tty.o ++OBJS+=$(OBJDIR)/i_kernel_sound.o + + CC=$(CROSS_COMPILE)gcc # gcc or g++ + CFLAGS+=-ggdb3 -Os +diff --git a/fbdoom/i_kernel_sound.c b/fbdoom/i_kernel_sound.c +new file mode 100644 +index 0000000..082b14b +--- /dev/null ++++ b/fbdoom/i_kernel_sound.c +@@ -0,0 +1,285 @@ ++/* ++ * i_kernel_sound.c — chocolate-doom sound module talking to /dev/dsp. ++ * ++ * The kandelo exposes an OSS-style /dev/dsp character ++ * device (kernel/audio.rs). This module fills the chocolate-doom ++ * `sound_module_t` shape, opens /dev/dsp once at init, mixes 8-bit ++ * mono SFX from WAD lumps into a 16-bit stereo @ 44.1 kHz mixbuffer, ++ * and `write()`s it to /dev/dsp every tic. The host (browser ++ * AudioContext) drains the kernel ring via the `kernel_drain_audio` ++ * export and feeds it back out as audio. ++ * ++ * No SDL / no ALSA / no resampler library — DOOM SFX sample rate ++ * (11025 Hz mono u8) is upmixed in-place with a 16.16 fixed-point ++ * step. Music (MUS / MIDI) is left to a future patch; this lands ++ * SFX only, which is what the demo most needs. ++ */ ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "doomtype.h" ++#include "deh_str.h" ++#include "i_sound.h" ++#include "m_misc.h" ++#include "w_wad.h" ++#include "z_zone.h" ++ ++/* OSS ioctls — same numeric values the kernel and Linux use. We ++ * hard-code rather than #include so the build ++ * doesn't pull additional headers out of the wasm sysroot. */ ++#define SNDCTL_DSP_RESET 0x00005000u ++#define SNDCTL_DSP_SPEED 0xc0045002u ++#define SNDCTL_DSP_STEREO 0xc0045003u ++#define SNDCTL_DSP_SETFMT 0xc0045005u ++#define SNDCTL_DSP_GETFMTS 0x8004500bu ++#define AFMT_S16_LE 0x10 ++ ++/* Output config — must match what the kernel ring is configured for ++ * (kernel auto-rounds to whole frames, so the host AudioContext sees ++ * stereo S16 @ 44100 Hz). */ ++#define OUTPUT_RATE 44100 ++#define OUTPUT_CHANNELS 2 ++#define BYTES_PER_FRAME (2 * OUTPUT_CHANNELS) /* S16 * stereo */ ++ ++/* DOOM ticks run at 35 Hz. At 44100 Hz that's exactly 1260 frames ++ * per tic. Match exactly so the producer rate equals the consumer ++ * rate — any mismatch makes the AudioContext queue grow without ++ * bound, sounds desync, and the kernel ring drops fresh SFX on ++ * overflow (most visibly: the pistol shot vanishes after a few s). */ ++#define MIX_FRAMES 1260 ++#define MIX_BYTES (MIX_FRAMES * BYTES_PER_FRAME) ++ ++#define NUM_CHANNELS 8 ++ ++/* DOOM SFX in WAD lumps are 11025 Hz mono u8. The 8-byte header ++ * encodes (u16 format, u16 sample_rate, u32 sample_count). We skip ++ * it and use the raw samples that follow. */ ++#define DOOM_SFX_HEADER_BYTES 8 ++#define DOOM_SFX_SAMPLE_RATE 11025 ++ ++/* 16.16 fixed-point step from DOOM rate to OUTPUT_RATE. */ ++#define SAMPLE_STEP ((DOOM_SFX_SAMPLE_RATE * 65536) / OUTPUT_RATE) ++ ++typedef struct { ++ int active; ++ int handle; ++ sfxinfo_t *sfxinfo; ++ const uint8_t *data; /* points past the 8-byte SFX header */ ++ uint32_t pos_fp; /* 16.16 fixed-point position in source samples */ ++ uint32_t length; /* total source samples (post-header) */ ++ int leftvol; /* 0..127 */ ++ int rightvol; /* 0..127 */ ++} sound_channel_t; ++ ++static int dsp_fd = -1; ++static boolean use_sfx_prefix; ++static int16_t mixbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; ++static sound_channel_t channels[NUM_CHANNELS]; ++ ++/* Monotonic id used to distinguish sounds played on the same channel ++ * over time. chocolate-doom's S_StartSoundEx hands the returned ++ * value back to I_StopSound / I_SoundIsPlaying. */ ++static int handle_counter = 100; ++ ++static int I_KERNEL_GetSfxLumpNum(sfxinfo_t *sfx) ++{ ++ char namebuf[9]; ++ snprintf(namebuf, sizeof namebuf, "%s%s", ++ use_sfx_prefix ? "ds" : "", sfx->name); ++ return W_GetNumForName(namebuf); ++} ++ ++static boolean I_KERNEL_InitSound(boolean _use_sfx_prefix) ++{ ++ use_sfx_prefix = _use_sfx_prefix; ++ ++ dsp_fd = open("/dev/dsp", O_WRONLY); ++ if (dsp_fd < 0) { ++ fprintf(stderr, ++ "I_KERNEL_InitSound: /dev/dsp unavailable; sound disabled.\n"); ++ return false; ++ } ++ ++ int speed = OUTPUT_RATE; ++ if (ioctl(dsp_fd, SNDCTL_DSP_SPEED, &speed) < 0) { ++ fprintf(stderr, ++ "I_KERNEL_InitSound: SNDCTL_DSP_SPEED failed; sound disabled.\n"); ++ close(dsp_fd); ++ dsp_fd = -1; ++ return false; ++ } ++ ++ int stereo = 1; /* 1 = stereo */ ++ if (ioctl(dsp_fd, SNDCTL_DSP_STEREO, &stereo) < 0) { ++ fprintf(stderr, ++ "I_KERNEL_InitSound: SNDCTL_DSP_STEREO failed; sound disabled.\n"); ++ close(dsp_fd); ++ dsp_fd = -1; ++ return false; ++ } ++ ++ int fmt = AFMT_S16_LE; ++ if (ioctl(dsp_fd, SNDCTL_DSP_SETFMT, &fmt) < 0) { ++ fprintf(stderr, ++ "I_KERNEL_InitSound: SNDCTL_DSP_SETFMT(S16_LE) failed; sound disabled.\n"); ++ close(dsp_fd); ++ dsp_fd = -1; ++ return false; ++ } ++ ++ memset(channels, 0, sizeof channels); ++ return true; ++} ++ ++static void I_KERNEL_ShutdownSound(void) ++{ ++ if (dsp_fd >= 0) { ++ close(dsp_fd); ++ dsp_fd = -1; ++ } ++} ++ ++static void I_KERNEL_UpdateSoundParams(int handle, int vol, int sep) ++{ ++ /* chocolate-doom passes the channel index here, not the handle — ++ * but the engine guards calls with SoundIsPlaying first, so a ++ * stale handle just no-ops. */ ++ for (int i = 0; i < NUM_CHANNELS; i++) { ++ if (channels[i].active && channels[i].handle == handle) { ++ int s = sep + 1; ++ int leftvol = vol - ((vol * s * s) >> 16); ++ s -= 257; ++ int rightvol = vol - ((vol * s * s) >> 16); ++ channels[i].leftvol = leftvol < 0 ? 0 : ++ (leftvol > 127 ? 127 : leftvol); ++ channels[i].rightvol = rightvol < 0 ? 0 : ++ (rightvol > 127 ? 127 : rightvol); ++ return; ++ } ++ } ++} ++ ++static int I_KERNEL_StartSound(sfxinfo_t *sfxinfo, int channel, ++ int vol, int sep) ++{ ++ if (dsp_fd < 0) return -1; ++ ++ int lump = I_KERNEL_GetSfxLumpNum(sfxinfo); ++ if (lump < 0) return -1; ++ int size = W_LumpLength(lump); ++ if (size <= DOOM_SFX_HEADER_BYTES) return -1; ++ ++ uint8_t *data = (uint8_t *)W_CacheLumpNum(lump, PU_STATIC); ++ ++ int slot = channel; ++ if (slot < 0 || slot >= NUM_CHANNELS) { ++ slot = 0; ++ for (int i = 0; i < NUM_CHANNELS; i++) { ++ if (!channels[i].active) { slot = i; break; } ++ } ++ } ++ ++ int handle = ++handle_counter; ++ if (handle <= 0) { handle_counter = 100; handle = 100; } ++ ++ channels[slot].active = 1; ++ channels[slot].handle = handle; ++ channels[slot].sfxinfo = sfxinfo; ++ channels[slot].data = data + DOOM_SFX_HEADER_BYTES; ++ channels[slot].pos_fp = 0; ++ channels[slot].length = (uint32_t)(size - DOOM_SFX_HEADER_BYTES); ++ /* Default vol/sep until S_UpdateSounds re-tickles us next tic. */ ++ channels[slot].leftvol = vol > 127 ? 127 : (vol < 0 ? 0 : vol); ++ channels[slot].rightvol = channels[slot].leftvol; ++ ++ I_KERNEL_UpdateSoundParams(handle, vol, sep); ++ return handle; ++} ++ ++static void I_KERNEL_StopSound(int handle) ++{ ++ for (int i = 0; i < NUM_CHANNELS; i++) { ++ if (channels[i].active && channels[i].handle == handle) { ++ channels[i].active = 0; ++ return; ++ } ++ } ++} ++ ++static boolean I_KERNEL_SoundIsPlaying(int handle) ++{ ++ for (int i = 0; i < NUM_CHANNELS; i++) { ++ if (channels[i].active && channels[i].handle == handle) return true; ++ } ++ return false; ++} ++ ++static void I_KERNEL_PrecacheSounds(sfxinfo_t *sounds, int num_sounds) ++{ ++ /* On-demand load via W_CacheLumpNum in StartSound — no-op here. */ ++ (void)sounds; (void)num_sounds; ++} ++ ++/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. Called ++ * once per game tic from S_UpdateSounds → I_UpdateSound. */ ++static void I_KERNEL_UpdateSound(void) ++{ ++ if (dsp_fd < 0) return; ++ ++ memset(mixbuffer, 0, sizeof mixbuffer); ++ ++ for (int frame = 0; frame < MIX_FRAMES; frame++) { ++ int dl = 0, dr = 0; ++ for (int c = 0; c < NUM_CHANNELS; c++) { ++ sound_channel_t *ch = &channels[c]; ++ if (!ch->active) continue; ++ ++ uint32_t idx = ch->pos_fp >> 16; ++ if (idx >= ch->length) { ch->active = 0; continue; } ++ ++ /* u8 -> s16 centered at 0; multiply in 8-bit-volume ++ * space then take the low 16 bits as the mixer ++ * contribution. */ ++ int sample = ((int)ch->data[idx] - 128) << 8; ++ dl += (sample * ch->leftvol) / 127; ++ dr += (sample * ch->rightvol) / 127; ++ ++ ch->pos_fp += SAMPLE_STEP; ++ } ++ if (dl > 0x7fff) dl = 0x7fff; ++ if (dl < -0x8000) dl = -0x8000; ++ if (dr > 0x7fff) dr = 0x7fff; ++ if (dr < -0x8000) dr = -0x8000; ++ mixbuffer[frame * 2] = (int16_t)dl; ++ mixbuffer[frame * 2 + 1] = (int16_t)dr; ++ } ++ ++ /* The kernel ring drops oldest frames on overflow rather than ++ * blocking, so a slow drain just costs audio — never wedges ++ * the game loop. We never short-write here in practice. */ ++ (void)write(dsp_fd, mixbuffer, sizeof mixbuffer); ++} ++ ++static snddevice_t sound_kernel_devices[] = { ++ SNDDEVICE_SB, ++}; ++ ++sound_module_t sound_kernel_module = { ++ sound_kernel_devices, ++ sizeof(sound_kernel_devices) / sizeof(sound_kernel_devices[0]), ++ I_KERNEL_InitSound, ++ I_KERNEL_ShutdownSound, ++ I_KERNEL_GetSfxLumpNum, ++ I_KERNEL_UpdateSound, ++ I_KERNEL_UpdateSoundParams, ++ I_KERNEL_StartSound, ++ I_KERNEL_StopSound, ++ I_KERNEL_SoundIsPlaying, ++ I_KERNEL_PrecacheSounds, ++}; +diff --git a/fbdoom/i_sound.c b/fbdoom/i_sound.c +index 71947d2..d4b4017 100644 +--- a/fbdoom/i_sound.c ++++ b/fbdoom/i_sound.c +@@ -89,8 +89,16 @@ static int snd_mport = 0; + + // Compiled-in sound modules: + +-static sound_module_t *sound_modules[] = ++// /dev/dsp-backed sound module — defined in i_kernel_sound.c. We ++// register it unconditionally (no FEATURE_SOUND gate) because the ++// Kandelo always exposes /dev/dsp; if the open fails (no ++// AudioContext sink wired up host-side) the module's Init returns ++// false and the dispatcher falls through to silence. ++extern sound_module_t sound_kernel_module; ++ ++static sound_module_t *sound_modules[] = + { ++ &sound_kernel_module, + #ifdef FEATURE_SOUND + &sound_sdl_module, + &sound_pcsound_module, diff --git a/patches/fbdoom/0004-music-support-vendor-fixups.patch b/patches/fbdoom/0004-music-support-vendor-fixups.patch new file mode 100644 index 0000000..5163427 --- /dev/null +++ b/patches/fbdoom/0004-music-support-vendor-fixups.patch @@ -0,0 +1,206 @@ +--- a/fbdoom/opl/opl.c ++++ b/fbdoom/opl/opl.c +@@ -15,14 +15,14 @@ + // OPL interface. + // + +-#include "config.h" ++// kandelo build: no autotools config.h, no SDL. The only ++// driver shipped is opl_kernel.c, which is a pull-model driver suitable ++// for the single-threaded /dev/dsp mixing loop in i_kernel_sound.c. + + #include + #include + #include + +-#include "SDL.h" +- + #include "opl.h" + #include "opl_internal.h" + +@@ -31,18 +31,7 @@ + + static opl_driver_t *drivers[] = + { +-#if (defined(__i386__) || defined(__x86_64__)) && defined(HAVE_IOPERM) +- &opl_linux_driver, +-#endif +-#if defined(HAVE_LIBI386) || defined(HAVE_LIBAMD64) +- &opl_openbsd_driver, +-#endif +-#ifdef _WIN32 +- &opl_win32_driver, +-#endif +-#ifndef DISABLE_SDL2MIXER +- &opl_sdl_driver, +-#endif // DISABLE_SDL2MIXER ++ &opl_kernel_driver, + NULL + }; + +@@ -441,61 +430,18 @@ + { + driver->unlock_func(); + } +-} +- +-typedef struct +-{ +- int finished; +- +- SDL_mutex *mutex; +- SDL_cond *cond; +-} delay_data_t; +- +-static void DelayCallback(void *_delay_data) +-{ +- delay_data_t *delay_data = _delay_data; +- +- SDL_LockMutex(delay_data->mutex); +- delay_data->finished = 1; +- +- SDL_CondSignal(delay_data->cond); +- +- SDL_UnlockMutex(delay_data->mutex); + } + + void OPL_Delay(uint64_t us) + { +- delay_data_t delay_data; +- +- if (driver == NULL) ++ // Single-threaded pull model: there is no audio thread to wake us ++ // up, so the driver advances its emulated clock synchronously and ++ // fires any due callbacks before we return. Used by OPL_Detect's ++ // ~1ms wait for the OPL chip's hardware timer to expire. ++ if (driver != NULL && driver->delay_func != NULL) + { +- return; ++ driver->delay_func(us); + } +- +- // Create a callback that will signal this thread after the +- // specified time. +- +- delay_data.finished = 0; +- delay_data.mutex = SDL_CreateMutex(); +- delay_data.cond = SDL_CreateCond(); +- +- OPL_SetCallback(us, DelayCallback, &delay_data); +- +- // Wait until the callback is invoked. +- +- SDL_LockMutex(delay_data.mutex); +- +- while (!delay_data.finished) +- { +- SDL_CondWait(delay_data.cond, delay_data.mutex); +- } +- +- SDL_UnlockMutex(delay_data.mutex); +- +- // Clean up. +- +- SDL_DestroyMutex(delay_data.mutex); +- SDL_DestroyCond(delay_data.cond); + } + + void OPL_SetPaused(int paused) +--- a/fbdoom/opl/opl_internal.h ++++ b/fbdoom/opl/opl_internal.h +@@ -33,6 +33,7 @@ + typedef void (*opl_unlock_func)(void); + typedef void (*opl_set_paused_func)(int paused); + typedef void (*opl_adjust_callbacks_func)(float value); ++typedef void (*opl_delay_func)(uint64_t us); + + typedef struct + { +@@ -48,6 +49,11 @@ + opl_unlock_func unlock_func; + opl_set_paused_func set_paused_func; + opl_adjust_callbacks_func adjust_callbacks_func; ++ // Synchronous time-advance for OPL_Detect's 1ms wait. The SDL driver ++ // implemented OPL_Delay via a callback signalled from the audio ++ // thread; with no audio thread (single-threaded pull model) the ++ // driver advances its own emulated clock directly. ++ opl_delay_func delay_func; + } opl_driver_t; + + // Sample rate to use when doing software emulation. +@@ -55,16 +61,10 @@ + extern unsigned int opl_sample_rate; + + +-#if (defined(__i386__) || defined(__x86_64__)) && defined(HAVE_IOPERM) +-extern opl_driver_t opl_linux_driver; +-#endif +-#if defined(HAVE_LIBI386) || defined(HAVE_LIBAMD64) +-extern opl_driver_t opl_openbsd_driver; +-#endif +-#ifdef _WIN32 +-extern opl_driver_t opl_win32_driver; +-#endif +-extern opl_driver_t opl_sdl_driver; ++// The kandelo build only ships the kernel pull driver — all ++// SDL/Linux/Win32 hardware drivers are stripped (no SDL in the sysroot, ++// no real OPL chip behind a wasm sandbox). ++extern opl_driver_t opl_kernel_driver; + + + #endif /* #ifndef OPL_INTERNAL_H */ +--- a/fbdoom/midifile.c ++++ b/fbdoom/midifile.c +@@ -26,6 +26,23 @@ + #include "m_misc.h" + #include "midifile.h" + ++// fbDOOM's i_swap.h doesn't pull in , so the SDL_SwapBE* ++// calls midifile.c uses to read big-endian MIDI fields are otherwise ++// unresolved. wasm32 is fixed little-endian; an unconditional builtin ++// swap is the cheapest equivalent. ++#define SDL_SwapBE16(x) ((unsigned short) __builtin_bswap16((unsigned short)(x))) ++#define SDL_SwapBE32(x) ((unsigned int) __builtin_bswap32((unsigned int)(x))) ++ ++// fbDOOM's doomtype.h ships PACKEDATTR but not the PACKED_STRUCT ++// wrapper macro chocolate-doom added later. ++#define PACKED_STRUCT(...) struct __VA_ARGS__ PACKEDATTR ++ ++// chocolate-doom-3.1.0 introduced I_Realloc (realloc + abort on NULL) ++// in i_system.c, and M_fopen (UTF-8 wrapper, plain fopen on POSIX) in ++// m_misc.c. Neither exists in the fbDOOM tree; map to libc directly. ++#define I_Realloc realloc ++#define M_fopen fopen ++ + #define HEADER_CHUNK_ID "MThd" + #define TRACK_CHUNK_ID "MTrk" + #define MAX_BUFFER_SIZE 0x10000 +--- a/fbdoom/midifile.h ++++ b/fbdoom/midifile.h +@@ -78,6 +78,11 @@ + MIDI_CONTROLLER_POLY_MODE_OFF = 0x7E, + MIDI_CONTROLLER_POLY_MODE_ON = 0x7F, + } midi_controller_t; ++ ++// fbDOOM's i_oplmusic.c (forked from a 2014-era chocolate-doom) still ++// uses the older spelling for controller 0x07; chocolate-doom-3.1.0 ++// renamed it MSB-explicit. Both spellings refer to the same value. ++#define MIDI_CONTROLLER_MAIN_VOLUME MIDI_CONTROLLER_VOLUME_MSB + + typedef enum + { +--- a/fbdoom/mus2mid.c ++++ b/fbdoom/mus2mid.c +@@ -23,6 +23,12 @@ + #include "memio.h" + #include "mus2mid.h" + ++// fbDOOM's doomtype.h ships PACKEDATTR but not the PACKED_STRUCT ++// wrapper macro chocolate-doom added later. mus2mid.c is the only ++// vendored source that uses it, so define it locally rather than ++// touching fbDOOM's doomtype.h. ++#define PACKED_STRUCT(...) struct __VA_ARGS__ PACKEDATTR ++ + #define NUM_CHANNELS 16 + + #define MIDI_PERCUSSION_CHAN 9 diff --git a/patches/fbdoom/0005-add-music-support.patch b/patches/fbdoom/0005-add-music-support.patch new file mode 100644 index 0000000..ffd4555 --- /dev/null +++ b/patches/fbdoom/0005-add-music-support.patch @@ -0,0 +1,479 @@ +--- a/fbdoom/i_kernel_sound.c ++++ b/fbdoom/i_kernel_sound.c +@@ -11,8 +11,10 @@ + * + * No SDL / no ALSA / no resampler library — DOOM SFX sample rate + * (11025 Hz mono u8) is upmixed in-place with a 16.16 fixed-point +- * step. Music (MUS / MIDI) is left to a future patch; this lands +- * SFX only, which is what the demo most needs. ++ * step. Music (MUS/MIDI through GENMIDI -> OPL2 emulation) is mixed ++ * into the same per-tic mixbuffer below: OPL_Kernel_FillBuffer fills a ++ * stereo S16 buffer at OUTPUT_RATE which we accumulate alongside SFX ++ * before clamping. One write() per tic carries both. + */ + + #include +@@ -26,6 +28,7 @@ + #include "doomtype.h" + #include "deh_str.h" + #include "i_sound.h" ++#include "i_timer.h" + #include "m_misc.h" + #include "w_wad.h" + #include "z_zone.h" +@@ -80,8 +83,18 @@ + static int dsp_fd = -1; + static boolean use_sfx_prefix; + static int16_t mixbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; ++/* Music samples produced by the OPL emulator each tic (when active). ++ * Filled by OPL_Kernel_FillBuffer (opl/opl_kernel.c) and accumulated ++ * into mixbuffer alongside SFX. When music is inactive the call ++ * returns silence so the mixer path is uniform. */ ++static int16_t musicbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; ++extern void OPL_Kernel_FillBuffer(int16_t *out, unsigned int frames); + static sound_channel_t channels[NUM_CHANNELS]; + ++/* S_UpdateSounds runs once per render frame, not once per tic. Gate on ++ * I_GetTime so OPL time advances at wallclock; else music tempo races. */ ++static int last_mix_tic = -1; ++ + /* Monotonic id used to distinguish sounds played on the same channel + * over time. chocolate-doom's S_StartSoundEx hands the returned + * value back to I_StopSound / I_SoundIsPlaying. */ +@@ -226,16 +239,26 @@ + (void)sounds; (void)num_sounds; + } + +-/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. Called +- * once per game tic from S_UpdateSounds → I_UpdateSound. */ ++/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. */ + static void I_KERNEL_UpdateSound(void) + { + if (dsp_fd < 0) return; + ++ int tic = I_GetTime(); ++ if (tic == last_mix_tic) return; ++ last_mix_tic = tic; ++ + memset(mixbuffer, 0, sizeof mixbuffer); + ++ /* Music base layer. OPL emulator runs at OUTPUT_RATE so no ++ * resampling — but it does fire MIDI register-write callbacks ++ * scheduled by i_oplmusic as time advances inside this call, ++ * which is how tempo / note events stay sample-precise. */ ++ OPL_Kernel_FillBuffer(musicbuffer, MIX_FRAMES); ++ + for (int frame = 0; frame < MIX_FRAMES; frame++) { +- int dl = 0, dr = 0; ++ int dl = musicbuffer[frame * 2]; ++ int dr = musicbuffer[frame * 2 + 1]; + for (int c = 0; c < NUM_CHANNELS; c++) { + sound_channel_t *ch = &channels[c]; + if (!ch->active) continue; +--- a/fbdoom/i_sound.c ++++ b/fbdoom/i_sound.c +@@ -107,12 +107,20 @@ + }; + + // Compiled-in music modules: ++ ++// OPL music (i_oplmusic.c) — synthesizes MUS/MIDI through the ++// chocolate-doom OPL2 emulator (opl/opl3.c) into PCM that ++// i_kernel_sound mixes into the same /dev/dsp write as SFX. We register ++// it unconditionally so the dispatcher picks it up for SNDDEVICE_SB / ++// SNDDEVICE_ADLIB; if OPL_Init or GENMIDI-lump load fails the module's ++// Init returns false and the dispatcher falls through to silence. ++extern music_module_t music_opl_module; + + static music_module_t *music_modules[] = + { ++ &music_opl_module, + #ifdef FEATURE_SOUND + &music_sdl_module, +- &music_opl_module, + #endif + NULL, + }; +--- a/fbdoom/Makefile ++++ b/fbdoom/Makefile +@@ -20,6 +20,15 @@ + OBJS+=$(OBJDIR)/i_input_tty.o + OBJS+=$(OBJDIR)/i_kernel_sound.o + ++# Music: MUS->MIDI->OPL2 software synthesis, mixed into /dev/dsp. ++OBJS+=$(OBJDIR)/i_oplmusic.o ++OBJS+=$(OBJDIR)/mus2mid.o ++OBJS+=$(OBJDIR)/midifile.o ++OBJS+=$(OBJDIR)/opl/opl.o ++OBJS+=$(OBJDIR)/opl/opl3.o ++OBJS+=$(OBJDIR)/opl/opl_queue.o ++OBJS+=$(OBJDIR)/opl/opl_kernel.o ++ + CC=$(CROSS_COMPILE)gcc # gcc or g++ + CFLAGS+=-ggdb3 -Os + LDFLAGS+=-Wl,--gc-sections +@@ -58,6 +67,7 @@ + mkdir -p $(OBJDIR) + + $(OBJDIR)/%.o: %.c ++ @mkdir -p $(@D) + @echo [Compiling $<] + $(VB)$(CC) $(CFLAGS) -c $< -o $@ + +--- a/fbdoom/opl/opl_kernel.c ++++ b/fbdoom/opl/opl_kernel.c +@@ -0,0 +1,354 @@ ++// ++// Copyright(C) 2005-2014 Simon Howard ++// Copyright(C) 2026 kandelo authors ++// ++// This program is free software; you can redistribute it and/or ++// modify it under the terms of the GNU General Public License ++// as published by the Free Software Foundation; either version 2 ++// of the License, or (at your option) any later version. ++// ++// This program is distributed in the hope that it will be useful, ++// but WITHOUT ANY WARRANTY; without even the implied warranty of ++// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++// GNU General Public License for more details. ++// ++// DESCRIPTION: ++// OPL pull-model driver — kandelo /dev/dsp variant. ++// ++// Chocolate-doom's OPL frontend (opl.c) drives an opl_driver_t ++// impl. The stock SDL driver (opl_sdl.c) is push-based: SDL's audio ++// thread invokes OPL_Mix_Callback, which advances time and calls ++// OPL3_GenerateStream into SDL's mix buffer. We have no audio ++// thread — i_kernel_sound.c sits in the game-tic loop and pulls ++// audio synchronously every ~28 ms before write()-ing to /dev/dsp. ++// ++// Hence "pull": OPL_Kernel_FillBuffer is the entry point. It ++// advances current_time as samples are produced, fires any due ++// MIDI register-write callbacks queued by i_oplmusic, and writes ++// stereo S16 frames into the caller-provided buffer at ++// opl_sample_rate. No threads; OPL_Lock/Unlock are no-ops. ++// ++// Behavior of port reads, register writes, timers and OPL3 mode is ++// a direct port of opl_sdl.c with the SDL primitives removed. ++// ++ ++#include ++#include ++#include ++ ++#include "opl3.h" ++ ++#include "opl.h" ++#include "opl_internal.h" ++ ++#include "opl_queue.h" ++ ++typedef struct ++{ ++ unsigned int rate; // Times the timer is advanced per second. ++ unsigned int enabled; // Non-zero if timer is enabled. ++ unsigned int value; // Last value set (used to compute expiry). ++ uint64_t expire_time; // Calculated time the timer will expire. ++} opl_timer_t; ++ ++static opl_callback_queue_t *callback_queue = NULL; ++ ++// Current time in microseconds since OPL_Init. Advanced by AdvanceTime ++// as samples are produced (and by OPL_Kernel_Delay during OPL_Detect). ++static uint64_t current_time; ++ ++// If non-zero, time still advances but pause_offset accumulates so ++// callbacks scheduled before the pause fire after we resume. ++static int paused; ++static uint64_t pause_offset; ++ ++// OPL3 software emulator state. ++static opl3_chip opl_chip; ++ ++// Last value written to OPL_REGISTER_PORT (or _OPL3); paired with the ++// next OPL_DATA_PORT write to form a (reg, value) pair. ++static int register_num; ++ ++// Hardware OPL timers: timer1 ticks at 12500 Hz (80 µs / tick), timer2 ++// at 3125 Hz. OPL_Detect uses timer1 to verify chip presence. ++static opl_timer_t timer1 = { 12500, 0, 0, 0 }; ++static opl_timer_t timer2 = { 3125, 0, 0, 0 }; ++ ++// Pop and invoke any callbacks whose scheduled time has passed. ++static void RunDueCallbacks(void) ++{ ++ opl_callback_t callback; ++ void *callback_data; ++ ++ while (callback_queue != NULL ++ && !OPL_Queue_IsEmpty(callback_queue) ++ && current_time >= OPL_Queue_Peek(callback_queue) + pause_offset) ++ { ++ if (!OPL_Queue_Pop(callback_queue, &callback, &callback_data)) ++ { ++ break; ++ } ++ callback(callback_data); ++ } ++} ++ ++// Advance emulated time by `nsamples` worth of microseconds. ++static void AdvanceTime(uint64_t nsamples) ++{ ++ uint64_t us; ++ ++ us = (nsamples * OPL_SECOND) / opl_sample_rate; ++ current_time += us; ++ ++ if (paused) ++ { ++ pause_offset += us; ++ } ++ ++ RunDueCallbacks(); ++} ++ ++// Generate `frames` stereo S16 frames into `out` and advance time ++// alongside, firing callbacks at sample-precise points so MIDI tempo ++// stays exact regardless of how often the caller pulls. ++// ++// Caller-facing entry point. Declared in opl_kernel.h-equivalent ++// extern in i_kernel_sound.c. ++void OPL_Kernel_FillBuffer(int16_t *out, unsigned int frames) ++{ ++ unsigned int filled = 0; ++ ++ // Init guard: if OPL_Init wasn't called (i.e., music subsystem ++ // never came up — `-nomusic`, missing GENMIDI lump, etc.) the ++ // opl3_chip is undefined memory. Caller still calls us every tic ++ // for a uniform mixer path, so produce silence. ++ if (callback_queue == NULL) ++ { ++ memset(out, 0, frames * 2 * sizeof(int16_t)); ++ return; ++ } ++ ++ while (filled < frames) ++ { ++ uint64_t chunk; ++ ++ // How many samples can we generate before the next callback? ++ if (paused ++ || callback_queue == NULL ++ || OPL_Queue_IsEmpty(callback_queue)) ++ { ++ chunk = frames - filled; ++ } ++ else ++ { ++ uint64_t next_us = OPL_Queue_Peek(callback_queue) + pause_offset; ++ ++ if (next_us <= current_time) ++ { ++ chunk = 0; ++ } ++ else ++ { ++ uint64_t us_until = next_us - current_time; ++ uint64_t s = (us_until * opl_sample_rate + OPL_SECOND - 1) ++ / OPL_SECOND; ++ if (s == 0) s = 1; ++ if (s > frames - filled) s = frames - filled; ++ chunk = s; ++ } ++ } ++ ++ if (chunk > 0) ++ { ++ // OPL3_GenerateStream lays down `chunk` interleaved L/R ++ // int16 frames; out's caller treats it as int16_t[frames*2]. ++ OPL3_GenerateStream(&opl_chip, ++ (Bit16s *)(out + filled * 2), ++ (Bit32u) chunk); ++ filled += chunk; ++ } ++ ++ AdvanceTime(chunk); ++ } ++} ++ ++static int OPL_Kernel_Init(unsigned int port_base) ++{ ++ paused = 0; ++ pause_offset = 0; ++ callback_queue = OPL_Queue_Create(); ++ current_time = 0; ++ ++ OPL3_Reset(&opl_chip, opl_sample_rate); ++ ++ return 1; ++} ++ ++static void OPL_Kernel_Shutdown(void) ++{ ++ if (callback_queue != NULL) ++ { ++ OPL_Queue_Destroy(callback_queue); ++ callback_queue = NULL; ++ } ++} ++ ++static unsigned int OPL_Kernel_PortRead(opl_port_t port) ++{ ++ unsigned int result = 0; ++ ++ if (port == OPL_REGISTER_PORT_OPL3) ++ { ++ return 0xff; ++ } ++ ++ if (timer1.enabled && current_time > timer1.expire_time) ++ { ++ result |= 0x80; ++ result |= 0x40; ++ } ++ ++ if (timer2.enabled && current_time > timer2.expire_time) ++ { ++ result |= 0x80; ++ result |= 0x20; ++ } ++ ++ return result; ++} ++ ++static void OPLTimer_CalculateEndTime(opl_timer_t *timer) ++{ ++ int tics; ++ ++ if (timer->enabled) ++ { ++ tics = 0x100 - timer->value; ++ timer->expire_time = current_time ++ + ((uint64_t) tics * OPL_SECOND) / timer->rate; ++ } ++} ++ ++static void WriteRegister(unsigned int reg_num, unsigned int value) ++{ ++ switch (reg_num) ++ { ++ case OPL_REG_TIMER1: ++ timer1.value = value; ++ OPLTimer_CalculateEndTime(&timer1); ++ break; ++ ++ case OPL_REG_TIMER2: ++ timer2.value = value; ++ OPLTimer_CalculateEndTime(&timer2); ++ break; ++ ++ case OPL_REG_TIMER_CTRL: ++ if (value & 0x80) ++ { ++ timer1.enabled = 0; ++ timer2.enabled = 0; ++ } ++ else ++ { ++ if ((value & 0x40) == 0) ++ { ++ timer1.enabled = (value & 0x01) != 0; ++ OPLTimer_CalculateEndTime(&timer1); ++ } ++ ++ if ((value & 0x20) == 0) ++ { ++ timer1.enabled = (value & 0x02) != 0; ++ OPLTimer_CalculateEndTime(&timer2); ++ } ++ } ++ break; ++ ++ default: ++ OPL3_WriteRegBuffered(&opl_chip, reg_num, value); ++ break; ++ } ++} ++ ++static void OPL_Kernel_PortWrite(opl_port_t port, unsigned int value) ++{ ++ if (port == OPL_REGISTER_PORT) ++ { ++ register_num = value; ++ } ++ else if (port == OPL_REGISTER_PORT_OPL3) ++ { ++ register_num = value | 0x100; ++ } ++ else if (port == OPL_DATA_PORT) ++ { ++ WriteRegister(register_num, value); ++ } ++} ++ ++static void OPL_Kernel_SetCallback(uint64_t us, opl_callback_t callback, ++ void *data) ++{ ++ if (callback_queue != NULL) ++ { ++ OPL_Queue_Push(callback_queue, callback, data, ++ current_time - pause_offset + us); ++ } ++} ++ ++static void OPL_Kernel_ClearCallbacks(void) ++{ ++ if (callback_queue != NULL) ++ { ++ OPL_Queue_Clear(callback_queue); ++ } ++} ++ ++// Single-threaded — no concurrent producer to lock against. ++static void OPL_Kernel_Lock(void) { } ++static void OPL_Kernel_Unlock(void) { } ++ ++static void OPL_Kernel_SetPaused(int p) ++{ ++ paused = p; ++} ++ ++static void OPL_Kernel_AdjustCallbacks(float factor) ++{ ++ if (callback_queue != NULL) ++ { ++ OPL_Queue_AdjustCallbacks(callback_queue, current_time, factor); ++ } ++} ++ ++// Used only by OPL_Detect, which writes a timer value, "waits 1ms" and ++// re-reads status to confirm the (emulated) chip is alive. We just step ++// the clock; no audio output is needed for detection. ++static void OPL_Kernel_Delay(uint64_t us) ++{ ++ current_time += us; ++ ++ if (paused) ++ { ++ pause_offset += us; ++ } ++ ++ RunDueCallbacks(); ++} ++ ++opl_driver_t opl_kernel_driver = ++{ ++ "Kernel", ++ OPL_Kernel_Init, ++ OPL_Kernel_Shutdown, ++ OPL_Kernel_PortRead, ++ OPL_Kernel_PortWrite, ++ OPL_Kernel_SetCallback, ++ OPL_Kernel_ClearCallbacks, ++ OPL_Kernel_Lock, ++ OPL_Kernel_Unlock, ++ OPL_Kernel_SetPaused, ++ OPL_Kernel_AdjustCallbacks, ++ OPL_Kernel_Delay, ++}; diff --git a/patches/fbdoom/0006-fix-G_CheckDemoStatus-atexit-signature.patch b/patches/fbdoom/0006-fix-G_CheckDemoStatus-atexit-signature.patch new file mode 100644 index 0000000..d604d53 --- /dev/null +++ b/patches/fbdoom/0006-fix-G_CheckDemoStatus-atexit-signature.patch @@ -0,0 +1,25 @@ +diff --git a/fbdoom/d_main.c b/fbdoom/d_main.c +index 0a6d001..c3df7d2 100644 +--- a/fbdoom/d_main.c ++++ b/fbdoom/d_main.c +@@ -1082,4 +1082,9 @@ static void D_Endoom(void) + I_Endoom(endoom); + } +- ++ ++static void D_CheckDemoStatusAtExit(void) ++{ ++ (void) G_CheckDemoStatus(); ++} ++ + #if ORIGCODE +@@ -1507,6 +1512,6 @@ void D_DoomMain (void) + printf("Playing demo %s.\n", file); + } +- +- I_AtExit((atexit_func_t) G_CheckDemoStatus, true); +- ++ ++ I_AtExit(D_CheckDemoStatusAtExit, true); ++ + // Generate the WAD hash table. Speed things up a bit. diff --git a/patches/fbdoom/0007-use-home-for-save-directory.patch b/patches/fbdoom/0007-use-home-for-save-directory.patch new file mode 100644 index 0000000..22cc8d2 --- /dev/null +++ b/patches/fbdoom/0007-use-home-for-save-directory.patch @@ -0,0 +1,14 @@ +diff --git a/fbdoom/m_config.c b/fbdoom/m_config.c +index e60a3bf..c746f41 100644 +--- a/fbdoom/m_config.c ++++ b/fbdoom/m_config.c +@@ -2053,8 +2053,7 @@ static char *GetDefaultConfigDir(void) + char *homedir; + char *result; + +- // frosted HACK - homedir = getenv("HOME"); +- homedir = "/mnt"; ++ homedir = getenv("HOME"); + + if (homedir != NULL) + { diff --git a/patches/fbdoom/0008-exit-after-fatal-error.patch b/patches/fbdoom/0008-exit-after-fatal-error.patch new file mode 100644 index 0000000..ae35ab6 --- /dev/null +++ b/patches/fbdoom/0008-exit-after-fatal-error.patch @@ -0,0 +1,13 @@ +diff --git a/fbdoom/i_system.c b/fbdoom/i_system.c +index 3c3f3df..9fc110a 100644 +--- a/fbdoom/i_system.c ++++ b/fbdoom/i_system.c +@@ -461,7 +461,5 @@ void I_Error (char *error, ...) + exit(-1); + #else +- while (true) +- { +- } ++ exit(EXIT_FAILURE); + #endif + } From 7c3386159d03fd564f7290667c3d544009065379 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 12 Jul 2026 22:55:21 -0400 Subject: [PATCH 2/2] fbdoom: enforce shared artifact validation --- Formula/fbdoom.rb | 48 ++++------------------------------------------- 1 file changed, 4 insertions(+), 44 deletions(-) diff --git a/Formula/fbdoom.rb b/Formula/fbdoom.rb index 5acd810..2a43777 100644 --- a/Formula/fbdoom.rb +++ b/Formula/fbdoom.rb @@ -10,6 +10,7 @@ class Fbdoom < Formula sha256 "77f57cee68fed438dffdba96f6070b8975c16652a63ddf4fb967994e5585a38a" license "GPL-2.0-or-later" + depends_on "binaryen" => :build depends_on "wabt" => [:build, :test] skip_clean "bin/fbdoom" @@ -60,7 +61,8 @@ def install "LIBS=-lm", "NOSDL=1" - validate_artifact!(artifact, root) + kandelo_validate_wasm_artifact(artifact, fork: :forbidden) + validate_imports!(artifact) end kandelo_install_bin(buildpath/"fbdoom", "fbdoom", "fbdoom") @@ -94,39 +96,9 @@ def apply_kandelo_patches system kandelo_host_tool("bash"), "-c", script, "bash", *patches end - def validate_artifact!(artifact, root) - expected_abi = (Pathname(root)/"crates/shared/src/lib.rs").read[ - /^pub const ABI_VERSION: u32 = (\d+);$/, - 1, - ] - odie "could not read Kandelo ABI version" if expected_abi.nil? - - host_dist = Pathname(root)/"host/dist" - rm_r host_dist if host_dist.exist? - abi_probe = <<~JS - import { readFileSync } from "node:fs"; - import { pathToFileURL } from "node:url"; - const { extractAbiVersion } = await import(pathToFileURL(process.argv[1]).href); - const bytes = readFileSync(process.argv[2]); - const program = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - const abi = extractAbiVersion(program); - if (abi === null) process.exit(2); - process.stdout.write(String(abi)); - JS - artifact_abi = cd(root) do - Utils.safe_popen_read( - "node", "--import", "tsx/esm", "--input-type=module", "--eval", abi_probe, - Pathname(root)/"host/src/constants.ts", artifact - ).strip - end - odie "fbDOOM ABI #{artifact_abi} does not match Kandelo ABI #{expected_abi}" if artifact_abi != expected_abi - - guards = Pathname(root)/"scripts/wasm-artifact-guards.sh" + def validate_imports!(artifact) system "bash", "-c", <<~SH set -euo pipefail - . #{guards.to_s.shellescape} - wasm_require_no_legacy_asyncify #{artifact.to_s.shellescape} - wasm_require_fork_instrumentation_if_needed #{artifact.to_s.shellescape} unexpected_env_imports=$(wasm-objdump -x #{artifact.to_s.shellescape} | awk '/<- env[.]/ { sub(/^.*<- env[.]/, ""); print $1 }' | grep -Ev '^(__channel_base|memory|setjmp|longjmp)$' || true) @@ -136,18 +108,6 @@ def validate_artifact!(artifact, root) exit 1 fi SH - - binary = artifact.binread - { - "formula build path" => buildpath.to_s, - "formula Cellar path" => prefix.to_s, - "Kandelo checkout path" => root.to_s, - "Nix store path" => "/nix/store/", - "temporary build path" => "/private/tmp/", - }.each do |description, marker| - odie "fbDOOM embeds #{description}: #{marker}" if binary.include?(marker) - end - odie "fbDOOM embeds a builder home path" if binary.match?(%r{/Users/[^/]+/}) end test do