diff --git a/neo/engine/doomdll.vcxproj b/neo/engine/doomdll.vcxproj
index ad5d85c6..84ca1818 100644
--- a/neo/engine/doomdll.vcxproj
+++ b/neo/engine/doomdll.vcxproj
@@ -409,6 +409,8 @@
+
+
@@ -483,6 +485,7 @@
+
@@ -1616,6 +1619,7 @@
+
@@ -2857,6 +2861,7 @@
+
diff --git a/neo/engine/doomdll.vcxproj.filters b/neo/engine/doomdll.vcxproj.filters
index fa236aac..1f227651 100644
--- a/neo/engine/doomdll.vcxproj.filters
+++ b/neo/engine/doomdll.vcxproj.filters
@@ -13,6 +13,9 @@
{19b363f5-76a7-4256-b7f0-7a6ec012d09d}
+
+ {a48802f5-5084-4d4a-bd3d-7161c1885b64}
+
{2b6b512d-2388-41b9-8880-402e156ec20b}
@@ -255,6 +258,12 @@
Renderer
+
+ Minigame
+
+
+ Minigame
+
Sound
@@ -519,6 +528,9 @@
Ui
+
+ Ui
+
Ui
@@ -812,6 +824,9 @@
Renderer
+
+ Minigame
+
Sound
@@ -1571,6 +1586,9 @@
Ui
+
+ Ui
+
Ui
diff --git a/neo/engine/minigame/MinigameHost.cpp b/neo/engine/minigame/MinigameHost.cpp
new file mode 100644
index 00000000..6d139c92
--- /dev/null
+++ b/neo/engine/minigame/MinigameHost.cpp
@@ -0,0 +1,315 @@
+/*
+===========================================================================
+
+IceTech GPL Source Code
+Copyright (C) 2026 Justin Marshall
+
+This file is part of the IceTech GPL Source Code.
+
+===========================================================================
+*/
+
+#include "precompiled.h"
+#pragma hdrstop
+
+#include "MinigameHost.h"
+#include "../renderer/RenderSystem.h"
+
+idMinigameInstance::idMinigameInstance() {
+ moduleHandle = 0;
+ memset( &exports, 0, sizeof( exports ) );
+ memset( &imports, 0, sizeof( imports ) );
+ instance = NULL;
+ memset( &threadInfo, 0, sizeof( threadInfo ) );
+ InitializeCriticalSection( &criticalSection );
+ threadStarted = false;
+ shutdownRequested = false;
+ active = false;
+ activeSent = false;
+ failed = false;
+ frameReady = false;
+ framebuffer = NULL;
+ framebufferWidth = 0;
+ framebufferHeight = 0;
+ framebufferBytes = 0;
+}
+
+idMinigameInstance::~idMinigameInstance() {
+ Shutdown();
+ DeleteCriticalSection( &criticalSection );
+}
+
+void idMinigameInstance::Lock() {
+ EnterCriticalSection( &criticalSection );
+}
+
+void idMinigameInstance::Unlock() {
+ LeaveCriticalSection( &criticalSection );
+}
+
+void idMinigameInstance::PrintCallback( void *userData, const char *text ) {
+ if ( text && text[0] ) {
+ common->Printf( "minigame: %s", text );
+ }
+}
+
+void idMinigameInstance::SubmitAudioCallback( void *userData, const iceMinigameAudioBuffer_t *audio ) {
+ idMinigameInstance *self = reinterpret_cast( userData );
+ if ( !self || !audio || !audio->samples || audio->frames <= 0 ) {
+ return;
+ }
+
+ // The ABI already carries raw PCM across the DLL boundary. The engine mixer
+ // bridge will consume this callback once the minigame sound emitter lands.
+}
+
+bool idMinigameInstance::Start( const char *module, const char *image, const char *cmdLine ) {
+ Shutdown();
+
+ if ( !module || !module[0] || !image || !image[0] ) {
+ failed = true;
+ return false;
+ }
+
+ moduleName = module;
+ imageName = image;
+ commandLine = cmdLine ? cmdLine : "";
+ failed = false;
+ shutdownRequested = false;
+
+ idStr relativePath;
+ if ( moduleName.Find( "/" ) >= 0 || moduleName.Find( "\\" ) >= 0 ) {
+ relativePath = moduleName;
+ } else {
+ relativePath = "minigames/";
+ relativePath += moduleName;
+ }
+ if ( relativePath.Find( "." ) < 0 ) {
+ relativePath += ".minigame";
+ }
+
+ resolvedPath = fileSystem->RelativePathToOSPath( relativePath.c_str(), "fs_basepath" );
+ moduleHandle = Sys_DLL_Load( resolvedPath.c_str() );
+ if ( !moduleHandle ) {
+ common->Warning( "Could not load minigame '%s' from '%s'", moduleName.c_str(), resolvedPath.c_str() );
+ failed = true;
+ return false;
+ }
+
+ iceMinigameGetAPI_t GetMinigameAPI = (iceMinigameGetAPI_t)Sys_DLL_GetProcAddress( moduleHandle, ICE_MINIGAME_ENTRY_POINT );
+ if ( !GetMinigameAPI ) {
+ common->Warning( "Minigame '%s' does not export %s", moduleName.c_str(), ICE_MINIGAME_ENTRY_POINT );
+ Shutdown();
+ failed = true;
+ return false;
+ }
+
+ imports.version = ICE_MINIGAME_API_VERSION;
+ imports.userData = this;
+ importBasePath = fileSystem->RelativePathToOSPath( "", "fs_basepath" );
+ importSavePath = fileSystem->RelativePathToOSPath( "", "fs_savepath" );
+ imports.basePath = importBasePath.c_str();
+ imports.savePath = importSavePath.c_str();
+ imports.Print = PrintCallback;
+ imports.SubmitAudio = SubmitAudioCallback;
+
+ if ( !GetMinigameAPI( &imports, &exports ) || exports.version != ICE_MINIGAME_API_VERSION ||
+ !exports.CreateInstance || !exports.DestroyInstance || !exports.RunFrame || !exports.GetFramebuffer ) {
+ common->Warning( "Minigame '%s' has an incompatible API", moduleName.c_str() );
+ Shutdown();
+ failed = true;
+ return false;
+ }
+
+ if ( !exports.CreateInstance( &imports, commandLine.c_str(), &instance ) || !instance ) {
+ common->Warning( "Minigame '%s' failed to create an instance", moduleName.c_str() );
+ Shutdown();
+ failed = true;
+ return false;
+ }
+
+ if ( renderSystem && exports.framebufferWidth > 0 && exports.framebufferHeight > 0 ) {
+ renderSystem->CreateScratchImage( imageName.c_str(), exports.framebufferWidth, exports.framebufferHeight );
+ }
+
+ Sys_CreateThread( ThreadProc, this, THREAD_NORMAL, threadInfo, "minigame", g_threads, &g_thread_count );
+ threadStarted = true;
+ return true;
+}
+
+void idMinigameInstance::Shutdown() {
+ shutdownRequested = true;
+ if ( threadStarted && threadInfo.threadHandle ) {
+ Sys_DestroyThread( threadInfo );
+ }
+ threadStarted = false;
+ shutdownRequested = false;
+
+ if ( exports.DestroyInstance && instance ) {
+ exports.DestroyInstance( instance );
+ }
+ instance = NULL;
+
+ if ( moduleHandle ) {
+ Sys_DLL_Unload( moduleHandle );
+ }
+ moduleHandle = 0;
+ memset( &exports, 0, sizeof( exports ) );
+
+ Lock();
+ pendingEvents.Clear();
+ ClearFramebuffer();
+ frameReady = false;
+ active = false;
+ activeSent = false;
+ Unlock();
+}
+
+void idMinigameInstance::SetActive( bool setActive ) {
+ Lock();
+ active = setActive;
+ Unlock();
+}
+
+void idMinigameInstance::QueueEvent( const sysEvent_t *event, int time ) {
+ if ( !event || !instance ) {
+ return;
+ }
+
+ iceMinigameInputEvent_t minigameEvent;
+ minigameEvent.type = ICE_MINIGAME_EVENT_NONE;
+ minigameEvent.value = event->evValue;
+ minigameEvent.value2 = event->evValue2;
+ minigameEvent.time = time;
+
+ switch ( event->evType ) {
+ case SE_KEY:
+ minigameEvent.type = ICE_MINIGAME_EVENT_KEY;
+ break;
+ case SE_CHAR:
+ minigameEvent.type = ICE_MINIGAME_EVENT_CHAR;
+ break;
+ case SE_MOUSE:
+ minigameEvent.type = ICE_MINIGAME_EVENT_MOUSE;
+ break;
+ default:
+ return;
+ }
+
+ Lock();
+ if ( pendingEvents.Num() < 256 ) {
+ pendingEvents.Append( minigameEvent );
+ }
+ Unlock();
+}
+
+bool idMinigameInstance::UploadFramebuffer() {
+ if ( !renderSystem || !instance ) {
+ return false;
+ }
+
+ Lock();
+ if ( !frameReady || !framebuffer || framebufferWidth <= 0 || framebufferHeight <= 0 ) {
+ Unlock();
+ return false;
+ }
+ const bool uploaded = renderSystem->UploadImage( imageName.c_str(), framebuffer, framebufferWidth, framebufferHeight );
+ frameReady = false;
+ Unlock();
+ return uploaded;
+}
+
+unsigned int idMinigameInstance::ThreadProc( void *parm ) {
+ idMinigameInstance *self = reinterpret_cast( parm );
+ return self ? self->RunThread() : 0;
+}
+
+unsigned int idMinigameInstance::RunThread() {
+ int lastTime = Sys_Milliseconds();
+
+ while ( !shutdownRequested ) {
+ iceMinigameInputEvent_t events[256];
+ int numEvents = 0;
+ bool nowActive;
+ bool sendActive;
+
+ Lock();
+ nowActive = active;
+ sendActive = ( activeSent != active );
+ activeSent = active;
+ numEvents = pendingEvents.Num();
+ if ( numEvents > 256 ) {
+ numEvents = 256;
+ }
+ for ( int i = 0; i < numEvents; i++ ) {
+ events[i] = pendingEvents[i];
+ }
+ pendingEvents.Clear();
+ Unlock();
+
+ if ( sendActive && exports.SetActive ) {
+ exports.SetActive( instance, nowActive ? 1 : 0 );
+ }
+
+ for ( int i = 0; i < numEvents; i++ ) {
+ if ( exports.QueueEvent ) {
+ exports.QueueEvent( instance, &events[i] );
+ }
+ }
+
+ const int now = Sys_Milliseconds();
+ const int elapsed = Max( 1, now - lastTime );
+ lastTime = now;
+
+ if ( nowActive || numEvents > 0 ) {
+ exports.RunFrame( instance, elapsed );
+
+ iceMinigameFramebuffer_t framebufferInfo;
+ memset( &framebufferInfo, 0, sizeof( framebufferInfo ) );
+ if ( exports.GetFramebuffer( instance, &framebufferInfo ) ) {
+ CopyFramebuffer( framebufferInfo );
+ }
+ }
+
+ Sys_Sleep( 1 );
+ }
+
+ return 0;
+}
+
+void idMinigameInstance::CopyFramebuffer( const iceMinigameFramebuffer_t &source ) {
+ if ( !source.pixels || source.width <= 0 || source.height <= 0 || source.pitchBytes <= 0 ||
+ source.pixelFormat != ICE_MINIGAME_PIXEL_RGBA8 ) {
+ return;
+ }
+
+ const int rowBytes = source.width * 4;
+ const int requiredBytes = rowBytes * source.height;
+
+ Lock();
+ if ( requiredBytes != framebufferBytes ) {
+ ClearFramebuffer();
+ framebuffer = (byte *)Mem_Alloc( requiredBytes );
+ framebufferBytes = requiredBytes;
+ }
+
+ if ( framebuffer ) {
+ for ( int y = 0; y < source.height; y++ ) {
+ memcpy( framebuffer + y * rowBytes, source.pixels + y * source.pitchBytes, rowBytes );
+ }
+ framebufferWidth = source.width;
+ framebufferHeight = source.height;
+ frameReady = true;
+ }
+ Unlock();
+}
+
+void idMinigameInstance::ClearFramebuffer() {
+ if ( framebuffer ) {
+ Mem_Free( framebuffer );
+ }
+ framebuffer = NULL;
+ framebufferWidth = 0;
+ framebufferHeight = 0;
+ framebufferBytes = 0;
+}
diff --git a/neo/engine/minigame/MinigameHost.h b/neo/engine/minigame/MinigameHost.h
new file mode 100644
index 00000000..2141e311
--- /dev/null
+++ b/neo/engine/minigame/MinigameHost.h
@@ -0,0 +1,73 @@
+/*
+===========================================================================
+
+IceTech GPL Source Code
+Copyright (C) 2026 Justin Marshall
+
+This file is part of the IceTech GPL Source Code.
+
+===========================================================================
+*/
+
+#ifndef __MINIGAME_HOST_H__
+#define __MINIGAME_HOST_H__
+
+#include "Minigame_public.h"
+
+class idMinigameInstance {
+public:
+ idMinigameInstance();
+ ~idMinigameInstance();
+
+ bool Start( const char *moduleName, const char *imageName, const char *commandLine );
+ void Shutdown();
+ void SetActive( bool active );
+ void QueueEvent( const sysEvent_t *event, int time );
+ bool UploadFramebuffer();
+
+ const char * GetImageName() const { return imageName.c_str(); }
+ bool IsStarted() const { return instance != NULL; }
+ bool HasFailed() const { return failed; }
+
+private:
+ static unsigned int ThreadProc( void *parm );
+ unsigned int RunThread();
+ void CopyFramebuffer( const iceMinigameFramebuffer_t &source );
+ void ClearFramebuffer();
+ void Lock();
+ void Unlock();
+
+ static void PrintCallback( void *userData, const char *text );
+ static void SubmitAudioCallback( void *userData, const iceMinigameAudioBuffer_t *audio );
+
+ INT_PTR moduleHandle;
+ iceMinigameExport_t exports;
+ iceMinigameImport_t imports;
+ iceMinigameInstance_t * instance;
+
+ idStr moduleName;
+ idStr imageName;
+ idStr commandLine;
+ idStr resolvedPath;
+ idStr importBasePath;
+ idStr importSavePath;
+
+ xthreadInfo threadInfo;
+ CRITICAL_SECTION criticalSection;
+
+ bool threadStarted;
+ volatile bool shutdownRequested;
+ bool active;
+ bool activeSent;
+ bool failed;
+ bool frameReady;
+
+ byte * framebuffer;
+ int framebufferWidth;
+ int framebufferHeight;
+ int framebufferBytes;
+
+ idList pendingEvents;
+};
+
+#endif // __MINIGAME_HOST_H__
diff --git a/neo/engine/minigame/Minigame_public.h b/neo/engine/minigame/Minigame_public.h
new file mode 100644
index 00000000..4cebbfb5
--- /dev/null
+++ b/neo/engine/minigame/Minigame_public.h
@@ -0,0 +1,108 @@
+/*
+===========================================================================
+
+IceTech GPL Source Code
+Copyright (C) 2026 Justin Marshall
+
+This file is part of the IceTech GPL Source Code.
+
+===========================================================================
+*/
+
+#ifndef __MINIGAME_PUBLIC_H__
+#define __MINIGAME_PUBLIC_H__
+
+#include
+
+#define ICE_MINIGAME_API_VERSION 1
+#define ICE_MINIGAME_ENTRY_POINT "GetMinigameAPI"
+
+#ifdef _WIN32
+#ifdef __cplusplus
+#define ICE_MINIGAME_EXPORT extern "C" __declspec(dllexport)
+#else
+#define ICE_MINIGAME_EXPORT __declspec(dllexport)
+#endif
+#else
+#ifdef __cplusplus
+#define ICE_MINIGAME_EXPORT extern "C"
+#else
+#define ICE_MINIGAME_EXPORT
+#endif
+#endif
+
+typedef enum {
+ ICE_MINIGAME_EVENT_NONE = 0,
+ ICE_MINIGAME_EVENT_KEY,
+ ICE_MINIGAME_EVENT_CHAR,
+ ICE_MINIGAME_EVENT_MOUSE
+} iceMinigameEventType_t;
+
+typedef enum {
+ ICE_MINIGAME_PIXEL_RGBA8 = 1
+} iceMinigamePixelFormat_t;
+
+typedef struct iceMinigameInputEvent_s {
+ int type;
+ int value;
+ int value2;
+ int time;
+} iceMinigameInputEvent_t;
+
+typedef struct iceMinigameFramebuffer_s {
+ const uint8_t *pixels;
+ int width;
+ int height;
+ int pitchBytes;
+ int pixelFormat;
+} iceMinigameFramebuffer_t;
+
+typedef struct iceMinigameAudioBuffer_s {
+ const float *samples;
+ int frames;
+ int channels;
+ int sampleRate;
+} iceMinigameAudioBuffer_t;
+
+typedef void (*iceMinigamePrint_t)( void *userData, const char *text );
+typedef void (*iceMinigameSubmitAudio_t)( void *userData, const iceMinigameAudioBuffer_t *audio );
+
+typedef struct iceMinigameImport_s {
+ int version;
+ void *userData;
+ const char *basePath;
+ const char *savePath;
+ iceMinigamePrint_t Print;
+ iceMinigameSubmitAudio_t SubmitAudio;
+} iceMinigameImport_t;
+
+typedef struct iceMinigameInstance_s iceMinigameInstance_t;
+
+typedef int (*iceMinigameCreateInstance_t)(
+ const iceMinigameImport_t *imports,
+ const char *commandLine,
+ iceMinigameInstance_t **instance );
+
+typedef void (*iceMinigameDestroyInstance_t)( iceMinigameInstance_t *instance );
+typedef void (*iceMinigameSetActive_t)( iceMinigameInstance_t *instance, int active );
+typedef void (*iceMinigameQueueEvent_t)( iceMinigameInstance_t *instance, const iceMinigameInputEvent_t *event );
+typedef void (*iceMinigameRunFrame_t)( iceMinigameInstance_t *instance, int elapsedMilliseconds );
+typedef int (*iceMinigameGetFramebuffer_t)( iceMinigameInstance_t *instance, iceMinigameFramebuffer_t *framebuffer );
+
+typedef struct iceMinigameExport_s {
+ int version;
+ const char *gameName;
+ int framebufferWidth;
+ int framebufferHeight;
+ int preferredSampleRate;
+ iceMinigameCreateInstance_t CreateInstance;
+ iceMinigameDestroyInstance_t DestroyInstance;
+ iceMinigameSetActive_t SetActive;
+ iceMinigameQueueEvent_t QueueEvent;
+ iceMinigameRunFrame_t RunFrame;
+ iceMinigameGetFramebuffer_t GetFramebuffer;
+} iceMinigameExport_t;
+
+typedef int (*iceMinigameGetAPI_t)( const iceMinigameImport_t *imports, iceMinigameExport_t *exports );
+
+#endif // __MINIGAME_PUBLIC_H__
diff --git a/neo/engine/renderer/RenderSystem.cpp b/neo/engine/renderer/RenderSystem.cpp
index 7fa5749c..3e290064 100644
--- a/neo/engine/renderer/RenderSystem.cpp
+++ b/neo/engine/renderer/RenderSystem.cpp
@@ -1048,6 +1048,36 @@ bool idRenderSystemLocal::UploadImage( const char *imageName, const byte *data,
return true;
}
+/*
+===============
+idRenderSystemLocal::CreateScratchImage
+===============
+*/
+bool idRenderSystemLocal::CreateScratchImage( const char *imageName, int width, int height ) {
+ if ( !imageName || !imageName[0] || width <= 0 || height <= 0 ) {
+ return false;
+ }
+
+ idImage *image = globalImages->GetImage( imageName );
+ if ( !image ) {
+ image = globalImages->AllocImage( imageName );
+ }
+ if ( !image ) {
+ return false;
+ }
+
+ const int bytes = width * height * 4;
+ byte *data = (byte *)Mem_Alloc( bytes );
+ memset( data, 0, bytes );
+ for ( int i = 3; i < bytes; i += 4 ) {
+ data[i] = 255;
+ }
+
+ image->GenerateImage( data, width, height, TF_LINEAR, false, TR_CLAMP, TD_DEFAULT );
+ Mem_Free( data );
+ return true;
+}
+
/*
===============
idRenderSystemLocal::LoadImage
diff --git a/neo/engine/renderer/RenderSystem.h b/neo/engine/renderer/RenderSystem.h
index 071aa08b..5e1e5e56 100644
--- a/neo/engine/renderer/RenderSystem.h
+++ b/neo/engine/renderer/RenderSystem.h
@@ -278,6 +278,7 @@ public:
// texture filter / mipmapping / repeat won't be modified by the upload
// returns false if the image wasn't found
virtual bool UploadImage( const char *imageName, const byte *data, int width, int height ) = 0;
+ virtual bool CreateScratchImage( const char *imageName, int width, int height ) = 0;
// Loads in a image.
virtual void LoadImage(const char* cname, byte** pic, int* width, int* height, ID_TIME_T* timestamp, bool makePowerOf2) = 0;
diff --git a/neo/engine/renderer/tr_local.h b/neo/engine/renderer/tr_local.h
index 3a673247..247afa7a 100644
--- a/neo/engine/renderer/tr_local.h
+++ b/neo/engine/renderer/tr_local.h
@@ -731,6 +731,7 @@ public:
virtual void UnCrop();
virtual void GetCardCaps( bool &oldCard, bool &nv10or20 );
virtual bool UploadImage( const char *imageName, const byte *data, int width, int height );
+ virtual bool CreateScratchImage( const char *imageName, int width, int height );
// Loads in a image.
virtual void LoadImage(const char* cname, byte** pic, int* width, int* height, ID_TIME_T* timestamp, bool makePowerOf2);
diff --git a/neo/engine/ui/Window.cpp b/neo/engine/ui/Window.cpp
index cfd6f0ba..fd067938 100644
--- a/neo/engine/ui/Window.cpp
+++ b/neo/engine/ui/Window.cpp
@@ -37,6 +37,7 @@ If you have questions concerning this license or the applicable additional terms
#include "BindWindow.h"
#include "ListWindow.h"
#include "RenderWindow.h"
+#include "MinigameWindow.h"
#include "MarkerWindow.h"
#include "FieldWindow.h"
@@ -2534,6 +2535,17 @@ bool idWindow::Parse(idParser* src, bool rebuild) {
dwt.win = win;
drawWindows.Append(dwt);
}
+ else if (token == "minigameDef") {
+ idMinigameWindow* win = new idMinigameWindow(dc, gui);
+ SaveExpressionParseState();
+ win->Parse(src, rebuild);
+ RestoreExpressionParseState();
+ AddChild(win);
+ win->SetParent(this);
+ dwt.simp = NULL;
+ dwt.win = win;
+ drawWindows.Append(dwt);
+ }
//
// added new onEvent
else if (token == "onNamedEvent") {