144 lines
4.6 KiB
JavaScript
144 lines
4.6 KiB
JavaScript
/** Phase 0 executable contract model for XZBT 0.1 GC4. */
|
|
|
|
const UINT32_RANGE = 0x1_0000_0000;
|
|
const STEP_MS = 1000 / 60;
|
|
|
|
function rotateLeft(value, count) {
|
|
return ((value << count) | (value >>> (32 - count))) >>> 0;
|
|
}
|
|
|
|
function multiply32(left, right) {
|
|
return Math.imul(left, right) >>> 0;
|
|
}
|
|
|
|
export function fnv1a32(text) {
|
|
let hash = 0x811c9dc5;
|
|
for (const byte of new TextEncoder().encode(text)) {
|
|
hash ^= byte;
|
|
hash = multiply32(hash, 0x01000193);
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
export function splitMix32(seed) {
|
|
let state = seed >>> 0;
|
|
return () => {
|
|
state = (state + 0x9e3779b9) >>> 0;
|
|
let value = state;
|
|
value = multiply32(value ^ (value >>> 16), 0x21f0aaad);
|
|
value = multiply32(value ^ (value >>> 15), 0x735a2d97);
|
|
return (value ^ (value >>> 15)) >>> 0;
|
|
};
|
|
}
|
|
|
|
export function deriveStreamState(rootSeed, domain, stableInstanceKey) {
|
|
if (!Number.isInteger(rootSeed) || rootSeed < 0 || rootSeed >= UINT32_RANGE) {
|
|
throw new RangeError('Root seed must be an unsigned 32-bit integer.');
|
|
}
|
|
const hash = fnv1a32(`xzbt-0.1\0${rootSeed}\0${domain}\0${stableInstanceKey}`);
|
|
const expand = splitMix32(hash);
|
|
const state = [expand(), expand(), expand(), expand()];
|
|
if (state.every((word) => word === 0)) state[3] = 1;
|
|
return state;
|
|
}
|
|
|
|
export class Xoshiro128StarStar {
|
|
constructor(state) {
|
|
if (!Array.isArray(state) || state.length !== 4 || state.some((word) => !Number.isInteger(word))) {
|
|
throw new TypeError('xoshiro128** state must contain four integer words.');
|
|
}
|
|
this.state = state.map((word) => word >>> 0);
|
|
if (this.state.every((word) => word === 0)) throw new RangeError('xoshiro128** state cannot be all zero.');
|
|
}
|
|
|
|
nextUint32() {
|
|
const state = this.state;
|
|
const result = multiply32(rotateLeft(multiply32(state[1], 5), 7), 9);
|
|
const temporary = (state[1] << 9) >>> 0;
|
|
state[2] ^= state[0];
|
|
state[3] ^= state[1];
|
|
state[1] ^= state[2];
|
|
state[0] ^= state[3];
|
|
state[2] ^= temporary;
|
|
state[3] = rotateLeft(state[3], 11);
|
|
for (let index = 0; index < 4; index++) state[index] >>>= 0;
|
|
return result >>> 0;
|
|
}
|
|
|
|
nextFloat() {
|
|
return this.nextUint32() / UINT32_RANGE;
|
|
}
|
|
}
|
|
|
|
export function createRandomStream(rootSeed, domain, stableInstanceKey) {
|
|
return new Xoshiro128StarStar(deriveStreamState(rootSeed, domain, stableInstanceKey));
|
|
}
|
|
|
|
export class FixedStepClock {
|
|
constructor({ stepMs = STEP_MS, maxTicksPerTurn = 8, maxElapsedMs = 250 } = {}) {
|
|
this.stepMs = stepMs;
|
|
this.maxTicksPerTurn = maxTicksPerTurn;
|
|
this.maxElapsedMs = maxElapsedMs;
|
|
this.tickIndex = 0;
|
|
this.accumulatorMs = 0;
|
|
this.lastNowMs = undefined;
|
|
this.pauseReasons = new Set();
|
|
}
|
|
|
|
setPauseReason(reason, active, nowMs) {
|
|
const wasPaused = this.pauseReasons.size > 0;
|
|
if (active) this.pauseReasons.add(reason);
|
|
else this.pauseReasons.delete(reason);
|
|
const isPaused = this.pauseReasons.size > 0;
|
|
if (wasPaused !== isPaused) this.lastNowMs = nowMs;
|
|
}
|
|
|
|
advance(nowMs) {
|
|
if (this.lastNowMs === undefined) {
|
|
this.lastNowMs = nowMs;
|
|
return { ticks: [], stalled: false };
|
|
}
|
|
if (this.pauseReasons.size > 0) {
|
|
this.lastNowMs = nowMs;
|
|
return { ticks: [], stalled: false };
|
|
}
|
|
|
|
const observed = Math.max(0, nowMs - this.lastNowMs);
|
|
this.lastNowMs = nowMs;
|
|
const accepted = Math.min(observed, this.maxElapsedMs);
|
|
this.accumulatorMs += accepted;
|
|
const available = Math.floor((this.accumulatorMs + 1e-9) / this.stepMs);
|
|
const count = Math.min(available, this.maxTicksPerTurn);
|
|
const ticks = [];
|
|
for (let index = 0; index < count; index++) ticks.push(++this.tickIndex);
|
|
this.accumulatorMs -= count * this.stepMs;
|
|
|
|
const stalled = observed > this.maxElapsedMs || available > this.maxTicksPerTurn;
|
|
if (available > this.maxTicksPerTurn) {
|
|
this.accumulatorMs = Math.min(this.accumulatorMs, this.stepMs - 1e-9);
|
|
}
|
|
return { ticks, stalled };
|
|
}
|
|
}
|
|
|
|
export function planAudioUnlock(invocations, logicalSeconds) {
|
|
return invocations.map((invocation) => {
|
|
if (invocation.start < logicalSeconds) {
|
|
if (invocation.kind === 'one-shot') return { id: invocation.id, action: 'skip' };
|
|
if (invocation.end === undefined || invocation.end > logicalSeconds) {
|
|
return { id: invocation.id, action: 'start-continuous', phase: logicalSeconds - invocation.start, rampMs: 20 };
|
|
}
|
|
return { id: invocation.id, action: 'dispose' };
|
|
}
|
|
return { id: invocation.id, action: 'schedule', delay: invocation.start - logicalSeconds };
|
|
});
|
|
}
|
|
|
|
export const GC4_CONSTANTS = Object.freeze({
|
|
stepMs: STEP_MS,
|
|
maxTicksPerTurn: 8,
|
|
maxElapsedMs: 250,
|
|
audioLookaheadMs: 100,
|
|
unlockRampMs: 20
|
|
});
|