diff --git a/.changeset/public-streets-pull.md b/.changeset/public-streets-pull.md new file mode 100644 index 0000000..7828c59 --- /dev/null +++ b/.changeset/public-streets-pull.md @@ -0,0 +1,5 @@ +--- +"@certes/ordo": minor +--- + +add ordo package diff --git a/.changeset/tangy-actors-stop.md b/.changeset/tangy-actors-stop.md new file mode 100644 index 0000000..832897c --- /dev/null +++ b/.changeset/tangy-actors-stop.md @@ -0,0 +1,11 @@ +--- +"@certes/composition": patch +"@certes/combinator": patch +"@certes/common": patch +"@certes/logic": patch +"@certes/lazy": patch +"@certes/list": patch +"@certes/ordo": patch +--- + +Import readme warning addition (warranted the patch bump) diff --git a/.gitignore b/.gitignore index 3500705..e86df23 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules # Vitest .vitest coverage +bench # TypeScript cache *.tsbuildinfo diff --git a/packages/combinator/README.md b/packages/combinator/README.md index 9862b79..8b8931c 100644 --- a/packages/combinator/README.md +++ b/packages/combinator/README.md @@ -2,6 +2,17 @@ Type-safe, pure functional combinators for TypeScript. A comprehensive collection of classical combinatory logic primitives for composable, point-free programming. +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + ## Installation ```bash npm install @certes/combinator diff --git a/packages/common/README.md b/packages/common/README.md index 33f970b..c88bafc 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -2,6 +2,17 @@ Common utility functions for functional programming patterns in TypeScript. +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + ## Installation ```bash npm install @certes/common diff --git a/packages/composition/README.md b/packages/composition/README.md index 5d20f69..f74676d 100644 --- a/packages/composition/README.md +++ b/packages/composition/README.md @@ -2,6 +2,17 @@ Type-safe function composition utilities for TypeScript. +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + ## Installation ```bash npm install @certes/composition diff --git a/packages/lazy/README.md b/packages/lazy/README.md index e3a35a9..07447ef 100644 --- a/packages/lazy/README.md +++ b/packages/lazy/README.md @@ -2,6 +2,17 @@ Type-safe, reusable lazy iteration utilities for TypeScript. A comprehensive collection of curried functions for composable, memory-efficient data processing. +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + ## Installation ```bash npm install @certes/lazy @@ -39,6 +50,149 @@ const evens = filter((x: number) => x % 2 === 0)([1, 2, 3, 4, 5, 6]); [...evens]; // [2, 4, 6] ``` +## When to Use Lazy vs Native + +### Use Lazy Evaluation When: + +#### Early Termination + +When you only need a subset of results: + +```typescript +// ✅ Lazy: only processes 10 elements +const firstTenEvens = pipe( + largeArray, + lazyMap(square), + lazyFilter(isEven), + take(10), + collect +); + +// ❌ Native: processes ALL elements before slicing +const firstTenEvens = largeArray + .map(square) // Processes all elements + .filter(isEven) // Filters all elements + .slice(0, 10); // Then takes 10 +``` + +#### Memory-Constrained Environments + +Lazy evaluation uses O(1) memory for transformations: + +```typescript +// ✅ Lazy: constant memory usage +const processed = pipe( + hugeDataset, + lazyMap(transform1), // No intermediate array + lazyMap(transform2), // No intermediate array + lazyFilter(predicate), // No intermediate array + take(1000), + collect // Only final 1000 items in memory +); + +// ❌ Native: O(n) memory for each step +const processed = hugeDataset + .map(transform1) // Creates array of _n_ items + .map(transform2) // Creates another array of _n_ items + .filter(predicate) // Creates another array + .slice(0, 1000); +``` + +#### Working with Infinite or Unknown-Size Streams + +```typescript +// ✅ Lazy: can handle infinite sequences +const fibonacci = iterate( + ([a, b]: [number, number]) => [b, a + b] +)([0, 1]); + +const first100Fibs = pipe( + fibonacci, + map(([a]) => a), + take(100), + collect +); + +// ❌ Native: impossible with infinite sequences +``` + +#### Complex Pipelines with Selective Processing + +When combining multiple operations where most elements get filtered out: + +```typescript +// ✅ Lazy: faster for selective processing +const errorLogs = pipe( + millionLogs, + lazyFilter(log => log.level === 'ERROR'), // Only processes until 100 found + lazyMap(enrichLog), + take(100), + collect +); +``` + +### Use Native Array Methods When: + +#### Processing All Elements + +When you need every element, native methods are optimized: + +```typescript +// ✅ Native: faster for full consumption +const doubled = smallArray.map(x => x * 2); + +// ❌ Lazy: overhead without benefit +const doubled = collect(lazyMap(x => x * 2)(smallArray)); +``` + +#### Simple Operations on Small Arrays (<1000 elements) + +The overhead of lazy evaluation isn't worth it for small datasets: + +```typescript +// ✅ Native: simpler and faster for small arrays +const result = [1, 2, 3, 4, 5] + .map(x => x * 2) + .filter(x => x > 5); + +// ❌ Lazy: unnecessary complexity +const result = pipe( + [1, 2, 3, 4, 5], + lazyMap(x => x * 2), + lazyFilter(x => x > 5), + collect +); +``` + +#### Random Access Needed + +When you need index-based access: + +```typescript +// ✅ Native: O(1) index access +const processed = array.map(transform); +console.log(processed[500]); // Instant access + +// ❌ Lazy: must iterate to index +const processed = lazyMap(transform)(array); +// No way to access element 500 without iterating +``` + +### Quick Decision Matrix + +| Scenario | Use Lazy? | Performance Gain | +|----------|-----------|------------------| +| Need first N results | ✅ Yes | 100x-10,000x | +| Process until condition | ✅ Yes | 100x-3,000x | +| Large dataset, small output | ✅ Yes | 10x-150x | +| Infinite sequences | ✅ Yes | Only option | +| Memory constraints | ✅ Yes | O(1) vs O(n) | +| Process all elements | ❌ No | 2x-20x slower | +| Small arrays (<1000) | ❌ No | 10x-20x slower | +| Multiple iterations | ❌ No | Recomputes each time | +| Need random access | ❌ No | Not supported | +| Simple single operation | ❌ No | 3x-20x slower | + ## API Reference ### Generators @@ -195,25 +349,6 @@ collect(take(10)(map(([a]: [number, number]) => a)(fibs))); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] ``` -## Performance - -| Aspect | Characteristic | -|--------|----------------| -| **Evaluation** | Lazy — elements processed on demand | -| **Memory** | O(1) for most operations | -| **Reusability** | All iterables can be iterated multiple times | -| **Short-circuit** | Operations like `take`, `find` stop early | - -```typescript -// Only processes 3 elements, not 1 million -const result = pipe( - filter((x: number) => x % 2 === 0), - take(3), - collect -)(range(1, 1_000_000)); -// [2, 4, 6] -``` - ## License MIT diff --git a/packages/lazy/src/helpers/take-eager/index.ts b/packages/lazy/src/helpers/take-eager/index.ts index 00964d0..c9867e2 100644 --- a/packages/lazy/src/helpers/take-eager/index.ts +++ b/packages/lazy/src/helpers/take-eager/index.ts @@ -1,4 +1,4 @@ -import { compose } from '@certes/composition'; +import { compose } from '@certes/composition/compose'; import { collect } from '@/helpers/collect'; import { take } from '@/iterators/take'; diff --git a/packages/lazy/src/lazy.bench.ts b/packages/lazy/src/lazy.bench.ts new file mode 100644 index 0000000..c711497 --- /dev/null +++ b/packages/lazy/src/lazy.bench.ts @@ -0,0 +1,233 @@ +import { pipe } from '@certes/composition/pipe'; +import { bench, describe } from 'vitest'; +import { chunk, collect, filter, map, take } from '../src/index'; + +const smallArray = Array.from({ length: 100 }, (_, i) => i); +const mediumArray = Array.from({ length: 10_000 }, (_, i) => i); +const largeArray = Array.from({ length: 1_000_000 }, (_, i) => i); + +const square = (x: number) => x * x; +const isEven = (x: number) => x % 2 === 0; +const add10 = (x: number) => x + 10; + +describe('lazy map vs native map - small array', () => { + bench('native', () => { + const _result = smallArray.map(square); + }); + + bench('lazy (collected)', () => { + const _result = collect(map(square)(smallArray)); + }); +}); + +describe('lazy map vs native map - large array', () => { + bench('native', () => { + const _result = largeArray.map(square); + }); + + bench('lazy (collected)', () => { + const _result = collect(map(square)(largeArray)); + }); +}); + +describe('lazy map vs native map - chain of 3 maps', () => { + bench('native', () => { + const _result = mediumArray.map(square).map(add10).map(square); + }); + + bench('lazy', () => { + const _result = collect( + pipe(map(square), map(add10), map(square))(mediumArray), + ); + }); +}); + +describe('lazy filter vs native filter - small array', () => { + bench('native', () => { + const _result = smallArray.filter(isEven); + }); + + bench('lazy (collected)', () => { + const _result = collect(filter(isEven)(smallArray)); + }); +}); + +describe('lazy filter vs native filter - large array', () => { + bench('native', () => { + const _result = largeArray.filter(isEven); + }); + + bench('lazy (collected)', () => { + const _result = collect(filter(isEven)(largeArray)); + }); +}); + +describe('chunk performance - simple chunking', () => { + bench('native chunking (manual)', () => { + const result: number[][] = []; + const chunkSize = 100; + for (let i = 0; i < mediumArray.length; i += chunkSize) { + result.push(mediumArray.slice(i, i + chunkSize)); + } + }); + + bench('lazy chunk', () => { + const _result = collect(chunk(100)(mediumArray)); + }); +}); + +describe('chunk performance - chunking with processing', () => { + bench('native chunking with processing', () => { + const result: number[][] = []; + const chunkSize = 100; + for (let i = 0; i < largeArray.length; i += chunkSize) { + const chunk = largeArray.slice(i, i + chunkSize); + if (chunk.some((x) => x % 1000 === 0)) { + result.push(chunk); + } + } + }); + + bench('lazy chunk with filter', () => { + const _result = collect( + filter((chunk: number[]) => chunk.some((x) => x % 1000 === 0))( + chunk(100)(largeArray), + ), + ); + }); +}); + +describe('early termination benefits - first 10', () => { + bench('native - map + filter + slice(0, 10)', () => { + const _result = largeArray.map(square).filter(isEven).slice(0, 10); + }); + + bench('lazy - map + filter + take(10)', () => { + const _result = collect( + pipe(map(square), filter(isEven), take(10))(largeArray), + ); + }); +}); + +describe('early termination benefits - find first 100 matching', () => { + bench('native', () => { + const result: number[] = []; + const mapped = largeArray.map(square); + + for (const item of mapped) { + if (item > 1000) { + result.push(item); + if (result.length === 100) { + break; + } + } + } + }); + + bench('lazy', () => { + const _result = collect( + pipe( + map(square), + filter((x: number) => x > 1000), + take(100), + )(largeArray), + ); + }); +}); + +describe('complex pipeline performance', () => { + bench('native', () => { + const _result = largeArray + .map((x) => x * 2) + .filter((x) => x % 3 === 0) + .map((x) => x + 1) + .filter((x) => x > 100) + .slice(0, 1000); + }); + + bench('lazy', () => { + const _result = collect( + pipe( + map((x: number) => x * 2), + filter((x: number) => x % 3 === 0), + map((x: number) => x + 1), + filter((x: number) => x > 100), + take(1000), + )(largeArray), + ); + }); +}); + +describe('real-world scenarios - process error logs', () => { + // Simulate processing log entries + const logEntries = Array.from({ length: 100_000 }, (_, i) => ({ + timestamp: Date.now() + i * 1000, + level: i % 5 === 0 ? 'ERROR' : i % 3 === 0 ? 'WARN' : 'INFO', + message: `Log entry ${i}`, + userId: i % 100, + })); + + bench('native', () => { + const _result = logEntries + .filter((log) => log.level === 'ERROR') + .map((log) => ({ + ...log, + processed: true, + severity: 10, + })) + .slice(0, 100); + }); + + bench('lazy', () => { + const _result = collect( + pipe( + filter((log: (typeof logEntries)[0]) => log.level === 'ERROR'), + map((log: (typeof logEntries)[0]) => ({ + ...log, + processed: true, + severity: 10, + })), + take(100), + )(logEntries), + ); + }); +}); + +describe('real-world scenarios - ETL pipeline', () => { + // Simulate processing log entries + const logEntries = Array.from({ length: 100_000 }, (_, i) => ({ + timestamp: Date.now() + i * 1000, + level: i % 5 === 0 ? 'ERROR' : i % 3 === 0 ? 'WARN' : 'INFO', + message: `Log entry ${i}`, + userId: i % 100, + })); + + // Simulate data transformation pipeline + bench('native', () => { + const _result = logEntries + .filter((log) => log.userId < 50) + .map((log) => ({ + time: new Date(log.timestamp).toISOString(), + user: `user_${log.userId}`, + level: log.level, + })) + .filter((log) => log.level !== 'INFO') + .slice(0, 500); + }); + + bench('lazy', () => { + const _result = collect( + pipe( + filter((log: (typeof logEntries)[0]) => log.userId < 50), + map((log: (typeof logEntries)[0]) => ({ + time: new Date(log.timestamp).toISOString(), + user: `user_${log.userId}`, + level: log.level, + })), + // biome-ignore lint/suspicious/noExplicitAny: Testing + filter((log: any) => log.level !== 'INFO'), + take(500), + )(logEntries), + ); + }); +}); diff --git a/packages/lazy/src/readme.test.ts b/packages/lazy/src/readme.test.ts index 822c104..3a6bf21 100644 --- a/packages/lazy/src/readme.test.ts +++ b/packages/lazy/src/readme.test.ts @@ -1,5 +1,6 @@ -import { compose, pipe } from '@certes/composition'; -import { describe, expect, it } from 'vitest'; +import { compose } from '@certes/composition/compose'; +import { pipe } from '@certes/composition/pipe'; +import { describe, expect, it, vi } from 'vitest'; import { chunk, collect, @@ -17,15 +18,45 @@ import { describe('@certes/lazy - README Examples', () => { describe('Quick Start', () => { - it('should process data lazily through a pipeline', () => { + it('should process data lazily', () => { + const mapFn = vi.fn((x: number) => x * x); + const filterFn = vi.fn((x: number) => x % 2 === 0); + const pipeline = pipe( + filter(filterFn), + map(mapFn), + take(5), + )(range(1, 1000)); + + // Make sure it hasn't been called yet + expect(mapFn).toHaveBeenCalledTimes(0); + expect(filterFn).toHaveBeenCalledTimes(0); + + const result = collect(pipeline); + + expect(result).toStrictEqual([4, 16, 36, 64, 100]); + // Make sure it only calls what is needed + // The 5 times from take(5) + expect(mapFn).toHaveBeenCalledTimes(5); + // take(5) * 2 to get 5 evens starting from 1 + expect(filterFn).toHaveBeenCalledTimes(10); + }); + + it('should process data lazily through a collected pipeline', () => { + const mapFn = vi.fn((x: number) => x * x); + const filterFn = vi.fn((x: number) => x % 2 === 0); const result = pipe( - filter((x: number) => x % 2 === 0), - map((x: number) => x * x), + filter(filterFn), + map(mapFn), take(5), collect, )(range(1, 1000)); expect(result).toStrictEqual([4, 16, 36, 64, 100]); + // Make sure it only calls what is needed + // The 5 times from take(5) + expect(mapFn).toHaveBeenCalledTimes(5); + // take(5) * 2 to get 5 evens starting from 1 + expect(filterFn).toHaveBeenCalledTimes(10); }); it('should create reusable iterables', () => { diff --git a/packages/lazy/vitest.config.js b/packages/lazy/vitest.config.js index 9b624b7..56cca42 100644 --- a/packages/lazy/vitest.config.js +++ b/packages/lazy/vitest.config.js @@ -16,5 +16,9 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, + benchmark: { + include: ['**/*.bench.ts'], + outputFile: './bench/report.json', + }, }, }); diff --git a/packages/list/README.md b/packages/list/README.md index ec7e0e4..7fef0bb 100644 --- a/packages/list/README.md +++ b/packages/list/README.md @@ -2,6 +2,17 @@ Curried array operations for functional programming in TypeScript. +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + ## Installation ```bash npm install @certes/list diff --git a/packages/logic/README.md b/packages/logic/README.md index 840d681..199b572 100644 --- a/packages/logic/README.md +++ b/packages/logic/README.md @@ -2,6 +2,17 @@ Type-safe, curried Boolean logic operations for TypeScript. A comprehensive collection of logical operators and combinators for composable, point-free programming. +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + ## Installation ```bash npm install @certes/logic diff --git a/packages/ordo/README.md b/packages/ordo/README.md new file mode 100644 index 0000000..15ddbf9 --- /dev/null +++ b/packages/ordo/README.md @@ -0,0 +1,804 @@ +# @certes/ordo + +Data-Oriented Design primitives for TypeScript with explicit memory layout control, cache-friendly struct definitions, and high-performance dynamic arrays built on TypedArrays. + +> [!CAUTION] +> ### ⚠️ Active Development & Alpha Status +> This repository is currently undergoing **active development**. +> +> **Until `1.0.0` release:** +> * **Stability:** APIs are subject to breaking changes without prior notice. +> * **Releases:** Current releases (tags/npm packages) are strictly for **testing and integration feedback**. +> * **Production:** Do not use this software in production environments where data integrity or high availability is required. + +--- + +## Installation + +```bash +npm install @certes/ordo +``` + +## Overview + +`@certes/ordo` provides low-level primitives for building cache-friendly, performance-critical applications in TypeScript. + +### Key Features + +- **Explicit Memory Layout**: Define structs with C-like memory layout control +- **Automatic Field Alignment**: Handles padding and alignment automatically based on field types +- **Cache-Friendly Structures**: Minimize cache misses through contiguous memory allocation +- **Zero-Copy Views**: Work with data without intermediate object allocation +- **TypedArray Foundation**: Built on JavaScript's native TypedArrays for performance +- **Type-Safe API**: Full TypeScript support with type inference + +### When to Use This Library + +Use `@certes/ordo` when you need: + +- High-performance data processing with predictable memory layout +- Efficient data transfer to WebGL, WebGPU, or Web Workers +- Large collections of structured data (particles, entities, vertices) +- Cache-friendly iteration over homogeneous data +- Memory-efficient data structures with minimal overhead + +### What This Library Provides + +**Struct System**: +- `struct()` - Define fixed-layout structs with primitive and complex fields +- `structView()` - Create views into single structs +- `structArray()` - Fixed-capacity arrays of structs +- `dynamicStructArray()` - Auto-growing arrays of structs + +**Field Types**: +- Primitives: `int8`, `uint8`, `int16`, `uint16`, `int32`, `uint32`, `float32`, `float64` +- Arrays: `array(type, length)` - Fixed-size typed arrays +- Strings: `utf8(byteLength)` - Fixed-size UTF-8 strings +- Circular Buffers: `circular(type, capacity)` - Embedded FIFO queues + +**Dynamic Collections**: +- `dynamicArray()` - Auto-growing typed arrays +- `sparseArray()` - Arrays that grow but never shrink (stable indices) +- `circularBuffer()` - Standalone FIFO buffers + +## Quick Start + +### Basic Struct Definition + +```typescript +import { struct, uint32, float32, array } from '@certes/ordo'; + +// Define a particle struct +const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), // [x, y, z] + velocity: array('f32', 3), // [vx, vy, vz] + lifetime: float32 +}); + +// Create a fixed-capacity array +const particles = structArray(ParticleDef, 1000); + +// Add a particle +const idx = particles.push(); +const particle = particles.at(idx); + +particle.set('id', 42); +particle.set('lifetime', 10.0); + +// Access array fields - returns TypedArray view +const pos = particle.get('position'); +pos[0] = 100.5; +pos[1] = 200.3; +pos[2] = 50.0; +``` + +### Memory Layout Inspection + +```typescript +ParticleDef.inspect(); +``` + +**Output:** +``` +Field Offset Size +-------------------- -------- -------- +id 0 4 +position[3] 4 12 +velocity[3] 16 12 +lifetime 28 4 + +Total size: 32 bytes +Actual data: 32 bytes +Wasted: 0 bytes +Efficiency: 100.0% +``` + +### Field Ordering Matters + +Just like in C, field ordering significantly impacts memory usage due to alignment requirements. Compare these equivalent C and TypeScript struct definitions: + +#### C Structs + +```c +struct BadEntity { + double timestamp; + uint8_t isActive; + double position[3]; + uint8_t team; + double rotation[4]; + float damage; + float velocity[3]; + uint8_t flags; + uint16_t health; + char name[16]; +}; + +struct GoodEntity { + double timestamp; + double position[3]; + double rotation[4]; + float damage; + float velocity[3]; + uint16_t health; + uint8_t isActive; + uint8_t team; + uint8_t flags; + char name[16]; +}; +``` + +**C Output:** +``` +=== BAD CASE (Poor Field Ordering) === +Total size: 120 bytes +Alignment: 8 bytes + +Field Offset Size +----- ------ ---- +timestamp 0 8 +isActive 8 1 +[PADDING] 9 7 ← Wasted! +position[3] 16 24 +team 40 1 +[PADDING] 41 7 ← Wasted! +rotation[4] 48 32 +damage 80 4 +velocity[3] 84 12 +flags 96 1 +[PADDING] 97 1 ← Wasted! +health 98 2 +name[16] 100 16 +[PADDING] 116 4 ← Wasted! + +Actual data: 101 bytes, Wasted: 19 bytes + +=== GOOD CASE (Optimal Field Ordering) === +Total size: 104 bytes +Alignment: 8 bytes + +Field Offset Size +----- ------ ---- +timestamp 0 8 +position[3] 8 24 +rotation[4] 32 32 +damage 64 4 +velocity[3] 68 12 +health 80 2 +isActive 82 1 +team 83 1 +flags 84 1 +name[16] 85 16 +[PADDING] 101 3 ← Minimal! + +Actual data: 101 bytes, Wasted: 3 bytes +``` + +#### TypeScript Equivalent + +```typescript +const badCaseDef = struct({ + timestamp: float64, + isActive: uint8, + position: array('f64', 3), + team: uint8, + rotation: array('f64', 4), + damage: float32, + velocity: array('f32', 3), + flags: uint8, + health: uint16, + name: utf8(16), +}); + +badCaseDef.inspect(); + +const goodCaseDef = struct({ + timestamp: float64, + position: array('f64', 3), + rotation: array('f64', 4), + damage: float32, + velocity: array('f32', 3), + health: uint16, + isActive: uint8, + team: uint8, + flags: uint8, + name: utf8(16), +}); + +goodCaseDef.inspect(); +``` + +**TypeScript Output:** +``` +=== BAD CASE (Poor Field Ordering) === +Field Offset Size +-------------------- -------- -------- +timestamp 0 8 +isActive 8 1 +[PADDING] 9 7 ← Wasted! +position[3] 16 24 +team 40 1 +[PADDING] 41 7 ← Wasted! +rotation[4] 48 32 +damage 80 4 +velocity[3] 84 12 +flags 96 1 +[PADDING] 97 1 ← Wasted! +health 98 2 +name[16] 100 16 +[PADDING] 116 4 ← Wasted! + +Total size: 120 bytes +Actual data: 101 bytes +Wasted: 19 bytes +Efficiency: 84.2% + +=== GOOD CASE (Optimal Field Ordering) === +Field Offset Size +-------------------- -------- -------- +timestamp 0 8 +position[3] 8 24 +rotation[4] 32 32 +damage 64 4 +velocity[3] 68 12 +health 80 2 +isActive 82 1 +team 83 1 +flags 84 1 +name[16] 85 16 +[PADDING] 101 3 ← Minimal! + +Total size: 104 bytes +Actual data: 101 bytes +Wasted: 3 bytes +Efficiency: 97.1% +``` + +**Result: Identical memory layout to C - 16 bytes saved per struct (13.3% reduction)** + +The TypeScript implementation produces **exactly the same memory layout** as C, demonstrating that this library provides true control over data structure layout, not just a JavaScript abstraction. + +**Struct Size and Padding:** +- Keep structs under 64 bytes when possible (fits in single cache line) +- Order fields largest-to-smallest to minimize padding +- Use `inspect()` to verify memory layout + +## API Reference + +### Struct System + +#### `struct(schema)` + +Define a struct with explicit memory layout. + +```typescript +import { struct, uint32, float64, array, utf8 } from '@certes/ordo'; + +const EntityDef = struct({ + id: uint32, + name: utf8(32), + position: array('f64', 3), + health: float32 +}); +``` + +#### `structView(def)` + +Create a view into a single struct's memory. + +```typescript +const entity = structView(EntityDef); + +entity.set('id', 1); +entity.set('health', 100.0); + +const nameField = entity.get('name'); +nameField.set('Player'); + +const pos = entity.get('position'); +pos[0] = 10.0; +``` + +#### `structArray(def, capacity)` + +Create a fixed-capacity array of structs. + +```typescript +const entities = structArray(EntityDef, 1000); + +const idx = entities.push(); +entities.set(idx, 'health', 100.0); + +// Or get a view for multiple operations +const entity = entities.at(idx); +entity.set('health', 100.0); +``` + +#### `dynamicStructArray(def, initialCapacity)` + +Create an auto-growing array of structs. + +```typescript +const entities = dynamicStructArray(EntityDef, 100); + +// Automatically grows as needed +for (let i = 0; i < 1000; i++) { + entities.push({ id: i, health: 100.0 }); +} + +// Automatic downsizing when removing elements +entities.remove(5); +``` + +### Field Types + +#### Primitive Types + +All primitive types are little-endian: + +- `int8` - 8-bit signed integer (1 byte, 1-byte alignment) +- `uint8` - 8-bit unsigned integer (1 byte, 1-byte alignment) +- `int16` - 16-bit signed integer (2 bytes, 2-byte alignment) +- `uint16` - 16-bit unsigned integer (2 bytes, 2-byte alignment) +- `int32` - 32-bit signed integer (4 bytes, 4-byte alignment) +- `uint32` - 32-bit unsigned integer (4 bytes, 4-byte alignment) +- `float32` - 32-bit floating point (4 bytes, 4-byte alignment) +- `float64` - 64-bit floating point (8 bytes, 8-byte alignment) + +#### `array(arrayType, length)` + +Fixed-size typed array embedded in the struct. + +```typescript +const TransformDef = struct({ + position: array('f32', 3), // [x, y, z] + rotation: array('f32', 4), // [x, y, z, w] quaternion + scale: array('f32', 3), // [x, y, z] + matrix: array('f64', 16) // 4x4 matrix +}); + +const transform = structView(TransformDef); + +const pos = transform.get('position'); +pos[0] = 100; +pos[1] = 200; +pos[2] = 50; + +const mat = transform.get('matrix'); +// Identity matrix +mat[0] = 1; mat[5] = 1; mat[10] = 1; mat[15] = 1; +``` + +Available array types: `'i8'`, `'u8'`, `'i16'`, `'u16'`, `'i32'`, `'u32'`, `'f32'`, `'f64'`, `'i64'`, `'u64'` + +#### `utf8(byteLength)` + +Fixed-size UTF-8 string field. + +```typescript +const PlayerDef = struct({ + id: uint32, + name: utf8(32), // 32-byte string + tag: utf8(8) // 8-byte string +}); + +const player = structView(PlayerDef); + +const nameField = player.get('name'); +nameField.set('PlayerOne'); +console.log(nameField.get()); // "PlayerOne" + +// Truncates if too long +nameField.set('VeryLongPlayerNameThatExceeds32Bytes'); +``` + +#### `circular(arrayType, capacity)` + +Embedded circular buffer (FIFO queue). + +```typescript +const SensorDef = struct({ + sensorId: uint32, + readings: circular('f32', 10), // Last 10 readings + avgReading: float32 +}); + +const sensor = structView(SensorDef); +sensor.set('sensorId', 101); + +const readings = sensor.get('readings'); + +// Add readings +for (let i = 0; i < 15; i++) { + readings.enqueue(Math.random() * 100); +} + +console.log(readings.size()); // 10 (capacity) +console.log(readings.toArray()); // Last 10 readings + +// Calculate average +const avg = readings.toArray().reduce((a, b) => a + b, 0) / readings.size(); +sensor.set('avgReading', avg); +``` + +### Dynamic Collections + +#### `dynamicArray(arrayType, initialSize)` + +Auto-growing typed array. + +```typescript +import { dynamicArray } from '@certes/ordo'; + +const positions = dynamicArray('f32', 100); + +positions.push(10.5); +positions.push(20.3); +positions.push(30.1); + +console.log(positions.at(0)); // 10.5 +console.log(positions.size()); // 3 + +// Automatically grows +for (let i = 0; i < 1000; i++) { + positions.push(i * 0.1); +} + +// Remove and shift +positions.remove(5); // O(n) operation + +// Automatically shrinks when size drops +``` + +#### `sparseArray(arrayType, initialSize)` + +Array that grows but never shrinks. Useful for stable indices. + +```typescript +import { sparseArray } from '@certes/ordo'; + +const entityIds = sparseArray('u32', 100); + +const id1 = entityIds.push(42); +const id2 = entityIds.push(43); + +// Remove doesn't shift - just sets to 0 +entityIds.remove(id1); + +console.log(entityIds.at(id1)); // 0 +console.log(entityIds.at(id2)); // 43 (index unchanged) +``` + +#### `circularBuffer(arrayType, capacity)` + +Standalone FIFO circular buffer. + +```typescript +import { circularBuffer } from '@certes/ordo'; + +const frameTimes = circularBuffer('f64', 60); + +// Add frame times +for (let i = 0; i < 100; i++) { + frameTimes.enqueue(16.67); // ~60 FPS +} + +// Automatically overwrites oldest when full +console.log(frameTimes.size()); // 60 + +// Get oldest +const oldest = frameTimes.dequeue(); + +// Calculate average +const avg = frameTimes.toArray().reduce((a, b) => a + b, 0) / frameTimes.size(); +console.log(`Average frame time: ${avg.toFixed(2)}ms`); +``` + +## Usage Patterns + +### Particle System + +```typescript +import { + struct, + structArray, + uint32, + uint8, + float32, + array, + circular +} from '@certes/ordo'; + +const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), + velocity: array('f32', 3), + color: array('u8', 4), // RGBA + lifetime: float32, + active: uint8, + velocityHistory: circular('f32', 5) // Last 5 velocity samples +}); + +const particles = structArray(ParticleDef, 10000); + +// Spawn particle +const spawnParticle = (x: number, y: number, z: number) => { + const idx = particles.push(); + const particle = particles.at(idx); + + particle.set('id', idx); + particle.set('lifetime', 10.0); + particle.set('active', 1); + + const pos = particle.get('position'); + pos[0] = x; + pos[1] = y; + pos[2] = z; + + const vel = particle.get('velocity'); + vel[0] = (Math.random() - 0.5) * 2; + vel[1] = (Math.random() - 0.5) * 2; + vel[2] = (Math.random() - 0.5) * 2; + + const color = particle.get('color'); + color[0] = 255; + color[1] = 128; + color[2] = 64; + color[3] = 255; + + return idx; +}; + +// Update loop - cache-friendly iteration +const updateParticles = (dt: number) => { + for (let i = 0; i < particles.length; i++) { + if (particles.get(i, 'active') === 0) continue; + + const particle = particles.at(i); + + const pos = particle.get('position'); + const vel = particle.get('velocity'); + + // Update position + pos[0] += vel[0] * dt; + pos[1] += vel[1] * dt; + pos[2] += vel[2] * dt; + + // Update lifetime + const lifetime = particle.get('lifetime'); + particle.set('lifetime', lifetime - dt); + + if (lifetime <= 0) { + particle.set('active', 0); + } + + // Track velocity magnitude + const speed = Math.sqrt(vel[0]**2 + vel[1]**2 + vel[2]**2); + const velHistory = particle.get('velocityHistory'); + velHistory.enqueue(speed); + } +}; +``` + +### Entity Component System (ECS) + +```typescript +import { + struct, + dynamicStructArray, + uint32, + uint8, + float32, + float64, + array, + utf8 +} from '@certes/ordo'; + +// Component definitions +const TransformDef = struct({ + position: array('f64', 3), + rotation: array('f64', 4), // Quaternion + scale: array('f64', 3) +}); + +const RenderableDef = struct({ + meshId: uint32, + materialId: uint32, + visible: uint8 +}); + +const PhysicsDef = struct({ + velocity: array('f32', 3), + acceleration: array('f32', 3), + mass: float32, + friction: float32 +}); + +// Component arrays +const transforms = dynamicStructArray(TransformDef, 1000); +const renderables = dynamicStructArray(RenderableDef, 1000); +const physics = dynamicStructArray(PhysicsDef, 1000); + +// Create entity +const createEntity = () => { + const transformIdx = transforms.push(); + const renderableIdx = renderables.push(); + const physicsIdx = physics.push(); + + // Initialize transform + const transform = transforms.at(transformIdx); + + const pos = transform.get('position'); + pos[0] = 0; pos[1] = 0; pos[2] = 0; + + const rot = transform.get('rotation'); + rot[0] = 0; rot[1] = 0; rot[2] = 0; rot[3] = 1; + + const scale = transform.get('scale'); + scale[0] = 1; scale[1] = 1; scale[2] = 1; + + return { transformIdx, renderableIdx, physicsIdx }; +}; + +// Physics system - processes only entities with physics +const physicsSystem = (dt: number) => { + for (let i = 0; i < physics.length; i++) { + const phys = physics.at(i); + const transform = transforms.at(i); + + const vel = phys.get('velocity'); + const acc = phys.get('acceleration'); + const pos = transform.get('position'); + + // Update velocity + vel[0] += acc[0] * dt; + vel[1] += acc[1] * dt; + vel[2] += acc[2] * dt; + + // Update position + pos[0] += vel[0] * dt; + pos[1] += vel[1] * dt; + pos[2] += vel[2] * dt; + } +}; +``` + +## Design Philosophy + +### Data-Oriented vs Object-Oriented + +**Object-Oriented (typical JavaScript):** +```typescript +class Particle { + x: number; + y: number; + z: number; + vx: number; + vy: number; + vz: number; + lifetime: number; +} + +const particles: Particle[] = []; +for (let i = 0; i < 10000; i++) { + particles.push(new Particle()); +} + +// Memory layout: scattered across heap +// Cache misses: high +// GC pressure: high +``` + +**Data-Oriented (this library):** +```typescript +const ParticleDef = struct({ + position: array('f32', 3), + velocity: array('f32', 3), + lifetime: float32 +}); + +const particles = structArray(ParticleDef, 10000); + +// Memory layout: contiguous +// Cache misses: minimal +// GC pressure: zero (after allocation) +``` + +### When NOT to Use This Library + +Don't use `@certes/ordo` if: + +- You have irregular, heterogeneous data structures +- You need frequent insertions/deletions in the middle of arrays +- Your data structures are small (<100 items) +- You need rich object behaviors and polymorphism +- Readability and maintainability trump performance + +This library trades API convenience for performance. Use it where performance matters. + +## Integration with @certes Ecosystem + +`@certes/ordo` complements other @certes packages: + +```typescript +import { pipe } from '@certes/composition'; +import { filter, map } from '@certes/lazy'; +import { struct, dynamicArray } from '@certes/ordo'; + +// Use lazy iterators with struct arrays +const ParticleDef = struct({ + lifetime: float32, + active: uint8 +}); + +const particles = structArray(ParticleDef, 1000); + +// Functional pipeline over struct data +const activeParticleLifetimes = pipe( + Array.from({ length: particles.length }, (_, i) => i), + filter(i => particles.get(i, 'active') === 1), + map(i => particles.get(i, 'lifetime')) +); + +for (const lifetime of activeParticleLifetimes) { + console.log(lifetime); +} +``` + +## TypeScript Support + +Full type inference and type safety: + +```typescript +const EntityDef = struct({ + id: uint32, + position: array('f32', 3), + name: utf8(32) +}); + +const entity = structView(EntityDef); + +// ✅ Type-safe: returns number +const id: number = entity.get('id'); + +// ✅ Type-safe: returns Float32Array +const pos: Float32Array = entity.get('position'); + +// ✅ Type-safe: returns Utf8StructField +const nameField = entity.get('name'); +const name: string = nameField.get(); + +// ❌ Compile error: unknown field +entity.get('unknown'); + +// ❌ Compile error: can't set complex field +entity.set('position', 123); // Use entity.get('position')[0] = 123 +``` + +## License + +MIT + +## Contributing + +Part of the [@certes](https://github.com/certes-ts/certes) monorepo. See main repository for contribution guidelines. diff --git a/packages/ordo/package.json b/packages/ordo/package.json new file mode 100644 index 0000000..685a40e --- /dev/null +++ b/packages/ordo/package.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "@certes/ordo", + "title": "Certes Ordo", + "description": "A Data-Oriented approach in JavaScript.", + "author": "https://github.com/certes-ts", + "keywords": [], + "subPath": "packages/ordo", + "version": "0.1.0", + "private": false, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/certes-ts/certes" + }, + "bugs": "https://github.com/certes-ts/certes/issues", + "type": "module", + "files": [ + "./dist/**", + "./CHANGELOG.md", + "./package.json", + "./README.md" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./circular-buffer": "./dist/circular-buffer/index.js", + "./dynamic-array": "./dist/dynamic-array/index.js", + "./sparse-array": "./dist/sparse-array/index.js", + "./struct/dynamic-struct-array": "./dist/struct/dynamic-struct-array.js", + "./struct/factory-functions": "./dist/struct/factory-functions.js", + "./struct/fields": "./dist/struct/fields.js", + "./struct/struct": "./dist/struct/struct.js", + "./struct/struct-array": "./dist/struct/struct-array.js", + "./struct/struct-view": "./dist/struct/struct-view.js", + "./types": "./dist/types.js", + "./utf8-array": "./dist/utf8-array/index.js", + "./utils": "./dist/utils.js", + "./package.json": "./package.json" + }, + "scripts": { + "bench": "pnpm vitest bench --run", + "build": "pnpm tsdown", + "format": "pnpm biome check --write", + "lint": "pnpm biome lint", + "test": "pnpm vitest --dir=src", + "test:types": "pnpm vitest --typecheck" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^4.0.8", + "tsdown": "^0.17.2", + "typescript": "5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.8" + }, + "engines": { + "node": "22.x", + "pnpm": "10.x" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false +} diff --git a/packages/ordo/src/circular-buffer/circular-buffer.test.ts b/packages/ordo/src/circular-buffer/circular-buffer.test.ts new file mode 100644 index 0000000..577bdf1 --- /dev/null +++ b/packages/ordo/src/circular-buffer/circular-buffer.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from 'vitest'; +import { circularBuffer, circularBufferFrom } from '.'; + +describe('CircularBuffer', () => { + describe('constructor', () => { + it('should create a buffer with valid parameters', () => { + const buf = circularBuffer('f32', 10); + + expect(buf.capacity()).toBe(10); + expect(buf.size()).toBe(0); + }); + + it('should throw on invalid buffer type - empty string', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing + expect(() => circularBuffer('' as any, 10)).toThrow( + 'bufferType must be provided', + ); + }); + + it('should throw on invalid buffer type - unknown type', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing + expect(() => circularBuffer('invalid' as any, 10)).toThrow( + 'Unknown buffer type', + ); + }); + + it('should throw on zero capacity', () => { + expect(() => circularBuffer('f32', 0)).toThrow( + 'capacity must be greater than 0', + ); + }); + + it('should throw on negative capacity', () => { + expect(() => circularBuffer('f32', -1)).toThrow( + 'capacity must be greater than 0', + ); + }); + }); + + describe('enqueue', () => { + it('should add elements to the empty buffer', () => { + const buf = circularBuffer('u32', 5); + + buf.enqueue(10); + buf.enqueue(20); + + expect(buf.size()).toBe(2); + expect(buf.peek()).toBe(10); + }); + + it('should wrap around when full', () => { + const buf = circularBuffer('u32', 3); + + buf.enqueue(1); + buf.enqueue(2); + buf.enqueue(3); + buf.enqueue(4); + + expect(buf.size()).toBe(3); + expect(buf.toArray()).toEqual([2, 3, 4]); + }); + + it('should maintain FIFO order', () => { + const buf = circularBuffer('i32', 10); + + for (let i = 0; i < 5; i++) { + buf.enqueue(i); + } + + expect(buf.toArray()).toEqual([0, 1, 2, 3, 4]); + }); + + it('should handle multiple wraparounds', () => { + const buf = circularBuffer('u8', 3); + + for (let i = 0; i < 10; i++) { + buf.enqueue(i); + } + + expect(buf.size()).toBe(3); + expect(buf.toArray()).toEqual([7, 8, 9]); + }); + }); + + describe('dequeue', () => { + it('should remove and return oldest element', () => { + const buf = circularBuffer('f64', 5); + + buf.enqueue(1.5); + buf.enqueue(2.5); + + const result = buf.dequeue(); + + expect(result).toBe(1.5); + expect(buf.size()).toBe(1); + }); + + it('should throw when empty', () => { + const buf = circularBuffer('u8', 5); + + expect(() => buf.dequeue()).toThrow('Cannot dequeue from empty buffer'); + }); + + it('should handle multiple dequeues', () => { + const buf = circularBuffer('i16', 5); + + buf.enqueue(10); + buf.enqueue(20); + buf.enqueue(30); + + const first = buf.dequeue(); + const second = buf.dequeue(); + const third = buf.dequeue(); + + expect(first).toBe(10); + expect(second).toBe(20); + expect(third).toBe(30); + expect(buf.size()).toBe(0); + }); + }); + + describe('peek', () => { + it('should return oldest element without removing', () => { + const buf = circularBuffer('i16', 5); + + buf.enqueue(100); + buf.enqueue(200); + + const result = buf.peek(); + + expect(result).toBe(100); + expect(buf.size()).toBe(2); + }); + + it('should throw when empty', () => { + const buf = circularBuffer('u16', 5); + + expect(() => buf.peek()).toThrow('Cannot peek into empty buffer'); + }); + + it('should always return head element', () => { + const buf = circularBuffer('u32', 3); + + buf.enqueue(1); + buf.enqueue(2); + buf.enqueue(3); + buf.enqueue(4); // Overwrites 1 + + expect(buf.peek()).toBe(2); // New head + }); + }); + + describe('clear', () => { + it('should reset the buffer to an empty state', () => { + const buf = circularBuffer('f32', 5); + + buf.enqueue(1); + buf.enqueue(2); + buf.enqueue(3); + + buf.clear(); + + expect(buf.size()).toBe(0); + expect(buf.toArray()).toEqual([]); + }); + + it('should allow reuse after cleared', () => { + const buf = circularBuffer('u32', 3); + + buf.enqueue(1); + buf.enqueue(2); + buf.clear(); + + buf.enqueue(10); + buf.enqueue(20); + + expect(buf.toArray()).toEqual([10, 20]); + }); + }); + + describe('toArray', () => { + it('should return elements in FIFO order', () => { + const buf = circularBuffer('u32', 5); + + buf.enqueue(10); + buf.enqueue(20); + buf.enqueue(30); + + const result = buf.toArray(); + + expect(result).toEqual([10, 20, 30]); + }); + + it('should handle wrapped buffer', () => { + const buf = circularBuffer('i32', 3); + + buf.enqueue(1); + buf.enqueue(2); + buf.enqueue(3); + buf.enqueue(4); + buf.enqueue(5); + + const result = buf.toArray(); + + expect(result).toEqual([3, 4, 5]); + }); + + it('should return an empty array for an empty buffer', () => { + const buf = circularBuffer('f64', 5); + const result = buf.toArray(); + + expect(result).toEqual([]); + }); + }); + + describe('from', () => { + it('should create a buffer from an array', () => { + const arr = [1, 2, 3, 4, 5]; + const buf = circularBufferFrom('f64', arr); + + expect(buf.size()).toBe(5); + expect(buf.toArray()).toEqual(arr); + }); + + it('should throw on empty array', () => { + expect(() => circularBufferFrom('u32', [])).toThrow( + 'arr must be an array with length greater than 0', + ); + }); + + it('should set capacity equal to the array length', () => { + const arr = [10, 20, 30]; + const buf = circularBufferFrom('i32', arr); + + expect(buf.capacity()).toBe(3); + expect(buf.size()).toBe(3); + }); + }); + + describe('iterator', () => { + it('should iterate in FIFO order', () => { + const buf = circularBuffer('i32', 5); + + buf.enqueue(10); + buf.enqueue(20); + buf.enqueue(30); + + const result = Array.from(buf); + + expect(result).toEqual([10, 20, 30]); + }); + + it('should handle wrapped buffer', () => { + const buf = circularBuffer('u32', 3); + + buf.enqueue(1); + buf.enqueue(2); + buf.enqueue(3); + buf.enqueue(4); + + const result = Array.from(buf); + + expect(result).toEqual([2, 3, 4]); + }); + + it('should work with a for...of loop', () => { + const buf = circularBuffer('f32', 5); + + buf.enqueue(1); + buf.enqueue(2); + buf.enqueue(3); + + const result: number[] = []; + for (const val of buf) { + result.push(val); + } + + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('Type Support', () => { + it('should support all numeric types', () => { + const u8 = circularBuffer('u8', 2); + const i8 = circularBuffer('i8', 2); + const u16 = circularBuffer('u16', 2); + const i16 = circularBuffer('i16', 2); + const u32 = circularBuffer('u32', 2); + const i32 = circularBuffer('i32', 2); + const f32 = circularBuffer('f32', 2); + const f64 = circularBuffer('f64', 2); + + expect(u8.capacity()).toBe(2); + expect(i8.capacity()).toBe(2); + expect(u16.capacity()).toBe(2); + expect(i16.capacity()).toBe(2); + expect(u32.capacity()).toBe(2); + expect(i32.capacity()).toBe(2); + expect(f32.capacity()).toBe(2); + expect(f64.capacity()).toBe(2); + }); + }); +}); diff --git a/packages/ordo/src/circular-buffer/index.ts b/packages/ordo/src/circular-buffer/index.ts new file mode 100644 index 0000000..d3e7bea --- /dev/null +++ b/packages/ordo/src/circular-buffer/index.ts @@ -0,0 +1,213 @@ +import { type ArrayKeys, arrayTypes, type KeyedArray } from '../types'; +import { assertDefined } from '../utils'; + +export class _CircularBuffer { + readonly #construct: (typeof arrayTypes)[Key]; + readonly #buffer: KeyedArray[Key]; + readonly #capacity: number; + #current = 0; + #head = 0; + #tail = 0; + + constructor(bufferType: Key, capacity: number) { + if (!bufferType) { + throw new TypeError('bufferType must be provided'); + } + + if (!arrayTypes[bufferType]) { + throw new TypeError(`Unknown buffer type "${bufferType}"`); + } + + if (!capacity || capacity < 1) { + throw new Error(`capacity must be greater than 0, ${capacity} given`); + } + + this.#construct = arrayTypes[bufferType]; + + const maxCapacity = Math.floor(2 ** 31 / this.#construct.BYTES_PER_ELEMENT); + + if (capacity > maxCapacity) { + throw new RangeError( + `Capacity ${capacity} exceeds maximum ${maxCapacity} for ${bufferType}`, + ); + } + + this.#buffer = new this.#construct(capacity) as KeyedArray[Key]; + this.#capacity = capacity; + } + + enqueue(value: KeyedArray[Key][number]): void { + const wasFull = this.#current >= this.#capacity; + + this.#buffer[this.#tail] = value; + this.#tail = (this.#tail + 1) % this.#capacity; + + if (wasFull) { + this.#head = (this.#head + 1) % this.#capacity; + } else { + this.#current++; + } + } + + dequeue(): KeyedArray[Key][number] { + if (this.#current === 0) { + throw new RangeError('Cannot dequeue from empty buffer'); + } + + const value = this.#buffer[this.#head]; + + assertDefined(value); + this.#head = (this.#head + 1) % this.#capacity; + this.#current--; + + return value; + } + + peek(): KeyedArray[Key][number] { + if (this.#current === 0) { + throw new RangeError('Cannot peek into empty buffer'); + } + + const value = this.#buffer[this.#head]; + + assertDefined(value); + + return value; + } + + size(): number { + return this.#current; + } + + capacity(): number { + return this.#capacity; + } + + clear(): void { + this.#head = 0; + this.#tail = 0; + this.#current = 0; + } + + toArray(): KeyedArray[Key][number][] { + const arr: KeyedArray[Key][number][] = new Array(this.#current); + + for (let i = 0; i < this.#current; i++) { + const value = this.#buffer[(this.#head + i) % this.#capacity]; + + assertDefined(value); + arr[i] = value; + } + + return arr; + } + + /** + * Creates a circular buffer from an existing array. + * + * @template K - The TypedArray type key + * @param bufferType - The data type for buffer elements + * @param arr - Array of numbers to initialize with + * @returns A new circular buffer instance + * @throws {Error} If array is empty + * + * @example + * ```typescript + * const buf = _CircularBuffer.from('f32', [1, 2, 3, 4, 5]); + * console.log(buf.size()); // 5 + * ``` + */ + static from( + bufferType: K, + arr: KeyedArray[K][number][], + ): _CircularBuffer { + if (!Array.isArray(arr) || arr.length < 1) { + throw new Error('arr must be an array with length greater than 0'); + } + + const buf = new _CircularBuffer(bufferType, arr.length); + + for (const e of arr) { + buf.enqueue(e); + } + + return buf; + } + + *[Symbol.iterator](): Iterator { + for (let i = 0; i < this.#current; i++) { + const value = this.#buffer[(this.#head + i) % this.#capacity]; + + assertDefined(value); + yield value; + } + } +} + +export type CircularBuffer = _CircularBuffer; + +/** + * Creates a fixed-capacity circular buffer (FIFO queue) with automatic wraparound. + * + * When the buffer is full, enqueueing a new element overwrites the oldest element. + * This is useful for sliding windows, frame timing, sensor readings, and other + * time-series data where only recent values matter. + * + * @template Key - The TypedArray type key (e.g., 'f32', 'u32') + * @param bufferType - The data type of elements in the buffer + * @param capacity - The maximum number of elements the buffer can hold + * @returns A new circular buffer instance + * @throws {Error} If bufferType is invalid or not recognized + * @throws {Error} If capacity is less than 1 + * + * @remarks + * The buffer uses contiguous memory (TypedArray) for cache-friendly access. + * + * @example + * ```typescript + * // Create buffer for last 60 frame times + * const frameTimes = circularBuffer('f64', 60); + * + * frameTimes.enqueue(16.67); // Add frame time + * frameTimes.enqueue(16.33); + * + * console.log(frameTimes.size()); // 2 + * console.log(frameTimes.peek()); // 16.67 (oldest) + * console.log(frameTimes.dequeue()); // 16.67 (removes oldest) + * ``` + * + * @see {@link circularBufferFrom} for creating from existing array + */ +export const circularBuffer = ( + bufferType: Key, + capacity: number, +): CircularBuffer => { + return new _CircularBuffer(bufferType, capacity); +}; + +/** + * Creates a circular buffer from an existing array of numbers. + * + * All elements from the input array are enqueued in order. The buffer + * capacity is set to the array length. + * + * @template Key - The TypedArray type key + * @param bufferType - The data type for buffer elements + * @param arr - Array of numbers to initialize the buffer with + * @returns A new circular buffer containing the array elements + * @throws {Error} If array is empty or has length less than 1 + * + * @example + * ```typescript + * const readings = circularBufferFrom('f32', [1.0, 2.0, 3.0, 4.0, 5.0]); + * + * console.log(readings.size()); // 5 + * console.log(readings.toArray()); // [1, 2, 3, 4, 5] + * ``` + */ +export const circularBufferFrom = ( + bufferType: Key, + arr: KeyedArray[Key][number][], +): CircularBuffer => { + return _CircularBuffer.from(bufferType, arr); +}; diff --git a/packages/ordo/src/dynamic-array/dynamic-array.test.ts b/packages/ordo/src/dynamic-array/dynamic-array.test.ts new file mode 100644 index 0000000..439e013 --- /dev/null +++ b/packages/ordo/src/dynamic-array/dynamic-array.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest'; +import { dynamicArray, dynamicArrayFrom } from '.'; + +describe('DynamicArray', () => { + describe('constructor', () => { + it('should create an array with valid parameters', () => { + const arr = dynamicArray('f32', 10); + + expect(arr.capacity()).toBe(10); + expect(arr.size()).toBe(0); + }); + + it('should throw on invalid array type - empty string', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing + expect(() => dynamicArray('' as any, 10)).toThrow( + 'arrayType must be provided', + ); + }); + + it('should throw on invalid array type - unknown type', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing + expect(() => dynamicArray('bad' as any, 10)).toThrow( + 'Unknown array type', + ); + }); + + it('should throw on zero initial size', () => { + expect(() => dynamicArray('u32', 0)).toThrow( + 'initialSize must be greater than 0', + ); + }); + + it('should throw on negative initial size', () => { + expect(() => dynamicArray('u32', -5)).toThrow( + 'initialSize must be greater than 0', + ); + }); + }); + + describe('push', () => { + it('should add an element to an empty array', () => { + const arr = dynamicArray('u32', 5); + + arr.push(42); + + expect(arr.size()).toBe(1); + expect(arr.at(0)).toBe(42); + }); + + it('should auto-resize when capacity is reached', () => { + const arr = dynamicArray('i32', 2); + + arr.push(1); + arr.push(2); + arr.push(3); + + expect(arr.size()).toBe(3); + expect(arr.capacity()).toBe(4); // 2 * SCALING_FACTOR + }); + + it('should return index from push', () => { + const arr = dynamicArray('u32', 10); + const idx0 = arr.push(100); + const idx1 = arr.push(200); + const idx2 = arr.push(300); + + expect(idx0).toBe(0); + expect(idx1).toBe(1); + expect(idx2).toBe(2); + expect(arr.at(idx0)).toBe(100); + expect(arr.at(idx1)).toBe(200); + expect(arr.at(idx2)).toBe(300); + }); + + it('should preserve existing elements after resize', () => { + const arr = dynamicArray('f64', 2); + + arr.push(1.5); + arr.push(2.5); + + arr.push(3.5); + + expect(arr.at(0)).toBe(1.5); + expect(arr.at(1)).toBe(2.5); + expect(arr.at(2)).toBe(3.5); + }); + + it('should handle multiple resizes', () => { + const arr = dynamicArray('u8', 2); + + // Trigger multiple resizes: 2 -> 4 -> 8 -> 16 + for (let i = 0; i < 10; i++) { + arr.push(i); + } + + expect(arr.size()).toBe(10); + expect(arr.capacity()).toBe(16); + }); + }); + + describe('at', () => { + it('should return an element at a valid index', () => { + const arr = dynamicArray('u16', 10); + + arr.push(100); + arr.push(200); + arr.push(300); + + expect(arr.at(0)).toBe(100); + expect(arr.at(1)).toBe(200); + expect(arr.at(2)).toBe(300); + }); + + it('should throw on negative index', () => { + const arr = dynamicArray('i8', 10); + + arr.push(5); + + expect(() => arr.at(-1)).toThrow('Index out of range'); + }); + + it('should throw on index >= size', () => { + const arr = dynamicArray('i8', 10); + + arr.push(5); + + expect(() => arr.at(1)).toThrow('Index out of range'); + expect(() => arr.at(100)).toThrow('Index out of range'); + }); + }); + + describe('set', () => { + it('should update an element at a valid index', () => { + const arr = dynamicArray('f32', 10); + + arr.push(1.0); + arr.push(2.0); + arr.set(1, 99.9); + + expect(arr.at(1)).toBeCloseTo(99.9, 1); + }); + + it('should throw on out of bounds index', () => { + const arr = dynamicArray('u32', 10); + + arr.push(1); + + expect(() => arr.set(-1, 10)).toThrow('Index out of range'); + expect(() => arr.set(5, 10)).toThrow('Index out of range'); + }); + }); + + describe('remove', () => { + it('should remove an element and shift remaining', () => { + const arr = dynamicArray('i32', 10); + + arr.push(10); + arr.push(20); + arr.push(30); + arr.push(40); + + arr.remove(1); // Remove 20 + + expect(arr.size()).toBe(3); + expect(arr.toArray()).toEqual([10, 30, 40]); + }); + + it('should remove the first element', () => { + const arr = dynamicArray('u32', 10); + + arr.push(1); + arr.push(2); + arr.push(3); + arr.remove(0); + + expect(arr.toArray()).toEqual([2, 3]); + }); + + it('should remove the last element', () => { + const arr = dynamicArray('i16', 10); + + arr.push(1); + arr.push(2); + arr.push(3); + arr.remove(2); + + expect(arr.toArray()).toEqual([1, 2]); + }); + + it('should auto-downsize when size drops', () => { + const arr = dynamicArray('u32', 2); + + arr.push(1); + arr.push(2); + arr.push(3); + arr.push(4); + + expect(arr.capacity()).toBe(4); + + arr.remove(0); + arr.remove(0); + + expect(arr.size()).toBe(2); + expect(arr.capacity()).toBe(2); + }); + + it('should throw on out of bounds index', () => { + const arr = dynamicArray('f64', 10); + + arr.push(1.5); + + expect(() => arr.remove(-1)).toThrow('Index out of range'); + expect(() => arr.remove(5)).toThrow('Index out of range'); + }); + }); + + describe('toArray', () => { + it('should return a standard array of elements', () => { + const arr = dynamicArray('i16', 10); + + arr.push(100); + arr.push(200); + arr.push(300); + + const result = arr.toArray(); + + expect(result).toEqual([100, 200, 300]); + expect(Array.isArray(result)).toBe(true); + }); + + it('should return an empty array for an empty dynamic array', () => { + const arr = dynamicArray('f32', 10); + const result = arr.toArray(); + + expect(result).toEqual([]); + }); + }); + + describe('from', () => { + it('should create array from valid input', () => { + const input = [1, 2, 3, 4, 5]; + const arr = dynamicArrayFrom('f32', input); + + expect(arr.size()).toBe(5); + expect(arr.toArray()).toEqual(input); + }); + + it('should filter out NaN values', () => { + const input = [1, Number.NaN, 3, 5] as number[]; + const arr = dynamicArrayFrom('u32', input); + + expect(arr.size()).toBe(3); + expect(arr.toArray()).toEqual([1, 3, 5]); + }); + + it('should filter out non-numbers', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing + const input = [1, undefined, 3, null, 5] as any; + const arr = dynamicArrayFrom('u32', input); + + expect(arr.size()).toBe(3); + expect(arr.toArray()).toEqual([1, 3, 5]); + }); + + it('should throw on empty array after filtering', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing + const input = [Number.NaN, undefined, null] as any; + + expect(() => dynamicArrayFrom('i32', input)).toThrow( + 'arr must be an array with non-null length greater than 0', + ); + }); + + it('should throw on empty array', () => { + expect(() => dynamicArrayFrom('u32', [])).toThrow('arr must be an array'); + }); + }); + + describe('iterator', () => { + it('should iterate over all elements', () => { + const arr = dynamicArray('u8', 10); + + arr.push(1); + arr.push(2); + arr.push(3); + + const result = Array.from(arr); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should work with for...of loop', () => { + const arr = dynamicArray('f64', 5); + + arr.push(1.1); + arr.push(2.2); + arr.push(3.3); + + const result: number[] = []; + for (const val of arr) { + result.push(val); + } + + expect(result).toEqual([1.1, 2.2, 3.3]); + }); + }); +}); diff --git a/packages/ordo/src/dynamic-array/index.ts b/packages/ordo/src/dynamic-array/index.ts new file mode 100644 index 0000000..2a22a3f --- /dev/null +++ b/packages/ordo/src/dynamic-array/index.ts @@ -0,0 +1,227 @@ +import { type ArrayKeys, arrayTypes, type KeyedArray } from '../types'; +import { assertDefined } from '../utils'; + +const SCALING_FACTOR = 2; + +class _DynamicArray { + readonly #construct: (typeof arrayTypes)[Key]; + #buffer: KeyedArray[Key]; + #capacity: number; + readonly #maxCapacity: number; + #current = 0; + + constructor(arrayType: Key, initialSize: number) { + if (!arrayType) { + throw new TypeError('arrayType must be provided'); + } + + if (!arrayTypes[arrayType]) { + throw new TypeError(`Unknown array type "${arrayType}"`); + } + + if (!initialSize || initialSize < 1) { + throw new Error( + `initialSize must be greater than 0, ${initialSize} given`, + ); + } + + this.#construct = arrayTypes[arrayType]; + this.#buffer = new this.#construct(initialSize) as KeyedArray[Key]; + this.#capacity = initialSize; + this.#maxCapacity = Math.floor(2 ** 31 / this.#construct.BYTES_PER_ELEMENT); + } + + private resize(newCapacity: number): void { + if (newCapacity > this.#maxCapacity) { + throw new RangeError( + `Capacity ${newCapacity} exceeds maximum ${this.#maxCapacity}`, + ); + } + + const newBuffer = new this.#construct(newCapacity); + + // biome-ignore lint/suspicious/noExplicitAny: This is A -> A so it's fine + newBuffer.set(this.#buffer.subarray(0, this.#current) as any); + this.#buffer = newBuffer as KeyedArray[Key]; + this.#capacity = newCapacity; + } + + size(): number { + return this.#current; + } + + capacity(): number { + return this.#capacity; + } + + push(element: KeyedArray[Key][number]): number { + if (this.#current >= this.#capacity) { + this.resize(this.#capacity * SCALING_FACTOR); + } + + this.#buffer[this.#current] = element; + this.#current++; + + return this.#current - 1; + } + + at(index: number): KeyedArray[Key][number] { + if (index < 0 || index >= this.#current) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#current - 1}`, + ); + } + + const value = this.#buffer[index]; + + assertDefined(value); + + return value; + } + + set(index: number, value: KeyedArray[Key][number]): void { + if (index < 0 || index >= this.#current) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#current - 1}`, + ); + } + + this.#buffer[index] = value; + } + + remove(index: number): void { + if (index < 0 || index >= this.#current) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#current - 1}`, + ); + } + + for (let i = index; i < this.#current - 1; i++) { + const value = this.#buffer[i + 1]; + + assertDefined(value); + this.#buffer[i] = value; + } + + this.#current--; + + const minCapacity = Math.max( + 1, + Math.floor(this.#capacity / SCALING_FACTOR), + ); + + if (this.#current <= minCapacity) { + this.resize(minCapacity); + } + } + + toArray(): KeyedArray[Key][number][] { + // biome-ignore lint/suspicious/noExplicitAny: Yes yes, I know + return Array.from(this.#buffer.subarray(0, this.#current) as any); + } + + /** + * Creates a dynamic array from an existing array. + * + * @template K - The TypedArray type key + * @param arrayType - The data type for array elements + * @param arr - Array of numbers to initialize with + * @returns A new dynamic array instance + * @throws {Error} If array is empty or invalid + * + * @example + * ```typescript + * const arr = _DynamicArray.from('f32', [1, 2, 3, 4, 5]); + * console.log(arr.size()); // 5 + * ``` + */ + static from( + arrayType: K, + arr: KeyedArray[K][number][], + ): _DynamicArray { + if (!Array.isArray(arr) || arr.length < 1) { + throw new Error('arr must be an array'); + } + + const filtered = arr.filter( + (x) => typeof x === 'number' && !Number.isNaN(x), + ); + + if (filtered.length < 1) { + throw new Error( + 'arr must be an array with non-null length greater than 0', + ); + } + + const dynArr = new _DynamicArray(arrayType, filtered.length); + + for (const e of filtered) { + dynArr.push(e); + } + + return dynArr; + } + + *[Symbol.iterator](): Iterator { + for (let i = 0; i < this.#current; i++) { + const value = this.#buffer[i]; + + assertDefined(value); + yield value; + } + } +} + +export type DynamicArray = _DynamicArray; + +/** + * Creates an auto-growing typed array with dynamic resizing. + * + * Unlike standard TypedArrays, DynamicArray automatically grows when + * capacity is reached (2x scaling) and shrinks when size drops below + * capacity/2 (to save memory). Elements are stored contiguously in + * a TypedArray for performance. + * + * @template Key - The TypedArray type key + * @param arrayType - The data type of array elements + * @param initialSize - The initial capacity of the array + * @returns A new dynamic array instance + * @throws {Error} If arrayType is invalid + * @throws {Error} If initialSize is less than 1 + * + * @remarks + * - Growth: When full, capacity doubles (capacity * 2) + * - Shrink: When size ≤ capacity/2, capacity halves + * - Use SparseArray if you need stable indices + * + * @example + * ```typescript + * const positions = dynamicArray('f32', 100); + * + * // Add elements + * positions.push(10.5); + * positions.push(20.3); + * + * console.log(positions.size()); // 2 + * console.log(positions.capacity()); // 100 + * + * // Auto-grows when needed + * for (let i = 0; i < 200; i++) { + * positions.push(i * 0.1); + * } + * console.log(positions.capacity()); // 200 (auto-grew) + * ``` + */ +export const dynamicArray = ( + arrayType: Key, + initialSize: number, +): DynamicArray => { + return new _DynamicArray(arrayType, initialSize); +}; + +export const dynamicArrayFrom = ( + arrayType: Key, + arr: KeyedArray[Key][number][], +): DynamicArray => { + return _DynamicArray.from(arrayType, arr); +}; diff --git a/packages/ordo/src/index.ts b/packages/ordo/src/index.ts new file mode 100644 index 0000000..498e6f7 --- /dev/null +++ b/packages/ordo/src/index.ts @@ -0,0 +1,41 @@ +export { circularBuffer, circularBufferFrom } from './circular-buffer'; +export { dynamicArray, dynamicArrayFrom } from './dynamic-array'; +export { sparseArray, sparseArrayFrom } from './sparse-array'; +export { + dynamicStructArray, + struct, + structArray, + structView, +} from './struct/factory-functions'; +export { + array, + circular, + float32, + float64, + int8, + int16, + int32, + isExtendedType, + uint8, + uint16, + uint32, + utf8, +} from './struct/fields'; +export { Utf8TypedArray } from './utf8-array'; +export type { CircularBuffer } from './circular-buffer'; +export type { DynamicArray } from './dynamic-array'; +export type { SparseArray } from './sparse-array'; +export type { DynamicStructArray } from './struct/dynamic-struct-array'; +export type { + ArrayFieldType, + CircularBufferFieldType, + CircularBufferStructField, + ExtendedFieldType, + FieldType, + Utf8FieldType, + Utf8StructField, +} from './struct/fields'; +export type { Struct } from './struct/struct'; +export type { StructArray } from './struct/struct-array'; +export type { StructView } from './struct/struct-view'; +export type { ArrayKeys, ArrayTypes, KeyedArray } from './types'; diff --git a/packages/ordo/src/readme.test.ts b/packages/ordo/src/readme.test.ts new file mode 100644 index 0000000..ef8c78f --- /dev/null +++ b/packages/ordo/src/readme.test.ts @@ -0,0 +1,873 @@ +import { describe, expect, it } from 'vitest'; +import { + array, + circular, + circularBuffer, + dynamicArray, + dynamicStructArray, + float32, + float64, + sparseArray, + struct, + structArray, + structView, + uint8, + uint16, + uint32, + utf8, +} from './'; + +describe('README Examples', () => { + describe('Quick Start', () => { + it('should work with basic struct definitions', () => { + const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), // [x, y, z] + velocity: array('f32', 3), // [vx, vy, vz] + lifetime: float32, + }); + + const particles = structArray(ParticleDef, 1000); + const idx = particles.push(); + const particle = particles.at(idx); + + particle.set('id', 42); + particle.set('lifetime', 10.0); + + const pos = particle.get('position'); + + pos[0] = 100.5; + pos[1] = 200.3; + pos[2] = 50.0; + + expect(particles.length).toBe(1); + expect(particle.get('id')).toBe(42); + expect(particle.get('lifetime')).toBe(10.0); + expect(pos[0]).toBeCloseTo(100.5); + expect(pos[1]).toBeCloseTo(200.3); + expect(pos[2]).toBeCloseTo(50.0); + }); + + it('should call inspect without throwing', () => { + const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), + velocity: array('f32', 3), + lifetime: float32, + }); + + expect(() => ParticleDef.inspect()).not.toThrow(); + }); + + it('should have the correct memory layout for the particle struct', () => { + const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), + velocity: array('f32', 3), + lifetime: float32, + }); + + const actualBytes = ParticleDef.layout.fields.reduce( + (sum, f) => sum + f.type.size, + 0, + ); + + const wastedBytes = ParticleDef.layout.stride - actualBytes; + + expect(ParticleDef.layout.stride).toBe(32); + expect(actualBytes).toBe(32); + expect(wastedBytes).toBe(0); + + expect(ParticleDef.getField('id').offset).toBe(0); + expect(ParticleDef.getField('position').offset).toBe(4); + expect(ParticleDef.getField('velocity').offset).toBe(16); + expect(ParticleDef.getField('lifetime').offset).toBe(28); + }); + }); + + describe('Field Ordering', () => { + it('should match the bad case memory layout', () => { + const badCaseDef = struct({ + timestamp: float64, + isActive: uint8, + position: array('f64', 3), + team: uint8, + rotation: array('f64', 4), + damage: float32, + velocity: array('f32', 3), + flags: uint8, + health: uint16, + name: utf8(16), + }); + + // Verify memory layout matches README + expect(badCaseDef.layout.stride).toBe(120); + + const actualBytes = badCaseDef.layout.fields.reduce( + (sum, f) => sum + f.type.size, + 0, + ); + + const wastedBytes = badCaseDef.layout.stride - actualBytes; + + expect(actualBytes).toBe(101); + expect(wastedBytes).toBe(19); + + // Verify efficiency percentage + const efficiency = (actualBytes / badCaseDef.layout.stride) * 100; + expect(efficiency).toBeCloseTo(84.2, 1); + + // Verify specific field offsets from README + expect(badCaseDef.getField('timestamp').offset).toBe(0); + expect(badCaseDef.getField('isActive').offset).toBe(8); + expect(badCaseDef.getField('position').offset).toBe(16); + expect(badCaseDef.getField('team').offset).toBe(40); + expect(badCaseDef.getField('rotation').offset).toBe(48); + expect(badCaseDef.getField('damage').offset).toBe(80); + expect(badCaseDef.getField('velocity').offset).toBe(84); + expect(badCaseDef.getField('flags').offset).toBe(96); + expect(badCaseDef.getField('health').offset).toBe(98); + expect(badCaseDef.getField('name').offset).toBe(100); + }); + + it('should match the good case memory layout', () => { + const goodCaseDef = struct({ + timestamp: float64, + position: array('f64', 3), + rotation: array('f64', 4), + damage: float32, + velocity: array('f32', 3), + health: uint16, + isActive: uint8, + team: uint8, + flags: uint8, + name: utf8(16), + }); + + // Verify memory layout matches README + expect(goodCaseDef.layout.stride).toBe(104); + + const actualBytes = goodCaseDef.layout.fields.reduce( + (sum, f) => sum + f.type.size, + 0, + ); + const wastedBytes = goodCaseDef.layout.stride - actualBytes; + + expect(actualBytes).toBe(101); + expect(wastedBytes).toBe(3); + + // Verify efficiency percentage + const efficiency = (actualBytes / goodCaseDef.layout.stride) * 100; + expect(efficiency).toBeCloseTo(97.1, 1); + + // Verify specific field offsets from README + expect(goodCaseDef.getField('timestamp').offset).toBe(0); + expect(goodCaseDef.getField('position').offset).toBe(8); + expect(goodCaseDef.getField('rotation').offset).toBe(32); + expect(goodCaseDef.getField('damage').offset).toBe(64); + expect(goodCaseDef.getField('velocity').offset).toBe(68); + expect(goodCaseDef.getField('health').offset).toBe(80); + expect(goodCaseDef.getField('isActive').offset).toBe(82); + expect(goodCaseDef.getField('team').offset).toBe(83); + expect(goodCaseDef.getField('flags').offset).toBe(84); + expect(goodCaseDef.getField('name').offset).toBe(85); + }); + + it('should demonstrate 16 bytes saved with good ordering', () => { + const badCaseDef = struct({ + timestamp: float64, + isActive: uint8, + position: array('f64', 3), + team: uint8, + rotation: array('f64', 4), + damage: float32, + velocity: array('f32', 3), + flags: uint8, + health: uint16, + name: utf8(16), + }); + + const goodCaseDef = struct({ + timestamp: float64, + position: array('f64', 3), + rotation: array('f64', 4), + damage: float32, + velocity: array('f32', 3), + health: uint16, + isActive: uint8, + team: uint8, + flags: uint8, + name: utf8(16), + }); + + const bytesSaved = badCaseDef.layout.stride - goodCaseDef.layout.stride; + const percentageReduction = (bytesSaved / badCaseDef.layout.stride) * 100; + + expect(bytesSaved).toBe(16); + expect(percentageReduction).toBeCloseTo(13.3, 1); + }); + }); + + describe('API Reference', () => { + it('structView - should work with the entity example', () => { + const EntityDef = struct({ + id: uint32, + name: utf8(32), + position: array('f64', 3), + health: float32, + }); + + const entity = structView(EntityDef); + + entity.set('id', 1); + entity.set('health', 100.0); + + const nameField = entity.get('name'); + nameField.set('Player'); + + const pos = entity.get('position'); + pos[0] = 10.0; + + expect(entity.get('id')).toBe(1); + expect(entity.get('health')).toBe(100.0); + expect(nameField.get()).toBe('Player'); + expect(pos[0]).toBe(10.0); + }); + + it('structArray - should work with entity array example', () => { + const EntityDef = struct({ + id: uint32, + name: utf8(32), + position: array('f64', 3), + health: float32, + }); + + const entities = structArray(EntityDef, 1000); + + const idx = entities.push(); + entities.set(idx, 'health', 100.0); + + const entity = entities.at(idx); + entity.set('health', 100.0); + + expect(entities.length).toBe(1); + expect(entities.get(idx, 'health')).toBe(100.0); + expect(entity.get('health')).toBe(100.0); + }); + + it('dynamicStructArray - should work with auto-growing example', () => { + const EntityDef = struct({ + id: uint32, + name: utf8(32), + position: array('f64', 3), + health: float32, + }); + + const entities = dynamicStructArray(EntityDef, 100); + + for (let i = 0; i < 1000; i++) { + entities.push({ id: i, health: 100.0 }); + } + + expect(entities.length).toBe(1000); + expect(entities.capacity).toBeGreaterThan(100); + + entities.remove(5); + + expect(entities.length).toBe(999); + }); + + it('array field type - should work with transform example', () => { + const TransformDef = struct({ + position: array('f32', 3), // [x, y, z] + rotation: array('f32', 4), // [x, y, z, w] quaternion + scale: array('f32', 3), // [x, y, z] + matrix: array('f64', 16), // 4x4 matrix + }); + + const transform = structView(TransformDef); + const pos = transform.get('position'); + pos[0] = 100; + pos[1] = 200; + pos[2] = 50; + + const mat = transform.get('matrix'); + // Identity matrix + mat[0] = 1; + mat[5] = 1; + mat[10] = 1; + mat[15] = 1; + + expect(pos[0]).toBe(100); + expect(pos[1]).toBe(200); + expect(pos[2]).toBe(50); + expect(mat[0]).toBe(1); + expect(mat[5]).toBe(1); + expect(mat[10]).toBe(1); + expect(mat[15]).toBe(1); + }); + + it('utf8 field type - should work with player example', () => { + const PlayerDef = struct({ + id: uint32, + name: utf8(32), // 32-byte string + tag: utf8(8), // 8-byte string + }); + + const player = structView(PlayerDef); + const nameField = player.get('name'); + nameField.set('PlayerOne'); + + // Truncates if too long + nameField.set('VeryLongPlayerNameThatExceeds32Bytes'); + + expect(nameField.get()).toBeTruthy(); + expect(nameField.get().length).toBeLessThanOrEqual(32); + }); + + it('circular field type - should work with sensor example', () => { + const SensorDef = struct({ + sensorId: uint32, + readings: circular('f32', 10), // Last 10 readings + avgReading: float32, + }); + + const sensor = structView(SensorDef); + sensor.set('sensorId', 101); + + const readings = sensor.get('readings'); + + for (let i = 0; i < 15; i++) { + readings.enqueue(Math.random() * 100); + } + + // Calculate average + const avg = + readings.toArray().reduce((a, b) => a + b, 0) / readings.size(); + + sensor.set('avgReading', avg); + + expect(readings.size()).toBe(10); // capacity + expect(readings.toArray().length).toBe(10); // Last 10 readings + expect(sensor.get('sensorId')).toBe(101); + expect(sensor.get('avgReading')).toBeGreaterThan(0); + }); + + it('dynamicArray - should work with positions example', () => { + const positions = dynamicArray('f32', 100); + + positions.push(10.5); + positions.push(20.3); + positions.push(30.1); + + expect(positions.at(0)).toBe(10.5); + expect(positions.size()).toBe(3); + + for (let i = 0; i < 1000; i++) { + positions.push(i * 0.1); + } + + expect(positions.size()).toBe(1003); + + positions.remove(5); // O(n) operation + + expect(positions.size()).toBe(1002); + }); + + it('sparseArray - should work with entity IDs example', () => { + const entityIds = sparseArray('u32', 100); + const id1 = entityIds.push(42); + const id2 = entityIds.push(43); + + entityIds.remove(id1); + + expect(entityIds.at(id1)).toBe(0); + expect(entityIds.at(id2)).toBe(43); // index unchanged + }); + + it('circularBuffer - should work with frame times example', () => { + const frameTimes = circularBuffer('f64', 60); + + for (let i = 0; i < 100; i++) { + frameTimes.enqueue(16.67); // ~60 FPS + } + + expect(frameTimes.size()).toBe(60); + + const oldest = frameTimes.dequeue(); + + expect(oldest).toBe(16.67); + expect(frameTimes.size()).toBe(59); + + const avg = + frameTimes.toArray().reduce((a, b) => a + b, 0) / frameTimes.size(); + + expect(avg).toBeCloseTo(16.67, 2); + }); + }); + + describe('Particle System', () => { + it('should create the particle system', () => { + // Define particle struct + const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), + velocity: array('f32', 3), + color: array('u8', 4), // RGBA + lifetime: float32, + active: uint8, + velocityHistory: circular('f32', 5), // Last 5 velocity samples + }); + + const particles = structArray(ParticleDef, 10000); + + // Spawn particle function + const spawnParticle = (x: number, y: number, z: number) => { + const idx = particles.push(); + const particle = particles.at(idx); + + particle.set('id', idx); + particle.set('lifetime', 10.0); + particle.set('active', 1); + + const pos = particle.get('position'); + pos[0] = x; + pos[1] = y; + pos[2] = z; + + const vel = particle.get('velocity'); + vel[0] = (Math.random() - 0.5) * 2; + vel[1] = (Math.random() - 0.5) * 2; + vel[2] = (Math.random() - 0.5) * 2; + + const color = particle.get('color'); + color[0] = 255; + color[1] = 128; + color[2] = 64; + color[3] = 255; + + return idx; + }; + + const idx = spawnParticle(10, 20, 30); + + expect(particles.length).toBe(1); + expect(particles.get(idx, 'id')).toBe(idx); + expect(particles.get(idx, 'lifetime')).toBe(10.0); + expect(particles.get(idx, 'active')).toBe(1); + + const particle = particles.at(idx); + const pos = particle.get('position'); + expect(pos[0]).toBe(10); + expect(pos[1]).toBe(20); + expect(pos[2]).toBe(30); + + const color = particle.get('color'); + expect(color[0]).toBe(255); + expect(color[1]).toBe(128); + expect(color[2]).toBe(64); + expect(color[3]).toBe(255); + }); + + it('should update the particles', () => { + // Define particle struct + const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), + velocity: array('f32', 3), + color: array('u8', 4), + lifetime: float32, + active: uint8, + velocityHistory: circular('f32', 5), + }); + + const particles = structArray(ParticleDef, 10000); + + // Create a particle + const idx = particles.push(); + const particle = particles.at(idx); + particle.set('active', 1); + particle.set('lifetime', 10.0); + + const pos = particle.get('position'); + pos[0] = 0; + pos[1] = 0; + pos[2] = 0; + + const vel = particle.get('velocity'); + vel[0] = 1; + vel[1] = 2; + vel[2] = 3; + + // Update loop function + const updateParticles = (dt: number) => { + for (let i = 0; i < particles.length; i++) { + if (particles.get(i, 'active') === 0) { + continue; + } + + const particle = particles.at(i); + const pos = particle.get('position'); + const vel = particle.get('velocity'); + + // Update position + pos[0] += vel[0] * dt; + pos[1] += vel[1] * dt; + pos[2] += vel[2] * dt; + + // Update lifetime + const lifetime = particle.get('lifetime'); + particle.set('lifetime', lifetime - dt); + + if (lifetime <= 0) { + particle.set('active', 0); + } + + // Track velocity magnitude + const speed = Math.sqrt(vel[0] ** 2 + vel[1] ** 2 + vel[2] ** 2); + const velHistory = particle.get('velocityHistory'); + velHistory.enqueue(speed); + } + }; + + // Update with dt = 1.0 + const initialLifetime = particle.get('lifetime'); + updateParticles(1.0); + + expect(pos[0]).toBe(1); // 0 + 1*1.0 + expect(pos[1]).toBe(2); // 0 + 2*1.0 + expect(pos[2]).toBe(3); // 0 + 3*1.0 + expect(particle.get('lifetime')).toBe(initialLifetime - 1.0); + + const velHistory = particle.get('velocityHistory'); + expect(velHistory.size()).toBe(1); // One speed sample added + + // Update many times to deactivate + for (let i = 0; i < 20; i++) { + updateParticles(1.0); + } + + // Particle should be deactivated + expect(particle.get('active')).toBe(0); + expect(velHistory.size()).toBe(5); // Circular buffer max capacity + }); + + it('should spawn multiple particles', () => { + const ParticleDef = struct({ + id: uint32, + position: array('f32', 3), + velocity: array('f32', 3), + color: array('u8', 4), + lifetime: float32, + active: uint8, + velocityHistory: circular('f32', 5), + }); + + const particles = structArray(ParticleDef, 10000); + + const spawnParticle = (x: number, y: number, z: number) => { + const idx = particles.push(); + const particle = particles.at(idx); + + particle.set('id', idx); + particle.set('lifetime', 10.0); + particle.set('active', 1); + + const pos = particle.get('position'); + pos[0] = x; + pos[1] = y; + pos[2] = z; + + const vel = particle.get('velocity'); + vel[0] = (Math.random() - 0.5) * 2; + vel[1] = (Math.random() - 0.5) * 2; + vel[2] = (Math.random() - 0.5) * 2; + + const color = particle.get('color'); + color[0] = 255; + color[1] = 128; + color[2] = 64; + color[3] = 255; + + return idx; + }; + + // Spawn 100 particles + const indices: number[] = []; + for (let i = 0; i < 100; i++) { + indices.push(spawnParticle(i * 10, i * 20, i * 30)); + } + + expect(particles.length).toBe(100); + expect(indices.length).toBe(100); + + // Verify each particle has correct position + for (let i = 0; i < 100; i++) { + const particle = particles.at(indices[i]); + const pos = particle.get('position'); + expect(pos[0]).toBe(i * 10); + expect(pos[1]).toBe(i * 20); + expect(pos[2]).toBe(i * 30); + } + }); + }); + + describe('Entity Component System (ECS)', () => { + it('should create component definitions', () => { + const TransformDef = struct({ + position: array('f64', 3), + rotation: array('f64', 4), // Quaternion + scale: array('f64', 3), + }); + + const RenderableDef = struct({ + meshId: uint32, + materialId: uint32, + visible: uint8, + }); + + const PhysicsDef = struct({ + velocity: array('f32', 3), + acceleration: array('f32', 3), + mass: float32, + friction: float32, + }); + + expect(TransformDef.layout.stride).toBeGreaterThan(0); + expect(RenderableDef.layout.stride).toBeGreaterThan(0); + expect(PhysicsDef.layout.stride).toBeGreaterThan(0); + }); + + it('should create an entity with components', () => { + const TransformDef = struct({ + position: array('f64', 3), + rotation: array('f64', 4), // Quaternion + scale: array('f64', 3), + }); + + const RenderableDef = struct({ + meshId: uint32, + materialId: uint32, + visible: uint8, + }); + + const PhysicsDef = struct({ + velocity: array('f32', 3), + acceleration: array('f32', 3), + mass: float32, + friction: float32, + }); + + // Component arrays + const transforms = dynamicStructArray(TransformDef, 1000); + const renderables = dynamicStructArray(RenderableDef, 1000); + const physics = dynamicStructArray(PhysicsDef, 1000); + + // Create entity function + const createEntity = () => { + const transformIdx = transforms.push(); + const renderableIdx = renderables.push(); + const physicsIdx = physics.push(); + + // Initialize transform + const transform = transforms.at(transformIdx); + + const pos = transform.get('position'); + pos[0] = 0; + pos[1] = 0; + pos[2] = 0; + + const rot = transform.get('rotation'); + rot[0] = 0; + rot[1] = 0; + rot[2] = 0; + rot[3] = 1; + + const scale = transform.get('scale'); + scale[0] = 1; + scale[1] = 1; + scale[2] = 1; + + return { transformIdx, renderableIdx, physicsIdx }; + }; + + // Act + const entity = createEntity(); + + // Assert + expect(transforms.length).toBe(1); + expect(renderables.length).toBe(1); + expect(physics.length).toBe(1); + + const transform = transforms.at(entity.transformIdx); + const pos = transform.get('position'); + expect(pos[0]).toBe(0); + expect(pos[1]).toBe(0); + expect(pos[2]).toBe(0); + + const rot = transform.get('rotation'); + expect(rot[0]).toBe(0); + expect(rot[1]).toBe(0); + expect(rot[2]).toBe(0); + expect(rot[3]).toBe(1); + + const scale = transform.get('scale'); + expect(scale[0]).toBe(1); + expect(scale[1]).toBe(1); + expect(scale[2]).toBe(1); + }); + + it('should run the physics system', () => { + const TransformDef = struct({ + position: array('f64', 3), + rotation: array('f64', 4), + scale: array('f64', 3), + }); + + const PhysicsDef = struct({ + velocity: array('f32', 3), + acceleration: array('f32', 3), + mass: float32, + friction: float32, + }); + + const transforms = dynamicStructArray(TransformDef, 1000); + const physics = dynamicStructArray(PhysicsDef, 1000); + + // Create entity + const transformIdx = transforms.push(); + const physicsIdx = physics.push(); + + const transform = transforms.at(transformIdx); + const pos = transform.get('position'); + pos[0] = 0; + pos[1] = 0; + pos[2] = 0; + + const phys = physics.at(physicsIdx); + const vel = phys.get('velocity'); + vel[0] = 10; + vel[1] = 20; + vel[2] = 30; + + const acc = phys.get('acceleration'); + acc[0] = 1; + acc[1] = 2; + acc[2] = 3; + + // Physics system function + const physicsSystem = (dt: number) => { + for (let i = 0; i < physics.length; i++) { + const phys = physics.at(i); + const transform = transforms.at(i); + + const vel = phys.get('velocity'); + const acc = phys.get('acceleration'); + const pos = transform.get('position'); + + // Update velocity + vel[0] += acc[0] * dt; + vel[1] += acc[1] * dt; + vel[2] += acc[2] * dt; + + // Update position + pos[0] += vel[0] * dt; + pos[1] += vel[1] * dt; + pos[2] += vel[2] * dt; + } + }; + + // Run physics system with dt = 1.0 + physicsSystem(1.0); + + expect(vel[0]).toBe(11); // 10 + 1*1.0 + expect(vel[1]).toBe(22); // 20 + 2*1.0 + expect(vel[2]).toBe(33); // 30 + 3*1.0 + + // Position updated + expect(pos[0]).toBe(11); // 0 + 11*1.0 + expect(pos[1]).toBe(22); // 0 + 22*1.0 + expect(pos[2]).toBe(33); // 0 + 33*1.0 + + // Run again + physicsSystem(1.0); + + expect(vel[0]).toBe(12); // 11 + 1*1.0 + expect(vel[1]).toBe(24); // 22 + 2*1.0 + expect(vel[2]).toBe(36); // 33 + 3*1.0 + + expect(pos[0]).toBe(23); // 11 + 12*1.0 + expect(pos[1]).toBe(46); // 22 + 24*1.0 + expect(pos[2]).toBe(69); // 33 + 36*1.0 + }); + + it('should handle multiple entities in the ECS', () => { + const TransformDef = struct({ + position: array('f64', 3), + rotation: array('f64', 4), + scale: array('f64', 3), + }); + + const RenderableDef = struct({ + meshId: uint32, + materialId: uint32, + visible: uint8, + }); + + const PhysicsDef = struct({ + velocity: array('f32', 3), + acceleration: array('f32', 3), + mass: float32, + friction: float32, + }); + + const transforms = dynamicStructArray(TransformDef, 1000); + const renderables = dynamicStructArray(RenderableDef, 1000); + const physics = dynamicStructArray(PhysicsDef, 1000); + + const createEntity = () => { + const transformIdx = transforms.push(); + const renderableIdx = renderables.push(); + const physicsIdx = physics.push(); + + const transform = transforms.at(transformIdx); + const pos = transform.get('position'); + pos[0] = 0; + pos[1] = 0; + pos[2] = 0; + + const rot = transform.get('rotation'); + rot[0] = 0; + rot[1] = 0; + rot[2] = 0; + rot[3] = 1; + + const scale = transform.get('scale'); + scale[0] = 1; + scale[1] = 1; + scale[2] = 1; + + return { transformIdx, renderableIdx, physicsIdx }; + }; + + const entities: Array<{ + transformIdx: number; + renderableIdx: number; + physicsIdx: number; + }> = []; + for (let i = 0; i < 100; i++) { + entities.push(createEntity()); + } + + expect(transforms.length).toBe(100); + expect(renderables.length).toBe(100); + expect(physics.length).toBe(100); + + for (let i = 0; i < 100; i++) { + const transform = transforms.at(entities[i].transformIdx); + const rot = transform.get('rotation'); + expect(rot[3]).toBe(1); // Quaternion w component + } + }); + }); +}); diff --git a/packages/ordo/src/sparse-array/index.ts b/packages/ordo/src/sparse-array/index.ts new file mode 100644 index 0000000..9f65ae5 --- /dev/null +++ b/packages/ordo/src/sparse-array/index.ts @@ -0,0 +1,196 @@ +import { type ArrayKeys, arrayTypes, type KeyedArray } from '../types'; +import { assertDefined } from '../utils'; + +const SCALING_FACTOR = 2; + +class _SparseArray { + readonly #construct: (typeof arrayTypes)[Key]; + #buffer: KeyedArray[Key]; + #capacity: number; + readonly #maxCapacity: number; + #current = 0; + + constructor(arrayType: Key, initialSize: number) { + if (!arrayType) { + throw new TypeError('arrayType must be provided'); + } + + if (!arrayTypes[arrayType]) { + throw new TypeError(`Unknown array type "${arrayType}"`); + } + + if (!initialSize || initialSize < 1) { + throw new Error( + `initialSize must be greater than 0, ${initialSize} given`, + ); + } + + this.#construct = arrayTypes[arrayType]; + this.#buffer = new this.#construct(initialSize) as KeyedArray[Key]; + this.#capacity = initialSize; + this.#maxCapacity = Math.floor(2 ** 31 / this.#construct.BYTES_PER_ELEMENT); + } + + private resize(newCapacity: number): void { + if (newCapacity > this.#maxCapacity) { + throw new RangeError( + `Capacity ${newCapacity} exceeds maximum ${this.#maxCapacity}`, + ); + } + + const newBuffer = new this.#construct(newCapacity); + + // biome-ignore lint/suspicious/noExplicitAny: This is A -> A so it's fine + newBuffer.set(this.#buffer.subarray(0, this.#current) as any); + this.#buffer = newBuffer as KeyedArray[Key]; + this.#capacity = newCapacity; + } + + size(): number { + return this.#current; + } + + capacity(): number { + return this.#capacity; + } + + push(element: KeyedArray[Key][number]): number { + if (this.#current >= this.#capacity) { + this.resize(this.#capacity * SCALING_FACTOR); + } + + this.#buffer[this.#current] = element; + this.#current++; + + return this.#current - 1; + } + + at(index: number): KeyedArray[Key][number] { + if (index < 0 || index >= this.#current) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#current - 1}`, + ); + } + + const value = this.#buffer[index]; + + assertDefined(value); + + return value; + } + + set(index: number, value: KeyedArray[Key][number]): void { + if (index < 0 || index >= this.#current) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#current - 1}`, + ); + } + + this.#buffer[index] = value; + } + + remove(index: number): void { + if (index < 0 || index >= this.#current) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#current - 1}`, + ); + } + + this.#buffer[index] = 0 as KeyedArray[Key][number]; + } + + toArray(): KeyedArray[Key][number][] { + // biome-ignore lint/suspicious/noExplicitAny: Yes yes, I know + return Array.from(this.#buffer.subarray(0, this.#current) as any); + } + + /** + * Creates a sparse array from an existing array. + * + * @template K - The TypedArray type key + * @param arrayType - The data type for array elements + * @param arr - Array of numbers to initialize with + * @returns A new sparse array instance + * @throws {Error} If array is empty or invalid + * + * @example + * ```typescript + * const arr = _SparseArray.from('f32', [1, 2, 3, 4, 5]); + * console.log(arr.size()); // 5 + * ``` + */ + static from( + arrayType: K, + arr: KeyedArray[K][number][], + ): _SparseArray { + if (!Array.isArray(arr) || arr.length < 1) { + throw new Error('arr must be an array with length greater than 0'); + } + + const sparseArr = new _SparseArray(arrayType, arr.length); + + for (const e of arr) { + sparseArr.push(e); + } + + return sparseArr; + } + + *[Symbol.iterator](): Iterator { + for (let i = 0; i < this.#current; i++) { + const value = this.#buffer[i]; + assertDefined(value); + + yield value; + } + } +} + +export type SparseArray = _SparseArray; + +/** + * Creates a sparse array that grows but never shrinks. + * + * Unlike DynamicArray, SparseArray maintains stable indices. When you + * remove an element, it's set to 0 instead of shifting remaining elements. + * This is useful for entity IDs, handle systems, and other cases where + * index stability matters. + * + * @template Key - The TypedArray type key + * @param arrayType - The data type of array elements + * @param initialSize - The initial capacity + * @returns A new sparse array instance + * @throws {Error} If arrayType is invalid + * @throws {Error} If initialSize is less than 1 + * + * @remarks + * - Removal sets value to 0 without shifting + * - Array only grows, never shrinks + * - Ideal for systems needing stable handles/IDs + * + * @example + * ```typescript + * const entityIds = sparseArray('u32', 100); + * + * const id1 = entityIds.push(42); // Returns 0 + * const id2 = entityIds.push(43); // Returns 1 + * + * entityIds.remove(id1); // Sets index 0 to 0 + * + * console.log(entityIds.at(id1)); // 0 (removed) + * console.log(entityIds.at(id2)); // 43 (index unchanged) + * ``` + */ +export const sparseArray = ( + arrayType: Key, + initialSize: number, +): SparseArray => { + return new _SparseArray(arrayType, initialSize); +}; + +export const sparseArrayFrom = ( + arrayType: Key, + arr: KeyedArray[Key][number][], +): SparseArray => { + return _SparseArray.from(arrayType, arr); +}; diff --git a/packages/ordo/src/sparse-array/sparse-array.test.ts b/packages/ordo/src/sparse-array/sparse-array.test.ts new file mode 100644 index 0000000..11c8033 --- /dev/null +++ b/packages/ordo/src/sparse-array/sparse-array.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { sparseArray, sparseArrayFrom } from '.'; + +describe('SparseArray', () => { + describe('constructor', () => { + it('should create an array with valid parameters', () => { + const arr = sparseArray('u32', 10); + + expect(arr.capacity()).toBe(10); + expect(arr.size()).toBe(0); + }); + }); + + describe('push', () => { + it('should add elements sequentially', () => { + const arr = sparseArray('u32', 10); + + arr.push(100); + arr.push(200); + arr.push(300); + + expect(arr.size()).toBe(3); + expect(arr.at(0)).toBe(100); + expect(arr.at(1)).toBe(200); + expect(arr.at(2)).toBe(300); + }); + + it('should auto-resize when capacity is reached', () => { + const arr = sparseArray('i32', 2); + + arr.push(1); + arr.push(2); + arr.push(3); + + expect(arr.size()).toBe(3); + expect(arr.capacity()).toBe(4); + }); + + it('should return index from push', () => { + const arr = sparseArray('u32', 10); + const idx0 = arr.push(100); + const idx1 = arr.push(200); + const idx2 = arr.push(300); + + expect(idx0).toBe(0); + expect(idx1).toBe(1); + expect(idx2).toBe(2); + expect(arr.at(idx0)).toBe(100); + expect(arr.at(idx1)).toBe(200); + expect(arr.at(idx2)).toBe(300); + }); + }); + + describe('remove', () => { + it('should set a removed element to 0 without shifting', () => { + const arr = sparseArray('u32', 10); + + arr.push(10); + arr.push(20); + arr.push(30); + arr.remove(1); + + expect(arr.size()).toBe(3); // Size unchanged + expect(arr.at(0)).toBe(10); + expect(arr.at(1)).toBe(0); // Set to 0 + expect(arr.at(2)).toBe(30); + }); + + it('should maintain stable indices', () => { + const arr = sparseArray('i32', 10); + + arr.push(100); + arr.push(200); + arr.push(300); + arr.push(400); + arr.remove(1); + arr.remove(2); + + expect(arr.at(0)).toBe(100); + expect(arr.at(1)).toBe(0); + expect(arr.at(2)).toBe(0); + expect(arr.at(3)).toBe(400); + }); + + it('should throw on out of bounds index', () => { + const arr = sparseArray('u16', 10); + + arr.push(1); + + expect(() => arr.remove(-1)).toThrow('Index out of range'); + expect(() => arr.remove(5)).toThrow('Index out of range'); + }); + }); + + describe('toArray', () => { + it('should include zeros for removed elements', () => { + const arr = sparseArray('u32', 10); + + arr.push(10); + arr.push(20); + arr.push(30); + arr.remove(1); + + const result = arr.toArray(); + + expect(result).toEqual([10, 0, 30]); + }); + }); + + describe('from', () => { + it('should create a sparse array from array input', () => { + const input = [1, 2, 3, 4]; + const arr = sparseArrayFrom('i32', input); + + expect(arr.size()).toBe(4); + expect(arr.toArray()).toEqual(input); + }); + + it('should throw on empty array', () => { + expect(() => sparseArrayFrom('u32', [])).toThrow( + 'arr must be an array with length greater than 0', + ); + }); + }); +}); diff --git a/packages/ordo/src/struct/dynamic-struct-array.test.ts b/packages/ordo/src/struct/dynamic-struct-array.test.ts new file mode 100644 index 0000000..df1ed92 --- /dev/null +++ b/packages/ordo/src/struct/dynamic-struct-array.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { dynamicStructArray, struct } from './factory-functions'; +import { float32, uint32 } from './fields'; + +describe('DynamicStructArray', () => { + describe('constructor', () => { + it('should create an array with initial capacity', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 10); + + expect(arr.capacity).toBe(10); + expect(arr.length).toBe(0); + }); + + it('should throw on invalid capacity', () => { + const def = struct({ id: uint32 }); + + expect(() => dynamicStructArray(def, 0)).toThrow( + 'initialCapacity must be greater than 0', + ); + }); + }); + + describe('get/set after #resize', () => { + it('should correctly get primitive fields after resize', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = dynamicStructArray(def, 2); + + arr.push({ id: 1, value: 1.1 }); + arr.push({ id: 2, value: 2.2 }); + arr.push({ id: 3, value: 3.3 }); + + expect(arr.get(0, 'id')).toBe(1); + expect(arr.get(1, 'id')).toBe(2); + expect(arr.get(2, 'id')).toBe(3); + expect(arr.get(0, 'value')).toBeCloseTo(1.1, 1); + expect(arr.get(1, 'value')).toBeCloseTo(2.2, 1); + expect(arr.get(2, 'value')).toBeCloseTo(3.3, 1); + }); + + it('should correctly set primitive fields after resize', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = dynamicStructArray(def, 2); + + arr.push({ id: 1, value: 1.1 }); + arr.push({ id: 2, value: 2.2 }); + arr.push({ id: 3, value: 3.3 }); // Triggers resize + + arr.set(0, 'value', 99.9); + arr.set(1, 'value', 88.8); + arr.set(2, 'value', 77.7); + + expect(arr.get(0, 'value')).toBeCloseTo(99.9, 1); + expect(arr.get(1, 'value')).toBeCloseTo(88.8, 1); + expect(arr.get(2, 'value')).toBeCloseTo(77.7, 1); + }); + + it('should handle multiple resizes with get/set', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 1); + + // Trigger multiple resizes: 1 -> 2 -> 4 -> 8 + for (let i = 0; i < 8; i++) { + arr.push({ id: i * 10 }); + } + + // Verify all values using get() + for (let i = 0; i < 8; i++) { + expect(arr.get(i, 'id')).toBe(i * 10); + } + + // Modify using set() + for (let i = 0; i < 8; i++) { + arr.set(i, 'id', i * 100); + } + + // Verify modifications + for (let i = 0; i < 8; i++) { + expect(arr.get(i, 'id')).toBe(i * 100); + } + }); + + it('should handle get/set after downsize', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 2); + + arr.push({ id: 1 }); + arr.push({ id: 2 }); + arr.push({ id: 3 }); + arr.push({ id: 4 }); + + arr.pop(); + arr.pop(); + + expect(arr.get(0, 'id')).toBe(1); + expect(arr.get(1, 'id')).toBe(2); + + arr.set(0, 'id', 100); + arr.set(1, 'id', 200); + + expect(arr.get(0, 'id')).toBe(100); + expect(arr.get(1, 'id')).toBe(200); + }); + }); + + describe('push', () => { + it('should auto-resize when the capacity is reached', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 2); + + arr.push({ id: 1 }); + arr.push({ id: 2 }); + arr.push({ id: 3 }); + + expect(arr.length).toBe(3); + expect(arr.capacity).toBe(4); + }); + + it('should preserve the data after a resize', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = dynamicStructArray(def, 2); + + arr.push({ id: 1, value: 1.1 }); + arr.push({ id: 2, value: 2.2 }); + arr.push({ id: 3, value: 3.3 }); + + expect(arr.at(0).get('id')).toBe(1); + expect(arr.at(1).get('id')).toBe(2); + expect(arr.at(2).get('id')).toBe(3); + }); + }); + + describe('pop', () => { + it('should remove and return the last element', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 10); + + arr.push({ id: 10 }); + arr.push({ id: 20 }); + arr.push({ id: 30 }); + + const view = arr.pop(); + + expect(view?.get('id')).toBe(30); + expect(arr.length).toBe(2); + }); + + it('should return undefined when empty', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 10); + const result = arr.pop(); + + expect(result).toBeUndefined(); + }); + + it('should auto-downsize when the length drops', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 2); + + arr.push({ id: 1 }); + arr.push({ id: 2 }); + arr.push({ id: 3 }); + arr.push({ id: 4 }); + + expect(arr.capacity).toBe(4); + + arr.pop(); + arr.pop(); + + expect(arr.length).toBe(2); + expect(arr.capacity).toBe(2); + }); + }); + + describe('remove', () => { + it('should remove an element and shift remaining', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 10); + + arr.push({ id: 10 }); + arr.push({ id: 20 }); + arr.push({ id: 30 }); + arr.remove(1); + + expect(arr.length).toBe(2); + expect(arr.at(0).get('id')).toBe(10); + expect(arr.at(1).get('id')).toBe(30); + }); + }); + + describe('getRawBuffer', () => { + it('should return a sliced buffer with only used portion', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 10); + + arr.push({ id: 1 }); + arr.push({ id: 2 }); + + const buffer = arr.getRawBuffer(); + + expect(buffer.byteLength).toBe(2 * def.layout.stride); + }); + }); + + describe('iterator', () => { + it('should iterate over all elements', () => { + const def = struct({ id: uint32 }); + const arr = dynamicStructArray(def, 10); + + arr.push({ id: 10 }); + arr.push({ id: 20 }); + arr.push({ id: 30 }); + + const ids: number[] = []; + + for (const view of arr) { + ids.push(view.get('id')); + } + + expect(ids).toEqual([10, 20, 30]); + }); + }); +}); diff --git a/packages/ordo/src/struct/dynamic-struct-array.ts b/packages/ordo/src/struct/dynamic-struct-array.ts new file mode 100644 index 0000000..e9284e6 --- /dev/null +++ b/packages/ordo/src/struct/dynamic-struct-array.ts @@ -0,0 +1,187 @@ +import { + type ExtendedFieldType, + type FieldType, + isExtendedType, +} from './fields'; +import { StructView } from './struct-view'; +import type { Struct } from './struct'; + +const SCALING_FACTOR = 2; + +export class DynamicStructArray> { + readonly #def: Struct; + #buffer: ArrayBuffer; + #capacity: number; + #view: DataView; + #length = 0; + + constructor(def: Struct, initialCapacity: number) { + if (initialCapacity < 1) { + throw new Error('initialCapacity must be greater than 0'); + } + + this.#def = def; + this.#capacity = initialCapacity; + this.#buffer = new ArrayBuffer(initialCapacity * def.layout.stride); + this.#view = new DataView(this.#buffer); + } + + get length(): number { + return this.#length; + } + + get capacity(): number { + return this.#capacity; + } + + private resize(newCapacity: number): void { + const newBuffer = new ArrayBuffer(newCapacity * this.#def.layout.stride); + const oldView = new Uint8Array( + this.#buffer, + 0, + this.#length * this.#def.layout.stride, + ); + const newView = new Uint8Array(newBuffer); + + newView.set(oldView); + + this.#buffer = newBuffer; + this.#capacity = newCapacity; + this.#view = new DataView(this.#buffer); + } + + push(values?: Partial>): number { + if (this.#length >= this.#capacity) { + this.resize(this.#capacity * SCALING_FACTOR); + } + + const index = this.#length++; + + if (values) { + this.at(index).init(values); + } + + return index; + } + + pop(): StructView | undefined { + if (this.#length === 0) { + return undefined; + } + + const view = this.at(this.#length - 1); + this.#length--; + + const minCapacity = Math.max( + 1, + Math.floor(this.#capacity / SCALING_FACTOR), + ); + if (this.#length <= minCapacity) { + this.resize(minCapacity); + } + + return view; + } + + at(index: number): StructView { + if (index < 0 || index >= this.#length) { + throw new RangeError( + `Index out of range: ${index}, length is ${this.#length}`, + ); + } + + const offset = index * this.#def.layout.stride; + return new StructView(this.#def, this.#buffer, offset); + } + + remove(index: number): void { + if (index < 0 || index >= this.#length) { + throw new RangeError( + `Index out of range: ${index}, length is ${this.#length}`, + ); + } + + const view = new Uint8Array(this.#buffer); + const stride = this.#def.layout.stride; + const srcOffset = (index + 1) * stride; + const dstOffset = index * stride; + const bytesToMove = (this.#length - index - 1) * stride; + + view.copyWithin(dstOffset, srcOffset, srcOffset + bytesToMove); + + this.#length--; + + const minCapacity = Math.max( + 1, + Math.floor(this.#capacity / SCALING_FACTOR), + ); + + if (this.#length <= minCapacity) { + this.resize(minCapacity); + } + } + + get(index: number, fieldName: keyof T): number { + if (index < 0 || index >= this.#length) { + throw new RangeError( + `Index out of range: ${index}, length is ${this.#length}`, + ); + } + + const field = this.#def.getField(fieldName as string); + const type = field.type; + + if (isExtendedType(type)) { + throw new Error( + `Cannot use get() on complex field '${String(fieldName)}'. ` + + `Use at(${index}).get('${String(fieldName)}') instead.`, + ); + } + + const offset = index * this.#def.layout.stride + field.offset; + + return (type as FieldType).get(this.#view, offset); + } + + set(index: number, fieldName: keyof T, value: number): void { + if (index < 0 || index >= this.#length) { + throw new RangeError( + `Index out of range: ${index}, length is ${this.#length}`, + ); + } + + const field = this.#def.getField(fieldName as string); + const type = field.type; + + if (isExtendedType(type)) { + throw new Error( + `Cannot use set() on complex field '${String(fieldName)}'. ` + + `Use at(${index}).get('${String(fieldName)}') to access and modify.`, + ); + } + + const offset = index * this.#def.layout.stride + field.offset; + + (type as FieldType).set(this.#view, offset, value); + } + + clear(): void { + this.#length = 0; + } + + forEach(fn: (view: StructView, index: number) => void): void { + for (let i = 0; i < this.#length; i++) { + fn(this.at(i), i); + } + } + + *[Symbol.iterator](): Iterator> { + for (let i = 0; i < this.#length; i++) { + yield this.at(i); + } + } + + getRawBuffer(): ArrayBuffer { + return this.#buffer.slice(0, this.#length * this.#def.layout.stride); + } +} diff --git a/packages/ordo/src/struct/factory-functions.ts b/packages/ordo/src/struct/factory-functions.ts new file mode 100644 index 0000000..a051d3c --- /dev/null +++ b/packages/ordo/src/struct/factory-functions.ts @@ -0,0 +1,222 @@ +import { DynamicStructArray } from './dynamic-struct-array'; +import { Struct } from './struct'; +import type { ExtendedFieldType } from './fields'; +import type { StructArray } from './struct-array'; +import type { StructView } from './struct-view'; + +/** + * Defines a struct with explicit memory layout and field alignment. + * + * Creates a struct definition with C-like memory layout. Fields are aligned + * according to their type requirements, with automatic padding insertion. + * The struct definition can then be used to create single views or arrays. + * + * @template T - Record mapping field names to field types + * @param schema - Object mapping field names to field type definitions + * @returns A struct definition that can create views and arrays + * + * @remarks + * **Field Ordering Matters:** + * - Order fields largest-to-smallest to minimize padding + * - 8-byte types (float64, f64 arrays) should come first + * - 4-byte types (float32, uint32, f32 arrays) next + * - 2-byte types (uint16) next + * - 1-byte types (uint8, utf8) last + * + * Use `.inspect()` to visualize memory layout and padding. + * + * @example + * ```typescript + * // Define a particle struct + * const ParticleDef = struct({ + * id: uint32, + * position: array('f32', 3), + * velocity: array('f32', 3), + * lifetime: float32, + * active: uint8 + * }); + * + * // Inspect memory layout + * ParticleDef.inspect(); + * + * // Create single view + * const particle = structView(ParticleDef); + * particle.set('id', 42); + * + * // Create array + * const particles = structArray(ParticleDef, 1000); + * ``` + * + * @see {@link structView} for single struct views + * @see {@link structArray} for fixed-capacity arrays + * @see {@link dynamicStructArray} for auto-growing arrays + */ +export const struct = >( + schema: T, +): Struct => { + return new Struct(schema); +}; + +/** + * Creates a view into a single struct's memory. + * + * StructView provides a temporary accessor into a struct's ArrayBuffer. + * It's not a standalone object - changes to the view are written directly + * to the underlying buffer. + * + * @template T - The struct schema type + * @param def - The struct definition + * @returns A view into a newly allocated struct buffer + * + * @remarks + * Views are lightweight - creating multiple views into the same buffer + * at different offsets is cheap. They don't copy data, just provide + * typed access to memory. + * + * @example + * ```typescript + * const EntityDef = struct({ + * id: uint32, + * position: array('f64', 3), + * name: utf8(32) + * }); + * + * const entity = structView(EntityDef); + * + * // Set primitive fields + * entity.set('id', 1); + * + * // Access array fields + * const pos = entity.get('position'); + * pos[0] = 10.0; + * pos[1] = 20.0; + * pos[2] = 30.0; + * + * // Access UTF-8 fields + * const nameField = entity.get('name'); + * nameField.set('Player'); + * console.log(nameField.get()); // "Player" + * ``` + */ +export const structView = >( + def: Struct, +): StructView => { + return def.create(); +}; + +/** + * Creates a fixed-capacity array of structs. + * + * StructArray allocates contiguous memory for a fixed number of structs. + * It provides cache-friendly iteration and direct field access without + * creating intermediate objects. + * + * @template T - The struct schema type + * @param def - The struct definition + * @param capacity - The maximum number of structs the array can hold + * @returns A new fixed-capacity struct array + * @throws {Error} When push() exceeds capacity + * + * @remarks + * **Performance:** + * - Use `get(index, field)` and `set(index, field, value)` for primitives + * - Use `at(index)` when accessing multiple fields + * - Sequential iteration is cache-friendly + * + * The array cannot grow beyond its capacity. Use DynamicStructArray + * if you need auto-resizing. + * + * @example + * ```typescript + * const ParticleDef = struct({ + * id: uint32, + * position: array('f32', 3), + * lifetime: float32 + * }); + * + * const particles = structArray(ParticleDef, 10000); + * + * // Add particle + * const idx = particles.push(); + * + * // Fast primitive access + * particles.set(idx, 'id', 42); + * particles.set(idx, 'lifetime', 10.0); + * + * // Access complex fields via view + * const pos = particles.at(idx).get('position'); + * pos[0] = 100.5; + * + * // Cache-friendly iteration + * for (let i = 0; i < particles.length; i++) { + * const lifetime = particles.get(i, 'lifetime'); + * particles.set(i, 'lifetime', lifetime - 0.016); + * } + * ``` + */ +export const structArray = >( + def: Struct, + capacity: number, +): StructArray => { + return def.createArray(capacity); +}; + +/** + * Creates an auto-growing array of structs. + * + * Similar to StructArray but automatically resizes (2x growth, 0.5x shrink) + * when needed. Useful when the final size is unknown. + * + * @template T - The struct schema type + * @param def - The struct definition + * @param initialCapacity - The initial capacity (will grow as needed) + * @returns A new dynamic struct array + * @throws {Error} If initialCapacity is less than 1 + * + * @remarks + * **Growth Strategy:** + * - Doubles capacity when full + * - Halves capacity when size ≤ capacity/2 + * - Preserves data during resize via memory copy + * + * **Trade-offs vs StructArray:** + * - No capacity limit + * - Includes `pop()`, `remove()`, iterator + * - Occasional resize overhead + * - Slightly more complex + * + * @example + * ```typescript + * const EntityDef = struct({ + * id: uint32, + * health: float32 + * }); + * + * const entities = dynamicStructArray(EntityDef, 100); + * + * // Auto-grows as needed + * for (let i = 0; i < 1000; i++) { + * entities.push({ id: i, health: 100.0 }); + * } + * + * console.log(entities.length); // 1000 + * console.log(entities.capacity); // 1024 (auto-grew) + * + * // Remove element (shifts remaining) + * entities.remove(5); + * + * // Pop last element + * const last = entities.pop(); + * + * // Iterate + * for (const entity of entities) { + * entity.set('health', entity.get('health') - 1); + * } + * ``` + */ +export const dynamicStructArray = >( + def: Struct, + initialCapacity: number, +): DynamicStructArray => { + return new DynamicStructArray(def, initialCapacity); +}; diff --git a/packages/ordo/src/struct/fields.ts b/packages/ordo/src/struct/fields.ts new file mode 100644 index 0000000..3ed6ce2 --- /dev/null +++ b/packages/ordo/src/struct/fields.ts @@ -0,0 +1,343 @@ +import { type ArrayKeys, arrayTypes, type KeyedArray } from '../types'; +import { assertDefined } from '../utils'; + +export type FieldType = { + readonly size: number; + readonly alignment: number; + readonly get: (view: DataView, offset: number) => number; + readonly set: (view: DataView, offset: number, value: number) => void; +}; + +// ------------------------------------------------- +// Primitive Types + +export const int8: FieldType = { + size: 1, + alignment: 1, + get: (view, offset) => view.getInt8(offset), + set: (view, offset, value) => view.setInt8(offset, value), +}; + +export const uint8: FieldType = { + size: 1, + alignment: 1, + get: (view, offset) => view.getUint8(offset), + set: (view, offset, value) => view.setUint8(offset, value), +}; + +export const int16: FieldType = { + size: 2, + alignment: 2, + get: (view, offset) => view.getInt16(offset, true), + set: (view, offset, value) => view.setInt16(offset, value, true), +}; + +export const uint16: FieldType = { + size: 2, + alignment: 2, + get: (view, offset) => view.getUint16(offset, true), + set: (view, offset, value) => view.setUint16(offset, value, true), +}; + +export const int32: FieldType = { + size: 4, + alignment: 4, + get: (view, offset) => view.getInt32(offset, true), + set: (view, offset, value) => view.setInt32(offset, value, true), +}; + +export const uint32: FieldType = { + size: 4, + alignment: 4, + get: (view, offset) => view.getUint32(offset, true), + set: (view, offset, value) => view.setUint32(offset, value, true), +}; + +export const float32: FieldType = { + size: 4, + alignment: 4, + get: (view, offset) => view.getFloat32(offset, true), + set: (view, offset, value) => view.setFloat32(offset, value, true), +}; + +export const float64: FieldType = { + size: 8, + alignment: 8, + get: (view, offset) => view.getFloat64(offset, true), + set: (view, offset, value) => view.setFloat64(offset, value, true), +}; + +// ------------------------------------------------- +// Field Accessor Classes + +export class Utf8StructField { + readonly #buffer: ArrayBuffer; + readonly #offset: number; + readonly #byteLength: number; + readonly #encoder = new TextEncoder(); + readonly #decoder = new TextDecoder(); + + constructor(buffer: ArrayBuffer, offset: number, byteLength: number) { + this.#buffer = buffer; + this.#offset = offset; + this.#byteLength = byteLength; + } + + get(): string { + const view = new Uint8Array(this.#buffer, this.#offset, this.#byteLength); + // biome-ignore lint/suspicious/noControlCharactersInRegex: This is intentional + return this.#decoder.decode(view).replace(/\u0000+$/, ''); + } + + set(value: string): void { + try { + const encoded = this.#encoder.encode(value); + const view = new Uint8Array(this.#buffer, this.#offset, this.#byteLength); + + view.fill(0); + + const bytesToCopy = Math.min(encoded.length, this.#byteLength); + view.set(encoded.subarray(0, bytesToCopy)); + } catch (err) { + throw new Error(`Failed to encode UTF-8 string: ${err}`); + } + } + + getRaw(): Uint8Array { + return new Uint8Array(this.#buffer, this.#offset, this.#byteLength); + } +} + +export class CircularBufferStructField { + readonly #capacity: number; + readonly #view: DataView; + readonly #data: KeyedArray[Key]; + + constructor( + buffer: ArrayBuffer, + offset: number, + arrayType: Key, + capacity: number, + dataOffset: number, + ) { + this.#capacity = capacity; + this.#view = new DataView(buffer, offset, 12); + + const TypedArrayConstructor = arrayTypes[arrayType]; + + this.#data = new TypedArrayConstructor( + buffer, + offset + dataOffset, + capacity, + ) as KeyedArray[Key]; + } + + private get head(): number { + return this.#view.getUint32(0, true); + } + + private set head(value: number) { + this.#view.setUint32(0, value, true); + } + + private get tail(): number { + return this.#view.getUint32(4, true); + } + + private set tail(value: number) { + this.#view.setUint32(4, value, true); + } + + private get current(): number { + return this.#view.getUint32(8, true); + } + + private set current(value: number) { + this.#view.setUint32(8, value, true); + } + + enqueue(value: KeyedArray[Key][number]): void { + const wasFull = this.current >= this.#capacity; + + this.#data[this.tail] = value; + this.tail = (this.tail + 1) % this.#capacity; + + if (wasFull) { + this.head = (this.head + 1) % this.#capacity; + } else { + this.current = this.current + 1; + } + } + + dequeue(): KeyedArray[Key][number] { + if (this.current === 0) { + throw new RangeError('Cannot dequeue from empty circular buffer'); + } + + const value = this.#data[this.head]; + + assertDefined(value); + this.head = (this.head + 1) % this.#capacity; + this.current = this.current - 1; + + return value; + } + + peek(): KeyedArray[Key][number] { + if (this.current === 0) { + throw new RangeError('Cannot peek into empty circular buffer'); + } + + const value = this.#data[this.head]; + + assertDefined(value); + + return value; + } + + size(): number { + return this.current; + } + + capacity(): number { + return this.#capacity; + } + + clear(): void { + this.head = 0; + this.tail = 0; + this.current = 0; + } + + toArray(): KeyedArray[Key][number][] { + const arr: KeyedArray[Key][number][] = new Array(this.current); + + for (let i = 0; i < this.current; i++) { + const value = this.#data[(this.head + i) % this.#capacity]; + + assertDefined(value); + arr[i] = value; + } + + return arr; + } + + getRaw(): KeyedArray[Key] { + return this.#data; + } +} + +// ------------------------------------------------- +// Extended Field Types + +export type ArrayFieldType = { + readonly kind: 'array'; + readonly arrayType: Key; + readonly length: number; + readonly size: number; + readonly alignment: number; + readonly elementSize: number; + readonly get: (buffer: ArrayBuffer, offset: number) => KeyedArray[Key]; +}; + +export type Utf8FieldType = { + readonly kind: 'utf8'; + readonly byteLength: number; + readonly size: number; + readonly alignment: number; + readonly get: (buffer: ArrayBuffer, offset: number) => Utf8StructField; +}; + +export type CircularBufferFieldType = { + readonly kind: 'circular'; + readonly arrayType: Key; + readonly capacity: number; + readonly size: number; + readonly alignment: number; + readonly get: ( + buffer: ArrayBuffer, + offset: number, + ) => CircularBufferStructField; +}; + +export type FieldTypeExtensions = + | ArrayFieldType + | Utf8FieldType + | CircularBufferFieldType; + +export type ExtendedFieldType = FieldType | FieldTypeExtensions; + +export const isExtendedType = ( + type: ExtendedFieldType, +): type is FieldTypeExtensions => Object.hasOwn(type, 'kind'); + +// ------------------------------------------------- +// Field Type Creators + +export const array = ( + arrayType: Key, + length: number, +): ArrayFieldType => { + const TypedArrayConstructor = arrayTypes[arrayType]; + const elementSize = TypedArrayConstructor.BYTES_PER_ELEMENT; + + return { + kind: 'array', + arrayType, + length, + size: elementSize * length, + alignment: elementSize, + elementSize, + get: (buffer: ArrayBuffer, offset: number) => { + return new TypedArrayConstructor( + buffer, + offset, + length, + ) as KeyedArray[Key]; + }, + }; +}; + +export const utf8 = (byteLength: number): Utf8FieldType => { + return { + kind: 'utf8', + byteLength, + size: byteLength, + alignment: 1, + get: (buffer: ArrayBuffer, offset: number) => { + return new Utf8StructField(buffer, offset, byteLength); + }, + }; +}; + +export const circular = ( + arrayType: Key, + capacity: number, +): CircularBufferFieldType => { + const TypedArrayConstructor = arrayTypes[arrayType]; + const elementSize = TypedArrayConstructor.BYTES_PER_ELEMENT; + + const metadataSize = 12; + const dataSize = elementSize * capacity; + const dataAlignment = elementSize; + + const alignedMetadataSize = + Math.ceil(metadataSize / dataAlignment) * dataAlignment; + + return { + kind: 'circular', + arrayType, + capacity, + size: alignedMetadataSize + dataSize, + alignment: Math.max(4, dataAlignment), + get: (buffer: ArrayBuffer, offset: number) => { + return new CircularBufferStructField( + buffer, + offset, + arrayType, + capacity, + alignedMetadataSize, + ); + }, + }; +}; diff --git a/packages/ordo/src/struct/struct-array.test.ts b/packages/ordo/src/struct/struct-array.test.ts new file mode 100644 index 0000000..e6bd3a9 --- /dev/null +++ b/packages/ordo/src/struct/struct-array.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { struct, structArray } from './factory-functions'; +import { array, float32, uint32 } from './fields'; + +describe('StructArray', () => { + describe('constructor', () => { + it('should create an array with capacity', () => { + const def = struct({ id: uint32 }); + const arr = structArray(def, 100); + + expect(arr.capacity).toBe(100); + expect(arr.length).toBe(0); + }); + }); + + describe('push', () => { + it('should add an empty struct to the array', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = structArray(def, 10); + const idx = arr.push(); + + arr.at(idx).set('id', 42); + + expect(arr.length).toBe(1); + expect(arr.at(0).get('id')).toBe(42); + }); + + it('should add an initialized struct from values object', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = structArray(def, 10); + const idx = arr.push({ id: 99, value: 3.14 }); + + expect(arr.at(idx).get('id')).toBe(99); + expect(arr.at(idx).get('value')).toBeCloseTo(3.14, 2); + }); + + it('should throw when capacity is exceeded', () => { + const def = struct({ id: uint32 }); + const arr = structArray(def, 2); + + arr.push(); + arr.push(); + + expect(() => arr.push()).toThrow('StructArray capacity exceeded'); + }); + }); + + describe('at', () => { + it('should return the view at valid index', () => { + const def = struct({ id: uint32 }); + const arr = structArray(def, 10); + + arr.push({ id: 100 }); + arr.push({ id: 200 }); + + const view = arr.at(1); + + expect(view.get('id')).toBe(200); + }); + + it('should throw on out of bounds index', () => { + const def = struct({ id: uint32 }); + const arr = structArray(def, 10); + + arr.push(); + + expect(() => arr.at(-1)).toThrow('Index out of bounds'); + expect(() => arr.at(5)).toThrow('Index out of bounds'); + }); + }); + + describe('get/set primitives', () => { + it('should get a primitive field directly', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = structArray(def, 10); + + arr.push({ id: 42, value: 3.14 }); + + const id = arr.get(0, 'id'); + + expect(id).toBe(42); + }); + + it('should set a primitive field directly', () => { + const def = struct({ id: uint32, value: float32 }); + const arr = structArray(def, 10); + + arr.push(); + arr.set(0, 'id', 999); + + expect(arr.get(0, 'id')).toBe(999); + }); + + it('should throw when getting a complex field', () => { + const def = struct({ position: array('f32', 3) }); + const arr = structArray(def, 10); + + arr.push(); + + expect(() => arr.get(0, 'position')).toThrow( + "Cannot use array.get() on complex field 'position'", + ); + }); + + it('should throw when setting a complex field', () => { + const def = struct({ position: array('f32', 3) }); + const arr = structArray(def, 10); + + arr.push(); + + // biome-ignore lint/suspicious/noExplicitAny: For testing + expect(() => arr.set(0, 'position', 123 as any)).toThrow( + "Cannot use array.set() on complex field 'position'", + ); + }); + }); + + describe('forEach', () => { + it('should iterate over all elements', () => { + const def = struct({ id: uint32 }); + const arr = structArray(def, 10); + + arr.push({ id: 10 }); + arr.push({ id: 20 }); + arr.push({ id: 30 }); + + const ids: number[] = []; + + arr.forEach((view) => { + ids.push(view.get('id')); + }); + + expect(ids).toEqual([10, 20, 30]); + }); + }); + + describe('clear', () => { + it('should reset length to 0', () => { + const def = struct({ id: uint32 }); + const arr = structArray(def, 10); + + arr.push(); + arr.push(); + arr.push(); + arr.clear(); + + expect(arr.length).toBe(0); + }); + }); +}); diff --git a/packages/ordo/src/struct/struct-array.ts b/packages/ordo/src/struct/struct-array.ts new file mode 100644 index 0000000..5063a29 --- /dev/null +++ b/packages/ordo/src/struct/struct-array.ts @@ -0,0 +1,104 @@ +import { + type ExtendedFieldType, + type FieldType, + isExtendedType, +} from './fields'; +import { StructView } from './struct-view'; +import type { Struct } from './struct'; + +export class StructArray> { + readonly #buffer: ArrayBuffer; + readonly #capacity: number; + readonly #view: DataView; + readonly #def: Struct; + #length = 0; + + constructor(def: Struct, capacity: number) { + this.#capacity = capacity; + this.#buffer = new ArrayBuffer(capacity * def.layout.stride); + this.#view = new DataView(this.#buffer); + this.#def = def; + } + + get length(): number { + return this.#length; + } + + get capacity(): number { + return this.#capacity; + } + + at(index: number): StructView { + if (index < 0 || index >= this.#length) { + throw new RangeError(`Index out of bounds: ${index}`); + } + + const offset = index * this.#def.layout.stride; + + return new StructView(this.#def, this.#buffer, offset); + } + + push(values?: Partial>): number { + if (this.#length >= this.#capacity) { + throw new RangeError('StructArray capacity exceeded'); + } + + const index = this.#length++; + + if (values) { + this.at(index).init(values); + } + + return index; + } + + get(index: number, fieldName: keyof T): number { + if (index < 0 || index >= this.#length) { + throw new RangeError(`Index out of bounds: ${index}`); + } + + const field = this.#def.getField(fieldName as string); + const type = field.type; + + if (isExtendedType(type)) { + throw new Error( + `Cannot use array.get() on complex field '${String(fieldName)}'. ` + + `Use array.at(${index}).get('${String(fieldName)}') instead.`, + ); + } + + const offset = index * this.#def.layout.stride + field.offset; + + return (type as FieldType).get(this.#view, offset); + } + + set(index: number, fieldName: keyof T, value: number): void { + if (index < 0 || index >= this.#length) { + throw new RangeError(`Index out of bounds: ${index}`); + } + + const field = this.#def.getField(fieldName as string); + const type = field.type; + + if (isExtendedType(type)) { + throw new Error( + `Cannot use array.set() on complex field '${String(fieldName)}'. ` + + `Use array.at(${index}).get('${String(fieldName)}') to access and modify.`, + ); + } + + const offset = index * this.#def.layout.stride + field.offset; + + (type as FieldType).set(this.#view, offset, value); + } + + forEach(fn: (view: StructView, index: number) => void): void { + for (let i = 0; i < this.#length; i++) { + fn(this.at(i), i); + } + } + + clear(): void { + this.#length = 0; + } +} diff --git a/packages/ordo/src/struct/struct-view.test.ts b/packages/ordo/src/struct/struct-view.test.ts new file mode 100644 index 0000000..33c0b1d --- /dev/null +++ b/packages/ordo/src/struct/struct-view.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; +import { struct, structView } from './factory-functions'; +import { + array, + circular, + float32, + float64, + int8, + int16, + int32, + uint8, + uint16, + uint32, + utf8, +} from './fields'; + +describe('StructView', () => { + describe('get/set primitives', () => { + it('should get and set primitive fields', () => { + const def = struct({ + id: uint32, + value: float32, + }); + + const view = structView(def); + + view.set('id', 42); + view.set('value', 3.14); + + expect(view.get('id')).toBe(42); + expect(view.get('value')).toBeCloseTo(3.14, 2); + }); + + it('should handle all primitive types', () => { + const def = struct({ + i8: int8, + u8: uint8, + i16: int16, + u16: uint16, + i32: int32, + u32: uint32, + f32: float32, + f64: float64, + }); + + const view = structView(def); + + view.set('i8', -100); + view.set('u8', 200); + view.set('i16', -30000); + view.set('u16', 60000); + view.set('i32', -2000000000); + view.set('u32', 4000000000); + view.set('f32', 3.14); + view.set('f64', 4.718281828); + + expect(view.get('i8')).toBe(-100); + expect(view.get('u8')).toBe(200); + expect(view.get('i16')).toBe(-30000); + expect(view.get('u16')).toBe(60000); + expect(view.get('i32')).toBe(-2000000000); + expect(view.get('u32')).toBe(4000000000); + expect(view.get('f32')).toBeCloseTo(3.14, 2); + expect(view.get('f64')).toBeCloseTo(4.718281828, 9); + }); + }); + + describe('get arrays', () => { + it('should return a TypedArray view for array fields', () => { + const def = struct({ + position: array('f32', 3), + }); + + const view = structView(def); + const pos = view.get('position'); + + pos[0] = 10.5; + pos[1] = 20.3; + pos[2] = 30.1; + + expect(pos).toBeInstanceOf(Float32Array); + expect(pos.length).toBe(3); + expect(view.get('position')[0]).toBe(10.5); + }); + + it('should support multiple array types', () => { + const def = struct({ + bytes: array('u8', 4), + floats: array('f32', 3), + doubles: array('f64', 2), + }); + + const view = structView(def); + const bytes = view.get('bytes'); + const floats = view.get('floats'); + const doubles = view.get('doubles'); + + bytes[0] = 255; + floats[0] = 1.23; + doubles[0] = 5.432; + + expect(bytes).toBeInstanceOf(Uint8Array); + expect(floats).toBeInstanceOf(Float32Array); + expect(doubles).toBeInstanceOf(Float64Array); + expect(view.get('bytes')[0]).toBe(255); + expect(view.get('floats')[0]).toBeCloseTo(1.23, 2); + expect(view.get('doubles')[0]).toBeCloseTo(5.432, 3); + }); + }); + + describe('get/set utf8', () => { + it('should get and set utf8 fields', () => { + const def = struct({ + name: utf8(32), + }); + + const view = structView(def); + const nameField = view.get('name'); + + nameField.set('TestName'); + + expect(nameField.get()).toBe('TestName'); + }); + + it('should truncate strings exceeding the byte length', () => { + const def = struct({ + tag: utf8(4), + }); + + const view = structView(def); + const field = view.get('tag'); + + field.set('VeryLongString'); + + expect(field.get().length).toBeLessThanOrEqual(4); + }); + + it('should pad with nulls', () => { + const def = struct({ + name: utf8(16), + }); + + const view = structView(def); + const field = view.get('name'); + + field.set('Hi'); + + expect(field.get()).toBe('Hi'); + expect(field.getRaw().length).toBe(16); + }); + + it('should handle empty strings', () => { + const def = struct({ + name: utf8(16), + }); + + const view = structView(def); + const field = view.get('name'); + + field.set(''); + + expect(field.get()).toBe(''); + }); + }); + + describe('get circular buffer', () => { + it('should get circular buffer field', () => { + const def = struct({ + history: circular('f32', 5), + }); + + const view = structView(def); + const hist = view.get('history'); + + hist.enqueue(1.0); + hist.enqueue(2.0); + hist.enqueue(3.0); + + expect(hist.size()).toBe(3); + expect(hist.toArray()).toEqual([1.0, 2.0, 3.0]); + }); + + it('should handle buffer wraparound', () => { + const def = struct({ + readings: circular('u32', 3), + }); + + const view = structView(def); + const readings = view.get('readings'); + + readings.enqueue(1); + readings.enqueue(2); + readings.enqueue(3); + readings.enqueue(4); // Overwrites 1 + + expect(readings.toArray()).toEqual([2, 3, 4]); + }); + }); + + describe('set on complex fields', () => { + it('should throw when trying to set an array field', () => { + const def = struct({ + position: array('f32', 3), + }); + + const view = structView(def); + + // biome-ignore lint/suspicious/noExplicitAny: For testing + expect(() => view.set('position', 123 as any)).toThrow( + "Cannot use set() on complex field 'position'", + ); + }); + + it('should throw when trying to set a utf8 field', () => { + const def = struct({ + name: utf8(16), + }); + + const view = structView(def); + + // biome-ignore lint/suspicious/noExplicitAny: For testing + expect(() => view.set('name', 123 as any)).toThrow( + "Cannot use set() on complex field 'name'", + ); + }); + + it('should throw when trying to set a circular buffer field', () => { + const def = struct({ + history: circular('f32', 5), + }); + + const view = structView(def); + + // biome-ignore lint/suspicious/noExplicitAny: For testing + expect(() => view.set('history', 123 as any)).toThrow( + "Cannot use set() on complex field 'history'", + ); + }); + }); + + describe('init', () => { + it('should initialize multiple primitive fields', () => { + const def = struct({ + id: uint32, + health: float32, + mana: float32, + }); + + const view = structView(def); + + view.init({ + id: 42, + health: 100.0, + mana: 50.0, + }); + + expect(view.get('id')).toBe(42); + expect(view.get('health')).toBe(100.0); + expect(view.get('mana')).toBe(50.0); + }); + + it('should skip undefined values', () => { + const def = struct({ + a: uint32, + b: uint32, + }); + + const view = structView(def); + + view.set('a', 10); + view.set('b', 20); + view.init({ a: 999 }); + + expect(view.get('a')).toBe(999); + expect(view.get('b')).toBe(20); // Unchanged + }); + }); + + describe('copyFrom', () => { + it('should copy all fields from another view', () => { + const def = struct({ + id: uint32, + value: float32, + position: array('f32', 3), + }); + + const src = structView(def); + const dst = structView(def); + + src.set('id', 42); + src.set('value', 3.14); + src.get('position')[0] = 10.5; + dst.copyFrom(src); + + expect(dst.get('id')).toBe(42); + expect(dst.get('value')).toBeCloseTo(3.14, 2); + expect(dst.get('position')[0]).toBe(10.5); + }); + }); +}); diff --git a/packages/ordo/src/struct/struct-view.ts b/packages/ordo/src/struct/struct-view.ts new file mode 100644 index 0000000..89ee63a --- /dev/null +++ b/packages/ordo/src/struct/struct-view.ts @@ -0,0 +1,122 @@ +import { + type ArrayFieldType, + type CircularBufferFieldType, + type CircularBufferStructField, + type ExtendedFieldType, + type FieldType, + isExtendedType, + type Utf8FieldType, + type Utf8StructField, +} from './fields'; +import type { ArrayKeys, KeyedArray } from '../types'; +import type { Struct } from './struct'; + +type AnyTypedArray = KeyedArray[ArrayKeys]; +type ExtractArrayKey = T extends ArrayFieldType ? K : never; +type ExtractCircularKey = + T extends CircularBufferFieldType ? K : never; + +export class StructView> { + readonly #def: Struct; + readonly #buffer: ArrayBuffer; + readonly #byteOffset: number; + + constructor(def: Struct, buffer: ArrayBuffer, byteOffset: number) { + if (byteOffset % def.layout.alignment !== 0) { + throw new Error( + `byteOffset ${byteOffset} is not aligned to ${def.layout.alignment} bytes`, + ); + } + + if (byteOffset + def.layout.stride > buffer.byteLength) { + throw new RangeError( + `Struct at offset ${byteOffset} exceeds buffer size ${buffer.byteLength}`, + ); + } + + this.#def = def; + this.#buffer = buffer; + this.#byteOffset = byteOffset; + } + + private get view(): DataView { + return new DataView( + this.#buffer, + this.#byteOffset, + this.#def.layout.stride, + ); + } + + get(fieldName: T[K] extends FieldType ? K : never): number; + get( + // biome-ignore lint/suspicious/noExplicitAny: It's fine + fieldName: T[K] extends ArrayFieldType ? K : never, + ): KeyedArray[ExtractArrayKey]; + get( + fieldName: T[K] extends Utf8FieldType ? K : never, + ): Utf8StructField; + get( + // biome-ignore lint/suspicious/noExplicitAny: It's fine + fieldName: T[K] extends CircularBufferFieldType ? K : never, + ): CircularBufferStructField>; + get( + fieldName: keyof T, + ): + | number + | AnyTypedArray + | Utf8StructField + | CircularBufferStructField { + const field = this.#def.getField(fieldName as string); + const type = field.type; + + if (isExtendedType(type)) { + if (type.kind === 'array') { + return type.get(this.#buffer, this.#byteOffset + field.offset); + } + if (type.kind === 'utf8') { + return type.get(this.#buffer, this.#byteOffset + field.offset); + } + if (type.kind === 'circular') { + return type.get(this.#buffer, this.#byteOffset + field.offset); + } + } + + return (type as FieldType).get(this.view, field.offset); + } + + set(fieldName: keyof T, value: number): void { + const field = this.#def.getField(fieldName as string); + const type = field.type; + + if (isExtendedType(type)) { + throw new Error( + `Cannot use set() on complex field '${String(fieldName)}'. ` + + 'Use get() to access the field and modify it directly.', + ); + } + + (type as FieldType).set(this.view, field.offset, value); + } + + init(values: Partial>): void { + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + this.set(key as keyof T, value); + } + } + } + + copyFrom(other: StructView): void { + const src = new Uint8Array( + other.#buffer, + other.#byteOffset, + this.#def.layout.stride, + ); + const dst = new Uint8Array( + this.#buffer, + this.#byteOffset, + this.#def.layout.stride, + ); + dst.set(src); + } +} diff --git a/packages/ordo/src/struct/struct.test.ts b/packages/ordo/src/struct/struct.test.ts new file mode 100644 index 0000000..602e5de --- /dev/null +++ b/packages/ordo/src/struct/struct.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { struct } from './factory-functions'; +import { array, float32, float64, uint8, uint16, uint32 } from './fields'; + +describe('Struct', () => { + describe('Memory Layout', () => { + it('should calculate correct stride with padding', () => { + const def = struct({ + a: uint8, + b: float64, + c: uint8, + }); + + // a: 1 byte at offset 0 + // [7 bytes padding] + // b: 8 bytes at offset 8 + // c: 1 byte at offset 16 + // [7 bytes padding] + // stride: 24 + expect(def.layout.stride).toBe(24); + expect(def.layout.alignment).toBe(8); + }); + + it('should handle arrays with proper alignment', () => { + const def = struct({ + position: array('f64', 3), + id: uint32, + }); + + // position: 24 bytes at offset 0 + // id: 4 bytes at offset 24 + // [4 bytes padding] + // stride: 32 + expect(def.layout.stride).toBe(32); + }); + + it('should calculate optimal layout for well-ordered fields', () => { + const def = struct({ + timestamp: float64, + position: array('f64', 3), + health: uint16, + active: uint8, + team: uint8, + }); + + const wastedBytes = def.layout.stride - (8 + 24 + 2 + 1 + 1); + + expect(wastedBytes).toBeLessThanOrEqual(8); + }); + + it('should calculate poor layout for badly-ordered fields', () => { + const badDef = struct({ + active: uint8, // 1 byte + position: array('f64', 3), // needs 8-byte alignment + team: uint8, // 1 byte + rotation: array('f64', 4), // needs 8-byte alignment + }); + + // Should have significant padding + const totalData = 1 + 24 + 1 + 32; // 58 bytes + expect(badDef.layout.stride).toBeGreaterThan(totalData); + }); + }); + + describe('getField', () => { + it('should return a field descriptor for a valid name', () => { + const def = struct({ + id: uint32, + value: float32, + }); + + const field = def.getField('id'); + + expect(field.name).toBe('id'); + expect(field.type).toBe(uint32); + expect(field.offset).toBe(0); + }); + + it('should throw for an unknown field', () => { + const def = struct({ + id: uint32, + }); + + expect(() => def.getField('unknown')).toThrow('Unknown field: unknown'); + }); + }); + + describe('inspect', () => { + it('should not throw when called', () => { + const def = struct({ + id: uint32, + value: float32, + }); + + expect(() => def.inspect()).not.toThrow(); + }); + }); +}); diff --git a/packages/ordo/src/struct/struct.ts b/packages/ordo/src/struct/struct.ts new file mode 100644 index 0000000..8e69cd8 --- /dev/null +++ b/packages/ordo/src/struct/struct.ts @@ -0,0 +1,139 @@ +import { type ExtendedFieldType, isExtendedType } from './fields'; +import { StructArray } from './struct-array'; +import { StructView } from './struct-view'; + +export type FieldDescriptor = { + readonly name: string; + readonly type: ExtendedFieldType; + readonly offset: number; +}; + +export type StructLayout = { + readonly fields: readonly FieldDescriptor[]; + readonly stride: number; + readonly alignment: number; +}; + +const alignOffset = (offset: number, alignment: number): number => { + const remainder = offset % alignment; + return remainder === 0 ? offset : offset + (alignment - remainder); +}; + +const calculateLayout = ( + schema: Record, +): StructLayout => { + const fields: FieldDescriptor[] = []; + let offset = 0; + let maxAlignment = 1; + + for (const [name, type] of Object.entries(schema)) { + offset = alignOffset(offset, type.alignment); + fields.push({ name, type, offset }); + offset += type.size; + maxAlignment = Math.max(maxAlignment, type.alignment); + } + + const stride = alignOffset(offset, maxAlignment); + + return { fields, stride, alignment: maxAlignment }; +}; + +export class Struct> { + readonly #layout: StructLayout; + readonly #fieldMap: Map; + + constructor(schema: T) { + this.#layout = calculateLayout(schema); + this.#fieldMap = new Map( + this.#layout.fields.map((field) => [field.name, field]), + ); + } + + get layout(): StructLayout { + return this.#layout; + } + + create(): StructView { + const buffer = new ArrayBuffer(this.#layout.stride); + + return new StructView(this, buffer, 0); + } + + createArray(capacity: number): StructArray { + return new StructArray(this, capacity); + } + + getField(name: string): FieldDescriptor { + const field = this.#fieldMap.get(name); + + if (!field) { + throw new Error(`Unknown field: ${name}`); + } + + return field; + } + + inspect(): void { + // Header + console.log( + `\n${'Field'.padEnd(20)} ${'Offset'.padEnd(8)} ${'Size'.padEnd(8)}`, + ); + console.log(`${'-'.repeat(20)} ${'-'.repeat(8)} ${'-'.repeat(8)}`); + + let lastEnd = 0; + + for (const field of this.#layout.fields) { + if (field.offset > lastEnd) { + const paddingSize = field.offset - lastEnd; + console.log( + `${'[PADDING]'.padEnd(20)} ${lastEnd.toString().padEnd(8)} ${paddingSize.toString().padEnd(8)}`, + ); + } + + const fieldDisplay = this.getFieldDisplay(field); + console.log( + `${fieldDisplay.padEnd(20)} ${field.offset.toString().padEnd(8)} ${field.type.size.toString().padEnd(8)}`, + ); + + lastEnd = field.offset + field.type.size; + } + + if (this.#layout.stride > lastEnd) { + const paddingSize = this.#layout.stride - lastEnd; + console.log( + `${'[PADDING]'.padEnd(20)} ${lastEnd.toString().padEnd(8)} ${paddingSize.toString().padEnd(8)}`, + ); + } + + const wastedBytes = + this.#layout.stride - + this.#layout.fields.reduce((sum, f) => sum + f.type.size, 0); + const efficiency = ( + ((this.#layout.stride - wastedBytes) / this.#layout.stride) * + 100 + ).toFixed(1); + + console.log(`\nTotal size: ${this.#layout.stride} bytes`); + console.log(`Actual data: ${this.#layout.stride - wastedBytes} bytes`); + console.log(`Wasted: ${wastedBytes} bytes`); + console.log(`Efficiency: ${efficiency}%`); + } + + private getFieldDisplay(field: FieldDescriptor): string { + const type = field.type; + + if (isExtendedType(type)) { + if (type.kind === 'array') { + return `${field.name}[${type.length}]`; + } + if (type.kind === 'utf8') { + return `${field.name}[${type.byteLength}]`; + } + if (type.kind === 'circular') { + return `${field.name}[${type.capacity}]`; + } + } + + return field.name; + } +} diff --git a/packages/ordo/src/types.ts b/packages/ordo/src/types.ts new file mode 100644 index 0000000..566d85c --- /dev/null +++ b/packages/ordo/src/types.ts @@ -0,0 +1,28 @@ +export type KeyedArray = { + u8: Uint8Array; + i8: Int8Array; + u16: Uint16Array; + i16: Int16Array; + u32: Uint32Array; + i32: Int32Array; + f32: Float32Array; + f64: Float64Array; + i64: BigInt64Array; + u64: BigUint64Array; +}; + +export const arrayTypes = { + u8: Uint8Array, + i8: Int8Array, + u16: Uint16Array, + i16: Int16Array, + u32: Uint32Array, + i32: Int32Array, + f32: Float32Array, + f64: Float64Array, + i64: BigInt64Array, + u64: BigUint64Array, +} as const; + +export type ArrayTypes = typeof arrayTypes; +export type ArrayKeys = keyof ArrayTypes; diff --git a/packages/ordo/src/utf8-array/index.ts b/packages/ordo/src/utf8-array/index.ts new file mode 100644 index 0000000..ae571eb --- /dev/null +++ b/packages/ordo/src/utf8-array/index.ts @@ -0,0 +1,63 @@ +export class Utf8TypedArray { + readonly #arr: Uint8Array; + readonly #byteLen: number; + #index = 0; + readonly #encoder = new TextEncoder(); + readonly #decoder = new TextDecoder(); + + constructor(stringLen: number, maxCollectionLen: number) { + this.#arr = new Uint8Array(maxCollectionLen); + this.#byteLen = stringLen; + } + + push(val: string): void { + // Check if we have space for another string + if (this.#index >= Math.floor(this.#arr.length / this.#byteLen)) { + throw new RangeError( + `Utf8TypedArray capacity exceeded. Current index: ${this.#index}, ` + + `max index: ${Math.floor(this.#arr.length / this.#byteLen) - 1}`, + ); + } + + const encoding = this.#encoder.encode(val); + const offset = this.#byteLen * this.#index; + + this.#arr.set(encoding, offset); + this.#index += 1; + } + + at(index: number): string { + // Bounds check + if (index < 0 || index >= this.#index) { + throw new RangeError( + `Index out of range. ${index} given, max index is ${this.#index - 1}`, + ); + } + + return ( + this.#decoder + .decode( + new Uint8Array( + this.#arr.buffer, + index * this.#byteLen, + this.#byteLen, + ), + ) + // biome-ignore lint/suspicious/noControlCharactersInRegex: This is intentional + .replace(/\u0000+$/, '') + ); + } + + array(): Uint8Array { + return this.#arr; + } + + static from(chunkSize: number, buffer: ArrayBuffer): Utf8TypedArray { + const len = buffer.byteLength; + const ta = new Utf8TypedArray(chunkSize, len); + + ta.#arr.set(new Uint8Array(buffer)); + + return ta; + } +} diff --git a/packages/ordo/src/utils.ts b/packages/ordo/src/utils.ts new file mode 100644 index 0000000..d4d5b1b --- /dev/null +++ b/packages/ordo/src/utils.ts @@ -0,0 +1,23 @@ +/** + * Asserts that a value is not undefined. + * + * Uses TypeScript's assertion signature to narrow the type, eliminating + * undefined from the type union. + * + * @template T - The expected type of the value + * @param value - The value to check + * @throws {Error} If value is undefined + * + * @example + * ```typescript + * const value = arr[index]; + * assertDefined(value); + * // TypeScript now knows value is T, not T | undefined + * return value; + * ``` + */ +export function assertDefined(value: T | undefined): asserts value is T { + if (value === undefined) { + throw new Error('Unexpected undefined value'); + } +} diff --git a/packages/ordo/tsconfig.json b/packages/ordo/tsconfig.json new file mode 100644 index 0000000..fc0076c --- /dev/null +++ b/packages/ordo/tsconfig.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["./src"], + "exclude": [ + "node_modules", + "**/*.bench.ts", + "**/*.test.ts", + "**/*.test-d.ts" + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "paths": { + "@/*": ["./src/*"] + }, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "module": "esnext", + "moduleResolution": "bundler", + "sourceMap": true, + "declaration": true, + "lib": ["es2023", "dom"] + } +} diff --git a/packages/ordo/tsdown.config.js b/packages/ordo/tsdown.config.js new file mode 100644 index 0000000..7403287 --- /dev/null +++ b/packages/ordo/tsdown.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + plugins: [], + entry: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.{d,test,test-d,bench}.{ts,tsx}', + '!**/__fixtures__', + ], + clean: true, + dts: true, + format: 'esm', + sourcemap: true, + unbundle: true, + treeshake: true, + platform: 'neutral', + minify: false, + exports: true, +}); diff --git a/packages/ordo/vitest.config.js b/packages/ordo/vitest.config.js new file mode 100644 index 0000000..d601790 --- /dev/null +++ b/packages/ordo/vitest.config.js @@ -0,0 +1,21 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + watch: false, + coverage: { + provider: 'istanbul', + reporter: ['json', 'json-summary', 'lcov', 'text'], + reportsDirectory: './coverage', + enabled: true, + clean: true, + passWithNoTests: true, + }, + clearMocks: true, + restoreMocks: true, + passWithNoTests: true, + silent: 'passed-only', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e525c22..0d5ac75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,24 @@ importers: specifier: ^4.0.8 version: 4.0.15 + packages/ordo: + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^4.0.8 + version: 4.0.15(vitest@4.0.15) + tsdown: + specifier: ^0.17.2 + version: 0.17.3(typescript@5.8.3) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@7.2.7) + vitest: + specifier: ^4.0.8 + version: 4.0.15 + packages: '@babel/code-frame@7.27.1':