VOGONS


First post, by TinPlat

User metadata
Rank Newbie
Rank
Newbie

Hi everyone,

Over the past few months I've been working on a project to bring several modern web engine components to 16-bit Windows for Workgroups 3.11. The goal was to make it possible to parse HTML5, CSS, and run JavaScript directly on period hardware without relying on a proxy or modern machine.

Here's what I've ported:

- FreeType (compiled with MSVC 1.52c) – TrueType/OpenType font rendering
- Gumbo-parser (compiled with Open Watcom 2.0) – HTML5 parser
- LibCSS + libparserutils + libwapcaplet (Open Watcom 2.0) – CSS parsing and selection engine
- QuickJS (Open Watcom 2.0) – modern JavaScript engine (ES2020 support)

All of these are compiled as 16-bit DLLs and can be loaded dynamically or statically by Win16 applications (FreeType is dynamic-load only).

Current status:

- FreeType, Gumbo-parser, and the LibCSS family successfully compile and can be initialized from a test program. Basic functionality should work, though not every feature has been thoroughly tested.
- QuickJS also compiles and JS_NewRuntime / JS_NewContext work without GPF. However, JS_Eval currently hits a GPF when performing string concatenation inside an OP_add bytecode handler. The root cause appears to be a stack-pointer corruption issue when the JavaScript code uses string operations.

The project is hosted on GitHub:
Win16WebStack

Each subdirectory contains the original project's license and the modified source files.

I'm sharing this in the hope that the retro computing community finds it useful, and maybe someone with a deep knowledge of Open Watcom's code generation or QuickJS internals might be interested in taking a look at the remaining QuickJS bug. I've spent a lot of time on this, but I'm not sure how much more I can personally dedicate to it. That said, I'd be very happy if others want to pick it up, improve it, or even build something cool on top of it.

Building:

I've kept the project files intact, so you can open and compile them directly in the IDE.

If you have questions, feel free to open an issue on GitHub or reply here.

Thanks!

Reply 1 of 9, by leon22

User metadata
Rank Newbie
Rank
Newbie

Thank you so much for your work!

Hopefully I have time to test the WebStack. Will let you know.

Best regards

PS: maybe it's better to use plain English in the Readme

https://funwithretrocomputers.blogspot.com/

Reply 2 of 9, by TinPlat

User metadata
Rank Newbie
Rank
Newbie
leon22 wrote on 2026-08-05, 20:20:
Thank you so much for your work! […]
Show full quote

Thank you so much for your work!

Hopefully I have time to test the WebStack. Will let you know.

Best regards

PS: maybe it's better to use plain English in the Readme

Thanks for your kind words! I look forward to your feedback when you have time to test it. Good suggestion about the README, I have updated it to English. Thanks for the advice! I just published a Release, feel free to check it out.

Reply 3 of 9, by youxiaojie

User metadata
Rank Newbie
Rank
Newbie

great work!

Reply 5 of 9, by OzzFan

User metadata
Rank Member
Rank
Member

This sounds like a very cool project!

Hobbyist Developer : https://www.shelteringoak.com/du/

Reply 6 of 9, by TinPlat

User metadata
Rank Newbie
Rank
Newbie

I want to share a frustrating issue I ran into while porting QuickJS to 16-bit Windows, and ask if anyone knows of a more portable JavaScript engine.

QuickJS assumes that int is 4 bytes. On 16-bit compilers like Open Watcom, int is only 2 bytes. This is not a small issue, it causes serious memory corruption.

For example, JS_DefineProperty takes a flags argument. The engine defines a flag called JS_PROP_NO_EXOTIC as (1L << 16), which is 65536. But when flags is declared as int, it is truncated to 16 bits. The condition !(flags & JS_PROP_NO_EXOTIC) then always returns true, because the high bit of the flag is lost. This leads to infinite recursion and a crash.

This assumption causes problems in many other places too, and I have spent a lot of time fighting them.

So, does anyone know of a JavaScript engine that does not assume int is 4 bytes? One designed for portability and using explicit 32-bit types like int32_t where needed? It would be even better if it supports a reasonably modern version of JavaScript.

I will try to port such a project when I have time, and I will drop QuickJS support and updates.

Reply 7 of 9, by TinPlat

User metadata
Rank Newbie
Rank
Newbie
Gumbo HTML5 parser — first stable Win16 release […]
Show full quote

Gumbo HTML5 parser — first stable Win16 release

Quick update on the browser project: the HTML5 parser is now fully working on Windows 3.11 for Workgroups.

GUMBO.DLL (built with Open Watcom 2.0, Large model) passes 94/94 tests covering:

  • HTML5 tree construction (implied tags, foster parenting, adoption agency)
  • Character entity decoding (&amp;lt; &amp;gt; &amp;amp; and 2000+ named refs)
  • SVG / MathML namespace handling
  • Error recovery for malformed input
  • gumbo_parse_with_options, gumbo_get_attribute, all public API

Source will be on GitHub tomorrow, link to follow.

Three Win16-specific landmines we hit

1. C99 compound literals are not implemented in Open Watcom 2.0

Gumbo uses ~50 instances of (gumbo_tagset){TAG(A), TAG(B), ...} which have to be manually rewritten as named static arrays. This is a high-risk manual process — I accidentally bound one call site to kTagSet_ForeignStartTags (which contains TAG(SPAN)) instead of a formatting-elements set. The result: &lt;b&gt; inside &lt;div&gt;&lt;span&gt;...&lt;/span&gt;&lt;b&gt;... got treated as a formatting element, triggerd reconstruct_active_formatting_elements, and ended up nested inside the span. Took two evenings of open_elements stack tracing to find.

/* Original C99 */
tag_in(token, kStartTag,
(gumbo_tagset){TAG(B), TAG(BIG), TAG(CODE), TAG(EM), TAG(FONT),
TAG(I), TAG(S), TAG(SMALL), TAG(STRIKE), TAG(STRONG), TAG(TT), TAG(U)})

/* After manual rewrite — MUST be this exact set */
static const gumbo_tagset kTagSet_FormattingStartTags = {
TAG(B), TAG(BIG), TAG(CODE), TAG(EM), TAG(FONT), TAG(I),
TAG(S), TAG(SMALL), TAG(STRIKE), TAG(STRONG), TAG(TT), TAG(U)
};

2. gperf stringpool offsets overflow 16-bit int

char_ref_gperf.c has entries like:

{(int)(size_t)&((struct stringpool_t *)0)->stringpool_str12646, 120010, -1},

The stringpool is 18872 bytes, so the largest member offset is 18871. When cast to int (16-bit on Win16), values above 32767 wrap to negative numbers and all named character references silently fail to resolve.

Fix: change struct GumboNamedCharRef.name from int to long, and the literals to (long)(size_t).

3. wordlist[] is 151 KB and quietly exceeds segment size

gperf's generated wordlist[] has 12647 entries × 12 bytes = 151764 bytes. Open Watcom in Large model puts this in FAR_DATA (not DGROUP), so it doesn't overflow the 64 KB DGROUP limit. But each FAR_DATA segment is still limited to 64 KB, and the compiler gives no error — it silently discards the initialization data. All lookups return zero.

Fix: script the array into 4 blocks of 3200 entries each (38 KB per block), plus a small dispatch function:

if      (key &lt; 3200u)  entry = &amp;wordlist_0[key];
else if (key &lt; 6400u) entry = &amp;wordlist_1[key - 3200u];
else if (key &lt; 9600u) entry = &amp;wordlist_2[key - 6400u];
else entry = &amp;wordlist_3[key - 9600u];

DGROUP vs FAR_DATA in the map file

For anyone curious where the memory actually goes:

_NULL                  BEGDATA  DGROUP  0008:0000   00000010
_DATA DATA DGROUP 0008:0010 000007b8
CONST DATA DGROUP 0008:07e0 00003ac4
CONST2 DATA DGROUP 0008:42a4 0000aab1
_BSS BSS DGROUP 0008:ed56 00000112
^-- DGROUP total ~60.7 KB / 64 KB

char_ref_gperf13_DATA FAR_DATA AUTO 0004:0000 00009600
char_ref_gperf14_DATA FAR_DATA AUTO 0005:0000 00009600
char_ref_gperf15_DATA FAR_DATA AUTO 0006:0000 00009600
char_ref_gperf16_DATA FAR_DATA AUTO 0007:0000 00008ed4

DGROUP is at ~95% capacity. Open Watcom automatically spills oversized static const arrays into FAR_DATA, which is why the split wordlist still links cleanly.

Call to Open Watcom devs

Please, please add C99 compound literals to -zastd=c99. I have manually replaced ~50 of them in Gumbo, and the one I got wrong cost me two evenings. Every other feature in C99 works; this one is a real portability blocker for bringing 32-bit C libraries to Win16.

What's next

Continue testing and fixing LibCSS and Duktape. QuickJS is no longer supported.

Source: https://github.com/qv1145145/Win16WebStack (uploading tomorrow)

Reply 8 of 9, by TinPlat

User metadata
Rank Newbie
Rank
Newbie
LibCSS port to Win16 — first stable release, 128/128 tests pass […]
Show full quote

LibCSS port to Win16 — first stable release, 128/128 tests pass

Continuing the browser project thread: LIBCSS.DLL and its two dependencies (WAPCAP.DLL from libwapcaplet, PARSUTIL.DLL from libparserutils) now build and run cleanly under Windows 3.11 for Workgroups with Open Watcom 2.0. Test suite: 128 PASS / 0 FAIL.

What was tested

  • libwapcaplet (20 tests): string intern, substring, caseless compare, tolower, hash, ref counting
    libparserutils (60 tests): error strings, MIB enums, UTF-8/UTF-16 decode, charset codec, buffer/vector/stack containers, input stream
  • LibCSS (48 tests): stylesheet create/append/done/destroy, select context, computed style accessors, unit conversion, complex CSS parsing

All cross-module callbacks (LibCSS invokes client-provided handlers for node queries, colour resolution, font resolution) are wrapped with MakeProcInstance thunks. Without this, the callback would execute with the DLL's DS and read garbage for any static variable in the caller. Win16 isn't forgiving about this.

Problem & Resolution

/* include/libcss/fpmath.h — original */
static inline css_fixed
css_multiply_fixed(const css_fixed x, const css_fixed y) {
int64_t xx = ((int64_t)x * (int64_t)y) >> CSS_RADIX_POINT;
if (xx < INT_MIN) xx = INT_MIN; // ← 16-bit int in Win16, value is -32768
if (xx > INT_MAX) xx = INT_MAX; // ← 16-bit int in Win16, value is 32767
return xx;
}

INT_MAX is 32767 on Win16, not 2147483647. Every fixed-point value above 32px got silently clamped. Symptom: 1in unit conversion returned 32.7px instead of 96px.

Fix is trivial:

if (xx < INT32_MIN) xx = INT32_MIN;
if (xx > INT32_MAX) xx = INT32_MAX;

Six lines total across five functions. I'll send this upstream as a PR. Any platform where int != int32_t needs the same patch.

The strengths of LibCSS

LibCSS consistently uses int32_t wherever a 32-bit integer is needed, and never assumes that int is 32-bit. css_fixed is typedef'd to int32_t, and css_color is uint32_t. The data widths are explicitly specified throughout. The only exception was fpmath.h using INT_MAX / INT_MIN, which is a trivial fix.

In the end, only a handful of source changes were needed to complete the port.

Compared to my earlier port of QuickJS, this was a much more pleasant experience.

Next

Duktape (JavaScript engine) is next on the test bench.

Source: https://github.com/qv1145145/Win16WebStack — uploading alongside Gumbo later this week.

Reply 9 of 9, by TinPlat

User metadata
Rank Newbie
Rank
Newbie
Duktape JavaScript engine running on Windows 3.11 — port notes […]
Show full quote

Duktape JavaScript engine running on Windows 3.11 — port notes

DUKTAPE.DLL is now building and running under Windows 3.11 for Workgroups with Open Watcom 2.0. The engine passes most of our API test suite (about 123 of 128 tests; the remaining failures are Open Watcom compiler bugs, not Duktape itself — see the end of the post).

What Duktape gives us

  • ES5.1 with selected ES6+ features (Proxy, Symbol, typed arrays when enabled)
  • Full JSON encode/decode
  • RegExp with Unicode tables
  • Byte buffers
  • Coroutines
  • Built-in Date, Math, Error with proper prototype chains
  • A single small allocator interface we can plug into, no external dependencies

For a 16-bit browser engine this is a good match: the whole DLL is around 636 KB, dwarfed by the LibCSS build from earlier in this thread.

Porting notes

1. duk_config.h had to be hand-tuned. The upstream repo ships a generated config for 32-bit Windows. For Win16 we had to:

/* Suppress the Windows platform detection so Duktape falls back to
the generic time() / gmtime() provider instead of trying to call
GetSystemTimeAsFileTime, SystemTimeToTzSpecificLocalTime, etc. */
#if defined(_WIN16)
#undef __WINDOWS__
#undef _WINDOWS
#undef _WINDOWS_
#undef _WIN32
#undef WIN32
#undef _WIN64
#undef WIN64
#undef __WIN32__
#undef __TOS_WIN__
#endif

2. Skip the amalgamated build. The 4 MB single-file duktape.c cannot be compiled under Win16 — one object file, one 64 KB code segment, immediate overflow. Instead we compile the many duk_*.c files from src-input/, each of which is well under 64 KB.

3. Export macro placement. Duktape uses a single DUK_EXTERNAL_DECL that sits before the return type. Win16 needs __export after the return type, before the function name. So every public function declaration had to be rewritten as:

duk_context *DUKTAPE_API duk_create_heap(...);

with

#define DUKTAPE_API __far __cdecl __export

Note __cdecl, not __pascal. Duktape has variadic functions like duk_push_sprintf and duk_error_raw; those cannot use the Pascal convention because the callee cannot know how many arguments were passed.

4. Allocator tuning. The default GlobalAlloc path returns a fresh segment for every allocation, with offset 0, which exhausts the GDT/LDT segment selectors after a few hundred calls. Switching Duktape's internal allocator to the C runtime's far-heap _fmalloc / _frealloc / _ffree fixes this — those functions page in a few KB at a time and subdivide within the segment.

5. Cross-module callbacks. Win16 does not carry a DS value in a far function pointer. When the EXE hands a callback to the DLL and the DLL invokes it, the DS register still belongs to the DLL, so any static variable the callback touches resolves to the wrong segment. Two workarounds exist: __loadds on the callback, or MakeProcInstance generating a thunk. The first changes the function pointer's type (incompatible with typedefs in Duktape's header), the second requires managing thunk lifetime. For the test suite we simply arranged for the callbacks not to touch any EXE-side static variables. This is documented in the Win16 porting literature and is not specific to Duktape.

6. Test suite discipline. The duktape.h API has subtle stack-effect rules — duk_json_encode and duk_json_decode replace in place, duk_join takes the count of flat values on the stack, duk_pcall consumes the function plus its arguments and leaves exactly one value — and getting one of these wrong produces an “invalid stack index” fatal from the engine. We ended up wrapping each test block with duk_set_top(ctx, 0) at both ends.

What is still broken

Five of our tests currently fail, all for the same underlying reason: Open Watcom 2.0's 16-bit code generator mis-handles DGROUP on DLL exports. Specifically:

  • #1678 — returning double from a DLL export does not put DGROUP into DX, so callers read the result from the wrong segment. This breaks duk_get_number and duk_random from the EXE side.
  • #1679 — the prologue of a DLL export function does not load the DLL's own DGROUP into DS, so string literals and static buffers inside the function resolve against the caller's data segment.

Both bugs reproduce with MSVC 1.52c correctly compiled code, so this is an OW code-generation issue.

Next

DOM bridge: wiring Gumbo's parse tree into LibCSS's node query handlers, exposing the result to Duktape as global objects and functions. Once that's up, the browser can parse a real HTML page, apply CSS, and run whatever JavaScript is on it — which for a 16-bit machine is a big step.

Source: https://github.com/qv1145145/Win16WebStack