Porting Quake to Go — The ironwail-go Story
Table of Contents
Prologue: Why Port Quake to Go in 2026
In 1996, id Software published Quake. It was the first fully-3D first-person
shooter from the studio that had defined the genre with Doom, and it shipped
with an engine whose architecture — a server-authoritative client-server
simulation, a BSP world with precomputed visibility, a bytecode scripting VM
for game logic, and a software (later OpenGL) renderer — set patterns that
game engines still follow thirty years later. Zachary Hickman, in an academic
analysis of the engine written for Northeastern University, framed it
succinctly: *"The impressive feature improvements required a much more
comprehensive engine."* Hickman
This is the story of re-implementing that engine in Go.
ironwail-go is a pure-Go port of Ironwail, a high-performance
modern fork of QuakeSpasm, which is itself a maintained fork of the original
GPL-Quake source. The port is not a line-for-line transliteration. It is a
deliberate re-architecture: garbage collection instead of a manual memory
hunk, Go packages instead of a flat Quake/*.c tree, goroutines instead of
SDL threads, and — most ambitiously — WebGPU via the pure-Go
gogpu library instead of OpenGL. The project compiles with
CGO_ENABLED=0. There is no C in the runtime path.
The README states the intent plainly:
Ironwail Go is an exercise in porting the entire Ironwail Quake codebase
from C to Go, for the purposes of learning and education. It is an
experiment to get more experience with agentic coding and furthermore to
learn more about the Quake engine, game programming and indulge in a bit
of nostalgia from my school days of hacking together Quake mods and maps.
README
Three threads run through the whole project, and through this article:
An experiment in agentic coding — with multiple agents
The README addresses the elephant directly in a section titled *"Did you say
agentic coding? Is this just AI slop?" The answer is a candid "Yes and
no."* A large portion of the codebase was written by AI agents converting C
to Go, but under a human-as-architect-and-reviewer model that the project's
AGENTS.md codifies as a "Senior-Junior partnership" — the human acts as
architect and reviewer, the agent as a fast, literal-minded junior
engineer. AGENTS
And critically: it was not one agent. The git history attributes work across
several different models:
conversion commits and the cgo/OpenGL era (visible in commit b2fb6e9
"Retire gl+sdl", co-authored with Copilot). Copilot appears as a
co-author on over 700 commits.
GoGPU renderer buildout, the QCVM sync unification, the renderer
diagnosis and fix passes). Named in the README as a primary agent.
majority of agentic work alongside Claude.
20 commits, concentrated in the renderer fix passes of mid-2026.
4f754e0, the QuakeGo modulepath cleanup).
The honest framing is that different agents have different strengths and
failure modes, and the project is as much a field test of *which models can
handle a large port at what stage* as it is a port of Quake. This article
will name agents where the git record supports it and will not invent
task-to-agent mappings the record does not contain. The later chapters —
especially the synthesis — reflect on what multi-agent porting at this
scale actually teaches.
An educational artifact, not just a working engine
The second thread is that the codebase is built to be **read and learned
from**, not merely run. The stated goal is readability and self-explanation,
extensive documentation, and making it possible to understand how the
engine works without prior deep graphics-programming or game-development
experience. This shows up concretely in:
doc.go files with an # Original C lineage sectionnaming the C source files each package mirrors, so a reader can always
locate the C counterpart before refactoring.
docs/internal/*.md — a guide for every internal/ package (qc.md, renderer.md, server.md, ...) explaining purpose, key types,
core workflow, integration points, and learning tips.
docs/RENDERER_LEARNING_PLAN.md — a 14-stage curriculum that walks areader who knows Go but not graphics programming from "what is a GPU?"
all the way to "read RenderFrame() top to bottom and explain every
line," citing Scratchapixel for theory and
webgpufundamentals for API practice, with
build-it-yourself milestones at each stage. LearningPlan
// Where in C: citation convention in tests, e.g. // Where in C: SV_WalkMove in sv_phys.c, anchoring every behavioral
assertion to the canonical reference.
bspdiag — an offline BSP inspection CLI (cmd/bspdiag) built sothat anyone can inspect map lumps, entities, leaf contents, lightmaps, and
liquid alpha settings without writing scratch scripts. AGENTS
TestExecuteProgramRunawayLoopLimitConstantMatchesC asserts the
runaway-loop limit is exactly 0x1000000, and the name itself explains
why that constant matters for mod compatibility. QCDocs
This article is written in the same spirit. It explains Quake-specific and
WebGPU-specific concepts inline rather than assuming them, and it cites
source by file:line so a reader can open the code and follow along.
The Quake Brutalist Jams as the unforgiving integration test
The third thread is that the project's bugs, and thus its bug-fix
narrative, are driven by a specific class of stress test: the **Quake
Brutalist Jam** community map packs.
The Brutalist Jams (qbj) are community mapping events that produce large,
ambitious, idiosyncratic Quake maps. They are exactly the kind of content
that breaks a port that only ever tested against id1/start. ironwail-go
uses qbj2 and qbj3 as its de facto integration suite, and the parity
documentation is shot through with their specifics:
qbj2 mod — specifically its start map — is a BSP2-formatlarge map. It surfaced the texture atlas overflow bug (the materials buffer
is hardcoded to 256 entries; the qbj2 start map has more), the lit-water
fallback mismatch, and the QCVM entity-sync pusher/non-pusher bug chain
(the qbj2 start map's lift trigger stack — a dozen trigger types firing on
spawn — exposed that executeQCFunction was not syncing
MOVETYPE_PUSH entities). QCVM MaterialsDiag
qbj3 (e.g. the qbj3_stickflip map) is the current prioritystress case. The parity guide records its scale: 85,936 raw faces,
77,001 built faces, 168,142 built triangles, 322,144 vertices, 22,195
leafs, 750 models, 106 textures, four lightmap pages, 1,295
lit-water/turbulent faces, and 228 sky faces. Its first rendered frame
at the captured spawn view reports 1,002 visible faces, eight opaque
world batches, seven opaque brush entities, and eleven opaque alias
entities. Parity
Each chapter of this article that discusses a bug will tie it back to the
specific qbj map that surfaced it. The brutalist jams are not a footnote;
they are the reason the open bugs are known and documented, and they keep
the parity claims honest.
The central tension
Here is the tension the rest of this article explores. Quake is a 1996 C
engine built around three assumptions that Go actively pushes against:
Hunk and Zone allocators are asingle pre-allocated heap with manual pointer arithmetic and
bump-allocation arenas. Hickman Go has a garbage collector
and forbids pointer arithmetic.
(Ironwail's modernized OpenGL path) draws directly to a framebuffer in
R_RenderView, binding textures one at a time in immediate GL calls.
WebGPU is explicitly a retained API: you write a "recipe" of commands
into a command buffer, the GPU executes it later, and the CPU and GPU
do not share memory directly. LearningPlan
and the engine code read and write the same edict_t structs in the
same memory. There is no sync. In Go, the GC'd Edict structs and the
flat QCVM.Edicts []byte array are different storage, requiring a sync
layer between them. A round of fixes (commit fe9e43c) collapsed the
old fragile multi-path selective sync (separate pusher/non-pusher
snapshot/diff/restore at five dispatch points) into a single
syncAllToQCVM/syncAllFromQCVM at one dispatch point
(executeQCFunction), eliminating the "forgot to sync this path" class
of bug. 157 typed accessor methods were also added to Edict
(internal/server/entity_accessors.go) that read/write the QCVM byte
array directly, and ~27 call sites in server.go have migrated to
direct-VM access. But the sync layer still exists — the physics and
movement hot paths still use the EntVars Go struct, and
server_qc_sync.go still performs reflection-based field-selective
copies at every QC callback. The long-term goal (steps 3–5 of the
migration plan) is to migrate all hot paths to the accessors, delete
EntVars and the sync layer entirely, and match C's zero-sync model.
Every architectural decision in ironwail-go — and every bug — flows from
the collision between Quake's 1996 assumptions and Go's 2026 reality. The
chapters that follow walk that collision, subsystem by subsystem: the
engine architecture, the Go divergence, the renderer, the render stages,
the QuakeC VM and modding system, and the GoGPU pure-Go WebGPU stack that
makes it all render without a line of C.
Chapter 1: How the Quake Engine Actually Works
Before we can understand what ironwail-go changes, we need to understand
what it is changing. This chapter is an architectural tour of the original
Quake engine — not the software renderer's scanline rasterizer, not the
Win32 platform glue, but the core engine: the simulation, the scripting
VM, the world representation, the networking, and the rendering contract.
It draws on two sources: Zachary Hickman's academic analysis of the Quake
engine, written for Northeastern University Hickman, and the
formal behavior specification in docs/QUAKE_SPECIFICATION.md
Spec, cross-referenced against the C source in ironwail/Quake/.
The game loop: time is real, and the server owns truth
Hickman opens with the observation that *"defining what sort of time the
game loop is based on [...] is critical, as all sub-processes of the engine
are related to the selection and definition of time."* Hickman
Quake's answer is unambiguous: time is real time. The engine measures
wall-clock seconds via Sys_DoubleTime(), computes a delta, and passes it
to Host_Frame. Here is the C Ironwail main loop, simplified from
host.c:1199:
void _Host_Frame (double time)
{
// decide the simulation time
accumtime += host_netinterval ? CLAMP(0.0, time, 0.2) : 0.0;
Host_AdvanceTime (time);
// input
Sys_SendKeyEvents ();
IN_Commands ();
Host_GetConsoleCommands ();
// console commands
Cbuf_Execute ();
// server runs at a fixed rate, separate from the renderer
if (accumtime >= host_netinterval)
{
CL_SendCmd ();
if (sv.active) {
PR_SwitchQCVM(&sv.qcvm);
Host_ServerFrame ();
PR_SwitchQCVM(NULL);
}
accumtime -= host_netinterval;
}
// client reads results from server
if (cls.state == ca_connected)
CL_ReadFromServer ();
// prediction
CL_SetUpPlayerPrediction(false);
CL_PredictMove;
CL_SetUpPlayerPrediction(true);
// render
SCR_UpdateScreen ();
host_framecount++;
}
Hickman summarized this as three phases: *"Network, Prediction/Collision,
and Rendition."* Hickman But there is a subtlety he did not
emphasize: the server and the renderer run at different rates. The
renderer can run at host_maxfps (default 250 in Ironwail), but the server
ticks at a fixed host_netinterval (72 Hz for network play). The
accumtime variable buffers real time and releases it in fixed-size chunks
to the server, so the simulation is deterministic regardless of frame rate.
This is the same pattern the Go port preserves — the internal/host
package's Frame() method in frame.go implements the same timing logic,
and the FrameCallbacks interface (GetEvents, ProcessConsoleCommands,
ProcessServer, ProcessClient, UpdateScreen, UpdateAudio) mirrors
the C call sequence. HostDocs
Resource management: the Hunk, the Zone, and the Cache
Quake manages memory through three mechanisms, each with a different
lifecycle. Hickman covers all three Hickman; the C code lives
in common.c.
The Hunk
The Hunk is a single large block of memory allocated at startup. It is
the engine's general-purpose arena — all map geometry, models, textures,
and most runtime structures live here. The Hunk provides four operations:
Hunk_AllocName(size, name) — bump-allocate from the low end.Hunk_Alloc() — shorthand for Hunk_AllocName with a generic name.Hunk_HighAllocName(size, name) — allocate from the high end (forshort-lived large allocations that can be freed together).
Hunk_TempAlloc(size) — allocate from the high end, freed on the nexttemp alloc. Used for transient buffers.
The Hunk is never freed piecemeal. When a map changes, the entire Hunk is
wiped and rebuilt. This is brutally simple and avoids fragmentation — you
allocate linearly, then nuke everything and start over.
The Zone
The Zone is a smaller heap allocator for objects with individual lifetimes:
strings, small structures, configuration data. It supports Z_Malloc,
Z_Free, and Z_CheckHeap. The Zone is a traditional free-list allocator
inside the Hunk.
The Cache
The Cache is for resources that can be evicted under memory pressure:
sounds, model data, textures. It supports Cache_Alloc, Cache_Free,
Cache_Flush, and Cache_TryAlloc. When the cache is full and a new
resource is needed, the least-recently-used cached resource is evicted
automatically.
The key architectural point: **Quake's memory model assumes the engine owns
a single contiguous block of memory and manages it by hand with pointer
arithmetic.** There is no GC, no per-object free, no safety. The Hunk is
reset by changing a single offset. This is fast and deterministic, and it
is the first thing the Go port has to replace — Chapter 2 covers that
collision.
The client-server architecture
This is the single most important architectural fact about Quake, and the
ironwail-go learning guide states it plainly:
The most important thing to understand about the Quake engine (and thus
Ironwail Go) is that it is fundamentally a client-server application,
even when playing single-player.
LearningGuide
The server is the source of truth
The server runs the physics, executes the QuakeC game logic, and decides
what happens in the world. In C, SV_Physics in sv_phys.c:1226 is the
heart of the server. Each frame it:
StartFrame function to let game logic know a newframe has begun.
for (i=0; i<entity_cap; i++, ent=NEXT_EDICT(ent))) and dispatches physics based on movetype:
```c
if (i > 0 && i <= svs.maxclients)
SV_Physics_Client (ent, i);
else if (ent->v.movetype == MOVETYPE_PUSH)
SV_Physics_Pusher (ent);
else if (ent->v.movetype == MOVETYPE_NONE)
SV_Physics_None (ent);
else if (ent->v.movetype == MOVETYPE_NOCLIP)
SV_Physics_Noclip (ent);
else if (ent->v.movetype == MOVETYPE_STEP)
SV_Physics_Step (ent);
else if (ent->v.movetype == MOVETYPE_TOSS
|| ent->v.movetype == MOVETYPE_GIB
|| ent->v.movetype == MOVETYPE_BOUNCE
|| ent->v.movetype == MOVETYPE_FLY
|| ent->v.movetype == MOVETYPE_FLYMISSILE)
SV_Physics_Toss (ent);
```
qcvm->time by host_frametime.The movetypes are:
| Movetype | Physics handler | Used for |
|---|---|---|
MOVETYPE_PUSH | SV_Physics_Pusher | Doors, platforms, trains (entities that push others) |
MOVETYPE_NONE | SV_Physics_None | Stationary entities |
MOVETYPE_NOCLIP | SV_Physics_Noclip | Entities that ignore collision |
MOVETYPE_STEP | SV_Physics_Step | Monsters (step up stairs, gravity) |
MOVETYPE_TOSS/GIB/BOUNCE/FLY/FLYMISSILE | SV_Physics_Toss | Projectiles, gibs, flying entities |
| (clients) | SV_Physics_Client → SV_WalkMove | Players |
SV_WalkMove is the player movement function — friction, acceleration,
stair-stepping. SV_FlyMove (sv_phys.c:231) is the "basic solid body
movement clip that slides along multiple planes" — the core collision
response for non-walking entities. SV_PushMove (sv_phys.c:434) moves
pusher entities and carries anything riding on them. SV_PushEntity
(sv_phys.c:403) moves a single entity and returns a trace with the
collision result.
The client is a predictive terminal
The client gathers input, sends it to the server as a UserCmd, and
renders the state updates it receives back. As the learning guide's client
package doc explains, the client does five things each frame:
KButton states and mouse movement are combined into a UserCmd (view angles, movement, buttons, impulse).
SendCmd serializes the UserCmd into a CLCMovemessage.
parse.go reads svc_update messages,delta-decompresses entity positions, and updates stats.
prediction.go runs the same physics code as the serverlocally, so the player's view responds instantly rather than waiting
for a server round-trip.
LerpPoint computes a 0.0–1.0 fraction to smoothlyinterpolate entity positions between server updates (which arrive at
20 Hz, while the renderer may run at 250 Hz). ClientDocs
The signon sequence
Even in single-player, the client connects to the server through a
multi-stage "signon" sequence. The formal specification
(QUAKE_SPECIFICATION.md §3.1) defines it:
| Stage | Name | What happens |
|---|---|---|
| 0 | SignonNone | Initial state |
| 1 | SignonPrespawn | Server info and precaches |
| 2 | SignonClientInfo | Client sends its info |
| 3 | SignonBegin | Loading the map |
| 4 | SignonDone | Fully connected and active |
The walkthrough doc puts it bluntly: *"single-player is not a shortcut
around the network model. It is the same conceptual client/server
lifecycle, just connected in-process"* via a loopback socket.
WalkSP In multiplayer, the same protocol runs over UDP.
Entity snapshots and delta compression
The server does not send the full state of every entity every frame. It
sends delta-compressed snapshots: the client maintains the previous
frame's state, and the server sends only what changed. If a frame is
missed, the client must "force link" (snap) to the new position. The
specification notes: *"the client maintains the previous frame's state to
interpolate positions and angles. If a frame is missed, the client must
'force link' (snap) to the new position to prevent visual glitches."*
Spec In C, this lives in cl_parse.c's CL_ParseDelta.
The filesystem: PAK files and search paths
Quake's asset system is a virtual filesystem. The formal specification
(§1) defines the search-path precedence, highest to lowest:
hipnotic/config.cfg).
pak%d.pak in the mod directory, descending numeric order (pak1.pak overrides pak0.pak).
id1/.pak%d.pak in id1/, descending numeric order.ironwail.pak in the application root.The PAK format is simple: a PACK header (4 bytes), a directory offset
(int32), and a directory length (int32). Each directory entry is a 56-byte
null-terminated filename, a position (int32), and a length (int32).
Lookups are case-insensitive. Spec In C, this lives in
common.c (COM_InitFilesystem, COM_AddGameDirectory,
COM_LoadPackFile). The Go port's internal/fs package mirrors this
exactly, including path-sanitization security checks against directory
traversal. FSDocs
The BSP world: geometry, visibility, and collision
Quake maps use Binary Space Partitioning (BSP). The BSP package doc
explains:
A .bsp file is a list of vertices, edges, faces, planes, and leaves.
You do not need to parse it yourself [...] but you must understand that
the world is "one big mesh with extra metadata". BSPDocs
What BSP gives you
A BSP file organizes world geometry into a tree of splitting planes. The
leaves of the tree are convex "rooms" — the empty spaces the player walks
through. Each leaf stores:
CONTENTS_EMPTY, CONTENTS_WATER, CONTENTS_SLIME,etc.
the PVS is a bitmap saying which other leaves can be seen from this
one. This is the single most important optimization in a Quake renderer:
before drawing, the engine finds the leaf containing the camera, looks
up the PVS, and only draws faces in leaves that are in the PVS. Huge
maps render at 60 FPS because most of the world is never drawn.
The tree also supports collision detection. Quake uses three fixed
hull sizes (point, player, large), each precomputed as a separate set
of clip nodes. A trace is a recursive walk down the clip node tree,
splitting the move at each plane. The formal specification (§4.1)
defines them:
| Hull | Bounds | Used for |
|---|---|---|
| 0 | Point (0x0x0) | Projectiles, small objects |
| 1 | Player (-16x-16x-24 to 16x16x32) | Player, most monsters |
| 2 | Large (-32x-32x-24 to 32x32x64) | Shambler, large monsters |
The three trees
A Quake BSP has three parallel trees:
to leaves.
clipnode root.
BSP file) used by the server to quickly find entities near each other
for trigger/collision queries. SV_AreaTriggerEdicts (world.c:287)
walks this tree.
SV_FindTouchedLeafs (world.c:389) recursively determines which BSP
leaves an entity overlaps, which is used for visibility and for deciding
which triggers should fire. SV_LinkEdict (world.c:467) inserts an
entity into the areanode tree and calls SV_FindTouchedLeafs.
SV_TouchLinks (world.c:336) walks the areanode tree to find and fire
trigger entities overlapping a given entity.
The QuakeC VM: game logic as bytecode
Hickman notes that Quake's scripting system allowed modders to *"change
the game without having the C source code"* and that scripts are
"processed by the exec command and sent to cmd.h to be run."
Hickman But the scripting system is far more sophisticated
than that summary suggests.
What QuakeC is
QuakeC is a compiled domain-specific language. Gameplay logic — how the
shotgun works, how monsters think, how doors open, how triggers fire — is
not in the C engine. It is compiled into a progs.dat bytecode file that
the engine loads at map start. The engine provides builtins (native
functions like traceline, spawn, sound, setorigin) that the
QuakeC code calls. The engine calls into QuakeC at specific dispatch
points: StartFrame, PlayerPreThink, PlayerPostThink, touch,
think, use, blocked, and the client lifecycle functions
(PutClientInServer, ClientConnect, etc.).
The VM architecture
The C qcvm_t struct (progs.h:203) holds:
progs — the parsed header of progs.dat.functions — the function table.statements — the bytecode instruction array.globals — the global variable array (floats, accessed by offset).fielddefs — field definitions (for reflection on entity fields).edicts — a byte * pointing to the entity array.edict_size — bytes per entity.builtins[MAX_BUILTINS] — the builtin function table.The shared-memory model
This is the critical architectural fact. In C, an edict_t is:
typedef struct edict_s
{
// engine-side fields (free, area chain, baseline, alpha, scale, ...)
qboolean free;
link_t area;
entity_state_t baseline;
unsigned char alpha;
// ...
entvars_t v; /* C exported fields from progs */
/* other fields from progs come immediately after */
} edict_t;
The entvars_t v field is embedded directly in the struct. QC bytecode's
OP_LOAD_ and OP_STORE_ instructions read and write
&ed->v + field_offset — the exact same memory the C engine code
accesses via ed->v.field. The macros are pure pointer arithmetic:
#define EDICT_TO_PROG(e) (int)((byte *)e - (byte *)qcvm->edicts)
#define PROG_TO_EDICT(e) ((edict_t *)((byte *)qcvm->edicts + e))
#define NEXT_EDICT(e) ((edict_t *)((byte *)e + qcvm->edict_size))
There is no sync. When QuakeC sets self.nextthink, the engine sees
it immediately. When the engine sets ent->v.velocity, QuakeC sees it
immediately. All entity fields are accessible by both C and QC through the
same memory. QCVM This is elegant and fast — and it is the
third thing the Go port has to replace (covered in Chapter 5).
The interpreter loop
PR_ExecuteProgram (pr_exec.c:395) is the interpreter entry point. It
walks the statement array, executing each opcode. Each statement is an
opcode plus up to three operands (typically offsets into the globals or
entity fields). When a statement calls a negative function index, the VM
dispatches to the corresponding builtin in the builtins array. The
interpreter has a runaway-loop limit (0x1000000 statements) as a safety
net against QC bugs hanging the engine.
The command system: everything is a command
Hickman covers scripting in §XIV, noting that scripts can set variables
(/set, /unset), create aliases, and run macros from .cfg files.
Hickman The deeper architectural point is that **everything
in the engine is a command**. When you press a key, it's bound to a
command (e.g., +forward). When you click a menu item, it queues a
command (e.g., map start). When the engine starts, it executes
quake.rc, which execs config.cfg.
The cmdsys package doc explains the dispatch flow:
AddCommand.
AddText (deferred) or InsertText (immediate).
Execute() drains the buffer each frame.and semicolons).
its handler is called; if an alias, it's expanded into the buffer; if
a cvar, it's treated as a set operation. CmdSysDocs
This design means the console, the menu, config files, and automation
all use the same control path. The single-player walkthrough notes:
"menu actions are mostly command producers" — choosing New Game queues
disconnect, maxplayers 1, deathmatch 0, coop 0, map start.
Cvars
Cvars (console variables) store engine state and configuration. They have
flags: FlagArchive (saved to config.cfg), FlagROM (read-only),
FlagAutoCvar (auto-synced to an engine variable). Cvars fire callbacks
when their value changes. The formal specification covers them in §2.3.
Networking: datagrams, reliability, and the protocol
Quake's networking model is UDP-based, even for single-player (where it
uses a loopback driver). Hickman covers the file structure in §XIII,
listing the net_* files for IPX, UDP, serial, loopback, and the VCR
playback driver. Hickman
The protocol
The Quake protocol is byte-oriented. The engine supports three protocol
versions: PROTOCOL_NETQUAKE (15, the original), PROTOCOL_FITZQUAKE
(666, Ironwail's extended protocol with larger entity counts and
additional message types), and PROTOCOL_RMQ (999, further extensions for
large-map coordinates). The Go port defaults to PROTOCOL_RMQ (999) to
support large-map coordinates. Spec
The internal/net package doc explains the messaging model:
than 1400 bytes are fragmented and reassembled. Used for important
state (map changes, precaches, signon).
state (entity positions) where losing a packet is preferable to waiting
for retransmission. NetDocs
The wire format uses SVC_ (server-to-client) and CLC_
(client-to-server) message type constants, defined in
internal/net/protocol.go in the Go port and scattered across the C
headers.
Rendering: from R_RenderView to the screen
Hickman covers the rendering system in §IV, noting that *"Quake uses
OpenGL for the drawing of all graphics in the game"* and that rendering
"mostly revolves around Alias models." Hickman The C
Ironwail renderer is a modernized OpenGL path (core profile, shaders) in
gl_.c and r_.c. The key entry point is R_RenderView in
gl_rmain.c, which:
R_DrawWorld → R_RecursiveWorldNode → R_DrawTextureChains).
R_DrawEntitiesOnList): brush entities, alias models,sprites.
R_DrawWater — opaque then translucent).The full rendering architecture — and how the Go port replaces it with
WebGPU — is the subject of Chapters 3 and 4.
Game object models: alias, sprite, BSP
Hickman covers all three in §IX. Hickman
Alias models (MDL)
Alias models represent players, monsters, and items. The format is
IDPO (IDPOLYGON), version 6. An MDL contains:
name, and an array of vertices (3D position + packed normal). Animation
is achieved by interpolating between frames. Hickman
The internal/model package and internal/renderer/alias/ handle these
in the Go port.
Sprites
Sprites are 2D billboards that always face the camera. They are faster to
render than alias models and are used for explosions, pickups, and other
detailed static objects. The format is IDSP, version 1. A sprite is a
list of 2D pictures organized into frames. Hickman
BSP models (submodels)
A BSP file contains multiple "models." Model 0 is the world itself.
Models 1+ are submodels — brush entities like doors, platforms, and
triggers that are part of the BSP geometry but can move independently.
The server loads these as 1, 2, etc. and assigns them to entities
via setmodel(). BSPDocs
Audio: spatialization and mixing
The audio subsystem splits into files prefixed snd_. snd_dma.c is
the main control for streaming sound output. Sound volume and panning are
spatialized: volume decreases with distance (attenuation), and panning
is calculated using the dot product between the listener's right vector and
the vector to the sound source. Spec The Go port uses the
Oto library for audio output, replacing the C DMA/sound-card
drivers.
Math: mathlib and the assembly fast paths
Hickman covers the math library in §XV. The functions in mathlib.c /
mathlib.h serve three purposes: rendering calculations, collision
detection, and physics. The key functions include:
VectorMA, DotProduct, VectorSubtract, VectorAdd, VectorCopy— basic vector ops.
CrossProduct, VectorNormalize, VectorScale — 3D vector math.R_ConcatRotations, R_ConcatTransforms — matrix composition.BoxOnPlaneSide — the core collision query: given a box and a plane,return 1 (in front), 2 (behind), or 3 (straddling). This is called
millions of times per frame during collision and visibility traversal.
AngleVectors — convert Euler angles to forward/right/up vectors.FloorDivMod, GreatestCommonDivisor, Invert24To16 — integer mathused by the renderer.
Three of these — Invert24To16, TransformVector, and BoxOnPlaneSide
— had hand-optimized assembly implementations in math.s / matha.s for
the original software renderer. Hickman The Go port replaces
all of this with pure-Go float32 math in pkg/types and inline
operations, relying on the Go compiler's optimization.
Why this architecture endures
Thirty years later, the patterns Quake established are still visible in
modern game engines:
standard model for networked games.
culling and portal systems) is still how engines avoid drawing what you
can't see.
universal (Lua in many engines, Blueprint in Unreal, C# in Unity).
static environments, even as real-time global illumination augments it.
but remains an elegant pattern for debuggability and automation.
The architecture is durable. The implementation is not — manual memory,
immediate-mode GL, shared-memory VM, Win32/DOS platform code. That is what
the Go port changes, and Chapter 2 begins that story.
Chapter 2: The Go Divergence — From C Hunk to GC, From OpenGL to WebGPU
Chapter 1 described the Quake engine as it was built in 1996: a manual-memory,
single-threaded, immediate-mode-GL, shared-memory-VM architecture. This chapter
is about what happens when you port that to Go in 2026. The ironwail-go
README states the intent:
Apart from the obvious that this is Go, rather than C, I'm building this
with the following changes: gogpu/WebGPU as the canonical gameplay
renderer/runtime; dividing the codebase up into packages; use Go stdlib
for as much as possible, rather than custom implementations of things
from the original C codebase. README
This is not a transliteration. It is a deliberate re-architecture that preserves
behavioral parity while changing the substrate. Each divergence has a reason,
and each reason has a cost.
Memory: from Hunk/Zone/Cache to the garbage collector
The most fundamental change. Chapter 1 covered Quake's three-tier memory model:
the Hunk (a single pre-allocated arena, bump-allocated and nuked on map change),
the Zone (a free-list heap for individual-lifetime objects), and the Cache
(LRU-evictable resource storage). All three assume the engine owns a contiguous
block and manages it with pointer arithmetic.
Go replaces all of this with the runtime garbage collector. Hunk_Alloc becomes
make() or new(). Raw pointer arrays become slices. Manual Z_Free becomes
implicit GC. The comparison doc states it plainly: *"Replaces Hunk_Alloc with
standard make() or new() and utilizes slices instead of raw pointers for
collections." Comparison The boot sequence doc adds: "The C
version's parms.membase = malloc(parms.memsize) is entirely absent in Go."*
The cost: GC pressure in hot paths
The garbage collector is a trade. You get safety and simplicity; you lose
deterministic deallocation and control over memory layout. In a game engine
running at 250 FPS, the GC pressure from per-frame allocations is real. The
project's git history shows a direct response: commit 5a04a01 ("Optimize
renderer allocations in hot paths") introduced:
Renderer struct for brush entity rendering, eliminating per-frame make() in renderOpaqueBrushEntitiesHAL.
sync.Pool for the dynamic lights slice, reusing allocations acrossframes.
unsafe.Slice for float32ToBytes conversions, avoiding per-call heapallocation.
RLock instead).These are not premature optimizations — they came from profiling the renderer
under the qbj3 brutalist jam map's 1,002 visible faces and 750 models. The
GC pressure is the Go tax on Quake's arena model, and the mitigation is pooling
and reuse rather than reverting to manual memory.
The long-term question: arena allocators
The prologue mentioned that one of the project's future plans is investigating
Go-based arena/region allocators. The Hunk's "allocate linearly, nuke
everything on map change" pattern maps cleanly onto Go's experimental arena
proposal or custom region allocators: allocate a large []byte, sub-allocate
into it, and discard the whole backing array when the map changes. This would
give deterministic cleanup for the bulk of per-map allocations (BSP data,
models, textures) without the GC tax, while still being memory-safe. It is an
open question, not a settled decision. AGENTS
Concurrency: from single-threaded + SDL threads to goroutines
The C engine is primarily single-threaded. Host_Frame runs on one thread.
SDL mutexes and threads are used only for specific tasks: async loading,
background music, and (in Ironwail) the renderer thread. The comparison doc
notes: *"Primarily single-threaded, with some use of SDL mutexes and threads
for specific tasks like async loading or background music."*
Go replaces this with goroutines and channels, but the project does not naively
parallelize the engine. The core simulation remains single-threaded — the
server physics loop, the QCVM execution, and the client update are sequential,
matching C's SV_Physics iteration. Where Go's concurrency model shines is in
the periphery:
The async queue
The internal/async package provides a bounded FIFO work queue that marshals
work from background goroutines back to the main frame pump. Its doc explains
the parity rationale:
This matches the semantics of the original C Ironwail's host.c
AsyncQueue. In the context of a game engine like Quake, many systems (like
save workers or mod downloaders) run in the background but need to update
the game state safely without racing against the client or server state.
AsyncDocs
The queue uses sync.Mutex and sync.Cond for blocking behavior, and is drained
once per frame in Host.Frame. The async doc is candid about the trade-off:
*"While idiomatic Go might use an unbounded channel for this purpose,
async.Queue mirrors the C implementation's bounded, blocking behavior and
atomic drain semantics."* AsyncDocs
Dedicated render thread
The GoGPU renderer runs on its own thread, coordinated through the gogpu.App
event loop. The OnDraw callback (renderer_gogpu_runtime.go:149) registers
the frame draw callback; OnUpdate (:199) registers the game logic update.
The MainThreadQueue in internal/host/mainthread.go ensures that OS-sensitive
operations (window management, renderer calls) execute on the correct thread.
Audio streaming
The internal/audio package uses the Oto library as its backend, replacing C's
DMA/sound-card drivers. Audio mixing still uses the same DMA-style buffer model
(mirroring classic sound card behavior), but the output device is abstracted
behind a Backend interface. The audio doc notes the mixer uses 24.8 fixed-point
arithmetic in SamplePair for precision without floating-point overhead.
Parallel asset loading
The internal/engine package provides ParallelLoad[T] and LoadPipeline[T]
using a worker-pool pattern with a buffered-channel semaphore for concurrency
limiting. This is used during level loading to fetch multiple sounds, models,
and textures concurrently. EngineDocs
Packaging: from flat Quake/.c to internal/ packages
The C Ironwail source is a flat directory of .c and .h files under
Quake/. There are no packages, no visibility control, no import boundaries.
Everything is global. extern declarations and header files are the only
interface contracts. The COM_* functions in common.c are called from
everywhere. The SV_ functions in sv_phys.c call CL_ functions in
cl_main.c directly.
Go cannot work this way. The internal/ package convention enforces visibility
boundaries. The project divides the engine into packages with specific
responsibilities:
| C area | Go package | Responsibility |
|---|---|---|
host.c, main_sdl.c | internal/host | Main loop, timing, session lifecycle |
common.c (VFS) | internal/fs | Virtual filesystem, PAK files |
gl_.c, r_.c | internal/renderer | WebGPU rendering pipeline |
in_sdl.c, keys.c | internal/input | Input abstraction |
pr_exec.c, pr_edict.c | internal/qc | QuakeC VM |
sv_main.c, sv_phys.c | internal/server | Authoritative simulation |
cl_main.c, cl_parse.c | internal/client | Client state, prediction |
cmd.c | internal/cmdsys | Command system |
cvar.c | internal/cvar | Console variables |
console.c | internal/console | Console buffer |
common.c (math) | pkg/types | Vec3, Mat4, angle math |
| — | internal/engine | Generic data structures (Cache, Registry, Queue) |
| — | internal/async | Thread-safe work queue |
| — | internal/game | Top-level coordinator wiring everything together |
The internal/game package is the Go equivalent of the C main() wiring —
it owns the Game struct (internal/game/game.go) that holds Host, Server, QC,
CSQC, Renderer, Client, Particles, Menu, Input, Draw, HUD, Audio, caches, and
overlays. Cvars are registered centrally in internal/game/game_init.go.
The doc.go lineage convention
Every package has a doc.go file with an # Original C lineage section naming
the C source files it mirrors. For example, internal/server/doc.go names
sv_main.c, sv_phys.c, world.c, and pr_cmds.c. This is not decoration —
it is a navigation tool. Before refactoring a Go package, you read its lineage
section to find the C counterpart, then study the C to understand the canonical
behavior. AGENTS
The pkg/qgo exception
pkg/qgo/quake and pkg/qgo/quakego are separate Go modules with their own
go.mod files, intentionally outside the root module. They are not importable by
the engine. This is by design: pkg/qgo/quakego is QuakeGo source (a Go dialect
compiled to QCVM progs.dat bytecode), not regular Go library code. The root
module does not require or replace pkg/qgo/*. AGENTS Chapter 5
covers QuakeGo in detail.
stdlib adoption: replacing custom Quake utilities
The README states: *"Use Go stdlib for as much as possible, rather than custom
implementations of things from the original C codebase."* README
In practice this means:
COM_Parse tokenizer and string utilities are replaced with Go strings / strconv where the semantics are compatible.
The command tokenizer still has custom logic (it must respect Quake's
quote/semicolon rules), but generic string manipulation uses stdlib.
io.Reader / io.Writer / io.NewSectionReader replace C's raw FILE * and byte-pointer I/O. The filesystem package uses io.NewSectionReader
to provide a standard io.Reader over a portion of a .pak file. FSDocs
sync.Map, sync.Pool, generic slices replace C's manuallinked lists and arrays.
pkg/types provides Vec3 and Mat4 as Go structs with both procedural (Vec3Add, Vec3Dot) and method (v.Add, v.Dot) APIs. The
procedural functions follow C Quake's style for parity; the methods provide
idiomatic Go. The doc notes: "Both produce identical results."
Where custom code remains
Some C utilities cannot be replaced by stdlib because they encode Quake-specific
semantics:
internal/cmdsys/cmd_buffer.go) replicatesQuake's specific rules for whitespace, quotes, and semicolons.
internal/qc) must bit-match C's bytecode interpretation, including IEEE divide-by-zero behavior and the 0x1000000 runaway-loop limit.
mod override order.
The CGO policy: pure Go, always
mise.toml sets CGO_ENABLED = "0". The project is pure Go. AGENTS.md states
this as a hard rule: *"CGO is always off. The project is pure Go. Never
introduce CGO dependencies."* AGENTS
This policy was not always in place. The git history tells a story:
The cgo-GLFW detour and return
The project started with GoGPU as the intended renderer (commit 064c027,
2026-02-24: "renderer: port WebGPU core initialization"). But early gogpu
issues — naga shader compilation bugs, Wayland input failures, crashes —
forced a detour. Commit 15b888e (2026-02-25, one day later) added *"alternate
cgo gl renderer"*. For over a month, the engine ran on a cgo-based OpenGL
renderer using GLFW for windowing and SDL for input.
The gogpu issue #157 opening body records the frustration:
I first attempted to tackle things using GoGPU as the rendering backend,
but eventually hit enough issues that I sadly switched to cgo GLFW code.
GogpuIssues
The return came with commit b2fb6e9 (2026-04-05: "Retire gl+sdl (#11)"),
which removed the OpenGL renderer, the SDL input backend, and made Oto the
canonical audio backend, with GoGPU as the sole renderer. The commit was
co-authored with Copilot. After that, commit 889f797 (2026-04-24) dropped
the renderer shims and cleaned up the game loop.
The current stack
The canonical gameplay stack is now:
github.com/gogpu/gogpu)github.com/ebitengine/oto/v3) internal/renderer/gogpu/input_backend.go)
github.com/ebitengine/purego (indirect, forcgo-free native function calls)
purego appears as an indirect dependency — it is used by the gogpu stack
for cgo-free FFI to platform libraries where needed, but the engine itself
compiles with CGO_ENABLED=0. Comparison
Input: from SDL to backend injection
The C engine uses in_sdl.c to interface with SDL2 for keyboard, mouse, and
gamepad events. The input handling comparison doc explains the divergence:
*"Go uses internal/input/ as a backend-neutral abstraction layer. The active
runtime backend is supplied by the executable/renderer integration rather than
by a package-local SDL implementation."* InputHandling
In practice, this means:
internal/input defines a Backend interface and System type thatnormalize keyboard, mouse, and gamepad events into Quake key codes and
movement commands.
(internal/renderer/gogpu/input_backend.go) that bridges gogpu window events
to the input.Backend interface.
KeyDest (console, menu, game), matching C's key_dest dispatch.
The Go implementation maintains identical Quake keycodes (KMWheelUp,
KMouse1, etc.) to ensure compatibility with config.cfg and
autoexec.cfg. Gamepad support is currently initial (deadzones only), compared
to C Ironwail's extensive gyro/rumble support. InputHandling
The gogpu input bugs that forced the cgo detour (issues #129, #173, #175) are
covered in Chapter 6.
Parity-first discipline
The comparison doc states the goal: "high-fidelity parity," meaning
identical progs.dat execution, identical physics and movement, visual parity
with the GoGPU renderer, and support for standard Quake data files.
This is enforced through several mechanisms:
The // Where in C: convention
Tests cite the C function they mirror. For example, in
internal/cmdsys/cmd_test.go:
// Where in C: Cmd_TokenizeString in cmd.c
This appears throughout the test suite. Every parity test is anchored to a
specific C function, so a reader can open both side by side.
Parity test naming
Test names document the invariant being protected:
TestPhysicsSendIntervalMatchesFitzQuakeParity — the send-interval lerptiming matches FitzQuake's protocol extension.
TestWriteEntityUpdate_FieldOrderMatchesCProtocol — the wire format fieldorder matches C exactly.
TestRandomBuiltinMatchesCompatSequence — the random() QC builtin producesthe same sequence as C's compatrand.
TestLoadExternalSkyboxWindMatchesCIronwailConfig — the external skybox windconfig parsing matches C.
The names are documentation. A reader scanning test names learns the parity
contract.
The parity screenshot harness
mise run parity-ref captures deterministic reference screenshots from C
Ironwail. mise run parity-go captures matching GoGPU screenshots.
mise run parity-compare writes visual diffs and exits nonzero if any scene
exceeds the configured mismatch threshold. This is a real CI gate, not a
manual eyeball check. README
The brutalist jam maps as integration tests
As the prologue established, the Quake Brutalist Jam (qbj) map packs are the
project's de facto integration test suite. The qbj2 mod's start map — a
BSP2-format large map — surfaced the texture atlas overflow (the materials
buffer is hardcoded to 256 entries but the map has more), the lit-water fallback
mismatch, and the QCVM entity-sync pusher/non-pusher bug chain (the lift trigger
stack). The qbj3 mod's qbj3_stickflip map is the current priority stress case:
85,936 raw faces, 750 models, 106 textures, 1,295 lit-water faces, 228 sky
faces. Parity
These maps are the unforgiving test. If a parity claim survives a qbj sweep,
it is real.
The technology stack
| Layer | C Ironwail | ironwail-go |
|---|---|---|
| Language | C99 | Go 1.26 |
| Renderer | OpenGL 1.x–3.x (legacy/core mix) | WebGPU via gogpu |
| Audio | SDL2 / DMA drivers | Oto (ebitengine/oto/v3) |
| Input | SDL2 (in_sdl.c) | gogpu input adapter |
| Windowing | SDL2 | gogpu App (Wayland/X11/native) |
| Math | mathlib.c + assembly (math.s) | pkg/types (pure Go float32) |
| Memory | Hunk / Zone / Cache | GC + slices + sync.Pool |
| Concurrency | SDL threads + mutexes | Goroutines + sync + internal/async |
| Data structures | Manual linked lists, arrays | internal/engine (generics: Cache[T], Registry[T], Queue[T]) |
| Platform | Win32 / DOS / Linux | Linux (Wayland/X11) via gogpu |
The Go runtime no longer carries parallel legacy renderer/input/audio variants.
The canonical gameplay stack is GoGPU rendering, renderer-provided input, and
Oto audio. There are no build tags selecting between renderers — the gogpu
renderer is always compiled. Comparison AGENTS
What this sets up
The divergences in this chapter — GC instead of Hunk, packages instead of flat
files, goroutines instead of SDL threads, WebGPU instead of OpenGL, a sync
layer instead of shared VM memory — are the root causes of every bug the rest
of this article covers. The renderer chapters (3 and 4) cover the
OpenGL-to-WebGPU leap. Chapter 5 covers the QCVM dual-storage sync problem.
Chapter 6 covers the gogpu-specific bugs that the pure-Go stack surfaced.
But first, Chapter 3 begins the renderer story: what the C renderer does, and
what replacing it with WebGPU means.
Chapter 3: The Renderer — OpenGL Then, WebGPU Now
The renderer is the most divergent subsystem in ironwail-go. The C Ironwail
renderer is a modernized OpenGL path — core-profile shaders, UBOs, SSBOs,
indirect draws — but it is still fundamentally an immediate-mode, single-pass,
single-framebuffer OpenGL renderer. The Go port replaces it with a WebGPU
renderer built on the gogpu library: explicit pipelines, bind groups,
command-buffer submission, render passes, and an offscreen scene target.
This chapter compares the two architectures and explains the conceptual leaps
required to move from one to the other. Chapter 4 will walk through the render
stages one by one.
The C renderer: R_RenderView and the single framebuffer
The C Ironwail renderer lives in gl_.c and r_.c. The entry point is
R_RenderView, which calls R_SetupView (gl_rmain.c:964) and then
R_RenderScene (gl_rmain.c:1888). The scene rendering order, from
R_RenderScene, is:
void R_RenderScene (void)
{
R_SetupScene ();
R_Clear ();
Fog_EnableGFog ();
S_ExtraUpdate ();
R_DrawEntitiesOnList (false); // opaque world geometry + opaque entities
R_DrawParticles (false); // opaque particles
Sky_DrawSky (); // sky
R_DrawWater (false); // opaque water (alpha == 1.0)
R_BeginTranslucency (); // set up translucent mode
R_DrawWater (true); // translucent water (alpha < 1.0)
R_DrawEntitiesOnList (true); // translucent entities
R_DrawParticles (true); // translucent particles
R_EndTranslucency ();
R_DrawViewModel (); // first-person weapon
R_ShowTris (); // debug wireframe overlay
}
Key characteristics of the C renderer:
Single framebuffer, no intermediate submits
The water diagnosis doc captures the essential constraint: *"C Ironwail (OpenGL)
renders the entire frame to a single framebuffer within one R_RenderView call.
There are no intermediate command buffer submits."* WaterDiag
Everything — opaque, translucent, viewmodel, particles — draws into the same
framebuffer in sequence. Blending just works because the destination buffer
accumulates results naturally.
Per-texture binding
The C renderer uses GL_Bind to bind individual textures to texture units
before each draw. gl_texmgr.c manages gltexture_t objects in a linked list,
with samplers created and deleted as a group. The world renderer in r_world.c
builds texture chains — linked lists of surfaces that share a texture — and
draws them in batches, but each batch still requires a GL_BindTextures call.
This is the classic OpenGL texture-binding pattern.
OIT as an option
C Ironwail supports Order-Independent Transparency via weighted-blended
transparency (McGuire & Bavoil 2013). R_BeginTranslucency (gl_rmain.c:1833)
checks R_GetEffectiveAlphaMode() == ALPHAMODE_OIT and, if so, binds a
separate OIT framebuffer with accumulation and revealage textures, sets up
stencil state, and renders translucent objects into it. A final OIT resolve
pass composites the result back into the scene framebuffer. WaterDiag
OpenGL state machine
The C renderer manipulates GL state directly:
glEnable(GL_POLYGON_OFFSET_FILL), glDisable(GL_STENCIL_TEST),
glStencilFunc, glBlendFunc, glDepthMask. State leaks between draws are
a constant hazard — forgetting to reset depth-write or blend mode after a
special pass produces visual corruption. Ironwail wraps this in GL_SetState
with GLS_* flags (GLS_BLEND_ALPHA, GLS_NO_ZTEST, GLS_NO_ZWRITE,
GLS_CULL_NONE), but the underlying model is a global mutable state machine.
The Go renderer: WebGPU command buffers and explicit pipelines
The Go port's renderer lives in internal/renderer/*_gogpu.go. The entry point
is RenderFrame() at renderer_gogpu_frame.go:82. The renderer package doc
states its core design: *"abstracts the complexities of modern GPU APIs
(specifically WebGPU via the gogpu library) and provides a unified interface
for rendering 3D world geometry, 2D overlays, and special effects."*
The CPU/GPU split
The learning plan explains the mental model: *"the CPU writes a 'recipe'
(commands) into a command buffer, the GPU executes it later. The CPU and GPU do
not share memory directly."* LearningPlan This is visible in
the DrawContext struct (renderer_gogpu.go:16):
type DrawContext struct {
ctx *gogpu.Context // the underlying gogpu context
gamma float32
renderer *Renderer
canvas CanvasState
sceneRenderActive bool
sceneRenderTarget *wgpu.TextureView
overlay *overlay2D // CPU-side 2D compositor buffer
}
The Renderer struct (same file, :101) holds all GPU-side resources:
pipelines, buffers, textures, bind groups. The CPU-side game logic never touches
pixels directly — it fills buffers and submits command encoders.
The Core: headless-capable GPU initialization
The Core struct (core_gogpu.go:46) holds the wgpu Instance, Adapter,
Device, and Queue. CoreConfig specifies backend type, graphics API, validation,
and GPU preference. DefaultCoreConfig() returns BackendGo,
GraphicsAPIAuto, validation enabled, and GPUPreferHighPerformance. The Core
is used for both windowed and headless/screenshot rendering. This is a direct
consequence of WebGPU's design — you create an Instance, request an Adapter,
open a Device, get a Queue. There is no implicit context like OpenGL's
wglMakeCurrent. LearningPlan
Explicit pipeline objects
In OpenGL, pipeline state (shaders, blend mode, depth test/write, cull mode) is
set imperatively before each draw. In WebGPU, you create a
RenderPipelineDescriptor once — with vertex shader, fragment shader, blend
state, depth-stencil state, primitive topology, vertex buffer layout — and the
device compiles it into an immutable RenderPipeline object. At draw time, you
bind the pipeline and issue draws. State cannot leak between draws because there
is no mutable global state — each draw uses exactly the pipeline it was issued
under.
The Go port has separate pipelines for each pass type:
discard in fragment shader.
pipeline for translucent-turbulent.
target, with underwater warp.
Bind groups: the resource binding model
In C OpenGL, textures and uniforms are bound to numbered texture units and
UBO binding points. In WebGPU, resources are organized into bind groups —
immutable bundles of resources (buffers, textures, samplers) bound to a
pipeline at specific @group(N) @binding(M) slots. The Go world shader declares:
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<uniform> materials: array<MaterialData, 256>;
@group(1) @binding(0) var worldSampler: sampler;
@group(1) @binding(1) var worldTexture: texture_2d<f32>;
@group(2) @binding(0) var worldLightmapSampler: sampler;
@group(2) @binding(1) var worldLightmap: texture_2d<f32>;
@group(3) @binding(0) var worldFullbrightSampler: sampler;
@group(3) @binding(1) var worldFullbrightTexture: texture_2d<f32>;
@group(4) @binding(0) var lightClusters: texture_3d<u32>;
@group(4) @binding(1) var<storage, read> dynamicLights: DynamicLights;
Each bind group is created once and rebound per draw. This is more verbose than
OpenGL's GL_Bind, but it eliminates the "forgot to bind a texture" class of
bug and allows the GPU driver to pre-validate resource compatibility.
The 48-byte WorldVertex contract
The most important structural decision in the Go renderer is that **every world,
brush, alias, sprite, and decal vertex uses the same 48-byte layout**. The
vertex layout doc calls this the "three-place contract": the Go struct, the byte
packing functions, and the WGSL pipeline vertex layout must all agree.
Offset Size Field Go type WGSL type Purpose
------ ---- ----- ------- --------- -------
0 12 Position [3]float32 vec3<f32> XYZ world position
12 8 TexCoord [2]float32 vec2<f32> UV into texture atlas
20 8 LightmapCoord [2]float32 vec2<f32> UV into lightmap array
28 12 Normal [3]float32 vec3<f32> Surface direction
40 4 LightmapLayer float32 f32 Lightmap page index
44 4 MaterialID uint32 u32 Materials buffer index
48 bytes total (stride)
The Go struct lives in internal/renderer/world/types.go. Four packing
functions convert WorldVertex slices to flat byte arrays for GPU upload:
createWorldVertexBuffer (static world), appendGoGPUWorldVertexBytes (brush
entities), VertexBytes (sky brushes), and aliasVertexBytesInto (alias
models). The WGSL VertexInput struct in the shader must match. If any one
disagrees, the GPU reads vertex data at wrong offsets — textures scramble,
lighting artifacts appear, geometry disappears. VertexLayout
In C, each vertex type (world, alias, sprite) has its own vertex format and
its own glVertexAttribPointer setup. The Go port unifies them into one
layout, which simplifies pipeline creation and buffer management at the cost
of some wasted bytes (a particle doesn't need a lightmap coordinate, but it
carries one anyway).
Texture atlas + per-vertex material ID
This is one of the biggest conceptual departures from the C renderer.
The problem
A Quake map has hundreds of small textures. In C OpenGL, the renderer binds
each texture individually before drawing the surfaces that use it. This works
because OpenGL's state machine tolerates frequent GL_Bind calls (though it is
slow). In WebGPU, binding individual textures per draw is impractical — bind
group limits and the overhead of creating/rebinding per-texture would cripple
performance. LearningPlan
The Go solution
The Go port packs all world textures into a single texture atlas — one large
2D texture (or texture array) with per-face UV offsets. Each WorldVertex
carries a MaterialID (uint32 at offset 44). The fragment shader looks up
materials[materialID] in a uniform buffer to find the atlas bounds and layer,
then samples the atlas at the correct sub-region. The materials buffer is updated
each frame for texture animation (water, lava, sky texture chains).
The atlas packer is a binary-tree packer in
internal/renderer/world_atlas_gogpu.go (TextureAtlasNode, AtlasLayer).
The materials buffer is a GPU uniform buffer with 256 entries of 32 bytes each
(32 = atlas bounds vec4 + layer f32 + padding). The animateWorldMaterials
function (world_material_gogpu.go:24) rewrites it each frame with the current
animation frame. A separate frame-1 buffer handles pressed button textures
(commit aa17df6).
The open bug
The materials buffer is hardcoded to 256 entries, but baseMaterials is
allocated as textureCount + 2 without clamping. When a map has more than 254
textures — as the qbj2 mod's start map does — a silent buffer overflow occurs.
This is the texture atlas overflow bug, currently open.
MaterialsDiag It is a direct consequence of the atlas design:
the C renderer's per-texture binding has no such limit.
Lightmap array with 1px padding and the Vulkan workaround
Quake's pre-baked lighting is stored in lightmaps — 16x16 texel blocks per
surface. The C renderer uploads these into a single large lightmap texture
(2D, or a 2D array in Ironwail's modernized path).
The Go port uses a lightmap texture array with 1px padding between pages
and a vertical-stacking workaround for Vulkan. The uploadWorldLightmapArray()
function (world_lightmap_gogpu.go:11) handles this. Lightstyles (animated
lighting like flickering lights) are evaluated per frame, and lightmap pages
whose style changed are rebuilt. The fragment shader samples
worldLightmap using the per-vertex lightmapCoord and lightmapLayer.
C never allocates lightmaps for SURF_DRAWTURB (water/lava) surfaces — they
are always fullbright. Ironwail added optional lit water via r_litwater. The
Go port samples the lightmap when litWater > 0.5 in the WGSL uniform,
defaulting to vec3<f32>(0.5) (fullbright when multiplied by 2.0).
Cluster-forward dynamic lights via compute shader
This is a feature the C renderer does not have. The Go port implements a
cluster-forward dynamic lighting system:
world_cluster_compute_gogpu.go:13) divides the camerafrustum into a 3D grid of clusters (32×16×32 tiles). For each cluster, it
computes which dynamic lights affect it and writes a bitmask.
assigned lights, rather than looping all lights.
internal/renderer/dynamic_light.go and dynamic_light_pool.go, then uploaded to a storage buffer.
Core.SetupFrameData() function (core_gogpu.go:158) computes thez-scale/bias used for cluster z-slicing (log-depth).
renderWorldInternal() at world_render_gogpu.go:99.
This is a modern rendering technique that goes beyond anything in C Ironwail's
OpenGL path. It exists because WebGPU's compute shader support makes it natural
to implement, and because the qbj3 stress maps push dynamic light counts that
would be prohibitively expensive with a naive "loop all lights" approach.
OIT: weighted-blended transparency as an optional path
The Go port also implements Order-Independent Transparency as an optional path,
enabled by a cvar:
internal/renderer/oit_mode.go.internal/renderer/oit_render_path.go.internal/renderer/oit_stub.go.internal/renderer/oit/.When enabled, the renderer replaces the sorted-translucent pass with a
weighted-blended one (accumulation texture + revealage texture), avoiding the
back-to-front sort. This mirrors C Ironwail's ALPHAMODE_OIT path, but the Go
implementation is a separate render path rather than a state switch within the
same pass. LearningPlan
Render order parity
Despite the architectural divergence, the Go renderer preserves the C render
order. The RenderFrame() function (renderer_gogpu_frame.go:82) executes
ordered phases:
| Phase | C function | Go function |
|---|---|---|
| Clear | R_Clear | :113-129 (clear or preserve scene target) |
| World BSP | R_DrawWorld → R_DrawTextureChains | renderWorld → renderWorldInternal (world_render_gogpu.go:16) |
| Opaque entities | R_DrawEntitiesOnList(false) | renderEntities (:586) |
| Translucent water | R_DrawWater(true) | within renderWorldInternal (translucent turbulent pipeline) |
| Translucent entities | R_DrawEntitiesOnList(true) | renderGoGPUSortedTranslucentFaceRendersHAL (world_gogpu_translucent.go) |
| Viewmodel | R_DrawViewModel | renderViewModelHAL (world_gogpu_alias.go:593) |
| Scene composite | (post-process via FBO) | compositeSceneRenderTarget (warpscale_gogpu.go:472) |
| PolyBlend | (inline in R_SetupView/V_CalcBlend) | renderPolyBlendHAL (polyblend_gogpu.go:224) |
| 2D overlay | Draw_Console/SCR_UpdateScreen | flush2DOverlay (renderer_gogpu_overlay.go:32) |
The key parity principle from the water diagnosis: *"no face is drawn both
opaquely and translucently. The split is by alpha value, not by pass."*
WaterDiag Both passes use the same framebuffer (in C) or the
same render pass (in Go). The Go port had to learn this the hard way — the
original architecture split the frame into multiple queue.Submit() calls,
and Vulkan drivers discarded the framebuffer contents between submits,
causing translucent water to blend over black instead of opaque geometry.
Commit 6802fc5 fixed this by drawing translucent liquid faces **within the
world render pass itself**, matching C's single-framebuffer model.
The offscreen scene render target
Unlike C, which renders directly to the window framebuffer (or a single FBO
for post-processing), the Go renderer uses an offscreen scene render target
that is later composited to the swapchain surface. This exists for the
underwater warp — a screen-space sinusoidal distortion applied when the
camera is in water. The scene composite pass
(compositeSceneRenderTarget at warpscale_gogpu.go:472) blits the offscreen
target to the swapchain, applying the warp if active. This adds an extra
render pass and texture allocation that C does not strictly need (C applies the
warp via OpenGL's glScissor and viewport tricks), but it is the clean
WebGPU way to do post-processing. LearningPlan
The 2D overlay: CPU compositing
The Go port composites the HUD, menu, and console into a single CPU-side
texture buffer (overlay2D in DrawContext) and blits it to the screen as one
GPU draw. This is different from C, which draws 2D elements via immediate-mode
GL calls. The flush2DOverlay function (renderer_gogpu_overlay.go:32) does
the blit. This approach reduces GPU draw calls for 2D (which can be hundreds of
text characters and pic draws per frame) to a single fullscreen blit.
Commit 3b9cfeb pooled the overlay CPU buffer and cached the GPU texture to
avoid per-frame allocation. RendererDocs
What this means for parity
The architectural divergence is real and has cost. The bugs documented in the
docs/diagnoses/ folder — water translucency, atlas overflow, lightmap
fallbacks, texture corruption on multi-layer atlas maps — are all consequences
of the architectural difference between OpenGL's implicit state model and
WebGPU's explicit pipeline model. Each fix is a lesson in how WebGPU's
constraints reshape the renderer:
render pass (commit 6802fc5).
(commit e99fad0), which then introduced the 256-entry overflow bug.
TextureViewDimension2DArray fallbackfix for faces without lightmap data.
for per-pass alpha values (commit 6802fc5).
Chapter 4 walks through each render stage in detail, with file:line references
and the specific bugs encountered at each stage.
Chapter 4: Render Stages, Broken Down
Chapter 3 compared the C OpenGL renderer and the Go WebGPU renderer
architecturally. This chapter walks through a single rendered frame,
stage by stage, from clear to overlay. For each stage: what it is for, where
it lived in C, how it works in Go, and what bugs were encountered.
The stage numbering follows docs/RENDERER_LEARNING_PLAN.md (Stages 0–14),
which is the project's canonical curriculum for learning the renderer.
LearningPlan The frame orchestration lives in
RenderFrame() at renderer_gogpu_frame.go:82, and the world render pass
lives in renderWorldInternal() at world_render_gogpu.go:16.
Stage 0: The GPU core — Instance, Adapter, Device, Queue
Purpose
Before any rendering can happen, the engine must establish a connection to
the GPU. In WebGPU, this is a four-step hierarchy: create an Instance (the
entry point to the WebGPU API), request an Adapter (a physical GPU), open a
Device (a logical GPU context with its own queue), and get the Queue (the
command submission interface). LearningPlan
C reference
OpenGL has no equivalent — the context is created implicitly by the
platform layer (SDL_GL_CreateContext in gl_vidsdl.c). There is no
adapter selection; the OS's default GPU is used.
GoGPU reality
The Core struct (core_gogpu.go:46) holds the Instance, Adapter, Device,
and Queue. CoreConfig specifies backend type (BackendGo), graphics API
(GraphicsAPIAuto), validation (enabled by default), and GPU preference
(GPUPreferHighPerformance). DefaultCoreConfig() returns these defaults.
The Core is used for both windowed and headless/screenshot rendering. In
windowed mode, the gogpu.App event loop owns the surface; in headless
mode, Core.InitHeadless() creates an offscreen surface for screenshot
capture. The GPU preference was the subject of gogpu issue #176 (adapter
power preference not forwarded on hybrid-GPU Linux systems).
Bugs/lessons
The screenshot path was originally a stub writing RGB(20,20,46) — a
plausible-looking dark color that was not a real GPU readback. This
actively misled the water translucency investigation until it was fixed.
Stage 1: The triangle — pipelines, shaders, bind groups
Purpose
The fundamental unit of WebGPU rendering: a vertex buffer + a WGSL vertex
shader + a WGSL fragment shader + a pipeline object + a bind group, all
wired together to produce pixels. LearningPlan
C reference
In C, this is glBegin/glEnd (legacy) or VBO + glDrawArrays (core
profile). Shaders are GLSL, compiled at runtime. Pipeline state is mutable
and set imperatively.
GoGPU reality
The simplest real shader is the polyblend fullscreen triangle
(polyblend_gogpu.go:15). It has no vertex buffer — positions are baked
into the shader using @builtin(vertex_index):
@vertex
fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
var positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var output: VertexOutput;
output.clipPosition = vec4<f32>(positions[vertexIndex], 0.0, 1.0);
return output;
}
The fragment shader (polyblend_gogpu.go:34) reads a blendColor uniform
and outputs it. This is a fullscreen tint — Quake's "polyblend" used for
underwater color wash and damage flashes. The pipeline setup is in
ensurePolyBlendResourcesLocked() (:83); per-frame use is
renderPolyBlendHAL() (:224), called from RenderFrame() at
:218. LearningPlan
For a real vertex-buffer example, the particle pipeline
(particle_gogpu.go:20) uses instanced vertices with per-particle position
and color attributes.
Bugs/lessons
The naga WGSL→SPIR-V compiler had a bug with scalar mix() (gogpu issue
#162) — mix(vec3, vec3, f32) produced invalid SPIR-V that crashed on
NVIDIA. The workaround was vec3<f32>(fog) splat. Fixed in naga v0.17.0+.
Stage 2: Matrices, the camera, and 3D-to-2D
Purpose
The view matrix transforms world space into camera/eye space. The
projection matrix transforms eye space into clip space (the GPU's
normalized cube). Together they are the VP matrix. LearningPlan
C reference
R_SetupView (gl_rmain.c:964) computes the view, calls
AngleVectors to get vpn/vright/vup, finds the view leaf via
Mod_PointInLeaf, and sets up the projection. The VP matrix is implicitly
the OpenGL projection/modelview stack.
GoGPU reality
Camera state and VP computation live in internal/renderer/camera.go. The
VP matrix is packed into the world uniform buffer (worldUniformsWGSL at
world_shaders_gogpu.go:10):
struct Uniforms {
viewProjection: mat4x4<f32>,
cameraOrigin: vec3<f32>,
fogDensity: f32,
fogColor: vec3<f32>,
time: f32,
alpha: f32,
litWater: f32,
skyWindPhase: f32,
// ...
}
The world vertex shader multiplies position by this matrix
(worldVertexShaderWGSL at world_shaders_gogpu.go:27). Uniform buffer
packing is in renderer_gogpu_uniforms.go. In renderWorldInternal(),
the VP is computed and written to the GPU buffer at :144-153.
Stage 3: Loading real geometry — the BSP world
Purpose
Upload the BSP world geometry (vertices, edges, faces, textures) to GPU
buffers and render it. The world is "one big mesh with extra metadata."
C reference
R_DrawWorld in r_world.c recursively walks the BSP tree, marks visible
surfaces, builds texture chains, and draws them. The C renderer uses SSBOs
and indirect multi-draw for batching (gl_bmodel_indirect_buffer,
GL_DrawTextures).
GoGPU reality
This is the largest stage. UploadWorld() at
world_upload_gogpu.go:18 orchestrates everything:
WorldGeometry in world_geometry_gogpu.go constructs the vertex data.
WorldVertex struct (see Chapter 3 and docs/VERTEX_LAYOUT.md) flows from Go struct → byte packer → WGSL
@vertex input. VertexLayout
appendGoGPUWorldVertexBytes in world_gogpu.go.createWorldPipeline() and friends in world_pipelines_gogpu.go:13.
renderWorldInternal() at world_render_gogpu.go:16 creates the command encoder, begins the
render pass with LoadOpClear, sets the pipeline, sets vertex/index
buffers, sets bind groups, and issues DrawIndexed calls.
The render pass descriptor (world_render_gogpu.go:107-118) attaches both
a color attachment (the surface view or scene render target) and a
depth-stencil attachment (worldDepthTextureView).
Bugs/lessons
Texture corruption on multi-layer atlas maps (commit d89b34c) was caused
by not copying both atlas layer and bounds when animating textures. The
fix was in animateWorldMaterials to swap the entire material config.
Stage 4: Textures and the texture atlas
Purpose
A Quake map has hundreds of small textures. WebGPU cannot bind hundreds of
textures individually. Solution: pack them into a single atlas texture and
use per-vertex materialID to index into a materials uniform buffer that
holds the atlas bounds and layer for each texture. LearningPlan
C reference
C uses per-texture GL_Bind calls. Texture chains group faces by texture
to minimize bind calls, but each chain still requires a bind.
GoGPU reality
world_atlas_gogpu.go (TextureAtlasNode, AtlasLayer). A 2048×2048 atlas with multiple layers.
world_resources_gogpu.go (search foratlas creation).
WorldVertex carries a MaterialID(uint32 at offset 44).
animateWorldMaterials (world_material_gogpu.go:24) with the current
animation frame.
buildWorldFragmentShaderWGSL() in world_shaders_gogpu.go:83 samples worldTexture using per-vertex UV and
materials[materialID].atlasBounds.
Bugs/lessons
The atlas overflow bug (still open): the materials buffer is hardcoded
to 256 entries, but baseMaterials is allocated as textureCount + 2
without clamping. When the qbj2 mod's start map has more than 254
textures, the WriteBuffer call silently overflows the 8192-byte GPU
buffer. The diagMaterialBufferCapacity and diagMaterialBufferWrite
functions in diag_atlas.go log warnings but do not clamp. The fix would
require changing the uniform buffer to a storage buffer to remove the
256-entry limit. MaterialsDiag
Stage 5: Lightmaps — pre-baked lighting
Purpose
Quake does not compute lighting at runtime. Lighting is pre-baked offline
by the map compiler (qrad) and stored as a lightmap: a small grayscale
texture per face. The fragment shader samples both the material texture and
the lightmap, and multiplies them. LearningPlan
C reference
R_DrawTextureChains in r_world.c binds the lightmap texture and draws
with multi-texturing. Lightstyles (animated lighting) are evaluated per
frame in CL_RunLightStyles (cl_main.c).
GoGPU reality
internal/renderer/lightmap_samples.go and internal/renderer/world/lightmap_samples.go.
uploadWorldLightmapArray() at world_lightmap_gogpu.go:11. Uses 1px padding and vertical stacking
(a Vulkan workaround).
rebuilds lightmap pages whose style changed. The setGoGPUWorldLightStyleValues
function is called from RenderFrame (renderer_gogpu_frame.go:135).
buildWorldFragmentShaderWGSL() samples worldLightmap and multiplies it into the final color.
C never allocates lightmaps for SURF_DRAWTURB (water/lava) surfaces —
they are fullbright. Ironwail added optional lit water via r_litwater. The
Go port samples the lightmap when litWater > 0.5 in the WGSL uniform.
Bugs/lessons
The fallback lightmap was created as TextureViewDimension2D but the
shader declared texture_2d_array<f32>. WebGPU rejected it silently,
defaulting to fullbright white (×2.0 overbright). Fixed by using
TextureViewDimension2DArray. WaterDiag
Stage 6: Visibility — BSP, PVS, and "don't draw what you can't see"
Purpose
The single most important optimization in a Quake renderer. The BSP tree
organizes the world into convex leaves. Each leaf has a PVS (Potentially
Visible Set) bitmask saying which other leaves can be seen from it. Before
drawing, the engine finds the camera's leaf, looks up the PVS, and only
draws faces in visible leaves. LearningPlan
C reference
R_MarkVisSurfaces (r_world.c:58) and R_MarkSurfaces (r_world.c:111)
walk the BSP tree and mark visible surfaces using the PVS.
GoGPU reality
WorldRenderData in world.go:57 is a passive data holder. Actual visible face selection is selectVisibleWorldFaces in
world_shared.go:172, called from world_render_gogpu.go:333.
turbulent/sky — helpers in world_shared.go.
renderWorldInternal() only draws faces that passedvisibility. This is why Quake can render huge maps at 60 FPS — the qbj3
qbj3_stickflip map has 85,936 raw faces but only 1,002 visible at the
spawn view. Parity
Bugs/lessons
Single-leaf PVS culled underwater geometry. Fixed by using FatPVS (from
C's SV_FatPVS) when the camera leaf contains water faces. Also, a BSP2
HeadNode traversal bug caused FatPVS/PointInLeaf to start at node 0
(submodel) instead of Models[0].HeadNode[0] — critical for BSP2 maps.
Fixed in internal/bsp/tree.go. WaterDiag
Stage 7: Depth testing and the opaque/translucent ordering problem
Purpose
Opaque objects use depth testing (draw in any order, the depth buffer
resolves which is in front). Translucent objects must be sorted
back-to-front and drawn with depth-write off. LearningPlan
C reference
C draws the entire frame to a single framebuffer with no intermediate
submits. Opaque water (R_DrawWater(false)) draws with blend=OPAQUE,
depth-write=ON. Translucent water (R_DrawWater(true)) draws with
blend=ALPHA, depth-write=OFF. Both use the same framebuffer. The key
principle: no face is drawn both opaquely and translucently — the split is
by alpha value, not by pass. WaterDiag
GoGPU reality
createWorldDepthTexture() at world_depth_gogpu.go:21.
world_pipelines_gogpu.go has separate opaque,alpha-test, translucent, turbulent, and sky pipelines — each with
different blend state and depth-write settings.
world_gogpu_translucent.go (renderGoGPUSortedTranslucentFaceRendersHAL).
translucent entities (see the RenderFrame phase table in Chapter 3).
oit_render_path.go replaces the sort withweighted-blended transparency.
Bugs/lessons
The water translucency bug (resolved in commit 6802fc5) had three root
causes:
into multiple queue.Submit() calls. The translucent water pass opened
a new render pass with LoadOpLoad after the world pass had already
submitted. Vulkan drivers may discard framebuffer contents between
submits, so translucent water blended over black. Fix: draw translucent
water within the world render pass itself.
(alpha=0.6) overwrote the opaque uniform (alpha=1.0) at offset 0.
Fix: dynamic uniform buffer offsets.
wateralpha bypass: ResolveLiquidAlphaSettings only applied the override when r_wateralpha was exactly 1.0. A stale
config value prevented the map's wateralpha=0.6 from taking effect.
Stage 8: Sky, liquids (turbulent), and fog
Purpose
Quake's water/lava/sky surfaces use a "turbulent" warp: UV coordinates are
animated with a sine function to make the texture swim. Sky is a special
surface that ignores depth and uses a two-layer scrolling texture. Fog is
exponential distance fog. When underwater, the final composited scene is
distorted by a sinusoidal screen-space warp. LearningPlan
C reference
Turbulent warp is in gl_warp.c / gl_warp_sin.h. Sky is in gl_sky.c.
Fog is in gl_fog.c. The underwater screen-space warp is in
gl_warp.c's R_BloomScreen / warp-scale pass.
GoGPU reality
turbulent and translucent-turbulent pipelines in world_pipelines_gogpu.go. The fragment shader warps UVs
over time using the time uniform.
sky pipeline. Two-layer scrolling texture with skyWindPhase/skyWindDir fields in worldUniformsWGSL.
skybox_external.go for loading (PNG/TGA/JPG cubemaps), world_external_sky_gogpu.go for GPU bind group/pipeline.
fog_color / fog_density uniforms in worldUniformsWGSL;the fragment shader applies exponential fog based on view distance.
warpscale_gogpu.go — the sceneCompositeFragmentShaderWGSL (:45) applies a sinusoidal UV
distortion when the camera is in water.
The scene composite fragment shader (warpscale_gogpu.go:64-79) is the
underwater warp math:
let aspect = dpdy(uv.y) / dpdx(uv.x);
let warpV = vec2<f32>(warpAmp, warpAmp * aspect);
let remapped = warpV + uv * (1.0 - 2.0 * warpV);
uv = remapped + warpV * sin(vec2<f32>(remapped.y / aspect, remapped.x)
* (3.14159265 * 8.0) + warpTime);
return textureSample(sceneTexture, sceneSampler, uv * uvScale);
Bugs/lessons
The scene composite shader's use of dpdx/dpdy was one of the naga SPIR-V
bugs surfaced in gogpu issue #157 — derivatives produced invalid SPIR-V.
Stage 9: Dynamic lights (cluster compute)
Purpose
Divide the camera frustum into a 3D grid of clusters (32×16×32 tiles). A
compute shader determines which lights affect each cluster. The fragment
shader iterates only the lights in its cluster, rather than looping all
lights. This is a modern technique the C renderer does not have.
C reference
C Ironwail does not have cluster-forward lighting. It uses a simpler
dynamic light model (OpenGL point lights via R_AddLights).
GoGPU reality
createWorldClusterComputePipeline() at world_cluster_compute_gogpu.go:13.
worldClusterComputeShaderWGSL at world_compute_shaders_gogpu.go:5.
dispatchWorldClusterCompute() at world_cluster_compute_gogpu.go:75, called from renderWorldInternal
at world_render_gogpu.go:99 — before the world render pass begins.
internal/renderer/dynamic_light.go and dynamic_light_pool.go.
Core.SetupFrameData() at core_gogpu.go:158computes the z-scale/bias for cluster z-slicing.
buildWorldFragmentShaderWGSL() reads the cluster bitmask from lightClusters (a texture_3d<u32>) and
iterates the assigned lights from the dynamicLights storage buffer.
Stage 10: Entities — brush, alias, sprite, decal, viewmodel
Purpose
Draw everything that isn't the static BSP world: doors and platforms (brush
entities), monsters and items (alias models), explosions and pickups
(sprites), bullet holes (decals), and the first-person weapon (viewmodel).
C reference
R_DrawEntitiesOnList (gl_rmain.c:1108) dispatches by model type:
R_DrawBrushModels (r_world.c:660), R_DrawAliasModels
(gl_mesh.c), sprites, etc.
GoGPU reality
Four sub-pipelines, each with its own shader and pipeline:
| Entity type | Pipeline setup | Render fn | Shader |
|---|---|---|---|
| Brush entity | world_gogpu_brush_render.go | renderOpaqueBrushEntitiesHAL | reuses world shaders |
| Alias (MDL) | world_gogpu_alias.go | renderAliasEntitiesHAL | AliasVertexShaderWGSL at world/gogpu/shaders.go:3 |
| Sprite | world_gogpu_sprite.go | renderSpriteEntitiesHAL | SpriteVertexShaderWGSL at world/gogpu/shaders.go:82 |
| Decal | world_gogpu_decal.go | renderDecalMarksHAL | DecalVertexShaderWGSL at world/gogpu/shaders.go:157 |
The viewmodel (renderViewModelHAL at world_gogpu_alias.go:593) is
a special alias-model render with its own depth handling — it draws on top
of the world without depth-testing against it. All of these are orchestrated
in renderEntities() at renderer_gogpu_frame.go:586, ordered into opaque
→ sky → translucent passes. LearningPlan
Bugs/lessons
CullModeFront (not CullModeBack) to match OpenGL's back-face culling convention
(commits 7505c81, 78a272d).
REPEAT wrap mode, not CLAMP_TO_EDGE (commit 7911202). Palette index 255 needed to be
treated as opaque (commit f0fb2af).
sampler (commits e68aa0c, 4f5e03b, 6dfda87).
entirely — pressed buttons showed their unpressed texture (commit
aa17df6). MaterialsDiag
Stage 11: Particles
Purpose
Particles are camera-facing billboards with a procedural soft-circle
fragment shader. Simulated on the CPU (gravity, decay), uploaded each frame.
C reference
r_part.c — R_RunParticle and R_DrawParticles.
GoGPU reality
internal/renderer/particle.go.
particle_gogpu.go (particleVertexShaderWGSL at :20, particleFragmentShaderWGSL at
:75, ensureParticleResourcesLocked at :148,
renderParticlesHAL at :354).
particleBatchCapacity).@location(0) position and @location(1) colorper-instance attributes.
Stage 12: Post-processing — scene composite, polyblend, overlay
Purpose
Render the 3D scene to an offscreen texture, then draw that texture to the
screen with a fullscreen shader that can distort it (underwater warp), tint
it (polyblend), and finally draw the 2D UI on top. LearningPlan
C reference
C uses OpenGL FBOs for post-processing. The underwater warp is applied via
viewport/scissor tricks. The polyblend is V_CalcBlend → R_SetupView.
2D overlay is SCR_UpdateScreen → Draw_Console etc.
GoGPU reality
Three post passes, in order:
compositeSceneRenderTarget() at warpscale_gogpu.go:472): blits the offscreen scene render target to
the swapchain surface, applying the underwater warp if the camera is in
water. Shaders: sceneCompositeVertexShaderWGSL (:16),
sceneCompositeFragmentShaderWGSL (:45).
renderPolyBlendHAL() at polyblend_gogpu.go:224): fullscreen tint. Shaders: polyBlendVertexShaderWGSL (:15),
polyBlendFragmentShaderWGSL (:34).
flush2DOverlay() at renderer_gogpu_overlay.go:32): HUD/menu/console composited CPU-side
into a single texture and blitted. Pipeline: overlay_composite_gogpu.go
(overlayCompositeVertexShaderWGSL at :11,
overlayCompositeFragmentShaderWGSL at :37).
All three use the same fullscreen-triangle pattern (vertex positions baked
into the shader via @builtin(vertex_index)).
Stage 13: The full frame — RenderFrame() top to bottom
Purpose
Combine all stages into one frame loop. LearningPlan
GoGPU reality
Reading RenderFrame() at renderer_gogpu_frame.go:82 end to end:
| Frame phase | Code | Stage |
|---|---|---|
| Clear | :113-129 | Stage 0 |
| Cluster compute dispatch | world_render_gogpu.go:99 | Stage 9 |
| World BSP render | renderWorldInternal world_render_gogpu.go:16 | Stages 3-8 |
| Opaque brush/alias/sprite/particle entities | renderEntities :586 | Stages 10-11 |
| Translucent water + entities (sorted) | renderGoGPUSortedTranslucentFaceRendersHAL | Stage 7 |
| Viewmodel | renderViewModelHAL world_gogpu_alias.go:593 | Stage 10 |
| Scene composite (warp) | compositeSceneRenderTarget warpscale_gogpu.go:472 | Stage 12 |
| PolyBlend | renderPolyBlendHAL polyblend_gogpu.go:224 | Stage 12 |
| 2D overlay | flush2DOverlay renderer_gogpu_overlay.go:32 | Stage 12 |
The host_speeds 1 cvar enables per-phase timing (clear_ms,
world_ms, entities_ms, viewmodel_ms, scene_composite_ms,
polyblend_ms, overlay_ms, total_ms) logged each frame. README
The depth-stencil is cleared before the entities phase
(:177-188) so entities can depth-test against the world without
re-rendering the world into the entity pass.
Stage 14 (optional): Order-Independent Transparency
Purpose
Replace the sorted-translucent pass with weighted-blended transparency
(McGuire & Bavoil 2013), avoiding the back-to-front sort. Enabled by a cvar.
C reference
C Ironwail's ALPHAMODE_OIT path in R_BeginTranslucency — uses an OIT
framebuffer with accumulation and revealage textures, stencil state, and a
final resolve pass.
GoGPU reality
internal/renderer/oit_mode.go.internal/renderer/oit_render_path.go.internal/renderer/oit_stub.go.internal/renderer/oit/.When enabled, translucent objects render to an accumulation texture +
revealage texture, then composite. This avoids the sort but is optional —
the default path is sorted translucency.
Chapter 5: The Modding System and the QuakeC VM
Quake's moddability is one of its most enduring legacies. The game logic —
how the shotgun works, how monsters think, how doors open, how triggers
fire — is not in the engine's C code. It is compiled into a progs.dat
bytecode file that the engine loads at map start and interprets via a
virtual machine. This chapter explains that VM, the Go port's challenges
with it, and the independent QuakeGo side project.
What QuakeC is
QuakeC is a compiled domain-specific language. It compiles to bytecode that
runs on the QuakeC Virtual Machine (QCVM), a simple stack-based interpreter
embedded in the engine. The language has:
float (32-bit). All numeric values — positions, velocities, health, flags, even booleans — are float32.
vec3 (three consecutive float32 values in the globalsarray or entity fields).
or builtins (native engine functions dispatched by negative index).
This design enabled a thriving modding community: modders could write
entirely new gameplay without access to the engine source. The engine
provides builtins — native functions like traceline, spawn,
sound, setorigin, makevectors — that QC code calls to interact with
the engine. The engine calls into QC at specific dispatch points:
StartFrame, PlayerPreThink, PlayerPostThink, touch, think,
use, blocked, and the client lifecycle functions (PutClientInServer,
ClientConnect, etc.). QCDocs Hickman
The VM architecture
The Go QCVM lives in internal/qc/. The key types are:
DProgs — the progs.dat header
type DProgs struct {
Version int32 // Must be 6
CRC int32
Statements int32 // Offset to bytecode statements
NumStatements int32
GlobalDefs int32 // Offset to global definitions
NumGlobalDefs int32
FieldDefs int32 // Offset to field definitions (reflection)
NumFieldDefs int32
Functions int32 // Offset to function table
NumFunctions int32
Strings int32 // Offset to string table
NumStrings int32
Globals int32 // Offset to global variables
NumGlobals int32
EntityFields int32 // Number of entity fields
}
This is the on-disk format, parsed by LoadProgs. The C counterpart is
dprograms_t in progs.h.
DFunction — a function definition
type DFunction struct {
FirstStatement int32 // Negative for builtins
ParmStart int32 // Offset of first parameter
Locals int32 // Number of local variables
Profile int32 // Profiling counter
Name int32 // String table index
File int32 // Source file string index
NumParms int32
ParmSize [MaxParms]byte
}
When FirstStatement is negative, the function is a builtin: the negative
value indexes into the Builtins array. When positive, it is a bytecode
function starting at that statement index.
DStatement — a single bytecode instruction
Each statement is an opcode plus up to three operands (typically offsets
into the globals or entity fields). The opcode categories are:
OPAddF, OPSubF, OPMulF, OPDivF, etc.OPEqF, OPNeF, OPLE, OPLT, OPGE, OPGT.OPIF, OPIFNot, OPGoto, OPReturn, OPDone.OPCall0–OPCall8.OPLoad, OPStore, OPAddress.OPState.The interpreter loop
ExecuteProgram (exec.go:62) is the entry point. It dispatches builtins
directly for negative FirstStatement, or enters the bytecode loop for
positive. The loop (:97) reads Statements[XStatement], switches on
the opcode, and increments XStatement at the bottom. The comment at
:85 notes a subtle difference from C's pre-increment convention.
The runaway-loop limit is 0x1000000 (exec.go:34):
const runawayLoopLimit = 0x1000000
If the statement count exceeds this, the VM aborts with `"runaway loop
error"`. This is a parity constant — changing it would break mods that
rely on the exact limit. QCDocs
Profile counters
Each function has a Profile counter. The profile console command prints
the top 10 functions by statement count and resets the counters. This is
the engine's built-in QC profiler. README
Bit-perfect parity concerns
The QCVM must bit-match C's behavior for demo compatibility. Several tests
in exec_test.go guard invariants that would break demos or mods:
1/+0→+Inf, -1/+0→-Inf, 0/+0→NaN. QCprograms sometimes divide by zero intentionally as an early-exit
pattern. The Go float32 division produces the same IEEE results as C.
(TestExecuteProgramDivByZeroBehaviorMatrixMatchesC)
mod builtin: 9 cases of integer/float modulo including sign combinations and zero divisors. The Go % operator uses truncated
division and must match C exactly. (TestModBuiltinBehaviorMatrixMatchesC)
random() builtin: the RNG sequence must be byte-for-byte identicalfor demo playback. Both the fixed and legacy formulas are tested with
hardcoded expected sequences. (TestRandomBuiltinMatchesCompatSequence)
0x1000000. (TestExecuteProgramRunawayLoopLimitConstantMatchesC)
The C shared-memory model
This is the critical architectural fact established in Chapter 1. In C, an
edict_t struct contains engine fields and an embedded entvars_t v
field. The QC bytecode's OP_LOAD_ / OP_STORE_ instructions read and
write &ed->v + field_offset — the exact same memory the C engine
code accesses via ed->v.field. The macros are pure pointer arithmetic:
#define EDICT_TO_PROG(e) (int)((byte *)e - (byte *)qcvm->edicts)
#define PROG_TO_EDICT(e) ((edict_t *)((byte *)qcvm->edicts + e))
#define NEXT_EDICT(e) ((edict_t *)((byte *)e + qcvm->edict_size))
No sync. When QC sets self.nextthink, the engine sees it
immediately. When the engine sets ent->v.velocity, QC sees it
immediately. All entity fields — standard and extension — are accessible
by both C and QC through the same memory. QCVM
The Go dual-storage problem
Go forbids pointer arithmetic and has a garbage collector. The engine
cannot share a raw byte * array with the QCVM and have Go structs point
into it. Instead, the Go port has two separate storage representations:
s.Edicts []*Edict (Go structs)
└── Edict.Vars *EntVars (78 typed fields — Go's source of truth)
s.QCVM.Edicts []byte (flat byte array, ~105+ fields)
└── [entNum*EdictSize + 28 + fieldOfs*4]
EntVars (the typed struct). ent.Vars.Origin, ent.Vars.Solid, ent.Vars.Velocity, etc.
QCVM.Edicts (the flat byte array) via vm.EFloat(entNum, fieldOfs), vm.EVector(...), vm.SetEFloat(...).
before and after every QC callback.
What is synced
The per-edict sync (syncEdictToQCVM / syncEdictFromQCVM in
server_qc_sync.go) uses reflection to copy fields between EntVars and
the QCVM byte array. Only 78 of ~105+ QCVM fields are bound to EntVars
struct fields and thus synced. Extension fields (state, speed, wait,
pos1, pos2, finaldest, think1, count, delay, killtarget,
trigger_field, th_checkattack, customflags, target2/3/4) exist
only in QCVM bytes — Go physics and networking never read them through
the sync layer (though some are accessible via the direct-VM accessor
methods). QCVM
The sync layer
syncAllToQCVM() (sync_all.go:47) copies all EntVars fields → QCVM
bytes for every active entity before a QC callback.
syncAllFromQCVM() (sync_all.go:14) copies all QCVM bytes → EntVars
for every active entity after a QC callback. Both are called from
executeQCFunction (qc_trace.go:69), the single sync point for all
QC dispatch.
The cost: reflection and GC pressure
The sync functions use reflect.ValueOf(vars).Elem() and iterate
entFieldBinding slices to copy each field. This is O(numEdicts ×
numFields) per QC callback, and it allocates nothing per call (the
bindings are cached), but the reflection overhead is real. The parity
doc's CPU profile of the qbj3 qbj3_stickflip map found that QC/server
edict sync paths — syncEntVarsFromQC, syncEntVarsToQC,
captureNonPusherQCVMEdictSnapshots, syncMutatedNonPushersFromQCVM,
SetEFloat — dominate the profile alongside the QC execution itself.
The bug chain: selective sync and the qbj2 lift
The original sync architecture was selective: it classified entities
as pushers (MOVETYPE_PUSH) or non-pushers and synced them differently
at each of five dispatch points (touchLinks, Impact, PhysicsPusher
think, executeQCFunction, executeQCFunctionLeavingGlobals). Each
dispatch point captured snapshots, executed QC, and selectively synced
back. This was fragile — "forgot to sync this path" was an entire bug
class.
The qbj2 mod's start map exposed this via a lift trigger stack:
trigger_multiple → multi_touch → multi_trigger → SUB_UseTargets.
func_button → button_use → button_fire → SUB_CalcMove(button starts moving).
button_wait → SUB_UseTargets (with delay=.5).DelayedUse entity (MOVETYPE_NONE) with think=DelayThink.DelayedUse think fires via RunThink → executeQCFunction.
DelayThink → SUB_UseTargets → finds func_train → train_use → train_next → SUB_CalcMove sets train velocity/nextthink in QCVM.
executeQCFunction synced non-pushers back but NOT pushers.PhysicsPusher nevermoves it. The lift doesn't work.
The fix: unified sync
Commit fe9e43c replaced the selective sync with
syncAllToQCVM/syncAllFromQCVM — all entities, unconditionally, at a
single dispatch point. The fragile pusher/non-pusher classification,
capturePusherSnapshots, syncPushersToQCVM,
syncMutatedPushersFromQCVM, and related functions were deleted (~170
lines of dead code). Callers now just set self/other/time globals
and call executeQCFunction. QCVM
The accessor infrastructure
The same commit added 157 typed accessor methods to Edict in
internal/server/entity_accessors.go. These read/write the QCVM byte
array directly via s.QCVM.EFloat(e.Num, qc.EntFieldModelIndex),
bypassing EntVars entirely. The doc comment is explicit:
Entity field accessors provide typed read/write access to QCVM entity
data via the byte array, eliminating the need for the EntVars sync layer.
These methods read/write directly to s.QCVM.Edicts[] — the single
source of truth — matching C Ironwail's shared-memory architecture.
~27 call sites in server.go have migrated to direct-VM access. But the
physics and movement hot paths still use ent.Vars.* — the full migration
to accessors is the remaining work (steps 3–5 of the migration plan).
Hook isolation
A separate bug: a package-level serverBuiltinHooks global caused all VMs
to share hooks. A CSQC VM and a server VM would cross-contaminate each
other's callbacks. Fixed by moving hooks to per-VM storage. Tested by
TestVMServerHooksIsolation. QCDocs
The long-term goal: eliminate sync entirely
The migration plan has five steps:
| Step | What | Status |
|---|---|---|
| 1 | Add accessor methods to Edict, cache extension field offsets | Done (157 accessors) |
| 2 | Migrate hot-path code to accessors | Partial — server.go has ~27 direct-VM sites, physics/movement still use EntVars |
| 3 | Remove sync functions (delete server_qc_sync.go, simplify qc_trace.go) | Partially done — old selective sync removed, sync-all replaces it |
| 4 | Remove EntVars struct (rewrite savegame.go for QCVM bytes) | Not done |
| 5 | Simplify callback dispatch (match C exactly — no sync, just set globals and execute) | Not done — sync-all still runs at every callback |
When steps 3–5 complete, EntVars, syncAllToQCVM,
syncAllFromQCVM, and server_qc_sync.go will be deleted entirely. The
executeQCFunction wrapper will simplify to just save/restore
self/other/time globals and execute — matching C's zero-sync model
exactly. The accessor infrastructure is in place; the remaining work is
migrating the hot paths. QCVM
CSQC: client-side QuakeC
The QC package also supports CSQC (Client-Side QuakeC), a specialized
wrapper for client-side logic: custom HUD rendering, client-side effects,
and input handling. The CSQC type in internal/qc/ loads a separate
csprogs.dat and provides hooks for CallDrawHud, CallDrawOverlay,
and client event dispatch.
CSQC runtime integration is currently deferred — the repo has CSQC
wrapper infrastructure, but host/client runtime wiring for a full CSQC
gameplay path is outside the current parity milestone. Parity
The tests in csqc_test.go verify construction, loading, precache
registry behavior, and global sync, but no e2e CSQC gameplay path is
wired.
QuakeGo: the side project (not used in the engine)
Critical clarification: pkg/qgo (the qgo compiler and QuakeGo
gameplay source) is an independent side project to explore porting the
QuakeC language to a Go-dialect variant. It is **not wired into the
engine. The engine runs the original** progs.dat bytecode compiled
from the original QuakeC sources — there are **no tests or e2e runs of the
game using a QuakeGo-compiled progs.dat**.
What QuakeGo is
qgo is a compiler in cmd/qgo/ that takes a Go package and emits
Quake progs.dat bytecode for the QCVM. QuakeGo is the Go subset and
runtime surface used by that compiler:
pkg/qgo/quake — core QCVM-facing types (Entity, Vec3, Func) and engine builtin stubs (pkg/qgo/quake/engine/).
pkg/qgo/quakego — translated gameplay package proving the model worksagainst real Quake game logic.
The mental model from the QGo guide: *"QuakeGo is not 'full Go running on
Quake.' It is a deliberately narrow Go subset that maps cleanly onto
QuakeC VM concepts."* QGoGuide The supported types are
float32, string, bool, quake.Vec3, *quake.Entity, and function
values. Struct fields tagged for qgo map to entity fields. Methods are
lowered to QCVM-compatible functions. Engine calls are expressed as imports
from quake/engine.
Why it is a separate module
pkg/qgo/quake and pkg/qgo/quakego are separate Go modules with
their own go.mod files. The root module does not require or replace them.
They cannot be imported by the engine. This is intentional: pkg/qgo/quakego
is QuakeGo source (a Go dialect compiled to QCVM progs.dat bytecode by
cmd/qgo), not regular Go library code. From the repo root, gopls/LSP may
report BrokenImport errors for these packages — these are expected.
The mechanical-port convention
pkg/qgo/quakego intentionally mirrors original QuakeC/progs source
structure. The QGo guide is explicit: *"Avoid cosmetic Go-idiom rewrites
(tagged switches, merged var decls) there — they drift the port from
progs.src and make resync harder."* .golangci.yml suppresses unused,
SA4017, QF1003, and S1021 for that package for the same reason.
AGENTS This is a resync concern specific to the QuakeGo side
project, not the engine — the engine itself uses the original QC bytecode.
How to use it
go build -o qgo ./cmd/qgo
cd pkg/qgo/quakego
../../../qgo
Or via mise: mise run build-progs. The qgo CLI also has a
source-order utility for deterministic function/file ordering. But the
output progs.dat is never loaded by the engine — the engine loads the
original progs.dat from the Quake data directory.
Chapter 6: GoGPU — Pure-Go WebGPU in Practice
The decision to use a pure-Go WebGPU stack is the defining technical gamble of
ironwail-go. The README states it as a first principle: *"gogpu/WebGPU as the
canonical gameplay renderer/runtime."* README The project compiles
with CGO_ENABLED=0. There is no C in the runtime path — not in the renderer,
not in the audio, not in the windowing. This chapter is a field report on what
that actually means, using the real bugs, issues, and lessons encountered over
the course of the port.
The GoGPU module family
GoGPU is not a single library. It is a family of Go modules, each with a
specific role in the WebGPU stack:
| Module | Version | Role |
|---|---|---|
github.com/gogpu/gogpu | v0.44.1 | High-level renderer: App, event loop, window, surface, input |
github.com/gogpu/gpucontext | v0.21.0 | Event source abstraction: keyboard, mouse, resize, focus, IME |
github.com/gogpu/gputypes | v0.5.1 | Type definitions: vertex formats, blend states, bind group layouts |
github.com/gogpu/naga | v0.17.15 | WGSL → SPIR-V shader compiler (Go port of the naga project) |
github.com/gogpu/wgpu | v0.30.10 | Low-level WebGPU bindings (Instance, Device, Queue, buffers, textures) |
github.com/go-webgpu/goffi | v0.5.6 (indirect) | FFI layer for native library calls without CGO |
github.com/go-webgpu/webgpu | v0.5.2 (indirect) | Underlying WebGPU API definitions |
The dependency graph is: gogpu → gpucontext (events) + wgpu (GPU
primitives) + gputypes (type defs). wgpu → goffi (cgo-free native FFI)
+ webgpu (API types). naga is used at shader compilation time to translate
WGSL source (Go string constants) into SPIR-V bytecode that the Vulkan backend
can consume. None of these require CGO — the native library FFI is done via
purego (cgo-free dynamic loading of shared libraries).
The WGSL → SPIR-V pipeline via naga
WebGPU shaders are written in WGSL (WebGPU Shading Language). But the native
Vulkan backend does not consume WGSL directly — it needs SPIR-V bytecode. The
naga module is the compiler that bridges this gap: it parses WGSL, builds an
intermediate representation, and emits SPIR-V. This happens at pipeline creation
time, when the Go code calls device.CreateRenderPipeline with a shader module
compiled from a WGSL string constant.
Every shader in ironwail-go is a Go string constant:
const worldVertexShaderWGSL = `
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) texCoord: vec2<f32>,
// ...
}
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
// ...
}
`
When the pipeline is created, naga compiles this string to SPIR-V, and the
resulting shader module is bound into the pipeline. If naga produces invalid
SPIR-V, the pipeline creation may succeed (naga does not always validate against
the SPIR-V spec) but the GPU driver will crash or produce garbage at draw time.
This is exactly what happened.
Bug: naga invalid SPIR-V for scalar mix() (issue #162)
The problem
The SPIR-V spec requires all FMix operands to have matching types. When
WGSL's mix() function uses a scalar blend factor (f32) with vec3<f32>
operands, naga v0.15.2 emitted an FMix instruction with mismatched operand
types — a vec3 and a scalar float. AMD's RADV driver tolerated this on the
integrated GPU, but running with DRI_PRIME=1 to enforce the discrete NVIDIA
GPU crashed with SIGSEGV at addr=0x10. GogpuIssues
The workaround
Commit d5ff084 splatted the scalar to a vec3 explicitly:
// Before (crashed on NVIDIA):
result = vec4<f32>(mix(result.rgb, uniforms.fogColor, uniforms.fogDensity), 1.0);
// After (workaround):
result = vec4<f32>(mix(result.rgb, uniforms.fogColor, vec3<f32>(uniforms.fogDensity)), 1.0);
This appears in world_shaders_gogpu.go:637 and :723. The explicit
vec3<f32>(...) splat forces naga to emit a correct
OpCompositeConstruct + FMix sequence.
The fix
naga v0.17.0+ fixed the scalar-to-vector splat automatically. The fix
produces:
%22 = OpLoad %float %fog_ptr
%23 = OpCompositeConstruct %v3float %22 %22 %22 ; scalar → vec3 splat
%24 = OpExtInst %v3float FMix %a %b %23 ; all operands vec3 ✅
After upgrading to naga v0.17.15 (the current go.mod version), the
workaround is no longer necessary, though the explicit splat remains in the
shader as defensive coding. GogpuIssues
Bug: naga swizzle gap (issue #157 comments)
The problem
naga's WGSL parser could not handle swizzle expressions in certain
contexts. The particle vertex shader used a writable swizzle compound
assignment that triggered ExprSwizzle is not a pointer expression in naga.
This prevented the GoGPU renderer from compiling its shaders at all — no
visuals, just a crash. GogpuIssues
The workaround
Commits de40302 and ef8f1c0 replaced writable swizzle compound
assignments with explicit vector reconstruction. The particle shader was
rewritten to avoid swizzles entirely. This was the breakthrough that
produced the first actual visuals on the GoGPU renderer — the screenshot in
issue #157 shows a rendered Quake scene after the swizzle fixes.
The response
gogpu maintainer kolkov confirmed both the swizzle gap and the
dpdx/dpdy/textureDimensions SPIR-V issue (which affected the scene
composite fragment shader) as naga bugs, filed them as naga #45 and #46, and
said: *"These are exactly the real-world 3D patterns we were missing in our
test coverage."* GogpuIssues
Bug: Linux X11 input stub (issue #129)
The problem
The X11 key handling code in gogpu's Linux platform layer was just a stub —
no keyboard events were delivered. Mouse input was also absent. This was the
first input blocker that forced the cgo-GLFW detour (Chapter 2).
The fix
Fixed in gogpu v0.22.8. The InputBackend in
internal/renderer/gogpu/input_backend.go bridges gogpu's gpucontext event
source to the engine's internal/input.Backend interface. It uses callback-based
input (OnKeyPress, OnMouseMove, etc.) when available, and falls back to
polling b.app.Input().Keyboard() / .Mouse() state otherwise. The polling path
has a heartbeat log to detect silent input failures. GogpuIssues
The input architecture
The Go port's input is layered:
gpucontext polls X11/Wayland events.InputBackend (input_backend.go) translates gpucontext events into Quake key codes via input_map.go
(MapGPUContextMouseButton, key code mappings).
internal/input.System normalizes events and dispatches based on KeyDest (console, menu, game).
internal/game/game_input.go routes keys to theappropriate subsystem.
This keeps the higher-level game code unaware of gogpu/X11/Wayland details,
matching the C engine's separation of in_sdl.c from keys.c.
Bug: no pointer lock / mouse grab (issues #173, #175)
The problem
An FPS requires pointer lock (mouse grab) to look around without the cursor
leaving the window. gogpu had no API for this. On Wayland, the pointer
constraints protocol implementation did not exist at all.
The resolution
Issue #173 asked for the feature; issue #175 pointed to
libwldevices-go as a potential
Wayland implementation dependency. Both were closed after gogpu added pointer
lock support. GogpuIssues
Bug: adapter power preference ignored (issue #176)
The problem
On hybrid-GPU Linux systems (integrated + discrete, e.g., Intel + NVIDIA
laptops), the windowed renderer did not forward a power preference to
RequestAdapter. This meant the runtime might select the discrete NVIDIA
adapter even when the application explicitly requested low-power/integrated.
DRI_PRIME environment variables are not a reliable substitute.
The fix
The issue proposed adding a PowerPreference field to gogpu.Config and
forwarding it through RequestAdapter. The Core struct in
core_gogpu.go:46 now has GPUPreference in CoreConfig, with
DefaultCoreConfig() returning GPUPreferHighPerformance. The CoreConfig
is the Go-side mechanism for this — the engine can expose a user-facing GPU
preference cvar and pass it through. GogpuIssues
The Wayland two-connection bug (BUG-GOGPU-002)
This is the defining architectural bug of the gogpu stack, and the one that
caused the most frustration during the port. It is documented in the issue
#157 comment thread by gogpu maintainer kolkov. GogpuIssues
The problem
gogpu used two separate wl_display_connect() calls from the same process:
wl_seat, wl_pointer, wl_keyboard(where gogpu listened for input events).
wl_surface + xdg_toplevel (where Vulkan rendered).
Wayland delivers input events to the connection that owns the focused
surface. The window was on the C connection. The input listeners were on
the Pure Go connection. They never met. No mouse, no keyboard, no input
of any kind registered.
Why it worked on X11
On X11, window IDs are server-side — they are shared across connections.
Two X11 connections to the same display server can both see the same
window. Wayland surfaces are client-side — they are scoped to the
connection that created them. The dual-connection design that worked on X11
was fundamentally broken on Wayland.
The verification
kolkov verified that no toolkit does this: *"GLFW, Gio, winit,
neurlang-wayland — all use a single connection."* The gogpu stack had gotten
away with it because X11's server-side window IDs masked the architectural
error.
The fix
Bind wl_seat + wl_pointer + wl_keyboard on the C connection and
forward events to Go. gogpu's CSD (client-side decoration) code already did
exactly this for pointer events on decoration subsurfaces — it needed to be
generalized to the main surface. Tracked as BUG-GOGPU-002 (P0).
The lesson for engine authors
This bug is not just a gogpu bug. It is a lesson in what happens when a
cross-platform abstraction assumes platform semantics that do not hold. The
X11/Wayland split in Linux desktop graphics is a minefield, and the
"pure-Go, no-CGO" constraint makes it harder, not easier, because the FFI
boundary between Go and C libraries is where these connection-scope issues
live.
The cgo-GLFW detour and return
Chapter 2 covered this at a high level. Here is the gogpu-specific arc:
064c027 — "renderer: port WebGPU coreinitialization." The first GoGPU commit. One day later...
15b888e — "alternate cgo gl renderer." Thedetour begins. gogpu hits naga swizzle bugs and Wayland input failures.
A cgo-based OpenGL renderer using GLFW for windowing becomes the
working path.
de40302 and ef8f1c0 — swizzle workaroundsland. GoGPU shaders compile. First visuals appear.
d5ff084 — scalar mix() splat workaround forNVIDIA SPIR-V crash. Issue #162 filed.
b2fb6e9 — "Retire gl+sdl (#11)." The OpenGLrenderer, SDL input, and SDL audio are removed. GoGPU becomes the sole
renderer. Oto becomes the canonical audio backend.
gameplay visuals on GoGPU.
889f797 — "drop renderer shims." Game loopcleanup, final shim removal.
The gogpu issue #157 opening body captures the state at the detour's peak:
I first attempted to tackle things using GoGPU as the rendering backend,
but eventually hit enough issues that I sadly switched to cgo GLFW code.
GogpuIssues
The return was driven by naga fixes (swizzle, scalar mix()), the X11 input
fix (v0.22.8), and the decision that pure-Go was worth the remaining pain.
General state of pure-Go graphics in 2026
Where it is strong
CGO_ENABLED=0. No C toolchainrequired. Cross-compilation is trivial. Static binaries. This is the
primary value proposition — ironwail-go is a real 3D game engine with
no C in its runtime.
same WGSL shaders run on Vulkan (Linux), Metal (macOS), and D3D12
(Windows). The browser target (WASM + WebGPU) is a future possibility.
ironwail-go is the largest real-world 3Dengine running on the pure-Go GPU stack. gogpu issue #163 ("Ironwail-go
demo") is the showcase thread. The gogpu maintainers use it as evidence
that the stack can handle a real engine, not just toy examples.
uses ironwail-go bug reports to prioritize naga and platform fixes.
Where it is weak
coverage. Swizzle expressions, scalar-vector mix(), derivatives
(dpdx/dpdy), and textureDimensions all produced invalid SPIR-V at
various points. Each was fixed, but each required a workaround until the
fix landed. Engine authors must be prepared to read SPIR-V disassembly and
file compiler bugs.
most severe example, but the broader issue is that Linux desktop
windowing (X11 vs Wayland, pointer lock, IME, multi-layout) is a large
surface area with many compositors and edge cases. gogpu issue #227
(multiple keyboard layouts) is another example — 75 comments of
discussion about X11 keyboard group handling.
SPIR-V that crashes NVIDIA. This means bugs can be hardware-specific and
invisible until tested on multiple GPUs. The DRI_PRIME=1 workflow is
essential for hybrid-GPU testing.
equivalent to apitrace or RenderDoc that works smoothly with the
pure-Go stack. The project built its own diagnostic tooling: bspdiag
for offline BSP inspection, r_debug_water for per-frame liquid face
telemetry, r_debug_passes for render pass tracing, and host_speeds
for per-phase timing.
newer than WebGPU. The docs/RENDERER_LEARNING_PLAN.md was written
precisely because there was no existing curriculum for learning WebGPU
via a real Go codebase.
Lessons for engine authors
reconstruction. It is more verbose but survives naga parser gaps.
vec3<f32>(scalar) is safer than relying on implicit scalar-to-vector promotion in mix(), clamp(), etc.
crashes on. Test on both if possible.
r_debug_water, bspdiag, host_speeds — these exist because standard graphics debuggers do not
integrate smoothly with the pure-Go stack.
Wayland two-connection bug was not a missing feature; it was a
fundamentally broken design that happened to work on X11.
The pure-Go graphics stack improves because real engines stress-test it.
ironwail-go is that stress test.
Chapter 7: Synthesis — What Was Learned, and Where It Goes
ironwail-go began as an experiment driven by three motives: nostalgia for
school days spent hacking Quake mods, a desire to test modern AI agentic coding
capabilities on a non-trivial codebase, and a technical curiosity to see if a
1996 3D engine could be re-architected into pure, safe Go with a WebGPU renderer
and zero C dependencies. README
Six chapters later, the engine runs. It loads BSP maps, executes QuakeC bytecode,
simulates player and monster physics, streams spatialized audio via Oto, and
renders 3D world geometry, alias models, sprites, particles, and liquids through GoGPU and WebGPU at high frame rates with CGO_ENABLED=0.
This final chapter ties the threads together. It reflects on what the port
validated about engine architecture, what it exposed about Go and WebGPU as a
systems graphics stack, what multi-agent agentic coding teaches about AI-assisted
engineering, and where the project goes next.
What the port validated
1. Quake's architecture is remarkably durable
Thirty years after Carmack, Abrash, and the id Software team wrote original Quake,
its fundamental architectural patterns remain exceptionally well-suited for game
engines:
Enforcing that single-player is simply a local server connected via a loopback
channel ensures that prediction, delta compression, and state synchronization are
never retrofitted — they are structural.
qbj3_stickflip, amassive map with 85,936 raw faces and 22,195 leaves, the PVS lookup instantly
reduces the first rendered frame to just 1,002 visible faces. Parity
cmdsys) as a unified control path for keybindings,console execution, menus, configuration files, and script automation remains
unmatched for engine debuggability.
allowing modding without engine re-compilation.
Moving from C to Go, or OpenGL to WebGPU, does not invalidate these core patterns.
If anything, re-implementing them in a memory-safe language with strong package
boundaries highlights just how clean and decoupled Quake's original high-level
design was.
2. Behavioral parity as a discipline works
The project adopted a strict parity-first discipline: preserve behavioral
parity with canonical C Ironwail/Quake unless a divergence is explicitly intended.
This discipline was enforced not by wishful thinking, but by structural practices:
// Where in C: citations in unit and integration tests, tying Go test assertions directly to function names in ironwail/Quake/*.c.
TestExecuteProgramRunawayLoopLimitConstantMatchesC, documenting the exact
invariant being protected.
mise run parity-compare), running deterministicvisual diffs between C Ironwail reference frames and GoGPU rendered frames.
Synthetic tests in small rooms pass easily; maps like qbj2 and qbj3 test
every boundary condition simultaneously.
Without this discipline, a port of this scale rapidly devolves into "looks roughly
right," where subtle physics bugs, trigger sequence breaks, or rendering glitches
multiply uncontrollably.
3. Pure-Go graphics is viable today
Compiling a full 3D game engine with CGO_ENABLED=0 was considered improbable
only a few years ago. ironwail-go proves that a pure-Go graphics stack —
gogpu/gogpu for windowing and event loops, gogpu/wgpu for low-level WebGPU
primitives, gogpu/naga for WGSL-to-SPIR-V translation, and ebitengine/oto for
audio — can drive a complex, real-time 3D rendering pipeline without a single line
of C code in the runtime path.
4. The educational mandate is achievable
A primary goal of the codebase was to make it self-explanatory and educational
— readable by someone without prior deep graphics or engine development experience.
This mandate manifested in concrete design artifacts:
doc.go files with # Original C lineage maps.docs/internal/*.md.docs/RENDERER_LEARNING_PLAN.md.cmd/bspdiag, an offline inspection CLI allowing developers to inspect BSPlumps, lightmap pages, entity definitions, and liquid settings without writing
scratch scripts.
The result is a codebase that serves as a working textbook for Quake engine
internals and WebGPU graphics programming in Go.
What the port exposed
Re-architecting a 1996 C engine into 2026 Go also exposed significant friction
points and architectural taxes:
1. Garbage collection pressure in hot paths
Quake's C memory model relied on the Hunk: a single contiguous memory block
where geometry, lightmaps, models, and temp buffers were bump-allocated and wiped
all at once on map change.
Go's garbage collector provides memory safety, but allocating per-frame slices or
temporary objects in high-framerate loops (250 FPS) generates significant GC pressure. Profiling under qbj3 revealed hot spots in per-frame rendering allocations and string conversions. Mitigations — such as scratch buffers on Renderer, sync.Pool for dynamic lights, unsafe.Slice for zero-allocation byte conversions, and RLocking shared maps — were required to maintain smooth frame rates.
2. The QCVM dual-storage sync tax
In C, edict_t structs and the QCVM memory space share the exact same memory;
pointer arithmetic connects engine code (ed->v.velocity) and bytecode
(OP_STORE_F).
Because Go forbids pointer arithmetic and requires type safety, ironwail-go
operates with dual storage: typed Go structs (Edict.Vars) for engine physics/networking, and a flat QCVM.Edicts []byte array for VM bytecode. Syncing data back and forth via reflection (syncAllToQCVM / syncAllFromQCVM) at every QuakeC callback introduces an O(numEdicts × numFields) tax. The qbj3 CPU profiles showed that edict synchronization is one of the heaviest CPU consumers in the entire server frame.
While the unified sync fixed fragile selective-sync bugs (like the qbj2 lift trigger failure), the long-term resolution requires completing the migration to direct-VM accessor methods (Edict.Velocity(), Edict.SetVelocity()), deleting EntVars and server_qc_sync.go entirely to achieve C's zero-sync model. QCVM
3. Naga compiler and desktop windowing maturity
Building on a pure-Go WebGPU stack placed ironwail-go on the bleeding edge of the gogpu ecosystem, uncovering early platform gaps:
mix() emitted invalid SPIR-V thatcrashed NVIDIA drivers (issue #162), and writable swizzles failed to parse
(issue #157).
(BUG-GOGPU-002) — where X11's server-side window IDs masked an architecture where
input listeners ran on a different connection than the rendering surface — was a
major platform lesson. Linux X11 input stubs (issue #129) and missing pointer
lock APIs (issues #173, #175) required rapid upstream collaboration.
4. Stress testing via Brutalist Jam maps
The Quake Brutalist Jam map packs (qbj2, qbj3) served as unforgiving stress
tests. qbj2_start surfaced the 256-entry material atlas uniform buffer limit
(causing silent overflows when maps exceed 254 textures) and complex pusher/lift
sync breaks. qbj3_stickflip pushed face counts (85,936 raw faces) and dynamic
lighting to limits that exposed rendering and CPU bottlenecks that clean standard maps (id1/e1m1) never triggered.
Reflection on multi-agent agentic coding
ironwail-go was developed as an agentic coding experiment under the "Senior-Junior" partnership model codified in AGENTS.md — the human engineer acts as architect and reviewer, while AI agents perform code translation, refactoring, and test writing. AGENTS
Crucially, the project was not built by a single AI model. Work was distributed across multiple agents over the course of the port:
+-------------------------------------------------------------------------+
| HUMAN ARCHITECT & REVIEWER |
| (Architecture, TDD red/green, PR review, Parity verification) |
+-------------------------------------------------------------------------+
|
+-------------------+------------+------------+-------------------+
| | | |
v v v v
GitHub Copilot Claude Opus 4.6 GPT-5.4 GLM-5.2 / Gemini
(700+ commits) (Primary Agent) (Primary Agent) (Renderer & Module
Early C->Go & GoGPU Renderer, Deep logic, fix passes, QGo
cgo-GLFW era QCVM Sync Unification Refactoring cleanup)
the early C-to-Go transliteration and the initial cgo/GLFW/OpenGL phase.
including building out the 14-stage GoGPU WebGPU renderer, designing the 48-byte
WorldVertex contract, implementing cluster compute dynamic lighting, and
unifying the QCVM entity synchronization layer.
pkg/qgomodule boundary cleanup.
Lessons learned in agentic engineering
(Red/Green TDD) or empirical verification harnesses, agents easily produce
"AI slop" — code that compiles but subtly breaks runtime contracts, swallows
errors, or introduces superficial symptom patches.
sub-tasks (e.g., "port sv_phys.c walkmove while matching C test signatures")
yields far higher precision than broad, multi-subsystem requests.
Copilot excelled at rapid line-by-line translation; Claude Opus 4.6 and GPT-5.4
excelled at multi-file architectural refactoring and root-cause debugging;
smaller or faster models worked well for localized fix passes.
Wayland input failures or selective QC sync bugs), reverting to a last stable
commit and re-prompting with a clearer plan proved vastly superior to patching a
degraded agent trajectory.
Future directions
While ironwail-go is a fully functional engine today, several concrete
architectural goals remain on the horizon:
1. Browser port (WASM + WebGPU)
Because the canonical renderer is built on WebGPU and the engine is pure Go (CGO_ENABLED=0), porting ironwail-go to the web is a natural next step. Compiling to WebAssembly (GOOS=js GOARCH=wasm) and binding the GoGPU renderer directly to the browser's native navigator.gpu surface will enable a zero-install, full-performance Quake engine playing directly inside modern web browsers.
2. Arena/Region allocators for map lifetimes
To eliminate GC pressure during gameplay, future work will investigate Go-based
arena/region allocators (e.g., Go's arena proposals or custom byte-slice region pools). Allocating map geometry, BSP nodes, textures, and models into a region pool that is discarded in a single operation upon level change will bring the memory model back to the zero-GC-overhead efficiency of Quake's original Hunk.
3. Direct-VM accessors & zero-sync QCVM
Completing steps 3–5 of the QCVM migration plan:
internal/server/ from ent.Vars.* to direct-VM accessor methods (ent.Origin(), ent.SetOrigin()).
EntVars and internal/server/server_qc_sync.go.syncAllToQCVM and syncAllFromQCVM calls from executeQCFunction.This will achieve C Quake's zero-sync architecture, eliminating the reflection overhead and matching native VM performance. QCVM
4. Continued parity closure & CSQC integration
a storage buffer (var<storage, read> materials) to remove the hardcoded
256-texture limit, fully resolving the qbj2 atlas overflow bug. MaterialsDiag
QuakeC (csprogs.dat), bringing full support for custom mod HUDs and client-side
predicted entities.
qbj3_stickflip sign-off: Resolve remaining lighting contrast deltas and z-fighting edge cases to achieve official parity sign-off on the qbj3 stress pack.
Conclusion: Quake as a forever-benchmark
Quake occupies a unique position in software engineering. Like Ray Casting or
Hello World, it has become a timeless benchmark for testing new programming
languages, paradigms, and graphics APIs.
ironwail-go demonstrates that 1996 engine architecture and 2026 Go technology can
meet harmoniously. By replacing manual memory with garbage collection, C headers
with Go packages, immediate-mode OpenGL with WebGPU pipelines, and manual coding with human-guided multi-agent engineering, the project breathes new life into classic software.
Even as features are added and parity gaps close, the codebase's lasting value remains its educational artifact: a clean, documented, memory-safe, pure-Go implementation of one of the most influential game engines ever written.
Chapter 8: The Hardening Days — Parity, Harness, and Developer Tooling
Chapter 7 ended on a roadmap: arena allocators, zero-sync QCVM, parity closure,
a wasm port, and a promise that the engine's "lasting value" was educational.
Three days later, the project's center of gravity shifted. The headline
features were done — the engine ran, rendered, and simulated — and what the
maintainer actually wanted next was a different kind of engineering: *make the
parity measurable, make the codebase teachable, and give the mod authors a
real debugger.*
This chapter is the story of that shift. It covers the modularisation wave
that tamed the big packages, the completion of the zero-sync QCVM story, a
parity audit that found (and fixed) a real physics-ordering race, the six
forward plans that turned the roadmap into a queue, and the first harvest of
executing those plans: docs consolidation, three new parity harnesses, a
standalone QuakeGo test simulator, and a hot-path optimization with
benchmarks to prove it.
8.1 Taming the megastructs: the modularisation wave
The original port was written the way a C engine is written: a handful of big
files per subsystem, namespaced by prefixes (server_physics.go,
renderer_gogpu_world_render.go). That is faithful to C, but Go has a
stronger tool — the package — and the project had spent months accumulating
megastructs: Server was a god object holding physics, networking, edict
lifecycle, and QC bridges; game.Game held every subsystem the engine has.
Over the week of 2026-08-05, a coordinated refactor split these along the
exactly the seam C already drew: SV_Physics became
internal/server/physics (the StepFrame frame loop plus the per-movetype
leaf algorithms Pusher/Walk/Toss/Impact moved out of
server_physics*.go), the wire codecs became internal/server/net, BSP
hull builders became internal/server/collision, the edict pool became
internal/server/edict, and so on — 53 packages across the tree today
(internal/server alone now holds collision, commands, debug, edict,
net, physics, qc, savegame, state, and types) plus pure-helper
subpackages in internal/game (camera, audio, ui, csqc) and
internal/renderer (alias, decal, lightmap, particle, pipeline,
scrap, sky, surface, warpscale, oit, world).
The key design decision was dependency injection without DI containers.
The physics system does not reach into *Server; it is constructed with
narrow interfaces — PhysicsFacade, CollisionWorld, EntityStore,
ServerHandle — that the server satisfies (internal/server/types/physicsfacade.go).
That is what lets the physics leafs be unit-tested in isolation with mocks,
which turned out to be the load-bearing wall for everything in this chapter:
the parity probes, the fuzz harness, and the save/load round-trip all stand on
it. The refactor is also why TestPhysicsStepOnGroundSkipsFreefall and its
siblings moved to internal/server/physics/ with their // Where in C:
citations intact — the migration audit was literally formed by preserving
those test names. AGENTS LearningGuide
8.2 Zero-sync QCVM, finished
Chapter 5 described the QCVM dual-storage problem: C shares one edict_t
array between the engine and QuakeC bytecode, while the Go port started with
an EntVars Go struct mirrored into the VM's byte array by a sync layer —
and the sync layer was the root cause of every trigger/entity regression the
qbj2 maps exposed.
By the time of the modularisation wave, the migration the article's roadmap
called out as "future work" was mostly done: 171 typed accessors
(Edict.Origin(sh), Edict.SetNextThink(sh, v), ...) read and write the
QCVM.Edicts byte array directly, the fragile
syncAllToQCVM/syncAllFromQCVM per-callback machinery is gone, and
syncEdictToQCVM/syncEdictFromQCVM survive only as no-op shims
(internal/server/server_qc_sync.go:11-19) kept to make the remaining
EntVars removal mechanical. executeQCFunction
(internal/server/qc_trace.go:71) now only captures and restores the VM
execution context — self/other/depth — exactly as PR_ExecuteProgram
does in C.
One subtlety worth recording: the docs were ahead of the code in the opposite
direction from usual — docs/QCVM_ENTITY_SYNC.md still described the old
per-callback sync running "at every QC callback," when the code was already
authoritative-accessor. A stale doc that says more machinery exists than
does is uniquely damaging for an educational project: newcomers read the
architecture backwards. The docs-consolidation pass (§8.4) rewrote that doc
to the current truth.
8.3 The parity audit: a real race, found and fixed
With the engine modular and the QCVM story resolved, the maintainer ran a
deep-research parity audit against C Ironwail
(ironwail/Quake/*.c, maintained beside the repo), producing a catalog of
behavioral divergences (labeled D1–D8) spanning physics, network encoding,
and rendering. The headline finding was a genuine physics-ordering bug:
D1 — the pusher think-gate race. In C, SV_Physics_Pusher
(sv_phys.c:618-652) reads thinktime = ent->v.nextthink once, derives
movetime from it, runs SV_PushMove, then gates whether to fire the think
on that same original thinktime:
oldltime = ent->v.ltime;
thinktime = ent->v.nextthink; // read once
movetime = thinktime - oldltime ...
SV_PushMove (ent, movetime); // may call a "blocked" QC callback
if (thinktime > oldltime && thinktime <= ent->v.ltime)
PR_ExecuteProgram (ent->v.think); // gate on the ORIGINAL thinktime
The Go port re-read ent.NextThink after PushMove, then gated on the
new value and the new ltime. If the pusher's blocked (or a touch that
re-armed nextthink inside the window) mutated the field mid-push — exactly
what the qbj2 twin-door investigation had been chasing — Go could fire the
think twice in one frame or skip it, leaving doors "armed but never
scheduled" (state=UP, think=SUB_CalcMoveDone, vel=0, nextthink=0), a
signature that had been documented in
docs/diagnoses/intermittent_anomalies.md without a root cause. Parity
The fix is three lines of discipline — snapshot thinkTime before PushMove
and gate on that snapshot — plus a red/green parity test
(internal/server/physics/pusher_think_gate_parity_test.go) that models a
blocked callback re-arming nextthink inside the movetime window and asserts
the think fires exactly once. The test harness needed a small mock-facade
extension so a runExecute hook could simulate the QC callback; that hook is
now part of the physics test infrastructure. QCVM
The audit also cleared two suspected divergences that turned out to be
already correct — a useful counterpoint to the temptation to "fix" things:
Go's FlyMove shipped MAX_CLIP_PLANES = 5 with bumpcount < 4, matching C's
numbumps = 4 exactly (the "4 vs 5" worry was a misread of the bump count vs
the plane array), and the LERP_FINISH byte
((byte)Q_rint((nextthink - sv.time) * 255)) is provably equal to Go's
byte(delta*255.0 + 0.5) for exactly the deltas that are ever sent
(0 < delta <= 1), pinned by existing encode tests.
8.4 Executing the plans: docs, gates, and the dev kit
The parity audit became six numbered plans (docs/plans/22–27), each with
status, prerequisite, steps, verification, and risks, filed in the repo's
existing docs/plans/ convention. The recommended execution order was
docs → parity fixes → parity gates → QC dev kit → hot-path opt → wasm
walkthrough, and the working session delivered the first four plus the start
of the fifth.
Docs consolidation (plan 26)
The stale-doc problem got a lint, not just a rewrite:
tools/doc_check.sh (mise run doc-check) fails CI when a live doc
references a symbol that no longer exists — matched only in backticks, so
historical prose (diagnoses, the sync-history section) is exempt while a
backticked syncAllToQCVM in a live doc is a hard error. The same script
enforces that every non-archive plan carries a Status line whose value
is in {PLANNED, DONE, COMPLETED, SUPERSEDED, APPROVED}, and that no doc
mentions the forbidden -tags gogpu/-tags opengl build tags (the repo has
none, by design). Twenty-eight completed plans moved to
docs/plans/archive/ with a README.md index recording each outcome and
its merge commit — history preserved, entrance hall decluttered.
Parity gates become tests (plans 23/24)
The intermittent-anomalies investigation had produced probes that passed —
proving individual code paths healthy — but nothing that proved ordering.
Three new deterministic, asset-free tests close that gap:
TestParityNarrativeDoorChainOrderedHops): a recording QC builtin appends to a hop log as door_fire and door_go_up
run; the test asserts the exact ordered sequence [owner, half] rather than
just "both halves moved." A duplicated or reordered firing fails even when
the final door position looks right. This is the "did the whole chain run in
order" binary the probes couldn't give.
(internal/server/physics/stepframe_fuzz_test.go): 300 randomized frames
of pusher velocity, rider groundentity, force_retouch, and solid toggles,
checking five per-frame invariants — no think without nextthink, pusher
ltime monotonic (within one block-restore), PushMoveScratch never
retaining entries across frames, and no FL_ONGROUND with a null
groundentity. A fixed seed keeps it reproducible; failures print the seed.
(TestSaveLoadRoundTripPreservesFrameEvolution): two servers run the same
120-frame scenario; one is saved and restored at frame 60; the post-load
edict field stream must equal the uninterrupted run field-for-field. This
extends the existing static-state round-trip tests to prove the simulation
itself is bit-identical after restore — the property a player actually
depends on.
Each carries its // Where in C: citation (doors.qc, SV_Physics,
SV_Physics_Pusher, SV_Savegame_f) and runs inside
go test ./internal/server/... with zero assets.
The QC dev kit: sim.World and qcmod (plan 25)
The most user-facing deliverable. Writing QuakeC/QuakeGo today means: edit →
compile progs.dat with cmd/qgo → boot the entire engine → play/fight
your way to the code path → iterate. The project's own QuakeGo sources
(pkg/qgo/quakego) already shipped a hidden gem: quake/engine exposes its
builtins as Go functions with an injectable Backend of hooks. That makes a
standalone test simulator possible: no progs.dat, no GPU, no assets.
pkg/qgo/quake/sim (sim.World) is that harness. It owns an edict registry
(entity 0 = worldspawn), a deterministic clock, and the engine.Backend
wiring so builtins route into the world: spawn/remove mutate the registry,
setorigin shifts absmin/absmax by the delta, find scans fields,
sound records into an assertable list, random is a fixed value. A mod test
is then plain Go:
w := sim.New()
door := w.Spawn("func_door")
door.Think = func() {
door.Velocity = quake.MakeVec3(0, 0, 100)
door.NextThink = w.Time + 1.0
engine.Sound(door, 0, "doors/door1.wav", 1, 1)
}
_ = w.Fire(door, nil, door.Think)
// assert velocity, nextthink delta, w.Sounds — no engine boot
cmd/qcmod wraps it: qcmod test <moddir> runs go test on a mod module
with GOWORK=off (the mod resolves quake via its own replace, exactly
like quakego does), and qcmod docs prints the guide. The repo ships an
examplemod fixture proving the loop end-to-end, plus qcmod-test /
qcmod-sim mise tasks. The plan keeps the harder phases for later: an In-VM
runner (qcmod test --vm) against the real internal/qc bytecode VM, a
statement debugger (break/step/watch), and a headless REPL whose JSON
snapshots will feed the browser walkthrough.
Phase A lands a specific developer-experience win: the mod-author loop goes
from "boot the game" to "go test under one second," with the same
engine.Backend semantics the engine itself uses, so tests can't drift from
production behavior the way hand-rolled stubs can. QGoGuide
Hot-path optimization with receipts (plan 27)
The article's earlier chapters noted qbj3-class maps push ~2,000 edicts
through hundreds of thousands of QCVM field accesses per frame. Plan 27's
rule was optimize after parity, and only with numbers: a baseline benchmark
first, then a change gated on both the parity suite and the benchmark.
The first target (O1) was the QCVM field accessors themselves. EFloat and
SetEFloat were correct but paid a per-call price: EdictData re-derived a
slice (re-multiplying the edict index by EdictSize and re-checking the
array bounds every call), and EVector/SetEVector called EFloat three
times, tripling that work. The change splits validation from slice with
edictBaseFor — return the backing array, the byte offset, and the end
offset so the field-range check is one integer compare against a fixed
per-edict window. Identical bounds semantics, identical layout (the 28-byte
edict_t header stays excluded). The receipts:
BenchmarkEdictFieldAccess 48,647 ns/op -> 41,465 ns/op (~15%)
BenchmarkEdictFieldAccessSingle 7,506 ns/op -> 6,153 ns/op (~18%)
both 0 B/op, 0 allocs/op
(AMD Ryzen 9 9900X3D, 2,048 edicts, read-modify-write per field.) The
benchmarks are committed beside the change so the next optimization has a
baseline to beat, and the full server suite — including every `// Where in
C:` parity test — stayed green.
8.5 The shape of the next era
What emerged from the hardening days is a project whose "educational
artifact" claim is now load-bearing in a concrete way: the docs are linted,
the parity is measured by tests that run in milliseconds without assets, the
modding story is a go test away, and the hot paths carry receipts.
Three threads remain, and they are a fair summary of where the maintainer's
energy is headed:
(GOOS=js GOARCH=wasm go build ./cmd/ironwailgo). The walkthrough is
deliberately sequenced last because it wants everything else: an
asset-free synthetic demo mode (the same idea as sim.World, at engine
scale), the parity gates' deterministic transcripts for replay, and the QC
dev kit's debugger for a live QuakeC pane. When the layers finally render
in a browser tab — console, host frame, server, QuakeC, client, renderer —
the engine will double as an interactive textbook.
a darker, lower-contrast frame (~7 mean channel delta). Now that the
behavior gates exist, the visual gate is the last big parity blocker.
sim.World runsQuakeGo functions; the plan's prize is stepping bytecode — breakpoints
on door_fire, watches on self.velocity, statement stepping through the
same internal/qc VM the engine ships.
None of these need a hero rewrite. They are the same incremental discipline
this chapter documented: name the C behavior, write the failing test, make
the change, keep the receipts. Quake's engine architecture was durable enough
to survive the move to Go; the practices around it are proving durable too.
Chapter 9: The Browser Frontier — WASM, WebGPU Validation, and the Interactive Walkthrough
Chapter 8 closed with the browser walkthrough as the capstone plan — the
thing that wanted everything else first. What happened next was not a
clean ascent. It was a three-week collision between Go's WASM runtime,
WebGPU's strict browser validation, and the assumption that "it compiles to
WASM" means "it runs in a browser." The engine did compile. It did not run.
Getting it to run — and then getting it to be useful as an interactive
educational artifact — required solving problems that no amount of parity
testing could have predicted, because they existed only in the gap between
native and web.
This chapter is the story of that gap.
9.1 Booting in the browser: the easy part that wasn't
The WASM build itself was straightforward. `GOOS=js GOARCH=wasm go build
./cmd/ironwailgo produces a binary; wasm_exec.js` (from Go's standard
library) provides the runtime glue. A minimal main_wasm.go entry point
(cmd/ironwailgo/main_wasm.go) replaces the native main: it fetches
pak0.pak over HTTP instead of reading from disk, installs the inspector
bridge, and blocks forever with select{} (returning from main()
terminates the Go WASM runtime). The engine boots, mounts its filesystem,
loads QC bytecode, and reaches the menu state — all without CGO, all in a
browser tab.
That was the easy part. Then came the renderer.
9.2 WebGPU's two faces: native leniency vs. browser strictness
The gogpu library targets both native Vulkan/Metal and browser WebGPU. In
practice, native drivers are forgiving: they accept pipeline layouts with
more bind groups than the spec allows, tolerate missing barriers before
conditional texture sampling, and silently promote depth formats. Browsers
are not. The WebGPU specification is enforced literally by Chrome, Firefox, and
Deno's GPU implementation, and every deviation from the spec is a hard error.
The port hit four categories of browser-only rejection:
Depth format mismatch. The native renderer used depth32float-stencil8,
which requires an optional feature browsers don't expose by default. The fix
was runtime feature detection with fallback to Depth24PlusStencil8
(internal/renderer/world/gogpu/worldstate.go:330-354). This also required
a test (world_depth_format_test.go) asserting the fallback path produces
valid pipelines.
Bind group limit. WebGPU caps pipeline layouts at 4 bind groups. The
native renderer had been using 5. The fix consolidated dynamic lights into
group 0 and merged sky lightmaps into group 2
(internal/renderer/pipeline/world_pipelines.go:20-34,136-139), verified by
world_bind_groups_test.go.
WGSL uniformity violations. WebGPU's shader validation requires
workgroupBarrier() before conditional texture sampling in compute shaders.
Native naga/Vulkan accepted the code without barriers; browsers rejected it.
The fix added explicit barriers in the world compute shaders
(internal/renderer/world/gogpu/shaders.go:677,
compute_shaders.go:41).
External sky sampler. The sky dome shader used a sampler binding that
browsers flagged as incompatible with the texture view dimension. Tracing
the panic through the WASM stack (no source maps, just offset numbers) led
to the external-sky pipeline setup, where the sampler type needed to match
the cubemap view. GogpuIssues
Each fix was invisible on native. Each was a blocker on web. The pattern
was consistent: the browser is the stricter oracle, and the native
renderer had been accumulating spec-violating shortcuts that only the web
target caught.
9.3 The frame loop problem: gogpu's App.Run doesn't work on WASM
The deeper problem was structural. gogpu's App.Run uses an event-wait
loop designed for native windowing: block on events, process, present,
repeat. On WASM, there is no blocking — WaitEvents returns immediately,
creating a hot busy-loop that locks the browser tab and burns CPU. The
engine appeared to boot (console logs flowed), but the page became
unresponsive within seconds.
The fix was to replace App.Run entirely on WASM with a custom
requestAnimationFrame driver (internal/game/wasm_frameloop.go:68-136).
StartWasmRendererFrameLoop schedules one frame per rAF tick, calls the
renderer's frame method, and presents. An idle watchdog counts frames
without GPU output; after 120 consecutive idle frames, it degrades to a
headless 60Hz loop (RunWasmHeadlessLoop, lines 40-54) that keeps the
simulation alive without burning GPU resources. This means the engine can
boot headlessly in environments without WebGPU (CI, Deno tests, older
browsers) and upgrade to GPU rendering when the device becomes available.
A related memory leak emerged from the spinning renderer: the busy-loop
allocated per-frame GPU resources without ever presenting them, exhausting
memory within minutes. The idle watchdog solved this as a side effect — by
stopping the renderer when it isn't producing output, the allocation churn
stops.
9.4 The CPU blit path: BGRA, RGBA, and the cost of putImageData
WebGPU render targets produce BGRA textures. The browser's putImageData
expects RGBA. On native, the swap happens in the presentation layer or is
handled by the compositor. On WASM, there is no compositor — the engine
must read back the GPU texture, swap channels per-pixel, and blit to the
canvas manually.
WasmBlitPresent (internal/renderer/wasm_blit.go:29-89) implements this
path. It reads back the world render target, converts BGRA to RGBA in a
tight loop (lines 63-75), and writes to the canvas via putImageData. The
key optimization is reusing the JS Uint8ClampedArray and ImageData
objects across frames rather than allocating new ones — Go's WASM bridge
allocates on every js.ValueOf call for byte slices, and the GC pressure
from per-frame allocations was measurable.
9.5 The inspector bridge: crossing the Go-JS boundary safely
The walkthrough's value proposition is live inspection of engine state from
JavaScript. This requires a bridge between Go structs and JS objects, and
the boundary is hostile: syscall/js.ValueOf panics on Go structs, typed
maps, and most non-primitive types. Reflection-based serialization fails
silently or loudly depending on the type.
The solution is a JSON round-trip (cmd/ironwailgo/inspector_wasm.go:32-41).
toJSValue marshals any Go value to JSON, then parses it back via
JSON.parse on the JS side. It is not fast, but it is safe — every type
that encoding/json handles crosses the bridge correctly. The inspector
exposes methods on window.ironwailInspector: getState, getStateJSON,
getTimeline, setPaused, getPaused, stepFrames, getGoroutines,
getTelemetryLog, getGpuStatus.
A subtler problem was non-deterministic map key ordering. Go maps iterate
in random order, so the JSON representation of engine state changed on every
call even when nothing had changed. The walkthrough's change-detection cache
keyed on the JSON string, causing every panel to rebuild every frame. The
fix was to sort keys in getStateJSON (lines 53-63) so identical states
produce identical strings, making the cache effective.
9.6 Pause, step, and the playback control protocol
An interactive walkthrough needs frame-level control. The engine's normal
frame loop runs at full speed with no external throttle. Adding pause/step
required a coordination mechanism between the JS UI and the Go frame loop.
internal/game/playback_control.go implements this with atomic gates:
playbackPaused and playbackSteps (lines 15-19).
RunRuntimeFrameUnlessPaused (lines 23-33) checks the pause flag before
each frame; if paused, it consumes one step token (if any) or skips. The JS
side calls setPaused(true) to freeze, stepFrames(1) to advance one
frame, setPaused(false) to resume. This is deliberately simple — no
message queues, no async channels, just atomics that the rAF loop polls.
9.7 UserFS: localStorage as a config directory
On native, the engine reads and writes config files from ~/.ironwail/. On
WASM, there is no home directory. The engine needs somewhere to store cvar
configs, key bindings, and save games.
internal/host/user_fs_wasm.go implements UserFS as an in-memory map
backed by localStorage. Every write persists to localStorage under the
ironwail: key prefix with base64 encoding for binary data (lines 88-96).
Every read checks the in-memory cache first, then falls back to
localStorage. The native counterpart (user_fs_native.go) is a thin OS
passthrough. The interface (user_fs.go:15-21) keeps the engine code
identical across targets.
9.8 The Deno harness: testing WASM without a browser
Browser testing is slow and manual. For automated verification, the project
built a pollable-engine harness (cmd/ironwailgo-harness/main.go) that
compiles to a separate WASM binary with //go:wasmexport functions:
state_poll, input_inject, engine_advance. These expose a shared-memory
struct layout (lines 27-56) that Deno tests can read directly from linear
memory without JSON serialization.
The Deno test suite (web/deno-tests/) includes a WebGPU smoke test
(proving Deno's GPU can create a device, draw, and read back pixels), a
harness integration test (boot, map load, frame advance, input injection),
and a browser-environment polyfill for DOM/canvas APIs. Tests run via
deno test -A --no-check web/deno-tests/ or mise run test-deno.
9.9 The walkthrough UI: seven layers of engine state
The frontend (web/walkthrough/) presents a seven-layer tour of the
engine's architecture: Boot/FS, Console, Host Frame, Server Physics,
QuakeC VM, Client Parse, and Renderer. Each layer shows live state from
the corresponding subsystem, updated via the inspector bridge. The user
can pause at any point, step forward one frame, and watch state propagate
through the layers — seeing, for example, how a keypress becomes a usercmd,
becomes a server physics update, becomes a QCVM callback, becomes a client
entity delta, becomes a rendered frame.
The CSS uses a dark terminal aesthetic. Panels are rebuilt only when their
JSON-keyed cache detects a change (§9.5). Anchor points
(walkthrough/anchors.json) define named camera positions for guided tours.
9.10 What the browser port taught
The browser walkthrough validated a hypothesis from Chapter 7: that WebGPU's
portability would make the Go→WASM transition smoother than an OpenGL port
would have been. The hypothesis was correct in direction but wrong in
magnitude. The API ported cleanly — the same pipeline descriptors, shader
modules, and buffer layouts work on both targets. The runtime environment
did not. Frame loops, filesystem access, config persistence, texture
readback, and the JS-Go bridge were all web-specific problems that no
amount of API compatibility could prevent.
The deeper lesson is about validation strictness as a design tool. The
browser's unforgiving enforcement of the WebGPU spec caught real bugs in
the native renderer — bind group overflows, missing barriers, format
assumptions — that had been silently tolerated by native drivers. The
browser port didn't just add a new target; it improved the native target by
forcing spec compliance. This is the inverse of the usual porting narrative,
where the secondary target is a compromise. Here, the secondary target was
an audit.
Chapter 10: Closing the Parity Gap — Dumpstate, Hash Gates, and Bit-Level Verification
Chapters 8 and 9 brought the engine to a functional milestone: modular
packages, deterministic parity tests, a browser walkthrough, and a working
QC dev kit. But "functional parity" and "provable parity" are different
things. The engine ran qbj3, rendered it, and simulated it — but nobody
could prove the simulation was bit-identical to C Ironwail without
manually comparing screenshots. Screenshots are lossy (compositor gamma,
window decorations, timing-dependent animation frames), expensive (two
engine boots per comparison), and fragile (a desktop theme change invalidates
the baseline).
This chapter is about replacing screenshots with hashes, replacing visual
inspection with stream diffs, and building the infrastructure to make parity
a CI gate rather than a manual ritual.
10.1 H1: Extending the dumpstate schema
The first step was giving both engines something comparable. C Ironwail
already had a -dumpstate flag that emitted per-frame JSON, but it only
captured origin, angles, and model index per visible edict — enough for
rough visual comparison, useless for behavioral verification. Entity-slot
reuse, think-cadence drift, velocity divergence, and groundentity changes
were invisible.
The fix was a coordinated extension on both sides. A patch to
ironwail/Quake/gl_rmain.c (tools/parity_generator/c_patch/dumpstate.patch)
added per-edict fields: modelindex, frame, skin, colormap,
effects, velocity, solid, movetype, flags, groundentity,
ltime, nextthink, enemy, goalentity. Plus per-frame globals:
sv_time, gravity, maxclients, force_retouch. View matrices,
viewleaf, and dynamic lights were added to capture the full render-input
state.
The Go side mirrors this schema exactly in DumpFrameState
(internal/game/dumpstate.go). Both engines now produce structurally
identical JSON for the same demo playback, making field-by-field comparison
possible. The test dumpstate_test.go verifies the Go schema's
serialization stability.
10.2 H3: Render-record hashing — parity without pixels
With full-state dumps available, the next step was eliminating screenshots
entirely. ComputeRenderRecordFromDump (internal/game/render_record.go)
takes a dumpstate frame and produces a deterministic SHA-256 hash of what
the renderer would draw, without drawing anything.
The algorithm quantizes entity origins and angles (×8 precision), sorts
entities by (modelindex, frame, skin, origin, angles) to be invariant to
draw-list traversal order, and hashes the sorted list alongside viewleaf
and vieworg. Two frames with identical render inputs produce identical
hashes regardless of which engine produced them, what order the entities
were visited in, or what GPU was used.
This catches bugs that image diffs miss: entity-slot shuffles (same visual
result, different internal state), ordering-dependent lighting, and
sub-pixel position drift. It also runs in milliseconds, headlessly, with no
GPU — making it viable as a CI gate. The test parity_record_test.go
verifies both determinism (same input → same hash) and sensitivity
(perturbed input → different hash).
10.3 H6: Message-stream recording — bit-level net codec parity
The network protocol is the third parity surface. Server messages
(svc_time, svc_clientdata, svc_sound, svc_entity, ...) are encoded
by MessageBuffer on the Go side and by SZ_Write / MSG_Write* on the
C side. Any encoding divergence — a float truncated differently, a byte
order swapped, a delta threshold off by epsilon — produces a different bit
stream that may or may not cause visible artifacts.
MessageRecorder (internal/server/message_recorder.go) captures every
outgoing server datagram per frame and client. It provides Hash() for
whole-stream SHA-256 fingerprinting and DiffStreams() for byte-level
first-difference detection between two recorded streams. The diff reports
the exact byte offset and surrounding context, making encoding divergences
debuggable without packet captures.
This complements H1/H3 by verifying the wire format, not just game state
or render output. A stream hash match proves the Go net codec is bit-exact
with C for the recorded scenario. A mismatch pinpoints the exact message
type and field that diverged.
10.4 Closing the intermittent anomalies
Chapter 8 documented four stochastic symptoms (texture misalignment,
double-door misfire, AI visibility, sound delay) that couldn't be
reproduced deterministically. The probes written during the hardening days
proved individual code paths healthy but couldn't prove ordering. With
the pusher think-gate fix (§8.3) landed and the parity infrastructure in
place, the investigation was formally closed (commit 24f38594).
Six regression probes now guard the previously-stochastic behaviors:
TestParityDoubleDoorPairAdvancesBothHalves,
TestParityAITracelineReportsClearLOS,
TestParitySoundEmittedSameFrame,
TestParityDoorChainFiresBothHalves,
TestParityAITraceThroughWaterStillVisible, and
TestParitySoundNotDroppedNearWatermark. All pass. The diagnosis document
(docs/diagnoses/intermittent_anomalies.md) was updated to CLOSED status
with the root causes identified and the regression guards cited.
The closure is significant not because the bugs were fixed (they were, in
§8.3), but because the category was eliminated. "Sometimes fails" became
"always tested." The probes run in go test ./internal/server/... with
zero assets, in milliseconds, on every CI run.
10.5 The parity verification stack
The three H-gates form a layered verification strategy:
| Layer | Gate | What it proves | Cost |
|---|---|---|---|
| Game state | H1 dumpstate | Full entity/frame state matches C | Requires C binary + assets |
| Render output | H3 render-record | Deterministic render-input hash | Headless, no GPU, milliseconds |
| Wire format | H6 message-stream | Bit-exact net codec | Headless, no GPU, milliseconds |
| Behavior | Probes (H2/H4/H5) | Ordering, invariants, round-trips | Asset-free, sub-second |
Layers 2-4 run in CI on every commit. Layer 1 runs opt-in behind
PARITY_GENERATOR=1 when the C binary and Quake data are available. The
stack replaces the previous parity workflow (boot both engines, take
screenshots, compare with SSIM) with something faster, more precise, and
automatable.
10.6 Renderer photometric progress: D5 audit
The visual parity gap (qbj3 mean channel delta ~7) got a systematic audit.
The headline finding was palette index 255: C Ironwail treats indices
224-255 as fullbright (unlit), while Go had hardcoded 224-254, excluding
index 255 — a brownish skin color (159,91,83) used extensively in qbj3's
leather/metal textures. The fix was a one-line range change in
BuildMaterialTextureRGBA
(internal/renderer/world/texture.go:152-158).
The parity capture methodology also changed: switching from X11 window grab
to engine readback (PARITY_GO_CAPTURE=engine) eliminated compositor gamma
interference, which had been adding ~2-3 mean delta of noise to every
comparison. With both fixes, median luma ratio improved from ~0.78 to 0.90.
The residual gap is parked pending per-surface GPU instrumentation — the
infrastructure exists (§8.4 O6 diagnostics), but the remaining divergence
appears to be lightmap-related rather than texture-related.
A related fix addressed redundant UV clamping in the world fragment shader
(commit 8f9f8b8e), a D5 residual where the shader was double-clamping
texture coordinates that were already clamped at upload time. Removing the
redundant clamp improved visual fidelity on high-frequency textures.
10.7 Performance receipts: O3-O6
Chapter 8's O1 optimization (QCVM field accessors, ~15-18% improvement) set
a pattern: benchmark first, change second, verify both parity and
performance. Three more optimizations followed:
O3 — Entity-send scratch reuse (`perf(server): reuse entity-send scratch
and skip no-op sort, commit 0939b3ca`). The per-client entity encoding
pass allocated scratch buffers every frame. Pre-allocating and reusing them
eliminated the allocation, and a no-op sort guard skipped the sort when the
entity list hadn't changed.
O4 — Allocation-free QC context capture (`perf(server): make QC
execution-context capture allocation-free, commit 1490447e`). The
executeQCFunction wrapper captured self/other/depth for debugging.
Hoisting the capture struct to a reusable per-server instance eliminated
per-call heap allocation.
O5 — Preallocated QCVM edict storage (`perf(server): preallocate QCVM
edict storage to the full cap, commit 2e4786f8`). Incremental growth of
the edict backing array caused mid-game relocations and risked dangling
pointers cached by SightEntity. Preallocating to MaxEdicts capacity on
first touch eliminates both. Test: TestEnsureQCVMEdictStorageStableCap.
O6 — World upload optimization (commit abd0b30f). Added comprehensive
diagnostics (diagWorldUploadSummary) to verify GPU texture array
dimensions, layer count matching, and material buffer sizing. Actual batch
consolidation remains gated on D5 photometric completion — optimizing
upload order before the render output is stable risks hiding bugs.
Each optimization carries benchmarks committed beside the change, and the
full server suite (including every parity test) stayed green.
Chapter 11: The Compiler Grows Up — QGo Function Values, Global Cells, and In-VM Debugging
Chapter 8 introduced the QC dev kit: sim.World for testing QuakeGo
functions without the engine, and qcmod as the CLI wrapper. The dev kit
worked for simple cases — spawn an entity, fire a think function, assert
field values. But it couldn't handle real QuakeGo programs, because the
compiler had three defects that made function values, cross-package entity
fields, and plain package variables silently broken. Fixing those defects,
and then building the In-VM debugger on top of the corrected compiler,
turned the dev kit from a toy into a tool.
This chapter covers the compiler corrections (plan 28 and adjacent fixes)
and the debugger evolution (plans 25 phases B-D), ending with the headless
REPL that will eventually power the browser walkthrough's QuakeC panel.
11.1 Three compiler defects: function values, global collisions, and field stores
QuakeGo function values (assigning a function to an entity's .think field,
passing functions as parameters) require the compiler to emit references to
entries in the QCVM function table. Three defects prevented this from
working:
D1 — Uninitialized cells. resolveObject(*types.Func) created virtual
VRegs with no backing global cell. When OP_CALL tried to read the function
index from Globals[st.A], it read garbage. Functions assigned to .think
appeared to work (the assignment compiled) but crashed at runtime when the
VM tried to call through the uninitialized slot.
D2 — Global/parameter collision. Free globals started at offset 82,
overlapping the QCVM parameter region (parm1..parm16 at offsets 43-91).
Function-value constants collided with call parameters, meaning a function
assigned to .think could be overwritten by the next PR_ExecuteProgram
call's arguments. This was a silent corruption — the function worked until
something else was called, then it didn't.
D3 — Func-field stores. Assignments like Self.Think = player_stand1
emitted STOREP with float conversion instead of OPStorePFNC, writing the
IEEE 754 representation of the function index (e.g., 0x41F00000) into the
field instead of the raw integer. The VM read back a nonsense function
index.
The fix (cmd/qgo/compiler/globals.go, lowering_helpers.go, codegen.go)
introduced FreeGlobalBase = OFSParmStart + 16*3 (offset 91) as the single
source of truth for global allocation, added a systemParamWindow() bounds
guard rejecting allocations in the 43-91 range, gave function objects real
global cells at lowering time (keyed by pkgpath.Name to avoid
cross-package collisions), and added a post-pass that resolves function
table indices by name and patches them into the global data slots.
lowerFieldStore now emits OPStorePFNC for EvFunction fields.
11.2 Plain package vars and dependency entity fields
Two adjacent defects compounded the function-value problem:
Plain package vars (commit 20f0a5ea). Non-tagged, non-function package
variables in the target package fell through to resolveObject's
virtual-local fallback, making them per-function locals instead of globals.
Cross-function access (e.g., NextMap written by NextLevel, read by
GotoNextMap) read uninitialized memory. The fix: lowerGenDecl now calls
allocGlobalOfs(slotsForType(g.Type)) for plain vars in the target package
and records the offset in l.globalVarOfs[obj]
(cmd/qgo/compiler/lowering_decls.go:86-109).
Dependency entity fields (commit c990111f). checkEntityType +
collectEntityFields only registered entity fields from the target package,
ignoring embedded structs from imported packages. This produced
num_entityfields=0, causing OPAddress pointer out of bounds errors at
runtime. The fix recursively walks embedded structs from imported packages,
registering all entity fields with correct offsets. After: EntityFields=201,
NumFieldDefs=157 (cmd/qgo/compiler/lowering_decls.go:128-211).
11.3 The dead sync stub: removing the last per-callback copy
With the zero-sync QCVM story complete (§8.2), the surviving
SyncEdictFromQCVM stub was a no-op that blindly copied all QCVM edict
bytes back to Go edicts after every QC call. It was harmless but wasteful,
and its existence confused readers who assumed the sync was still active.
Commit f969ae58 removed it. The surviving sync mechanism is
SyncSpawnedEdictsFromQCVM(startEntNum)
(internal/server/server_qc_sync.go:69), which only re-links newly spawned
edicts. The test TestSyncEdictFromQCVM_EmptyModelClearsStaleModelIndex
documents the new explicit-clear contract: when QC clears a model field,
the caller must explicitly clear modelindex too — there is no automatic
full-sync pass anymore.
11.4 In-VM bytecode runner: plan 25 phase B
sim.World (§8.4) runs QuakeGo functions as native Go code. The In-VM
runner (cmd/qcmod/vmrunner.go) runs real bytecode — the same
internal/qc.VM the engine ships — without the full engine. newVMWorld
loads compiled progs.dat bytes, registers builtins, pre-allocates edict
storage (world + 8 slots), and exposes fire(funcIdx, self, other, time)
for invoking QC functions by index.
This is the authoritative counterpart to sim.World: the mode-parity suite
asserts both agree on the same test scenarios. Where sim.World is fast
(no compilation, no VM overhead), the In-VM runner is correct (it
exercises the actual bytecode interpreter, catching compiler bugs that
sim.World would mask).
11.5 Resumable breakpoints: plan 25 phase C
A debugger needs to stop execution without destroying state. The QCVM's
ExecuteProgram was designed as a run-to-completion call: enter function,
execute statements, return. There was no mechanism to pause mid-execution
and resume later.
The fix adds BreakHook func(vm *VM, stmtIdx int) bool to the VM
(internal/qc/vm.go:300-309). The hook is called before each statement;
returning true aborts the execution loop with ErrBreak **without unwinding
the stack**. The VM's statement pointer, local variables, and call stack
remain intact. ExecuteFrom(fidx int) resumes from the current
XStatement using the live stack, skipping EnterFunction and the
statement reset (internal/qc/exec.go:121-127). Zero overhead when the
hook is nil.
The debugger state machine (cmd/qcmod/debugger.go) tracks four modes:
dbgContinue, dbgStepInto, dbgStepOver, dbgStepOut. Breakpoints are
sets of function-entry or (function, statement) pairs. Watches are field
expressions re-evaluated every statement via WatchEval. The hook()
method returns a BreakHook closure that calls statement() and returns
d.paused; nil-safe when no breakpoints or watches are active.
11.6 The headless REPL: plan 25 phase D
runREPL(r, w) (cmd/qcmod/repl.go) implements the qcmod sim command
over the In-VM debugger. Commands: run <fn> [self [other [time]]],
break <fn>, step, cont, watch <n>.<field>, inspect <n>,
globals, functions [prefix], reset, quit. When run hits
ErrBreak, the prompt shows (paused: <message>) qc> and step/cont
resume via ExecuteFrom. inspect dumps key edict fields (origin,
velocity, health, nextthink, etc.) via vm.EFloat.
The REPL shares the same state machine as the future wasm walkthrough
QuakeC panel. The design intent is that the browser walkthrough's
"QuakeC VM" layer will expose the same breakpoint/watch/step interface
through the inspector bridge, letting users debug QuakeC gameplay logic
in a browser tab with the same tooling they'd use from the command line.
11.7 The compiler-devkit feedback loop
The QGo evolution illustrates a development pattern that recurs throughout
the project: the tool and the thing it builds improve each other. The
compiler defects were found by trying to write real QuakeGo programs
(pkg/qgo/quakego). The debugger was built to test the compiler's output.
The REPL was built to make the debugger usable. Each layer exposed bugs in
the layer below it, and fixing those bugs made the next layer possible.
This is the same feedback loop that the browser walkthrough creates for
the engine itself: the walkthrough exposed WebGPU validation bugs that
improved the native renderer (§9.10), and the parity tools exposed
simulation bugs that improved the walkthrough's accuracy. The project's
educational mandate isn't just about the output being readable; it's about
the development process being self-correcting.
Chapter 12: The OIT Endgame — Weighted-Blended Transparency Goes to Production
Chapter 3 described order-independent transparency as an optional renderer
path, and Chapter 4 covered it as Stage 14. In late August 2026, it stopped
being optional. A player looking at water saw a solid slab instead of a
surface you can see through. Submerged stairs, floors, and monsters
vanished. Liquid was only transparent when one liquid face looked through
another liquid face. The bug took days to find, and the root cause was not
in the engine at all.
12.1 The symptom and the suspect list
The renderer's OIT path draws liquid into two extra images, then combines
them. McGuire's weighted-blended formulation multiplies each fragment's
alpha into a reveal image, so that fully covered pixels end up revealing
nothing behind them. When the water came out fully opaque, the reveal image
was coming out as zero coverage everywhere: the shader was reading alpha
equal to one even where the water should be see-through.
The investigation worked through the usual suspects first. Depth state was
correct: both passes shared the world depth texture with depth writes off.
Visibility was correct: the face selection code picked the liquid faces and
the underwater solid faces. The multiple-render-target blend states matched
the C engine, and the OIT math matched C Ironwail sample for sample. Only
one theory survived: the dynamic uniform buffer offset that carries the
per-face alpha value was not reaching the shader.
12.2 Render target dumps at the byte level
The team dumped every attachment of the failing frame: the accumulation
image, the reveal image, the depth image, and the final scene. The reveal
target stored a constant full-brightness value, which is exactly what
(1.0 - 1.0) produces. The fragment shader was receiving alpha = 1.0
from a uniform even though the Go side passed a list of per-face offsets
into a shared uniform buffer.
That combination pointed at one thing: the offsets were ignored, and every
surface read the first few bytes of the buffer. The first surface in the
buffer was the opaque world pass, whose alpha is always one. The water was
not broken. It was reading the wrong uniform block.
12.3 The missing dynamic descriptor
The bug lived in the upstream pure-Go Vulkan backend, gogpu/wgpu. Its
CreateBindGroupLayout saw HasDynamicOffset: true and ignored it,
creating a static VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER descriptor set
instead. Vulkan then silently dropped the dynamic offsets passed to
vkCmdBindDescriptorSets. The GPU drew the water with byte offset zero
and never complained.
This is the worst kind of graphics bug: no validation error, no crash, and
a wrong picture that is still a complete picture. The engine side fixed it
by restructuring the uniforms to 16-byte std140 alignment and by giving
the OIT passes dedicated uniform buffers and static bind groups bound at
offset zero. The renderer no longer depends on dynamic offsets for the
translucent passes that carry per-face values.
12.4 The upstream report and the regression tests
The fix shipped with two documents and one reproducer. The post-mortem,
docs/OIT_WATER_INVESTIGATION.md, walks the full diagnostic chain. The
bug report, docs/GOGPU_VULKAN_DYNAMIC_OFFSET_BUG.md, isolates the HAL
behavior. A standalone reproducer at tools/repro_wgpu_dynamic_offset
drops the whole matter to one compute shader, one descriptor set, and 16
bytes of buffer, so the upstream project can fix the backend. Raster
regression tests now run the OIT accumulation and resolve paths against
golden expectations, so a silent offset regression shows up in a test run
instead of on a player's screen.
Two smaller fixes followed. The translucent liquid alpha binding in the
accumulation pass was aligned with the other per-surface uniforms, and OIT
pipelines got proper cleanup paths on world and subsystem resource
destruction, closing leaks on repeated map loads. Weighted-blended
transparency crossed from experimental to production-ready by the end of
the week.
Chapter 13: The Debugger Arrives — DAP, Source Maps, and the Transpiler
Chapter 11 ended with an in-VM debugger and a headless REPL. The next step
was to make that state machine speak a protocol that real editors
understand. September opened with the Debug Adapter Protocol, or DAP, the
wire protocol used by VS Code, Neovim, and Emacs debuggers.
13.1 The design: one protocol, two targets
The design spec (docs/superpowers/specs/2026-09-01-qcvm-remote-debugging-dap-design.md)
and the implementation plan
(docs/superpowers/plans/2026-09-01-qcvm-remote-debugging-dap.md) fix the
shape: a pure-Go DAP server inside the engine, speaking TCP with the
standard Content-Length framing, and a small Target interface that lets
the same server attach to either a live engine session or the headless
simulator world. There is no CGO, no native extension, and no second
debugger to learn.
13.2 The protocol layer in internal/qc/dap
The package implements the DAP message types and the wire framing, plus a
session state machine that tracks breakpoints, sequence numbers, and
stopped state. A hierarchical variable inspector walks the three levels a
QuakeC developer cares about: the VM locals, the engine globals, and the
edict table with its fields. The same inspector drives the REPL watches
from Chapter 11 and the DAP variable panels.
The interesting part is the execution barrier. When a breakpoint hits, the
simulation thread blocks inside vm.BreakHook on a synchronization point.
While paused, the DAP listener inspects VM memory, call stacks, globals,
and entity state with no race and no mutation. Stepping resumes by setting
the instruction pointer and calling ExecuteFrom, exactly like the REPL.
Breakpoints and watches resolve through the same BreakHook interface the
REPL used, so the engine exposes one debugging core with two front ends.
13.3 Engine integration and the standalone server
The game binary gains two startup flags: -qcdbg <port> starts the DAP
server, and -qcdbg-wait pauses engine startup until a debugger attaches
and finishes configuring. The initial map spawn waits for the attach, which
makes source-level breakpoints in the very first frames reliable. A console
command starts the server from inside a running game, and the DAP manual
(docs/manuals/DAP.md) documents flags, cvars, and every editor setup.
The debugger is also a qcmod command. qcmod dap boots a standalone DAP
server with the simulator world as its target, defaulting to
127.0.0.1:2345. Both targets speak the same protocol, and both attach the
side-car source map that the compiler writes next to progs.dat, so
breakpoint and stack-frame resolution works at QuakeGo source lines rather
than raw bytecode offsets. End-to-end tests cover connect, breakpoint,
step, inspect, and continue against both the real server and the simulator,
and a series of hardening fixes tightened bounds checking, socket
deadlines, and shutdown cleanup.
13.4 The disassembler and the source map
Debugging at source level needs two supporting pieces. The disassembler
(qcmod disasm) prints compiled bytecode as readable opcode mnemonics,
optionally filtered to one function with -func and written to a file with
-o. The source map is the other piece: the compiler emits a .map
side-car beside every progs.dat it builds, mapping statement indexes back
to QuakeGo source lines. The DAP server loads that map at attach time, and
the debugger falls back to bytecode-level debugging when no map exists.
13.5 The transpiler: QuakeC to QuakeGo
The same compiler sprint produced qcmod qc2go, a one-file transpiler from
QuakeC to QuakeGo (internal/qc/transpile). It handles the mechanical
translation: declarations, statements, and the classic QuakeC idioms that
map onto the QuakeGo runtime. Constructs that need human judgment carry
TODO(transpile) markers in the output, because a transpiler can guess but
should not silently decide. The tool exists so the mechanical port
convention from Chapter 5 can extend to arbitrary existing mods, not just
the hand-ported pkg/qgo/quakego.
One more VM fix landed in the same window. The rerelease progs.dat
declares its extension builtins in a different order, so LoadProgs now
remaps extension builtin numbers on load. The original Quake shareware data
and the rerelease data both run through the same VM without a second
code path.
Chapter 14: The Engine Becomes a Platform — gameconfig, the SDK, and qcmod
Chapter 8 introduced qcmod as a developer tool, and Chapter 11 grew its
compiler and simulator. But the road from tool to platform runs through
one architectural change: the engine must be importable as a library.
SPEC-006 (docs/internal/specs/006-engine-sdk.md) defines that surface.
14.1 SPEC-006: the SDK spec
The spec's central claim is that a mod is an ordinary Go module. Its
main builds a config, calls one function, and the engine owns the rest.
Nothing in the mod imports an internal/ package, because Go forbids that
across module boundaries. Instead, the engine exposes a thin public facade
package, sdk, that re-exports the stable parts of the bootstrap.
The supporting refactor replaced hard-coded Quake identity with a config
struct. Twenty-two hard-coded "id1" literals became one BaseGameDir
field. The shareware and registered gate became config fields, skill,
deathmatch, coop, and teamplay defaults became config fields, and
the network handshake identity (the "QUAKE" magic and its version byte)
became config fields too. Menu labels, the game name, and the user config
directory follow the same pattern. Any field left at zero resolves to the
stock Quake default, so a mod only overrides what it cares about.
14.2 engine.Run and the sdk package
The bootstrap entry point is engine.Run(config, opts...), re-exported as
sdk.Run. It boots the same engine the binary uses: filesystem, server,
renderer, audio, and the game loop, and it returns the initialized game.
Options stay small on purpose. sdk.Headless() runs without rendering,
which is how dedicated servers and automated tests work, and
sdk.Args(...) passes a command line in the same format the engine binary
accepts. The package also re-exports the config type, the feature toggles,
and the protocol identity numbers. Nothing else is stable enough yet to
promise to mod authors.
14.3 qcmod init: scaffolding a game
The scaffolder turns the spec into a working project:
qcmod init -kind tc mygame
Four template kinds exist. generic is a minimal standalone game with the
registration gate off. sp adds single-player stubs for spawn and think.
dm starts from a deathmatch-first config with item respawn stubs. tc is
a total conversion: it overrides the menu labels, the game name, and the
base data directory.
The generated directory is a complete Go module. It has a go.mod with
replace directives pointing back at the engine and the Quake module, a
main.go that calls sdk.Run, a gameconfig.go pre-populated from the
directory name, QuakeGo sources under progs/, a game_test.go with
simulation tests, and a Makefile with the build, test, and run targets.
The replace paths are relative, so the scaffold stays portable across
checkouts, and qcmod init resolves the engine location automatically or
from -engine <path> when it cannot.
The first build stays inside the mod directory: go mod tidy, then
make test, make build, and make run. The run target launches the
binary from the parent directory, because the engine mounts the game data
at ./<BaseGameDir> relative to the working directory.
14.4 Packaging: PAK and WAD tooling
qcmod pak creates, extracts, lists, and tests PAK archives. The writer
sorts entries and produces byte-deterministic output, and every name is
validated against the Quake rules: at most 56 bytes, forward slashes only,
and no .. traversal. Archives are named pak0.pak, pak1.pak, and so
on, and the engine mounts them in override order so later packs win.
qcmod wad converts PNG and TGA images into Quake WAD lumps. The auto
type picks QPic for menu art and MipTex for world textures, with explicit
-type and a -palette override for custom color tables. MipTex inputs
must be multiples of 16, the classic Quake constraint, and pixels are
quantised to the palette by nearest color, with alpha below 128 mapping to
the transparent index. Together the two commands cover the whole data
pipeline of a standalone game without touching the C toolchain.
14.5 The platform plumbing
Two smaller changes completed the platform story. Command-line parsing now
reorders arguments so that -flags work no matter where they sit among
+commands and positionals, which matters once mods pass their own flags
through the same entry path. And the engine gained a localisation
subsystem (internal/loc) ported from C Ironwail's KEX localization code,
parsing loc_*.txt files and replacing $key tokens with translated
strings and format placeholders.
The documentation wave kept pace: with the SDK came the mod authoring
guide, the qcmod manual, and the SDK manual in docs/manuals/, the
rebuilt project README, and the plain-English glossary. SPEC-007
(docs/internal/specs/007-postfx.md) drafts the next shelf: a modular
post-processing pipeline with bloom, SSAO, and CRT passes behind r_bloom,
r_ssao, and r_crt cvars, plus a registry that lets mods insert their
own WGSL passes at named points in the frame chain. The engine is becoming
a platform, and this chapter's shape keeps it importable, testable, and
documented.
Chapter 15: The Map Compiler Pipeline — qbsp, vis, and light in Pure Go
The renderer, the physics, the VM, and the debugger were all pure Go by
September. One workflow still pointed outside the repository: building a
level. Quake maps ship as .map text files and become .bsp files
through a reference C toolchain built outside this repository. The September push replaced it with three
Go tools, qbsp, vis, and light, so a complete map ships entirely
through this repository.
The three tools are clean-room ports of the classic algorithms, written
from the formats and from the behavior of the reference tools. The
reference for the pipeline is the C qbsp, vis, and light toolchain by
ericw-tools, and the parity harness runs the Go output against those
binaries when they are present. The result is a pure-Go chain:
map.map -> qbsp -> map.bsp + map.prt -> vis -> map.bsp -> light -> map.bsp + map.lit
15.1 qbsp: from brushes to a world
qbsp is the geometry stage. Its parser accepts both brush syntaxes, the
classic QuakeEd form and the Valve 220 axis-aligned form, and it detects
the style per face. Entities are key-value blocks, and brushes sit inside
them. The compiler merges the solid brushes with classic solidbsp brush
splitting, choosing split planes under a split-policy heuristic, and a
t-junction pass splits crack-prone edges where faces meet coplanar
neighbors.
The output covers the whole format family. BSP29 is the default, -bsp2
emits the extended 32-bit-index form, and -2psb emits the BSP2RMQ
variant. A BRUSHLIST extension section carries the per-model brushes for
tooling, and the engine ignores it. The texture table carries the names
plus placeholder 16 by 16 gray mip data; the game resolves the real images
by name from its data, which is the classic Quake arrangement.
15.2 qbsp: hulls, submodels, and leaks
Collision comes from clip trees built per model and per hull. Hull one
suits the player box, plus or minus 16 units, and hull two suits large
monsters, plus or minus 32 units, with roots in the per-model headnode
slots. Brush-carrying entities compile into inline submodels, each with
its own node tree, its own faces, and its own clip trees, and the entity
record gains a model key like *1 that the engine resolves at run time.
A leak is a path from inside the map to the void. qbsp floods from the
void with a breadth-first search, assigns every reachable leaf a distance
to the outside, and walks the shortest path back to a point entity to
produce a .pts trail the editor can open. In build scripts, -leaktest
turns any leak into a non-zero exit. -margin widens or narrows the empty
ring around the map, and -omitdetail drops every func_detail* brush
from the output entirely.
15.3 vis: portal flow and the PVS
qbsp also writes the .prt portal file: for every boundary between two
leaves, the polygon that separates them. vis consumes that file and
computes the Potentially Visible Set, the per-leaf bitmask the engine uses
to skip hidden geometry and the server uses to skip hidden entities.
The Go vis mirrors the classic portal-flow algorithm, supporting both the
leaf-based portal file form and the cluster form that groups leaves into
shared visibility footprints. The initial visibility pass tests portal
pairs, and a recursive flow pass pushes visibility from leaf to leaf
through clipped portal polygons, tracking the window of sight. The result
is compressed into the engine's exact row format, run-length encoded as
literal bytes and zero-run skips, so the engine's DecompressVis reads it
verbatim. Leaf visibility offsets and the world leaf count are patched
back into the BSP, and the output file is the input file with visibility
attached.
15.4 light: luxels, styles, and shadows
light bakes the lightmaps. It parses the faces and the light entities,
builds a BSP tracer from the tree, and casts a shadow ray to every
sample point. Luxels sit 16 units apart in the face's texture space, and
each one accumulates light / dist^2 scaled by the angle of incidence,
clamped to 255. Style keys from 0 to 31 allocate separate animated
lightmaps per face, and _color keys tint the light. Sky faces are exempt:
they take no lightmap at all, in the classic Quake rule.
Sun and bounce lighting extend the direct pass. -sun reads a sun entity
or the worldspawn sunlight keys and lights every non-sky face from one
direction with shadow casts. -bounce adds clamped single-bounce
radiosity, so each lit surface re-emits light onto its neighbors and walls
with no direct light brighten instead of staying black. The final pass
supports luxel supersampling for soft edges (-extra, at factors of two
and four as in the classic tools) and phong-shaded normals at shared
vertices within a configurable angle, so large flat surfaces smooth
instead of faceting.
Colored light needs a colored store. The BSP lightmap is one byte per
sample, so -lit writes a QLIT side-car file next to the BSP with the
style-zero samples as RGB triplets, and the engine's ApplyLitFile reads
it when present. The tool reads and patches both BSP29 faces and the
larger BSP2 faces, so the compiler choices from section 15.1 flow through
unchanged.
15.5 Verification and the limits
The pipeline is tested the same way the engine is. The loader round-trips
every stage and asserts the structural invariants of the formats. Hermetic
fixtures are embedded in the tests, so the suite runs with no assets and
no network. The parity harness compiles the same maps with the Go tools
and the ericw-tools binaries and compares the portal files, the PVS bytes,
and the light output when an ericw build is available, with bspinfo
reading the Go output as an independent check.
The documented limits are honest. HDR lightmaps and the -lit2 variant,
the lightgrid for entity tinting, per-submodel PVS rows, and full
texture-color bounce are tracked as follow-up work, and per-hull submodel
clip trees currently share one expansion seed. None of them block the
central claim, which is that this repository now stands alone: source
maps, compiled maps, and gameplay logic all start and end in Go.
Consolidated References and Sources
README.md, ironwail-go repository.AGENTS.md, ironwail-go repository.docs/LEARNING_GUIDE.md, ironwail-go repository.docs/COMPARISON.md, ironwail-go repository.docs/PARITY.md, ironwail-go repository.docs/RENDERER_LEARNING_PLAN.md, ironwail-go repository.docs/VERTEX_LAYOUT.md, ironwail-go repository.docs/QUAKE_SPECIFICATION.md, ironwail-go repository.docs/QCVM_ENTITY_SYNC.md, ironwail-go repository.docs/QGO_QUAKEGO_GUIDE.md, ironwail-go repository.docs/internal/host.md, ironwail-go repository.docs/internal/client.md, ironwail-go repository.docs/WALKTHROUGH_SINGLEPLAYER_FORWARD.md, ironwail-go repository.docs/internal/fs.md, ironwail-go repository.docs/internal/bsp.md, ironwail-go repository.docs/internal/qc.md, ironwail-go repository.docs/internal/cmdsys.md, ironwail-go repository.docs/internal/net.md, ironwail-go repository.docs/INPUT_HANDLING.md, ironwail-go repository.docs/internal/async.md, ironwail-go repository.docs/internal/audio.md, ironwail-go repository.docs/internal/engine.md, ironwail-go repository.pkg/types/types.go, ironwail-go repository.docs/BOOT_SEQUENCE.md, ironwail-go repository.docs/internal/renderer.md, ironwail-go repository.docs/diagnoses/qbj2_water.md, ironwail-go repository.docs/diagnoses/qbj2_materials.md, ironwail-go repository.article/gogpu_issues.md (transcript of fetched gogpu/gogpu issues).internal/game/dumpstate.go, ironwail-go repository.internal/game/render_record.go, ironwail-go repository.internal/server/message_recorder.go, ironwail-go repository.internal/game/wasm_frameloop.go, ironwail-go repository.cmd/ironwailgo/inspector_wasm.go, ironwail-go repository.internal/game/playback_control.go, ironwail-go repository.internal/host/user_fs_wasm.go, ironwail-go repository.internal/renderer/wasm_blit.go, ironwail-go repository.cmd/qcmod/repl.go, ironwail-go repository.cmd/qcmod/vmrunner.go, ironwail-go repository.docs/diagnoses/intermittent_anomalies.md, ironwail-go repository.docs/OIT_WATER_INVESTIGATION.md, ironwail-go repository.docs/GOGPU_VULKAN_DYNAMIC_OFFSET_BUG.md, ironwail-go repository.docs/superpowers/specs/2026-09-01-qcvm-remote-debugging-dap-design.md, ironwail-go repository.docs/internal/specs/006-engine-sdk.md, ironwail-go repository.docs/internal/specs/007-postfx.md, ironwail-go repository.docs/manuals/qcmod.md, ironwail-go repository.docs/manuals/sdk.md, ironwail-go repository.docs/manuals/DAP.md, ironwail-go repository.docs/manuals/qbsp.md, ironwail-go repository.docs/manuals/vis.md, ironwail-go repository.docs/manuals/light.md, ironwail-go repository.docs/MAP_COMPILING.md, ironwail-go repository.docs/MOD_AUTHORING.md, ironwail-go repository.docs/GLOSSARY.md, ironwail-go repository.