Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/REAL_GAME_CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |

Expand Down
4 changes: 4 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/run.mts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ async function browsers(): Promise<void> {
'15182',
]);
for (const test of [
'test:browser:audio',
'test:graphics',
'test:graphics:upscale',
'test:graphics:ai',
Expand Down
89 changes: 81 additions & 8 deletions src/adapter/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;
const workletModules = new WeakMap<AudioContext, Promise<void>>();
function loadPcmStreamWorklet(context: AudioContext): Promise<void> {
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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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<typeof globalThis.setInterval> | 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();
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -358,6 +382,8 @@ export class WebAudioPcmSink {

async destroy(): Promise<void> {
if (this.destroyed) return;
if (this.diagnosticsTimer !== null) globalThis.clearInterval(this.diagnosticsTimer);
this.diagnosticsTimer = null;
this.stopAll();
this.buffers.clear();
this.destroyed = true;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion src/adapter/pcmStreamWorklet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/adapter/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/adapter/vmClient.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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' });
Expand Down
Loading
Loading