diff --git a/Cargo.lock b/Cargo.lock index c841f62cd..a8f1b8bb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,8 @@ name = "playsrc-mp3" version = "0.1.0" dependencies = [ "nanomp3", + "playsrc-vpk", + "serde_json", "sha2 0.10.9", ] diff --git a/packages/formats/mp3/rust/Cargo.toml b/packages/formats/mp3/rust/Cargo.toml index ce5e7ab0f..599064dba 100644 --- a/packages/formats/mp3/rust/Cargo.toml +++ b/packages/formats/mp3/rust/Cargo.toml @@ -12,3 +12,5 @@ path = "src/lib.rs" [dev-dependencies] sha2 = "0.10.9" +serde_json = "1" +playsrc-vpk = { path = "../../vpk/rust", default-features = false } diff --git a/packages/formats/mp3/rust/src/lib.rs b/packages/formats/mp3/rust/src/lib.rs index ded0e38ae..7433c186a 100644 --- a/packages/formats/mp3/rust/src/lib.rs +++ b/packages/formats/mp3/rust/src/lib.rs @@ -52,12 +52,20 @@ pub fn decode(bytes: &[u8], max_input: usize, max_samples: usize) -> Result output.samples.capacity() { + // Reserve a frame without increasing the former per-sample push + // capacity bound (power-of-two growth for this i16 buffer). + output + .samples + .reserve_exact((start + count).next_power_of_two() - start); } + output.samples.resize(start + count, 0); + quantize( + &pcm[..count], + &mut output.samples[start..], + usize::from(output.channels), + ); } if output.samples.is_empty() { return Err(Error::InvalidStream); @@ -65,6 +73,52 @@ pub fn decode(bytes: &[u8], max_input: usize, max_samples: usize) -> Result= 4 { + // Four readable f32 inputs and four writable i16 outputs. Neither + // load nor store requires vector alignment or touches a tail lane. + unsafe { + let sample = f32x4_mul( + v128_load(input.as_ptr().add(at).cast()), + f32x4_splat(32768.0), + ); + let rounded = i32x4_trunc_sat_f32x4(f32x4_nearest(sample)); + let packed = i16x8_narrow_i32x4(rounded, rounded); + v128_store64_lane::<0>(packed, output.as_mut_ptr().add(at).cast()); + } + at += 4; + } + at + }; + #[cfg(not(all(target_arch = "wasm32", target_feature = "simd128")))] + let at = 0; + for (sample, value) in input[at..].iter().zip(&mut output[at..]) { + *value = pcm_sample(*sample, false); + } +} + // minimp3's PC synthesis uses scalar quantization for the pair at positions // 0/16 and nearest-even SIMD quantization for the other synthesis samples. fn pcm_sample(normalized: f32, scalar_pair: bool) -> i16 { @@ -102,4 +156,83 @@ mod tests { assert_eq!(decode(b"ID3", 2, 100).unwrap_err(), Error::InputLimit); assert_eq!(decode(b"ID3", 3, 100).unwrap_err(), Error::InvalidStream); } + + #[test] + fn synthesis_groups_preserve_every_lane_and_short_tail() { + check_synthesis_groups(); + } + + #[cfg(target_arch = "wasm32")] + #[unsafe(no_mangle)] + pub extern "C" fn check_wasm_synthesis_groups() { + check_synthesis_groups(); + } + + #[cfg(target_arch = "wasm32")] + mod wasm_decode { + use std::sync::Mutex; + static PCM: Mutex> = Mutex::new(Vec::new()); + + #[unsafe(no_mangle)] + pub extern "C" fn test_input_alloc(length: usize) -> *mut u8 { + assert!(length <= 32 * 1024 * 1024); + Box::into_raw(vec![0_u8; length].into_boxed_slice()) as *mut u8 + } + + #[unsafe(no_mangle)] + /// # Safety + /// Pass one allocation returned by test_input_alloc with its exact length. + pub unsafe extern "C" fn test_decode(pointer: *mut u8, length: usize) -> usize { + let input = + unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(pointer, length)) }; + let decoded = super::decode(&input, 32 * 1024 * 1024, 32 * 1024 * 1024).unwrap(); + let mut pcm = PCM.lock().unwrap(); + *pcm = decoded.samples; + pcm.len() + } + + #[unsafe(no_mangle)] + pub extern "C" fn test_pcm_pointer() -> *const i16 { + PCM.lock().unwrap().as_ptr() + } + } + + fn check_synthesis_groups() { + let values = [ + 0.0, + -0.0, + f32::from_bits(1), + -f32::from_bits(1), + f32::INFINITY, + f32::NEG_INFINITY, + f32::NAN, + f32::from_bits(0xffc12345), + -32767.5 / 32768.0, + 32766.5 / 32768.0, + -1.0 / 32768.0, + -2.5 / 32768.0, + 2.5 / 32768.0, + 1.0, + -1.0, + ]; + for channels in [1, 2] { + for offset in 0..values.len() { + let input: Vec<_> = (0..97) + .map(|index| values[(index + offset) % values.len()]) + .collect(); + for length in 0..input.len() { + let mut output = vec![12345; length + 2]; + quantize(&input[..length], &mut output[1..length + 1], channels); + assert_eq!(output[0], 12345); + assert_eq!(output[length + 1], 12345); + for index in 0..length { + assert_eq!( + output[index + 1], + pcm_sample(input[index], (index / channels) % 16 == 0) + ); + } + } + } + } + } } diff --git a/packages/formats/mp3/rust/tests/configured.rs b/packages/formats/mp3/rust/tests/configured.rs index 49b103c7b..bc5f89174 100644 --- a/packages/formats/mp3/rust/tests/configured.rs +++ b/packages/formats/mp3/rust/tests/configured.rs @@ -1,10 +1,56 @@ use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Seek, SeekFrom}, + ops::Range, + path::PathBuf, +}; + +struct Segments(PathBuf); +impl playsrc_vpk::SegmentReader for Segments { + fn len(&self, index: u32) -> Result { + Ok( + fs::metadata(self.0.join(format!("tf2_sound_misc_{index:03}.vpk"))) + .unwrap() + .len(), + ) + } + fn read(&self, index: u32, range: Range) -> Result, playsrc_vpk::SourceError> { + let mut file = + fs::File::open(self.0.join(format!("tf2_sound_misc_{index:03}.vpk"))).unwrap(); + file.seek(SeekFrom::Start(range.start)).unwrap(); + let mut bytes = vec![0; usize::try_from(range.end - range.start).unwrap()]; + file.read_exact(&mut bytes).unwrap(); + Ok(bytes) + } +} #[test] -#[ignore = "requires configured cow1.mp3; no game assets are distributed"] +#[ignore = "requires the exact configured TF2 sound archive; no game assets are distributed"] fn configured_mono_matches_public_minimp3_sse_pcm() { - let root = std::env::var_os("PLAYSRC_AUDIO_EVIDENCE").expect("explicit evidence directory"); - let input = std::fs::read(std::path::PathBuf::from(root).join("cow1.mp3")).unwrap(); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../.."); + let config: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("playsrc.local.json")).unwrap()).unwrap(); + let contract: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("games/tf2/content-build.json")).unwrap()) + .unwrap(); + let tf2 = PathBuf::from(config["tf2Dir"].as_str().unwrap()); + let directory = fs::read(tf2.join("tf2_sound_misc_dir.vpk")).unwrap(); + assert_eq!( + format!("{:x}", Sha256::digest(&directory)), + contract["archiveIndexes"]["tf2SoundMisc"].as_str().unwrap() + ); + let archive = playsrc_vpk::parse( + &directory, + "tf2_sound_misc_dir.vpk", + playsrc_vpk::Layout::Split, + Default::default(), + ) + .unwrap(); + let input = archive + .read_entry("sound/ambient_mp3/cow1.mp3", &Segments(tf2)) + .unwrap() + .bytes; assert_eq!( format!("{:x}", Sha256::digest(&input)), "6d5029641d1a058b5316d4fd49b7ee923ec6490bb5ce93e40fa25ccaa169aad5" @@ -14,6 +60,10 @@ fn configured_mono_matches_public_minimp3_sse_pcm() { (decoded.sample_rate, decoded.channels, decoded.samples.len()), (44100, 1, 73728) ); + assert_eq!( + decoded.samples.capacity(), + decoded.samples.len().next_power_of_two() + ); let mut digest = Sha256::new(); for sample in &decoded.samples { digest.update(sample.to_le_bytes()); @@ -28,4 +78,8 @@ fn configured_mono_matches_public_minimp3_sse_pcm() { playsrc_mp3::decode(&input, input.len(), decoded.samples.len() - 1).unwrap_err(), playsrc_mp3::Error::OutputLimit ); + let retained = PathBuf::from(config["sourceCacheDir"].as_str().unwrap()) + .join("evidence/tf2-wasm-simd-performance/configured"); + fs::create_dir_all(&retained).unwrap(); + fs::write(retained.join("cow1.mp3"), &input).unwrap(); } diff --git a/packages/presentation/audio/src/playback.ts b/packages/presentation/audio/src/playback.ts index 853030749..ec9a23466 100644 --- a/packages/presentation/audio/src/playback.ts +++ b/packages/presentation/audio/src/playback.ts @@ -1,4 +1,5 @@ import { AudioError } from "./error" +import { compileAudioModule } from "./wasm" import type { Listener, NeutralVoice, SoundSource } from "./source" export type PcmResource = Readonly<{ identity: string; sampleRate: number; numberOfChannels: number; bits: number; length: number; duration: number; loopStartSeconds: number | null }> @@ -60,8 +61,7 @@ export async function createSourceAudioSystem(context: AudioContext, moduleUrl: module = fetch(moduleUrl).then(async response => { if (!response.ok) throw new AudioError("BrowserFailure", "Audio module is unavailable") const bytes = await response.arrayBuffer() - if (bytes.byteLength > 8 * 1024 * 1024) throw new AudioError("Capacity", "Audio module exceeds its bound") - return WebAssembly.compile(bytes) + return compileAudioModule(bytes) }) moduleCache.set(moduleUrl.href, module) void module.catch(() => moduleCache.delete(moduleUrl.href)) diff --git a/packages/presentation/audio/src/wasm.ts b/packages/presentation/audio/src/wasm.ts new file mode 100644 index 000000000..983dcb374 --- /dev/null +++ b/packages/presentation/audio/src/wasm.ts @@ -0,0 +1,12 @@ +import { AudioError } from "./error" + +// (module (func (drop (v128.const i32x4 0 0 0 0)))) +// Check standard SIMD without validating the complete audio module twice. +const SIMD128 = new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,23,1,21,0,253,12,...Array(16).fill(0),26,11]) + +/** Reject an unsupported target before compiling or instantiating audio. */ +export function compileAudioModule(bytes: ArrayBuffer): Promise { + if (bytes.byteLength > 8 * 1024 * 1024) throw new AudioError("Capacity", "Audio module exceeds its bound") + if (typeof WebAssembly === "undefined" || !WebAssembly.validate(SIMD128)) throw new AudioError("BrowserFailure", "This browser lacks required standard WebAssembly SIMD128 support") + return WebAssembly.compile(bytes) +} diff --git a/packages/presentation/audio/tests/wasm.test.ts b/packages/presentation/audio/tests/wasm.test.ts new file mode 100644 index 000000000..3d16e3786 --- /dev/null +++ b/packages/presentation/audio/tests/wasm.test.ts @@ -0,0 +1,21 @@ +import { expect, spyOn, test } from "bun:test" +import { compileAudioModule } from "../src/wasm" + +// () -> (), v128.const zero; drop. Standard SIMD, no relaxed instructions. +const simd = new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,23,1,21,0,253,12,...Array(16).fill(0),26,11]).buffer + +test("audio validates actual SIMD bytecode before compiling, independently of GPU or browser identity", async () => { + expect(WebAssembly.validate(simd)).toBe(true) + expect(await compileAudioModule(simd)).toBeInstanceOf(WebAssembly.Module) + await expect(compileAudioModule(new ArrayBuffer(0))).rejects.toBeInstanceOf(WebAssembly.CompileError) + expect(() => compileAudioModule(new ArrayBuffer(8 * 1024 * 1024 + 1))).toThrow("exceeds its bound") +}) + +test("an unsupported SIMD target is rejected without compiling the audio module", () => { + const validate = spyOn(WebAssembly, "validate").mockReturnValue(false) + const compile = spyOn(WebAssembly, "compile") + try { + expect(() => compileAudioModule(simd)).toThrow("lacks required standard WebAssembly SIMD128") + expect(compile).not.toHaveBeenCalled() + } finally { validate.mockRestore(); compile.mockRestore() } +}) diff --git a/playwright.simd-profile.config.ts b/playwright.simd-profile.config.ts new file mode 100644 index 000000000..5a8acaa59 --- /dev/null +++ b/playwright.simd-profile.config.ts @@ -0,0 +1,2 @@ +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +export default headedProfileConfiguration({ match: "simd-decoder.profile.ts" }) diff --git a/tools/playsrc/local-jobs.md b/tools/playsrc/local-jobs.md index 6245a0780..384236960 100644 --- a/tools/playsrc/local-jobs.md +++ b/tools/playsrc/local-jobs.md @@ -27,6 +27,11 @@ Builds/tests open no browser. The browser stage installs only the Chromium and supporting binaries selected by the pinned Playwright package and records the executable hash; it never launches a browser or selects a fallback channel. +`simd-decoder` compares the configured MP3 decoder's scalar reference and SIMD +module in the real headed browser, not gameplay FPS. Prepare its authenticated +fixtures with `test tools/playsrc/tests/simd-configured.test.ts` at the exact +checkout commit, then use `PrepareProfile simd-decoder` and ordinary `Run`. + ## Windows consent and ownership Every Windows `run` above returns a scheduled task identity immediately. The diff --git a/tools/playsrc/profile/simd-decoder.profile.ts b/tools/playsrc/profile/simd-decoder.profile.ts new file mode 100644 index 000000000..372a0db60 --- /dev/null +++ b/tools/playsrc/profile/simd-decoder.profile.ts @@ -0,0 +1,113 @@ +import path from "node:path" +import { readFile, writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import { execFileSync } from "node:child_process" +import { test, expect, guardStartupInput } from "./application-test" +import { profileArtifact } from "./profile-artifacts" +import { startupNativeReader } from "./native-startup" +import { requireStartupNative } from "./static-startup-gate" +import { loadLocalConfig } from "../src/config" + +// Module initialization is silent, before the application fixture requests its +// native browser stage. Build these fixtures with simd-configured.test.ts first. +const config = await loadLocalConfig(process.cwd()) +const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex") +const index = JSON.parse(await readFile(path.join(config.sourceCacheDir, "simd-tests", hash(Buffer.from(path.resolve(process.cwd()))).slice(0, 8), "comparison.json"), "utf8")) +const recordBytes = await readFile(index.path) +if (hash(recordBytes) !== index.sha256) throw Error("SIMD comparison record changed") +const inputRecord = JSON.parse(recordBytes.toString()) +if (inputRecord.records.length !== 2 || inputRecord.records[0].variant !== "scalar" || inputRecord.records[1].variant !== "simd") throw Error("Expected exactly the scalar and SIMD comparison") +const commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", windowsHide: true }).trim() +if (inputRecord.commit !== commit) throw Error("Prepare SIMD fixtures at this exact source commit") +const input = await readFile(inputRecord.input.path) +if (inputRecord.input.sha256 !== "6d5029641d1a058b5316d4fd49b7ee923ec6490bb5ce93e40fa25ccaa169aad5" || hash(input) !== inputRecord.input.sha256 || input.length !== inputRecord.input.bytes) throw Error("Configured MP3 changed") +const modules = await Promise.all(inputRecord.records.map(async (record: any) => { + const bytes = await readFile(record.file) + if (hash(bytes) !== record.sha256 || bytes.length !== record.bytes) throw Error("SIMD module changed") + return { variant: record.variant, bytes: bytes.toString("base64"), sha256: record.sha256 } +})) + +test("headed browser executes exact scalar and SIMD decoder kernels", async ({ page }, testInfo) => { + const directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY! + const native = await startupNativeReader(page, config.sourceCacheDir) + guardStartupInput(page, async () => requireStartupNative(await native.read())) + let result: any, pixels: Buffer | undefined, failure: string | null = null + const admissions: unknown[] = [] + try { + const admission = await native.read(); requireStartupNative(admission); admissions.push(admission) + await page.goto("/") + await expect(page.locator("main")).toHaveAttribute("data-phase", "MainMenu", { timeout: 30_000 }) + await page.setContent('Source MP3 SIMD comparison

Source MP3 decoder: exact SIMD comparison

Configured input; real browser WebAssembly execution. This is a decoder diagnostic, not gameplay FPS or a freeze fix.

Preparing scalar and SIMD modules…
') + result = await page.evaluate(async ({ modules, encoded }) => { + const bytes = (value: string) => Uint8Array.from(atob(value), c => c.charCodeAt(0)) + const input = bytes(encoded), records = [] + const digest = async (value: Uint8Array) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", value))].map(value => value.toString(16).padStart(2, "0")).join("") + const states = [] + for (const entry of modules) { + const binary = bytes(entry.bytes), valid = WebAssembly.validate(binary), began = performance.now() + if (!valid) throw Error(`${entry.variant} module unsupported`) + const module = await WebAssembly.compile(binary), compiled = performance.now() + const e = (await WebAssembly.instantiate(module)).exports as any, instantiated = performance.now() + e.check_wasm_synthesis_groups() + const invoke = () => { + const pointer = e.test_input_alloc(input.length) + new Uint8Array(e.memory.buffer, pointer, input.length).set(input) + const started = performance.now(), count = e.test_decode(pointer, input.length) + const milliseconds = performance.now() - started + if (count !== 73728) throw Error("Decoded sample count changed") + return { milliseconds, count } + } + const first = invoke() + const pcm = new Uint8Array(e.memory.buffer, e.test_pcm_pointer(), first.count * 2).slice() + const pcmSha256 = await digest(pcm) + const record = { variant: entry.variant, moduleSha256: await digest(binary), valid, compileMilliseconds: compiled-began, instantiateMilliseconds: instantiated-compiled, + firstDecodeMilliseconds: first.milliseconds, samples: first.count, pcmSha256, linearBytes: e.memory.buffer.byteLength, batches: [] as number[], calls: [] as number[] } + records.push(record); states.push({ invoke, record, memory: e.memory as WebAssembly.Memory, + pcm: () => new Uint8Array(e.memory.buffer, e.test_pcm_pointer(), first.count * 2).slice() }) + if (entry.variant === "simd") { + const samples = new Int16Array(pcm.buffer), canvas = document.querySelector("canvas")!, context = canvas.getContext("2d")! + context.strokeStyle="#66dfb7"; context.beginPath() + for(let x=0;x<1000;x++){const value=samples[Math.floor(x*samples.length/1000)]!/32768; const y=140-value*125; if(x===0)context.moveTo(x,y);else context.lineTo(x,y)}context.stroke() + } + for (let i=0;i<8;i++) invoke() + } + // Alternate ABBA order. Each measurement spans 32 real decoder calls, + // reducing timer granularity without changing the per-call sample/input. + const sampleStart=performance.now(); let round=0 + while(performance.now()-sampleStart<5000) { + for(const i of round++%2===0?[0,1,1,0]:[1,0,0,1]) { + let elapsed=0;for(let n=0;n<32;n++){ + const value=states[i]!.invoke().milliseconds + if(states[i]!.record.calls.length>=65536)throw Error("Decoder sample count exceeded its bound") + states[i]!.record.calls.push(value);elapsed+=value + } + states[i]!.record.batches.push(elapsed/32) + } + await new Promise(resolve=>requestAnimationFrame(()=>resolve())) + } + const sampleEnd=performance.now() + for(const state of states){const record=state.record,values=[...record.batches].sort((a,b)=>a-b),calls=[...record.calls].sort((a,b)=>a-b);Object.assign(record,{median:values[Math.floor(values.length/2)],p95:values[Math.floor(values.length*.95)],maximum:values.at(-1),callMedian:calls[Math.floor(calls.length/2)],callP95:calls[Math.floor(calls.length*.95)],callP99:calls[Math.floor(calls.length*.99)],callMaximum:calls.at(-1),finalLinearBytes:state.memory.buffer.byteLength,finalPcmSha256:await digest(state.pcm())})} + document.querySelector("#result")!.textContent=records.map((r:any)=>`${r.variant}: batch median ${r.median.toFixed(4)} ms/call\n individual calls: p95 ${r.callP95.toFixed(4)} ms, MAX ${r.callMaximum.toFixed(4)} ms`).join("\n") + return { records, timeOrigin:performance.timeOrigin,sampleStartedMilliseconds:sampleStart,sampleEndedMilliseconds:sampleEnd,sampleMilliseconds:sampleEnd-sampleStart, userAgent:navigator.userAgent, platform:navigator.platform, hardwareConcurrency:navigator.hardwareConcurrency, + browserEvidence:true, sustainedGameplayEvidence:false, scope:"Configured decoder kernel; edge checks, first decode and eight warmup decodes precede sampling. Every measured call retained; ABBA batches contain 32 calls. Host input allocation/copy excluded identically, decoder allocations and output replacement/free included. No gameplay or freeze claim." } + }, { modules, encoded: input.toString("base64") }) + const after = await native.read(); requireStartupNative(after); admissions.push(after) + for (const record of result.records) { + expect(record.samples).toBe(73728) + expect(record.pcmSha256).toBe("b1e43ccf681c3529aad850231599216cfd55778a27bb559b8859917be486ee42") + expect(record.finalPcmSha256).toBe(record.pcmSha256) + } + pixels = await page.screenshot() + } catch (error) { failure = String(error); throw error } + finally { + await native.close() + await profileArtifact(async () => { + const image=pixels?{file:"simd-decoder.png",bytes:pixels.length,sha256:hash(pixels)}:null + await writeFile(path.join(directory, "simd-decoder.json"), JSON.stringify({ commit, inputRecord, result: result ?? null, admissions, failure, image }, null, 2)) + if (pixels) { + await writeFile(path.join(directory, "simd-decoder.png"), pixels) + await testInfo.attach("simd-decoder", { body: pixels, contentType: "image/png" }) + } + }) + } +}) diff --git a/tools/playsrc/src/profile-runner.ts b/tools/playsrc/src/profile-runner.ts index 467e436fa..5a26d1177 100644 --- a/tools/playsrc/src/profile-runner.ts +++ b/tools/playsrc/src/profile-runner.ts @@ -35,6 +35,7 @@ const PROFILES = Object.freeze({ "browser-input": { config: "playwright.browser-input.config.ts", target: "pl_upward", minimumRemainingMilliseconds: 90_000 }, "browser-input-lifecycle": { config: "playwright.browser-input.config.ts", target: "pl_upward", environment: { PROFILE_INPUT_LIFECYCLE_ONLY: "1" }, minimumRemainingMilliseconds: 75_000 }, "runner-handoff": { config: "playwright.runner-handoff.config.ts", target: "jump_beef" }, + "simd-decoder": { config: "playwright.simd-profile.config.ts", target: "koth_viaduct", environment: process.platform === "win32" ? { PLAYSRC_PROFILE_BROWSER_CHANNEL: "msedge" } : {}, minimumRemainingMilliseconds: 40_000 }, "damage-indicator": { config: "playwright.profile.config.ts", target: "pl_upward", environment: { PROFILE_SCENARIOS: "damage-indicator" }, minimumRemainingMilliseconds: environment => environment.PROFILE_DAMAGE_LIFECYCLE === "1" ? 70_000 : environment.PROFILE_DAMAGE_DYNAMIC === "1" ? 40_000 : DEFAULT_BROWSER_MINIMUM_MILLISECONDS }, "setup-round": { config: "playwright.profile.config.ts", target: "pl_upward", environment: { PROFILE_SCENARIOS: "setup-round" }, minimumRemainingMilliseconds: 140_000 }, "soundscape-selection": { config: "playwright.profile.config.ts", target: "cp_granary", environment: { PROFILE_SCENARIOS: "soundscape-selection" }, minimumRemainingMilliseconds: 60_000 }, diff --git a/tools/playsrc/src/tf2-wasm-build.ts b/tools/playsrc/src/tf2-wasm-build.ts index b6165ee94..06c69426d 100644 --- a/tools/playsrc/src/tf2-wasm-build.ts +++ b/tools/playsrc/src/tf2-wasm-build.ts @@ -26,12 +26,20 @@ export function threadedWasmRustFlags(root: string, cargoHome: string, sysroot: "-Clink-arg=--export=__tls_size", "-Clink-arg=--export=__tls_align", "-Clink-arg=--export=__tls_base", - `--remap-path-prefix=${root}=/playsrc`, - `--remap-path-prefix=${cargoHome}=/cargo`, - `--remap-path-prefix=${sysroot}=/rust`, + ...wasmSourcePathFlags(root, cargoHome, sysroot), ] } +function wasmSourcePathFlags(root: string, cargoHome: string, sysroot: string): string[] { + return [`--remap-path-prefix=${root}=/playsrc`, `--remap-path-prefix=${cargoHome}=/cargo`, `--remap-path-prefix=${sysroot}=/rust`] +} + +export function audioWasmRustFlags(root: string, cargoHome: string, sysroot: string): string[] { + // Audio has its own unshared memory and measured decoder SIMD requirement. + // Gameplay keeps its independently qualified target features. + return ["-Ctarget-feature=+simd128", ...wasmSourcePathFlags(root, cargoHome, sysroot)] +} + type WasmBuildManifest = Readonly<{ schema: "playsrc-threaded-wasm-build-v2" identity: string @@ -198,7 +206,7 @@ export async function buildThreadedTf2Wasm( "--target", "wasm32-unknown-unknown", "--target-dir", audioTarget, "--release", "-Z", "build-std=panic_abort,std", ], { cwd: repositoryRoot, - env: { ...buildEnvironment, RUSTFLAGS: undefined, CARGO_ENCODED_RUSTFLAGS: flags.filter(flag => flag.startsWith("--remap-path-prefix=")).join("\x1f"), CARGO_BUILD_JOBS: process.env.PLAYSRC_PROFILE_OWNER_TOKEN ? "2" : process.env.CARGO_BUILD_JOBS }, + env: { ...buildEnvironment, RUSTFLAGS: undefined, CARGO_ENCODED_RUSTFLAGS: audioWasmRustFlags(repositoryRoot, cargoHome, sysroot).join("\x1f"), CARGO_BUILD_JOBS: process.env.PLAYSRC_PROFILE_OWNER_TOKEN ? "2" : process.env.CARGO_BUILD_JOBS }, stdout: "inherit", stderr: "inherit", }) const audioExit = await audio.exited diff --git a/tools/playsrc/tests/simd-configured.test.ts b/tools/playsrc/tests/simd-configured.test.ts new file mode 100644 index 000000000..a22ddcab1 --- /dev/null +++ b/tools/playsrc/tests/simd-configured.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { loadLocalConfig, repositoryRoot } from "../src/config" +import { rustEnvironment } from "../src/setup" +import { acquireHeadedProfileLock, releaseHeadedProfileLock } from "../src/profile-lock" +import { borrowedWindowsJobLock } from "../src/windows-job-native" +import toolchains from "../toolchains.json" + +// Configured acceptance is opt-in on the local host, or explicitly scheduled +// under the native local-job supervisor. It is not a browser/profile admission. +test.skipIf(!process.env.PLAYSRC_LOCAL_JOB_OWNER && process.env.RUN_CONFIGURED_SIMD_TESTS !== "1")("configured native and actual scalar/SIMD WASM PCM are byte-exact", async () => { + const config = await loadLocalConfig() + const lockPath = path.join(config.sourceCacheDir, "evidence/tf2-browser-performance/chromium-profile.lock") + const borrowed = await borrowedWindowsJobLock(lockPath, { testFile: import.meta.filename }) + if (process.platform === "win32" && !borrowed) throw new Error("Windows configured tests require the native local-job supervisor") + const lock = borrowed ?? await acquireHeadedProfileLock(lockPath, "simd-configured-parity", 175_000) + const directory = path.join(config.sourceCacheDir, "evidence/tf2-wasm-simd-performance", `configured-${process.platform}-${crypto.randomUUID()}`) + await mkdir(directory, { recursive: true }) + const environment = { ...process.env, ...rustEnvironment(config.sourceCacheDir) } + const cargo = path.join(config.sourceCacheDir, "toolchains/rust/cargo/bin", process.platform === "win32" ? "cargo.exe" : "cargo") + const run = async (name: string, args: string[], flags?: string) => { + const child = Bun.spawn([cargo, `+${toolchains.rust.threadedToolchain}`, ...args], { cwd: repositoryRoot, + env: { ...environment, RUSTFLAGS: flags, CARGO_ENCODED_RUSTFLAGS: undefined, CARGO_BUILD_JOBS: "2" }, stdout: "pipe", stderr: "pipe", timeout: 145_000 }) + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]) + await writeFile(path.join(directory, `${name}.log`), stdout + stderr) + expect(code, stderr).toBe(0) + } + const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex") + try { + await run("native", ["test", "--locked", "-p", "playsrc-mp3", "--", "--include-ignored"]) + await run("native-audio", ["test", "--locked", "-p", "playsrc-audio"]) + const input = await readFile(path.join(config.sourceCacheDir, "evidence/tf2-wasm-simd-performance/configured/cow1.mp3")) + expect(hash(input)).toBe("6d5029641d1a058b5316d4fd49b7ee923ec6490bb5ce93e40fa25ccaa169aad5") + const records = [] + for (const simd of [false, true]) { + // MSVC's host build-script linker still has a bounded output path. Keep + // Cargo scratch short and owned by this checkout; retain module bytes in + // the unique evidence run rather than relying on mutable Cargo output. + const variant = simd ? "simd" : "scalar", target = path.join(config.sourceCacheDir, "simd-tests", hash(Buffer.from(repositoryRoot)).slice(0, 8), variant) + await run(variant, ["rustc", "--locked", "-p", "playsrc-mp3", "--lib", "--crate-type=cdylib", "--target", "wasm32-unknown-unknown", "--target-dir", target, "--release", "-Z", "build-std=panic_abort,std", "--", "--cfg", "test"], `-Ctarget-feature=${simd ? "+" : "-"}simd128`) + const file = path.join(directory, `${variant}.wasm`) + await copyFile(path.join(target, "wasm32-unknown-unknown/release/playsrc_mp3.wasm"), file) + const bytes = await readFile(file) + const { instance } = await WebAssembly.instantiate(bytes), e = instance.exports as any + e.check_wasm_synthesis_groups() + const pointer = e.test_input_alloc(input.length) + new Uint8Array(e.memory.buffer, pointer, input.length).set(input) + const count = e.test_decode(pointer, input.length) + expect(count).toBe(73728) + const pcmSha256 = hash(new Uint8Array(e.memory.buffer, e.test_pcm_pointer(), count * 2)) + expect(pcmSha256).toBe("b1e43ccf681c3529aad850231599216cfd55778a27bb559b8859917be486ee42") + records.push({ variant, file, bytes: bytes.length, sha256: hash(bytes), samples: count, pcmSha256 }) + } + const commit = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: repositoryRoot }).stdout.toString().trim() + const result = JSON.stringify({ commit, platform: process.platform, arch: process.arch, engine: process.versions, browserEvidence: false, input: { path: path.join(config.sourceCacheDir, "evidence/tf2-wasm-simd-performance/configured/cow1.mp3"), bytes: input.length, sha256: hash(input) }, records }, null, 2) + const resultPath = path.join(directory, "result.json") + await writeFile(resultPath, result) + const index = path.join(config.sourceCacheDir, "simd-tests", hash(Buffer.from(path.resolve(repositoryRoot))).slice(0, 8), "comparison.json") + await mkdir(path.dirname(index), { recursive: true }) + await writeFile(index, JSON.stringify({ path: resultPath, sha256: hash(Buffer.from(result)) })) + console.log(`SIMD parity evidence: ${directory}`) + } finally { if (!borrowed) await releaseHeadedProfileLock(lockPath, lock.token) } +}, 175_000) diff --git a/tools/playsrc/tests/tf2-wasm-build.test.ts b/tools/playsrc/tests/tf2-wasm-build.test.ts index 5b5bb5cb0..927e7da4c 100644 --- a/tools/playsrc/tests/tf2-wasm-build.test.ts +++ b/tools/playsrc/tests/tf2-wasm-build.test.ts @@ -2,11 +2,12 @@ import { expect, test } from "bun:test" import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" -import { threadedWasmRustFlags } from "../src/tf2-wasm-build" +import { audioWasmRustFlags, threadedWasmRustFlags } from "../src/tf2-wasm-build" test("threaded WASM flags retain memory contracts and encode paths with spaces as single arguments", () => { const flags = threadedWasmRustFlags("/build one/app", "/build one/cargo", "/build one/rust") expect(flags).toContain("-Ctarget-feature=+atomics,+bulk-memory") + expect(flags.some(flag => flag.includes("simd128"))).toBe(false) expect(flags).toContain("-Clink-arg=--shared-memory") expect(flags).toContain("-Clink-arg=--max-memory=4294967296") expect(flags).toContain("--remap-path-prefix=/build one/app=/playsrc") @@ -15,6 +16,21 @@ test("threaded WASM flags retain memory contracts and encode paths with spaces a expect(flags.join("\x1f").split("\x1f")).toEqual(flags) }) +test("audio enables standard SIMD independently without importing gameplay memory or TLS", () => { + const flags = audioWasmRustFlags("C:/build one/app", "C:/build one/cargo", "C:/build one/rust") + expect(flags).toEqual([ + "-Ctarget-feature=+simd128", + "--remap-path-prefix=C:/build one/app=/playsrc", + "--remap-path-prefix=C:/build one/cargo=/cargo", + "--remap-path-prefix=C:/build one/rust=/rust", + ]) + expect(flags.join("\x1f").split("\x1f")).toEqual(flags) + for (const build of [flags, threadedWasmRustFlags("app", "cargo", "rust")]) { + expect(build.filter(flag => flag.startsWith("-Ctarget-feature="))).toHaveLength(1) + expect(build.some(flag => flag.includes("relaxed-simd") || flag.includes("fast-math"))).toBe(false) + } +}) + test("source-location constants compile identically from distinct absolute roots", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "playsrc-remap-")) try { @@ -31,7 +47,9 @@ test("source-location constants compile identically from distinct absolute roots const errors = await new Response(child.stderr).text() expect(await child.exited, errors).toBe(0) const bytes = await readFile(output) - expect(bytes.toString()).toContain("/playsrc/fixture.rs") + // LLVM IR escapes the host separator retained by file!() after the + // directory prefix is remapped. Compare roots on the same target host. + expect(bytes.toString()).toContain(`/playsrc${process.platform === "win32" ? "\\\\" : "/"}fixture.rs`) expect(bytes.toString()).not.toContain(root) outputs.push(bytes) }