diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c28ad62..fe8baca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,6 +72,12 @@ Startup-page and direct-battlefield entry use one-shot guest hooks while retaini RA2 and YR remain separate engines. A single gamemd loading original RA2 resources is not a supported promise. Such conversion involves game logic and patches; filename mapping alone does not establish compatibility. Local guards do not prove upstream defects fully resolved: for example, `repairRa2InvalidRepairRate` corrects only nonpositive or nonfinite RepairRate values and cannot cover all custom rules. +DirectPlay enumeration returns the reserved guest callback frame to the caller so its staging allocations can follow that frame's lifetime. Before another enumeration, the shim reclaims only buffers whose callback owner flag is clear; guest return tails and thread exit clear that flag. Nested or concurrent enumerations retain their own descriptors, names, and timeout pointers. An unrelated callback reusing a slot may delay collection, but cannot cause early release. Failed allocation or bridge generation rolls back unpublished buffers and reservations. Remaining staging is bounded by the callback-slot count and ends with the VM heap. + +Guest callback slots can reserve a caller-sized scratch tail; bridge generation checks its code against that boundary before publication. DirectDraw display-mode enumeration stores its descriptor there, keeping each active callback's mode snapshot stable across nested mode changes without permanent heap staging. The existing callback owner and return/exit paths govern both code and scratch lifetime. + +Date and time formatting validate only the SYSTEMTIME fields used by the respective API. Date validation checks actual month lengths and leap years before deriving the weekday; unused time fields do not invalidate a date, and unused date fields do not invalidate a time. + ## Scheduling, presentation, and ownership Worker execution is one guest path; main-thread fallback uses identical game policies and file semantics. `platform/browser/emulator.ts` wraps v86 browser adaptation. When the upstream interface matches, Workers use an in-thread MessageChannel scheduler while retaining original positive-wait durations. Otherwise, upstream scheduling remains in place. The main thread keeps its own scheduler. Adaptation does not change the guest clock or PIT frequency. diff --git a/docs/REAL_GAME_CI.md b/docs/REAL_GAME_CI.md index d97fd10..599519c 100644 --- a/docs/REAL_GAME_CI.md +++ b/docs/REAL_GAME_CI.md @@ -4,7 +4,7 @@ All CI lives in `.github/workflows/`, using `ubuntu-latest` runners. Workflow fi ## Unified pipeline and Basic test -`quality-check.yml` is the only workflow, accepting PRs targeting dev/main, pushes to dev/main, and manual runs. Ordering is `Basic test → Real game RA2 → Real game YR`, with separate runners per job. Basic runs frozen installation, Prettier, type/unit/synthetic VM checks, builds, firmware consistency, and real-browser graphics, React UI, touch, maps, layered archives, and main-thread/Worker relay regressions. +`quality-check.yml` is the only workflow, accepting PRs targeting dev/main, pushes to dev/main, and manual runs. Ordering is `Basic test → Real game RA2 → Real game YR`, with separate runners per job. Basic runs frozen installation, Prettier, type/unit/synthetic VM checks, builds, firmware consistency, and real-browser audio lifecycle, graphics, React UI, touch, maps, layered archives, and main-thread/Worker relay regressions. This runner is ephemeral and isolated, with no game directory, resource secrets, deployment credentials, host-directory mounts, or private caches. The workflow checks that game/ and .tmp-third-party/ are absent from the checkout and downloads no game executable. External PRs do not run asset-enabled jobs. Basic and game jobs share no writable cache. @@ -43,8 +43,8 @@ YAML declares triggers, runners, tool installation, and secrets, then invokes pn | Entry | Responsibility | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm run ci:basic` | Check asset-free environment, check, firmware consistency, browser installation, nine browser regressions | -| `pnpm run ci:browser` | Use installed browsers, exclusively start Vite/relay, run nine browser regressions | +| `pnpm run ci:basic` | Check asset-free environment, check, firmware consistency, browser installation, and browser regressions | +| `pnpm run ci:browser` | Use installed browsers, exclusively start Vite/relay, and run browser regressions | | `pnpm run ci:real-game ra2` / `yr` | Download/validate corresponding secret resources, install browser, run original-executable and battlefield startup regressions, clean up | | `pnpm run ci:resources --record` | Explicit maintainer inventory creation; not called by CI | diff --git a/docs/TESTING.md b/docs/TESTING.md index b6101a8..e23a772 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -53,6 +53,10 @@ Current asset-enabled CI requires original-executable startup, the RA2 quick-gam Fixed-address/instruction evidence lives in game modules and their tests. Never widen architecture allowlists, modify clocks, fabricate acknowledgments, or skip defeat checks to pass tests. Preserve failure reasons; a successful rerun does not erase earlier failures. +`tests/basic/shimDplayEnumeration.test.ts` covers nested enumeration storage, out-of-order completion, thread exit, and failed allocation/bridge generation. `tests/basic/vm/dplayEnumeration.e2e.test.ts` executes nested and hardware-preempted callbacks in real v86, including another thread exiting inside its callback. It checks descriptor stability, stack balance, and heap reclamation. `threadRecycle.e2e.test.ts` runs more thread lifetimes than the slot limit and executes x87 instructions after reuse; `displayEnumeration.e2e.test.ts` checks repeated stdcall/cdecl callbacks without growing heap or dynamic code, plus descriptor stability across nested display-mode changes. `tests/basic/guestCodeAllocation.test.ts` also checks variable-size callback scratch boundaries. `tests/basic/shimHeapPressure.test.ts` checks failed object/data/back-buffer allocations and rollback. `tests/basic/shimKernelFileTime.test.ts` checks field independence, invalid dates, leap years, signed capacities, quoted literals, and DBCS trail-byte collisions. All run under `check`; they do not replace original-executable or real multiplayer acceptance. + +`pnpm run test:browser:audio` runs asset-free Chromium audio lifecycle acceptance against the configured development origin. It observes real Worklet messages through repeated playback/release cycles across separate AudioContexts, verifies bounded active-processor counts, and checks context closure. Basic CI includes it. This finite lifecycle test does not measure browser heap reclamation or establish full-match audio reliability. + ## Save and cold-load regression `pnpm run check` runs the asset-free OLE callback, storage metadata, asynchronous file-open, time conversion, and window-order regressions: `tests/basic/vm/olePersistence.e2e.test.ts`, `tests/basic/shimOleStorage.test.ts`, `tests/basic/vmCore.test.ts`, `tests/basic/shimFile.test.ts`, `tests/basic/shimWindowZOrder.test.ts`, and `tests/basic/shimScrollbarOcclusion.test.ts`. These do not replace the real RA2/YR save/load regressions: diff --git a/package.json b/package.json index 94c6a04..733e644 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test:browser:keyboard-lock": "tsx tests/basic/browser/keyboardLockBrowserSmoke.mts", "test:browser:touch-ui": "tsx tests/basic/browser/touchUiBrowserSmoke.mts", "test:browser:performance": "tsx tests/basic/browser/performanceDiagnosticsBrowserSmoke.mts", + "test:browser:audio": "tsx tests/basic/browser/audioLifecycleBrowserSmoke.mts", "test:browser:react-ui": "tsx tests/basic/browser/reactUiBrowserSmoke.mts", "test:browser:third-party-preload": "tsx tests/real-game/browser/thirdPartyPreloadBrowserSmoke.mts", "test:browser:startup-page": "tsx tests/real-game/browser/ra2StartupPageBrowserSmoke.mts", diff --git a/scripts/ci/run.mts b/scripts/ci/run.mts index 42e74dd..664cead 100644 --- a/scripts/ci/run.mts +++ b/scripts/ci/run.mts @@ -67,6 +67,7 @@ async function browsers(): Promise { '15182', ]); for (const test of [ + 'test:browser:audio', 'test:graphics', 'test:graphics:upscale', 'test:graphics:ai', diff --git a/src/adapter/audio.ts b/src/adapter/audio.ts index d4b3d1a..b0474b0 100644 --- a/src/adapter/audio.ts +++ b/src/adapter/audio.ts @@ -19,15 +19,18 @@ const STREAM_PROCESSOR_FRAMES = 4_096; const PCM_STREAM_WORKLET_NAME = 'ra2-pcm-stream'; /** AudioWorklet module cache: share one addModule call per context and allow retries after failure. */ -let workletModulePromise: Promise | null = null; +const workletModules = new WeakMap>(); function loadPcmStreamWorklet(context: AudioContext): Promise { - workletModulePromise ??= context.audioWorklet - .addModule(new URL('./pcmStreamWorklet.js', import.meta.url)) - .catch((error) => { - workletModulePromise = null; + let pending = workletModules.get(context); + if (!pending) { + // Processor registration belongs to one context; a later session has a new audio-thread global scope. + pending = context.audioWorklet.addModule(new URL('./pcmStreamWorklet.js', import.meta.url)).catch((error) => { + workletModules.delete(context); throw error; }); - return workletModulePromise; + workletModules.set(context, pending); + } + return pending; } export interface PcmBufferSnapshot { @@ -50,6 +53,8 @@ export interface WebAudioPcmSinkOptions { /** Connect to context.destination by default. */ destination?: (context: AudioContext) => AudioNode; onError?: (error: unknown) => void; + /** Development diagnostics: log sink state and guest audio activity at this interval, plus every AudioContext state change. */ + diagnosticsIntervalMs?: number; } interface PcmBufferState { @@ -95,6 +100,10 @@ export function directSoundPanToStereo(pan: number): number { /** * Initial linear master gain: a 50% slider gives squared gain (0.5)^2. These two constants derive slider percentage and linear gain from one another, avoiding duplicate literals in the page, toolbar, and Worker configuration. */ +/** Development builds log audio diagnostics every 30s; production and tests leave them off. */ +export const AUDIO_DIAGNOSTICS_INTERVAL_MS = + import.meta.env.DEV && import.meta.env.MODE !== 'test' ? 30_000 : undefined; + export const DEFAULT_MASTER_VOLUME = 0.25; /** Slider percentage (0..100) equivalent to DEFAULT_MASTER_VOLUME. */ export const DEFAULT_VOLUME_PERCENT = Math.round(Math.sqrt(DEFAULT_MASTER_VOLUME) * 100); @@ -107,7 +116,18 @@ export class WebAudioPcmSink { /** Linear master gain 0..1, applied after all buffers and before the destination. */ private masterVolume = DEFAULT_MASTER_VOLUME; - constructor(private readonly options: WebAudioPcmSinkOptions = {}) {} + /** Guest activity since the last diagnostics line; only maintained when diagnostics are enabled. */ + private readonly activity = { plays: 0, stops: 0, writes: 0, writeBytes: 0, errors: 0, lastError: '' }; + private diagnosticsTimer: ReturnType | null = null; + private lastDiagnosticsContextTime: number | null = null; + /** Last processor count reported by the audio thread; grows without bound if disconnected nodes are never collected. */ + private liveWorkletProcessors = 0; + + constructor(private readonly options: WebAudioPcmSinkOptions = {}) { + if (options.diagnosticsIntervalMs) { + this.diagnosticsTimer = globalThis.setInterval(() => this.logDiagnostics(), options.diagnosticsIntervalMs); + } + } createBuffer(id: PcmBufferId, byteLength: number, format: PcmWaveFormat = DEFAULT_PCM_FORMAT as PcmWaveFormat): void { this.assertAlive(); @@ -164,6 +184,8 @@ export class WebAudioPcmSink { /** Write the guest PCM snapshot obtained by Unlock into the mirrored buffer. */ writeBuffer(id: PcmBufferId, offset: number, bytes: Uint8Array): number { + this.activity.writes++; + this.activity.writeBytes += bytes.byteLength; const state = this.buffers.get(id); if (!state || bytes.byteLength === 0) return 0; const start = clamp(Math.trunc(offset), 0, state.pcm.byteLength); @@ -186,6 +208,7 @@ export class WebAudioPcmSink { } play(id: PcmBufferId, options: PcmPlayOptions = {}): boolean { + this.activity.plays++; const state = this.buffers.get(id); if (!state) return false; state.loop = options.loop ?? false; @@ -208,6 +231,7 @@ export class WebAudioPcmSink { } stop(id: PcmBufferId): boolean { + this.activity.stops++; const state = this.buffers.get(id); if (!state) return false; state.positionBytes = this.currentPosition(state); @@ -358,6 +382,8 @@ export class WebAudioPcmSink { async destroy(): Promise { if (this.destroyed) return; + if (this.diagnosticsTimer !== null) globalThis.clearInterval(this.diagnosticsTimer); + this.diagnosticsTimer = null; this.stopAll(); this.buffers.clear(); this.destroyed = true; @@ -457,9 +483,10 @@ export class WebAudioPcmSink { private onWorkletMessage( state: PcmBufferState, worklet: AudioWorkletNode, - message: { kind: string; frame?: number }, + message: { kind: string; frame?: number; live?: number }, ): void { if (state.worklet !== worklet || !this.context || message.kind !== 'position') return; + if (message.live !== undefined) this.liveWorkletProcessors = message.live; state.streamFrame = message.frame ?? state.streamFrame; state.workletPositionAt = this.context.currentTime; } @@ -702,6 +729,14 @@ export class WebAudioPcmSink { } // A new context needs a new master gain node; the old node is destroyed with its context. this.masterGain = null; + if (this.options.diagnosticsIntervalMs) { + const context = this.context; + context.addEventListener('statechange', () => + console.warn( + `[音频诊断] AudioContext 状态变为 ${context.state}(音频时钟 ${context.currentTime.toFixed(1)}s)`, + ), + ); + } return this.context; } catch (error) { this.report(error); @@ -724,8 +759,46 @@ export class WebAudioPcmSink { } private report(error: unknown): void { + this.activity.errors++; + this.activity.lastError = error instanceof Error ? error.message : String(error); this.options.onError?.(error); } + + /** + * One line separating guest-side silence (no Play/Unlock writes arriving) from host-side silence (context not + * running, audio clock frozen, or worklets no longer reporting their playback cursor). + */ + private logDiagnostics(): void { + const context = this.context; + let playing = 0; + let worklets = 0; + let streams = 0; + let sources = 0; + let staleWorklets = 0; + for (const state of this.buffers.values()) { + if (state.playing) playing++; + if (state.worklet) { + worklets++; + // The worklet posts its cursor about every 100ms while rendering; a playing one silent for >1s has stalled. + if (context && state.playing && context.currentTime - state.workletPositionAt > 1) staleWorklets++; + } + if (state.stream) streams++; + if (state.source) sources++; + } + const clock = context?.currentTime ?? null; + const clockDelta = + clock !== null && this.lastDiagnosticsContextTime !== null ? clock - this.lastDiagnosticsContextTime : null; + this.lastDiagnosticsContextTime = clock; + const activity = this.activity; + console.info( + `[音频诊断] Context=${context?.state ?? '未创建'} 音频时钟+${clockDelta?.toFixed(1) ?? '-'}s ` + + `缓冲${this.buffers.size} 播放中${playing}(worklet ${worklets}/停滞${staleWorklets},脚本流${streams},一次性源${sources});` + + `音频线程处理器${this.liveWorkletProcessors};` + + `本周期 Play${activity.plays} Stop${activity.stops} 写入${activity.writes}次/${(activity.writeBytes / 1024).toFixed(0)}KB ` + + `错误${activity.errors}${activity.lastError ? `(${activity.lastError})` : ''}`, + ); + Object.assign(activity, { plays: 0, stops: 0, writes: 0, writeBytes: 0, errors: 0, lastError: '' }); + } } function readPcmSample(view: DataView, offset: number, bits: number): number { diff --git a/src/adapter/pcmStreamWorklet.js b/src/adapter/pcmStreamWorklet.js index b51c275..5523a40 100644 --- a/src/adapter/pcmStreamWorklet.js +++ b/src/adapter/pcmStreamWorklet.js @@ -17,12 +17,22 @@ // use JSDoc for types here while keeping the file itself plain JS. /* global AudioWorkletProcessor, registerProcessor, sampleRate, currentTime */ +/** Live processor instances on the audio thread; reported with the cursor to detect leaked nodes. */ +let liveProcessors = 0; + class Ra2PcmStreamProcessor extends AudioWorkletProcessor { constructor() { super(); + liveProcessors++; /** @type {{ channels: number, frames: number, pcm: Float32Array, frame: number, * playing: boolean, loop: boolean, step: number, lastPositionAt: number } | null} */ this.state = null; + /** + * Set by destroy. process() must then return false: while it returns true the node keeps "active processing" + * status, so the browser cannot collect a disconnected node and its per-quantum work accumulates on the audio + * thread for the whole session, eventually starving rendering and silencing the game. + */ + this.destroyed = false; this.port.onmessage = (event) => this.onMessage(event.data); } @@ -76,12 +86,15 @@ class Ra2PcmStreamProcessor extends AudioWorkletProcessor { } case 'destroy': { this.state = null; + if (!this.destroyed) liveProcessors--; // A repeated destroy must not double-count. + this.destroyed = true; break; } } } process(_inputs, outputs) { + if (this.destroyed) return false; const state = this.state; const output = outputs[0]; if (!state || !output || output.length === 0) return true; @@ -111,7 +124,7 @@ class Ra2PcmStreamProcessor extends AudioWorkletProcessor { // Report the cursor about every 100ms as the main thread's extrapolation baseline. if (currentTime - state.lastPositionAt >= 0.1) { state.lastPositionAt = currentTime; - this.port.postMessage({ kind: 'position', frame: state.frame }); + this.port.postMessage({ kind: 'position', frame: state.frame, live: liveProcessors }); } return true; } diff --git a/src/adapter/runtime.ts b/src/adapter/runtime.ts index c3cf016..7748bbc 100644 --- a/src/adapter/runtime.ts +++ b/src/adapter/runtime.ts @@ -3,7 +3,7 @@ import { serveRelayPort, relayAddressCandidates, relayRoomFromPath } from 'relay import { createBrowserEmulator } from '../platform/browser/emulator'; import { BrowserEmulatorProbe } from '../platform/browser/emulatorProbe'; import type { VmDiagnosticAction, VmDiagnostics, VmRuntimeInfo } from './vmDiagnostics'; -import { DEFAULT_MASTER_VOLUME, WebAudioPcmSink } from './audio'; +import { AUDIO_DIAGNOSTICS_INTERVAL_MS, DEFAULT_MASTER_VOLUME, WebAudioPcmSink } from './audio'; import { gameVmConfiguration } from '../games/vmConfiguration'; import { collectDirectoryOverlays, @@ -217,6 +217,7 @@ export async function createVmShell( export class Win32GameVm implements VmShell { private readonly audio = new WebAudioPcmSink({ onError: (error) => console.warn('[VM audio]', error), + diagnosticsIntervalMs: AUDIO_DIAGNOSTICS_INTERVAL_MS, }); private readonly core: VmCore; private removeAudioUnlock: (() => void) | null = null; diff --git a/src/adapter/vmClient.ts b/src/adapter/vmClient.ts index 665e5e2..78802d9 100644 --- a/src/adapter/vmClient.ts +++ b/src/adapter/vmClient.ts @@ -1,7 +1,7 @@ import type { GamePerformanceSample } from '../games/performance'; import type { VmDiagnosticAction, VmDiagnostics, VmRuntimeInfo } from './vmDiagnostics'; import { normalizeGameClockRate } from '../vm86/clock'; -import { WebAudioPcmSink } from './audio'; +import { AUDIO_DIAGNOSTICS_INTERVAL_MS, WebAudioPcmSink } from './audio'; import type { GuestMemRecordResult } from './memRecord'; import { createRequestId, @@ -89,6 +89,7 @@ export class WorkerVmClient implements VmShell { options.audio ?? new WebAudioPcmSink({ onError: (error) => console.warn('[VM audio]', error), + diagnosticsIntervalMs: AUDIO_DIAGNOSTICS_INTERVAL_MS, }); this.worker = options.workerFactory?.() ?? new Worker(new URL('./vmWorker.ts', import.meta.url), { type: 'module' }); diff --git a/src/adapter/vmCore.ts b/src/adapter/vmCore.ts index 128f1d2..8f0bbc8 100644 --- a/src/adapter/vmCore.ts +++ b/src/adapter/vmCore.ts @@ -71,6 +71,8 @@ const STUB_LIMIT = 0x000c_0000; const HYPERCALLS_PER_HOST_YIELD = 128; /** Yielding by call count is not yielding by time: complex guest frames can stretch 128 calls into tens of milliseconds. */ const HOST_SLICE_MS = 4; +/** Development builds log heap/stub/sound-buffer state at this interval to diagnose long-session degradation. */ +const DIAGNOSTICS_INTERVAL_MS = 30_000; /** Win32 audio output plus host master-volume, stop-all, and destruction capabilities. */ export interface VmAudioSink extends Win32AudioSink { @@ -111,6 +113,8 @@ export class VmCore { private image: PeImage | null = null; private shim: Win32ShimBase | null = null; private pollTimer: ReturnType | null = null; + /** Development-only periodic console diagnostics for long sessions (heap, stubs, sound buffers, logic FPS). */ + private diagnosticsTimer: ReturnType | null = null; private handling = false; private calls = 0; private readonly recentCalls: string[] = []; @@ -305,6 +309,9 @@ export class VmCore { // Port events are primary; 50ms polling is only a fallback for CPU exceptions and exceptional conditions. this.pollTimer = globalThis.setInterval(() => void this.poll(), 50); + if (import.meta.env.DEV && import.meta.env.MODE !== 'test') { + this.diagnosticsTimer = globalThis.setInterval(() => void this.logDiagnostics(), DIAGNOSTICS_INTERVAL_MS); + } await emulator.run(); this.status('running', `${game.executable} 正在 v86 中执行(入口 0x${image.entry.toString(16)})`); } catch (error) { @@ -809,6 +816,24 @@ export class VmCore { private clearPoll(): void { if (this.pollTimer !== null) globalThis.clearInterval(this.pollTimer); this.pollTimer = null; + if (this.diagnosticsTimer !== null) globalThis.clearInterval(this.diagnosticsTimer); + this.diagnosticsTimer = null; + } + + private async logDiagnostics(): Promise { + const shim = this.shim; + if (!shim || this.currentPhase !== 'running') return; + const performance = await this.getGamePerformance().catch(() => null); + const heap = shim.inspectHeapState(); + const counts = shim.inspectResourceCounts(); + const mb = (bytes: number) => (bytes / 1_048_576).toFixed(1); + console.info( + `[VM诊断] 逻辑帧=${performance?.frame ?? '-'} 逻辑fps=${performance?.logicFps?.toFixed(1) ?? '-'} ` + + `页面=${shim.inspectShellPageTitle() || '战场'} ` + + `堆: 活动${heap.liveAllocations}个/${mb(heap.liveBytes)}MB 空闲${mb(heap.freeBytes)}MB(${heap.freeBlocks}块) ` + + `顶端${mb(heap.nextAddress)}MB 虚拟${mb(heap.virtualBytes)}MB;` + + `stub已用${(counts.dynamicStubBytes / 1024).toFixed(1)}KB 声音缓冲${counts.soundBuffers} 表面${counts.surfaces}`, + ); } private currentPhase: VmStatus['phase'] = 'loading'; diff --git a/src/vm86/pe.ts b/src/vm86/pe.ts index 9024c70..31aca5d 100644 --- a/src/vm86/pe.ts +++ b/src/vm86/pe.ts @@ -75,6 +75,8 @@ export const GUEST_CALLBACK_OWNERS = 0x0007_3c00; export const GUEST_CALLBACK_BASE = 0x0022_0000; export const GUEST_CALLBACK_STRIDE = 4096; export const GUEST_CALLBACK_SLOTS = 64; +/** Tail bytes of each callback slot reserved for bridge scratch data (see ole32 CoCreateInstance); code must not reach them. */ +export const GUEST_CALLBACK_SCRATCH_BYTES = 32; /** Match boot.asm FNSAVE/FRSTOR layout: 108-byte state with 128-byte stride. */ export const GUEST_THREAD_FPU_CONTEXTS = 0x0007_8000; export const GUEST_THREAD_FPU_CONTEXT_BYTES = 128; @@ -85,7 +87,8 @@ export const GUEST_THREAD_FPU_CONTEXT_BYTES = 128; // Allocate hwnd sequentially from 0x2000 and index by hwnd-0x2000; out-of-range/unsynchronized entries use full hypercalls. // X/Y hold absolute screen coordinates, accumulated through parents during shim synchronization; guest stubs need no parent traversal. export const GUEST_WINDOW_TABLE = 0x0006_2000; -export const GUEST_WINDOW_TABLE_MAX = 896; +/** Power of two: guest stubs mask the hwnd instead of dividing, and entries carry their owner for collisions. */ +export const GUEST_WINDOW_TABLE_MAX = 512; export const GUEST_WINDOW_ENTRY_BYTES = 64; export const GUEST_WINDOW_X = 0; export const GUEST_WINDOW_Y = 4; @@ -102,6 +105,7 @@ export const GUEST_WINDOW_EXTRA4 = 44; export const GUEST_WINDOW_EXTRA8 = 48; export const GUEST_WINDOW_EXTRA12 = 52; export const GUEST_WINDOW_VALID = 56; +export const GUEST_WINDOW_OWNER = 60; // HWND owning this wrapped entry. export interface PeImport { /** Hypercall request ID; reserve 0 for no request, so IDs start at 1. */ diff --git a/src/vm86/shim/directx.ts b/src/vm86/shim/directx.ts index 7e943a5..4acc7fc 100644 --- a/src/vm86/shim/directx.ts +++ b/src/vm86/shim/directx.ts @@ -1,12 +1,6 @@ import type { PaletteState, SoundBufferState, SurfaceState, Win32Call, Win32Result } from '../win32'; import { DEFAULT_PCM_FORMAT, parsePcmWaveFormatEx, type PcmWaveFormat } from '../audio'; -import { - HYPERCALL_ACTIVE_SHELL_SURFACE, - HYPERCALL_CALLBACK_DEPTH, - makeConstantImportStub, - makeImportStub, - type PeImport, -} from '../pe'; +import { HYPERCALL_ACTIVE_SHELL_SURFACE, makeConstantImportStub, makeImportStub, type PeImport } from '../pe'; import { win32ModuleOf } from './text'; import { withWinmm } from './winmm'; import type { Constructor } from './state'; @@ -114,6 +108,8 @@ const CLIPPER_METHODS: Array<[string, number]> = [ ['SetHWnd', 12], ]; +/** E_OUTOFMEMORY; DirectSound and DirectDraw report allocation failure as DSERR_/DDERR_OUTOFMEMORY, the same value. */ +const OUT_OF_MEMORY = 0x8007_000e; const SOUND_BUFFER_METHODS: Array<[string, number]> = [ ['QueryInterface', 12], ['AddRef', 4], @@ -358,13 +354,13 @@ export function withDirectx>(Base: TBase) if (!a[1]) return { eax: 0x8000_4003 }; // E_POINTER const object = this.createComObject('IDirectDraw', DDRAW_METHODS); this.writeU32(a[1], object); - return { eax: 0 }; // DD_OK + return { eax: object ? 0 : OUT_OF_MEMORY }; // DD_OK } case 'DSOUND.DLL!ord1': { if (!a[1]) return { eax: 0x8000_4003 }; const object = this.createComObject('IDirectSound', DSOUND_METHODS, 'DSOUND.COM'); this.writeU32(a[1], object); - return { eax: 0 }; + return { eax: object ? 0 : OUT_OF_MEMORY }; } default: void name; @@ -429,7 +425,10 @@ export function withDirectx>(Base: TBase) case 'EnumDisplayModes': { const callback = a[4] ?? 0; if (!callback) return { eax: 0x8000_4003 }; - const desc = this.alloc(108, true); + // Each suspended callback keeps its own mode snapshot, including across nested mode changes. + // Slot ownership releases the descriptor with the bridge, without allocating permanent heap data. + const frame = this.reserveGuestCallback(108); // sizeof(DDSURFACEDESC) + const desc = frame.scratchAddress; this.writeSurfaceDesc(desc, { object: 0, width: this.displayWidth, @@ -454,20 +453,17 @@ export function withDirectx>(Base: TBase) code.push(0x68); emit32(value); }; - code.push(0xff, 0x05); - emit32(HYPERCALL_CALLBACK_DEPTH); + code.push(0x55, 0x89, 0xe5); // push ebp; mov ebp,esp push(a[3] ?? 0); push(desc); code.push(0xb8); emit32(callback); code.push(0xff, 0xd0); // callback(&DDSURFACEDESC, context) - code.push(0xff, 0x0d); - emit32(HYPERCALL_CALLBACK_DEPTH); + code.push(0x89, 0xec, 0x5d); // mov esp,ebp; pop ebp accommodates stdcall/cdecl cleanup differences. code.push(0x31, 0xc0); // DD_OK - code.push(0xb9); - emit32(originalReturn); - code.push(0xff, 0xe1); - this.writeU32(call.stack, this.allocateDynamicCode(code)); + this.appendGuestCallbackReturn(code, frame, originalReturn); + this.memory.write_memory(code, frame.trampoline); + this.writeU32(call.stack, frame.trampoline); return { eax: 0 }; } case 'WaitForVerticalBlank': { @@ -514,6 +510,10 @@ export function withDirectx>(Base: TBase) case 'CreateClipper': { if (!a[2]) return { eax: 0x8000_4003 }; const clipper = this.createComObject('IDirectDrawClipper', CLIPPER_METHODS); + if (!clipper) { + this.writeU32(a[2], 0); + return { eax: OUT_OF_MEMORY }; + } this.clipperWindows.set(clipper, 0); this.writeU32(a[2], clipper); return { eax: 0 }; @@ -522,15 +522,15 @@ export function withDirectx>(Base: TBase) if (!a[3]) return { eax: 0x8000_4003 }; const palette = this.createPalette(a[1] ?? 0, a[2] ?? 0); this.writeU32(a[3], palette); - return { eax: 0 }; + return { eax: palette ? 0 : OUT_OF_MEMORY }; } case 'CreateSurface': { const desc = a[1] ?? 0; const out = a[2] ?? 0; if (!desc || !out) return { eax: 0x8000_4003 }; const surface = this.createSurfaceFromDesc(desc); - this.writeU32(out, surface.object); - return { eax: 0 }; + this.writeU32(out, surface?.object ?? 0); + return { eax: surface ? 0 : OUT_OF_MEMORY }; } default: return null; @@ -750,13 +750,17 @@ export function withDirectx>(Base: TBase) return { eax: 0x8878_0064 }; // DSERR_BADFORMAT } const buffer = this.createSoundBuffer(this.readU32(a[1] + 8), format); - this.writeU32(a[2], buffer.object); - return { eax: 0 }; + this.writeU32(a[2], buffer?.object ?? 0); + return { eax: buffer ? 0 : OUT_OF_MEMORY }; } case 'DuplicateSoundBuffer': { const source = this.soundBuffers.get(a[1] ?? 0); if (!source || !a[2]) return { eax: 0x8878_001e }; const duplicate = this.createSoundBuffer(source.size, source.format); + if (!duplicate) { + this.writeU32(a[2], 0); + return { eax: OUT_OF_MEMORY }; + } this.memory.write_memory(this.memory.read_memory(source.data, source.size), duplicate.data); duplicate.position = source.position; duplicate.volume = source.volume; @@ -931,6 +935,7 @@ export function withDirectx>(Base: TBase) let vtable = this.vtables.get(vtableKey); if (!vtable) { vtable = this.alloc(methods.length * 4, true); + if (!vtable) return 0; for (let i = 0; i < methods.length; i++) { const [method, argBytes] = methods[i]!; const id = this.nextDynamicId++; @@ -973,14 +978,27 @@ export function withDirectx>(Base: TBase) this.vtables.set(vtableKey, vtable); } const object = this.alloc(Math.max(8, objectBytes), true); + if (!object) return 0; this.writeU32(object, vtable); this.writeU32(object + 4, 1); return object; } - protected createSoundBuffer(size: number, format: PcmWaveFormat = { ...DEFAULT_PCM_FORMAT }): SoundBufferState { + /** + * Returns null when the shim heap cannot hold the object or its PCM data. Reporting success with a NULL or + * low-memory buffer would make the game write samples over guest address 0 and play nothing. + */ + protected createSoundBuffer( + size: number, + format: PcmWaveFormat = { ...DEFAULT_PCM_FORMAT }, + ): SoundBufferState | null { const safeSize = Math.max(1, Math.min(size || 65_536, 4 * 1024 * 1024)); const object = this.createComObject('IDirectSoundBuffer', SOUND_BUFFER_METHODS, 'DSOUND.COM', 16); + if (!object) return null; const data = this.alloc(safeSize, true); + if (!data) { + this.freeAllocation(object); + return null; + } const buffer: SoundBufferState = { object, data, @@ -1047,8 +1065,10 @@ export function withDirectx>(Base: TBase) view.setUint16(16, format.cbSize, true); this.memory.write_memory(bytes.subarray(0, Math.min(bytes.length, capacity)), pointer); } + /** Returns 0 when the shim heap cannot hold the object; callers must report DDERR_OUTOFMEMORY. */ protected createPalette(caps: number, entriesPtr: number): number { const object = this.createComObject('IDirectDrawPalette', PALETTE_METHODS); + if (!object) return 0; const entries = entriesPtr ? this.memory.read_memory(entriesPtr, 256 * 4).slice() : new Uint8Array(256 * 4); const palette = { object, caps, entries }; this.applyReservedSystemPalette(palette); @@ -1062,7 +1082,8 @@ export function withDirectx>(Base: TBase) if (palette.caps & 0x40) return; palette.entries.set([0, 0, 0, 0], 0); } - protected createSurfaceFromDesc(desc: number): SurfaceState { + /** Returns null when the shim heap cannot hold the surface or its pixels; callers must report DDERR_OUTOFMEMORY. */ + protected createSurfaceFromDesc(desc: number): SurfaceState | null { const flags = this.readU32(desc + 4); const caps = this.readU32(desc + 104); const primary = (caps & 0x200) !== 0; @@ -1073,25 +1094,37 @@ export function withDirectx>(Base: TBase) const width = requestedWidth || this.displayWidth || 800; const height = requestedHeight || this.displayHeight || 600; const surface = this.createSurface(width, height, caps); - if (primary) this.primarySurface = surface.object; + if (!surface) return null; const backBuffers = (flags & 0x20) !== 0 ? this.readU32(desc + 20) : 0; if (backBuffers > 0) { const back = this.createSurface(surface.width, surface.height, 0x4 | 0x40); + // A flip chain without its back buffer would present garbage; release the front surface and fail the call. + if (!back) { + this.releaseComObject(surface.object); + return null; + } surface.attached = back.object; back.attached = surface.object; } + if (primary) this.primarySurface = surface.object; return surface; } - protected createSurface(width: number, height: number, caps: number): SurfaceState { + /** Returns null when the shim heap cannot hold the object or its pixels; never reports a surface at address 0. */ + protected createSurface(width: number, height: number, caps: number): SurfaceState | null { const object = this.createComObject( 'IDirectDrawSurface', SURFACE_METHODS, 'DDRAW.COM', this.gameProfile.directDraw?.guestSurfaceFastPath ? SURFACE_OBJECT_BYTES : 8, ); + if (!object) return null; const bpp = this.displayBpp === 16 ? 16 : 8; const pitch = (width * (bpp >>> 3) + 3) & ~3; const pixels = this.alloc(pitch * height, true); + if (!pixels) { + this.freeAllocation(object); + return null; + } const surface: SurfaceState = { object, width, diff --git a/src/vm86/shim/dplayx.ts b/src/vm86/shim/dplayx.ts index 93f439d..ca7cbff 100644 --- a/src/vm86/shim/dplayx.ts +++ b/src/vm86/shim/dplayx.ts @@ -1,6 +1,6 @@ import type { Win32Call, Win32Result } from '../win32'; import { HYPERCALL_CALLBACK_RESULT } from '../pe'; -import type { Constructor } from './state'; +import type { Constructor, GuestCallbackFrame } from './state'; import type { withDirectx } from './directx'; import { createDefaultDplayTransport } from './dplayTransport'; import type { DplayTransport } from './dplayTransport'; @@ -233,6 +233,8 @@ export function withDplayx>(Base: TBase) protected lastInjectRejectAt = 0; /** Received-message queue consumed by Receive; data lives in the shim heap. */ protected dplayQueue: Array<{ from: number; to: number; data: number; size: number }> = []; + /** Guest staging by callback owner address; an active bridge must retain all its callback arguments. */ + private readonly enumSessionsScratch = new Map(); /** Cached remote sessions for EnumSessions, refreshed by announce heartbeats. */ private dplayRemoteSessions = new Map< string, @@ -280,7 +282,12 @@ export function withDplayx>(Base: TBase) /** * Guest callback bridge using the same trampoline style as WndProc: push each argument group right-to-left, call the guest function, and repeat for multiple players/sessions. Before return, set EAX=returnEax and jump to the original return address. Enumeration APIs return DP_OK independently of callback BOOL results. */ - protected invokeGuestCallbacks(call: Win32Call, callback: number, argSets: number[][], returnEax = 0): void { + protected invokeGuestCallbacks( + call: Win32Call, + callback: number, + argSets: number[][], + returnEax = 0, + ): GuestCallbackFrame | undefined { if (argSets.length === 0) return; const originalReturn = this.readU32(call.stack); const frame = this.reserveGuestCallback(); @@ -306,9 +313,15 @@ export function withDplayx>(Base: TBase) code.push(0x5d); // pop ebp code.push(0xb8); emit32(returnEax); // mov eax, returnEax - this.appendGuestCallbackReturn(code, frame, originalReturn); - this.memory.write_memory(code, trampoline); - this.writeU32(call.stack, trampoline); + try { + this.appendGuestCallbackReturn(code, frame, originalReturn); + this.memory.write_memory(code, trampoline); + this.writeU32(call.stack, trampoline); + return frame; + } catch (error) { + this.cancelGuestCallback(frame); + throw error; + } } /** Convenience wrapper for one callback. */ @@ -1081,6 +1094,14 @@ export function withDplayx>(Base: TBase) } case 'EnumSessions': { // Build session lists from cached host announcements; discard after 30s. + // Guest return tails and ExitThread clear the owner. Enumeration count says nothing about completion: + // another thread or nested callback can enumerate repeatedly while an outer callback is suspended. + // Reusing the slot for an unrelated active bridge can delay collection, but cannot free data early. + for (const [owner, pointers] of this.enumSessionsScratch) { + if (this.readU32(owner) !== 0) continue; + for (const pointer of pointers) this.freeAllocation(pointer); + this.enumSessionsScratch.delete(owner); + } const callback = a[3] ?? 0; const context = a[4] ?? 0; if (!callback) return { eax: 0x8000_4003 }; @@ -1108,42 +1129,55 @@ export function withDplayx>(Base: TBase) const now = this.clock.now(); const callbackFlags = this.gameProfile.directPlay?.enumSessionsCallbackFlags ?? 0; const argSets: number[][] = []; - for (const [instance, s] of this.dplayRemoteSessions) { - if (now - s.lastSeen > DPLAY_DISCOVERY_TTL_MS) { - this.dplayRemoteSessions.delete(instance); - continue; + let scratch: number[] = []; + try { + for (const [instance, s] of this.dplayRemoteSessions) { + if (now - s.lastSeen > DPLAY_DISCOVERY_TTL_MS) { + this.dplayRemoteSessions.delete(instance); + continue; + } + const name = this.alloc(s.nameBytes.length + 1, true); + const desc = this.alloc(80, true); + const timeout = this.alloc(4, true); + scratch.push(name, desc, timeout); + if (!name || !desc || !timeout) return { eax: 0x8007_000e }; // DPERR_OUTOFMEMORY + this.memory.write_memory(s.nameBytes, name); + this.memory.write_memory(new Uint8Array(80), desc); + this.writeU32(desc, 80); // dwSize + this.writeU32(desc + 4, s.sessionFlags); // dwFlags: host session flags. + this.memory.write_memory(guidBytes(instance), desc + 8); // guidInstance + this.memory.write_memory(guidBytes(s.appGuid), desc + 24); // guidApplication: the game filters by this value. + this.writeU32(desc + 40, s.maxPlayers); // dwMaxPlayers + this.writeU32(desc + 44, s.currentPlayers); // dwCurrentPlayers + this.writeU32(desc + 48, name); // lpszSessionNameA + this.writeU32(timeout, a[2] ?? 0); // Return the enumeration timeout supplied by the game. + // LPDPENUMSESSIONSCALLBACK2(DPSESSIONDESC2*, DWORD*, flags, context); + // see game-registered enumSessionsCallbackFlags for values and rationale. + argSets.push([desc, timeout, callbackFlags, context]); + } + if (argSets.length === 0) { + if (DPLAY_VERBOSE_LOG) + console.log(`[dplayx] EnumSessions 返回 0 个会话(缓存 ${this.dplayRemoteSessions.size})`); + return { eax: 0 }; + } + if (DPLAY_VERBOSE_LOG) { + const first = [...this.dplayRemoteSessions.values()][0]; + console.log( + `[dplayx] EnumSessions 返回 ${argSets.length} 个会话` + + `(app=${first?.appGuid}, flags=0x${first?.sessionFlags.toString(16)}, ` + + `cur=${first?.currentPlayers}/max=${first?.maxPlayers})`, + ); + } + const frame = this.invokeGuestCallbacks(call, callback, argSets); + if (frame) { + this.enumSessionsScratch.set(frame.ownerAddress, scratch); + scratch = []; } - const name = this.bytesToGuest(s.nameBytes); - const desc = this.alloc(80, true); - this.memory.write_memory(new Uint8Array(80), desc); - this.writeU32(desc, 80); // dwSize - this.writeU32(desc + 4, s.sessionFlags); // dwFlags: host session flags. - this.memory.write_memory(guidBytes(instance), desc + 8); // guidInstance - this.memory.write_memory(guidBytes(s.appGuid), desc + 24); // guidApplication: the game filters by this value. - this.writeU32(desc + 40, s.maxPlayers); // dwMaxPlayers - this.writeU32(desc + 44, s.currentPlayers); // dwCurrentPlayers - this.writeU32(desc + 48, name); // lpszSessionNameA - const timeout = this.alloc(4, true); - this.writeU32(timeout, a[2] ?? 0); // Return the enumeration timeout supplied by the game. - // LPDPENUMSESSIONSCALLBACK2(DPSESSIONDESC2*, DWORD*, flags, context); - // see game-registered enumSessionsCallbackFlags for values and rationale. - argSets.push([desc, timeout, callbackFlags, context]); - } - if (argSets.length === 0) { - if (DPLAY_VERBOSE_LOG) - console.log(`[dplayx] EnumSessions 返回 0 个会话(缓存 ${this.dplayRemoteSessions.size})`); return { eax: 0 }; + } finally { + // Allocation or bridge-generation failures never hand these buffers to the guest. + for (const pointer of scratch) this.freeAllocation(pointer); } - if (DPLAY_VERBOSE_LOG) { - const first = [...this.dplayRemoteSessions.values()][0]; - console.log( - `[dplayx] EnumSessions 返回 ${argSets.length} 个会话` + - `(app=${first?.appGuid}, flags=0x${first?.sessionFlags.toString(16)}, ` + - `cur=${first?.currentPlayers}/max=${first?.maxPlayers})`, - ); - } - this.invokeGuestCallbacks(call, callback, argSets); - return { eax: 0 }; } case 'InitializeConnection': { // conn=null initializes the default connection; this succeeds in the reference 2001 PC environment with TCP/IP. diff --git a/src/vm86/shim/kernel32.ts b/src/vm86/shim/kernel32.ts index 5509251..c11ddb0 100644 --- a/src/vm86/shim/kernel32.ts +++ b/src/vm86/shim/kernel32.ts @@ -22,6 +22,9 @@ import { GUEST_THREAD_CONTEXT_LAST_ERROR, GUEST_THREAD_CONTEXT_SEH, GUEST_THREAD_CONTEXT_STACK_BOTTOM, + GUEST_THREAD_FPU_CONTEXT_BYTES, + GUEST_THREAD_FPU_CONTEXTS, + GUEST_THREAD_CRITICAL_DEPTH, GUEST_THREAD_CONTEXT_STACK_TOP, GUEST_THREAD_LIMIT, } from '../pe'; @@ -306,9 +309,9 @@ export function withKernel32>(Base: // The save-game screen labels its slots through the locale date/time APIs. a[0] is the LCID, which // the shim ignores because it carries a single (invariant) calendar; a[1] holds the DATE_/TIME_ flags. case 'KERNEL32.DLL!GetDateFormatA': - return { eax: this.getDateFormatA(a[1] ?? 0, a[2] ?? 0, a[3] ?? 0, a[4] ?? 0, a[5] ?? 0) }; + return { eax: this.getDateFormatA(a[1] ?? 0, a[2] ?? 0, a[3] ?? 0, a[4] ?? 0, (a[5] ?? 0) | 0) }; case 'KERNEL32.DLL!GetTimeFormatA': - return { eax: this.getTimeFormatA(a[1] ?? 0, a[2] ?? 0, a[3] ?? 0, a[4] ?? 0, a[5] ?? 0) }; + return { eax: this.getTimeFormatA(a[1] ?? 0, a[2] ?? 0, a[3] ?? 0, a[4] ?? 0, (a[5] ?? 0) | 0) }; case 'KERNEL32.DLL!CreateFileA': return { eax: this.openFile(a) }; case 'KERNEL32.DLL!FindFirstFileA': { @@ -629,7 +632,7 @@ export function withKernel32>(Base: return { eax: 1 }; case 'KERNEL32.DLL!CloseHandle': { const handle = a[0] ?? 0; - if (!this.closeGuestSyncHandle(handle)) this.closeFile(handle); + if (!this.closeGuestThreadHandle(handle) && !this.closeGuestSyncHandle(handle)) this.closeFile(handle); return { eax: 1 }; } case 'KERNEL32.DLL!FlushFileBuffers': { @@ -926,6 +929,43 @@ export function withKernel32>(Base: private readI32From(data: Uint8Array, offset: number): number { return data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24) | 0; } + /** + * Drop the guest's last reference to a thread. The id becomes reusable once the thread has also exited; + * until then its state stays so waits and exit codes on other handles keep working. + */ + protected closeGuestThreadHandle(handle: number): boolean { + const id = this.guestThreadHandles.get(handle); + if (id === undefined) return false; + const thread = this.guestThreads.get(id); + if (!thread) { + this.guestThreadHandles.delete(handle); + return true; + } + // Keep the handle resolvable until the thread is reclaimed: another thread may already be blocked on it, and + // Windows keeps the object alive for that waiter. Handle numbers are never reused, so this cannot alias. + thread.handleClosed = true; + return true; + } + + /** + * Release exited threads' stacks and recycle their ids. Called from CreateThread, never from the exit path + * itself: the exiting thread is still executing on its own stack when ExitThread reaches the shim. + */ + protected reclaimExitedGuestThreads(): void { + const current = this.readU32(HYPERCALL_THREAD_CURRENT); + for (const thread of [...this.guestThreads.values()]) { + if (!thread.terminated || thread.id === current || thread.id === 0) continue; + if (thread.stackBase) { + this.freeAllocation(thread.stackBase); + thread.stackBase = undefined; + } + if (!thread.handleClosed) continue; + this.guestThreads.delete(thread.id); + this.guestThreadHandles.delete(thread.handle); + this.freeThreadIds.push(thread.id); + } + } + protected createGuestThread( stackBytes: number, start: number, @@ -933,17 +973,23 @@ export function withKernel32>(Base: flags: number, tidPtr: number, ): number { - if (!start || this.nextThreadId >= GUEST_THREAD_LIMIT) { + // Reclaim first: without it, a session that churns threads exhausts the 64 ids and leaks every stack. + this.reclaimExitedGuestThreads(); + if (!start || (this.freeThreadIds.length === 0 && this.nextThreadId >= GUEST_THREAD_LIMIT)) { this.lastError = 8; return 0; } - const id = this.nextThreadId++; + const id = this.freeThreadIds.pop() ?? this.nextThreadId++; const handle = this.nextThreadHandle++; if (shimTraceEnabled('VM_TRACE_THREAD')) console.log(`🧵 CreateThread id=${id} 入口=0x${start.toString(16)} 参数=0x${parameter.toString(16)}`); const reserve = Math.max(64 * 1024, Math.min(stackBytes || 64 * 1024, 1024 * 1024)); const base = this.alloc(reserve, true); - if (!base) return 0; + if (!base) { + this.freeThreadIds.push(id); + this.lastError = 8; + return 0; + } if (!this.threadExitStub) { this.threadExitStub = this.registerDynamicWin32Import('KERNEL32.DLL', 'ExitThread', 4); this.threadReturnTrampoline = this.allocateDynamicCode([ @@ -970,12 +1016,20 @@ export function withKernel32>(Base: this.writeU32(GUEST_THREAD_CONTEXT_STACK_BOTTOM + id * 4, base); this.writeU32(GUEST_THREAD_CONTEXT_LAST_ERROR + id * 4, 0); this.zero(FAST_TLS_TABLE + id * FAST_TLS_THREAD_BYTES, FAST_TLS_THREAD_BYTES); + // A reused id must not inherit the previous thread's compat lock depth (its import tails would skip STI and + // starve the scheduler) or its saved x87 state, which boot.asm restores with FRSTOR on the first switch. + this.writeU32(GUEST_THREAD_CRITICAL_DEPTH + id * 4, 0); + const fpu = GUEST_THREAD_FPU_CONTEXTS + id * GUEST_THREAD_FPU_CONTEXT_BYTES; + this.zero(fpu, GUEST_THREAD_FPU_CONTEXT_BYTES); + this.writeU32(fpu, 0x037f); // Default x87 control word. + this.writeU32(fpu + 8, 0xffff); // Tag word marking every register empty. this.guestThreads.set(id, { id, handle, runnable: (flags & 0x4) === 0, terminated: false, wakeAt: 0, + stackBase: base, }); this.writeU32(GUEST_THREAD_RUN_STATES + id * 4, (flags & 0x4) === 0 ? 1 : 0); this.writeU32(HYPERCALL_THREAD_COUNT, this.nextThreadId); @@ -1743,6 +1797,12 @@ export function withKernel32>(Base: let i = 0; while (i < picture.length) { const ch = picture[i]!; + // Keep DBCS pairs intact even when the trail byte is an ASCII format token. + if (ch.charCodeAt(0) >= 0x81 && ch.charCodeAt(0) <= 0xfe) { + out += picture.slice(i, i + 2); + i += 2; + continue; + } if (ch === "'") { if (picture[i + 1] === "'") { out += "'"; // '' escapes a single quote. @@ -1761,11 +1821,7 @@ export function withKernel32>(Base: } if (isToken(ch)) { let run = 1; - while ( - i + run < picture.length && - isToken(picture[i + run]!) && - picture[i + run]!.toLowerCase() === ch.toLowerCase() - ) { + while (picture[i + run] === ch) { run++; } out += field(ch, run); @@ -1820,18 +1876,38 @@ export function withKernel32>(Base: this.lastError = ERROR_INSUFFICIENT_BUFFER; return 0; } - this.writeAscii(buffer, value); + this.memory.write_memory(Uint8Array.from([...Array.from(value, (ch) => ch.charCodeAt(0)), 0]), buffer); this.lastError = 0; return required; } + /** Format pictures are guest bytes, not decoded Unicode; output must retain the guest's code page. */ + private readLocalePicture(pointer: number): string { + const bytes = this.readBytes(pointer, 256); + const end = bytes.indexOf(0); + return String.fromCharCode(...bytes.subarray(0, end < 0 ? bytes.length : end)); + } /** * GetDateFormatA honors the picture string RA2 supplies for its save-slot labels; a NULL format falls back to * the invariant short/long patterns. */ protected getDateFormatA(flags: number, datePtr: number, formatPtr: number, buffer: number, cch: number): number { const date = datePtr ? this.readSystemTimeFields(datePtr) : this.localSystemTimeFields(); + // GetDateFormat ignores the time half of SYSTEMTIME and derives the weekday itself. + const calendarDate = new Date(Date.UTC(date.year, date.month - 1, date.day)); + if ( + date.year < 1601 || + date.year > 30827 || + calendarDate.getUTCFullYear() !== date.year || + calendarDate.getUTCMonth() !== date.month - 1 || + calendarDate.getUTCDate() !== date.day || + cch < 0 + ) { + this.lastError = 87; + return 0; + } + date.weekday = calendarDate.getUTCDay(); const picture = formatPtr - ? this.readCString(formatPtr) + ? this.readLocalePicture(formatPtr) : flags & DATE_LONGDATE ? 'dddd, MMMM d, yyyy' : flags & DATE_YEARMONTH @@ -1839,7 +1915,7 @@ export function withKernel32>(Base: : 'M/d/yyyy'; // DATE_SHORTDATE and dwFlags == 0 share the invariant short pattern. const value = this.expandPicture( picture, - (ch) => 'dmyg'.includes(ch.toLowerCase()), + (ch) => 'dMyg'.includes(ch), (ch, run) => this.dateField(ch, run, date), ); return this.writeLocaleString(buffer, cch, value); @@ -1847,9 +1923,14 @@ export function withKernel32>(Base: /** GetTimeFormatA mirrors GetDateFormatA with the TIME_ flags; the save screen formats date and time together. */ protected getTimeFormatA(flags: number, timePtr: number, formatPtr: number, buffer: number, cch: number): number { const time = timePtr ? this.readSystemTimeFields(timePtr) : this.localSystemTimeFields(); + // GetTimeFormat ignores date fields, which callers may leave uninitialized. + if (time.hour > 23 || time.minute > 59 || time.second > 59 || cch < 0) { + this.lastError = 87; + return 0; + } let picture: string; if (formatPtr) { - picture = this.readCString(formatPtr); + picture = this.readLocalePicture(formatPtr); } else { const twentyFourHour = (flags & TIME_FORCE24HOURFORMAT) !== 0; picture = twentyFourHour ? 'HH' : 'h'; @@ -1886,7 +1967,8 @@ export function withKernel32>(Base: this.lastError = 87; // ERROR_INVALID_PARAMETER return false; } - const date = this.fileTimeToDate(this.readFileTimeValue(fileTime)); + const value = this.readFileTimeValue(fileTime); + const date = value < 0x8000_0000_0000_0000n ? this.fileTimeToDate(value) : null; if (!date) { this.lastError = 87; return false; @@ -1904,21 +1986,21 @@ export function withKernel32>(Base: this.lastError = 0; return true; } - /** FileTimeToLocalFileTime subtracts the host bias (UTC = local + Bias, matching getTimezoneOffset) at that instant. */ + /** Win32 FileTimeToLocalFileTime uses the current timezone bias, matching GetTimeZoneInformation. */ protected fileTimeToLocalFileTime(fileTime: number, localFileTime: number): boolean { if (!fileTime || !localFileTime) { this.lastError = 87; return false; } const value = this.readFileTimeValue(fileTime); - const date = this.fileTimeToDate(value); - if (!date) { + const bias = + BigInt(new Date(this.clock.wallNow()).getTimezoneOffset()) * 60_000n * FILETIME_TICKS_PER_MILLISECOND; + const local = value - bias; + if (local < 0n || local > 0xffff_ffff_ffff_ffffn) { this.lastError = 87; return false; } - // getTimezoneOffset is in minutes, while FILETIME_TICKS_PER_MILLISECOND is per millisecond. - const bias = BigInt(date.getTimezoneOffset()) * 60_000n * FILETIME_TICKS_PER_MILLISECOND; - this.writeFileTimeValue(localFileTime, value - bias); + this.writeFileTimeValue(localFileTime, local); this.lastError = 0; return true; } diff --git a/src/vm86/shim/ole32.ts b/src/vm86/shim/ole32.ts index 4941114..870277b 100644 --- a/src/vm86/shim/ole32.ts +++ b/src/vm86/shim/ole32.ts @@ -891,6 +891,8 @@ export function withOle32>(Base: TBase) { */ private redirectOleSaveToStream(call: Win32Call, persistStream: number, stream: number): void { const originalReturn = this.readU32(call.stack); + // A callback slot rather than bump-allocated code and permanent heap: a campaign save runs this hundreds of + // times, and the slot's tail releases the bridge under CLI so a pending PIT cannot preempt the return path. const frame = this.reserveGuestCallback(); const clsid = frame.trampoline + GUEST_CALLBACK_STRIDE - 32; const written = clsid + 16; @@ -1067,8 +1069,11 @@ export function withOle32>(Base: TBase) { riid: number, ppv: number, ): void { - const factory = this.alloc(4, true); - const iidClassFactory = this.alloc(16, true); + // Reuse a callback slot for both code and scratch data instead of leaking stub and heap space per call. + const frame = this.reserveGuestCallback(); + const factory = frame.trampoline + GUEST_CALLBACK_STRIDE - 32; + const iidClassFactory = factory + 4; + this.writeU32(factory, 0); // IID_IClassFactory = {00000001-0000-0000-C000-000000000046} in memory byte order. this.memory.write_memory([1, 0, 0, 0, 0, 0, 0, 0, 0xc0, 0, 0, 0, 0, 0, 0, 0x46], iidClassFactory); const originalReturn = this.readU32(call.stack); @@ -1101,9 +1106,7 @@ export function withOle32>(Base: TBase) { code.push(0x51, 0x8b, 0x11, 0xff, 0x52, 0x08); // factory->Release code.push(0x58); // Restore CreateInstance HRESULT. const finish = code.length; - code.push(0xb9); - emit32(originalReturn); - code.push(0xff, 0xe1); + this.appendGuestCallbackReturn(code, frame, originalReturn); for (const patch of [failedPatch, emptyPatch]) { const relative = finish - (patch + 4); code[patch] = relative & 0xff; @@ -1111,7 +1114,8 @@ export function withOle32>(Base: TBase) { code[patch + 2] = (relative >>> 16) & 0xff; code[patch + 3] = relative >>> 24; } - this.writeU32(call.stack, this.allocateDynamicCode(code)); + this.memory.write_memory(code, frame.trampoline); + this.writeU32(call.stack, frame.trampoline); } /** diff --git a/src/vm86/shim/state.ts b/src/vm86/shim/state.ts index f5009a9..1f26ff0 100644 --- a/src/vm86/shim/state.ts +++ b/src/vm86/shim/state.ts @@ -16,6 +16,7 @@ import { GUEST_CALLBACK_BASE, GUEST_CALLBACK_STRIDE, GUEST_CALLBACK_SLOTS, + GUEST_CALLBACK_SCRATCH_BYTES, GUEST_CALLBACK_OWNERS, HYPERCALL_CALLBACK_DEPTH, GUEST_THREAD_FPU_CONTEXTS, @@ -36,6 +37,7 @@ import { GUEST_WINDOW_TABLE, GUEST_WINDOW_TABLE_MAX, GUEST_WINDOW_USERDATA, + GUEST_WINDOW_OWNER, GUEST_WINDOW_VALID, GUEST_WINDOW_WIDTH, GUEST_WINDOW_WNDPROC, @@ -59,6 +61,8 @@ import { type RegistryDefaultValue, } from './gameProfile'; +/** Dynamic guest stubs grow upward from here; the region ends at 0x200000. */ +export const DYNAMIC_STUB_BASE = 0x000c_0000; /** Generic constructor for the mixin chain, producing instance type T. */ export type Constructor = new (...args: any[]) => T; @@ -96,6 +100,8 @@ export interface GuestCallbackFrame { depth: number; trampoline: number; ownerAddress: number; + /** Reserved data at the slot tail; generated instructions must stop before this address. */ + scratchAddress: number; } export interface GuestThreadState { @@ -104,6 +110,10 @@ export interface GuestThreadState { runnable: boolean; terminated: boolean; wakeAt: number; + /** Heap allocation backing this thread's stack; released once the thread has exited and stopped running. */ + stackBase?: number; + /** CloseHandle has been called, so the guest can no longer observe this thread and its id may be reused. */ + handleClosed?: boolean; wait?: GuestWaitState; criticalSection?: number; /** Wait result completed before saving the shared context, consumed by the host-delay return path. */ @@ -183,6 +193,8 @@ export class ShimState { protected nextThreadHandle = 0x0001_1000; protected readonly guestThreads = new Map(); protected readonly guestThreadHandles = new Map(); + /** Ids of exited threads whose handles are closed; reused so long sessions do not exhaust GUEST_THREAD_LIMIT. */ + protected readonly freeThreadIds: number[] = []; protected threadExitStub = 0; protected threadReturnTrampoline = 0; protected readonly commandLine = 0x0006_1000; @@ -191,7 +203,7 @@ export class ShimState { protected readonly environmentW = 0x0006_1300; /** Dynamic import IDs and stub allocator; stateGuestDll owns registration. */ protected nextDynamicId: number; - protected nextDynamicStub = 0x000c_0000; + protected nextDynamicStub = DYNAMIC_STUB_BASE; protected readonly windowClasses = new Map(); protected readonly windows = new Map(); protected readonly windowClassNames = new Map(); @@ -530,17 +542,27 @@ export class ShimState { protected disposeGameNetwork(): void {} /** Reserve slots during host generation; other threads or not-yet-started bridges cannot reuse them. */ - protected reserveGuestCallback(): GuestCallbackFrame { + protected reserveGuestCallback(scratchBytes = GUEST_CALLBACK_SCRATCH_BYTES): GuestCallbackFrame { + if (!Number.isInteger(scratchBytes) || scratchBytes < 0 || scratchBytes >= GUEST_CALLBACK_STRIDE) { + throw new Error(`Invalid guest callback scratch size: ${scratchBytes}`); + } for (let depth = 0; depth < GUEST_CALLBACK_SLOTS; depth++) { const ownerAddress = GUEST_CALLBACK_OWNERS + depth * 4; if (this.readU32(ownerAddress) !== 0) continue; this.writeU32(ownerAddress, this.readU32(HYPERCALL_THREAD_CURRENT) + 1); this.writeU32(HYPERCALL_CALLBACK_DEPTH, this.readU32(HYPERCALL_CALLBACK_DEPTH) + 1); - return { depth, trampoline: GUEST_CALLBACK_BASE + depth * GUEST_CALLBACK_STRIDE, ownerAddress }; + const trampoline = GUEST_CALLBACK_BASE + depth * GUEST_CALLBACK_STRIDE; + return { depth, trampoline, ownerAddress, scratchAddress: trampoline + GUEST_CALLBACK_STRIDE - scratchBytes }; } throw new Error(`客体回调槽耗尽(${GUEST_CALLBACK_SLOTS} 个活动回调)`); } + /** Cancel a reserved bridge when generation fails before its address is handed to the guest. */ + protected cancelGuestCallback(frame: GuestCallbackFrame): void { + this.writeU32(frame.ownerAddress, 0); + this.writeU32(HYPERCALL_CALLBACK_DEPTH, this.readU32(HYPERCALL_CALLBACK_DEPTH) - 1); + } + protected releaseExitedThreadCallbacks(threadId: number): void { let released = 0; for (let slot = 0; slot < GUEST_CALLBACK_SLOTS; slot++) { @@ -571,7 +593,10 @@ export class ShimState { emit32(GUEST_THREAD_CRITICAL_DEPTH); code.push(0); code.push(0x75, 0x01, 0xfb, 0xc3); // jne ret; sti; ret, with STI's interrupt shadow covering RET. - if (code.length > GUEST_CALLBACK_STRIDE) throw new Error(`客体回调桥超出槽位: ${code.length}`); + // Reject before the scratch tail, not at the slot end: bridges such as CoCreateInstance keep their data there. + if (code.length > frame.scratchAddress - frame.trampoline) { + throw new Error(`客体回调桥超出槽位: ${code.length}`); + } } /** Generate dynamic guest stubs on the host, shared by file, DLL, synchronization, and graphics mixins. */ @@ -727,12 +752,27 @@ export class ShimState { } /** - * Mirror shim window geometry/properties into GUEST_WINDOW_TABLE for guest fast stubs. Synchronize after every state mutation: create, move, SetWindowLong, destroy, or dialog-item changes, otherwise guest reads become stale. Ignore out-of-range hwnd values because stubs fall back to full hypercalls. Store absolute X/Y by accumulating parent-relative offsets so stubs need no parent traversal. + * HWND values are never reused: RA2 keeps stale handles (page changes then repaint through them) and reuse made + * new dialogs inherit a destroyed window's messages, leaving the menu blank. The mirror table wraps instead. + */ + protected allocateWindowHandle(): number { + return this.nextWindow++; + } + + /** + * Mirror window properties after each mutation so guest fast stubs observe current state. Store absolute + * coordinates by accumulating parent offsets; colliding handles fall back to hypercalls after owner validation. */ protected syncWindowToGuest(hwnd: number): void { - const index = hwnd - 0x2000; - if (index < 0 || index >= GUEST_WINDOW_TABLE_MAX) return; + if (hwnd < 0x2000) return; + // Wrap instead of giving up past the end: a long session creates far more than GUEST_WINDOW_TABLE_MAX windows, + // and without wrapping every later window permanently loses its fast stubs. Colliding hwnds differ by the table + // size, so the owner field below tells the stub whether this entry is really its window. + const index = (hwnd - 0x2000) & (GUEST_WINDOW_TABLE_MAX - 1); const base = GUEST_WINDOW_TABLE + index * GUEST_WINDOW_ENTRY_BYTES; + // A destroyed window must not clear an entry a colliding live window has since claimed, or that window would + // lose its fast stubs until its next state change. Live windows still take the slot over. + if (!this.windows.has(hwnd) && this.readU32(base + GUEST_WINDOW_OWNER) !== hwnd) return; let absX = 0; let absY = 0; { @@ -763,7 +803,18 @@ export class ShimState { this.writeU32(base + GUEST_WINDOW_EXTRA4, (this.windowLongs.get(`${hwnd}:4`) ?? 0) >>> 0); this.writeU32(base + GUEST_WINDOW_EXTRA8, (this.windowLongs.get(`${hwnd}:8`) ?? 0) >>> 0); this.writeU32(base + GUEST_WINDOW_EXTRA12, (this.windowLongs.get(`${hwnd}:12`) ?? 0) >>> 0); - this.writeU32(base + GUEST_WINDOW_VALID, this.windows.has(hwnd) ? 1 : 0); + this.writeU32(base + GUEST_WINDOW_OWNER, hwnd >>> 0); + const live = this.windows.has(hwnd); + this.writeU32(base + GUEST_WINDOW_VALID, live ? 1 : 0); + // The slot is free again: hand it to a live window that wraps onto it, otherwise a long-lived window evicted + // by a newer one (the main window is evicted every GUEST_WINDOW_TABLE_MAX windows) would stay on hypercalls. + if (!live) { + for (const candidate of this.windows.keys()) { + if (candidate === hwnd || ((candidate - 0x2000) & (GUEST_WINDOW_TABLE_MAX - 1)) !== index) continue; + this.syncWindowToGuest(candidate); + break; + } + } } /** diff --git a/src/vm86/shim/stateGraphics.ts b/src/vm86/shim/stateGraphics.ts index fc653a1..ebe3762 100644 --- a/src/vm86/shim/stateGraphics.ts +++ b/src/vm86/shim/stateGraphics.ts @@ -12,7 +12,7 @@ import type { } from '../win32'; import { HYPERCALL_CURSOR_COUNT } from '../pe'; import { RGB565_TO_RGBA32 as rgb565Colors } from '../pixels'; -import { shimTraceEnabled, type Constructor } from './state'; +import { DYNAMIC_STUB_BASE, shimTraceEnabled, type Constructor } from './state'; import type { ShimSyncChain } from './stateSync'; // Bind shared tables locally so development/test ESM live-binding getters stay out of pixel loops. @@ -29,6 +29,15 @@ export function withShimGraphics>(Base: protected readonly soundBuffers = new Map(); protected readonly gdiDcs = new Map(); protected readonly gdiFonts = new Map(); + + /** Live object counts for development diagnostics of long sessions; read-only. */ + inspectResourceCounts(): { soundBuffers: number; surfaces: number; dynamicStubBytes: number } { + return { + soundBuffers: this.soundBuffers.size, + surfaces: this.surfaces.size, + dynamicStubBytes: this.nextDynamicStub - DYNAMIC_STUB_BASE, + }; + } /** HBRUSH to COLORREF; null denotes NULL/HOLLOW_BRUSH. */ protected readonly gdiBrushes = new Map(); protected readonly gdiStockObjects = new Set(); diff --git a/src/vm86/shim/user32.ts b/src/vm86/shim/user32.ts index 2d4b26c..aa27aaf 100644 --- a/src/vm86/shim/user32.ts +++ b/src/vm86/shim/user32.ts @@ -294,7 +294,7 @@ export function withUser32>(Base: TBase) { // Modeless dialogs created by CreateDialogIndirectParam are HWNDs too. // RA2 parses main-menu controls itself and initializes them through later message pumping; // retain the dialog procedure so DispatchMessageA can enter the native dialog proc. - const hwnd = this.nextWindow++; + const hwnd = this.allocateWindowHandle(); const callback = a[3] ?? 0; this.windows.set(hwnd, callback); this.placeWindow(hwnd, 0); @@ -310,7 +310,7 @@ export function withUser32>(Base: TBase) { const resource = this.findPeResource(a[0] ?? 0, a[1] ?? 0, 5); // RT_DIALOG if (!resource) return { eax: 0 }; this.loadedResources.set(resource.handle, resource); - const hwnd = this.nextWindow++; + const hwnd = this.allocateWindowHandle(); const callback = a[3] ?? 0; this.windows.set(hwnd, callback); this.placeWindow(hwnd, 0); diff --git a/src/vm86/shim/user32Windowing.ts b/src/vm86/shim/user32Windowing.ts index 9c6b17e..b29e17c 100644 --- a/src/vm86/shim/user32Windowing.ts +++ b/src/vm86/shim/user32Windowing.ts @@ -39,7 +39,7 @@ export function withUser32Windowing>(Base: protected createWindow(call: Win32Call): number { const classPtr = call.args[1] ?? 0; const className = classPtr > 0xffff ? this.readCString(classPtr).toLowerCase() : ''; - const hwnd = this.nextWindow++; + const hwnd = this.allocateWindowHandle(); const callback = this.windowClasses.get(className) ?? 0; this.windows.set(hwnd, callback); this.placeWindow(hwnd, 0); @@ -111,7 +111,7 @@ export function withUser32Windowing>(Base: protected createMciWindow(call: Win32Call): number { // MCIWndCreateA uses cdecl: the IAT stub must not pop arguments, but all four still follow the return address. const parent = this.readU32(call.stack + 4) || this.primaryWindow; - const hwnd = this.nextWindow++; + const hwnd = this.allocateWindowHandle(); this.mciWindows.set(hwnd, { parent, playing: false }); return hwnd; } @@ -532,7 +532,7 @@ export function withUser32Windowing>(Base: cursor = item + 2 + extra; const key = `${parent}:${id}`; if (this.dialogChildren.has(key)) continue; - const hwnd = this.nextWindow++; + const hwnd = this.allocateWindowHandle(); this.dialogChildren.set(key, hwnd); this.windows.set(hwnd, 0); this.placeWindow(hwnd, 0); diff --git a/src/vm86/win32.ts b/src/vm86/win32.ts index 6eed962..381a542 100644 --- a/src/vm86/win32.ts +++ b/src/vm86/win32.ts @@ -26,6 +26,7 @@ import { GUEST_WINDOW_ID, GUEST_WINDOW_PARENT, GUEST_WINDOW_STYLE, + GUEST_WINDOW_OWNER, GUEST_WINDOW_TABLE, GUEST_WINDOW_TABLE_MAX, GUEST_WINDOW_USERDATA, @@ -958,13 +959,11 @@ function emitWindowEntryPreamble(code: number[]): number[] { }; const patches: number[] = []; code.push(0x8b, 0x4c, 0x24, 0x04); // mov ecx, [esp + 4](hwnd) + code.push(0x8b, 0xc1); // mov eax, ecx(保留 hwnd 供属主校验) code.push(0x81, 0xe9); emit32(0x2000); // sub ecx, 0x2000 - code.push(0x81, 0xf9); - emit32(GUEST_WINDOW_TABLE_MAX); // cmp ecx, MAX - code.push(0x0f, 0x83); - patches.push(code.length); - emit32(0); // jae fallback + code.push(0x81, 0xe1); + emit32(GUEST_WINDOW_TABLE_MAX - 1); // and ecx, MAX-1(表按 2 的幂环绕) code.push(0xc1, 0xe1, 0x06); // shl ecx, 6(×GUEST_WINDOW_ENTRY_BYTES=64) code.push(0x81, 0xc1); emit32(GUEST_WINDOW_TABLE); // add ecx, TABLE @@ -972,6 +971,10 @@ function emitWindowEntryPreamble(code: number[]): number[] { code.push(0x0f, 0x84); patches.push(code.length); emit32(0); // je fallback + code.push(0x39, 0x41, GUEST_WINDOW_OWNER); // cmp [ecx + OWNER], eax + code.push(0x0f, 0x85); + patches.push(code.length); + emit32(0); // jne fallback(环绕碰撞时回退到完整 hypercall) return patches; } diff --git a/tests/basic/audio.test.ts b/tests/basic/audio.test.ts index aa5970c..a7f5110 100644 --- a/tests/basic/audio.test.ts +++ b/tests/basic/audio.test.ts @@ -491,3 +491,42 @@ describe('DirectSound 流式音乐(RA2 增补,原 audioSmoke)', () => { } }); }); + +describe('PCM 流 worklet 处理器生命周期', () => { + /** Load the worklet module with AudioWorklet globals stubbed, returning the registered processor class. */ + async function loadProcessor(): Promise< + new () => { + port: { onmessage: ((event: { data: unknown }) => void) | null; postMessage: (message: unknown) => void }; + process: (inputs: unknown, outputs: Float32Array[][]) => boolean; + } + > { + const globals = globalThis as unknown as Record; + let registered: unknown = null; + globals.AudioWorkletProcessor = class { + readonly port = { onmessage: null as ((event: { data: unknown }) => void) | null, postMessage: () => {} }; + }; + globals.registerProcessor = (_name: string, processor: unknown) => { + registered = processor; + }; + globals.sampleRate = 48_000; + globals.currentTime = 0; + // Plain JS worklet source with no declarations; it is loaded for its registerProcessor side effect only. + // @ts-expect-error -- untyped module + await import('../../src/adapter/pcmStreamWorklet.js'); + return registered as never; + } + + it('destroy 后 process 返回 false,浏览器才能回收已断开的节点', async () => { + const Processor = await loadProcessor(); + const processor = new Processor(); + const outputs = [[new Float32Array(128)]]; + processor.port.onmessage?.({ + data: { kind: 'create', channels: 1, frames: 4, frequency: 48_000, loop: true, frame: 0 }, + }); + // A live stream keeps rendering; only destroy ends processing. Returning true after destroy leaks the node's + // per-quantum work onto the audio thread for the whole session, which silences the game over a long match. + expect(processor.process(null, outputs)).toBe(true); + processor.port.onmessage?.({ data: { kind: 'destroy' } }); + expect(processor.process(null, outputs)).toBe(false); + }); +}); diff --git a/tests/basic/browser/audioLifecycleBrowserSmoke.mts b/tests/basic/browser/audioLifecycleBrowserSmoke.mts new file mode 100644 index 0000000..6076024 --- /dev/null +++ b/tests/basic/browser/audioLifecycleBrowserSmoke.mts @@ -0,0 +1,82 @@ +/** Exercise real audio-thread messages and repeated sink/context lifetimes without game resources. */ +import assert from 'node:assert/strict'; +import { chromium } from '@playwright/test'; +import { preventThirdPartyDownloads } from '../../helpers/offlineBrowser'; + +const browser = await chromium.launch({ args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +try { + const page = await browser.newPage({ ignoreHTTPSErrors: true }); + await preventThirdPartyDownloads(page); + await page.goto(process.env.RA2_BROWSER_ORIGIN ?? 'https://127.0.0.1:15174/'); + // Observe production nodes and messages; do not replace the processor, audio clock or destruction behavior. + const result = await page.evaluate<{ cycles: number; contexts: string[]; processorCounts: number[] }>(`(async () => { + const { WebAudioPcmSink } = await import('/src/adapter/audio.ts'); + const OriginalNode = AudioWorkletNode; + const errors = [], contexts = [], processorCounts = []; + let latest = null, created = 0, cycles = 0; + globalThis.AudioWorkletNode = class extends OriginalNode { + constructor(...args) { + super(...args); + const entry = { count: null, messages: 0, detach: null }; + const observe = event => { + if(event.data.kind === 'position') { + entry.count = event.data.live; + entry.messages++; + } + }; + this.port.addEventListener('message', observe); + this.port.start(); + entry.detach = () => this.port.removeEventListener('message', observe); + latest = entry; + created++; + } + }; + const wait = async condition => { + const deadline = performance.now() + 5000; + while(!condition()) { + if(errors.length) throw new Error(errors.join('; ')); + if(performance.now() > deadline) throw new Error('Audio lifecycle observation timed out'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + }; + try { + // A fresh context must register its own processor after the previous session has closed. + for(let session = 0; session < 2; session++) { + const context = new AudioContext(); + const sink = new WebAudioPcmSink({ contextFactory: () => context, onError: e => errors.push(String(e)) }); + try { + if(!await sink.unlock()) throw new Error('AudioContext did not enter running state'); + for(let cycle = 0; cycle < 16; cycle++) { + const before = created; + sink.createBuffer('music', 88200); + sink.writeBuffer('music', 0, new Uint8Array(88200)); + if(!sink.play('music', {loop: true})) throw new Error('PCM playback failed'); + sink.writeBuffer('music', 1024, new Uint8Array(2048)); + await wait(() => created === before + 1 && latest.messages > 0 && latest.count === 1); + processorCounts.push(latest.count); + if(!sink.getState('music')?.playing) throw new Error('Stream stopped during playback'); + if(cycle % 2 === 0) sink.stop('music'); + if(!sink.releaseBuffer('music') || sink.getState('music') !== null) throw new Error('Buffer was retained'); + latest.detach(); + cycles++; + } + } finally { + latest?.detach(); + await sink.destroy(); + } + contexts.push(context.state); + if(context.state !== 'closed') throw new Error('Sink retained its AudioContext'); + } + if(errors.length) throw new Error(errors.join('; ')); + return { cycles, contexts, processorCounts }; + } finally { + globalThis.AudioWorkletNode = OriginalNode; + } + })()`); + assert.equal(result.cycles, 32); + assert.deepEqual(result.contexts, ['closed', 'closed']); + assert.deepEqual(result.processorCounts, new Array(32).fill(1)); + console.log(result); +} finally { + await browser.close(); +} diff --git a/tests/basic/guestCodeAllocation.test.ts b/tests/basic/guestCodeAllocation.test.ts index 8d08562..b89c4c0 100644 --- a/tests/basic/guestCodeAllocation.test.ts +++ b/tests/basic/guestCodeAllocation.test.ts @@ -10,6 +10,7 @@ import { HYPERCALL_THREAD_CURRENT, } from '../../src/vm86/pe'; import { callShim, createGuestMemory, readU32, writeAsciiZ, writeU32 } from '../helpers/guestMemory'; +import type { GuestCallbackFrame } from '../../src/vm86/shim/state'; class AllocationShim extends Win32Shim { allocateCode(bytes: Uint8Array): number { @@ -18,8 +19,14 @@ class AllocationShim extends Win32Shim { createCom(): number { return this.createComObject('ITest', [['Invoke', 4]]); } - reserveCallback() { - return this.reserveGuestCallback(); + reserveCallback(scratchBytes?: number) { + return this.reserveGuestCallback(scratchBytes); + } + publishCallback(frame: GuestCallbackFrame, body: number[]) { + const code = body.slice(); + this.appendGuestCallbackReturn(code, frame, 0x401000); + this.memory.write_memory(code, frame.trampoline); + return code.length; } enumerate(stack: number): void { this.invokeGuestCallbacks( @@ -49,6 +56,27 @@ function fixture() { } describe('动态客体代码内存边界', () => { + it.each([-1, GUEST_CALLBACK_STRIDE, 1.5, NaN])('rejects invalid scratch size %s before reserving a slot', (size) => { + const { memory, shim } = fixture(); + expect(() => shim.reserveCallback(size)).toThrow(/scratch size/); + expect(readU32(memory, GUEST_CALLBACK_OWNERS)).toBe(0); + expect(readU32(memory, HYPERCALL_CALLBACK_DEPTH)).toBe(0); + }); + + it('rejects callback code before it can overwrite its variable-size scratch tail', () => { + const { memory, shim } = fixture(); + const frame = shim.reserveCallback(108); + const scratch = new Uint8Array(108).fill(0xa5); + memory.write_memory(scratch, frame.scratchAddress); + const tailBytes = shim.publishCallback(frame, []); + const body = new Array(frame.scratchAddress - frame.trampoline - tailBytes).fill(0x90); + expect(shim.publishCallback(frame, body)).toBe(GUEST_CALLBACK_STRIDE - scratch.length); + expect(memory.read_memory(frame.scratchAddress, scratch.length)).toEqual(scratch); + const before = memory.read_memory(frame.trampoline, GUEST_CALLBACK_STRIDE).slice(); + expect(() => shim.publishCallback(frame, [...body, 0x90])).toThrow(/回调桥超出槽位/); + expect(memory.read_memory(frame.trampoline, GUEST_CALLBACK_STRIDE)).toEqual(before); + }); + it.each([0x30000, 0x2fff0])('分配到固件边界 %i 后,COM 桩跳过整个 BIOS', (size) => { const { memory, shim } = fixture(); const firmware = new Uint8Array(0x10000).fill(0xa5); @@ -152,6 +180,35 @@ describe('动态客体代码内存边界', () => { expect(readU32(memory, lock + 8)).toBe(1); }); + it('长局反复 CoCreateInstance 已注册类复用回调槽,不消耗动态 stub 区', () => { + const { memory, shim } = fixture(); + const clsid = 0x3000, + iid = 0x3010, + factory = 0x3100, + cookie = 0x3020, + ppv = 0x3024, + stack = 0x3200; + memory.write_memory(new Uint8Array(16).fill(0x11), clsid); + memory.write_memory(new Uint8Array(16).fill(0x22), iid); + expect(callShim(shim, 'OLE32.DLL!CoRegisterClassObject', [clsid, factory, 4, 1, cookie]).eax).toBe(0); + // Leave only 16 bytes of dynamic stub space: any per-call stub allocation would throw. + shim.allocateCode(new Uint8Array(0x30000)); + shim.allocateCode(new Uint8Array(0xffff0)); + let first = 0; + for (let i = 0; i < 50_000; i++) { + writeU32(memory, stack, 0x401000); + expect(callShim(shim, 'OLE32.DLL!CoCreateInstance', [clsid, 0, 1, iid, ppv], stack).eax).toBe(0); + const bridge = readU32(memory, stack); + if (i === 0) first = bridge; + expect(bridge).toBe(first); + expect(bridge).toBeGreaterThanOrEqual(GUEST_CALLBACK_BASE); + // Simulate the guest tail releasing the slot. + writeU32(memory, GUEST_CALLBACK_OWNERS, 0); + writeU32(memory, HYPERCALL_CALLBACK_DEPTH, 0); + } + expect(shim.allocateCode(new Uint8Array(16))).toBe(0x1ffff0); + }); + it('线程在嵌套回调中退出时只回收自己的槽', () => { const { memory, shim } = fixture(); callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x401000, 0, 0, 0]); diff --git a/tests/basic/shimDplayEnumeration.test.ts b/tests/basic/shimDplayEnumeration.test.ts new file mode 100644 index 0000000..a096316 --- /dev/null +++ b/tests/basic/shimDplayEnumeration.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { Win32Shim } from '../../src/games/win32Shim'; +import type { Win32Call } from '../../src/vm86/win32'; +import type { DplayTransportHandlers } from '../../src/vm86/shim/dplayTransport'; +import { + GUEST_CALLBACK_BASE, + GUEST_CALLBACK_OWNERS, + GUEST_CALLBACK_STRIDE, + HYPERCALL_CALLBACK_DEPTH, + HYPERCALL_THREAD_CURRENT, +} from '../../src/vm86/pe'; +import { callShim, createGuestMemory, readU32, writeU32 } from '../helpers/guestMemory'; + +class EnumerationShim extends Win32Shim { + callbackArguments: number[][] = []; + startDplay(): number { + return this.createDirectPlay(); + } + protected override invokeGuestCallbacks(call: Win32Call, callback: number, sets: number[][], result = 0) { + this.callbackArguments = sets; + return super.invokeGuestCallbacks(call, callback, sets, result); + } +} + +function fixture() { + const memory = createGuestMemory(); + let transport!: DplayTransportHandlers; + const shim = new EnumerationShim(memory, { + heapTop: 0xc00000, + virtualTop: 0xc00000, + dplayTransportFactory: (handlers) => { + transport = handlers; + return { clientId: 'enumeration-test', send: () => true, close: () => {} }; + }, + }); + const object = shim.startDplay(); + callShim(shim, 'DPLAYX.COM!IDirectPlay3.InitializeConnection', [object, 0, 0]); + const instance = '11111111-1111-1111-1111-111111111111'; + function announce(players: number, session = instance) { + transport.onMessage({ + t: 'announce', + i: session, + n: Uint8Array.from([65]), + m: 8, + c: players, + g: '22222222-2222-2222-2222-222222222222', + f: 0, + }); + } + function enumerate() { + const stack = 0x3000; + writeU32(memory, stack, 0x401000); + const result = callShim(shim, 'DPLAYX.COM!IDirectPlay3.EnumSessions', [object, 0, 0, 0x402000, 0, 0], stack); + const bridge = readU32(memory, stack); + const owner = GUEST_CALLBACK_OWNERS + ((bridge - GUEST_CALLBACK_BASE) / GUEST_CALLBACK_STRIDE) * 4; + return { result, owner, descriptor: shim.callbackArguments[0]?.[0] ?? 0 }; + } + function finish(owner: number) { + // Dispatch-level tests model the release performed by the real guest bridge tail. + writeU32(memory, owner, 0); + writeU32(memory, HYPERCALL_CALLBACK_DEPTH, readU32(memory, HYPERCALL_CALLBACK_DEPTH) - 1); + } + return { + memory, + shim, + announce, + enumerate, + finish, + closeRoom: () => transport.onMessage({ t: 'sclose', i: instance }), + }; +} + +describe('DirectPlay enumeration callback storage', () => { + it('releases staging and the reserved slot when the callback bridge is too large', () => { + const { memory, shim, announce, enumerate } = fixture(); + const baseline = shim.inspectHeapState().liveBytes; + for (let i = 0; i < 200; i++) announce(1, `${i.toString(16).padStart(8, '0')}-1111-1111-1111-111111111111`); + expect(() => enumerate()).toThrow(/超出槽位/); + expect(shim.inspectHeapState().liveBytes).toBe(baseline); + expect(readU32(memory, HYPERCALL_CALLBACK_DEPTH)).toBe(0); + expect(readU32(memory, GUEST_CALLBACK_OWNERS)).toBe(0); + expect(readU32(memory, 0x3000)).toBe(0x401000); + }); + + it('rolls back partial allocation failure without writing low guest memory', () => { + const { memory, shim, announce, enumerate } = fixture(); + const heap = 0x10001; + const spare = callShim(shim, 'KERNEL32.DLL!HeapAlloc', [heap, 0, 16]).eax; + expect(spare).toBeGreaterThan(0); + while (callShim(shim, 'KERNEL32.DLL!HeapAlloc', [heap, 0, 0x10000]).eax); + while (callShim(shim, 'KERNEL32.DLL!HeapAlloc', [heap, 0, 16]).eax); + // Leave space for a name but not its descriptor; failure must return the name allocation too. + expect(callShim(shim, 'KERNEL32.DLL!HeapFree', [heap, 0, spare]).eax).toBe(1); + const baseline = shim.inspectHeapState().liveBytes; + const lowMemory = memory.read_memory(0, 128).slice(); + announce(1); + expect(enumerate().result.eax).toBe(0x8007000e); + expect(shim.inspectHeapState().liveBytes).toBe(baseline); + expect(memory.read_memory(0, 128)).toEqual(lowMemory); + expect(readU32(memory, HYPERCALL_CALLBACK_DEPTH)).toBe(0); + }); + + it('preserves suspended outer callbacks across nested enumerations', () => { + const { memory, announce, enumerate } = fixture(); + announce(1); + const outer = enumerate(); + const before = memory.read_memory(outer.descriptor, 80).slice(); + announce(2); + enumerate(); + announce(3); + enumerate(); + expect(readU32(memory, outer.owner)).not.toBe(0); + expect(memory.read_memory(outer.descriptor, 80)).toEqual(before); + }); + + it('reclaims completed callbacks out of order while retaining an older active callback', () => { + const { memory, shim, announce, enumerate, finish, closeRoom } = fixture(); + const baseline = shim.inspectHeapState().liveBytes; + announce(1); + const outer = enumerate(); + const retained = shim.inspectHeapState().liveBytes; + for (let i = 0; i < 100; i++) { + announce(2); + const inner = enumerate(); + expect(readU32(memory, outer.descriptor + 44)).toBe(1); + finish(inner.owner); + } + closeRoom(); + enumerate(); + expect(shim.inspectHeapState().liveBytes).toBe(retained); + finish(outer.owner); + enumerate(); + expect(shim.inspectHeapState().liveBytes).toBe(baseline); + }); + + it('reclaims an exited thread callback without releasing another thread data', () => { + const { memory, shim, announce, enumerate, finish, closeRoom } = fixture(); + const baseline = shim.inspectHeapState().liveBytes; + announce(1); + const outer = enumerate(); + const outerBytes = shim.inspectHeapState().liveBytes - baseline; + const handle = callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x401000, 0, 0, 0]).eax; + const thread = shim.inspectGuestThreads().find((value) => value.handle === handle)!; + writeU32(memory, HYPERCALL_THREAD_CURRENT, thread.id); + announce(2); + enumerate(); + const imported = { + id: 1, + dll: 'KERNEL32.DLL', + name: 'ExitThread', + key: 'KERNEL32.DLL!ExitThread', + stub: 0, + slot: 0, + argBytes: 4, + }; + shim.prepareGuestThreadReturn({ imported, args: [0] }, callShim(shim, imported.key, [0])); + writeU32(memory, HYPERCALL_THREAD_CURRENT, 0); + const beforeCleanup = shim.inspectHeapState().liveBytes; + closeRoom(); + enumerate(); + expect(shim.inspectHeapState().liveBytes).toBe(beforeCleanup - outerBytes); + expect(readU32(memory, outer.descriptor + 44)).toBe(1); + finish(outer.owner); + enumerate(); + }); +}); diff --git a/tests/basic/shimFile.test.ts b/tests/basic/shimFile.test.ts index 9e02bc5..5ca8882 100644 --- a/tests/basic/shimFile.test.ts +++ b/tests/basic/shimFile.test.ts @@ -303,22 +303,14 @@ describe('FILETIME 族(保存游戏路径)', () => { } }); - it('FileTimeToLocalFileTime 按该时刻的宿主时区偏移换算', () => { + it('FileTimeToLocalFileTime uses the current host timezone bias', () => { const memory = createGuestMemory(); const shim = createTestShim(memory); const instant = Date.UTC(2024, 0, 15, 3, 4, 5); const utc = fileTimeFromUnixMilliseconds(instant); writeFileTime(memory, FT_A, utc); expect(callShim(shim, 'KERNEL32.DLL!FileTimeToLocalFileTime', [FT_A, FT_B]).eax).toBe(1); - const local = new Date(instant); - const localFieldsAsUtc = Date.UTC( - local.getFullYear(), - local.getMonth(), - local.getDate(), - local.getHours(), - local.getMinutes(), - local.getSeconds(), - ); + const localFieldsAsUtc = instant - new Date().getTimezoneOffset() * 60_000; expect(readFileTime(memory, FT_B)).toBe(fileTimeFromUnixMilliseconds(localFieldsAsUtc)); expect(callShim(shim, 'KERNEL32.DLL!GetLastError').eax).toBe(0); diff --git a/tests/basic/shimHeapPressure.test.ts b/tests/basic/shimHeapPressure.test.ts new file mode 100644 index 0000000..b3a1212 --- /dev/null +++ b/tests/basic/shimHeapPressure.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { Win32Shim } from '../../src/games/win32Shim'; +import { callShim, createGuestMemory, readU32, writeU32 } from '../helpers/guestMemory'; + +const PROCESS_HEAP = 0x1_0001; + +function fixture() { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + return { memory, shim }; +} + +function exhaustHeap(shim: Win32Shim): void { + while (callShim(shim, 'KERNEL32.DLL!HeapAlloc', [PROCESS_HEAP, 0, 0x10000]).eax); + while (callShim(shim, 'KERNEL32.DLL!HeapAlloc', [PROCESS_HEAP, 0, 16]).eax); +} + +describe('长局堆压力下的 shim 行为', () => { + it.each([ + ['DDRAW.DLL!DirectDrawCreate', [0, 0x1200, 0]], + ['DSOUND.DLL!ord1', [0, 0x1200, 0]], + ['DDRAW.COM!IDirectDraw.CreateClipper', [0, 0, 0x1200, 0]], + ['DDRAW.COM!IDirectDraw.CreatePalette', [0, 0, 0, 0x1200, 0]], + ['DDRAW.COM!IDirectDraw.CreateSurface', [0, 0x1000, 0x1200, 0]], + ] as const)('%s clears its output when the heap is exhausted', (api, args) => { + const { memory, shim } = fixture(); + exhaustHeap(shim); + writeU32(memory, 0x1000, 108); + writeU32(memory, 0x1008, 16); + writeU32(memory, 0x100c, 16); + writeU32(memory, 0x1200, 0xdeadbeef); + const lowMemory = memory.read_memory(0, 128).slice(); + const baseline = shim.inspectHeapState().liveBytes; + expect(callShim(shim, api, [...args]).eax).toBe(0x8007000e); + expect(readU32(memory, 0x1200)).toBe(0); + expect(memory.read_memory(0, 128)).toEqual(lowMemory); + expect(shim.inspectHeapState().liveBytes).toBe(baseline); + expect(shim.inspectSurfaceObjects()).toEqual([]); + }); + + it.each(['sound', 'duplicate', 'surface', 'back-buffer'] as const)( + 'rolls back %s allocations when only the final data allocation fails', + (kind) => { + const { memory, shim } = fixture(); + const sound = kind === 'sound' || kind === 'duplicate'; + const api = sound ? 'DSOUND.COM!IDirectSound.CreateSoundBuffer' : 'DDRAW.COM!IDirectDraw.CreateSurface'; + const release = sound ? 'DSOUND.COM!IDirectSoundBuffer.Release' : 'DDRAW.COM!IDirectDrawSurface.Release'; + const desc = 0x1000, + out = 0x1200; + writeU32(memory, desc, sound ? 20 : 108); + if (!sound) writeU32(memory, desc + 4, 6); // DDSD_HEIGHT | DDSD_WIDTH + writeU32(memory, desc + 8, sound ? 0x8000 : 16); + writeU32(memory, desc + 12, 16); + // Prime the shared vtable before measuring per-object ownership. Keep the duplicate's source alive. + expect(callShim(shim, api, [0, desc, out, 0]).eax).toBe(0); + const source = readU32(memory, out); + if (kind !== 'duplicate') expect(callShim(shim, release, [source]).eax).toBe(0); + const budget = kind === 'back-buffer' ? 288 : 16; + const spare = callShim(shim, 'KERNEL32.DLL!HeapAlloc', [PROCESS_HEAP, 0, budget]).eax; + expect(spare).toBeGreaterThan(0); + exhaustHeap(shim); + expect(callShim(shim, 'KERNEL32.DLL!HeapFree', [PROCESS_HEAP, 0, spare]).eax).toBe(1); + if (kind === 'back-buffer') { + writeU32(memory, desc + 4, 0x26); + writeU32(memory, desc + 20, 1); + writeU32(memory, desc + 104, 0x200); + } + const baseline = shim.inspectHeapState().liveBytes; + const lowMemory = memory.read_memory(0, 128).slice(); + writeU32(memory, out, 0xdeadbeef); + const result = + kind === 'duplicate' + ? callShim(shim, 'DSOUND.COM!IDirectSound.DuplicateSoundBuffer', [0, source, out]) + : callShim(shim, api, [0, desc, out, 0]); + expect(result.eax).toBe(0x8007000e); + expect(readU32(memory, out)).toBe(0); + expect(shim.inspectHeapState().liveBytes).toBe(baseline); + expect(memory.read_memory(0, 128)).toEqual(lowMemory); + expect(shim.inspectSurfaceObjects()).toEqual([]); + // Rollback must restore enough contiguous capacity for the original reservation. + const recovered = callShim(shim, 'KERNEL32.DLL!HeapAlloc', [PROCESS_HEAP, 0, budget]).eax; + expect(recovered).toBeGreaterThan(0); + if (kind === 'duplicate') expect(callShim(shim, release, [source]).eax).toBe(0); + }, + ); + + it('堆耗尽时 CreateSoundBuffer 返回 DSERR_OUTOFMEMORY,而不是成功但给空缓冲', () => { + const { memory, shim } = fixture(); + exhaustHeap(shim); + const desc = 0x1000, + out = 0x1200; + writeU32(memory, desc, 20); // dwSize + writeU32(memory, desc + 8, 0x8000); // dwBufferBytes + writeU32(memory, out, 0xdead_beef); + const lowMemory = memory.read_memory(0, 32).slice(); + const result = callShim(shim, 'DSOUND.COM!IDirectSound.CreateSoundBuffer', [0x3000, desc, out, 0], 0x2000); + expect(result.eax).toBe(0x8007_000e); + expect(readU32(memory, out)).toBe(0); + expect(memory.read_memory(0, 32)).toEqual(lowMemory); + }); +}); diff --git a/tests/basic/shimKernelFileTime.test.ts b/tests/basic/shimKernelFileTime.test.ts new file mode 100644 index 0000000..92605a7 --- /dev/null +++ b/tests/basic/shimKernelFileTime.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { + callShim, + createGuestMemory, + createTestShim, + readU32, + writeAsciiZ, + writeU32, + type FakeGuestMemory, +} from '../helpers/guestMemory'; + +const FILETIME_UNIX_EPOCH = 116_444_736_000_000_000n; + +function writeFileTime(memory: ReturnType, ptr: number, value: bigint): void { + writeU32(memory, ptr, Number(value & 0xffff_ffffn)); + writeU32(memory, ptr + 4, Number(value >> 32n)); +} + +function readFileTime(memory: ReturnType, ptr: number): bigint { + return BigInt(readU32(memory, ptr)) | (BigInt(readU32(memory, ptr + 4)) << 32n); +} + +function readSystemTime(memory: ReturnType, ptr: number): number[] { + const bytes = memory.read_memory(ptr, 16); + return Array.from({ length: 8 }, (_, i) => bytes[i * 2]! | (bytes[i * 2 + 1]! << 8)); +} + +describe('KERNEL32 FILETIME 转换', () => { + it('FileTimeToLocalFileTime 使用与 GetTimeZoneInformation 相同的宿主偏移', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + const utc = FILETIME_UNIX_EPOCH + 1_700_000_000_000n * 10_000n; + writeFileTime(memory, 0x3000, utc); + + expect(callShim(shim, 'KERNEL32.DLL!FileTimeToLocalFileTime', [0x3000, 0x3010]).eax).toBe(1); + expect(callShim(shim, 'KERNEL32.DLL!GetTimeZoneInformation', [0x3100]).eax).toBe(0); + const bias = BigInt(readU32(memory, 0x3100) | 0); + expect(readFileTime(memory, 0x3010)).toBe(utc - bias * 600_000_000n); + }); + + it('FileTimeToLocalFileTime 偏移越界时失败而不回绕', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + expect(callShim(shim, 'KERNEL32.DLL!GetTimeZoneInformation', [0x3100]).eax).toBe(0); + const bias = readU32(memory, 0x3100) | 0; + // Pick whichever end the host's bias pushes past: east of UTC overflows the top, west underflows below zero. + // The old code masked the result into range and still reported success. + writeFileTime(memory, 0x3000, bias <= 0 ? 0xffff_ffff_ffff_ffffn : 1n); + writeFileTime(memory, 0x3010, 0xdead_beefn); + expect(callShim(shim, 'KERNEL32.DLL!FileTimeToLocalFileTime', [0x3000, 0x3010]).eax).toBe(bias === 0 ? 1 : 0); + if (bias !== 0) expect(readFileTime(memory, 0x3010)).toBe(0xdead_beefn); + }); + + it('FileTimeToLocalFileTime 拒绝空指针', () => { + const shim = createTestShim(createGuestMemory()); + expect(callShim(shim, 'KERNEL32.DLL!FileTimeToLocalFileTime', [0, 0x3010]).eax).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!FileTimeToLocalFileTime', [0x3000, 0]).eax).toBe(0); + }); + + it('FileTimeToSystemTime 与 SystemTimeToFileTime 往返一致,含星期几', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + // 2026-09-19 16:41:07.123, a Saturday. + memory.write_memory(new Uint8Array(new Uint16Array([2026, 9, 0, 19, 16, 41, 7, 123]).buffer), 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!SystemTimeToFileTime', [0x3000, 0x3020]).eax).toBe(1); + expect(callShim(shim, 'KERNEL32.DLL!FileTimeToSystemTime', [0x3020, 0x3040]).eax).toBe(1); + expect(readSystemTime(memory, 0x3040)).toEqual([2026, 9, 6, 19, 16, 41, 7, 123]); + }); + + it('FileTimeToSystemTime 拒绝符号位置位的 FILETIME', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeFileTime(memory, 0x3000, 0x8000_0000_0000_0000n); + memory.write_memory(new Uint8Array(16).fill(0xa5), 0x3040); + expect(callShim(shim, 'KERNEL32.DLL!FileTimeToSystemTime', [0x3000, 0x3040]).eax).toBe(0); + expect(memory.read_memory(0x3040, 16)).toEqual(new Uint8Array(16).fill(0xa5)); + }); +}); + +/** SYSTEMTIME: 2026-09-19 16:41:07.123, a Saturday. */ +function writeSystemTime(memory: FakeGuestMemory, pointer: number): void { + memory.write_memory(new Uint8Array(new Uint16Array([2026, 9, 0, 19, 16, 41, 7, 123]).buffer), pointer); +} + +function readAscii(memory: FakeGuestMemory, pointer: number, max = 64): string { + const bytes = memory.read_memory(pointer, max); + const end = bytes.indexOf(0); + return String.fromCharCode(...bytes.subarray(0, end < 0 ? bytes.length : end)); +} + +describe('KERNEL32 日期/时间格式化', () => { + it.each([ + ['GetDateFormatA', "yyyy 'year' MM 'month' dd ''", "2026 year 09 month 19 '"], + ['GetTimeFormatA', "HH 'hours' mm 'minutes' ss ''", "16 hours 41 minutes 07 '"], + ['GetTimeFormatA', 'hH hhHH', '416 0416'], + ])('%s preserves quoted literals and token case in %s', (api, picture, expected) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + writeAsciiZ(memory, 0x3200, picture); + expect(callShim(shim, `KERNEL32.DLL!${api}`, [0x400, 0, 0x3000, 0x3200, 0x3100, 64]).eax).toBe(expected.length + 1); + expect(readAscii(memory, 0x3100)).toBe(expected); + }); + + it.each([ + ['GetDateFormatA', 'y'], + ['GetDateFormatA', 'M'], + ['GetTimeFormatA', 'H'], + ['GetTimeFormatA', 'm'], + ])('%s keeps a DBCS trail byte equal to %s literal', (api, trail) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + const pair = [0x81, trail.charCodeAt(0)]; + const suffix = api === 'GetDateFormatA' ? 'yyyy' : 'HH'; + const expected = [...pair, 32, ...Array.from(api === 'GetDateFormatA' ? '2026' : '16', (c) => c.charCodeAt(0)), 0]; + memory.write_memory([...pair, 32, ...Array.from(suffix, (c) => c.charCodeAt(0)), 0], 0x3200); + memory.write_memory(new Uint8Array(32).fill(0xa5), 0x3100); + const args = [0x400, 0, 0x3000, 0x3200, 0x3100, expected.length]; + expect(callShim(shim, `KERNEL32.DLL!${api}`, args).eax).toBe(expected.length); + expect([...memory.read_memory(0x3100, expected.length)]).toEqual(expected); + expect(memory.read_memory(0x3100 + expected.length, 1)[0]).toBe(0xa5); + }); + + it.each([ + [0, '4:41:07 PM'], + [1, '4 PM'], + [2, '4:41 PM'], + [4, '4:41:07'], + [8, '16:41:07'], + [9, '16'], + [10, '16:41'], + ])('honors default time flags 0x%s', (flags, expected) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x400, flags, 0x3000, 0, 0x3100, 64]).eax).toBe( + expected.length + 1, + ); + expect(readAscii(memory, 0x3100)).toBe(expected); + }); + + it.each(['GetDateFormatA', 'GetTimeFormatA'])( + '%s rejects negative capacity and preserves output on short buffers', + (api) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + const invoke = (buffer: number, capacity: number) => + callShim(shim, `KERNEL32.DLL!${api}`, [0x400, 0, 0x3000, 0, buffer, capacity]).eax; + const required = invoke(0, 0); + expect(required).toBeGreaterThan(1); + memory.write_memory(new Uint8Array(64).fill(0xa5), 0x3100); + expect(invoke(0x3100, -1)).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError').eax).toBe(87); + // Guest stack arguments arrive as unsigned DWORDs even though cch is a signed Win32 int. + expect(invoke(0x3100, 0xffffffff)).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError').eax).toBe(87); + expect(invoke(0x3100, required - 1)).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError').eax).toBe(122); + expect(memory.read_memory(0x3100, 64)).toEqual(new Uint8Array(64).fill(0xa5)); + expect(invoke(0x3100, required)).toBe(required); + expect(memory.read_memory(0x3100 + required - 1, 2)).toEqual(Uint8Array.from([0, 0xa5])); + }, + ); + + it('formats time while ignoring uninitialized date fields', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + memory.write_memory(new Uint8Array(new Uint16Array([0, 0, 0, 0, 16, 41, 7, 0]).buffer), 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x0400, 0x8, 0x3000, 0, 0x3100, 64]).eax).toBe(9); + expect(readAscii(memory, 0x3100)).toBe('16:41:07'); + writeAsciiZ(memory, 0x3200, 'HH:mm:ss yyyy/MMMM/dd'); + expect(callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x0400, 0, 0x3000, 0x3200, 0x3100, 64]).eax).toBeGreaterThan( + 0, + ); + expect(readAscii(memory, 0x3100)).toBe('16:41:07 yyyy/MMMM/dd'); + }); + + it('formats dates while ignoring uninitialized time fields and correcting the weekday', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + memory.write_memory( + new Uint8Array(new Uint16Array([2026, 9, 0xffff, 19, 0xffff, 0xffff, 0xffff, 0xffff]).buffer), + 0x3000, + ); + writeAsciiZ(memory, 0x3200, 'yyyy/MM/dd ddd'); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0, 0x3000, 0x3200, 0x3100, 64]).eax).toBe(15); + expect(readAscii(memory, 0x3100)).toBe('2026/09/19 Sat'); + writeAsciiZ(memory, 0x3200, 'yyyy/MM/dd HH:mm:ss'); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0, 0x3000, 0x3200, 0x3100, 64]).eax).toBeGreaterThan( + 0, + ); + expect(readAscii(memory, 0x3100)).toBe('2026/09/19 HH:mm:ss'); + }); + + it.each([ + [2026, 2, 31], + [2025, 2, 29], + [1900, 2, 29], + [2026, 4, 31], + ])('rejects nonexistent dates %i/%i/%i without writing output', (year, month, day) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + memory.write_memory(new Uint8Array(new Uint16Array([year, month, 0, day, 0, 0, 0, 0]).buffer), 0x3000); + memory.write_memory(new Uint8Array(64).fill(0xa5), 0x3100); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0, 0x3000, 0, 0x3100, 64]).eax).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError').eax).toBe(87); + expect(memory.read_memory(0x3100, 64)).toEqual(new Uint8Array(64).fill(0xa5)); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0, 0x3000, 0, 0, 0]).eax).toBe(0); + }); + + it.each([2000, 2024])('accepts leap day in %i', (year) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + memory.write_memory(new Uint8Array(new Uint16Array([year, 2, 0, 29, 0, 0, 0, 0]).buffer), 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0, 0x3000, 0, 0x3100, 64]).eax).toBe(10); + expect(readAscii(memory, 0x3100)).toBe(`2/29/${year}`); + }); + + it.each([ + [24, 0, 0], + [0, 60, 0], + [0, 0, 60], + ])('rejects invalid time %i:%i:%i', (hour, minute, second) => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + memory.write_memory(new Uint8Array(new Uint16Array([0, 0, 0, 0, hour, minute, second, 0]).buffer), 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x0400, 0x8, 0x3000, 0, 0x3100, 64]).eax).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError').eax).toBe(87); + }); + + it('GetDateFormatA 默认短日期,返回含结尾符的长度', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + // LOCALE_USER_DEFAULT, DATE_SHORTDATE, no picture. + const written = callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0x1, 0x3000, 0, 0x3100, 64]).eax; + expect(readAscii(memory, 0x3100)).toBe('9/19/2026'); + expect(written).toBe('9/19/2026'.length + 1); + }); + + it('GetTimeFormatA 24 小时制标志与 TIME_NOSECONDS', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x0400, 0x8, 0x3000, 0, 0x3100, 64]); + expect(readAscii(memory, 0x3100)).toBe('16:41:07'); + callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x0400, 0xa, 0x3000, 0, 0x3100, 64]); + expect(readAscii(memory, 0x3100)).toBe('16:41'); + }); + + it('formats named date fields and 12-hour time while retaining GBK bytes', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + // Retain the original GBK bytes for 日 alongside formatted ASCII fields. + memory.write_memory( + Uint8Array.from([ + ...'ddd dd'.split('').map((c) => c.charCodeAt(0)), + 0xc8, + 0xd5, + 0x20, + ...'MMM yy'.split('').map((c) => c.charCodeAt(0)), + 0, + ]), + 0x3200, + ); + callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0, 0x3000, 0x3200, 0x3100, 64]); + expect([...memory.read_memory(0x3100, 16)]).toEqual([ + ...'Sat 19'.split('').map((c) => c.charCodeAt(0)), + 0xc8, + 0xd5, + 0x20, + ...'Sep 26'.split('').map((c) => c.charCodeAt(0)), + 0, + ]); + writeAsciiZ(memory, 0x3200, 'hh:mm tt'); + callShim(shim, 'KERNEL32.DLL!GetTimeFormatA', [0x0400, 0, 0x3000, 0x3200, 0x3100, 64]); + expect(readAscii(memory, 0x3100)).toBe('04:41 PM'); + }); + + it('容量为 0 时只返回所需长度,容量不足时失败且不写入', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + writeSystemTime(memory, 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0x1, 0x3000, 0, 0, 0]).eax).toBe(10); + memory.write_memory(new Uint8Array(16).fill(0xa5), 0x3100); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0x1, 0x3000, 0, 0x3100, 4]).eax).toBe(0); + expect(memory.read_memory(0x3100, 16)).toEqual(new Uint8Array(16).fill(0xa5)); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError', []).eax).toBe(122); // ERROR_INSUFFICIENT_BUFFER + }); + + it('非法 SYSTEMTIME 被拒绝,空指针表示当前本地时间', () => { + const memory = createGuestMemory(); + const shim = createTestShim(memory); + memory.write_memory(new Uint8Array(new Uint16Array([2026, 13, 0, 19, 16, 41, 7, 0]).buffer), 0x3000); + expect(callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0x1, 0x3000, 0, 0x3100, 64]).eax).toBe(0); + expect(callShim(shim, 'KERNEL32.DLL!GetLastError', []).eax).toBe(87); + // A null SYSTEMTIME means "now", which must still produce this year's date. + callShim(shim, 'KERNEL32.DLL!GetDateFormatA', [0x0400, 0x1, 0, 0, 0x3100, 64]); + expect(readAscii(memory, 0x3100).endsWith(String(new Date().getFullYear()))).toBe(true); + }); +}); diff --git a/tests/basic/shimOleStorage.test.ts b/tests/basic/shimOleStorage.test.ts index ebd7b24..71fdd48 100644 --- a/tests/basic/shimOleStorage.test.ts +++ b/tests/basic/shimOleStorage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { GUEST_CALLBACK_BASE } from '../../src/vm86/pe'; import { callShim, createGuestMemory, createTestShim, readU32, writeU32 } from '../helpers/guestMemory'; import { guidBytes } from '../../src/vm86/shim/dplayx'; import { RA2_ABI } from '../../src/games/ra2/abi'; @@ -91,6 +92,9 @@ describe('OLE structured storage', () => { expect(contains([0x8b, 0x11, 0xff, 0x52, 0x0c])).toBe(true); // GetClassID expect(contains([0x8b, 0x11, 0xff, 0x52, 0x10])).toBe(true); // IStream::Write expect(contains([0x8b, 0x11, 0xff, 0x52, 0x18])).toBe(true); // IPersistStream::Save + // The bridge lives in a callback slot, so its tail releases the slot under CLI and returns with push/ret + // instead of jumping through ECX; the original return address must still be what it returns to. + expect(bridge).toBeGreaterThanOrEqual(GUEST_CALLBACK_BASE); expect( contains([ 0x68, @@ -100,6 +104,7 @@ describe('OLE structured storage', () => { originalReturn >>> 24, ]), ).toBe(true); + expect(contains([0xfb, 0xc3])).toBe(true); // sti; ret }); it('为 IStorage/IStream vtable 登记正确的 x86 stdcall 参数字节数', () => { diff --git a/tests/basic/shimThreadRecycle.test.ts b/tests/basic/shimThreadRecycle.test.ts new file mode 100644 index 0000000..5fd9e5b --- /dev/null +++ b/tests/basic/shimThreadRecycle.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { Win32Shim } from '../../src/games/win32Shim'; +import { + GUEST_THREAD_CRITICAL_DEPTH, + GUEST_THREAD_FPU_CONTEXTS, + GUEST_THREAD_FPU_CONTEXT_BYTES, + GUEST_THREAD_LIMIT, + HYPERCALL_THREAD_CURRENT, +} from '../../src/vm86/pe'; +import { callShim, createGuestMemory, readU32, writeU32, type FakeGuestMemory } from '../helpers/guestMemory'; + +/** Run one CreateThread/ExitThread/CloseHandle cycle from the main thread's point of view. */ +function cycle(memory: FakeGuestMemory, shim: Win32Shim, stackBytes: number): number { + const handle = callShim(shim, 'KERNEL32.DLL!CreateThread', [0, stackBytes, 0x401000, 0, 0, 0]).eax; + if (!handle) return 0; + const imported = { + id: 1, + dll: 'KERNEL32.DLL', + name: 'ExitThread', + key: 'KERNEL32.DLL!ExitThread', + stub: 0, + slot: 0, + argBytes: 4, + }; + // The worker thread exits on its own stack, then the main thread closes its handle. + const threadId = shim.inspectGuestThreads().find((thread) => thread.handle === handle)!.id; + writeU32(memory, HYPERCALL_THREAD_CURRENT, threadId); + const result = callShim(shim, imported.key, [0]); + shim.prepareGuestThreadReturn({ imported, args: [0] }, result); + writeU32(memory, HYPERCALL_THREAD_CURRENT, 0); + callShim(shim, 'KERNEL32.DLL!CloseHandle', [handle]); + return handle; +} + +describe('客体线程回收', () => { + it('退出并关闭句柄后复用线程 id 与栈内存,长会话不会耗尽 64 个线程', () => { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + const before = shim.inspectHeapState(); + // Far more cycles than GUEST_THREAD_LIMIT: ids and 1MB stacks must both come back. + for (let i = 0; i < GUEST_THREAD_LIMIT * 3; i++) expect(cycle(memory, shim, 1024 * 1024)).toBeGreaterThan(0); + const after = shim.inspectHeapState(); + // Only the final cycle's thread may still be pending; the reclaim runs on the next CreateThread. + expect(shim.inspectGuestThreads().filter((thread) => thread.terminated).length).toBeLessThanOrEqual(1); + // One live stack may remain until the next CreateThread reclaims it; nothing like 192 leaked stacks. + expect(after.liveBytes - before.liveBytes).toBeLessThanOrEqual(1024 * 1024); + }); + + it('句柄仍打开时保留已退出线程的状态,等待方仍可观察到它已结束', () => { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + const handle = callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x401000, 0, 0, 0]).eax; + const threadId = shim.inspectGuestThreads().find((thread) => thread.handle === handle)!.id; + writeU32(memory, HYPERCALL_THREAD_CURRENT, threadId); + const imported = { + id: 1, + dll: 'KERNEL32.DLL', + name: 'ExitThread', + key: 'KERNEL32.DLL!ExitThread', + stub: 0, + slot: 0, + argBytes: 4, + }; + shim.prepareGuestThreadReturn({ imported, args: [0] }, callShim(shim, imported.key, [0])); + writeU32(memory, HYPERCALL_THREAD_CURRENT, 0); + // A second CreateThread runs the reclaim scan; the open handle must keep the exited thread observable. + callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x402000, 0, 0, 0]); + expect(shim.inspectGuestThreads().find((thread) => thread.id === threadId)?.terminated).toBe(true); + expect(callShim(shim, 'KERNEL32.DLL!WaitForSingleObject', [handle, 0]).eax).toBe(0); + }); + + it('复用 id 时清空上个线程的兼容锁深度与 x87 状态', () => { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + const handle = callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x401000, 0, 0, 0]).eax; + const id = shim.inspectGuestThreads().find((thread) => thread.handle === handle)!.id; + // The thread dies holding a compat lock and with dirty x87 state saved by FNSAVE. + writeU32(memory, GUEST_THREAD_CRITICAL_DEPTH + id * 4, 3); + const fpu = GUEST_THREAD_FPU_CONTEXTS + id * GUEST_THREAD_FPU_CONTEXT_BYTES; + writeU32(memory, fpu, 0x0c7f); + writeU32(memory, fpu + 8, 0); + writeU32(memory, HYPERCALL_THREAD_CURRENT, id); + const imported = { + id: 1, + dll: 'KERNEL32.DLL', + name: 'ExitThread', + key: 'KERNEL32.DLL!ExitThread', + stub: 0, + slot: 0, + argBytes: 4, + }; + shim.prepareGuestThreadReturn({ imported, args: [0] }, callShim(shim, imported.key, [0])); + writeU32(memory, HYPERCALL_THREAD_CURRENT, 0); + callShim(shim, 'KERNEL32.DLL!CloseHandle', [handle]); + const reused = callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x402000, 0, 0, 0]).eax; + expect(shim.inspectGuestThreads().find((thread) => thread.handle === reused)!.id).toBe(id); + // Inheriting depth 3 would make the new thread's import tails skip STI and starve the scheduler. + expect(readU32(memory, GUEST_THREAD_CRITICAL_DEPTH + id * 4)).toBe(0); + expect(readU32(memory, fpu)).toBe(0x037f); + expect(readU32(memory, fpu + 8)).toBe(0xffff); + }); + + it('句柄关闭后线程仍可被等待,直到被回收', () => { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + const handle = callShim(shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, 0x401000, 0, 0, 0]).eax; + // Windows keeps the object alive for a thread already blocked on it when a third thread closes the handle. + callShim(shim, 'KERNEL32.DLL!CloseHandle', [handle]); + expect(callShim(shim, 'KERNEL32.DLL!WaitForSingleObject', [handle, 0]).eax).not.toBe(0xffff_ffff); + }); +}); diff --git a/tests/basic/shimWindowRecycle.test.ts b/tests/basic/shimWindowRecycle.test.ts new file mode 100644 index 0000000..99ff86f --- /dev/null +++ b/tests/basic/shimWindowRecycle.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { Win32Shim } from '../../src/games/win32Shim'; +import { + GUEST_WINDOW_ENTRY_BYTES, + GUEST_WINDOW_OWNER, + GUEST_WINDOW_TABLE, + GUEST_WINDOW_TABLE_MAX, + GUEST_WINDOW_VALID, + GUEST_WINDOW_WIDTH, +} from '../../src/vm86/pe'; +import { callShim, createGuestMemory, readU32, writeAsciiZ, type FakeGuestMemory } from '../helpers/guestMemory'; + +function mirror(memory: FakeGuestMemory, hwnd: number, field: number): number | null { + if (hwnd < 0x2000) return null; + const index = (hwnd - 0x2000) & (GUEST_WINDOW_TABLE_MAX - 1); + return readU32(memory, GUEST_WINDOW_TABLE + index * GUEST_WINDOW_ENTRY_BYTES + field); +} + +describe('客体窗口镜像表环绕', () => { + it('反复开关菜单页后新窗口仍有镜像槽位,快速桩不会永久退化为 hypercall', () => { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + writeAsciiZ(memory, 0x330000, 'Static'); + let hwnd = 0; + // Far more create/destroy cycles than the mirror table holds; only one window is ever live. + for (let i = 0; i < GUEST_WINDOW_TABLE_MAX + 200; i++) { + hwnd = callShim(shim, 'USER32.DLL!CreateWindowExA', [0, 0x330000, 0, 0, 0, 0, 120, 40, 0, 0, 0, 0]).eax; + expect(hwnd).toBeGreaterThan(0); + expect(callShim(shim, 'USER32.DLL!DestroyWindow', [hwnd]).eax).toBe(1); + } + const live = callShim(shim, 'USER32.DLL!CreateWindowExA', [0, 0x330000, 0, 0, 0, 0, 120, 40, 0, 0, 0, 0]).eax; + expect(live).toBeGreaterThan(0x2000 + GUEST_WINDOW_TABLE_MAX); // Past the table: the old code gave up here. + expect(mirror(memory, live, GUEST_WINDOW_VALID)).toBe(1); + expect(mirror(memory, live, GUEST_WINDOW_WIDTH)).toBe(120); + expect(mirror(memory, live, GUEST_WINDOW_OWNER)).toBe(live); + }); + + it('HWND 不复用;环绕碰撞的两个窗口各自记录属主,旧窗口的桩因属主不符而回退', () => { + const memory = createGuestMemory(); + const shim = new Win32Shim(memory, { heapTop: 0x00c0_0000, virtualTop: 0x00c0_0000 }); + writeAsciiZ(memory, 0x330000, 'Static'); + const create = (width: number) => + callShim(shim, 'USER32.DLL!CreateWindowExA', [0, 0x330000, 0, 0, 0, 0, width, 10, 0, 0, 0, 0]).eax; + const first = create(11); + const second = create(12); + callShim(shim, 'USER32.DLL!DestroyWindow', [first]); + // RA2 keeps stale handles, so a destroyed HWND must never come back. + expect(create(13)).not.toBe(first); + // Keep `second` alive and collect the later windows that wrap onto its entry. + const sharing: number[] = []; + for (let i = 0; i < GUEST_WINDOW_TABLE_MAX * 3 && sharing.length < 2; i++) { + const hwnd = create(14); + if ((hwnd - second) % GUEST_WINDOW_TABLE_MAX === 0) sharing.push(hwnd); + } + expect(sharing).toHaveLength(2); + const [colliding, newest] = sharing as [number, number]; + // The newest window owns the shared entry, so the older ones' stubs fall back instead of reading its geometry. + expect(mirror(memory, newest, GUEST_WINDOW_OWNER)).toBe(newest); + // Destroying an evicted older window must leave the live claimant's entry intact. + expect(callShim(shim, 'USER32.DLL!DestroyWindow', [second]).eax).toBe(1); + expect(mirror(memory, newest, GUEST_WINDOW_OWNER)).toBe(newest); + expect(mirror(memory, newest, GUEST_WINDOW_VALID)).toBe(1); + // Once the claimant goes away the slot returns to a window still alive instead of staying unusable. + expect(callShim(shim, 'USER32.DLL!DestroyWindow', [newest]).eax).toBe(1); + expect(mirror(memory, colliding, GUEST_WINDOW_OWNER)).toBe(colliding); + expect(mirror(memory, colliding, GUEST_WINDOW_VALID)).toBe(1); + }); +}); diff --git a/tests/basic/vm/displayEnumeration.e2e.test.ts b/tests/basic/vm/displayEnumeration.e2e.test.ts new file mode 100644 index 0000000..97f23ce --- /dev/null +++ b/tests/basic/vm/displayEnumeration.e2e.test.ts @@ -0,0 +1,128 @@ +import { expect, it } from 'vitest'; +import { HYPERCALL_CALLBACK_DEPTH } from '../../../src/vm86/pe'; +import { callShim } from '../../helpers/guestMemory'; +import { call32, finish, le32, PROGRAM, push32, withGuestMachine } from '../../helpers/guestMachine'; + +it('retains each display descriptor through a nested mode change and enumeration', async () => { + await withGuestMachine(async (m) => { + const data = 0x310000, + outer = PROGRAM + 0x1000, + inner = PROGRAM + 0x2000; + expect(callShim(m.shim, 'DDRAW.DLL!DirectDrawCreate', [0, data, 0]).eax).toBe(0); + const object = m.read(data); + expect(callShim(m.shim, 'DDRAW.COM!IDirectDraw.SetDisplayMode', [object, 640, 480, 8]).eax).toBe(0); + const enumerate = m.api('IDirectDraw.EnumDisplayModes', 20, undefined, 'DDRAW.COM'); + const setMode = m.api('IDirectDraw.SetDisplayMode', 16, undefined, 'DDRAW.COM'); + const enumCall = (callback: number) => [ + ...push32(callback), + ...push32(0), + ...push32(0), + ...push32(0), + ...push32(object), + ...call32(enumerate), + ]; + m.code(inner, [0x8b, 0x44, 0x24, 4, 0x8b, 0x40, 12, 0xa3, ...le32(data + 8), 0xb8, 1, 0, 0, 0, 0xc2, 8, 0]); + m.code(outer, [ + 0x53, + 0x8b, + 0x5c, + 0x24, + 8, // EBX retains the outer DDSURFACEDESC pointer. + ...push32(16), + ...push32(600), + ...push32(800), + ...push32(object), + ...call32(setMode), + ...enumCall(inner), + 0x8b, + 0x43, + 12, + 0xa3, + ...le32(data + 4), + 0x5b, + 0xb8, + 1, + 0, + 0, + 0, + 0xc2, + 8, + 0, + ]); + m.code(PROGRAM, [...enumCall(outer), ...finish]); + await m.run(); + expect(m.read(data + 8)).toBe(800); + expect(m.read(data + 4)).toBe(640); + expect(m.read(HYPERCALL_CALLBACK_DEPTH)).toBe(0); + }); +}); + +it.each(['stdcall', 'cdecl'])('repeated display enumeration reclaims real %s callback bridges', async (convention) => { + await withGuestMachine(async (m) => { + const data = 0x310000, + callback = PROGRAM + 0x1000; + expect(callShim(m.shim, 'DDRAW.DLL!DirectDrawCreate', [0, data, 0]).eax).toBe(0); + const object = m.read(data), + enumerate = m.api('IDirectDraw.EnumDisplayModes', 20, undefined, 'DDRAW.COM'); + const resources = m.shim.inspectResourceCounts(); + const baseline = m.shim.inspectHeapState().liveBytes; + // Validate callback arguments through real instructions, including the enumeration context. + m.code(callback, [ + 0xff, + 0x05, + ...le32(data + 4), + 0x8b, + 0x44, + 0x24, + 4, + 0x8b, + 0x00, + 0xa3, + ...le32(data + 8), + 0x8b, + 0x44, + 0x24, + 8, + 0xa3, + ...le32(data + 12), + 0xb8, + 1, + 0, + 0, + 0, + ...(convention === 'stdcall' ? [0xc2, 8, 0] : [0xc3]), + ]); + const body = [ + ...push32(callback), + ...push32(0x12345678), + ...push32(0), + ...push32(0), + ...push32(object), + ...call32(enumerate), + 0x4e, + ]; + m.code(PROGRAM, [ + 0x89, + 0x25, + ...le32(data + 16), + 0xbe, + ...le32(2000), + ...body, + 0x75, + -(body.length + 2) & 255, + 0x89, + 0x25, + ...le32(data + 20), + ...finish, + ]); + await m.run(); + expect(m.read(data + 4)).toBe(2000); + expect(m.read(data + 8)).toBe(108); + expect(m.read(data + 12)).toBe(0x12345678); + expect(m.read(data + 20)).toBe(m.read(data + 16)); + expect(m.read(HYPERCALL_CALLBACK_DEPTH)).toBe(0); + expect(m.shim.inspectResourceCounts()).toEqual(resources); + // Descriptors live in reusable callback slots, with no permanent heap staging allocation. + expect(m.shim.inspectHeapState().liveBytes).toBe(baseline); + }); +}); diff --git a/tests/basic/vm/dplayEnumeration.e2e.test.ts b/tests/basic/vm/dplayEnumeration.e2e.test.ts new file mode 100644 index 0000000..13b92c0 --- /dev/null +++ b/tests/basic/vm/dplayEnumeration.e2e.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import { + GUEST_CALLBACK_OWNERS, + GUEST_CALLBACK_SLOTS, + GUEST_SCHEDULER_TICKS, + HYPERCALL_CALLBACK_DEPTH, +} from '../../../src/vm86/pe'; +import { CLSID_DIRECTPLAY, guidBytes } from '../../../src/vm86/shim/dplayx'; +import type { DplayTransportHandlers } from '../../../src/vm86/shim/dplayTransport'; +import { callShim } from '../../helpers/guestMemory'; +import { call32, finish, le32, PROGRAM, push32, store32, withGuestMachine } from '../../helpers/guestMachine'; + +describe('DirectPlay enumeration in real v86', () => { + it.each(['return', 'exit'] as const)( + 'retains a PIT-preempted callback while another thread enumerates and completes by %s', + async (mode) => { + let transport!: DplayTransportHandlers; + const instance = '11111111-1111-1111-1111-111111111111'; + await withGuestMachine( + async (m) => { + const data = 0x310000, + callback = PROGRAM + 0x1000, + otherCallback = PROGRAM + 0x2000, + worker = PROGRAM + 0x3000; + m.code(data, guidBytes(CLSID_DIRECTPLAY)); + m.code(data + 32, guidBytes('{133efe41-32dc-11d0-9cfb-00a0c90a43cb}')); + expect(callShim(m.shim, 'OLE32.DLL!CoCreateInstance', [data, 0, 1, data + 32, data + 64]).eax).toBe(0); + const object = m.read(data + 64); + expect(callShim(m.shim, 'DPLAYX.COM!IDirectPlay3.InitializeConnection', [object, 0, 0]).eax).toBe(0); + const announce = (players: number) => + transport.onMessage({ + t: 'announce', + i: instance, + n: Uint8Array.of(65), + m: 8, + c: players, + g: instance, + f: 0, + }); + const enumApi = m.api('IDirectPlay3.EnumSessions', 24, undefined, 'DPLAYX.COM'); + const exit = m.api('ExitThread', 4); + const wait = m.api('WaitForSingleObject', 8); + const enumerate = (target: number) => [ + ...push32(0), + ...push32(0), + ...push32(target), + ...push32(0), + ...push32(0), + ...push32(object), + ...call32(enumApi), + ]; + // STI/HLT waits for actual hardware interrupts; neither clocks nor scheduler state are injected. + const waitFlag = (address: number) => [0x83, 0x3d, ...le32(address), 0, 0x75, 4, 0xfb, 0xf4, 0xeb, 0xf3]; + const started = data + 80, + completed = data + 84, + observed = data + 88, + visits = data + 92; + m.code(callback, [ + 0x53, + 0x8b, + 0x5c, + 0x24, + 8, // Preserve the descriptor in EBX across hardware context switches. + ...store32(started, 1), + ...waitFlag(completed), + 0x8b, + 0x43, + 44, + 0xa3, + ...le32(observed), + 0x5b, + 0xb8, + 1, + 0, + 0, + 0, + 0xc2, + 16, + 0, + ]); + m.code(otherCallback, [ + 0xff, + 0x05, + ...le32(visits), + ...(mode === 'exit' ? [...store32(completed, 1), ...push32(0), ...call32(exit)] : []), + 0xb8, + 1, + 0, + 0, + 0, + 0xc2, + 16, + 0, + ]); + const repetitions = mode === 'return' ? 24 : 1; + m.code(worker, [ + ...waitFlag(started), + ...Array.from({ length: repetitions }, () => enumerate(otherCallback)).flat(), + ...store32(completed, 1), + ...push32(0), + ...call32(exit), + ]); + const handle = callShim(m.shim, 'KERNEL32.DLL!CreateThread', [0, 0x10000, worker, 0, 0, 0]).eax; + expect(handle).toBeGreaterThan(0); + const baseline = m.shim.inspectHeapState().liveBytes; + let enumerations = 0; + m.afterCall = (call) => { + if (call.imported.key !== 'DPLAYX.COM!IDirectPlay3.EnumSessions') return; + enumerations++; + if (enumerations === 1) announce(2); + if (enumerations === repetitions + 1) transport.onMessage({ t: 'sclose', i: instance }); + }; + announce(1); + m.code(PROGRAM, [ + ...enumerate(callback), + ...push32(0xffffffff), + ...push32(handle), + ...call32(wait), + 0xa3, + ...le32(data + 96), + ...enumerate(callback), + ...finish, + ]); + await m.run(); + expect(m.read(GUEST_SCHEDULER_TICKS)).toBeGreaterThan(0); + expect(m.read(observed)).toBe(1); + expect(m.read(visits)).toBe(repetitions); + expect(m.read(data + 96)).toBe(0); + expect(m.shim.inspectGuestThreads().find((t) => t.handle === handle)?.terminated).toBe(true); + expect(m.read(HYPERCALL_CALLBACK_DEPTH)).toBe(0); + for (let slot = 0; slot < GUEST_CALLBACK_SLOTS; slot++) + expect(m.read(GUEST_CALLBACK_OWNERS + slot * 4)).toBe(0); + expect(m.shim.inspectHeapState().liveBytes).toBe(baseline); + }, + { + dplayTransportFactory: (handlers) => { + transport = handlers; + return { clientId: 'preemption-test', send: () => true, close: () => {} }; + }, + }, + ); + }, + ); + + it('keeps outer callback data stable through two nested enumerations and reclaims it after return', async () => { + let transport!: DplayTransportHandlers; + const instance = '11111111-1111-1111-1111-111111111111'; + const announce = (players: number) => + transport.onMessage({ + t: 'announce', + i: instance, + n: Uint8Array.from([65]), + m: 8, + c: players, + g: '22222222-2222-2222-2222-222222222222', + f: 0, + }); + await withGuestMachine( + async (m) => { + const clsid = 0x300100, + iid = 0x300120, + out = 0x300140; + const observed = 0x300160, + savedStack = 0x300164; + m.code(clsid, guidBytes(CLSID_DIRECTPLAY)); + m.code(iid, guidBytes('{133efe41-32dc-11d0-9cfb-00a0c90a43cb}')); + expect(callShim(m.shim, 'OLE32.DLL!CoCreateInstance', [clsid, 0, 1, iid, out]).eax).toBe(0); + const object = m.read(out); + expect(callShim(m.shim, 'DPLAYX.COM!IDirectPlay3.InitializeConnection', [object, 0, 0]).eax).toBe(0); + const baseline = m.shim.inspectHeapState().liveBytes; + announce(1); + const callback = PROGRAM + 0x1000; + const enumerate = m.api('IDirectPlay3.EnumSessions', 24, undefined, 'DPLAYX.COM'); + const enumCall = (context: number) => [ + ...push32(0), + ...push32(context), + ...push32(callback), + ...push32(0), + ...push32(0), + ...push32(object), + ...call32(enumerate), + ]; + const outerBody = [ + ...enumCall(1), + ...enumCall(1), + 0x8b, + 0x43, + 44, // mov eax,[ebx+44]: original descriptor's dwCurrentPlayers + 0xa3, + ...le32(observed), + ]; + m.code(callback, [ + 0x53, // preserve EBX, which keeps the outer descriptor across nested callbacks + 0x8b, + 0x5c, + 0x24, + 8, // mov ebx,[esp+8]: descriptor + 0x83, + 0x7c, + 0x24, + 20, + 0, // cmp [esp+20],0: only the outer context enumerates recursively + 0x75, + outerBody.length, + ...outerBody, + 0x5b, + 0xb8, + 1, + 0, + 0, + 0, + 0xc2, + 16, + 0, // pop ebx; return TRUE with stdcall cleanup + ]); + let enumerations = 0; + m.afterCall = (call) => { + if (call.imported.key !== 'DPLAYX.COM!IDirectPlay3.EnumSessions') return; + enumerations++; + // Incoming transport messages update discovery between guest calls without touching callback data. + if (enumerations < 3) announce(enumerations + 1); + else transport.onMessage({ t: 'sclose', i: instance }); + }; + m.code(PROGRAM, [ + 0x89, + 0x25, + ...le32(savedStack), + ...enumCall(0), + ...enumCall(0), // The final empty enumeration collects returned bridges' staging. + 0x89, + 0x25, + ...le32(savedStack + 4), + ...finish, + ]); + await m.run(); + expect(enumerations).toBe(4); + expect(m.read(observed)).toBe(1); + expect(m.read(savedStack + 4)).toBe(m.read(savedStack)); + expect(m.read(HYPERCALL_CALLBACK_DEPTH)).toBe(0); + expect(m.read(GUEST_CALLBACK_OWNERS)).toBe(0); + expect(m.shim.inspectHeapState().liveBytes).toBe(baseline); + }, + { + dplayTransportFactory: (handlers) => { + transport = handlers; + return { clientId: 'native-enumeration-test', send: () => true, close: () => {} }; + }, + }, + ); + }); +}); diff --git a/tests/basic/vm/threadRecycle.e2e.test.ts b/tests/basic/vm/threadRecycle.e2e.test.ts new file mode 100644 index 0000000..cdd93d8 --- /dev/null +++ b/tests/basic/vm/threadRecycle.e2e.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from 'vitest'; +import { GUEST_THREAD_LIMIT } from '../../../src/vm86/pe'; +import { call32, finish, le32, PROGRAM, push32, withGuestMachine } from '../../helpers/guestMachine'; + +it('reuses exited guest threads beyond the slot limit with clean x87 state and bounded stacks', async () => { + await withGuestMachine(async (m) => { + const data = 0x310000, + worker = PROGRAM + 0x20000; + const create = m.api('CreateThread', 24), + wait = m.api('WaitForSingleObject', 8), + close = m.api('CloseHandle', 4); + const iterations = GUEST_THREAD_LIMIT * 3; + const baseline = m.shim.inspectHeapState().liveBytes; + m.write(data + 64, 0x0b7f); + m.code(worker, [ + 0xd9, + 0x35, + ...le32(data + 16), // FNSTENV records the initial control and tag words. + 0xd9, + 0xe8, + 0xdb, + 0x1d, + ...le32(data + 48), // FLD1; FISTP: execute x87, not just inspect saved bytes. + 0xd9, + 0x2d, + ...le32(data + 64), + 0xd9, + 0xe8, // Leave a different control word and a nonempty x87 stack. + 0x31, + 0xc0, + 0xc2, + 4, + 0, // Returning invokes the production ExitThread trampoline. + ]); + const controlWords: number[] = [], + tagWords: number[] = [], + values: number[] = [], + ids: number[] = []; + m.afterCall = (call) => { + if (call.imported.name === 'CreateThread') ids.push(m.read(data + 4)); + if (call.imported.name === 'WaitForSingleObject') { + controlWords.push(m.read(data + 16) & 0xffff); + tagWords.push(m.read(data + 24) & 0xffff); + values.push(m.read(data + 48)); + } + }; + const cycle = [ + ...push32(data + 4), + ...push32(0), + ...push32(0), + ...push32(worker), + ...push32(0x10000), + ...push32(0), + ...call32(create), + 0xa3, + ...le32(data), + ...push32(0xffffffff), + 0x50, + ...call32(wait), + 0xff, + 0x35, + ...le32(data), + ...call32(close), + ]; + m.code(PROGRAM, [...Array.from({ length: iterations }, () => cycle).flat(), ...finish]); + await m.run(); + // Public thread IDs are slot index + 1; slot 0 belongs to the main thread. + expect(ids).toEqual(new Array(iterations).fill(2)); + expect(controlWords).toEqual(new Array(iterations).fill(0x037f)); + expect(tagWords).toEqual(new Array(iterations).fill(0xffff)); + expect(values).toEqual(new Array(iterations).fill(1)); + expect(m.shim.inspectGuestThreads().filter((thread) => thread.terminated)).toHaveLength(1); + expect(m.shim.inspectHeapState().liveBytes - baseline).toBe(0x10000); + }); +}); diff --git a/tests/helpers/guestMachine.ts b/tests/helpers/guestMachine.ts index 4c207fe..36ae643 100644 --- a/tests/helpers/guestMachine.ts +++ b/tests/helpers/guestMachine.ts @@ -15,7 +15,7 @@ import { HYPERCALL_STACK_TOP, type PeImport, } from '../../src/vm86/pe'; -import { readStackArgs, type Win32Call } from '../../src/vm86/win32'; +import { readStackArgs, type Win32Call, type Win32ShimOptions } from '../../src/vm86/win32'; export const PROGRAM = 0x0040_0000; export const DONE = 0x0030_0000; @@ -30,8 +30,16 @@ export class GuestMachine { readonly calls: Win32Call[] = []; afterCall?: (call: Win32Call) => void; private readonly imports = new Map(); - constructor(readonly memory: V86) { - this.shim = new Win32Shim(memory, { heapTop: 0x00e0_0000, virtualTop: 0x00e0_0000, firstDynamicId: 1000 }); + constructor( + readonly memory: V86, + options: Win32ShimOptions = {}, + ) { + this.shim = new Win32Shim(memory, { + heapTop: 0x00e0_0000, + virtualTop: 0x00e0_0000, + firstDynamicId: 1000, + ...options, + }); this.write(HYPERCALL_ENTRY, PROGRAM); this.write(HYPERCALL_STACK_TOP, 0x0070_0000); } @@ -126,7 +134,10 @@ export class GuestMachine { } } -export async function withGuestMachine(test: (machine: GuestMachine) => Promise): Promise { +export async function withGuestMachine( + test: (machine: GuestMachine) => Promise, + options: Win32ShimOptions = {}, +): Promise { const bios = new Uint8Array(readFileSync(resolve('src/vm86/boot.bin'))); const vm = new V86({ wasm_path: resolve('node_modules/v86/build/v86.wasm'), @@ -139,7 +150,7 @@ export async function withGuestMachine(test: (machine: GuestMachine) => Promise< }); await new Promise((done) => vm.add_listener('emulator-ready', done)); try { - await test(new GuestMachine(vm)); + await test(new GuestMachine(vm, options)); } finally { await vm.destroy(); }