ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
377 lines
11 KiB
Go
377 lines
11 KiB
Go
package asset
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"aego/core/ids"
|
|
"aego/gpu"
|
|
)
|
|
|
|
// TryArtifacts is a bounded prepared payload for documentation sessions, not a game archive; exports still use their directory manifest.
|
|
// Items contain the asset loader's runtime artifacts; SourceHash covers the source/meta manifest and importer versions at build time.
|
|
type TryArtifacts struct {
|
|
SourceHash [32]byte
|
|
Assets []TryAsset
|
|
}
|
|
|
|
type TryAsset struct {
|
|
ID ids.AssetID
|
|
Kind ArtifactKind
|
|
Name string
|
|
Deps []ids.AssetID
|
|
Artifacts []TryArtifact
|
|
}
|
|
|
|
type TryArtifact struct {
|
|
Kind ArtifactKind
|
|
Name string
|
|
Data []byte
|
|
}
|
|
|
|
const (
|
|
TryArtifactsVersion = 1
|
|
MaxTryAssets = 64
|
|
MaxTryArtifactBytes = 2 << 20
|
|
MaxTryArtifactsBytes = 8 << 20
|
|
tryArtifactsHeader = 76
|
|
)
|
|
|
|
var tryKinds = [...]ArtifactKind{"", ArtTexture, ArtAtlasPage, ArtSpriteRegion, ArtMaterial, ArtShader}
|
|
|
|
func tryKindCode(kind ArtifactKind) byte {
|
|
for i, candidate := range tryKinds {
|
|
if candidate == kind {
|
|
return byte(i)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func compareTryID(a, b ids.AssetID) int {
|
|
if a.Hi < b.Hi {
|
|
return -1
|
|
}
|
|
if a.Hi > b.Hi {
|
|
return 1
|
|
}
|
|
if a.Lo < b.Lo {
|
|
return -1
|
|
}
|
|
if a.Lo > b.Lo {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func validTryName(name string) bool {
|
|
return len(name) <= 256 && utf8.ValidString(name) && !strings.ContainsAny(name, "\x00\r\n")
|
|
}
|
|
|
|
func validateTryArtifacts(bundle *TryArtifacts) error {
|
|
if bundle == nil || bundle.SourceHash == ([32]byte{}) || len(bundle.Assets) == 0 || len(bundle.Assets) > MaxTryAssets {
|
|
return fmt.Errorf("try artifacts need a source hash and 1..%d assets", MaxTryAssets)
|
|
}
|
|
byID := make(map[ids.AssetID]*TryAsset, len(bundle.Assets))
|
|
bytes := 0
|
|
for i := range bundle.Assets {
|
|
entry := &bundle.Assets[i]
|
|
if entry.ID.IsZero() || IsBuiltin(entry.ID) || byID[entry.ID] != nil || tryKindCode(entry.Kind) == 0 || !validTryName(entry.Name) || len(entry.Deps) > MaxTryAssets || len(entry.Artifacts) == 0 || len(entry.Artifacts) > 4 {
|
|
return fmt.Errorf("invalid or duplicate try asset %s", entry.ID)
|
|
}
|
|
byID[entry.ID] = entry
|
|
seen := make(map[ArtifactKind]bool, len(entry.Artifacts))
|
|
for _, artifact := range entry.Artifacts {
|
|
allowed := artifact.Kind == entry.Kind || (entry.Kind == ArtTexture || entry.Kind == ArtAtlasPage) && (artifact.Kind == ArtTexture || artifact.Kind == ArtSpriteRegion)
|
|
if !allowed || seen[artifact.Kind] || !validTryName(artifact.Name) || len(artifact.Data) == 0 || len(artifact.Data) > MaxTryArtifactBytes {
|
|
return fmt.Errorf("invalid artifact in try asset %s", entry.ID)
|
|
}
|
|
seen[artifact.Kind] = true
|
|
bytes += len(artifact.Data)
|
|
if bytes > MaxTryArtifactsBytes {
|
|
return fmt.Errorf("try artifact payload exceeds %d bytes", MaxTryArtifactsBytes)
|
|
}
|
|
}
|
|
main := entry.Kind
|
|
if main == ArtAtlasPage {
|
|
main = ArtTexture
|
|
}
|
|
if !seen[main] && !(entry.Kind == ArtAtlasPage && seen[ArtAtlasPage]) {
|
|
return fmt.Errorf("try asset %s has no %s artifact", entry.ID, main)
|
|
}
|
|
}
|
|
for i := range bundle.Assets {
|
|
entry := &bundle.Assets[i]
|
|
seen := map[ids.AssetID]bool{}
|
|
for _, dependency := range entry.Deps {
|
|
_, builtin := LookupBuiltin(dependency)
|
|
if dependency.IsZero() || dependency == entry.ID || seen[dependency] || byID[dependency] == nil && !builtin {
|
|
return fmt.Errorf("try asset %s has a missing, duplicate or self dependency %s", entry.ID, dependency)
|
|
}
|
|
seen[dependency] = true
|
|
}
|
|
require := func(id ids.AssetID) error {
|
|
if id == entry.ID || id.IsZero() {
|
|
return nil
|
|
}
|
|
if !seen[id] {
|
|
return fmt.Errorf("try asset %s omits artifact dependency %s", entry.ID, id)
|
|
}
|
|
return nil
|
|
}
|
|
for _, artifact := range entry.Artifacts {
|
|
var err error
|
|
switch artifact.Kind {
|
|
case ArtTexture, ArtAtlasPage:
|
|
var texture *TextureArtifact
|
|
texture, err = DecodeTexture(artifact.Data)
|
|
if err == nil && (texture.Width > 2048 || texture.Height > 2048 || texture.Filter > gpu.FilterLinear || len(texture.Pixels) != texture.Width*texture.Height*4) {
|
|
err = fmt.Errorf("try texture dimensions, filter or pixel size are invalid")
|
|
}
|
|
case ArtSpriteRegion:
|
|
var region *RegionArtifact
|
|
region, err = DecodeRegion(artifact.Data)
|
|
if err == nil {
|
|
err = require(region.Page)
|
|
}
|
|
if err == nil {
|
|
err = require(region.Normal)
|
|
}
|
|
case ArtMaterial:
|
|
var document *MaterialDoc
|
|
document, err = DecodeMaterial(artifact.Data, entry.Name)
|
|
if err == nil {
|
|
for _, id := range document.Deps() {
|
|
if err = require(id); err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
case ArtShader:
|
|
_, err = DecodeShader(artifact.Data)
|
|
default:
|
|
err = fmt.Errorf("unsupported try artifact kind %s", artifact.Kind)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("try asset %s: %w", entry.ID, err)
|
|
}
|
|
}
|
|
}
|
|
// Cycles are diagnosed before anything reaches a loader or GPU resource.
|
|
state := make(map[ids.AssetID]uint8, len(byID))
|
|
var visit func(ids.AssetID) error
|
|
visit = func(id ids.AssetID) error {
|
|
if state[id] == 1 {
|
|
return fmt.Errorf("try artifact dependency cycle at %s", id)
|
|
}
|
|
if state[id] == 2 || byID[id] == nil {
|
|
return nil
|
|
}
|
|
state[id] = 1
|
|
for _, dep := range byID[id].Deps {
|
|
if err := visit(dep); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
state[id] = 2
|
|
return nil
|
|
}
|
|
for _, entry := range bundle.Assets {
|
|
if err := visit(entry.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeTryArtifacts canonicalizes entry/dependency order and checks every
|
|
// prepared decoder. Hashes cover source provenance, descriptors and all bytes.
|
|
func EncodeTryArtifacts(bundle *TryArtifacts) ([]byte, error) {
|
|
if err := validateTryArtifacts(bundle); err != nil {
|
|
return nil, err
|
|
}
|
|
entries := slices.Clone(bundle.Assets)
|
|
slices.SortFunc(entries, func(a, b TryAsset) int { return compareTryID(a.ID, b.ID) })
|
|
out := make([]byte, tryArtifactsHeader)
|
|
copy(out, "AETB")
|
|
binary.LittleEndian.PutUint32(out[4:8], TryArtifactsVersion)
|
|
binary.LittleEndian.PutUint32(out[8:12], uint32(len(entries)))
|
|
copy(out[12:44], bundle.SourceHash[:])
|
|
appendID := func(id ids.AssetID) {
|
|
out = binary.LittleEndian.AppendUint64(out, id.Hi)
|
|
out = binary.LittleEndian.AppendUint64(out, id.Lo)
|
|
}
|
|
appendString := func(text string) {
|
|
out = binary.LittleEndian.AppendUint16(out, uint16(len(text)))
|
|
out = append(out, text...)
|
|
}
|
|
for _, entry := range entries {
|
|
appendID(entry.ID)
|
|
out = append(out, tryKindCode(entry.Kind))
|
|
appendString(entry.Name)
|
|
deps := slices.Clone(entry.Deps)
|
|
slices.SortFunc(deps, compareTryID)
|
|
out = binary.LittleEndian.AppendUint16(out, uint16(len(deps)))
|
|
for _, dep := range deps {
|
|
appendID(dep)
|
|
}
|
|
artifacts := slices.Clone(entry.Artifacts)
|
|
slices.SortFunc(artifacts, func(a, b TryArtifact) int { return strings.Compare(string(a.Kind), string(b.Kind)) })
|
|
out = append(out, byte(len(artifacts)))
|
|
for _, artifact := range artifacts {
|
|
out = append(out, tryKindCode(artifact.Kind))
|
|
appendString(artifact.Name)
|
|
out = binary.LittleEndian.AppendUint32(out, uint32(len(artifact.Data)))
|
|
hash := sha256.Sum256(artifact.Data)
|
|
out = append(out, hash[:]...)
|
|
out = append(out, artifact.Data...)
|
|
}
|
|
}
|
|
if len(out) > MaxTryArtifactsBytes {
|
|
return nil, fmt.Errorf("try artifact manifest exceeds %d bytes", MaxTryArtifactsBytes)
|
|
}
|
|
hash := tryArtifactsHash(out)
|
|
copy(out[44:76], hash[:])
|
|
return out, nil
|
|
}
|
|
|
|
func tryArtifactsHash(data []byte) [32]byte {
|
|
hash := sha256.New()
|
|
hash.Write(data[4:44])
|
|
hash.Write(data[76:])
|
|
var out [32]byte
|
|
copy(out[:], hash.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
type tryArtifactReader struct {
|
|
data []byte
|
|
at int
|
|
err error
|
|
}
|
|
|
|
func (r *tryArtifactReader) take(n int) []byte {
|
|
if r.err != nil {
|
|
return nil
|
|
}
|
|
if n < 0 || n > len(r.data)-r.at {
|
|
r.err = fmt.Errorf("truncated try artifact manifest")
|
|
return nil
|
|
}
|
|
out := r.data[r.at : r.at+n]
|
|
r.at += n
|
|
return out
|
|
}
|
|
func (r *tryArtifactReader) u8() byte {
|
|
b := r.take(1)
|
|
if len(b) != 1 {
|
|
return 0
|
|
}
|
|
return b[0]
|
|
}
|
|
func (r *tryArtifactReader) u16() uint16 {
|
|
b := r.take(2)
|
|
if len(b) != 2 {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint16(b)
|
|
}
|
|
func (r *tryArtifactReader) u32() uint32 {
|
|
b := r.take(4)
|
|
if len(b) != 4 {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint32(b)
|
|
}
|
|
func (r *tryArtifactReader) id() ids.AssetID {
|
|
b := r.take(16)
|
|
if len(b) != 16 {
|
|
return ids.AssetID{}
|
|
}
|
|
return ids.AssetID{Hi: binary.LittleEndian.Uint64(b), Lo: binary.LittleEndian.Uint64(b[8:])}
|
|
}
|
|
func (r *tryArtifactReader) text() string {
|
|
n := int(r.u16())
|
|
if n > 256 {
|
|
r.err = fmt.Errorf("try artifact name exceeds 256 bytes")
|
|
return ""
|
|
}
|
|
return string(r.take(n))
|
|
}
|
|
func (r *tryArtifactReader) kind() ArtifactKind {
|
|
b := r.u8()
|
|
if b == 0 || int(b) >= len(tryKinds) {
|
|
r.err = fmt.Errorf("unknown try artifact kind")
|
|
return ""
|
|
}
|
|
return tryKinds[b]
|
|
}
|
|
|
|
// DecodeTryArtifacts borrows immutable artifact bytes from data. It checks
|
|
// bounds before allocating, verifies all hashes and validates dependency closure.
|
|
func DecodeTryArtifacts(data []byte) (*TryArtifacts, error) {
|
|
if len(data) < tryArtifactsHeader || len(data) > MaxTryArtifactsBytes || string(data[:4]) != "AETB" || binary.LittleEndian.Uint32(data[4:8]) != TryArtifactsVersion {
|
|
return nil, fmt.Errorf("unsupported or oversized try artifact manifest")
|
|
}
|
|
count := binary.LittleEndian.Uint32(data[8:12])
|
|
if count == 0 || count > MaxTryAssets {
|
|
return nil, fmt.Errorf("try artifact asset count is outside budget")
|
|
}
|
|
hash := tryArtifactsHash(data)
|
|
if !bytes.Equal(hash[:], data[44:76]) {
|
|
return nil, fmt.Errorf("try artifact manifest hash mismatch")
|
|
}
|
|
bundle := &TryArtifacts{Assets: make([]TryAsset, int(count))}
|
|
copy(bundle.SourceHash[:], data[12:44])
|
|
r := tryArtifactReader{data: data, at: tryArtifactsHeader}
|
|
for i := range bundle.Assets {
|
|
entry := &bundle.Assets[i]
|
|
entry.ID, entry.Kind, entry.Name = r.id(), r.kind(), r.text()
|
|
deps := int(r.u16())
|
|
if deps > MaxTryAssets {
|
|
return nil, fmt.Errorf("try artifact dependency budget exceeded")
|
|
}
|
|
entry.Deps = make([]ids.AssetID, deps)
|
|
for j := range entry.Deps {
|
|
entry.Deps[j] = r.id()
|
|
}
|
|
artifacts := int(r.u8())
|
|
if artifacts == 0 || artifacts > 4 {
|
|
return nil, fmt.Errorf("try artifact per-asset budget exceeded")
|
|
}
|
|
entry.Artifacts = make([]TryArtifact, artifacts)
|
|
for j := range entry.Artifacts {
|
|
artifact := &entry.Artifacts[j]
|
|
artifact.Kind, artifact.Name = r.kind(), r.text()
|
|
size := r.u32()
|
|
if size > MaxTryArtifactBytes {
|
|
return nil, fmt.Errorf("try artifact byte budget exceeded")
|
|
}
|
|
want := r.take(32)
|
|
artifact.Data = r.take(int(size))
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
got := sha256.Sum256(artifact.Data)
|
|
if !bytes.Equal(got[:], want) {
|
|
return nil, fmt.Errorf("try artifact %s hash mismatch", entry.ID)
|
|
}
|
|
}
|
|
}
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
if r.at != len(data) {
|
|
return nil, fmt.Errorf("trailing try artifact data")
|
|
}
|
|
if err := validateTryArtifacts(bundle); err != nil {
|
|
return nil, err
|
|
}
|
|
return bundle, nil
|
|
}
|