Modern Problems Require Ancient Solutions
I’ve managed to pull off something truly illegal: using the MSVC 2026 compiler to generate 32-bit code, stripped of all modern "bloat" like SSE and AVX. The resulting code is as pure as a newborn's tear.
But here’s the kicker: I fed those modern .obj files into a "grandfather" linker from Visual C++ 6.0. The result? Modern C++23 features running natively on Windows 95.
Cool, right? Now, it’s time to go full throttle.
The Grand Strategy
Our goal isn't just to make it work; it’s to make it seamless. I don't want to write "retro code" in a notepad. I want the full power of 2026 at my fingertips.
The Plan:
Seamless Integration: Write C++23 code for Windows 95 using the latest MSVC, with full syntax highlighting, IntelliSense, and all the bells and whistles.
Modern Dev Experience: Use modern debugging tools and IDE features while targeting a 30-year-old OS.
One-Button Build: Everything must be handled by CMake. One click, and your modern templates are packaged for the world of Pentium I.
Embrace the New: We aren't ignoring modern dev; we are weaponizing it. We’re using std, templates, and even C++ Modules.
As I joked in a C++ chat recently: "I might be the only C# developer actually using C++20 Modules... to target Windows 95."
Let’s Dive In
First things first: if we want our code to look like "normal" modern C++, we need to implement a minimal subset of the Standard Library. Now, I’m not completely insane—I know life is short, and I don’t plan on rewriting every single API and feature. But we do need the essentials: vector, string, move, unique_ptr, expected, and other bits of modern sugar.
This project isn't just a standalone stunt. It’s the foundation that allows me to continue my series on the Arcanum Engine development in C++23, while maintaining compatibility with Windows 95, 98, and Me.
At this point, many of you are probably asking a very blunt question: "Why the hell are you doing this?"
The "Why"
Here is my answer: Curiosity, fun, and a point to prove.
I want to show that C++23 isn't some bloated monster. It’s a beautifully designed language built with backward compatibility in mind. Its Standard Library isn't actually that massive—if you look at Java or Python, their standard libraries are several times larger. I want to prove that C++ shouldn't be feared. On the outside, it looks like a brutal, raw tool, but once you get to know it, it’s simple, straightforward, and incredibly capable.
My personal goal is to build the Arcanum engine using C++23 for ancient hardware. Everyone talks about teraflops and gigabytes, but let’s be honest: 2D graphics haven't fundamentally changed since the early 90s. If it worked smoothly back then, it should work even better now if we use modern tools correctly.
So, let's dive into the enchanting world of templates, compatible "crutches" (hacks), and pure, unadulterated excitement! Life is too short to just move JSON from one folder to another or wrap Docker inside Docker inside Docker (with all due respect to those who do).
Phase 1: The Minimalist STL
To start, we need to implement a minimal necessary subset of the STL.
A key technical detail: Since not all compilers fully support import std; yet, I will implement our custom std the "old-fashioned" way using headers. However, all other project code will use C++ Modules.
This approach ensures out-of-the-box compatibility with various compiler versions (including Linux) without forced updates. My long-term plan is to expand this library until we can seamlessly port modern software to Windows 95—just for the hell of it.
static HANDLE GetHeap() noexcept
{
static HANDLE h = GetProcessHeap();
return h;
}
static HANDLE GetOutput() noexcept
{
static HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
return h;
}
void* malloc(size_t size) noexcept
{
return HeapAlloc(GetHeap(), 0, size);
}
void free(void* ptr) noexcept
{
if (ptr)
{
HeapFree(GetHeap(), 0, ptr);
}
}
Memory Management
Regarding memory management, for now, I am relying entirely on the operating system's native mechanisms. It’s not the most efficient approach in the world, but it works. I’ve already added a task to my backlog to implement a smarter custom allocator later. One potential option I’m considering is adapting the allocator from the musl library.
extern "C" int main();
extern "C" void EntryPoint()
{
int result = main();
ExitProcess(result);
}
The EntryPoint
The EntryPoint is our gateway into the program. Upon launch, it initializes the environment and then calls the familiar main function. For this first version, I decided to keep things simple and skipped command-line argument parsing for now.
Now, we can move on to creating our own stdlib.h.
#pragma once
#include <cstddef>
void* malloc(size_t size) noexcept;
void free(void* ptr) noexcept;
Modern Features (From 15 Years Ago)
We are starting to see various "modern" C++ features creep into the codebase. I say "modern," but most of these have actually been part of the standard for about 15 years now. It’s funny how a feature from 2011 feels like cutting-edge tech when your target OS was released in 1995.
#include <stdlib.h>
#include <memory>
[[nodiscard]]
void* operator new(size_t bytes)
{
return malloc(bytes);
}
void operator delete(void* ptr)
{
return free(ptr);
}
[[nodiscard]]
void* operator new[](size_t bytes)
{
return ::operator new(bytes);
}
void operator delete[](void* ptr)
{
::operator delete(ptr);
}
[[nodiscard]]
void* operator new(size_t bytes, void* ptr)
{
return ptr;
}
[[nodiscard]]
void* operator new[](size_t bytes, void* ptr)
{
return ptr;
}
void __cdecl operator delete(void* ptr, size_t size) noexcept
{
::operator delete(ptr);
}
void __cdecl operator delete[](void* ptr, size_t size) noexcept
{
::operator delete[](ptr);
}
Memory Allocation & The "Magic" of Move
With the foundation laid, we can now use new and placement new. But before we jump into containers, we need to implement std::move.
If you think std::move is some kind of compiler magic, I’ve got news for you: it’s not. It’s just a couple of clever templates. Essentially, it’s a cast that tells the compiler, "Treat this object as an rvalue," enabling move semantics even on a system that thinks a Pentium Pro is the pinnacle of technology.
#pragma once
namespace std
{
template <typename T>
struct remove_reference
{
using type = T;
};
template <typename T>
struct remove_reference<T&>
{
using type = T;
};
template <typename T>
struct remove_reference<T&&>
{
using type = T;
};
template <typename T>
using remove_reference_t = typename remove_reference<T>::type;
template <typename T>
[[nodiscard]] constexpr remove_reference_t<T>&& move(T&& t) noexcept
{
return static_cast<remove_reference_t<T>&&>(t);
}
template <typename T>
[[nodiscard]] constexpr T&& forward(remove_reference_t<T>& t) noexcept
{
return static_cast<T&&>(t);
}
template <typename T>
[[nodiscard]] constexpr T&& forward(remove_reference_t<T>&& t) noexcept
{
return static_cast<T&&>(t);
}
}
The Move Semantics Myth and a Cleaner Allocator
Next up, we implement std::move and std::forward. At their core, these are just convenient templates leveraging standard capabilities. We all know they don’t actually move anything, right? :) They are simply casts that help the compiler understand our intentions.
To make things look truly elegant, let’s create std::allocator. One of the great things about C++23 is that it has been stripped of a lot of legacy baggage. No more rebind, no more bind—all that historical garbage is gone. And people still say C++ is getting more complicated! In reality, the modern standard allows us to write much cleaner code than we could ten years ago.
#pragma once
#include <cstddef>
#include <memory>
namespace std
{
template <typename T>
class allocator
{
public:
using value_type = T;
using size_type = size_t;
constexpr allocator() noexcept = default;
template <typename U>
constexpr allocator(const allocator<U>&) noexcept
{
}
[[nodiscard]]
constexpr T* allocate(size_t n)
{
if (n == 0)
{
return nullptr;
}
return static_cast<T*>(::operator new(n * sizeof(T)));
}
constexpr void deallocate(T* p, size_t n) noexcept
{
if (p)
{
::operator delete(p);
}
}
};
template <typename T, typename U>
constexpr bool operator==(const allocator<T>&, const allocator<U>&) noexcept
{
return true;
}
template <typename T, typename U>
constexpr bool operator!=(const allocator<T>&, const allocator<U>&) noexcept
{
return false;
}
}
Simplicity and the Reality of Development
Isn't it beautiful? In modern C++, you don't even need to implement the != operator anymore; the compiler can automatically generate it based on your == implementation. To help with optimization, I’ve sprinkled constexpr and noexcept throughout the code. Since I’ve disabled exceptions entirely for this project, we can boldly mark everything as noexcept.
A quick reality check: I’m not aiming for peak efficiency right here, right now. My implementation is pretty "brick-like" and probably contains a few bugs. For instance, my std::string lacks Small String Optimization (SSO). This whole project was built over a few evenings, and I have to balance it with my job and family life. I’m in "Maximum Light Code Mode." I’ll definitely fix bugs and optimize the containers later, but I can’t do everything at once.
Error Handling in C++23 style
C++23 introduced a very convenient class for error handling: std::expected. We can wrap any value in it, allowing us to check for success or failure without the overhead of exceptions. It’s perfect for our "no-exception" Windows 95 environment.
template<typename T, typename E>
class expected
{
private:
union
{
T _value;
E _error;
};
bool _hasValue;
public:
expected(const T& val) :
_value(val),
_hasValue(true)
{
}
expected(T&& val) :
_value(static_cast<T&&>(val)),
_hasValue(true)
{
}
Under the Hood: std::expected
This is a part of the implementation. There’s nothing magical here: it’s essentially a sophisticated union that can hold either the value we passed to it or an error code. It's a clean, lightweight way to handle failures without dragging the whole exception handling runtime into our 1995 environment.
Moving to Smart Pointers: unique_ptr
Next, let's talk about resource management. I decided to start with unique_ptr. In a world where we’ve manually taken over the entry point and the runtime, having a reliable way to ensure memory is freed is a lifesaver—even if our target OS doesn't have much memory to begin with.
template<typename T, typename Deleter = default_delete<T>>
class unique_ptr
{
private:
T* _ptr = nullptr;
Deleter _deleter;
public:
constexpr unique_ptr() noexcept :
_ptr(nullptr)
{
}
constexpr unique_ptr(nullptr_t) noexcept :
_ptr(nullptr)
{
}
Smart Pointers: No Magic, Just Scope
At its core, unique_ptr is just a thin wrapper that knows how to call a destructor (or a custom deleter) when it goes out of scope. To create the pointer, we use variadic templates.
Again, it’s a perfect example of how modern C++ features—like variadic templates and perfect forwarding—allow us to write safe, high-level abstractions without adding any runtime overhead. Even on Windows 95, your code remains as lean as manual malloc and free, but without the headache of manual memory management.
template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args)
{
return unique_ptr<T>(new T(std::forward<Args>(args)...));
}
The Reality of Templates
This is where std::forward finally comes into play. As you can see, there’s no magic involved. Sure, the code isn’t exactly simple, but it is logical. It’s essentially templates inside templates :) and a healthy dose of hope that it will not only compile but actually work.
I decided not to include the full implementations of std::string and std::vector here. There isn't much "excitement" in them yet—they were written in a bit of a rush. For instance, I haven't even implemented emplace_back yet. But overall, the core functionality is there and it's functional enough for our "hospital takeover."
I also created a basic ostream for string output. After all, we need a way to print something to the console and see that our modern C++23 code is actually alive on this ancient system.
#include <ostream.hpp>
#include <system.hpp>
using namespace std;
ostream& ostream::operator<< (const string & str)
{
write(str.c_str(), str.size());
return *this;
}
ostream& ostream::operator<<(const char* str)
{
write(str, strlen(str));
return *this;
}
ostream& ostream::operator<<(char c)
{
write(&c, 1);
return *this;
}
ostream& ostream::operator<<(ostream& (*pf)(ostream&))
{
return pf(*this);
}
ostream& std::endl(ostream& os)
{
os << '\n';
return os;
}
The Output: Quick and Dirty
My current write function is unbuffered; it just dumps text straight to the console. It’s "quick and dirty." I’ll definitely need to fix this later because, right now, it’s far from optimal. Then again, I’m hardly going to be outputting megabytes of text on a Windows 95 machine, so it serves its purpose for now.
The Result
As a result, we’ve proven that it’s possible to compile this kind of code not just for modern systems, but for Windows 95 as well. We are using the latest compiler technology to breathe life into an OS that belongs in a museum.
#include <vector>
#include <string>
#include <iostream>
int main()
{
std::vector<std::string> vec;
vec.push_back("1");
vec.push_back("2");
for (auto i : vec)
{
std::cout << i << std::endl;
}
std::string message = "Crazy programming!";
std::cout << message << std::endl;
return 0;
}
It Works!
The proof of concept is solid: it works. And this is exactly how I plan to move forward. To support the Arcanum engine, I’ll be adding more containers and utilities—unordered_map, set, shared_ptr, std::chrono, and std::filesystem—to keep the codebase high-level and modern.
The Media Layer: Bridging Decades
A game engine needs graphics, sound, and OS event handling. For the legacy side, the choice is obvious: SDL 1.2. It’s stable and supports Windows 95 out of the box. For fonts, we’ll stick with SDL_ttf.
However, dragging SDL 1.2 into modern OS versions is a headache. My solution? Abstraction.
I’m creating a universal rendering and audio layer.
On ancient systems, it wraps SDL 1.2.
On modern systems, it wraps SDL3.
Since their APIs are fundamentally similar, we can simply swap modules at the build stage. The result: we write modern C++23 code using the latest features and a unified API. The engine code remains the same whether it’s running on a top-of-the-line Core i9 under Windows/Linux or a classic Pentium under Windows 95.
Embracing Modules and Dynamic Loading
We are officially moving to C++ Modules. Since we can’t always rely on static .lib linking for such a hybrid setup, we’ll be dynamically loading functions from DLLs. It’s the ultimate way to keep the engine flexible and future-proof (while remaining past-compatible).
module;
export module SDL.Loader;
import LDL.WinAPI;
import SDL.Init;
import SDL.Error;
import SDL.Events;
import SDL.Surface;
export
{
class SDL_Loader
{
public:
static void Init()
{
_load = LoadLibraryA("SDL.dll");
if (_load)
{
Bind(SDL_Init, "SDL_Init");
Bind(SDL_Quit, "SDL_Quit");
Bind(SDL_SetVideoMode, "SDL_SetVideoMode");
Bind(SDL_PollEvent, "SDL_PollEvent");
Bind(SDL_GetError, "SDL_GetError");
Bind(SDL_WM_SetCaption, "SDL_WM_SetCaption");
Bind(SDL_Flip, "SDL_Flip");
}
}
~SDL_Loader()
{
if (_load)
{
FreeLibrary(_load);
}
}
private:
template<typename T>
static void Bind(T& funcPtr, const char* name)
{
funcPtr = reinterpret_cast<T>(GetProcAddress(_load, name));
}
inline static HMODULE _load = nullptr;
};
}
Zero Macros, Pure Modules
At this stage, the number of implemented functions is small, but it’s more than enough for a working prototype. The best part? Not a single macro was used in the modules. Every structure, function, and constant is wrapped cleanly in the new C++ Module system.
It’s a strange but satisfying feeling: using the most modern, macro-free way to write C++ to describe APIs for an operating system from 1995.
module;
export module SDL;
export import SDL.Color;
export import SDL.Events;
export import SDL.Init;
export import SDL.Key;
export import SDL.Mod;
export import SDL.Palette;
export import SDL.PixelFormat;
export import SDL.Rect;
export import SDL.Surface;
export import SDL.Types;
export import SDL.Loader;
export import SDL.Error;
The "Hello Window" Example
To wrap things up, let’s write a basic example: creating a window and closing it with the Esc key. This code looks like modern, high-level C++, but it’s destined to run on a system that still remembers the launch of the original PlayStation.
import SDL;
int main()
{
SDL_Loader::Init();
if (SDL_Init(SDL_INIT_VIDEO) < 0)
{
return -1;
}
auto screen = SDL_SetVideoMode(800, 600, 24, SDL_SWSURFACE);
if (!screen)
{
return -1;
}
bool running = true;
while (running)
{
SDL_Event event = {};
if (SDL_PollEvent(&event))
{
if (event.type == SDL_QUIT)
{
running = false;
}
}
SDL_Flip(screen);
}
SDL_Quit();
return 0;
}
The Universal Layer
Now, we are starting to build the engine's Universal Layer. The core idea is simple: we rely on our custom STL, while the hardware implementation is swapped seamlessly at the build stage.
Clean Abstraction: The entire engine logic is written in pure C++23.
Compile-time Switching: Using CMake, we link either the SDL 1.2 module (for Win95) or the SDL3 module (for modern systems).
The result? The high-level code stays modern and elegant. The engine doesn't care if it's running on a brand-new Core i9 or a vintage Pentium — all the "dirty work" is hidden behind the modules.
module;
#include <expected>
#include <memory>
#include <string>
export module Graphics;
import SDL;
import Events;
export namespace Graphics
{
class Canvas
{
public:
Canvas(SDL_Surface* screen) :
_running(true),
_screen(screen)
{
}
~Canvas()
{
SDL_Quit();
}
bool GetEvent(Events::Event& dest)
{
if (_running)
{
SDL_Event event = {};
if (SDL_PollEvent(&event))
{
if (event.type == SDL_QUIT)
{
dest.Type = Events::Event::IsQuit;
}
}
}
return _running;
}
void StopEvent()
{
_running = false;
}
void Update()
{
SDL_Flip(_screen);
}
private:
bool _running;
SDL_Surface* _screen;
};
std::expected<std::unique_ptr<Canvas>, const char*> CanvasNew(int width, int height, const std::string& title)
{
SDL_Loader::Init();
if (SDL_Init(SDL_INIT_VIDEO) < 0)
{
return std::unexpected(SDL_GetError());
}
auto screen = SDL_SetVideoMode(width, height, 24, SDL_HWSURFACE);
if (!screen)
{
SDL_Quit();
return std::unexpected(SDL_GetError());
}
SDL_WM_SetCaption(title.c_str(), nullptr);
return std::make_unique<Canvas>(screen);
}
}
Safety and Automation
Smart pointers handle the destructors automatically. You don't need to manually worry about closing the window or complex error cleanup—we just check the state, and the language takes care of the rest. It’s an incredibly convenient mechanism, especially in an environment where every leaked byte matters.
import Framework;
using namespace Events;
using namespace Graphics;
int main()
{
auto canvasResource = CanvasNew(800, 600, "Canvas SDL");
if (!canvasResource)
{
return -1;
}
auto& canvas = *canvasResource;
Events::Event event;
while (canvas->GetEvent(event))
{
if (event.Type == Events::Event::IsQuit)
{
canvas->StopEvent();
}
canvas->Update();
}
return 0;
}
The Moment of Truth
Here is the result: a modern C++23 application, written with modules and smart pointers, running natively on Windows 95.
Fighting Planned Obsolescence
While Microsoft claims that a 3-to-5-year-old PC is "obsolete" and can't handle Windows 11, we are here to expose this as nothing more than a bold marketing stunt.
If we can bring the cutting-edge features of C++23 to a 30-year-old operating system, your "old" laptop is more than capable of doing great things. Efficiency isn't about hardware age; it's about how you treat your resources.
And here is a small example of it in action:
#include <string>
#include <iostream>
int main()
{
std::string s1 = "Hello ";
s1 += "World!";
std::cout << s1 << std::endl;
return 0;
}
Source Code & Repository
If you want to see exactly how I managed to crossbreed MSVC 2026 with a linker from Visual C++ 6.0, check out the repository here:
👉 JordanCpp/ModularLDL
Warning: Technical content is strictly 18+ due to extreme levels of linker gore and "Frankenstein" engineering! :)
Final Thoughts
I look forward to your comments, advice, and suggestions!
This is just the beginning. Of course, at its core, this is one giant "crutch" (a hack), but it’s a working one. It’s a perfect setup for building retro tools or engines without being shackled by ancient compilers and outdated standards. And that, in itself, is a huge win.
Thank you for your attention!





Top comments (1)
If we can make C++23 run on an OS from 1995, there's no reason why your 2018 laptop shouldn't be 'compatible' with modern software. It's all about efficiency, not marketing.