459 lines
19 KiB
Java
459 lines
19 KiB
Java
package p.packer.services;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import p.packer.messages.assets.AssetFamilyCatalog;
|
|
import p.packer.messages.assets.OutputCodecCatalog;
|
|
import p.packer.messages.assets.OutputFormatCatalog;
|
|
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
|
|
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
|
|
import p.packer.models.PackerAssetArtifactSelection;
|
|
import p.packer.models.PackerAssetDeclaration;
|
|
import p.packer.models.PackerAssetDeclarationParseResult;
|
|
import p.packer.models.PackerDiagnostic;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Path;
|
|
import java.util.*;
|
|
|
|
public final class PackerAssetDeclarationParser {
|
|
private static final Set<Integer> SUPPORTED_SCHEMA_VERSIONS = Set.of(1);
|
|
private static final int MAX_PIPELINE_PALETTES = 64;
|
|
|
|
private final ObjectMapper mapper;
|
|
|
|
public PackerAssetDeclarationParser(ObjectMapper mapper) {
|
|
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
|
}
|
|
|
|
public PackerAssetDeclarationParseResult parse(final Path assetManifestPath) {
|
|
final var manifestPath = Objects.requireNonNull(assetManifestPath, "assetManifestPath").toAbsolutePath().normalize();
|
|
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
|
|
final JsonNode root;
|
|
try {
|
|
root = mapper.readTree(manifestPath.toFile());
|
|
} catch (IOException exception) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Unable to parse asset.json: " + exception.getMessage(),
|
|
manifestPath,
|
|
true));
|
|
return new PackerAssetDeclarationParseResult(null, diagnostics);
|
|
}
|
|
|
|
final var schemaVersion = requiredInt(root, "schema_version", diagnostics, manifestPath);
|
|
final var assetUuid = requiredText(root, "asset_uuid", diagnostics, manifestPath);
|
|
final var name = requiredText(root, "name", diagnostics, manifestPath);
|
|
final var assetFamily = requiredAssetFamily(root, diagnostics, manifestPath);
|
|
rejectLegacyInputs(root.path("inputs"), diagnostics, manifestPath);
|
|
final var artifacts = optionalArtifacts(root.path("artifacts"), diagnostics, manifestPath);
|
|
final var outputFormat = requiredOutputFormat(root.path("output"), diagnostics, manifestPath);
|
|
final var outputCodec = requiredOutputCodec(root.path("output"), diagnostics, manifestPath);
|
|
final var outputMetadata = optionalOutputMetadata(root.path("output"), diagnostics, manifestPath);
|
|
final var outputPipelineMetadata = optionalOutputPipelineMetadata(root.path("output"), diagnostics, manifestPath);
|
|
final var preloadEnabled = requiredBoolean(root.path("preload"), "enabled", diagnostics, manifestPath);
|
|
|
|
if (schemaVersion != null && !SUPPORTED_SCHEMA_VERSIONS.contains(schemaVersion)) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.VERSIONING,
|
|
"Unsupported asset.json schema_version: " + schemaVersion,
|
|
manifestPath,
|
|
true));
|
|
}
|
|
|
|
if (diagnostics.stream().anyMatch(PackerDiagnostic::blocking)) {
|
|
return new PackerAssetDeclarationParseResult(null, diagnostics);
|
|
}
|
|
|
|
return new PackerAssetDeclarationParseResult(
|
|
new PackerAssetDeclaration(
|
|
schemaVersion,
|
|
assetUuid,
|
|
name,
|
|
assetFamily,
|
|
artifacts,
|
|
outputFormat,
|
|
outputCodec,
|
|
outputMetadata,
|
|
outputPipelineMetadata,
|
|
preloadEnabled),
|
|
diagnostics);
|
|
}
|
|
|
|
private Integer requiredInt(
|
|
final JsonNode node,
|
|
final String fieldName,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode field = node.path(fieldName);
|
|
if (!field.isInt()) {
|
|
diagnostics.add(missingOrInvalid(fieldName, "integer", manifestPath));
|
|
return null;
|
|
}
|
|
return field.intValue();
|
|
}
|
|
|
|
private String requiredText(
|
|
final JsonNode node,
|
|
final String fieldName,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode field = node.path(fieldName);
|
|
if (!field.isTextual() || field.asText().isBlank()) {
|
|
diagnostics.add(missingOrInvalid(fieldName, "non-blank string", manifestPath));
|
|
return null;
|
|
}
|
|
return field.asText().trim();
|
|
}
|
|
|
|
private AssetFamilyCatalog requiredAssetFamily(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final String manifestType = requiredText(node, "type", diagnostics, manifestPath);
|
|
if (manifestType == null) {
|
|
return null;
|
|
}
|
|
final AssetFamilyCatalog assetFamily = AssetFamilyCatalog.fromManifestType(manifestType);
|
|
if (assetFamily == AssetFamilyCatalog.UNKNOWN) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'type' must be one of: glyph_bank, scene_bank, sound_bank.",
|
|
manifestPath,
|
|
true));
|
|
return null;
|
|
}
|
|
return assetFamily;
|
|
}
|
|
|
|
private Boolean requiredBoolean(
|
|
final JsonNode node,
|
|
final String fieldName,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode field = node.path(fieldName);
|
|
if (!field.isBoolean()) {
|
|
diagnostics.add(missingOrInvalid(fieldName, "boolean", manifestPath));
|
|
return null;
|
|
}
|
|
return field.booleanValue();
|
|
}
|
|
|
|
private OutputFormatCatalog requiredOutputFormat(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final String fmtValue = requiredText(node, "format", diagnostics, manifestPath);
|
|
if (fmtValue == null) {
|
|
return null;
|
|
}
|
|
final OutputFormatCatalog outputFormat = OutputFormatCatalog.fromManifestValue(fmtValue);
|
|
if (outputFormat == OutputFormatCatalog.UNKNOWN) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output' must have a valid value.",
|
|
manifestPath,
|
|
true));
|
|
return null;
|
|
}
|
|
return outputFormat;
|
|
}
|
|
|
|
private OutputCodecCatalog requiredOutputCodec(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final String codecValue = requiredText(node, "codec", diagnostics, manifestPath);
|
|
if (codecValue == null) {
|
|
return null;
|
|
}
|
|
final OutputCodecCatalog outputCodec = OutputCodecCatalog.fromManifestValue(codecValue);
|
|
if (outputCodec == OutputCodecCatalog.UNKNOWN) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'codec' must have a valid value.",
|
|
manifestPath,
|
|
true));
|
|
return null;
|
|
}
|
|
return outputCodec;
|
|
}
|
|
|
|
private Map<String, String> optionalOutputMetadata(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode metadataNode = node.path("metadata");
|
|
if (metadataNode.isMissingNode() || metadataNode.isNull()) {
|
|
return Map.of();
|
|
}
|
|
if (!metadataNode.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.metadata' must be a JSON object.",
|
|
manifestPath,
|
|
true));
|
|
return Map.of();
|
|
}
|
|
|
|
final Map<String, String> metadata = new LinkedHashMap<>();
|
|
metadataNode.fields().forEachRemaining(entry -> {
|
|
final String key = Objects.requireNonNullElse(entry.getKey(), "").trim();
|
|
if (key.isBlank()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.metadata' has an invalid empty key.",
|
|
manifestPath,
|
|
true));
|
|
return;
|
|
}
|
|
|
|
final JsonNode valueNode = entry.getValue();
|
|
if (valueNode == null || valueNode.isContainerNode()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.metadata' values must be scalar (text/number/boolean).",
|
|
manifestPath,
|
|
true));
|
|
return;
|
|
}
|
|
metadata.put(key, valueNode.asText());
|
|
});
|
|
|
|
return Map.copyOf(metadata);
|
|
}
|
|
|
|
private Map<String, JsonNode> optionalOutputPipelineMetadata(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode pipelineNode = node.path("pipeline");
|
|
if (pipelineNode.isMissingNode() || pipelineNode.isNull()) {
|
|
return Map.of();
|
|
}
|
|
if (!pipelineNode.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.pipeline' must be a JSON object.",
|
|
manifestPath,
|
|
true));
|
|
return Map.of();
|
|
}
|
|
|
|
final Map<String, JsonNode> pipelineMetadata = new LinkedHashMap<>();
|
|
pipelineNode.fields().forEachRemaining(entry -> {
|
|
final String key = Objects.requireNonNullElse(entry.getKey(), "").trim();
|
|
if (key.isBlank()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.pipeline' has an invalid empty key.",
|
|
manifestPath,
|
|
true));
|
|
return;
|
|
}
|
|
final JsonNode valueNode = entry.getValue();
|
|
if (valueNode == null) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.pipeline' values must be valid JSON values.",
|
|
manifestPath,
|
|
true));
|
|
return;
|
|
}
|
|
if ("palettes".equals(key)) {
|
|
pipelineMetadata.put(key, validatePaletteDeclarations(valueNode, diagnostics, manifestPath));
|
|
return;
|
|
}
|
|
pipelineMetadata.put(key, valueNode.deepCopy());
|
|
});
|
|
return Map.copyOf(pipelineMetadata);
|
|
}
|
|
|
|
private JsonNode validatePaletteDeclarations(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
if (!node.isArray()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.pipeline.palettes' must be an array of palette declarations.",
|
|
manifestPath,
|
|
true));
|
|
return node.deepCopy();
|
|
}
|
|
|
|
final Set<Integer> seenIndices = new HashSet<>();
|
|
int paletteCount = 0;
|
|
for (JsonNode entry : node) {
|
|
paletteCount += 1;
|
|
if (!entry.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each palette declaration must be an object with non-negative integer 'index' and object 'palette'.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
|
|
final JsonNode indexNode = entry.path("index");
|
|
final JsonNode paletteNode = entry.path("palette");
|
|
if (!indexNode.isInt() || indexNode.intValue() < 0) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each palette declaration must define a non-negative integer 'index'.",
|
|
manifestPath,
|
|
true));
|
|
} else if (!seenIndices.add(indexNode.intValue())) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.pipeline.palettes' contains duplicate palette index " + indexNode.intValue() + ".",
|
|
manifestPath,
|
|
true));
|
|
}
|
|
|
|
if (!paletteNode.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each palette declaration must define an object 'palette' payload.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
|
|
final JsonNode originalNode = paletteNode.path("originalArgb8888");
|
|
final JsonNode convertedNode = paletteNode.path("convertedRgb565");
|
|
if (!originalNode.isArray() || !convertedNode.isArray()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each palette payload must contain array fields 'originalArgb8888' and 'convertedRgb565'.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (paletteCount > MAX_PIPELINE_PALETTES) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.pipeline.palettes' cannot declare more than " + MAX_PIPELINE_PALETTES + " palettes.",
|
|
manifestPath,
|
|
true));
|
|
}
|
|
|
|
return node.deepCopy();
|
|
}
|
|
|
|
private void rejectLegacyInputs(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
if (node.isMissingNode() || node.isNull()) {
|
|
return;
|
|
}
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'inputs' is no longer supported; use 'artifacts' instead.",
|
|
manifestPath,
|
|
true));
|
|
}
|
|
|
|
private List<PackerAssetArtifactSelection> optionalArtifacts(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
if (node.isMissingNode() || node.isNull()) {
|
|
return List.of();
|
|
}
|
|
if (!node.isArray()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'artifacts' must be an array.",
|
|
manifestPath,
|
|
true));
|
|
return List.of();
|
|
}
|
|
|
|
final List<PackerAssetArtifactSelection> result = new ArrayList<>();
|
|
for (JsonNode entry : node) {
|
|
if (!entry.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each artifact entry must be an object with 'file' and 'index'.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
final JsonNode artifactIdNode = entry.path("artifact_id");
|
|
final JsonNode fileNode = entry.path("file");
|
|
final JsonNode indexNode = entry.path("index");
|
|
final JsonNode fingerprintNode = entry.path("fingerprint");
|
|
if (!fileNode.isTextual() || fileNode.asText().isBlank() || !indexNode.isInt() || indexNode.asInt() < 0) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each artifact entry must define a non-blank 'file' and a non-negative integer 'index'.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
final String relativePath = fileNode.asText().trim();
|
|
if (!isTrustedRelativePath(relativePath)) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Artifact file path must stay inside the asset root.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
final String artifactId = artifactIdNode.isTextual() && !artifactIdNode.asText().isBlank()
|
|
? artifactIdNode.asText().trim()
|
|
: null;
|
|
final String fingerprint = fingerprintNode.isTextual() && !fingerprintNode.asText().isBlank()
|
|
? fingerprintNode.asText().trim()
|
|
: null;
|
|
result.add(new PackerAssetArtifactSelection(artifactId, relativePath, indexNode.asInt(), fingerprint));
|
|
}
|
|
result.sort(Comparator.comparingInt(PackerAssetArtifactSelection::index));
|
|
return List.copyOf(result);
|
|
}
|
|
|
|
private boolean isTrustedRelativePath(final String value) {
|
|
final Path path = Path.of(value).normalize();
|
|
return !path.isAbsolute() && !path.startsWith("..");
|
|
}
|
|
|
|
private PackerDiagnostic missingOrInvalid(
|
|
final String fieldName,
|
|
final String expected,
|
|
final Path manifestPath) {
|
|
return new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field '" + fieldName + "' must be a " + expected + ".",
|
|
manifestPath,
|
|
true);
|
|
}
|
|
}
|