Finished up most documentation

This commit is contained in:
2026-09-10 21:11:07 -07:00
parent 2cfa0ca690
commit 8355743ada
14 changed files with 1871 additions and 868 deletions

View File

@@ -231,3 +231,114 @@ test('reduced-motion opening keeps the existing 70 ms transition duration', (t)
step(1)
assert.equal(pack.state, 'stackReady')
})
test('GPU preparation uploads unique textures and compiles hidden fronts and both edge looks', async (t) => {
const { pack, canvas } = setupPack(t)
pack.setActive(false)
const scene = new THREE.Scene()
scene.environment = pack.options.environment
const backTexture = new THREE.Texture()
pack.options.backMaterial.map = backTexture
const uploaded = []
const compiledEdges = []
let reveals = 0
canvas.addEventListener('packreveal', () => reveals++)
const before = geometryHash(pack.wrapper)
let finishCompile
const firstCompile = new Promise((resolve) => { finishCompile = resolve })
const renderer = {
initTexture(texture) { uploaded.push(texture) },
async compileAsync(root, camera, targetScene) {
assert.equal(root, pack.root)
assert.equal(camera, pack.camera)
assert.equal(targetScene, scene)
assert.equal(root.visible, false)
assert.equal(pack.state, 'sealed')
assert.equal(pack.cards.some((card) => card.front.visible), false)
assert.ok(pack.cards.every((card) => root.getObjectById(card.front.id)))
compiledEdges.push(pack.cards.map((card) => card.edge.clearcoat))
if (compiledEdges.length === 1) await firstCompile
},
}
let ready = false
const preparation = pack.prepareGPU(renderer, scene).then(() => { ready = true })
await Promise.resolve()
assert.equal(ready, false)
finishCompile()
await preparation
assert.equal(uploaded.length, new Set(uploaded).size)
assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps')
for (const { artwork, mask } of Object.values(pack.options.textures)) {
assert.ok(uploaded.includes(artwork))
assert.ok(uploaded.includes(mask))
}
assert.ok(uploaded.includes(backTexture))
assert.ok(uploaded.includes(pack.options.normalMap))
assert.ok(uploaded.includes(scene.environment))
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(geometryHash(pack.wrapper), before)
assert.equal(reveals, 0)
})
test('failed reveal-material warmup restores neutral edges and can be retried', async (t) => {
const { pack } = setupPack(t)
pack.setActive(false)
let compilations = 0
const failure = new Error('GPU compilation failed')
const renderer = {
initTexture() {},
async compileAsync() {
if (++compilations === 2) throw failure
},
}
const scene = new THREE.Scene()
await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure)
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(pack.root.visible, false)
assert.equal(pack.state, 'sealed')
await pack.prepareGPU(renderer, scene)
assert.equal(compilations, 4)
})
test('GPU upload errors propagate and preparation cannot mutate an active pack', async (t) => {
const { pack } = setupPack(t)
const failure = new Error('GPU upload failed')
let uploads = 0
const renderer = {
initTexture() { uploads++; throw failure },
async compileAsync() { assert.fail('Compilation must not run after upload failure') },
}
const scene = new THREE.Scene()
await assert.rejects(pack.prepareGPU(renderer, scene), /before activating/)
assert.equal(uploads, 0)
pack.setActive(false)
await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure)
})
test('pack cleanup disposes owned resources without disposing shared or caller-owned textures', (t) => {
const { pack } = setupPack(t)
const scene = new THREE.Scene()
scene.add(pack.root)
const shared = [
pack.options.backMaterial, pack.options.normalMap, pack.options.environment,
...Object.values(pack.options.textures).flatMap(({ artwork, mask }) => [artwork, mask]),
]
const sharedSpies = shared.map((resource) => t.mock.method(resource, 'dispose'))
const geometries = new Set()
const materials = new Set()
pack.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
geometries.add(object.geometry)
materials.add(object.material)
})
materials.delete(pack.options.backMaterial)
const resources = [...geometries, ...materials,
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
pack.dispose()
assert.equal(pack.root.parent, null)
assert.equal(pack.root.visible, false)
assert.ok(ownedSpies.every((spy) => spy.mock.callCount() === 1))
assert.ok(sharedSpies.every((spy) => spy.mock.callCount() === 0))
})

View File

@@ -0,0 +1,127 @@
import * as THREE from 'three'
import { PackOpening, packContents } from '../src/packOpening.ts'
import { createStudioCubeTexture } from '../src/cardMaterial.ts'
export async function verifyPackPreparation() {
const startedAt = performance.now()
const loader = new THREE.TextureLoader()
const paths = ['david-front', 'david-finish-mask', 'timothy-front', 'timothy-finish-mask', 'card-back', 'linen-normal']
const textures = await Promise.all(paths.map((path) => loader.loadAsync(`/reference/${path}.png`)))
for (const index of [0, 2, 4]) textures[index].colorSpace = THREE.SRGBColorSpace
textures[5].wrapS = textures[5].wrapT = THREE.RepeatWrapping
const environment = createStudioCubeTexture()
const results = []
try {
for (const lightType of ['Point', 'Directional', 'Spot']) {
// A fresh context prevents the preceding pixel test from accidentally warming pack shaders.
const renderer = new THREE.WebGLRenderer({ antialias: false })
renderer.setSize(192, 264)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
const shaderErrors = []
renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
shaderErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex),
gl.getShaderInfoLog(fragment)].join('\n'))
}
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b0e14')
scene.environment = environment
const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const light = lightType === 'Point' ? new THREE.PointLight('#fff0d0', 34, 20, 1.7)
: lightType === 'Directional' ? new THREE.DirectionalLight('#fff0d0', 5.4)
: new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7)
light.position.copy(lightPosition)
scene.add(light)
if (light.target) scene.add(light.target)
const backMaterial = new THREE.MeshPhysicalMaterial({
map: textures[4], roughness: 0.42, metalness: 0, clearcoat: 0.2,
clearcoatRoughness: 0.24, normalMap: null, side: THREE.DoubleSide,
})
const pack = new PackOpening({
canvas: renderer.domElement,
textures: {
David: { artwork: textures[0], mask: textures[1] },
Timothy: { artwork: textures[2], mask: textures[3] },
},
backMaterial, normalMap: textures[5], environment, lightPosition, onChange() {},
})
pack.resize(192, 264)
const revealed = []
renderer.domElement.addEventListener('packreveal', (event) => revealed.push(event.detail))
const originalNow = performance.now
try {
const programsBeforePreparation = renderer.info.programs.length
await pack.prepareGPU(renderer, scene)
if (pack.root.visible || pack.state !== 'sealed' || revealed.length) {
throw new Error('Preparation changed visibility or reveal state')
}
const preparedPrograms = new Set(renderer.info.programs.map((program) => program.id))
if (!preparedPrograms.size) throw new Error('Preparation compiled no programs')
const texturesAfterPreparation = renderer.info.memory.textures
const unexpectedPrograms = new Map()
const visitedStates = new Set()
let renderedFrames = 0
let maximumTextureCount = texturesAfterPreparation
const textureGrowth = []
let now = originalNow.call(performance)
performance.now = () => now
function frame() {
pack.tick(now)
renderer.render(scene, pack.camera)
renderedFrames++
visitedStates.add(pack.state)
if (renderer.info.memory.textures > maximumTextureCount) {
textureGrowth.push({ state: pack.state, count: renderer.info.memory.textures })
}
maximumTextureCount = Math.max(maximumTextureCount, renderer.info.memory.textures)
for (const program of renderer.info.programs) {
if (!preparedPrograms.has(program.id) && !unexpectedPrograms.has(program.id)) {
unexpectedPrograms.set(program.id, { id: program.id, name: program.name, firstState: pack.state })
}
}
const gl = renderer.getContext()
if (shaderErrors.length || gl.isContextLost() || gl.getError() !== gl.NO_ERROR) {
throw new Error(`Pack WebGL rendering failed: ${shaderErrors.join('\n')}`)
}
}
scene.add(pack.root)
pack.setActive(true)
frame()
const durations = { opening: 3200, lifting: 620, revealing: 820, advancing: 650 }
for (let action = 0; action < 10 && pack.state !== 'complete'; action++) {
pack.primary()
const duration = durations[pack.state]
if (!duration) throw new Error(`Unexpected animated state: ${pack.state}`)
const started = now
for (const progress of [0, 0.1, 0.25, 0.4, 0.5, 0.65, 0.8, 0.9, 1.001]) {
now = started + duration * progress
frame()
}
}
if (pack.state !== 'complete' || JSON.stringify(revealed.map(({ fixture, finish, substrate }) =>
({ fixture, finish, substrate }))) !== JSON.stringify(packContents)) {
throw new Error('First reveal cycle did not complete in authored order')
}
pack.restart()
frame()
results.push({
lightType, passed: unexpectedPrograms.size === 0, programsBeforePreparation,
programsAfterPreparation: preparedPrograms.size,
programsAfterRevealAndRestart: renderer.info.programs.length,
unexpectedPrograms: [...unexpectedPrograms.values()], texturesAfterPreparation,
maximumTextureCount, textureGrowth, renderedFrames, visitedStates: [...visitedStates], revealed,
})
} finally {
performance.now = originalNow
pack.dispose()
backMaterial.dispose()
renderer.dispose()
renderer.forceContextLoss()
}
}
return { passed: results.every((result) => result.passed),
durationMs: Math.round(performance.now() - startedAt), results }
} finally {
for (const texture of [...textures, environment]) texture.dispose()
}
}

View File

@@ -0,0 +1,191 @@
import * as THREE from 'three'
import { createCardMaterial, createStudioCubeTexture, applyMaterialControls } from '../src/cardMaterial.ts'
import { createCardGeometry } from '../src/cardGeometry.ts'
import { verifyPackPreparation } from './shader-pack-preparation.mjs'
const width = 192
const height = 264
const substrates = ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']
const finishes = ['Printed ink', 'Foil', 'Holographic']
const conditions = [1, 0.999, 0.7, 0]
const angles = [0, 75]
const lights = [
{ name: 'Point', mode: 0, position: [2.2, 2.5, 4.4] },
{ name: 'Directional', mode: 1, position: [-2.8, 3.1, 4.8] },
{ name: 'Spot', mode: 2, position: [3.6, 1.9, 3.6] },
]
async function compare() {
const started = performance.now()
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(1)
renderer.setClearColor(0, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
const compileErrors = []
renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
compileErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex),
gl.getShaderInfoLog(fragment)].join('\n'))
}
const gl = renderer.getContext()
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
const gpu = {
version: gl.getParameter(gl.VERSION),
vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
userAgent: navigator.userAgent,
}
const loader = new THREE.TextureLoader()
const [artwork, mask, normal, referenceSource] = await Promise.all([
loader.loadAsync('/reference/david-front.png'),
loader.loadAsync('/reference/david-finish-mask.png'),
loader.loadAsync('/reference/linen-normal.png'),
fetch('/__shader_reference').then((response) => response.json()),
])
artwork.colorSpace = THREE.SRGBColorSpace
mask.colorSpace = THREE.NoColorSpace
normal.colorSpace = THREE.NoColorSpace
normal.wrapS = normal.wrapT = THREE.RepeatWrapping
const environment = createStudioCubeTexture()
const candidate = createCardMaterial(artwork, mask, normal, environment, new THREE.Vector3())
const reference = candidate.clone()
reference.fragmentShader = referenceSource.fragmentShader
const baseline = candidate.clone()
baseline.fragmentShader = referenceSource.baselineShader
const edge = new THREE.MeshBasicMaterial({ color: '#9c8c69' })
const card = createCardGeometry(candidate, candidate, edge)
const front = card.getObjectByName('CARD_FRONT')
// Isolate the changed face shader; unchanged edge/back pixels must not dilute coverage.
card.getObjectByName('CARD_EDGE').visible = false
card.getObjectByName('CARD_BACK').visible = false
const scene = new THREE.Scene()
scene.add(card)
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100)
camera.position.set(0, 0, 6.4)
camera.lookAt(0, 0, 0)
const target = new THREE.WebGLRenderTarget(width, height, {
type: THREE.UnsignedByteType,
format: THREE.RGBAFormat,
depthBuffer: true,
stencilBuffer: false,
})
target.texture.colorSpace = THREE.SRGBColorSpace
const currentPixels = new Uint8Array(width * height * 4)
const originalPixels = new Uint8Array(currentPixels.length)
const baselinePixels = new Uint8Array(currentPixels.length)
function render(material, buffer) {
front.material = material
renderer.setRenderTarget(target)
renderer.render(scene, camera)
renderer.readRenderTargetPixels(target, 0, 0, width, height, buffer)
if (compileErrors.length) throw new Error(compileErrors.join('\n'))
if (gl.isContextLost()) throw new Error('WebGL context lost')
const error = gl.getError()
if (error !== gl.NO_ERROR) throw new Error(`WebGL error: ${error}`)
}
const failures = []
const summary = { cases: 0, comparedPixels: 0, differingPixels: 0, differingChannels: 0, maxChannelDelta: 0 }
const byCondition = Object.fromEntries(conditions.map((value) => [value,
{ cases: 0, differingPixels: 0, maxChannelDelta: 0 }]))
let minimumCoveredPixels = Infinity
let negativeControl
const baselineComparison = { unchangedCases: 0, differingPixels: 0, changedPixels: { Linen: 0, Metal: 0, Wood: 0 } }
try {
for (const substrate of substrates) for (const finish of finishes) {
for (const condition of conditions) for (const angle of angles) for (const light of lights) {
const controls = {
substrate, finish, condition, imperfectionSeed: 81251, finishStrength: 0.6,
roughness: 0.23, normalStrength: 0.14, environmentIntensity: 0.7,
}
for (const material of [candidate, reference, baseline]) {
applyMaterialControls(material, controls)
material.uniforms.lightPosition.value.set(...light.position)
material.uniforms.lightMode.value = light.mode
}
card.rotation.set(0, THREE.MathUtils.degToRad(angle), 0)
render(reference, originalPixels)
render(candidate, currentPixels)
let differingPixels = 0
let maxChannelDelta = 0
let coveredPixels = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (originalPixels[i + 3]) coveredPixels++
let different = false
for (let channel = 0; channel < 4; channel++) {
const delta = Math.abs(originalPixels[i + channel] - currentPixels[i + channel])
if (delta) {
different = true
summary.differingChannels++
maxChannelDelta = Math.max(maxChannelDelta, delta)
}
}
if (different) differingPixels++
}
minimumCoveredPixels = Math.min(minimumCoveredPixels, coveredPixels)
if (coveredPixels < width * height * 0.05) throw new Error('Insufficient rendered card coverage')
summary.cases++
summary.comparedPixels += width * height
summary.differingPixels += differingPixels
summary.maxChannelDelta = Math.max(summary.maxChannelDelta, maxChannelDelta)
byCondition[condition].cases++
byCondition[condition].differingPixels += differingPixels
byCondition[condition].maxChannelDelta = Math.max(byCondition[condition].maxChannelDelta, maxChannelDelta)
if (differingPixels && failures.length < 30) {
failures.push({ substrate, finish, condition, angle, light: light.name, differingPixels, maxChannelDelta })
}
render(baseline, baselinePixels)
let changed = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== baselinePixels[i + channel])) changed++
}
if (Object.hasOwn(baselineComparison.changedPixels, substrate)) {
baselineComparison.changedPixels[substrate] += changed
} else {
baselineComparison.unchangedCases++
baselineComparison.differingPixels += changed
}
if (!negativeControl && condition === 1 && angle === 0) {
candidate.uniforms.condition.value = 0
render(candidate, currentPixels)
let changed = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== originalPixels[i + channel])) changed++
}
if (changed < 100) throw new Error('Negative control failed: fully worn and mint unexpectedly match')
negativeControl = { mintVsFullyWornDifferingPixels: changed }
}
}
// Keep the browser event loop responsive, without unbounded animation frames.
await new Promise((resolve) => setTimeout(resolve, 0))
}
return {
passed: summary.differingPixels === 0 && summary.cases === 360
&& baselineComparison.differingPixels === 0
&& Object.values(baselineComparison.changedPixels).every(value => value > 0),
gpu, resolution: [width, height], substrates, finishes, conditions, angles, lights,
seed: 81251, tolerance: 0, ...summary, byCondition, minimumCoveredPixels, negativeControl,
baselineComparison, failures, durationMs: Math.round(performance.now() - started),
}
} finally {
target.dispose()
candidate.dispose()
reference.dispose()
baseline.dispose()
edge.dispose()
card.traverse((object) => object.geometry?.dispose())
for (const texture of [artwork, mask, normal, environment]) texture.dispose()
renderer.dispose()
}
}
try {
const report = await compare()
report.packPreparation = await verifyPackPreparation()
report.passed &&= report.packPreparation.passed
await fetch('/__shader_pixels', { method: 'POST', body: JSON.stringify(report) })
} catch (error) {
await fetch('/__shader_pixels', {
method: 'POST', body: JSON.stringify({ passed: false, error: String(error), stack: error.stack }),
})
}

View File

@@ -0,0 +1,126 @@
// Run: node scripts/shader-pixel-regression.mjs
// Optional: SHADER_BROWSER=/path/to/firefox SHADER_TIMEOUT_MS=180000
// Chromium: SHADER_BROWSER=/path/to/chrome-headless-shell SHADER_BROWSER_KIND=chromium
// Hosts without user namespaces: SHADER_NO_SANDBOX=1 (local test content only).
// No automation dependency: the browser POSTs WebGL readback results to a loopback Vite server.
// Also checks that prepared packs create no new programs through reveal and restart.
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { readFile, mkdir, writeFile, rm } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
import { originalFragment, baselineFragment, referenceCommit, referenceHash } from './shader-wear-reference.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profile = resolve(root, 'scripts', `.shader-browser-${process.pid}`)
const timeoutMs = Number(process.env.SHADER_TIMEOUT_MS ?? 180000)
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
throw new Error('SHADER_TIMEOUT_MS must be between 1000 and 600000')
}
const fragmentShader = originalFragment(await readFile(resolve(root, 'src/cardMaterial.ts'), 'utf8'))
const baselineShader = baselineFragment(root)
let resolveResult
let rejectResult
const result = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
// Attach a handler immediately; startup errors can precede awaiting the result.
result.catch(() => {})
let server
let browser
let timer
try {
await mkdir(profile)
await writeFile(resolve(profile, 'user.js'), [
'user_pref("webgl.force-enabled", true);',
'user_pref("webgl.disabled", false);',
'user_pref("browser.shell.checkDefaultBrowser", false);',
'user_pref("browser.startup.homepage_override.mstone", "ignore");',
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
].join('\n'))
server = await createServer({
root,
configFile: false,
cacheDir: resolve(profile, 'vite'),
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, watch: null },
plugins: [{
name: 'shader-pixel-regression',
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url === '/__shader_reference') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ fragmentShader, baselineShader }))
} else if (req.url === '/__shader_pixels' && req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk
if (body.length > 2_000_000) req.destroy()
})
req.on('end', () => {
try {
resolveResult(JSON.parse(body))
res.end('ok')
} catch (error) {
rejectResult(error)
res.statusCode = 400
res.end('Invalid JSON')
}
})
} else if (req.url === '/__shader_test') {
res.setHeader('Content-Type', 'text/html')
res.end('<!doctype html><title>Shader pixel regression</title><script type="module" src="/scripts/shader-pixel-client.mjs"></script>')
} else {
next()
}
})
},
}],
})
await server.listen()
const address = server.httpServer.address()
const url = `http://127.0.0.1:${address.port}/__shader_test`
const probe = await fetch(url)
if (!probe.ok) throw new Error(`Local test server failed: ${probe.status}`)
console.log(`Shader reference verified against ${referenceCommit} (${referenceHash})`)
console.log(`Serving ${url}; browser timeout ${timeoutMs}ms`)
timer = setTimeout(() => rejectResult(new Error('Browser pixel regression timed out')), timeoutMs)
const browserArgs = process.env.SHADER_BROWSER_KIND === 'chromium' ? [
'--headless', `--user-data-dir=${profile}`, '--no-first-run',
'--disable-background-networking', '--disable-dev-shm-usage',
'--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', url,
] : ['--headless', '--no-remote', '--profile', profile, url]
if (process.env.SHADER_BROWSER_KIND === 'chromium' && process.env.SHADER_NO_SANDBOX === '1') {
browserArgs.unshift('--no-sandbox')
}
browser = spawn(process.env.SHADER_BROWSER ?? '/usr/bin/firefox', browserArgs, {
env: { ...process.env, TMPDIR: profile, MOZ_HEADLESS: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
})
let browserLog = ''
browser.stderr.on('data', (chunk) => { browserLog = (browserLog + chunk).slice(-12000) })
browser.once('error', rejectResult)
browser.once('exit', (code) => rejectResult(new Error(`Browser exited with ${code}\n${browserLog}`)))
let report
try {
report = await result
} catch (error) {
console.error(browserLog)
throw error
}
console.log(JSON.stringify({ referenceCommit, referenceHash, ...report }, null, 2))
if (!report.passed) process.exitCode = 1
} finally {
clearTimeout(timer)
if (browser?.pid && browser.exitCode === null && browser.signalCode === null) {
const exited = once(browser, 'exit')
browser.kill('SIGTERM')
const forceKill = setTimeout(() => browser.kill('SIGKILL'), 3000)
await exited
clearTimeout(forceKill)
}
await server?.close()
await rm(profile, { recursive: true, force: true })
}

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
// SHA-256 of the whitespace-normalized fragment shader at this pre-fast-path commit.
export const referenceCommit = '2cfa0ca690134aa4988f01f0170e9e202b608c1c'
export const referenceHash = '5490249511bf2511ac78b6ef3b061fc8ef12ed42b255fe567954c16d5559f9b4'
export function baselineFragment(root) {
const source = execFileSync('git', [
'show', `${referenceCommit}:spikes/card-harness/src/cardMaterial.ts`,
], { cwd: root, encoding: 'utf8' })
const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1]
assert.ok(fragment, 'Cannot find original card fragment shader')
const hash = createHash('sha256').update(fragment.replace(/\s+/g, '')).digest('hex')
assert.equal(hash, referenceHash, 'Original shader reference must retain its recorded hash')
return fragment
}
export function originalFragment(source) {
const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1]
assert.ok(fragment, 'Cannot find card fragment shader')
const gate = /float edgeWear = 0\.0;\s*float scratchWear = 0\.0;\s*float scuffWear = 0\.0;\s*if \(condition < 1\.0\) \{([\s\S]*?)\}\s*(?=float wearDamage)/
assert.ok(gate.test(fragment), 'Expected the mint-only wear fast path')
const original = fragment.replace(gate, (_, body) => body.replace(
/^(\s*)(edgeWear|scratchWear|scuffWear) =/gm, '$1float $2 =',
))
return original
}