Skip to content

3D cards: the 3dfx, ATI and S3 builds ​

Where the code is. The stand-in libraries were written here and moved to GameFrame on 2026-10-24 (wrappers/ in GameFrame, PRs 6 and 7); paths wrappers/... below are GameFrame's. POD keeps only its patch file (run/wrappers/pod-patches.txt) and the run scripts (run/wrappers/stage.sh, run/vm/wrapper-pod.sh).

POD 2.0 ships three builds for 1997's 3D chips, each in a Pentium and an MMX version (mmx.md): 3dfx Voodoo through Glide (WPOD3DFX.EXE, PODX3DFX.EXE), ATI 3D Rage through ATI3DCIF (WPODATI.EXE, PODATIX.EXE) and S3 ViRGE through the S3D Toolkit (WPODS3D.EXE, PODS3DX.EXE). They differ a great deal: only the 3dfx build hands the whole scene and its textures to the card, and only the 3dfx build reads .BL4 tracks. State: traced in the Pentium builds (Ghidra exports in analysis/export/); the MMX builds import the same functions (observed). Reusable code: GameFrame's crates/gpu-1997 (the chips' texture formats and enums).

Imports ​

All three link their card's library at load time; none uses LoadLibrary or GetProcAddress for it (observed, import tables).

BuildLibraryFunctions imported
WPOD3DFX.EXEglide.dll (Glide 2.x; every call below)47: grGlideInit, grSstQueryHardware, grSstSelect, grSstOpen, grTexCalcMemRequired, grTexMinAddress, grTexDownloadMipMap, grTexDownloadTable, grTexSource, grTexFilterMode, grTexClampMode, grTexMipMapMode, grTexCombineFunction, grDrawTriangle, grDrawLine, grChromakeyMode, grFogTable, grLfbBegin, grLfbGetWritePtr, guFbWriteRegion and others; no DDRAW.dll
WPODATI.EXEati3dcif.dll6: ATI3DCIF_Init, ContextCreate, ContextSetState, RenderBegin, RenderPrimList, RenderEnd; plus DirectDrawCreate
WPODS3D.EXES3DTKW.dll4: S3DTK_InitLib, S3DTK_CreateRenderer, S3DTK_DestroyRenderer, S3DTK_LinearToPhysical; plus DirectDrawCreate. The drawing calls go through the function table S3DTK_CreateRenderer fills (DAT_006a2a40)

Which files each build reads ​

The track loader picks the extension from a selector (observed: the jump table at 0x46eef0 in WINPOD.EXE, 0x469570 in WPOD3DFX.EXE): 0 = .BL3, 2 = .BL4, 1 = .BL5, 4 = .BL6.

BuildTrack loaderSelector
WINPOD.EXE, WPODATI.EXE, WPODS3D.EXEFUN_0046ef040, or 1 when the display mode is 3 or more and DAT_0061ffaf is clear (FUN_0049b6a0 in WINPOD.EXE)
WPOD3DFX.EXEFUN_00469584always 2 (mov edx, 2 at 0x49f7d0)

So the 3dfx build is the only one that reads .BL4 (observed). No .BL5 or .BL6 file is on the disc (measured): selector 1 would fail to open its file, so that display mode is not reached in practice (inferred).

3dfx Glide (WPOD3DFX.EXE) ​

FUN_0049f3a0 starts Glide: grGlideInit, grSstQueryHardware (error "Voodoo" if none or if the board type is not Voodoo Graphics), grSstSelect(0), then 640 × 480. It sets the number of texture units used, DAT_0067767c, to 1 whatever the board has (observed, mov ah, 1 at 0x49f422): the two-unit code paths in FUN_004a422c are never taken.

Texture slots ​

Textures live in a table of 32-byte slots at 0x655f60 (observed): +0 texture unit, +4 start address in texture memory, +8 even/odd mask (3, both), then a Glide GrTexInfo (+0xc smallest LOD, +0x10 largest LOD, +0x14 aspect ratio, +0x18 format, +0x1c data pointer). FUN_004a4130 fills slots 0 to 25 and adds up their sizes with grTexCalcMemRequired; FUN_004a422c fills one slot per track page from slot 26 on (0x6562a0). FUN_004a4378 selects a slot with grTexSource.

SlotsSizeGlide formatHolds (inferred from the loader modes)
0–4256 × 256 (GR_LOD_256)GR_TEXFMT_P_8 (5), palette by grTexDownloadTable(GR_TEXTABLE_PALETTE)8-bit pages: fonts, menu skin
5–22, 24, 25128 × 128 (GR_LOD_128)GR_TEXFMT_RGB_565 (10)car pages (0x8000 bytes) and effects
23256 × 256GR_TEXFMT_RGB_565one large page (loader mode 1)
26 on256 × 256 or 128 × 128 (the track flags below)GR_TEXFMT_RGB_565track pages (0x20000 bytes)

All use aspect 1:1 (GR_ASPECT_1x1, 3) and one mip level. The texture-block reader FUN_004a4414 takes a mode in bl (0 track, 1, 2, 5, 6 and 7 cars and effects, 8 kept in system memory only, 9 and 10 two fixed slots) and calls grTexDownloadMipMap on every board for each page. For an 8-bit page with a palette (mode 7) it turns the 768-byte palette into 256 0x00RRGGBB words at 0x6568c4 and downloads it with grTexDownloadTable(0, 2, …).

The 16-bit pages are handed to the card exactly as stored in the file: RGB565, little-endian, 256 or 128 texels per row (observed: the data pointer goes straight to GrTexInfo.data).

The 16 track flags in .BL4 ​

The sector reader FUN_00468f5c (WINPOD.EXE: FUN_0046e910) reads 16 u32 after the project name when its selector is 2, into DAT_005758f0[16] (observed, loop at 0x468f79). FUN_004a422c uses flag i as both LOD values of track page i: 0 gives GR_LOD_256, 1 gives GR_LOD_128. FUN_004a4414 (mode 0) halves a page whose flag is 1 in place before uploading it, keeping every other texel of every other row (0x4a49e2). So each flag says "load this track page at half size", 0 = 256 × 256, 1 = 128 × 128: a way to fit a track into the Voodoo's 2 MB of texture memory (observed for the mechanism; the purpose is inferred). A second copy of the read loop at 0x468f30 has no caller.

The flags in the 18 .BL4 files (measured with decoders/track.py, 2026-10-23):

Flags (page 0 first)TracksPagesPages halved
0000000111111111Alderon, Bank, Burrow, City, Downtown, HQ, Nuke, Plant21, Pompeii6 or 7none
0000000111111111Galleria, Sewer, Veitland8page 7
1000000011111111Canyon8page 0
0000011111111111Cocoon, Roc5none
0000000011111111Test1none
all 0Beltane, Special5, 6none

Flags past the last page are never used. Every track keeps at most 7 pages at full size, so the track's textures never need more than 7 × 128 KB + 32 KB of texture memory.

The extra word per face in .BL4 ​

The face reader FUN_004aa718 (WINPOD.EXE: FUN_0046ad54) reads the same fields as the software build, plus one u32 just before the face's flags word, at 0x4aab80 (observed). It reads it only while DAT_00676d3a is 1, which the track loader sets around the sector read, so car models in .BV4 do not have it. The low byte goes to the face record at +0x1b (records of 0x24 bytes); faces without it get 0.

FUN_004a54c4 counts the faces of each value (at most 100: "Groupements" error at 0x65), builds one 0x34-byte record per value at DAT_00676d34 from the faces' vertices, and the strings beside it name them "Mirror_Faces" (observed). The renderers FUN_004962b4, FUN_004982dc and FUN_00498b40 test +0x1b before drawing a face. So the word is the face's mirror group: 0 for an ordinary face, n for a face of reflecting plane n (inferred from the names and the plane records; how the reflection is drawn is not traced). No other build has these strings.

Every Glide call, and how the game uses it ​

The import table names glide.dll (lower case), not glide2x.dll, with the 47 functions under their __stdcall names, _grGlideInit@0 to _guFbWriteRegion@24 (observed, import table at 0x6f0904). That is the early Glide 2 interface: grLfbBegin, grLfbEnd, grLfbGetWritePtr, grLfbGetReadPtr, grLfbWriteMode and grLfbBypassMode instead of the later grLfbLock/grLfbUnlock, and grSstOpen instead of grSstWinOpen. So the game takes no window handle: Glide draws full screen. Every call goes through a jump stub in the run 0x4e38fe–0x4e3a96. The arguments below are the constants pushed before the stubs (observed, a scan of all 360 call sites with capstone, 2026-10-23); names are from the Glide 2.x SDK's glide.h. A replacement library has to export these names exactly (wrappers/glide/).

CallSitesHow POD uses it
grGlideInit, grSstQueryHardware, grSstSelect1, 1, 31FUN_0049f3a0: the GrHwConfiguration at 0x6775e8 must have num_sst > 0 and every board's type (+4, records of 0x24 bytes) 0 = GR_SSTTYPE_VOODOO, or the game stops with "Voodoo". Every state call is wrapped in a loop over the boards (DAT_0067767e + 1), each with grSstSelect(i)
grSstOpen1FUN_004a9f20: (res, refresh, GR_COLORFORMAT_ARGB, GR_ORIGIN_UPPER_LEFT, GR_SMOOTHING_ENABLE, 2 buffers). Called again whenever the resolution changes, after waiting for grSstStatus to show an empty FIFO and grSstIdle. Resolutions: 7 = GR_RESOLUTION_640x480 (menus, FUN_004aa068), 3 = 512x384, and a variable one for the race (FUN_004a9fdc)
grSstStatus, grSstIdle3, 1Busy-waits until bits 12–27 (memory FIFO free) read 0xffff, or until enough room is free before a large LFB write (0x5460, 0x79e0 entries)
grSstScreenWidth, grSstScreenHeight5, 4To centre LFB pictures. Elsewhere the game assumes 640 × 480: grClipWindow(0, 0, 640, 480), and 0x280, 0x1e0, 0x500 (stride) stored at 0x63d454–0x63d5e0
grSstPassthruMode51 (VGA pass-through) around the videos and the Windows dialogs (FUN_004ab734, FUN_004abc08), 0 back to the Voodoo
grGlideShutdown2At exit
grDepthBufferMode32 = GR_DEPTHBUFFER_WBUFFER after each grSstOpen; 1 = ZBUFFER once at start
grDepthBufferFunction, grDepthMask26, 111 LESS for the scene, 7 ALWAYS for LFB pictures and the 2D overlay, 6 GEQUAL restored after them
grBufferClear8(colour, 0, 0xffff): depth cleared to the far value
grBufferSwap14Interval 0 or 1, or the variable 0x677680 (sync to vertical retrace or not)
grClipWindow2Always 640 × 480
grColorCombine15(SCALE_OTHER_ADD_LOCAL 4, LOCAL_ALPHA 3, ITERATED, TEXTURE or CONSTANT, FALSE) for lit textured and flat faces; (LOCAL 1, ONE 8, ITERATED, TEXTURE, FALSE) for the loading picture (FUN_004a6ca0); (SCALE_OTHER 3, LOCAL_ALPHA, ITERATED, CONSTANT) at 0x490261. So a lit face's colour is the texel scaled by the vertex alpha plus the vertex colour: the vertex alpha carries the light (inferred)
guColorCombineFunction16GR_COLORCOMBINE_ 1 CCRGB, 4 DECAL_TEXTURE, 6 TEXTURE_TIMES_ITRGB, 7 TEXTURE_TIMES_ITRGB_DELTA0: the menus and the HUD
guAlphaSource31 = GR_ALPHASOURCE_ITERATED_ALPHA, after every grSstOpen. grAlphaCombine is not imported, so alpha is always the vertex alpha
grAlphaControlsITRGBLighting10
grConstantColorValue150x7f7f7f, 0xffffff, 0, and per-face colours (0xAARRGGBB)
grAlphaBlendFunction20RGB factors (ONE, ZERO) opaque, (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) translucent, (ONE, ONE) and (SRC_ALPHA, ONE) additive, (ONE_MINUS_SRC_ALPHA, x); alpha factors always (ZERO, ZERO)
grChromakeyMode, grChromakeyValue22, 7Enabled for faces with flag bit 0 (0x594220, the face's own flags): key colour 0 (black)
grFogMode, grFogTable, grFogColorValue6, 3, 3GR_FOG_WITH_TABLE (2) with a 64-entry table built by FUN_0049bdd0 and FUN_004a9ec0 (GR_FOG_TABLE_SIZE); 0 off. Fog colour 0 or a track colour
grGammaCorrectionValue71.4 (0x4f6a04) in the menus, 1.0 (0x4f6a00) in the race, and a value from the options (FUN_004abc08, at most 2.5)
grTexMinAddress, grTexCalcMemRequired3, 3Lay out the slots (above) on TMU 0
grTexSource, grTexDownloadMipMap, grTexDownloadTable1, 3, 2The slot table above: (tmu, start, GR_MIPMAPLEVELMASK_BOTH, &info); palette (0, GR_TEXTABLE_PALETTE, 0x6568c4)
grTexFilterMode, grTexClampMode, grTexMipMapMode, grTexCombineFunction7, 5, 3, 5Bilinear both ways; clamp both ways for slots, wrap (0) for some faces; no mipmaps (GR_MIPMAP_DISABLE); GR_TEXTURECOMBINE_DECAL (1)
grDrawTriangle, grDrawLine24, 16GrVertex records of 60 bytes (0x3c, observed: 0x597cb0, 0x597cec, 0x597d28 are one apart): x, y, z, r, g, b, ooz, a, oow, then sow, tow, oow for two TMUs. Screen x, y in pixels with no snapping offset; colours 0–255; ooz 65535 for 2D (0x477fff00); sow/tow 0–256 across a texture. Lines are drawn by FUN_0048d4c4 and FUN_00495398 (what they draw is not traced)
grLfbBegin, grLfbEnd9, 16Around every LFB access
grLfbGetWritePtr8Buffer 1 (back). The game writes with a fixed stride of 1024 pixels: y << 11 bytes in 16-bit mode, y << 12 in 32-bit mode (observed, 0x46594a, 0x465af4, FUN_00492a9c)
grLfbWriteMode80 = GR_LFBWRITEMODE_565, 4 = GR_LFBWRITEMODE_888 (0x00RRGGBB words)
grLfbBypassMode, grLfbConstantAlpha9, 1Bypass 1 for plain pictures; 0 with constant alpha 0x80 and (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) for the loading screen's fade
guFbWriteRegion1FUN_004a6ca0: the loading picture in 16 strips of 640 × 30 pixels, 565, stride 0x500
grLfbGetReadPtr2Buffer 3 (depth) in FUN_0048f37c: five depth samples around the sun's screen position must all read 0xffff (nothing drawn there) for the lens flare to show. Buffer 0 (front) in FUN_0049aa6c: the screenshot key writes the front buffer to a file

So the whole scene goes through one texture unit, the W buffer, the general colour combiner with iterated alpha, table fog, chroma key and alpha blending; 2D pictures go through the LFB in 565 or 888, and two features read the LFB back (observed). How the race resolution is chosen (DAT_00651f3c, values 4, 7 and 8) is open.

ATI 3D Rage (WPODATI.EXE) ​

The ATI build uses the chip only for faces without a texture (observed). Its face dispatcher FUN_004899d0 sends face kinds 0x10 and 0x20 (untextured; flat and shaded, inferred) to FUN_00488ac0, which draws them with ATI3DCIF_RenderPrimList, when DAT_004f72f8 is set; textured faces (0x40, 0x60) go to the same software span routines as WINPOD.EXE (FUN_0049dab0, FUN_004dfe60, FUN_004e097d). It never registers a texture (ATI3DCIF_TextureReg is not imported) and never sets the texture states.

ATI3DCIF_ContextSetState is called with these states (observed, the pushed constants; names from ATI's SDK, below): 2 C3D_ERS_PRIM_TYPE (1 triangles, 2 quads), 4 C3D_ERS_SHADE_MODE, 8 C3D_ERS_TMAP_PERSP_COR, 11 C3D_ERS_ALPHA_SRC, 12 C3D_ERS_ALPHA_DST, 13 C3D_ERS_SURF_DRAW_PTR and 14 C3D_ERS_SURF_DRAW_PITCH (the DirectDraw back buffer the software renderer also draws into).

The six calls, and what the chip is given ​

All six are __stdcall, imported by their decorated names (_ATI3DCIF_Init@0 … _ATI3DCIF_RenderPrimList@8), and the game treats 0 (C3D_EC_OK) as success (observed, import table and call sites). Measured in a race on Beltane through wrappers/ati3dcif (2026-10-24):

CallUse
ATI3DCIF_Init, ATI3DCIF_ContextCreateOnce, in FUN_0048a6bc; the context handle is only passed back
ATI3DCIF_ContextSetState22 sites. At start: TMAP_PERSP_COR 0, ALPHA_SRC 4 (source alpha), ALPHA_DST 5 (one minus source alpha), SURF_DRAW_PITCH 640 (pixels). Per primitive: PRIM_TYPE 1 or 2, SHADE_MODE 2 or 3, SURF_DRAW_PTR the locked back buffer. SURF_DRAW_PF is never set: the chip draws into the game's 16-bit surface
ATI3DCIF_RenderBegin, ATI3DCIF_RenderEndAround each batch; RenderEnd also on every error path
ATI3DCIF_RenderPrimList(list of vertex pointers, count): 3 for a triangle, 4 for a quad (measured)

The vertices are C3D_VTCF records (x, y, z, s, t, w, r, g, b, a floats; measured: the dumps show x and y in pixels, colours 0–255 and the rest 0). In the race the primitives the chip draws are the cars' shadows: black quads with alpha 80 under each car (measured). Every textured face of the track and the cars, the HUD and the menus come from the software renderer.

S3 ViRGE (WPODS3D.EXE) ​

The S3 build imports four functions from S3DTKW.dll, all __cdecl with undecorated names (observed: add esp after each call): S3DTK_InitLib (0 is success; -1, -2 and -12 are the errors it reports, 0x4a648a), S3DTK_CreateRenderer (1 is success, 0x4a64d1; it fills the function table at 0x6a2a40), S3DTK_DestroyRenderer and S3DTK_LinearToPhysical (on page addresses, the offset added after, 0x4a5fd6). Everything else goes through the table, whose functions take the table itself first and return 1 on success ("S3DTK Error : SetState" otherwise). POD uses three slots (observed, a scan of the indirect calls): 0 S3DTK_SetState (17 sites), 1 S3DTK_GetState (once, for S3DTK_VIDEOMEMORYADDRESS, FUN_004a5f8c) and 2 S3DTK_TriangleSet (6 sites, in FUN_004a4b28 and FUN_004a4d6c: (table, vertex pointers, count, 2), a fan).

The states it sets (observed): 20 S3DTK_FOGCOLOR (0xffffffff, fog off), 19 S3DTK_ALPHABLENDING (0, or 0xc0000 source alpha, or 0x80000 texture alpha), 10 S3DTK_RENDERINGTYPE (0 Gouraud, 0x08000000 lit texture, 0x10000000 unlit texture: no perspective-corrected kinds), 15 S3DTK_TEXTUREACTIVE, 13 S3DTK_ZBUFFERENABLE (0) and 4 S3DTK_DRAWSURFACE, which alternates between two S3DTK_SURFACE records (0x6929e4, 0x692a08) for the two buffers of the flip chain.

Measured through wrappers/s3dtkw (2026-10-24):

  • A draw surface is {sfOffset, sfWidth, sfHeight, sfFormat} = {linear address, 640, 480, 4}. 4 is S3DTK_VIDEORGB15: the ViRGE's 3D engine draws 8, 15 or 24-bit pixels, not 5-6-5, so the build's 16-bit surfaces are 5-5-5 (inferred from the format).
  • A vertex is 32 bytes: X, Y, Z, W floats, then B, G, R, A bytes, then D, U, V floats.
  • In a race on Beltane every TriangleSet call is Gouraud (RENDERINGTYPE 0): 15,000 calls, none textured, and no texture surface is ever created. What the chip draws there is the cars' shadows (black fans, alpha 128). The textured faces come from the software renderer, as in the ATI build. When the texture types would be used (face kinds 4, 6, 0x40004, 0x40006 in FUN_004a4b28) is open, and so is the texture surface format.

What a 3D card adds in each build, and what that means for the wrapper ​

BuildThe card drawsUpscaled by the wrapper
WPOD3DFX.EXEEverything: every face, textured, with fog and blending; the menus as LFB picturesThe whole scene at any size (wrappers/glide)
WPODATI.EXEUntextured faces only: the cars' shadows in a race (measured)The finished 640 × 480 frame, scaled (wrappers/ddraw)
WPODS3D.EXEGouraud faces only in a race: the shadows (measured)The same

So drawing the ATI and S3 chips' work at a higher resolution (replacing DirectDraw and compositing the game's software pixels with the chip's triangles) would sharpen only the shadows: the textured scene is the game's software renderer at 640 × 480 in both builds. The 3dfx build is the one whose scene can be drawn at a higher resolution.

DirectDraw in the ATI, S3 and software builds ​

The ATI and S3 builds set up the display the same way (FUN_0048aecc in WPODATI.EXE; WINPOD.EXE makes the same interface calls, observed): DirectDrawCreate, QueryInterface for IDirectDraw2, SetCooperativeLevel(0x51) (exclusive, full screen), EnumDisplayModes, GetCaps, GetAvailableVidMem, SetDisplayMode(640, 480, 16), then a primary surface with one back buffer (DDSD_CAPS | DDSD_BACKBUFFERCOUNT, caps 0x218). Each frame is drawn into the locked back buffer and flipped (measured through wrappers/ddraw). Under Wine on a display that cannot change mode, SetDisplayMode fails ("lpDD::SetDisplayMode->DDERR_UNSUPPORTED"). run/wine/patch_winpod.py worked around it by patching the EXE into windowed mode; wrappers/ddraw grants the mode instead and shows the frame scaled in the game's window.

POD 2.8's card builds (the reference version) ​

POD (target/pod, docs/versions.md) has the same card builds, rebuilt, and two new ones. Imports from POD's exports in analysis/export/pod/ (observed, 2026-10-24):

BuildCard libraryCallsDirectDraw
WPOD3DFX.EXEGLIDE2X.DLL (the first edition: glide.dll)43no
WPODATI.EXEati3dcif.dllas the first editionyes
WPODS3D.EXES3DTKW.dllas the first editionyes
WPODD3D.EXEnone: Direct3D through DirectDraw (DirectDrawCreate, DirectDrawEnumerateA)yes
WPODPVR.EXEPowerVRddraw, sglnone (its CPUID only tests MMX)

POD's 3dfx build uses the later Glide 2 interface ​

Against the first edition's 47 calls, POD's WPOD3DFX.EXE drops grSstOpen, grSstStatus, grSstIdle, grSstPassthruMode, the old LFB calls (grLfbBegin, grLfbEnd, grLfbGetWritePtr, grLfbGetReadPtr, grLfbWriteMode, grLfbBypassMode, grLfbConstantAlpha) and guFbWriteRegion, and adds (observed, the pushed constants at each call site):

CallUse
grSstWinOpen(window [0x69b4f0], resolution, refresh, ARGB, upper left, 2 colour buffers, 1 aux): the game names its window
grSstWinClose, grSstControlClose; grSstControl 2, 3, 4 (deactivate, resize, move) when the window changes
grLfbLock, grLfbUnlockWrites to the back buffer (write only, buffer 1, 565, upper left, no pipeline); a read of buffer 2 (the depth buffer, GR_LFBWRITEMODE_ANY) for the lens flare, as before
grLfbWriteRegionOne site, 0x4a63b8: a 565 picture to the back buffer
grTexCombine(0, 9, 5, 9, 5, 0, 0) and (0, 9, 0xd, 9, 0xd, 0, 0): with one TMU and no mipmaps the first is the texel (decal)
guTexCombineFunctionTwo sites, GR_TEXTURECOMBINE_DECAL

grSstScreenWidth and grSstScreenHeight are called 19 and 18 times, against 5 and 4: POD lays out more by the screen's size (observed; which resolutions its options offer is not checked). wrappers/glide builds as glide2x.dll and exports both sets of names (55).

POD's 3dfx build through the wrapper (measured, 2026-10-24) ​

Run by the race-loop session in the emulated PC with GameFrame's glide2x.dll (wrappers/glide) at 2×: the English menus, the car screen and a race on Beltane at 1280 × 960, captured out to race clock 5'11" (derived/wrapper/pod/3dfx/). Two in-memory patches (run/wrappers/pod-patches.txt, read by the wrappers' shim):

  • the CPU check at 0x486aa1 (kind 4 instead of 0xff): the run passing it confirms this is the same check as WINPOD.EXE's;
  • FUN_004a63e8 calls GetDeviceCaps(BITSPIXEL) and stops with "POD cannot run in Win95 8/24/32 bits graphic mode" unless the desktop is 16 bits; Wine reports 32 for any X display, so the jne at 0x4a6414 becomes nops. The first edition's 3dfx build has no such check.

Speed: about 1.5 frames a second (Mesa's software OpenGL on an emulated CPU), the swap taking 650–800 ms a frame. At that rate the scripted player's short throttle presses are lost and its car stays at 0 MPH; the other seven race.

All 14 POD builds through the wrappers (measured, 2026-10-24) ​

POD ships 14 Windows builds. The X builds are the MMX twins of the card builds, as PODMMX.EXE is of WINPOD.EXE (observed: 506 to 787 MMX instructions each, none in the W builds; the same card libraries). The two 64 builds are for Guillemot's Maxi Sound 64 sound card: they import six calls by ordinal (mpuMessage, mpuMessageAc, UpLoad, GetMemMapAddress, waveSetAuxVolume, waveSetMainVolume) from 94DLL32.dll ("Dream thunk for 9407 products", a thunk to the card's 16-bit driver; MAXI64/ on the disc), all __stdcall (observed, the DLL's own ret counts). GameFrame's wrappers/maxi94 stands in for it.

Each build ran unchanged in the emulated PC (the race-loop session's run/vm/allbuilds.sh: start, menus, a single race on Beltane, about 60 s of racing; derived/vm/builds/<BUILD>/), with GameFrame's wrappers and the in-memory patches of run/wrappers/pod-patches.txt:

BuildKindLibraries (ours)PatchesResult
WINPOD.EXEsoftwareddrawCPUraces
PODMMX.EXEsoftware, MMXddrawCPUraces
PODMAX64.EXEsoftware, Maxi Sound 64ddraw, 94DLL32 (maxi94)CPUraces, with the game's sound through maxi94 (a whole race heard in the emulated PC; GameFrame PR #18)
WPOD3DFX.EXE3dfxglide2xCPU, 16-bit desktopraces, 1280 × 960
PODX3DFX.EXE3dfx, MMXglide2xCPU, 16-bit desktop (0x4a5fc4)races, 1280 × 960
PODFXM64.EXE3dfx, Maxi Sound 64glide2x, 94DLL32CPU, 16-bit desktop (0x4a6354)races, 1280 × 960
WPODATI.EXEATI Rageddraw, ati3dcifCPUraces; the cars' light beams can come out as opaque white shapes (below)
PODATIX.EXEATI Rage, MMXddraw, ati3dcifCPUraces; a light beam as an opaque white shape in the 30 s capture
WPODS3D.EXES3 ViRGEddraw (5-5-5), S3DTKWCPUraces
PODS3DX.EXES3 ViRGE, MMXddraw (5-5-5), S3DTKWCPUraces
WPODPVR.EXEPowerVRddraw, sglnone (its CPUID only tests MMX)races; first much darker than the other builds: the stand-in's spot-light falloff (cos^32, against the driver's (1 - min((1 - cos)·log2 32, 1))², out to 37°), fixed in GameFrame's wrappers/sgl (PR #13); rerun pending
PODPVRX.EXEPowerVR, MMXddraw, sglnonethe same (rerun confirmed)
WPODD3D.EXEDirect3Dddraw handing over to Wine's (DDRAW_SYSTEM=1)CPUdraws through Wine's Direct3D at 640 × 480: with the intro off (PlayIntroAtStart=0) it starts into the game's demo race and draws it; then, and at start-up with the intro on, a fault reading address 8 at 0x47c5e5 (FUN_0047c3b0) in the AVI player when a video plays, with no Indeo codec (derived/vm/builds-d3d-nointro/)
PODD3DX.EXEDirect3D, MMXthe sameCPUthe same, the fault at 0x48e648

The software and card builds' frames are shown at 1280 × 960: the 3dfx and PowerVR builds drawn at that size, the others scaled (above). The Direct3D builds draw at the game's own 640 × 480 through Wine's Direct3D; nothing upscales them yet. What is left for them is the Indeo codec, not the wrapper. Each run took 401 to 474 s for about 60 s of racing: every renderer runs in software on an emulated CPU. In every build the player's car stays at 0 MPH: at these frame rates the run's throttle presses fall between frames (a limit of the input, not of the builds).

The ATI builds' light beams. The game hands the same faces (the cars' light beams, the face flag 0x80000000) to each card with a different alpha (observed, POD):

  • the S3 build: colour 0xff, alpha = table[((light & 0x1fff) >> 5) | 0xdc00], a 0–255 level taken to about 86 % (row 220 of the game's multiply table), FUN_0049fd24;
  • the ATI build: C3D_VTCF alpha = (float)(light >> 4) with no mask (FUN_004837cc, FUN_004839f4, FUN_00483bbc), so up to about 717, twice the S3 scale; the colours are 256.

GameFrame's ati3dcif clamps each vertex's colour to 0–255 before blending, so where light >> 4 passes 255 the beam is opaque white. What the Rage did with values above 255 is unknown: if it clamped as well, the original ATI build drew these white shapes too; if not, only a capture of the real card (or a video of it) can show what it did. Drawing the beams as the S3 build does would be a guess presented as a fix, so it is left open.

POD's CPU check ​

Every POD card build but the PowerVR one has the check that quits on an unknown CPU (QEMU's AMD family 6): mov dword [kind], 0xff beside its CPUID (observed): WPOD3DFX.EXE0x486aa1, WPODD3D.EXE 0x4aca71, WPODATI.EXE 0x4a4f31, WPODS3D.EXE 0x4a6521 (WINPOD.EXE 0x4a3e01, as run/wine/patch_winpod.py has it). The wrapper DLLs patch it in memory when they load (wrappers/shim/src/patch.rs), checked by the build's PE timestamp and the original bytes. That these addresses are the same check as WINPOD.EXE's is inferred from the identical instruction beside CPUID; for the 3dfx build the run confirms it. The PowerVR build's CPUID only tests for MMX (0x4a6860).

The PowerVR build ​

WPODPVR.EXE (PE timestamp 0x3457b104) draws through VideoLogic's SGL 3.1 scene graph, not triangles. It imports 46 calls from SGL.DLL (observed, import table). Their declarations are in sgl.h and sglwin32.h of the PowerVR driver package on POD's own disc (POWERVR/310C3.ZIP, March 1997; extracted to derived/powervr-sdk/, not tracked): all __cdecl, sgl_bool an int, vectors and colours pointers to three floats. The game's calls agree with those declarations at every site read (observed, decomp.c). The stand-in is wrappers/sgl (wrappers/sgl/README.md).

How the build uses SGL, from its own code (observed unless marked):

StepFunctionCalls
StartFUN_004a8480Reads SW_XRegionSize and SW_YRegionSize from sglhw.ini (default 0), then sgl_use_address_mode(0x4a82f0, &0x802a68): the game's address callback and where SGL puts its status word
DeviceFUN_0049db00Rounds the resolution down to whole regions and divides by the region size: without sglhw.ini that divides by zero. sgl_create_screen_device(0, w, h, 16-bit, double buffer), then sgl_get_device for the size granted. The PowerVR installer writes 32 × 32 (MIDAS.INF, [Midas4_UpdateInis])
ViewportsFUN_0049dbe0, FUN_0049dca0, FUN_00456970One over the device with a square camera rectangle (800 × 600: camera rectangle 100 to 700 across); in split screen a top and a bottom one subtracted from it (sgl_subtract_viewport); sgl_set_viewport moves the camera rectangle for the screen shake
SceneFUN_0049d540Clears the root list (sgl_modify_list(-1, 1)), then builds in order: cameras, the sky, fog and background, the track, the ambient light, point lights, the cars, with sgl_new_translucent between groups
CamerasFUN_0049a020Two, one per player, each in its own list with a named transform: sgl_create_camera(0.7, near, 1/far)
FogFUN_0049a430sgl_set_fog(camera, colour, density) and sgl_set_background_colour per camera, from the track's fog setting
TrackFUN_00497710One list per face (sgl_create_list(1, 1, 0), kept state), each a mesh of 4 vertices with normals and texture coordinates (bytes / 256) and two triangles (0, 1, 2) and (3, 0, 2); material set right after sgl_create_mesh: specular (shininess 1000), diffuse, ambient, all four texture effects, the texture map. Each list starts ignored
Each frameFUN_00486ef0, FUN_00487930 and otherssgl_set_ignore_list (52 sites) shows the visible pieces; sgl_modify_transform with sgl_rotate and sgl_translate (47 and 17 sites) moves cars, wheels and cameras
LightsFUN_00499f90, FUN_004888e0One ambient light; point lights moved and recoloured each frame with sgl_set_point_light
Light volumeFUN_0049b770A point light, sgl_create_light_volume(0, 1, light), then a mesh and 40 planes around an axis (sgl_add_plane) and a closing sgl_add_simple_plane: a cone of light (inferred: a headlight beam)
TexturesFUN_004997d0Each page converted to SGL's intermediate map (RGBA, alpha 0 opaque, 255 clear; black made clear on translucent maps), sgl_preprocess_texture into a second map, sgl_create_texture from it: map types 0 (16-bit) and 3 (translucent), sizes 128 and 256
RenderFUN_00489000sgl_render(viewport, camera, swap): in split screen the second player's view without swapping, then the first's with it
Address callback0x4a82f0Draws the dashboard over the last render and shows it (FUN_004a7ff0: a Blt to the window, or a Flip full screen), locks the back buffer (FUN_004a73c0) and returns its memory, stride and bits per pixel (CALLBACK_ADDRESS_PARAMS, packed: pointer, 16-bit stride, 8-bit depth)

The stand-in calls the address callback at the first sgl_render of a frame, fills the viewport in the memory it returns with a marker colour, and draws the scene with OpenGL; the picture the game then shows comes to it through wrappers/ddraw and goes on top wherever it is not the marker (inferred from the callback's order: what the chip did with the memory is not documented in the headers). The stand-in passes its own scene test (wrappers/sgl/test/sgltest.c, measured under Wine on macOS). In the emulated PC POD's PowerVR build runs through it from the menus (the track map and the car on the car screen are SGL scenes) to a race on Beltane at 1280 × 960 (measured, 2026-10-24, run/vm/pvr-race.sh; about 3 pictures a second in software OpenGL).

What the race showed, each now in the stand-in:

FindingEvidence
Cars are lit almost only by their point light: opponents' ambient material is 0.01 (DAT_004c88c0), their lamp a spot at (0, 0, 0) along +z, concentration 32, colour 0.6 (FUN_0049ba60, recoloured by FUN_004888e0). Placed by the transforms above it the lamp sat at the world's origin and every car was black; SGL positions lights with sgl_position_light, which POD never imports, so an unplaced light shines from the cameraobserved in the scene dump (SGL_DUMP) and the code; the camera rule inferred
The sky (FUN_0049a560) sets glow 1, all four texture effects off and fog off. In PowerVR's driver a term whose effect is off is added after the texture, so the sky is white where its translucent texture is opaqueobserved (trace; driver FUN_1001c170)
The cars' headlight beams are light volumes in lists ignored by day (41 planes each: a cone and its end, FUN_0049b770). POD asks for the volume, makes a transform and an empty mesh, then adds the planes: they go to the volume, which stays where it was made (PowerVR's driver appends it to the list at once, FUN_10025270 in SGLMID4.dll)observed (scene dump, driver)
Shadows are 32 × 32-textured meshes at opacity 0.5 with a material of 0.25; ghost cars beyond the field at 0.5; the player's car at 0.7 in one camera mode (FUN_0049ba60)observed
Faces of a car model that are not textured get an all-black material on purposeobserved (FUN_0049ba60)
A race sends pictures faster than software OpenGL draws them: the stand-in's unbounded queue filled the guest's 4 GB and stopped the VM; it now keeps only the latest frame and picturemeasured

PowerVR's own SGL driver is on POD's disc too (POWERVR/310C3.ZIP: SGLMID4.dll for the PCX2, SGLMID3.dll for the PCX1; exported with Ghidra to analysis/export/powervr/, not tracked). Its code settles what the headers leave open (observed):

RuleWhere in SGLMID4.dll
Fog: colour as bytes; density turned into a rate -log2(1 - density), split into mantissa and exponent for the chip, and folded into its depth scale with the camera's foreground distance; a surface keeps (1 - density) of its colour per unit of depthsgl_set_fog, FUN_10006bf0
Projection: the camera rectangle's centre, and its half sizes times the zoomFUN_10006bf0 (constants 0.5)
Shading: ambient, diffuse, specular and glow each go into the sum the texture multiplies when its texture effect is on (material bits 0x20, 0x40, 0x80, 0x100), else into the sum added after the texturesgl_set_texture_effect, FUN_1001c170
A light volume is appended to the current list when created; later planes go to itsgl_create_light_volume, FUN_10025270
Point lights: no fall-off with distance; diffuse is N·L only where positive. A spot light's concentration becomes k = log2(concentration), and it lights (1 - min((1 - cos) k, 1))^2 off its axis; POD's lamps (32) reach 37° (the stand-in's earlier cos^32 left the PowerVR builds' cars near black)FUN_10035360, FUN_1001c170
A light's position is a separate list entry made by sgl_position_light (linked from the light at +0x38); POD never calls itsgl_create_point_light, sgl_position_light

Open: where the driver puts a light no sgl_position_light placed (the stand-in uses the camera, which POD's car lamps need), and how strongly a light volume lights what it meets.

Sources for the APIs ​

  • Glide 2.x enum values (GR_TEXFMT_*, GR_LOD_*, GR_ASPECT_*, GR_TEXTABLE_*): 3dfx Glide 2.x SDK headers (glide.h), as used by GameFrame's crates/gpu-1997.
  • ATI3DCIF: 3D RAGE Windows 95 Programmer's Guide, ATI SDK-C02700 rev. 1.30, 1997 (VGA Museum copy): C3D_ERSID (p. 5-34), C3D_ETEXFMT, C3D_EPIXFMT.
  • S3D Toolkit: S3DTK.H of the S3D ToolKit SDK 2.6 (kytulendu/S3DTK): S3DTK_STATEKEY, S3DTK_FUNCTIONLIST, texture and video formats.

Open questions ​

  • How the ATI Rage drew vertex colours above 255 (the ATI builds' light beams, above): a capture of the real card or a period video would settle it.

  • POD's PowerVR build (WPODPVR.EXE): races through wrappers/sgl in the emulated PC ("The PowerVR build" above); the fog formula and the light volumes' strength are guesses until compared with a PowerVR card's picture.

  • The S3 build's texture surface format, and when its textured face kinds are used.

  • How the 3dfx build draws the mirror planes.

  • The selector 1 (.BL5) mode in the software builds, and whether any release shipped .BL5.

  • What the extra slots 9 and 10 (0x656260, 0x656280) hold.

Prose CC0 1.0, code Apache 2.0. POD: Planet of Death is © Ubi Soft; none of its files are on this site.