@forgeax/engine-assets-runtime 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,12 +12,33 @@ import type {
12
12
  TextureAsset,
13
13
  TilesetAsset,
14
14
  } from '@forgeax/engine-types';
15
- import { AssetError } from '@forgeax/engine-types';
15
+ import { AssetError, deriveTextureLayout } from '@forgeax/engine-types';
16
+ import {
17
+ type TexturePackVerificationDetail,
18
+ TexturePackVerificationError,
19
+ } from '../errors/asset.js';
16
20
  import type { PackLoaderInput } from '../loader-registry';
17
- import { numMipLevels } from '../mipmap-generator';
18
21
 
19
22
  type PackArtifact = PackLoaderInput['artifacts'][string];
20
23
 
24
+ export interface TexturePackLoadInput {
25
+ readonly pack: PackLoaderInput;
26
+ readonly sourceKey: string;
27
+ readonly generation: number;
28
+ readonly expectedDigest: string;
29
+ }
30
+
31
+ export type VerifiedTexturePack = {
32
+ readonly asset: TextureAsset;
33
+ readonly sourceKey: string;
34
+ readonly generation: number;
35
+ readonly digest: string;
36
+ };
37
+
38
+ export type VerifiedTexturePackResult =
39
+ | { readonly ok: true; readonly value: VerifiedTexturePack }
40
+ | { readonly ok: false; readonly error: TexturePackVerificationError | AssetError };
41
+
21
42
  type CodecFailureProjection = {
22
43
  readonly code: string;
23
44
  readonly expected: string;
@@ -38,6 +59,36 @@ function payloadColorSpace(payload: Record<string, unknown>): 'srgb' | 'linear'
38
59
  return payload.colorSpace === 'linear' ? 'linear' : 'srgb';
39
60
  }
40
61
 
62
+ function payloadTextureShape(payload: Record<string, unknown>): TextureAsset['shape'] | undefined {
63
+ const shape = payload.shape;
64
+ if (!record(shape) || typeof shape.viewDimension !== 'string' || !record(shape.extent)) {
65
+ return undefined;
66
+ }
67
+ const { width, height, layers, depth } = shape.extent;
68
+ if (!positiveInteger(width) || !positiveInteger(height)) return undefined;
69
+ if (shape.viewDimension === '2d' && layers === undefined && depth === undefined) {
70
+ return { viewDimension: '2d', extent: { width, height } };
71
+ }
72
+ if (shape.viewDimension === '2d-array' && positiveInteger(layers) && depth === undefined) {
73
+ return { viewDimension: '2d-array', extent: { width, height, layers } };
74
+ }
75
+ if (shape.viewDimension === '3d' && positiveInteger(depth) && layers === undefined) {
76
+ return { viewDimension: '3d', extent: { width, height, depth } };
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function payloadTextureMips(payload: Record<string, unknown>): TextureAsset['mips'] | undefined {
82
+ const mips = payload.mips;
83
+ if (!record(mips) || typeof mips.kind !== 'string') return undefined;
84
+ if (mips.kind === 'none') return { kind: 'none' };
85
+ if (mips.kind === 'generate') return { kind: 'generate' };
86
+ if (mips.kind === 'packed' && positiveInteger(mips.levelCount)) {
87
+ return { kind: 'packed', levelCount: mips.levelCount };
88
+ }
89
+ return undefined;
90
+ }
91
+
41
92
  function invalidPackAsset<T>(input: PackLoaderInput, expected: string): LoaderAsyncResult<T> {
42
93
  return {
43
94
  ok: false,
@@ -62,6 +113,32 @@ function positiveInteger(value: unknown): value is number {
62
113
  return finiteNumber(value) && Number.isInteger(value) && value > 0;
63
114
  }
64
115
 
116
+ async function textureDigest(bytes: Uint8Array): Promise<string> {
117
+ const subtle = globalThis.crypto?.subtle;
118
+ if (subtle === undefined) throw new Error('Web Crypto API is required for texture verification');
119
+ const digest = await subtle.digest('SHA-256', bytes.slice().buffer as ArrayBuffer);
120
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
121
+ }
122
+
123
+ function textureVerificationError(
124
+ input: TexturePackLoadInput,
125
+ cause: TexturePackVerificationDetail['cause'],
126
+ expected: string,
127
+ fields: Partial<TexturePackVerificationDetail> = {},
128
+ ): TexturePackVerificationError {
129
+ return new TexturePackVerificationError(
130
+ {
131
+ guid: input.pack.guid,
132
+ sourceKey: input.sourceKey,
133
+ generation: input.generation,
134
+ stage: 'loader',
135
+ cause,
136
+ ...fields,
137
+ },
138
+ expected,
139
+ );
140
+ }
141
+
65
142
  function validRenderPipelineConfig(value: unknown): value is Record<string, unknown> {
66
143
  if (!record(value)) return false;
67
144
  const passCount = value.passCount;
@@ -295,8 +372,6 @@ async function loadTexturePack(
295
372
  if (artifact === undefined)
296
373
  return invalidPackAsset<TextureAsset>(input, 'texture asset-local image artifact');
297
374
  const payload = input.payload;
298
- const width = payloadNumber(payload, 'width', 0);
299
- const height = payloadNumber(payload, 'height', 0);
300
375
  const colorSpace = payloadColorSpace(payload);
301
376
  const codec = artifact.descriptor.assetCodec;
302
377
  const profile = codecProfile(codec);
@@ -346,13 +421,14 @@ async function loadTexturePack(
346
421
  ok: true,
347
422
  value: {
348
423
  kind: 'texture',
349
- width: transcoded.value.width,
350
- height: transcoded.value.height,
424
+ shape: {
425
+ viewDimension: '2d',
426
+ extent: { width: transcoded.value.width, height: transcoded.value.height },
427
+ },
351
428
  format: target,
352
429
  data,
353
430
  colorSpace,
354
- mipmap: transcoded.value.mips.length > 1,
355
- mipLevelCount: Math.max(1, transcoded.value.mips.length),
431
+ mips: { kind: 'packed', levelCount: Math.max(1, transcoded.value.mips.length) },
356
432
  },
357
433
  };
358
434
  } catch (error) {
@@ -433,13 +509,14 @@ async function loadTexturePack(
433
509
  ok: true,
434
510
  value: {
435
511
  kind: 'texture',
436
- width: transcoded.value.width,
437
- height: transcoded.value.height,
512
+ shape: {
513
+ viewDimension: '2d',
514
+ extent: { width: transcoded.value.width, height: transcoded.value.height },
515
+ },
438
516
  format: target,
439
517
  data,
440
518
  colorSpace,
441
- mipmap: transcoded.value.mips.length > 1,
442
- mipLevelCount: Math.max(1, transcoded.value.mips.length),
519
+ mips: { kind: 'packed', levelCount: Math.max(1, transcoded.value.mips.length) },
443
520
  },
444
521
  };
445
522
  } catch (error) {
@@ -465,19 +542,143 @@ async function loadTexturePack(
465
542
  }
466
543
  }
467
544
 
468
- const mipmap = payload.mipmap === true;
545
+ const shape = payloadTextureShape(payload);
546
+ const mips = payloadTextureMips(payload);
547
+ if (shape === undefined || mips === undefined) {
548
+ return invalidPackAsset<TextureAsset>(
549
+ input,
550
+ 'texture payload with a valid shape and mip policy',
551
+ );
552
+ }
469
553
  return {
470
554
  ok: true,
471
555
  value: {
472
556
  kind: 'texture',
473
- width,
474
- height,
557
+ shape,
475
558
  format: (payload.format ??
476
559
  (colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm')) as GPUTextureFormat,
477
560
  data: artifact.bytes,
478
561
  colorSpace,
479
- mipmap,
480
- mipLevelCount: mipmap ? numMipLevels({ width, height }) : 1,
562
+ mips,
563
+ },
564
+ };
565
+ }
566
+
567
+ /** Verify producer facts and then expose one accepted texture projection. */
568
+ export async function loadVerifiedTexturePack(
569
+ input: TexturePackLoadInput,
570
+ ctx: LoadContext,
571
+ ): Promise<VerifiedTexturePackResult> {
572
+ const artifact = firstArtifact(input.pack);
573
+ if (artifact === undefined) {
574
+ return {
575
+ ok: false,
576
+ error: textureVerificationError(input, 'byte-length', 'one asset-local body artifact', {
577
+ expectedBytes: 0,
578
+ actualBytes: 0,
579
+ }),
580
+ };
581
+ }
582
+ const actualBytes = artifact.bytes.byteLength;
583
+ const declaredBytes = artifact.descriptor.byteLength;
584
+ if (declaredBytes !== undefined && declaredBytes !== actualBytes) {
585
+ return {
586
+ ok: false,
587
+ error: textureVerificationError(
588
+ input,
589
+ 'byte-length',
590
+ `artifact byte length ${declaredBytes}`,
591
+ { expectedBytes: declaredBytes, actualBytes },
592
+ ),
593
+ };
594
+ }
595
+ const payloadOrder = input.pack.payload.packingOrder;
596
+ if (payloadOrder !== undefined && payloadOrder !== 'mip-major,image-major,row-major') {
597
+ return {
598
+ ok: false,
599
+ error: textureVerificationError(
600
+ input,
601
+ 'packing-order-mismatch',
602
+ 'mip-major,image-major,row-major packing order',
603
+ ),
604
+ };
605
+ }
606
+ const shape = payloadTextureShape(input.pack.payload);
607
+ const mips = payloadTextureMips(input.pack.payload);
608
+ const format = (input.pack.payload.format ??
609
+ (payloadColorSpace(input.pack.payload) === 'srgb'
610
+ ? 'rgba8unorm-srgb'
611
+ : 'rgba8unorm')) as GPUTextureFormat;
612
+ if (shape === undefined || mips === undefined) {
613
+ return {
614
+ ok: false,
615
+ error: textureVerificationError(input, 'shape-mismatch', 'valid TextureAsset shape and mips'),
616
+ };
617
+ }
618
+ const layout = deriveTextureLayout({
619
+ shape,
620
+ format,
621
+ mips,
622
+ actualByteLength: actualBytes,
623
+ order: 'mip-major,image-major,row-major',
624
+ });
625
+ if (!layout.ok) {
626
+ const expectedBytes =
627
+ layout.error.code === 'texture-packing-invalid'
628
+ ? layout.error.detail.expectedBytes
629
+ : actualBytes;
630
+ const actualLayoutBytes =
631
+ layout.error.code === 'texture-packing-invalid'
632
+ ? layout.error.detail.actualBytes
633
+ : actualBytes;
634
+ return {
635
+ ok: false,
636
+ error: textureVerificationError(
637
+ input,
638
+ 'byte-length',
639
+ `canonical texture byte length ${expectedBytes}`,
640
+ {
641
+ expectedBytes,
642
+ actualBytes: actualLayoutBytes,
643
+ },
644
+ ),
645
+ };
646
+ }
647
+ const actualDigest = await textureDigest(artifact.bytes);
648
+ const descriptorDigest = artifact.descriptor.integrity?.digest;
649
+ if (descriptorDigest !== input.expectedDigest || actualDigest !== input.expectedDigest) {
650
+ return {
651
+ ok: false,
652
+ error: textureVerificationError(
653
+ input,
654
+ 'digest-mismatch',
655
+ `artifact digest ${input.expectedDigest}`,
656
+ {
657
+ expectedDigest: input.expectedDigest,
658
+ actualDigest,
659
+ },
660
+ ),
661
+ };
662
+ }
663
+ const loaded = await loadTexturePack(input.pack, ctx);
664
+ if (!loaded.ok) {
665
+ if (loaded.error instanceof AssetError) return { ok: false, error: loaded.error };
666
+ return {
667
+ ok: false,
668
+ error: textureVerificationError(
669
+ input,
670
+ 'shape-mismatch',
671
+ 'runtime texture loader to return a verified TextureAsset',
672
+ ),
673
+ };
674
+ }
675
+ return {
676
+ ok: true,
677
+ value: {
678
+ asset: loaded.value,
679
+ sourceKey: input.sourceKey,
680
+ generation: input.generation,
681
+ digest: actualDigest,
481
682
  },
482
683
  };
483
684
  }
@@ -10,6 +10,60 @@ import {
10
10
  } from '@forgeax/engine-types';
11
11
  import type { AssetRegistry } from '../asset-registry';
12
12
 
13
+ export interface TexturePublicationStateSnapshot {
14
+ readonly guid: string;
15
+ readonly sourceKey: string;
16
+ readonly acceptedGeneration: number;
17
+ readonly acceptedDigest: string;
18
+ readonly candidateGeneration?: number;
19
+ readonly candidateFailure?: string;
20
+ }
21
+
22
+ export interface TexturePublicationState {
23
+ reject(candidate: {
24
+ readonly generation: number;
25
+ readonly reason: string;
26
+ }): TexturePublicationStateSnapshot;
27
+ accept(candidate: {
28
+ readonly generation: number;
29
+ readonly digest: string;
30
+ }): TexturePublicationStateSnapshot;
31
+ }
32
+
33
+ /** Candidate/accepted state that keeps the last known good publication honest. */
34
+ export function createTexturePublicationState(input: {
35
+ readonly guid: string;
36
+ readonly sourceKey: string;
37
+ readonly generation: number;
38
+ readonly digest: string;
39
+ }): TexturePublicationState {
40
+ let accepted = {
41
+ generation: input.generation,
42
+ digest: input.digest,
43
+ };
44
+ let candidate: { readonly generation: number; readonly reason: string } | undefined;
45
+ const snapshot = (): TexturePublicationStateSnapshot => ({
46
+ guid: input.guid,
47
+ sourceKey: input.sourceKey,
48
+ acceptedGeneration: accepted.generation,
49
+ acceptedDigest: accepted.digest,
50
+ ...(candidate === undefined
51
+ ? {}
52
+ : { candidateGeneration: candidate.generation, candidateFailure: candidate.reason }),
53
+ });
54
+ return {
55
+ reject(next) {
56
+ candidate = next;
57
+ return snapshot();
58
+ },
59
+ accept(next) {
60
+ accepted = next;
61
+ candidate = undefined;
62
+ return snapshot();
63
+ },
64
+ };
65
+ }
66
+
13
67
  /**
14
68
  * Validate a MaterialAsset's passes[] against the ShaderRegistry's
15
69
  * paramSchema (union semantics: all declared params across all passes