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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
- Add the v2.4 tracker core with bounded continuous FM voice graphs, deterministic LFSR noise, packed four-channel 32 or 64 row patterns, tracker effects, lookahead sequencing, compact `.neonseq` song codecs, offline PCM/WAV export helpers, and the generated Tracker Studio editor with QWERTY input, transport, scopes, patch controls, and cabinet BGM routing.
- Add versioned local tracker project persistence for songs, FM patches, song order, and cabinet BGM slots. Reject corrupt browser data and safely restore a fresh project.
- Add Tracker Studio browser coverage for catalog launch, QWERTY authoring, transport playback, `.neonseq` export, WAV rendering, lobby return, and persisted cabinet audio assignment.
- Add a procedural Cabinet Decal Workshop with typed vector paths, symmetry, dual-pass neon strokes, `.neonart` codec validation, bounded history, local cabinet skins, and a 2.5D cabinet projection preview.

### Changed
- Remove the unused Vite build manifest and enforce a 1,950,000-byte decimal production bundle ceiling. The verified Tracker Studio build measures 1,938,402 bytes.
- Disable redundant module-preload bookkeeping while retaining the complete offline precache. The Cabinet Decal Workshop build measures 1,946,957 bytes.

## [v2.3.0] - 2026-08-31

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Capture every image below directly from the current production build. Show proce
<p align="center">
<img src="docs/images/screenshots/lobby.png" alt="BiosSystem Neon Arcade lobby with the 2026 Overdrive presentation enabled" width="800">
</p>
<p align="center"><sub>Browse the complete 30-entry cabinet catalog from the live BiosSystem Neon Arcade lobby.</sub></p>
<p align="center"><sub>Browse the complete 31-entry cabinet catalog from the live BiosSystem Neon Arcade lobby.</sub></p>

<table>
<tr>
Expand Down Expand Up @@ -65,7 +65,7 @@ npm run tauri build

| Area | Current implementation |
|---|---|
| Arcade catalog | 28 original games, the generated Meta-Arcade Hall, and Sound Workshop / Tracker Studio |
| Arcade catalog | 28 original games, the generated Meta-Arcade Hall, Sound Workshop / Tracker Studio, and Cabinet Art / Decal Workshop |
| Runtime | Phaser 4, TypeScript, Vite, lazy game-scene loading, fixed 60 Hz Arcade Physics |
| Input | Keyboard, touch, Xbox, PlayStation, Nintendo, 8BitDo, arcade encoders, generic controllers, local Player 1 and Player 2 actions |
| Game flow | Difficulty selection, solo and supported local multiplayer modes, pause, restart, shared Game Over, high-score entry, achievements |
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ The v2.4 tracker core lives in `src/core/audio/` so pattern storage and scheduli

`TrackerStudioScene` loads lazily from the lobby Sound Workshop entry. Its generated matrix, patch panel, transport display, scope view, and file controls operate on pure editor models. `CabinetAudioManager` selects a local custom tracker song where one exists, otherwise retains the established default cabinet BGM path.

`DecalWorkshopScene` loads lazily from the Cabinet Art entry. `VectorArtModel` keeps line, quadratic, cubic, and closed-path geometry in typed layers with cabinet-slot tags and optional X or Y symmetry. `DecalCodec` serializes those layers into validated `.neonart` `DataView` buffers. `CabinetSkinStore` persists only locally and exposes a validated lightweight lobby marker, while the full drawing model remains deferred until the workshop opens. The canvas renders a core stroke plus a soft glow stroke and projects the same document to generated side art, marquee, and control-panel surfaces.

`AudioVoiceAllocator` caps generated sound effects and patch previews at 24 concurrent voices. It uses a fixed `Float64Array` of release times, so overloaded effects are dropped rather than creating an unbounded graph. `WebAudioTrackerBackend` caches its two pulse waves and limits active scheduled tracker sources to 48. It drops a note only when the graph is already at that hard limit.

The spatial AudioWorklet ring reserves header slots for read position, write position, and missed render quanta. The processor increments the underrun counter once per missed output block. Neon Epoch samples this counter and sends a bounded `arcade-audio-underrun` signal to `PerformanceBaselineMonitor`. The MessagePort fallback remains playable but does not claim shared-ring underrun telemetry.
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Use `master` as the protected integration target. Create focused branches with t
| Priority | Workstream | Planned outcome | Acceptance gate |
|---|---|---|---|
| P1 | In-Game Chiptune Tracker and FM Synthesizer Studio | Completed on `feat/v2.4-chiptune-tracker-studio`: bounded FM voice graphs, packed four-channel patterns, lookahead sequencing, `.neonseq` import and export, offline WAV rendering, interactive Studio controls, local project and cabinet BGM persistence, and browser workflow coverage. | Preserve autoplay safety, AudioWorklet fallback, bounded node allocation, offline play, and a 1,950,000-byte decimal production bundle ceiling |
| P2 | Cabinet Decal and Vector Art Workshop | Add generated cabinet side-art, palette decals, and animated marquee authoring | Keep artwork procedural, validate import data, and add no raster asset payload |
| P2 | Cabinet Decal and Vector Art Workshop | In progress on `feat/v2.4-cabinet-decal-vector-workshop`: deliver typed vector paths, mirror symmetry, `.neonart` serialization, local cabinet skins, drawing tools, bounded history, and a generated 2.5D cabinet preview. | Keep artwork procedural, validate import data, add no raster asset payload, and retain the 1,950,000-byte decimal budget |
| P3 | Cryptographic High-Score Proofs and P2P Leaderboard Relay | Add verifiable score proofs and opt-in peer relay | Preserve local-first leaderboards, bounded storage, and no central service dependency |

## v2.3.0 release status
Expand Down
27 changes: 27 additions & 0 deletions src/core/graphics/vector/CabinetSkinStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { decodeDecal, encodeDecal } from './DecalCodec';
import type { VectorArtDocument } from './VectorArtModel';

const STORAGE_KEY = 'bios_cabinet_skins_v1';
const META_STORAGE_KEY = 'bios_cabinet_skin_meta_v1';
export interface SkinStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; }

export class CabinetSkinStore {
private readonly storage: SkinStorage | undefined;
constructor(storage: SkinStorage | undefined = browserStorage()) { this.storage = storage; }
get(cabinetScene: string) {
try { const skins = this.read(); const value = skins[cabinetScene]; return value ? decodeDecal(fromBase64(value)) : undefined; } catch { this.clear(); return undefined; }
}
set(cabinetScene: string, document: VectorArtDocument) {
if (!sceneKey(cabinetScene) || !this.storage) return false;
try { const skins = this.read(); skins[cabinetScene] = toBase64(new Uint8Array(encodeDecal(document))); this.storage.setItem(STORAGE_KEY, JSON.stringify(skins)); this.storage.setItem(META_STORAGE_KEY, JSON.stringify(Object.keys(skins))); return true; } catch { return false; }
}
remove(cabinetScene: string) { try { const skins = this.read(); delete skins[cabinetScene]; this.storage?.setItem(STORAGE_KEY, JSON.stringify(skins)); this.storage?.setItem(META_STORAGE_KEY, JSON.stringify(Object.keys(skins))); } catch { this.clear(); } }
private read() { const value = JSON.parse(this.storage?.getItem(STORAGE_KEY) ?? '{}') as unknown; if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Cabinet skin storage is invalid'); return value as Record<string, string>; }
private clear() { try { this.storage?.removeItem(STORAGE_KEY); this.storage?.removeItem(META_STORAGE_KEY); } catch { /* Browser storage is optional. */ } }
}

export const cabinetSkinStore = new CabinetSkinStore();
function sceneKey(value: string) { return /^[A-Za-z][A-Za-z0-9]*Scene$/.test(value); }
function toBase64(bytes: Uint8Array) { let text = ''; for (let offset = 0; offset < bytes.length; offset += 0x8000) text += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); return btoa(text); }
function fromBase64(value: string) { const text = atob(value); const bytes = new Uint8Array(text.length); for (let index = 0; index < text.length; index += 1) bytes[index] = text.charCodeAt(index); return bytes; }
function browserStorage(): SkinStorage | undefined { try { return typeof localStorage === 'undefined' ? undefined : localStorage; } catch { return undefined; } }
51 changes: 51 additions & 0 deletions src/core/graphics/vector/DecalCodec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CABINET_SLOTS, SYMMETRY_MODES, type VectorArtDocument, type VectorLayer, type VectorPath, type VectorSegment } from './VectorArtModel';

const MAGIC = 0x4e415254; const VERSION = 1; const HEADER_BYTES = 12; const LAYER_BYTES = 16;
const SEGMENT_BYTES = { LINE: 5, QUADRATIC: 9, CUBIC: 13, CLOSE: 1 } as const;
export class DecalCodecError extends Error {}

export function encodeDecal(document: VectorArtDocument): ArrayBuffer {
validateDocument(document); const buffer = new ArrayBuffer(byteLength(document)); const view = new DataView(buffer); let offset = 0;
view.setUint32(offset, MAGIC); offset += 4; view.setUint8(offset++, VERSION); view.setUint16(offset, document.width); offset += 2; view.setUint16(offset, document.height); offset += 2; view.setUint8(offset++, SYMMETRY_MODES.indexOf(document.symmetry)); view.setUint8(offset++, document.layers.length); view.setUint8(offset++, 0);
for (const layer of document.layers) {
view.setUint16(offset, layer.id); offset += 2; view.setUint8(offset++, CABINET_SLOTS.indexOf(layer.slot)); view.setUint8(offset++, layer.visible ? 1 : 0); view.setUint32(offset, layer.style.stroke); offset += 4; view.setUint32(offset, layer.style.fill ?? 0xffffffff); offset += 4; view.setUint8(offset++, layer.style.width); view.setUint8(offset++, layer.style.glow); view.setUint16(offset, layer.paths.length); offset += 2;
for (const path of layer.paths) offset = encodePath(view, offset, path);
}
return buffer;
}

export function decodeDecal(source: ArrayBuffer | Uint8Array): VectorArtDocument {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); requireBytes(bytes, 0, HEADER_BYTES);
let offset = 0; if (view.getUint32(offset) !== MAGIC) throw new DecalCodecError(); offset += 4; if (view.getUint8(offset++) !== VERSION) throw new DecalCodecError();
const width = view.getUint16(offset); offset += 2; const height = view.getUint16(offset); offset += 2; const symmetry = SYMMETRY_MODES[view.getUint8(offset++)]; const layerCount = view.getUint8(offset++); offset += 1;
if (!width || !height || !symmetry || layerCount === 0) throw new DecalCodecError(); const layers: VectorLayer[] = [];
for (let index = 0; index < layerCount; index += 1) {
requireBytes(bytes, offset, LAYER_BYTES); const id = view.getUint16(offset); offset += 2; const slot = CABINET_SLOTS[view.getUint8(offset++)]; const visible = view.getUint8(offset++) === 1; const stroke = view.getUint32(offset); offset += 4; const fillRaw = view.getUint32(offset); offset += 4; const widthStyle = view.getUint8(offset++); const glow = view.getUint8(offset++); const pathCount = view.getUint16(offset); offset += 2;
if (!slot || widthStyle < 1 || widthStyle > 16) throw new DecalCodecError(); const paths: VectorPath[] = [];
for (let pathIndex = 0; pathIndex < pathCount; pathIndex += 1) { const decoded = decodePath(view, bytes, offset); paths.push(decoded.path); offset = decoded.offset; }
layers.push({ id, slot, visible, style: { stroke, ...(fillRaw === 0xffffffff ? {} : { fill: fillRaw }), width: widthStyle, glow }, paths });
}
if (offset !== bytes.length) throw new DecalCodecError(); return { width, height, symmetry, layers };
}

function byteLength(document: VectorArtDocument) { return HEADER_BYTES + document.layers.reduce((total, layer) => total + LAYER_BYTES + layer.paths.reduce((pathTotal, path) => pathTotal + 9 + path.segments.reduce((segmentTotal, segment) => segmentTotal + SEGMENT_BYTES[segment.type], 0), 0), 0); }
function encodePath(view: DataView, offset: number, path: VectorPath) {
view.setUint16(offset, path.id); offset += 2; view.setUint8(offset++, path.closed ? 1 : 0); view.setUint16(offset, path.segments.length); offset += 2; offset = point(view, offset, path.start);
for (const segment of path.segments) {
view.setUint8(offset++, segmentCode(segment)); if (segment.type === 'LINE') offset = point(view, offset, segment.to); else if (segment.type === 'QUADRATIC') { offset = point(view, offset, segment.control); offset = point(view, offset, segment.to); } else if (segment.type === 'CUBIC') { offset = point(view, offset, segment.controlA); offset = point(view, offset, segment.controlB); offset = point(view, offset, segment.to); }
}
return offset;
}
function decodePath(view: DataView, bytes: Uint8Array, offset: number) {
requireBytes(bytes, offset, 9); const id = view.getUint16(offset); offset += 2; const closed = view.getUint8(offset++) === 1; const count = view.getUint16(offset); offset += 2; const start = readPoint(view, offset); offset += 4; const segments: VectorSegment[] = [];
for (let index = 0; index < count; index += 1) { requireBytes(bytes, offset, 1); const code = view.getUint8(offset++); if (code === 1) { requireBytes(bytes, offset, 4); segments.push({ type: 'LINE', to: readPoint(view, offset) }); offset += 4; } else if (code === 2) { requireBytes(bytes, offset, 8); segments.push({ type: 'QUADRATIC', control: readPoint(view, offset), to: readPoint(view, offset + 4) }); offset += 8; } else if (code === 3) { requireBytes(bytes, offset, 12); segments.push({ type: 'CUBIC', controlA: readPoint(view, offset), controlB: readPoint(view, offset + 4), to: readPoint(view, offset + 8) }); offset += 12; } else if (code === 4) segments.push({ type: 'CLOSE' }); else throw new DecalCodecError(); }
return { path: { id, start, segments, closed }, offset };
}
function point(view: DataView, offset: number, value: { x: number; y: number }) { view.setUint16(offset, value.x); view.setUint16(offset + 2, value.y); return offset + 4; }
function readPoint(view: DataView, offset: number) { return { x: view.getUint16(offset), y: view.getUint16(offset + 2) }; }
function segmentCode(segment: VectorSegment) { return segment.type === 'LINE' ? 1 : segment.type === 'QUADRATIC' ? 2 : segment.type === 'CUBIC' ? 3 : 4; }
function requireBytes(bytes: Uint8Array, offset: number, count: number) { if (offset < 0 || count < 0 || offset + count > bytes.length) throw new DecalCodecError(); }
function validateDocument(document: VectorArtDocument) {
if (!Number.isInteger(document.width) || !Number.isInteger(document.height) || document.width < 1 || document.height < 1 || document.width > 65535 || document.height > 65535 || !SYMMETRY_MODES.includes(document.symmetry) || document.layers.length < 1 || document.layers.length > 255) throw new DecalCodecError();
for (const layer of document.layers) { if (!CABINET_SLOTS.includes(layer.slot) || !Number.isInteger(layer.style.width) || layer.style.width < 1 || layer.style.width > 16 || !Number.isInteger(layer.style.glow) || layer.style.glow < 0 || layer.style.glow > 255 || layer.paths.length > 65535) throw new DecalCodecError(); }
}
Loading
Loading