@maccesar/titools 5.1.1 → 5.2.1

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.
Files changed (98) hide show
  1. package/CONTRIBUTING.md +69 -0
  2. package/README.md +112 -72
  3. package/agents/ti-pro.md +5 -1
  4. package/commands/ti-audit.md +5 -1
  5. package/docs/examples.md +91 -0
  6. package/docs/knowledge-index.md +57 -0
  7. package/lib/config.js +2 -0
  8. package/lib/downloader.js +1 -1
  9. package/package.json +4 -2
  10. package/skills/purgetss/references/multi-density-images.md +1 -1
  11. package/skills/ti-admob-unified/SKILL.md +58 -0
  12. package/skills/ti-admob-unified/agents/openai.yaml +4 -0
  13. package/skills/ti-admob-unified/assets/example/app.js +1207 -0
  14. package/skills/ti-admob-unified/assets/example/lib/ads.js +646 -0
  15. package/skills/ti-admob-unified/assets/example/library-usage.js +136 -0
  16. package/skills/ti-admob-unified/references/api-reference.md +615 -0
  17. package/skills/ti-admob-unified/references/banners.md +435 -0
  18. package/skills/ti-admob-unified/references/contributing.md +233 -0
  19. package/skills/ti-admob-unified/references/dependencies.md +91 -0
  20. package/skills/ti-admob-unified/references/fullscreen.md +238 -0
  21. package/skills/ti-admob-unified/references/game-monetization.md +148 -0
  22. package/skills/ti-admob-unified/references/getting-started.md +319 -0
  23. package/skills/ti-admob-unified/references/index.md +33 -0
  24. package/skills/ti-admob-unified/references/integration.md +87 -0
  25. package/skills/ti-admob-unified/references/library.md +220 -0
  26. package/skills/ti-admob-unified/references/mediation-parity.md +17 -0
  27. package/skills/ti-admob-unified/references/mediation.md +145 -0
  28. package/skills/ti-admob-unified/references/migration.md +259 -0
  29. package/skills/ti-admob-unified/references/module-changelog.md +84 -0
  30. package/skills/ti-admob-unified/references/module-license.txt +21 -0
  31. package/skills/ti-admob-unified/references/privacy.md +281 -0
  32. package/skills/ti-admob-unified/references/production.md +67 -0
  33. package/skills/ti-admob-unified/references/rewarded-interstitial.md +101 -0
  34. package/skills/ti-admob-unified/references/roadmap.md +143 -0
  35. package/skills/ti-admob-unified/references/source-notes.md +80 -0
  36. package/skills/ti-admob-unified/references/source-snapshot.json +644 -0
  37. package/skills/ti-admob-unified/references/third-party-notices.md +22 -0
  38. package/skills/ti-game/SKILL.md +31 -5
  39. package/skills/ti-game/references/api.md +36 -24
  40. package/skills/ti-game/references/debugging-performance.md +22 -7
  41. package/skills/ti-game/references/examples.md +113 -20
  42. package/skills/ti-game/references/input.md +101 -0
  43. package/skills/ti-game/references/patterns.md +56 -10
  44. package/skills/ti-game/references/physics.md +530 -0
  45. package/skills/ti-game/references/project-setup.md +131 -47
  46. package/skills/ti-game/references/recipes-action.md +149 -18
  47. package/skills/ti-game/references/recipes-adventure-effects.md +47 -4
  48. package/skills/ti-game/references/roadmap.md +11 -4
  49. package/skills/ti-game/references/source-notes.md +81 -0
  50. package/skills/ti-game/references/tilemaps.md +13 -4
  51. package/skills/ti-game-assets/SKILL.md +53 -0
  52. package/skills/ti-game-assets/agents/openai.yaml +4 -0
  53. package/skills/ti-game-assets/references/alpha.md +26 -0
  54. package/skills/ti-game-assets/references/art-direction.md +37 -0
  55. package/skills/ti-game-assets/references/integration.md +49 -0
  56. package/skills/ti-game-assets/references/manifest.example.json +429 -0
  57. package/skills/ti-game-assets/references/manifest.md +39 -0
  58. package/skills/ti-game-assets/references/offline-audio.md +33 -0
  59. package/skills/ti-game-assets/references/prompts.md +33 -0
  60. package/skills/ti-game-assets/references/repeating-textures.md +17 -0
  61. package/skills/ti-game-assets/references/responsible-ads.md +31 -0
  62. package/skills/ti-game-assets/references/sound-assets.md +42 -0
  63. package/skills/ti-game-assets/references/vector-sources.md +21 -0
  64. package/skills/ti-game-assets/scripts/alpha_tools.py +160 -0
  65. package/skills/ti-game-assets/scripts/requirements.txt +1 -0
  66. package/skills/ti-game-assets/scripts/validate_manifest.py +267 -0
  67. package/skills/ti-synthengine/SKILL.md +77 -67
  68. package/skills/ti-synthengine/assets/example/README.md +84 -0
  69. package/skills/ti-synthengine/assets/example/app.js +1178 -0
  70. package/skills/ti-synthengine/assets/example/lib/sequenceOwner.js +83 -0
  71. package/skills/ti-synthengine/assets/example/lib/sequenceScore.js +25 -0
  72. package/skills/ti-synthengine/references/advanced-examples.md +174 -0
  73. package/skills/ti-synthengine/references/android-sim80-correction-evidence.zip +0 -0
  74. package/skills/ti-synthengine/references/android-sim80-correction.json +162 -0
  75. package/skills/ti-synthengine/references/api.md +573 -153
  76. package/skills/ti-synthengine/references/audio-engine.md +272 -124
  77. package/skills/ti-synthengine/references/bypass-physical-recheck-evidence.zip +0 -0
  78. package/skills/ti-synthengine/references/examples.md +355 -106
  79. package/skills/ti-synthengine/references/frontier-android-evidence.zip +0 -0
  80. package/skills/ti-synthengine/references/frontier-ipad-evidence.zip +0 -0
  81. package/skills/ti-synthengine/references/frontier-physical-validation.json +1918 -0
  82. package/skills/ti-synthengine/references/guide.md +426 -75
  83. package/skills/ti-synthengine/references/performance-review-evidence.zip +0 -0
  84. package/skills/ti-synthengine/references/performance-review.json +1146 -0
  85. package/skills/ti-synthengine/references/physical-validation.json +1457 -0
  86. package/skills/ti-synthengine/references/recipes-interaction.md +549 -0
  87. package/skills/ti-synthengine/references/recipes.md +434 -420
  88. package/skills/ti-synthengine/references/recommendations.md +72 -85
  89. package/skills/ti-synthengine/references/sound-design.md +375 -155
  90. package/skills/ti-synthengine/references/source-notes.md +109 -0
  91. package/skills/ti-synthengine/references/source-snapshot.json +452 -0
  92. package/skills/ti-synthengine/references/stats-performance.json +2992 -0
  93. package/skills/ti-synthengine/references/stats-performance.md +82 -0
  94. package/skills/ti-synthengine/references/studio-validation.json +6202 -0
  95. package/skills/ti-synthengine/references/troubleshooting.md +123 -78
  96. package/skills/ti-synthengine/references/unified-bypass-performance.json +22425 -0
  97. package/skills/ti-synthengine/references/unified-validation.json +314 -0
  98. package/AGENTS-VERCEL-RESEARCH.md +0 -175
@@ -0,0 +1,21 @@
1
+ # Editable vector and procedural sources
2
+
3
+ Use an existing SVG/procedural workflow when the project uses it or the user requests editable art generated with code. Integrated `image_gen` remains the default for raster drawing creation/edits; it is not a requirement to replace authored vectors. Rasterizing an SVG is an export step, not image-model generation.
4
+
5
+ ## Source of truth
6
+
7
+ Keep the editable SVG, generator and parameters as canonical sources; retain individual full-canvas PNG renders as reproducible derivatives. Avoid making a generated PNG the only editable master. Preserve original/legacy source when it has a documented purpose. Name symbols and groups by meaningful parts (hull, cockpit, lights, propulsion, appendages), with explicit `viewBox`, proportions and pivots. Parameterize palette, poses and damage overlays without shifting silhouette or anchor accidentally.
8
+
9
+ Record deterministic seeds for procedural distributions. Use a maintained SVG rasterizer already in the project, such as `rsvg-convert`/librsvg, and record its version, font dependencies, antialiasing and target dimensions. Rasterizer changes can change bytes: regenerate and review the affected set rather than merely updating hashes. Avoid hidden system-font or external-image dependencies in gameplay graphics.
10
+
11
+ Reuse a build that emits PNG sheets, runtime catalogs/frame maps, source/generator/output hashes, contact sheets and animation/parallax previews together. The runtime consumes generated PNG/catalog data, not the offline generator. Verify missing/empty cells, frame bounds, dimensions, anchor consistency, stale output hashes and repeat seams. Run it twice in a disposable output directory to check determinism in a fixed environment. Do not infer human authorship from a PNG compression filter or overwrite policy: preserve authored assets through explicit provenance.
12
+
13
+ Render at the declared logical proportions and preview at actual game sizes, in grayscale and relevant color-vision simulations. Distinguish roles by silhouette/symbol as well as color. Preserve warning readability under reduced-flash settings; do not silently change mechanics, flashes or accessibility preferences while exporting.
14
+
15
+ ## Manifest extension
16
+
17
+ Keep `source` pointing at the PNG render inspected by the bundled validator; add `vector_source` pointing at its existing SVG master. The validator checks an SVG root and finite positive `viewBox` extents, not vector aesthetics or generation dependencies. Record generator, parameters and render settings in provenance. Optional top-level `build.files` maps source/generator/output paths to SHA-256 hashes; validation detects modified declared files. Include *all* relevant dependencies, not just outputs. Hash agreement does not prove a build was run or that a rasterizer is correct.
18
+
19
+ ## Evidence from Guardian
20
+
21
+ `docs/art/source/guardian-parts.svg` holds named symbols/editable groups. `docs/scripts/generate-art.js` creates PNGs, runtime catalog and review sheets. `docs/scripts/verify-art.js` checks hashes, cells, animation frames and background edges. `docs/art/README.md` documents the renderer and why its high-resolution antialiased art uses smoothing. These are reusable design choices, not a requirement to copy its cell sizes, frame counts or art.
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect alpha; explicitly remove reviewed exterior residue into new files."""
3
+ import argparse
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+
8
+ from PIL import Image, ImageDraw
9
+
10
+ THRESHOLDS = (1, 4, 8, 16, 32)
11
+
12
+
13
+ def bbox(alpha, threshold):
14
+ return alpha.point(lambda value: 255 if value >= threshold else 0).getbbox()
15
+
16
+
17
+ def analyze(image):
18
+ alpha = image.getchannel('A')
19
+ histogram = alpha.histogram()
20
+ return {
21
+ 'dimensions': list(image.size),
22
+ 'alpha_range': list(alpha.getextrema()),
23
+ 'translucent_pixels': sum(histogram[1:255]),
24
+ 'visible_pixels': sum(histogram[1:]),
25
+ 'bounding_boxes': {str(t): bbox(alpha, t) for t in THRESHOLDS},
26
+ }
27
+
28
+
29
+ def rectangle(values, size):
30
+ x0, y0, x1, y1 = values
31
+ if not (0 <= x0 < x1 <= size[0] and 0 <= y0 < y1 <= size[1]):
32
+ raise ValueError('rectangle must be nonempty and inside the source canvas')
33
+ return tuple(values)
34
+
35
+
36
+ def clean(image, protected):
37
+ alpha = image.getchannel('A')
38
+ retained = Image.new('L', image.size, 0)
39
+ retained.paste(alpha.crop(protected), protected[:2])
40
+ result = image.copy()
41
+ result.putalpha(retained)
42
+ return result
43
+
44
+
45
+ def preview(before, after=None):
46
+ images = [before] if after is None else [before, after]
47
+ cell = 280
48
+ output = Image.new('RGB', (cell * len(images), (cell + 24) * 3), 'white')
49
+ draw = ImageDraw.Draw(output)
50
+ for column, source in enumerate(images):
51
+ scaled = source.copy()
52
+ scaled.thumbnail((cell, cell), Image.Resampling.NEAREST)
53
+ for row, background in enumerate(('checker', '#eeeeee', '#181818')):
54
+ x, y = column * cell, row * (cell + 24) + 24
55
+ panel = Image.new('RGBA', (cell, cell), background if row else '#cccccc')
56
+ if row == 0:
57
+ painter = ImageDraw.Draw(panel)
58
+ for cy in range(0, cell, 16):
59
+ for cx in range(0, cell, 16):
60
+ if (cx // 16 + cy // 16) % 2:
61
+ painter.rectangle((cx, cy, cx + 15, cy + 15), fill='#999999')
62
+ panel.alpha_composite(scaled)
63
+ output.paste(panel.convert('RGB'), (x, y))
64
+ draw.text((x + 4, y - 20), ('original' if column == 0 else 'candidate') + ' / ' + background, fill='black')
65
+ for threshold, color in zip(THRESHOLDS, ('red', 'orange', 'lime', 'cyan', 'magenta')):
66
+ box = bbox(source.getchannel('A'), threshold)
67
+ if box:
68
+ sx, sy = scaled.width / source.width, scaled.height / source.height
69
+ draw.rectangle((x + box[0] * sx, y + box[1] * sy,
70
+ x + (box[2] - 1) * sx, y + (box[3] - 1) * sy), outline=color)
71
+ return output
72
+
73
+
74
+ def main():
75
+ parser = argparse.ArgumentParser(description=__doc__)
76
+ parser.add_argument('source', type=Path)
77
+ parser.add_argument('--profile', choices=('pixel-art', 'hard-surface', 'soft-fx'), default='hard-surface')
78
+ parser.add_argument('--logical-size', nargs=2, type=int, metavar=('W', 'H'))
79
+ parser.add_argument('--threshold', type=int, default=8)
80
+ parser.add_argument('--protect', nargs=4, type=int, metavar=('X0', 'Y0', 'X1', 'Y1'))
81
+ parser.add_argument('--auto-protect', action='store_true')
82
+ parser.add_argument('--reason', help='reviewed residue and justification for the protected region')
83
+ parser.add_argument('--clean', type=Path, help='new PNG output; omit for analysis only')
84
+ crop = parser.add_mutually_exclusive_group()
85
+ crop.add_argument('--crop', action='store_true')
86
+ crop.add_argument('--crop-box', nargs=4, type=int, metavar=('X0', 'Y0', 'X1', 'Y1'))
87
+ parser.add_argument('--report', type=Path)
88
+ parser.add_argument('--preview', type=Path)
89
+ args = parser.parse_args()
90
+ try:
91
+ if not 1 <= args.threshold <= 255:
92
+ raise ValueError('threshold must be 1..255')
93
+ if args.logical_size and min(args.logical_size) <= 0:
94
+ raise ValueError('logical size must be positive')
95
+ if args.protect and args.auto_protect:
96
+ raise ValueError('choose explicit or automatic protection, not both')
97
+ if not args.clean and (args.crop or args.crop_box or args.protect or args.auto_protect):
98
+ raise ValueError('protection and crop flags require --clean')
99
+ outputs = [p.resolve() for p in (args.clean, args.report, args.preview) if p]
100
+ if len(outputs) != len(set(outputs)) or args.source.resolve() in outputs:
101
+ raise ValueError('outputs must be distinct from each other and the source')
102
+ if any(p.exists() for p in outputs):
103
+ raise ValueError('refusing to overwrite an existing output')
104
+ with Image.open(args.source) as opened:
105
+ source = opened.convert('RGBA')
106
+ report = {'source': str(args.source), 'profile': args.profile, 'analysis': analyze(source)}
107
+ candidate = None
108
+ if args.clean:
109
+ if not args.reason or not args.reason.strip():
110
+ raise ValueError('cleaning requires --reason documenting the review')
111
+ if args.profile == 'soft-fx' and (not args.protect or args.auto_protect):
112
+ raise ValueError('soft-fx requires an explicit protected region')
113
+ if args.profile == 'pixel-art' and not args.logical_size:
114
+ raise ValueError('pixel-art cleaning requires --logical-size')
115
+ padding = [math.ceil(source.size[i] / args.logical_size[i]) for i in (0, 1)] if args.logical_size else [1, 1]
116
+ if args.protect:
117
+ protected = rectangle(args.protect, source.size)
118
+ elif args.auto_protect:
119
+ detected = bbox(source.getchannel('A'), args.threshold)
120
+ if not detected:
121
+ raise ValueError('no content at threshold; preserve source and inspect lower thresholds')
122
+ protected = (max(0, detected[0] - padding[0]), max(0, detected[1] - padding[1]),
123
+ min(source.width, detected[2] + padding[0]), min(source.height, detected[3] + padding[1]))
124
+ else:
125
+ raise ValueError('cleaning requires --protect or reviewed --auto-protect')
126
+ candidate = clean(source, protected)
127
+ report['cleanup'] = {
128
+ 'protected_region': protected, 'threshold': args.threshold,
129
+ 'logical_size': args.logical_size, 'logical_pixel_padding': padding,
130
+ 'reason': args.reason, 'region_mode': 'explicit' if args.protect else 'automatic-trial',
131
+ 'removed_pixels': report['analysis']['visible_pixels'] - analyze(candidate)['visible_pixels'],
132
+ 'analysis': analyze(candidate),
133
+ }
134
+ trim = args.crop_box or (bbox(candidate.getchannel('A'), 1) if args.crop else None)
135
+ if (args.crop or args.crop_box) and not bbox(candidate.getchannel('A'), 1):
136
+ raise ValueError('cannot crop empty content')
137
+ if trim:
138
+ trim = rectangle(trim, source.size)
139
+ visible = bbox(candidate.getchannel('A'), 1)
140
+ if visible and not (trim[0] <= visible[0] and trim[1] <= visible[1] and trim[2] >= visible[2] and trim[3] >= visible[3]):
141
+ raise ValueError('crop box would discard visible content')
142
+ report['export'] = {'path': str(args.clean), 'offset': list(trim[:2]) if trim else [0, 0],
143
+ 'source_dimensions': list(source.size),
144
+ 'dimensions': [trim[2] - trim[0], trim[3] - trim[1]] if trim else list(source.size)}
145
+ # Build comparison before writing any output. It uses common source canvases.
146
+ comparison = preview(source, candidate) if args.preview else None
147
+ if args.clean:
148
+ (candidate.crop(trim) if trim else candidate).save(args.clean, format='PNG')
149
+ if args.preview:
150
+ comparison.save(args.preview, format='PNG')
151
+ encoded = json.dumps(report, indent=2)
152
+ if args.report:
153
+ args.report.write_text(encoded + '\n', encoding='utf-8')
154
+ print(encoded)
155
+ except (ValueError, OSError) as error:
156
+ parser.error(str(error))
157
+
158
+
159
+ if __name__ == '__main__':
160
+ main()
@@ -0,0 +1 @@
1
+ Pillow>=10
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """Validate per-set game art and sound contracts, files, frames and parallax."""
3
+ import argparse
4
+ from collections import Counter, defaultdict
5
+ import json
6
+ import hashlib
7
+ import re
8
+ import wave
9
+ import xml.etree.ElementTree as ET
10
+ import math
11
+ from pathlib import Path
12
+
13
+ from PIL import Image
14
+
15
+ FAMILIES = {'players', 'enemies', 'allies', 'obstacles', 'ammunition', 'rewards', 'ambient', 'backgrounds', 'technical'}
16
+
17
+
18
+ def numbers(value, length, positive=False):
19
+ return (isinstance(value, list) and len(value) == length
20
+ and all(type(n) in (int, float) and math.isfinite(n) and (not positive or n > 0) for n in value))
21
+
22
+
23
+ def dimensions(value):
24
+ return numbers(value, 2, True) and all(type(n) is int for n in value)
25
+
26
+
27
+ def validate(data, root):
28
+ errors = []
29
+ counts = []
30
+
31
+ def require(condition, message):
32
+ if not condition:
33
+ errors.append(message)
34
+
35
+ def source_path(value, label):
36
+ if not isinstance(value, str) or not value:
37
+ errors.append(label + ': missing source')
38
+ return None
39
+ path = (root / value).resolve()
40
+ if not path.is_file():
41
+ errors.append(label + ': source not found: ' + value)
42
+ return None
43
+ return path
44
+
45
+ build = data.get('build')
46
+ if build is not None:
47
+ files = build.get('files') if isinstance(build, dict) else None
48
+ require(isinstance(files, dict) and bool(files), 'build.files must be a nonempty path-to-SHA256 object')
49
+ for filename, digest in files.items() if isinstance(files, dict) else []:
50
+ valid_hash = isinstance(digest, str) and re.fullmatch(r'[0-9a-f]{64}', digest) is not None
51
+ require(valid_hash, 'build: invalid SHA256 for ' + filename)
52
+ path = source_path(filename, 'build')
53
+ if path and valid_hash:
54
+ require(hashlib.sha256(path.read_bytes()).hexdigest() == digest, 'build: stale source/output hash: ' + filename)
55
+
56
+ require(data.get('schema_version') == 1, 'schema_version must be 1')
57
+ contracts = data.get('contracts', {})
58
+ sets = data.get('sets', [])
59
+ require(isinstance(contracts, dict) and bool(contracts), 'contracts must be a nonempty object')
60
+ require(isinstance(sets, list) and bool(sets), 'sets must be a nonempty list')
61
+ if not isinstance(contracts, dict) or not isinstance(sets, list):
62
+ return errors, counts
63
+ seen_sets = set()
64
+ for group in sets:
65
+ if not isinstance(group, dict):
66
+ errors.append('set must be an object')
67
+ continue
68
+ name = group.get('id')
69
+ require(isinstance(name, str) and bool(name) and name not in seen_sets, 'set ids must be unique nonempty strings')
70
+ if not isinstance(name, str):
71
+ continue
72
+ seen_sets.add(name)
73
+ label = name + ': '
74
+ contract = contracts.get(group.get('contract'))
75
+ if not isinstance(contract, dict):
76
+ errors.append(label + 'unknown contract')
77
+ continue
78
+ expected = Counter()
79
+ definitions = contract.get('resources', [])
80
+ expected_sounds = Counter()
81
+ require(isinstance(definitions, list), label + 'contract resources must be a list')
82
+ for definition in definitions if isinstance(definitions, list) else []:
83
+ resource, family, variant, state = (definition.get(k) for k in ('resource', 'family', 'variant', 'state'))
84
+ frames = definition.get('frames')
85
+ if not all(isinstance(v, str) and v for v in (resource, family, variant, state)) or type(frames) is not int or frames < 1:
86
+ errors.append(label + 'invalid resource definition')
87
+ continue
88
+ require(family in FAMILIES, label + 'unknown resource family ' + family)
89
+ for frame in range(frames):
90
+ expected[(resource, family, variant, state, frame)] += 1
91
+ require(all(n == 1 for n in expected.values()), label + 'duplicate contract resource definition')
92
+ sound_defs = contract.get('sounds', [])
93
+ require(isinstance(sound_defs, list), label + 'contract sounds must be a list')
94
+ for definition in sound_defs if isinstance(sound_defs, list) else []:
95
+ sound, variants = definition.get('sound'), definition.get('variants')
96
+ if not isinstance(sound, str) or not sound or not isinstance(variants, list) or not variants or not all(isinstance(v, str) and v for v in variants):
97
+ errors.append(label + 'invalid sound definition')
98
+ continue
99
+ expected_sounds.update((sound, variant) for variant in variants)
100
+ require(all(n == 1 for n in expected_sounds.values()), label + 'duplicate sound contract variant')
101
+ require(bool(expected or expected_sounds), label + 'contract must require visual or sound assets')
102
+ assets, sounds = group.get('assets', []), group.get('sounds', [])
103
+ require(isinstance(assets, list) and isinstance(sounds, list), label + 'assets and sounds must be lists')
104
+ if not isinstance(assets, list) or not isinstance(sounds, list):
105
+ continue
106
+ actual = Counter()
107
+ ids = set()
108
+ asset_by_id = {}
109
+ alignment = defaultdict(list)
110
+ sequences = defaultdict(list)
111
+ for asset in assets:
112
+ identity = asset.get('id')
113
+ require(isinstance(identity, str) and bool(identity) and identity not in ids, label + 'asset ids must be unique strings')
114
+ if not isinstance(identity, str):
115
+ continue
116
+ ids.add(identity)
117
+ asset_by_id[identity] = asset
118
+ fields = [asset.get(k) for k in ('resource', 'family', 'variant', 'state')]
119
+ frame = asset.get('frame')
120
+ if not all(isinstance(v, str) and v for v in fields) or type(frame) is not int or frame < 0:
121
+ errors.append(label + identity + ': invalid identity/frame')
122
+ continue
123
+ actual[tuple(fields) + (frame,)] += 1
124
+ sequences[tuple(fields)].append(frame)
125
+ size, canvas, pivot = (asset.get(k) for k in ('dimensions', 'canvas', 'pivot'))
126
+ require(dimensions(size), label + identity + ': dimensions must be positive integers')
127
+ require(dimensions(canvas), label + identity + ': canvas must be positive integers')
128
+ require(numbers(pivot, 2), label + identity + ': pivot must be finite coordinates')
129
+ require(size == canvas, label + identity + ': master must retain its common canvas')
130
+ if dimensions(canvas) and numbers(pivot, 2):
131
+ require(all(0 <= pivot[i] <= canvas[i] for i in (0, 1)), label + identity + ': pivot outside canvas')
132
+ alignment[tuple(fields[:3])].append((canvas, pivot, asset.get('logical_size')))
133
+ require(type(asset.get('transparency')) is bool, label + identity + ': transparency must be Boolean')
134
+ style = asset.get('style')
135
+ require(style in ('pixel-art', 'hard-surface', 'soft-fx', 'hd'), label + identity + ': unknown style')
136
+ if style == 'pixel-art':
137
+ require(dimensions(asset.get('logical_size')), label + identity + ': pixel-art requires logical_size')
138
+ require(isinstance(asset.get('provenance'), dict) and bool(asset['provenance'].get('prompt')),
139
+ label + identity + ': provenance.prompt required')
140
+ if 'vector_source' in asset:
141
+ vector = source_path(asset['vector_source'], label + identity + ' vector')
142
+ if vector:
143
+ try:
144
+ svg = ET.parse(vector).getroot()
145
+ require(svg.tag in ('svg', '{http://www.w3.org/2000/svg}svg'), label + identity + ': vector source must have SVG root')
146
+ viewbox = [float(n) for n in svg.get('viewBox', '').replace(',', ' ').split()]
147
+ require(numbers(viewbox, 4) and viewbox[2] > 0 and viewbox[3] > 0,
148
+ label + identity + ': SVG requires finite viewBox with positive extents')
149
+ except (ET.ParseError, ValueError, OSError) as error:
150
+ errors.append(label + identity + ': unreadable SVG: ' + str(error))
151
+ path = source_path(asset.get('source'), label + identity)
152
+ if path:
153
+ try:
154
+ with Image.open(path) as image:
155
+ require(image.format == 'PNG', label + identity + ': master must be PNG')
156
+ require(list(image.size) == size, label + identity + ': source dimensions mismatch')
157
+ alpha = image.convert('RGBA').getchannel('A')
158
+ require(alpha.getextrema()[1] > 0, label + identity + ': empty image')
159
+ if asset.get('transparency') is True:
160
+ require(('A' in image.getbands() or 'transparency' in image.info) and alpha.getextrema()[0] < 255,
161
+ label + identity + ': real transparency required')
162
+ elif asset.get('transparency') is False:
163
+ require(alpha.getextrema()[0] == 255, label + identity + ': declared opaque source contains transparency')
164
+ except OSError as error:
165
+ errors.append(label + identity + ': unreadable image: ' + str(error))
166
+ require(actual == expected, label + 'resource/frame contract incomplete or contains unexpected/duplicate entries')
167
+ for key, frames in sequences.items():
168
+ require(frames == list(range(len(frames))), label + str(key) + ': frames must appear in contiguous order from zero')
169
+ for key, values in alignment.items():
170
+ require(all(value == values[0] for value in values), label + str(key) + ': states/frames must share canvas, pivot and logical size')
171
+ audio_ids = set()
172
+ actual_sounds = Counter()
173
+ for sound in sounds:
174
+ identity = sound.get('id')
175
+ require(isinstance(identity, str) and bool(identity) and identity not in audio_ids and identity not in ids,
176
+ label + 'sound ids must be unique and distinct from images')
177
+ if not isinstance(identity, str):
178
+ continue
179
+ audio_ids.add(identity)
180
+ key = (sound.get('sound'), sound.get('variant'))
181
+ if not all(isinstance(v, str) and v for v in key):
182
+ errors.append(label + identity + ': invalid sound/variant')
183
+ continue
184
+ actual_sounds[key] += 1
185
+ source_path(sound.get('source'), label + identity)
186
+ if 'wav' in sound:
187
+ wav = sound['wav']
188
+ if not isinstance(wav, dict):
189
+ errors.append(label + identity + ': wav must be an object')
190
+ else:
191
+ fields = ('sample_rate', 'channels', 'sample_width', 'frames')
192
+ require(all(type(wav.get(k)) is int and wav[k] > 0 for k in fields), label + identity + ': WAV format fields must be positive integers')
193
+ require(type(wav.get('loop')) is bool, label + identity + ': WAV loop must be Boolean')
194
+ file = source_path(wav.get('file'), label + identity + ' WAV')
195
+ if file:
196
+ try:
197
+ with wave.open(str(file), 'rb') as audio:
198
+ actual_format = (audio.getframerate(), audio.getnchannels(), audio.getsampwidth(), audio.getnframes())
199
+ require(actual_format == tuple(wav.get(k) for k in fields), label + identity + ': WAV header differs from contract')
200
+ require(audio.getcomptype() == 'NONE' and audio.getnframes() > 0, label + identity + ': WAV must be nonempty PCM')
201
+ expected_bytes = audio.getnframes() * audio.getnchannels() * audio.getsampwidth()
202
+ # Stream the data to avoid loading an entire music file into memory.
203
+ actual_bytes = 0
204
+ while block := audio.readframes(65536):
205
+ actual_bytes += len(block)
206
+ require(actual_bytes == expected_bytes, label + identity + ': truncated WAV payload')
207
+ except (wave.Error, EOFError, OSError) as error:
208
+ errors.append(label + identity + ': unreadable WAV: ' + str(error))
209
+ require(isinstance(sound.get('reference'), str) and bool(sound['reference']), label + identity + ': recipe/pattern reference required')
210
+ require(sound.get('kind') in ('effect', 'sustained', 'music', 'ambient', 'ui'), label + identity + ': unknown sound kind')
211
+ require(isinstance(sound.get('design'), dict) and bool(sound['design']), label + identity + ': sound design required')
212
+ require(isinstance(sound.get('provenance'), dict) and bool(sound['provenance']), label + identity + ': sound provenance required')
213
+ require(actual_sounds == expected_sounds, label + 'sound contract incomplete or contains unexpected/duplicate variants')
214
+ aliases = group.get('aliases', {})
215
+ require(isinstance(aliases, dict), label + 'aliases must be an object')
216
+ if isinstance(aliases, dict):
217
+ for alias, target in aliases.items():
218
+ require(alias not in ids | audio_ids and isinstance(target, str) and target in ids | audio_ids,
219
+ label + alias + ': alias must directly reference an existing asset or sound without masking an id')
220
+ layers = group.get('parallax', [])
221
+ require(isinstance(layers, list), label + 'parallax must be a list')
222
+ if layers or contract.get('parallax'):
223
+ require(isinstance(layers, list) and len(layers) == 3 and {layer.get('depth') for layer in layers} == {'far', 'middle', 'near'},
224
+ label + 'parallax requires far, middle and near layers')
225
+ if isinstance(layers, list):
226
+ layer_assets = [layer.get('asset') for layer in layers]
227
+ require(len(set(layer_assets)) == 3, label + 'parallax layers must be independent assets')
228
+ orders = {}
229
+ for layer in layers:
230
+ asset = asset_by_id.get(layer.get('asset'))
231
+ require(bool(asset) and asset.get('family') == 'backgrounds', label + 'parallax references unknown/non-background asset')
232
+ require(type(layer.get('transparent')) is bool and asset is not None and asset.get('transparency') == layer.get('transparent'),
233
+ label + 'parallax transparency must match its asset')
234
+ require(layer.get('repeat') in ('none', 'x', 'y', 'xy'), label + 'invalid parallax repeat')
235
+ require(numbers(layer.get('displacement'), 2), label + 'parallax displacement must be finite x/y')
236
+ order = layer.get('order')
237
+ require(type(order) is int, label + 'parallax order must be integer')
238
+ orders[layer.get('depth')] = order
239
+ if all(type(orders.get(k)) is int for k in ('far', 'middle', 'near')):
240
+ require(orders['far'] < orders['middle'] < orders['near'], label + 'parallax orders must increase with depth')
241
+ for event in group.get('events', []):
242
+ require(isinstance(event.get('event'), str) and bool(event['event']), label + 'semantic event required')
243
+ require(event.get('visual') in ids and event.get('sound') in audio_ids, label + 'event references unknown visual/sound')
244
+ require(event.get('timing') in ('discrete', 'sustained', 'music'), label + 'event timing must be discrete, sustained or music (never each frame)')
245
+ counts.append({'set': name, 'contract': group.get('contract'), 'unique_resources': len({k[0] for k in actual}),
246
+ 'png_frames': len(assets), 'sound_variants': len(sounds), 'aliases': len(aliases)})
247
+ return errors, counts
248
+
249
+
250
+ def main():
251
+ parser = argparse.ArgumentParser(description=__doc__)
252
+ parser.add_argument('manifest', type=Path)
253
+ parser.add_argument('--root', type=Path, help='source root; defaults to manifest directory')
254
+ args = parser.parse_args()
255
+ try:
256
+ data = json.loads(args.manifest.read_text(encoding='utf-8'))
257
+ if not isinstance(data, dict):
258
+ raise ValueError('manifest must be an object')
259
+ errors, counts = validate(data, args.root or args.manifest.parent)
260
+ except (ValueError, OSError, TypeError, KeyError, AttributeError) as error:
261
+ errors, counts = ['malformed manifest: ' + str(error)], []
262
+ print(json.dumps({'valid': not errors, 'counts': counts, 'errors': errors}, indent=2))
263
+ raise SystemExit(1 if errors else 0)
264
+
265
+
266
+ if __name__ == '__main__':
267
+ main()