Hitman: Codename 47 demo — original Glide 3 renderer

The registered hitman_glide_demo runs the installer-derived launch-game/Hitman.Exe with the original Render3DFX.dll. The WebGL route reaches the original restaurant mission; the current regression is described below.

The renderer originally stopped with “Unable to run Glide on this card.” Static inspection showed a real capability requirement: Render3DFX.dll queries GR_NUM_TMU at preferred addresses 0x0fb978f3–0x0fb978f8, rejects a single TMU at 0x0fb978fd, and later selects GR_CLIP_COORDS at 0x0fb97b6a. The implementation now supplies independent texture units and native homogeneous clipping. Remote native ABI and software pixel tests passed, and the original renderer progressed beyond that capability failure. This does not establish complete game compatibility.

Original EAX dependency

The subsequent CLI run trapped as UNIMPLEMENTED API: KERNEL32.#00005 with return address 0x00db82f8. That fallback label misidentifies the missing DLL. The actual caller is Sound.dll, instruction call [0x00dd2008] at runtime 0x00db82f2. Relocating the IAT slot to Sound's preferred base 0x0ff30000 yields 0x0ff50008, whose original PE import is EAX.DLL ordinal 5. The bundled EAX.dll exports ordinal 5 as EAXDirectSoundCreate, preferred entry point 0x10001000; its three arguments match the captured call.

EAX.dll was present in launch-game but absent from the app's initial DLL seeds. The app now seeds it alongside Globals.dll and xmlparse.dll so the runtime-loaded sound module binds to original guest PE code. The wrapper imports only KERNEL32, USER32, ADVAPI32, OLE32 and DSOUND. This change neither aliases EAX to DirectSound nor fabricates an implementation. The earlier XML seed similarly supplies the original parser needed by EngineData/Locale.

Reproduce with the registered app after a normal build:

node test/run.js --app=hitman_glide_demo --quiet-api --trace-api --max-batches=300
node tools/disasm_fn.js test/binaries/candidates/hitman-codename-47-demo/launch-game/EAX.dll 10001000 20

The pre-EAX-fix log is retained on the reserved test machine under ~/nfs-movsd/build/hitman-two-tmu-startup.log. It demonstrates progression past Glide initialization, then an audio import failure; it contains no successful gameplay screenshot. Later browser runs pass this dependency.

After EAX loads: incomplete DirectMusic interface

The next run (build/hitman-eax-startup.log on the same machine) passes the EAX import and traps with the decoder's 0xCA002E20 marker at guest EIP 0x20. This is a bad COM method target, not an unsupported CPU opcode.

Sound's preferred 0x0ff315c3 calls CoCreateInstance for CLSID_DirectMusic {636B9F10-0C7D-11D1-95B2-0020AFDC7421} and requests IID_IDirectMusic {6536115A-7B2D-11D2-BA18-0000F875AC12}. Creation succeeds. At 0x0ff315f9 (runtime 0x00ebd5f9) it calls vtable slot 3, IDirectMusic::EnumPort(this, 0, caps), with caps.dwSize = 0x134. The earlier availability-probe implementation allocated only the three IUnknown slots (src/09a7-handlers-dispatch.wat, init_com_vtable(3076,3)). Reading slot 3 therefore obtains unrelated data (0x20) instead of a method.

A complete interface must give every method a valid target and return honest availability/results. Port enumeration ends on any nonzero HRESULT in this caller; after enumeration Sound immediately creates another DirectMusic object, so fixing the unsafe vtable alone does not establish music or game support. Do not hide this with a CPU workaround or fabricate playable music.

node tools/disasm_fn.js test/binaries/candidates/hitman-codename-47-demo/launch-game/Sound.dll ff31590 60
node tools/disasm_fn.js test/binaries/candidates/hitman-codename-47-demo/launch-game/Sound.dll ff315e4 70

The safety fix now allocates all twelve IDirectMusic slots while preserving its existing QueryInterface/AddRef/Release behavior. EnumPort returns S_FALSE for every index because no DirectMusic port backend exists, without reading or modifying the descriptor; null output returns E_POINTER. These semantics follow the upstream Wine implementation, and the slot order follows its DirectMusic header. The remaining eight methods have explicit named fail-fast dispatch entries. They no longer jump through allocator data. This provides safe interface coverage, not a music synthesizer. Remote startup passed enumeration and reached the original renderer's first draws. The existing test/test-directmusic-query-interface.js now verifies slot IDs, real vtable-derived EnumPort dispatch, untouched descriptors, stack cleanup, and explicit failures beyond enumeration. All 59 focused checks passed on the remote box, together with the full build gates.

Clip lowering must ignore unused texture fields

After the DirectMusic fix, startup reached a fullscreen quad in Render3DFX (preferred call 0x0fb9aa83). Its vertex layout still enables ST/Q for both TMUs, but the local-iterated framebuffer combiner consumes no textures. The game sets W/Q and color while leaving texture stack fields undefined. Native projection initially multiplied those unused values by texture scale and tripped its finite-range check (glide3_project_product). The native setup now fetches texture attributes according to framebuffer/TMU consumption, leaving unused enabled fields unread. Regressions distinguish inactive NaN or huge values from consumed invalid values. The later NaN S/T policy below is the sole compatibility exception. Remote native ABI and software/WebGL pixel regressions pass. The browser then reached texture uploads.

Aligned texture-memory limit

The subsequent browser trap at runtime 0x00ec78b2 (Render3DFX loaded at 0x00eb2000) maps to preferred 0x0fba58b2, the thunk for grTexDownloadMipMap. A source audit found grTexMaxAddress incorrectly reported 4194303, the last byte of 4 MiB RAM. The SDK defines this as the largest valid texture start address, aligned for a download. The pinned Glide2 CVG implementation returns total_mem - 8; the H3 Glide3 variant subtracts its own alignment. The canonical backend advertises eight-byte alignment, so both API versions now report 4194296. ABI tests upload a 1×1 texture at that address on both Glide3 TMUs and verify the Glide2 limit. The subsequent original browser run reached the rendered main menu. A software CLI diagnostic diverged earlier through a NULL call and does not establish browser behavior (build/hitman-texture-address.log).

Menu input uses a recentered cursor

BOX3 build/hitman-input-observed/002-input.json records normal DOM movement, Win32 mouse dispatch and original guest SetCursorPos calls. The CSS-to-guest transform reaches the intended menu point (483,468) correctly. The original renderer then recenters to (400,300) after each mouse message. Feeding the next absolute browser point directly makes it integrate displacement from the center repeatedly: (135,0) adds (-265,-300), then (162,0) produces cumulative (-503,-600). By the attempted click, the engine accumulated (-1105,-729), so its own drawn cursor was far from the browser click target. Button-down and button-up also reused (483,468) after recentering and each added another (83,168). This is not a Glide viewport or CSS scaling failure.

The original Render3DFX.dll mouse method starts at preferred 0x0fbb3090. Its recenter call is at 0x0fbb330d; engine mouse accumulators are at object offsets 0xa95/0xa99 and the input-mode byte is at 0x38f1. The observer found mode zero. The module's global at relative 0x29024 points to the engine pointer slot.

The Hitman profile now opts into existing relativeMouse behavior. The browser suppresses absolute movement before capture, requests Pointer Lock on the first trusted click, forwards subsequent physical deltas relative to the guest's virtual cursor and uses that cursor for button events. This preserves recentering without new game-specific input code. Real acceptance must acquire capture first, then move the guest-drawn cursor and click; an absolute drawableClick alone is not a valid input route for this engine.

Trusted-host verification (/home/vg/glide-validation) passes the three existing relative-input/capture tests. With capture active, normal movement and clicks advance through Start Game, the restaurant mission loading splash, briefing, objectives, target, location map and equipment selection. Artifacts: build/hitman-relative-world/006-drawable.png (loading), build/hitman-relative-world/013-drawable.png (briefing), and build/hitman-briefing-start/037-drawable.png (equipment). These are observed original game UI screens; later captures establish in-world gameplay. The guest-drawn cursor starts at the top-left, independently of the Win32 virtual cursor at (400,300), and moves at roughly 0.4 drawable pixels per relative guest unit. The temporary route captures at center, sends [1230,1190] relative units to Start, then [380,-130] to the briefing's next arrow. Buttons use the virtual cursor and no longer add displacement.

The subsequent build/hitman-gameplay-final/025-drawable.png reaches the actual third-person restaurant mission: Agent 47, textured streets/buildings, sky/fog and health/holster HUD are visible. An ArrowUp hold was sent at 180 s; before/after captures show character animation but do not establish translation. At approximately 301 s the guest traps in original Render3DFX runtime 0x00ec77d4, preferred 0x0fba57d4: an indirect jump through IAT 0x0fbb9230, _grDrawVertexArrayContiguous@16. The browser worker discarded the native exception stack, so the exact failure branch is not retained in that run. A served-only diagnostic replay then captured native stack, ABI arguments and bounded raw/state data before worker teardown.

The diagnostic replay reproduces the failure and resolves the native stack to glide_fail → glide3_vertex → glide3_array → handle_grDrawVertexArrayContiguous. Its retained artifact is build/hitman-trap.json: polygon mode 3, three vertices, stride 60, buffer 0x00ee39b0. All three original guest vertices already contain quiet NaNs (0x7fc00000) in ST0 S/T; positions, homogeneous W, colors, Q and ST1 are finite. The active framebuffer/TMU combination samples texture 0, so treating these coordinates as unused would be incorrect.

The original producer is preferred Render3DFX!0x0fb91bc0, called by 0x0fb93ad2 before the draw at 0x0fb93b06. It adds texture-object offsets +0x2c/+0x30 to indexed source UV pairs using simple FLD/FADD/FSTP operations, then writes Q=1. Source globals at preferred 0x0fbc194c, 0x0fbc1950 and 0x0fbc195c identify the source-UV object, index records and texture object. The subsequent capture below identifies the original source values.

The next capture exonerates the CPU arithmetic and texture-offset animation. Both texture offsets are finite 0.001953125; the indexed source UV pairs already contain NaNs. More decisively, the captured 2,048-byte neighborhood matches the original unmodified C1_HongKong/C1_3.zip member Pack.SPK byte-for-byte at offset 0x14defa. The UV array starts 512 bytes later, at 0x14e0fa, and contains six consecutive quiet-NaN floats. The asset itself ships these values. C1_3_Laptop.zip:Pack.SPK contains additional NaN runs. The prepared NaN-store CPU probe was therefore never built or run.

The original Glide setup code forwards floating-point texture coordinates without a finite-value API check. The Voodoo3 specification, sections 8.6 and 8.60–8.64, describes IEEE floating-point S/T inputs and internal fixed-point conversion but does not define NaN texel selection. MAME's Voodoo register conversion saturates exponent-255 inputs; its Voodoo2 setup path uses a different host conversion. Neither justifies claiming a particular replacement texel is hardware-exact. The chosen policy preserves geometry and valid coordinates.

The compatibility policy now replaces each consumed NaN S or T component with zero before clipping/projection. Both TMUs and both coordinate modes use the same conversion. Finite UV components, geometry, color, and Q are unchanged; existing infinity and non-UV validation remains in place. This is deterministic handling of unspecified texture sampling, not a claim of hardware-exact H3 output. ABI regression draws a complete textured triangle with mixed NaN/finite UVs and checks every emitted vertex. Remote focused ABI checks pass, including NaN position/W/Q/color rejection and infinite ST rejection.

Bounded WebGL gameplay regression

The patched canonical build completed the same normal pointer-lock menu and briefing route for 422.9 seconds on the trusted remote host, with default CPU settings and no diagnostic WASM override. build/hitman-nan-fixed/ retains screenshots and states.jsonl; 026-drawable.png shows the restaurant mission, Agent 47, textured buildings/street/sky and health/holster HUD. The final 078-drawable.png remains in the live world with zero renderer errors. The original route is unchanged through 320 seconds, beyond the earlier approximately 301-second failure. This run did not count NaN-coordinate draws, so elapsed survival is same-route regression evidence, not proof of a specific NaN-bearing draw executing. The ABI fixture supplies direct coverage of that case.

ArrowUp, W and D were delivered through normal browser input. Before/after screenshots show character animation and advancing NPCs; they do not establish player translation. This is bounded sustained world-rendering acceptance, not complete mission/game compatibility, software-renderer acceptance, or a performance result. Three existing relative-input/capture regressions and the Glide 3 ABI regression also pass remotely. The deterministic NaN texel policy remains an explicit approximation.

The shipped HitmanKeyboardLayout_WASD.pdf identifies W as Walk Forward and D as Turn Right. However, HitmanKeyboardLayout_Numpad.pdf also describes a default layout: Numpad5 walks forward, Numpad6 turns right, and Numpad8 runs. The fixture's empty hitman.cfg and lack of a binding override in Hitman.ini do not establish which preset is active. Probe logs confirm the WASD keydown, 1.8/1.2-second hold, and keyup completed, but do not prove the active guest bindings or keyboard-state consumption.

The separate build/hitman-numpad-final/ replay resolves that input question. Using the same original menu/briefing route, it holds Numpad5 for eight seconds at 150 s, Numpad6 for four seconds at 170 s, and Numpad8 for six seconds at 185 s. Input is delivered through browser CDP key events with physical numpad codes, location 3 and VK 101/102/104; the ordinary browser input log records these key messages. No guest memory or bindings are modified. This avoids a test harness ambiguity where numlock-off synthetic keys can report Clear or arrow virtual keys instead of the intended numpad keys.

Visual comparison establishes movement: 026-drawable.png is the starting street view, 028-drawable.png is against the building after walking, 032-drawable.png faces the opposite street after turning, and 034-drawable.png reaches the opposite building after running. The mission notification also appears. The active setup therefore accepts the shipped numpad layout; the earlier WASD result was not evidence of a keyboard failure. The replay completes at 210.5 s, still running, with zero renderer errors, zero LFB reads/writes, and zero GPU readbacks. These screenshots were opened in Preview. This establishes bounded walking/turning/running acceptance on the WebGL backend, while full mission completion and software gameplay remain untested.