Graphics, audio, input
The ported game code never calls DirectDraw, Direct3D, OpenGL 1.x, DirectSound, Miles, FMOD or DirectInput again; it calls emulations of them that sit on gasm's 2D present, gasm:gl (OpenGL ES 3.0 with WebGL 2's rules) or gasm:gfx (a WebGPU subset), one pushed audio stream, and raw keyboard, pointer and pad state. The aim is that the layer above — the game, and often the vendor's own driver or middleware ported with it — sees the API it was written for, with the defaults, structure layouts, capabilities and quirks of one plausible machine of the time. This page covers the techniques the ENCORE projects used, and what each cost. For the frame model and backend file, see Building for gasm.
The shape of a platform layer
Two rules recur:
- Split the API-recording layer from the back end. cmr2's
src/render/d3d7.crecords device state with D3D7 defaults and D3D7 structure layouts, GPU-free, so native tests can check it (tests/d3d7_test.c: 27 texture records, 30 batches, 672 triangles for one scene).src/render/gl_dev.cis the GL back end. nfspu has a fake recording THRASH driver for EAGL tests. - Port the original's own layer above the emulation, quirks included. cmr2 ports the game's render-state cache (
rstate.c), including the quirk that the firstRender_AlphaBlend(0)does nothing because the cache starts at 0 while the device state is on. nfspu ports EA's THRASH driver DLL itself. gta reimplements the Miles calls the game makes and ports the game's sound modules on top.
2D games: DirectDraw to a framebuffer
Software-rendered games keep a framebuffer in the original's pixel format and convert only at present, through gasm_video_present(rgba, w, h, stride). The runner scales and letterboxes.
| Project | Original surface | Port |
|---|---|---|
| rf | DirectDraw 8-bit paletted, 3DO cel engine in software | 640×480 8-bit framebuffer through the palette to RGBA; movie frames presented at 320×240 RGB — mixed-size presents are fine on gasm |
| sheep | DirectDraw 640×480 16-bit (rgb565/555) | rgb565 framebuffer; plat_present widens 5/6-bit channels by bit replication |
| gta | SciTech MGL over DirectDraw, DMA's Poly_* rasteriser | 640×480 32 bpp (0x00RRGGBB after converting palettes with the surface's shifts) |
| i76 | v1.0 pure software renderer into an 8-bit DirectDraw surface | 8-bit frame + palette expanded to RGBA in the guest |
Palette details are behaviour. rf's sprite pixels index the DirectDraw LOGPALETTE, with game colours at +10 because slots 0–9 and 246–255 are Windows system colours; its fade is subtractive (c - ((0x10000-b)>>8)), not multiplicative. i76's gamma rows are pow(i/255, 1/g)*255 truncated, so even gamma 1.0 is not identity. gasm_video_set_aspect(4, 3) tells the runner the display aspect for non-square pixel modes.
Scaling is the runner's job: gasm-run --filter sharp|nearest|xbr|fsr|crt and --integer-scale, and the same filters in the browser through gasm-present. rf, sheep and gta offer them with no change to the game.
Direct3D 7 on GLES 3
The device emulation (cmr2)
cmr2's back end (adapted from nfspu's THRASH device) implements the flip chain; a two-stage texture combiner with every D3DTOP up to DOTPRODUCT3 plus bump ops 22/23 and every D3DTA with modifiers; alpha test; linear/exp/exp2 vertex fog from eye depth or range; the pre-transformed XYZRHW path; and D3D7 T&L in the vertex shader — row vectors, normals by inverse-transpose of world-view, point/spot/directional lights with range, attenuation and cone, four material sources, local/infinite viewer specular, generated texture coordinates with texture matrices. Render and texture-stage state form a shader key.
- Pre-transformed vertices are mapped by D3D's viewport rule to the same screen coordinates as transformed ones, in a form linear in clip space so GL clipping stays exact.
- All textures are decoded on the CPU to RGBA8, one mip level. DXT1/DXT5 are decoded with bit-replicated endpoints and truncated 1/3, 2/3 mixes.
- Display modes are emulated by replaying DirectDraw's mode-enumeration callback rules over a fake 64 MB card's mode list (
src/render/display.c). - Not reproduced, and documented: 16-bit surface formats and their banding, mip chains.
Capability bits
Emulate one plausible card and verify each caps bit's struct offset in the disassembly. cmr2 misread 0x6e0c34 as DRAWPRIMITIVES2EX; it is ZBUFFERLESSHSR in dpcTriCaps.dwRasterCaps (dword +0x44 of D3DDEVICEDESC7). Reporting it made the game skip its depth clears and broke the service-area car (90b9f07). Also from gasm's DirectX 7 guide: D3DRECT is x1, y1, x2, y2.
Porting the vendor's driver (nfspu)
nfspu renders only through THRASH, EA's driver ABI, loaded from dx7z.dll. The port ports the driver: src/render/thrash.c is the DLL's batching, software cull, state recording and fog tables; src/render/thrash_dev.c is a small Direct3D 7 device on GLES 3 beneath it (docs/re/thrash.md).
- Pre-transformed TL vertices (FVF 0x1c4, 32 bytes):
x, y + 0.5(D3D7 pixel centres on integer coordinates),w = 1/rhwfor perspective-correct interpolation, z remapped to −1..1, and the viewport as a scissor because pre-transformed vertices are not moved by the viewport. - One uber fixed-function shader: two texture stages with
D3DTOP_*/D3DTA_*ops, specular add, fog (table modes from pixel z, else specular alpha), alpha test. Flat shading is the first vertex's colour, done by spelling triangles out. - Batcher quirks kept: a 253-vertex buffer flushed on primitive change, overflow or state change; depth bias added per vertex and reset each EndScene; points adjusted in place so a redrawn point gains bias again;
clearwindowdoes not flush, so queued triangles land on top of the clear. - A fixed card profile: GeForce 256-class (subtype 0x12), 2 stages, stencil, table and vertex fog, specular, anisotropic and trilinear filtering, no W-buffer, no AA, 32 MB. Pick the card the game's readme targets, so card-dependent branches go the expected way.
- 16-bit modes use RGB565 surfaces, keeping the original's banding; 32-bit modes RGB8. Rows top-first so locks and
glReadPixelsagree. - Readbacks are expensive. Movies lock the back buffer every frame;
glReadPixelseach time dropped below 60 fps and starved audio. The fix tracks surfaces cleared whole or written whole by unlock and serves the lock from a CPU copy (74e6dfa). - Know the runner's readback behaviour. The lens flare reads 4×4 pixels at the sun every 200 ms; gasm-run 0.16 returns pixels from
read_pixelsonly with--screenshot, otherwise zeros, so the flare shows full strength. - Mirrored matrices flip winding. Car model matrices have determinant −1, so the original swaps cull modes around cars.
Direct3D 7/8 on gasm:gfx (ballance)
ballance targets the WebGPU-style module: one WGSL shader with per-frame and per-draw uniforms through dynamic offsets into a ring, a pipeline cache keyed by blend, Z, cull, alpha test, fog and stage count, and D3D7 per-vertex lighting. Two traps: row-vector matrices uploaded as-is read as their transpose in WGSL, so M * v computes v * M; and DX8 blends in gamma space, so do not use -srgb formats. Free GPU objects for destroyed and replaced resources or memory grows per level (353bc4a).
OpenGL 1.x fixed function on GLES 3
bv is a source port whose engine draws with immediate mode, display lists, glPushAttrib and gluSphere. src/port/gl1/gl1.cpp provides real GL 1.x entry points so the vendored code calls glBegin unchanged (README):
- T&L on the CPU per the specification: modelview, 8 lights with Blinn specular, local/infinite viewer, spot, attenuation, colour material,
RESCALE_NORMAL/NORMALIZE; per-vertex fog; projection; texture matrix. The GPU gets clip-space vertices and one shader. The maths is wasm float, so it is identical on every runner. - Batches break only on rasteriser-state changes, never on matrix, light or material changes.
- Display lists record and replay, including nested
glCallListandglCallListswithglListBaseresolved at execution. - An offscreen RGBA8 + D24S8 framebuffer, because the game blends with
GL_DST_ALPHAand the default framebuffer has no alpha;gl1_end_frameblits and forces opaque. - Level 0 of each texture kept on the CPU for
glGetTexImage;gluBuild2DMipmapsandgluSpheretessellation as SGI GLU; an incomplete texture turns texturing off (GL 1.x), not WebGL's black. - Approximations are listed:
GL_CLAMPasCLAMP_TO_EDGE, no borders, points, stipple, texgen or clip planes,GL_EXTENSIONSempty. - Do not link gasm's
gasm_gl.calongside: its GLES names clash with the GL 1.x ones; call thegasm_gl_*imports directly.
oni loads OpenGL 1.1 at run time and hands GL already-projected points from its own software geometry engine. The port reproduces that pipeline on one GLES 3 shader in src/render/gl_dev.c: textured vertex-coloured triangles, alpha test by discard, blending, depth, and linear fog on gl_FragCoord.z*2-1.
GL performance
Original D3D-era code sets the whole state per draw. Natively that was fine; under WebGL it dominated. cmr2's sequence, released as 0.1.1 "the race's stutter fixed":
| Commit | Change | Effect |
|---|---|---|
0a0a3fc | GL state shadow sending only deltas | ~60 calls per draw, 13,400 per frame → ~900; 2× native speed; browser frame skipping fixed |
eff6984 | dirty-range vertex buffer uploads via glBufferSubData | re-uploads only changed vertices |
654615d | triangle queue and car shading upload only written vertices; per-frame textures (mud, movies) via glTexSubImage2D | |
4cd5427 | indices kept on the device; VAOs |
Build the state cache into the GL back end from day one. gasm 0.16.0's own cache in gasm_gl.c records the rules (905e086): fixed tables (a growing table walked on each delete made Godot 15× slower), forget deleted names by generation counter, never cache uniforms looked up with [, never skip an upload, and verify with pixel-identical screenshots, not only hashes. Upload textures as RGBA8 — RGB8, L8 and LA8 stall ANGLE by 10–60 ms.
Audio
One mixer, original semantics
Every project replaces the sound API with one guest-side mixer producing interleaved float frames for gasm_audio_push, and ports the game's sound layer on top with its exact volume and priority arithmetic.
- DirectSound emulations: cmr2's
src/audio/dsound.c(create, duplicate, lock, play looped/once, stop, volume in 1/100 dB, pan, frequency) under the original's 200-sound, 32-voice layer; sheep's 256 voices withDuplicateSoundBuffersharing PCM and volumeftol(-(7000 − 7000·base·vol·group)); oni keepsds_*functions in DirectSound units so volume and pan curves match. - Middleware replicas: gta reproduces the Miles 3.6B software mixer (pan table
T[p] = min(2p, 128), no resampling within 1% of unity, point sampling, output high byte for the 8-bit device format); nfspu ports EA SND 4.1g with its node chains and Catmull-Rom resampler; bv implements only theFSOUND_*calls it uses (src/port/fmod/) and lists the behaviours taken from documentation rather than verified. - Quirks stay: sheep's duplicates start at base volume 0, so every duplicate plays at −70 dB; rf's sample-rate field is never written, so frequency ratios evaluate to 0; bv's
FSOUND_2Dis sticky; gta'sMusic_PlayTrackmakes offsets only even, so a 16-bit stereo track at offset 2 mod 4 plays with channels swapped. - Determinism deviations are named: gta carries the resampling phase across mix buffers (Miles restarted it) so output does not depend on render chunking.
Pull audio for the time that passed
The original's DirectSound thread filled ahead of the play cursor. On gasm the mixer is pulled once per frame. Push the samples for the runner time elapsed since the last frame, not rate / fps, or a window's vsync drift drains or floods the queue for seconds (cmr2 0cf854a, following nfspu):
double now = gasm_time_ms();
static double last_ms = -1;
audio_acc += 0 <= last_ms ? (now - last_ms) * APP_AUDIO_RATE / 1000.0 : APP_AUDIO_RATE / frame_hz;
last_ms = now;
if (audio_acc > 2048) audio_acc = 2048; /* a longer gap is lost */
unsigned n = (unsigned)audio_acc;
audio_acc -= n;
app_audio(audio_buf, n);
gasm_audio_push(audio_buf, n);Headless, gasm_time_ms is frame-derived, so hashes are unchanged. Projects with a fixed frame budget carry the remainder instead: bv computes n = (rate + rem) / 60; rem = (rate + rem) % 60 so 22,050 Hz averages exactly 367.5 frames; rf alternates 705 and 706 frames at 62.5 Hz.
gta goes further: one clock for pacing — the audio rendered (5ebdbb6). Its 70 Hz Miles timer counts rendered audio (one tick = 315 frames at 22,050 Hz), so simulation and sound stay locked and the game core's own timer copy disappears. rf and sheep slave movie video to audio frames rendered so far, which makes movie playback reproducible in headless hashes.
Underruns and runner bugs
The runner resamples, keeps about 60 ms of latency, drops the oldest audio when the guest runs ahead and plays silence on underrun. gasm-run 0.16.0 discarded the samples it was waiting for while rebuffering after an underrun, so audio stayed silent after a long load frame. cmr2 and nfspu pushed silence after a gap as a labelled workaround (f50c634) and removed it when 0.16.1 fixed the runner (3fbc27d). --param audiolog=1 in cmr2 logs peak, mean and RMS every 5 s to check sound headless; GASM_AUDIO_LOG=1 logs the runner's queue.
Music streams and CD audio
- Stream large tracks through positional VFS reads: rf's single 223 MB
SCORE.WAVwith an 18-entry track table of byte ranges relative to thedatachunk; gta's tracks up to 58 MB in 64 KBvfs_read_atreads; cmr2's MS-ADPCM.adpwith its own decoder replacing ACM. - Decode compressed music as a stream from memory: bv's 4.9 MB
Music.oggthrough stb_vorbis would be about 54 MB decoded. - CD audio (i76, planned): disc 2 is mixed mode, track 1 data and tracks 2–17 Red Book audio. Converting the BIN to ISO throws the music away; keep the BIN/CUE. The plan is a
vfs_cue.cexposing tracks as raw 44.1 kHz s16 stereo ranges, falling back to FLAC/Ogg/WAV, then to silence logged once, with the MCI play/loop/aux-volume calls ported on top. gasm:media(0.16.0) lets the runner fetch, decode and play an https audio stream mixed after the game's audio and never hashed.
Input
Raw keyboard as DirectInput scan codes
gasm_input_mode(GASM_INPUT_KEYS_RAW) turns off gasm's keymap and gives the game a 32-byte key bitset keyed by W3C KeyboardEvent.code, plus ordered down/up events. A table maps those to DirectInput DIK scan codes so the original's bindings, rebinding screens and config files work unchanged — physical keys, layout-independent. ballance's DIK_OF table in src/platform_gasm.c was reused verbatim by i76 and inherited by gta. Extended keys follow the game's convention (oni: 0x80 set for extended, Up 0xc8; gta: +0x80 again on release).
Do not lose short presses
- Latch presses between game ticks: bv reports
keysDI | latchedDIuntil the 30 Hz engine reads them, and the same for mouse buttons; sheep useskey_eventsfor ordered presses and reports them to the first poll of a frame. - Count "pressed since last frame" for mouse clicks: nfspu lost clicks within one frame when it read only held state (
c0e6cf0). - Backends must tolerate several polls per frame when state changes consume edges (rf).
Pointer and text
- DirectInput mice are relative: hand the game
cursor - lastso its accumulated position tracks the absolute pointer exactly; map the pointer through the letterbox into game pixels. Wheel: DirectInput is 120 per notch up-positive, gasm +1 per notch down, so-w*120(bv). GASM_INPUT_POINTER_HIDDENwhen the game draws its own cursor;POINTER_LOCKEDfor mouselook (oni, sheep).- Text input gives printable characters plus
\band\n: synthesise Esc and Tab from key events, map\nto\r, keep only the game's charset (bv keeps Latin-1 0xA0–0xFF for Windows-1252).
Gamepads and two players
- Present pads as the original's devices: nfspu shows gasm's pads as DirectInput joysticks (keyboard 0, mouse 1, pads from 2) with bindings in
fe.txt(495e36a); ballance maps pad buttons onto DIK codes (A/Start Enter, B Esc, X Space); bv maps the standard layout onto the DirectInput joystick convention; sheep uses the first connectedgasm_gamepadslot. _Static_assertthat the core'sPAD_*bits equalGASM_BTN_*(gta, sheep).- Two players on one keyboard keep the original's split: rf P1 WASD+HJK+QE, P2 numpad; cmr2 arrows and keypad. rf also maps pads 0/1 to P1/P2.
- Digital directions may need analogue magnitudes: rf's input word needs
0xffmagnitude bytes or vehicles move at 1/256 speed. - Force feedback and rumble are not in gasm; cmr2 and nfspu document them as left out.
Mode switches and Escape
cmr2's input has a menu mode and a race mode; omitting the tiny switch call in the loading state made Esc arrive as the menu's 0x20 rather than the pause's 0x400, so Esc did not pause (4f532dc). Port every state's side effects in order. Across the collection, a short Esc goes to the game and holding Esc for one second quits the runner, which also sidesteps browsers eating Esc in fullscreen.
Window title
GASM_TITLE("Return Fire") in the backend file writes the gasm.title custom section, which runners use as the default window title (gasm spec/gasm.h). gasm_set_title changes it at run time; the capabilities manifest can also carry a name.
Checklist
- Split each graphics API into a GPU-free recording layer with the API's defaults and layouts, and a back end; test the recording layer natively.
- Port the original's own state cache, driver or middleware layer above the emulation, with its quirks.
- 2D games: keep the original pixel format in the framebuffer, convert at present, leave scaling to gasm's filters.
- D3D-era 3D: implement fixed-function T&L, the two-stage combiner, fog and alpha test in one shader keyed by state; map pre-transformed vertices with the D3D pixel-centre rule; viewport as scissor.
- Emulate one card's caps; verify every caps bit's offset in the disassembly.
- Add a GL state shadow, partial buffer and texture uploads, device-side indices and VAOs from the start; upload RGBA8; verify with screenshots.
- Serve locks and readbacks from CPU copies; know what the runner returns for reads headless.
- Put all sound on one mixer pulled per frame for elapsed runner time, capped; carry fractional remainders.
- Stream music through positional reads; keep BIN/CUE for mixed-mode CDs.
- Map raw keys to DIK scan codes; latch presses between ticks; count clicks pressed since last frame.
- Convert absolute pointer to relative deltas and map through the letterbox; handle wheel sign and scale.
- Present pads as the original's devices; static-assert pad bit equality; keep the original's two-player keyboard split.
- Set the window title with
GASM_TITLE.