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).
| Build | Library | Functions imported |
|---|---|---|
WPOD3DFX.EXE | glide.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.EXE | ati3dcif.dll | 6: ATI3DCIF_Init, ContextCreate, ContextSetState, RenderBegin, RenderPrimList, RenderEnd; plus DirectDrawCreate |
WPODS3D.EXE | S3DTKW.dll | 4: 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.
| Build | Track loader | Selector |
|---|---|---|
WINPOD.EXE, WPODATI.EXE, WPODS3D.EXE | FUN_0046ef04 | 0, or 1 when the display mode is 3 or more and DAT_0061ffaf is clear (FUN_0049b6a0 in WINPOD.EXE) |
WPOD3DFX.EXE | FUN_00469584 | always 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.
| Slots | Size | Glide format | Holds (inferred from the loader modes) |
|---|---|---|---|
| 0–4 | 256 × 256 (GR_LOD_256) | GR_TEXFMT_P_8 (5), palette by grTexDownloadTable(GR_TEXTABLE_PALETTE) | 8-bit pages: fonts, menu skin |
| 5–22, 24, 25 | 128 × 128 (GR_LOD_128) | GR_TEXFMT_RGB_565 (10) | car pages (0x8000 bytes) and effects |
| 23 | 256 × 256 | GR_TEXFMT_RGB_565 | one large page (loader mode 1) |
| 26 on | 256 × 256 or 128 × 128 (the track flags below) | GR_TEXFMT_RGB_565 | track 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) | Tracks | Pages | Pages halved |
|---|---|---|---|
0000000111111111 | Alderon, Bank, Burrow, City, Downtown, HQ, Nuke, Plant21, Pompeii | 6 or 7 | none |
0000000111111111 | Galleria, Sewer, Veitland | 8 | page 7 |
1000000011111111 | Canyon | 8 | page 0 |
0000011111111111 | Cocoon, Roc | 5 | none |
0000000011111111 | Test | 1 | none |
| all 0 | Beltane, Special | 5, 6 | none |
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/).
| Call | Sites | How POD uses it |
|---|---|---|
grGlideInit, grSstQueryHardware, grSstSelect | 1, 1, 31 | FUN_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) |
grSstOpen | 1 | FUN_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, grSstIdle | 3, 1 | Busy-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, grSstScreenHeight | 5, 4 | To centre LFB pictures. Elsewhere the game assumes 640 × 480: grClipWindow(0, 0, 640, 480), and 0x280, 0x1e0, 0x500 (stride) stored at 0x63d454–0x63d5e0 |
grSstPassthruMode | 5 | 1 (VGA pass-through) around the videos and the Windows dialogs (FUN_004ab734, FUN_004abc08), 0 back to the Voodoo |
grGlideShutdown | 2 | At exit |
grDepthBufferMode | 3 | 2 = GR_DEPTHBUFFER_WBUFFER after each grSstOpen; 1 = ZBUFFER once at start |
grDepthBufferFunction, grDepthMask | 26, 11 | 1 LESS for the scene, 7 ALWAYS for LFB pictures and the 2D overlay, 6 GEQUAL restored after them |
grBufferClear | 8 | (colour, 0, 0xffff): depth cleared to the far value |
grBufferSwap | 14 | Interval 0 or 1, or the variable 0x677680 (sync to vertical retrace or not) |
grClipWindow | 2 | Always 640 × 480 |
grColorCombine | 15 | (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) |
guColorCombineFunction | 16 | GR_COLORCOMBINE_ 1 CCRGB, 4 DECAL_TEXTURE, 6 TEXTURE_TIMES_ITRGB, 7 TEXTURE_TIMES_ITRGB_DELTA0: the menus and the HUD |
guAlphaSource | 3 | 1 = GR_ALPHASOURCE_ITERATED_ALPHA, after every grSstOpen. grAlphaCombine is not imported, so alpha is always the vertex alpha |
grAlphaControlsITRGBLighting | 1 | 0 |
grConstantColorValue | 15 | 0x7f7f7f, 0xffffff, 0, and per-face colours (0xAARRGGBB) |
grAlphaBlendFunction | 20 | RGB 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, grChromakeyValue | 22, 7 | Enabled for faces with flag bit 0 (0x594220, the face's own flags): key colour 0 (black) |
grFogMode, grFogTable, grFogColorValue | 6, 3, 3 | GR_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 |
grGammaCorrectionValue | 7 | 1.4 (0x4f6a04) in the menus, 1.0 (0x4f6a00) in the race, and a value from the options (FUN_004abc08, at most 2.5) |
grTexMinAddress, grTexCalcMemRequired | 3, 3 | Lay out the slots (above) on TMU 0 |
grTexSource, grTexDownloadMipMap, grTexDownloadTable | 1, 3, 2 | The slot table above: (tmu, start, GR_MIPMAPLEVELMASK_BOTH, &info); palette (0, GR_TEXTABLE_PALETTE, 0x6568c4) |
grTexFilterMode, grTexClampMode, grTexMipMapMode, grTexCombineFunction | 7, 5, 3, 5 | Bilinear both ways; clamp both ways for slots, wrap (0) for some faces; no mipmaps (GR_MIPMAP_DISABLE); GR_TEXTURECOMBINE_DECAL (1) |
grDrawTriangle, grDrawLine | 24, 16 | GrVertex 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, grLfbEnd | 9, 16 | Around every LFB access |
grLfbGetWritePtr | 8 | Buffer 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) |
grLfbWriteMode | 8 | 0 = GR_LFBWRITEMODE_565, 4 = GR_LFBWRITEMODE_888 (0x00RRGGBB words) |
grLfbBypassMode, grLfbConstantAlpha | 9, 1 | Bypass 1 for plain pictures; 0 with constant alpha 0x80 and (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) for the loading screen's fade |
guFbWriteRegion | 1 | FUN_004a6ca0: the loading picture in 16 strips of 640 × 30 pixels, 565, stride 0x500 |
grLfbGetReadPtr | 2 | Buffer 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):
| Call | Use |
|---|---|
ATI3DCIF_Init, ATI3DCIF_ContextCreate | Once, in FUN_0048a6bc; the context handle is only passed back |
ATI3DCIF_ContextSetState | 22 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_RenderEnd | Around 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 isS3DTK_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
TriangleSetcall is Gouraud (RENDERINGTYPE0): 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 kinds4,6,0x40004,0x40006inFUN_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
| Build | The card draws | Upscaled by the wrapper |
|---|---|---|
WPOD3DFX.EXE | Everything: every face, textured, with fog and blending; the menus as LFB pictures | The whole scene at any size (wrappers/glide) |
WPODATI.EXE | Untextured faces only: the cars' shadows in a race (measured) | The finished 640 × 480 frame, scaled (wrappers/ddraw) |
WPODS3D.EXE | Gouraud 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):
| Build | Card library | Calls | DirectDraw |
|---|---|---|---|
WPOD3DFX.EXE | GLIDE2X.DLL (the first edition: glide.dll) | 43 | no |
WPODATI.EXE | ati3dcif.dll | as the first edition | yes |
WPODS3D.EXE | S3DTKW.dll | as the first edition | yes |
WPODD3D.EXE | none: Direct3D through DirectDraw (DirectDrawCreate, DirectDrawEnumerateA) | yes | |
WPODPVR.EXE | PowerVR | ddraw, sgl | none (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):
| Call | Use |
|---|---|
grSstWinOpen | (window [0x69b4f0], resolution, refresh, ARGB, upper left, 2 colour buffers, 1 aux): the game names its window |
grSstWinClose, grSstControl | Close; grSstControl 2, 3, 4 (deactivate, resize, move) when the window changes |
grLfbLock, grLfbUnlock | Writes 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 |
grLfbWriteRegion | One 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) |
guTexCombineFunction | Two 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 asWINPOD.EXE's; FUN_004a63e8callsGetDeviceCaps(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 thejneat0x4a6414becomesnops. 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:
| Build | Kind | Libraries (ours) | Patches | Result |
|---|---|---|---|---|
WINPOD.EXE | software | ddraw | CPU | races |
PODMMX.EXE | software, MMX | ddraw | CPU | races |
PODMAX64.EXE | software, Maxi Sound 64 | ddraw, 94DLL32 (maxi94) | CPU | races, with the game's sound through maxi94 (a whole race heard in the emulated PC; GameFrame PR #18) |
WPOD3DFX.EXE | 3dfx | glide2x | CPU, 16-bit desktop | races, 1280 × 960 |
PODX3DFX.EXE | 3dfx, MMX | glide2x | CPU, 16-bit desktop (0x4a5fc4) | races, 1280 × 960 |
PODFXM64.EXE | 3dfx, Maxi Sound 64 | glide2x, 94DLL32 | CPU, 16-bit desktop (0x4a6354) | races, 1280 × 960 |
WPODATI.EXE | ATI Rage | ddraw, ati3dcif | CPU | races; the cars' light beams can come out as opaque white shapes (below) |
PODATIX.EXE | ATI Rage, MMX | ddraw, ati3dcif | CPU | races; a light beam as an opaque white shape in the 30 s capture |
WPODS3D.EXE | S3 ViRGE | ddraw (5-5-5), S3DTKW | CPU | races |
PODS3DX.EXE | S3 ViRGE, MMX | ddraw (5-5-5), S3DTKW | CPU | races |
WPODPVR.EXE | PowerVR | ddraw, sgl | none (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.EXE | PowerVR, MMX | ddraw, sgl | none | the same (rerun confirmed) |
WPODD3D.EXE | Direct3D | ddraw handing over to Wine's (DDRAW_SYSTEM=1) | CPU | draws 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.EXE | Direct3D, MMX | the same | CPU | the 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_VTCFalpha= (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):
| Step | Function | Calls |
|---|---|---|
| Start | FUN_004a8480 | Reads 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 |
| Device | FUN_0049db00 | Rounds 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]) |
| Viewports | FUN_0049dbe0, FUN_0049dca0, FUN_00456970 | One 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 |
| Scene | FUN_0049d540 | Clears 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 |
| Cameras | FUN_0049a020 | Two, one per player, each in its own list with a named transform: sgl_create_camera(0.7, near, 1/far) |
| Fog | FUN_0049a430 | sgl_set_fog(camera, colour, density) and sgl_set_background_colour per camera, from the track's fog setting |
| Track | FUN_00497710 | One 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 frame | FUN_00486ef0, FUN_00487930 and others | sgl_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 |
| Lights | FUN_00499f90, FUN_004888e0 | One ambient light; point lights moved and recoloured each frame with sgl_set_point_light |
| Light volume | FUN_0049b770 | A 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) |
| Textures | FUN_004997d0 | Each 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 |
| Render | FUN_00489000 | sgl_render(viewport, camera, swap): in split screen the second player's view without swapping, then the first's with it |
| Address callback | 0x4a82f0 | Draws 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:
| Finding | Evidence |
|---|---|
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 camera | observed 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 opaque | observed (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 purpose | observed (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 picture | measured |
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):
| Rule | Where 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 depth | sgl_set_fog, FUN_10006bf0 |
| Projection: the camera rectangle's centre, and its half sizes times the zoom | FUN_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 texture | sgl_set_texture_effect, FUN_1001c170 |
| A light volume is appended to the current list when created; later planes go to it | sgl_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 it | sgl_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'scrates/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.Hof 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 throughwrappers/sglin 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.