Hype: The Time Quest demo

App: hype_glide_demo (experimental). Original executable: test/binaries/candidates/hype-time-quest-demo/launch-game/MaiDFXvr_bleu.exe. The executable directly imports 50 Glide 3 entry points. Static extraction, English installer mappings, package provenance and generated Windows INI are documented in the Glide 3 corpus report.

Startup DebugBreak: missing main-image export lookup

The 2026-09-29 remote CLI smoke stopped in the original sound plugin before establishing Glide gameplay. The relevant log is ~/nfs-movsd/build/hype-glide-smoke.log on the reserved test box. It loaded:

Image Original base Observed loaded base
MaiDFXvr_bleu.exe 0x00400000 0x00400000
dll/WAVx2BVR.dll 0x10000000 0x04301000

Thread 1 stopped at runtime 0x04302129, preferred DLL VA 0x10001129. The preceding instruction reads the callback cell at preferred 0x10024b9c; if it is zero, the plugin calls DebugBreak. After the break, the wrapper would call that same pointer, so ignoring the break would enter address zero rather than repair startup.

The plugin initializer at 0x10003fb0 first resolves _SND_fn_vDisplayError@8 from the main executable handle. Its lookup helper at 0x10003f40 calls GetProcAddress. If that returns zero, it constructs the message Function cannot be loaded dynamically and invokes the error callback that it has not yet initialized. Thus the visible break is the error-reporting path for the failed export lookup.

The original executable does export the requested callback:

Name Ordinal Original function VA
_SND_fn_vDisplayError@8 2229 0x004d5430
_SND_fn_vDisplayErrorEx@12 2230 0x004d5490

The executable has 2,739 export-address entries, no zero holes and no forwarders. Its export directory is RVA 0x194d70, size 115,602 bytes. This was checked against the original file, not inferred from strings.

handle_GetProcAddress previously searched DLL_TABLE, then known native API names. The executable is not a member of DLL_TABLE, so it could not answer this lookup. The fix resolves exports from the main image before that existing path, using the shared mapped DOS/PE headers to obtain the export directory. It must not rely on the loader instance's exe_export_rva global: Hype makes the call on a secondary guest thread. No sound configuration, executable bytes, or DebugBreak behavior changed.

The existing test/test-getprocaddress-sparse-name.js now checks the callback name and ordinal, out-of-range ordinals, a zero address-table slot, an absent name, stdcall stack cleanup and operation with the loader-global RVA zero. Its original sparse-heap Win32 lookup still passes. The focused test passed remotely on 2026-09-29 after correcting the synthetic fixture's name pointers to lie above the 16-bit ordinal range. The post-fix remote CLI run reached a visible main menu without the startup DebugBreak; evidence is build/hype-fixed-startup.log and build/hype-fixed-startup.png on the test box. The screenshot was opened for review. Browser Glide identity, entry into the playable world and input-driven movement remain pending; menu acceptance alone does not establish gameplay.

Reproduction

Prepare the original extracted corpus, then use a current compiled artifact on the remote test machine:

node tools/prepare-glide3-corpus.js --check
node test/test-getprocaddress-sparse-name.js
node test/run.js --app=hype_glide_demo --threads --no-build \
  --wasm=build/wine-assembly.wasm --quiet-api --max-batches=2000 \
  --png=build/hype-fixed-startup.png

Static evidence can be reproduced without executing the game:

node tools/pe-exports.js test/binaries/candidates/hype-time-quest-demo/launch-game/MaiDFXvr_bleu.exe
node tools/disasm.js test/binaries/candidates/hype-time-quest-demo/launch-game/dll/WAVx2BVR.dll 0x10001120 0x10001142
node tools/disasm.js test/binaries/candidates/hype-time-quest-demo/launch-game/dll/WAVx2BVR.dll 0x10003f40 0x10003fe7

Export lookup limits

Native KERNEL32 currently aliases the main image handle. A missing EXE export therefore retains the existing native-API fallback; removing it would break callers looking up Win32 functions through that alias. An unknown callback still returns zero. This is compatibility with the current handle model, not evidence that distinct Windows module handles are interchangeable.

The reused resolve_image_export helper validates ordinal bounds and empty address entries. It assumes valid name-to-ordinal indices and does not follow PE export forwarders. The new dynamic main-image path guards the export directory's RVA/size range: a resolved address inside it logs marker 0x46574452 (FWDR) and the address, then traps. It therefore cannot return a forwarder string as callable code. Named and ordinal forwarder cases have focused regressions, which also passed remotely. Hype has no such entries. This is an explicit unsupported case, not general forwarded-export support; malformed PE export tables remain outside this investigation.

Normal-input gameplay probe

The original launch-game/Readme.txt, section IX, documents the default QWERTY bindings: arrows control the character, left Shift runs, Ctrl jumps, Space performs an action, and Enter uses magic. Up/Down navigate menus; Left/Right turn in the world. Do not assume a held Enter key moves the player. Gamedata/Options/Default.cfg is binary data, not a text configuration to edit.

The existing investigation probe build/glide-app-probe.js can launch hype_glide_demo webgl 120 build/hype-gameplay-webgl. It records desktop and actual Glide drawable screenshots every five seconds, plus API version, endpoint/backend and draw/present counters in states.jsonl. Its screenshots are observations, not automatic world acceptance. A normal New Game menu selection must be verified visually before calling a changed image gameplay. After reaching the world, compare a stationary frame against a held ArrowUp interval and a turn, while confirming API version 3, a glide endpoint and advancing geometry/presents. The probe accepts investigator input through input.json, for example [{"hold":"ArrowUp","ms":3000}]; this drives ordinary browser input and does not modify the guest state directly.

Menu input, viewport and FIFO polling (2026-09-30)

The menu initially ignored held keys despite correct host physical-key state. Original code at 0x48e3bd checks DIDEVCAPS.dwFlags & DIDC_ATTACHED before enabling its keyboard. Our capabilities omitted that flag. Correct attached keyboard/mouse reporting, plus the correct dwButtons offset 16, passes the remote DirectInput regression for both 24-byte and 44-byte structures. Normal Enter now loads the level. Keyboard flags change from 0x12 to 0x13, and the guest poll counter advances. No focus or input-transport workaround was needed.

The resulting level capture still occupies only part of the 640×480 drawable. A read-only snapshot confirms the top window has a 640×480 client, while its three nested children retain 388×268 clients. Hype initializes its logical resolution globals 0x5d8108/0x5d810c to 640/480 and correctly selects Glide resolution enum 7. Its viewport creation (0x49a730) uses the parent's client rectangle, and DEV_Device::OnSize (0x499250) resizes the child. The Glide fullscreen path changed geometry without the normal resize notification. A candidate delivered WM_WINDOWPOSCHANGED after releasing the Glide lock, allowing normal DefWindowProc processing to produce WM_SIZE. The subsequent build/hype-full-world-webgl.log snapshot still had 388×268 child clients. The candidate and its callback fixture were removed: it did not improve the layout and its internal synchronous dispatcher bypassed window-owner thread routing. A subsequent owner-thread fix is described below.

The matched hype-capture3-webgl / hype-no-notify-webgl captures also exclude notification as a necessary cause of the observed bad geometry. Both capture three triangles with nine vertices: five X values are NaN and all finite X values are zero. Their draw state and non-finite field patterns match; all eight drawable PNGs in both runs share SHA-256 d38b77b951118e53418317ab5ab5fea654c449b3dc757ede3c171383b4902ceb.

Static inspection narrows the next trace: CPA_MainFrame::OnSize at 0x499fe0 calls helper 0x477a70, which always returns zero. It therefore takes the branch that conditionally waits on the application's semaphore before calling native MFC42 ordinal 5030 through thunk 0x4f442c. That MFC handler (preferred address 0x5f40df89) invokes its default handler and then virtual method +0xd0 (frame layout) unless the size type is minimized. Counting entries to 0x499fe0 and 0x499250, alongside child creation and Glide open, will distinguish missing dispatch from notification timing or layout suppression. No compositor scaling workaround is justified by this evidence.

For upstream geometry diagnosis, original polygon clipper 0x483670 takes the vertex count in ECX, a 60-byte-stride vertex buffer in EDX, and the renderer context at [ESP+4]. Float clip bounds in that context are top +0x3daa8, bottom +0x3daac, left +0x3dab0, and right +0x3dab4. Screen-quad producers 0x486a30 and 0x486d20 use staging buffer 0x835ae0 (four vertices), but which produced the captured frame remains unverified.

The Y-edge interpolator at 0x483bf0 is a specific candidate for the NaNs: it calculates (boundary-yA)/(yB-yA), interpolates X and attributes, and writes the boundary directly as Y. This can produce the captured finite-Y / NaN-X and attribute pattern. At its entry ECX is the output vertex, EDX is vertex A, and stack offsets +4,+8,+12,+16,+20 hold vertex B, yA, yB, boundary, and context respectively. Capturing these inputs and the pre-clip staging buffer will distinguish invalid source geometry or bounds from arithmetic failure; the instruction sequence alone does not establish which occurred.

The subsequent world stall is a separate query bug. The rendering thread repeatedly reaches 0x4f1626, the grGet import thunk. Calls at 0x482427, 0x482442 and related sites ask for GR_FIFO_FULLNESS (3), length 8, and loop while the first output word exceeds 2,000,000. The unimplemented query returned zero without writing the output, leaving a stale stack value to drive an infinite polling loop. The query now drains pending commands and waits for backend completion (WebGL finish, synchronous native software), then writes the SDK's free-entry count and status words. This adds no pixel readback. ABI ordering, invalid-length and output-boundary tests pass remotely, as do software and WebGL 1/2 completion tests. The render loop now progresses.

A subsequent captured frame still cannot change the picture: it clears only depth, then submits three invalid/degenerate triangles. Five of nine vertices have NaN X; the other four have X=0. Both software and WebGL retain the menu. This first post-load frame was not enough to characterize steady gameplay. The later matched notification comparison and frame captures below narrow the observation without establishing a CPU arithmetic bug.

Evidence on the remote machine: build/hype-attached-webgl/ contains the first world capture; build/hype-world-diagnostics.log and its corresponding states.jsonl record the window tree and stalled thread. These captures do not yet demonstrate movement.

Later frame comparison

Matched runs build/hype-steady-default/ and build/hype-steady-interpreter/ use the same no-notification WASM and normal Enter, Space, ArrowUp and ArrowRight route. The second disables both the micro-op tier and x87 folding. A frame captured after 800 presents contains no geometry in the default run, versus 390 triangles with 1,170 finite vertices in the interpreter run. This comparison does not isolate either optimization or prove a CPU bug: the captured frames can represent different points in the application's execution.

Interpreter screenshot 006-drawable.png shows the character in a blue corridor, still restricted to 388×268 pixels. It was opened in Preview. The other 15 drawable captures retain the exact earlier menu hash. LFB read/write counts stop at 238 and remain unchanged through the later capture interval, so repeated LFB uploads do not explain that return to the menu image. Later actual-presentation captures below identify the alternating guest swaps; this was not dropped delivery by the shared render worker.

Owner-thread resize and movement

Glide fullscreen open now posts WM_MOVE and WM_SIZE through the existing owner-routed USER queue, matching DirectDraw's mode-change path. It does not call the window procedure on the rendering thread or while holding the Glide lock. The focused ABI regression opens from one instance with a window owned by another, verifies the ordered payloads and absence of inline callbacks, and checks that a failed open posts nothing.

On the trusted fallback server, build/hype-post-size-webgl/ captures a 636×476 child viewport inside the 640×480 drawable, replacing the earlier 388×268 viewport. Actual presentation frames in build/hype-swap-callers/ show normal ArrowUp movement and ArrowRight turning; world-present-661.png and world-present-1047.png were compared visually and opened in Preview. Default CPU settings in build/hype-default-callers/ also produce four captured world frames with 403–406 triangles and no nonfinite fields. The earlier single-frame optimized/interpreter comparison sampled opposite halves of the UI/world alternation and does not establish a CPU bug. The remaining empty UI swap still prevents stable visible presentation.

Alternating world and UI swaps

The eight adjacent swaps in build/hype-swap-callers/frame-capture.json all originate from thread 1, return to 0x4671d4, and use interval 1. World frames have higher caller 0x43f631; empty frames have caller 0x42252b. Device 0x03f012d8 has field +0x38 == 0, and both globals 0x77728c and 0x5da054 are zero throughout this sample.

Static disassembly explains the pair: frame-finish callback 0x43f610 (installed at 0x5b2290, paired with render callback 0x43f180 at 0x5b228c) finalizes descriptor 0x71cd04, swaps surface ID short[0x71cd02], then directly calls 0x422260 at 0x43f678 before releasing semaphore [0x71cd6c]. That second function acquires surface short[0x71cd70] into descriptor 0x71cd74, visits UI objects from [0x7136e0] through links at +0xd8 using 0x41f300, finalizes the descriptor, and unconditionally calls the same swap wrapper. These are nested guest paths, not two independently scheduled windows or threads.

Wrapper 0x467180 decodes its second argument as device index /16 and surface index %16, clears the surface's acquired flag at +0x64, and calls Glide swap when [0x77728c] == 0. Acquisition 0x467070 sets that global from whether device field +0x38 is nonzero. No renderer suppression is justified by this evidence. The follow-up capture records world surface ID 0 and UI surface ID 1 on device 0, an empty UI-list head at 0x7136e0, mode byte 9 at 0x71c620, and zero flags at 0x5d9680/84. The unresolved question is why this device/surface configuration requests a second flip without intervening color drawing.

Device field +0x38 is not populated by a Glide capability query. Constructor 0x466810 allocates a 0x10c-byte device and copies the caller's first 0x6c configuration bytes into it at 0x466b9e–0x466ba5. The normal MFC creation path at 0x499450 explicitly sets configuration +0x38 to zero at 0x49949e, then calls this constructor at 0x49950d. An alternate path at 0x499320 sets it to one, but its entry checks 0x477a70, whose original implementation is exactly xor eax,eax; ret; consequently that alternate path is disabled in this executable. The constructor can also clear a nonzero value if an existing device already has it set. The observed zero therefore matches the original executable's selection, and changing Glide query results or forcing this field would not be a supported fix.

The pinned Glide 3 SDK's gglide.c implementation of grBufferSwap unconditionally cycles current/front/back indices modulo the configured buffer count, queues the swap command, and selects the new drawing buffer. It has no exception for an empty frame. Its fast clear also respects the RGB write mask, so a depth-only clear correctly preserves the older menu color. These rules agree with the observed alternating buffer contents. One concrete difference remains: our handle_grBufferSwap does not pass the guest's swap interval to the host; it flips and publishes immediately. The SDK encodes interval 1 as a retrace-synchronized swap and bounds pending swaps. Browser compositing may therefore repeatedly sample the second UI presentation when our two swaps happen close together. Correct pacing would preserve both requested flips; it is not evidence that the retained menu pixels should be discarded or that pacing alone fixes gameplay.

Buffer initialization does not explain the alternation either. All four original grSstWinOpen call sites request two color buffers and one auxiliary buffer. The executable imports no grRenderBuffer, grGlideGetState, or grGlideSetState; it keeps the default back-buffer target. The SDK's initial physical buffer numbering differs from ours, but the logical front/back rotation is equivalent. Initial parity cannot account for menu pixels retained after the later LFB uploads.

An isolated 120-second default-CPU replay then tested an interval-1 wait before each swap using the existing virtual-vblank scheduler. It preserved every requested flip and left canonical WASM and production source unchanged. build/hype-vsync-default/frame-capture.json still alternates four finite world frames (403–406 triangles) with the same old menu. Device and main-thread present counts agree; the run ends without errors. World visibility between actual publications was about 32–38 ms, versus 36–47 ms in the unpaced sample. These are diagnostic observations, not controlled performance measurements. Pacing alone did not fix stable presentation and was not promoted to production.