implements PR-34

This commit is contained in:
bQUARKz 2026-03-20 09:41:35 +00:00
parent 37b898a9bb
commit 99d1070c46
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
3 changed files with 270 additions and 1 deletions

View File

@ -1,5 +1,6 @@
package p.packer.repositories; package p.packer.repositories;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.collections4.MapUtils; import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@ -16,6 +17,8 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
public class PackerAssetWalker { public class PackerAssetWalker {
private static final int TILE_BANK_EMITTED_SHEET_SIZE = 256;
private static final int TILE_BANK_MAX_PALETTES = 64;
private static final Set<OutputFormatCatalog> TILE_BANK_SUPPORTED_FORMATS = Set.of( private static final Set<OutputFormatCatalog> TILE_BANK_SUPPORTED_FORMATS = Set.of(
OutputFormatCatalog.TILES_INDEXED_V1 OutputFormatCatalog.TILES_INDEXED_V1
); );
@ -65,8 +68,11 @@ public class PackerAssetWalker {
true)); true));
return new PackerWalkResult(List.of(), diagnostics); return new PackerWalkResult(List.of(), diagnostics);
} }
diagnostics.addAll(tileBankDeclarationDiagnostics(assetRoot, declaration, requirementBuildResult.requirements));
final var walkResult = tileBankWalker.walk(assetRoot, requirementBuildResult.requirements, priorAssetCache); final var walkResult = tileBankWalker.walk(assetRoot, requirementBuildResult.requirements, priorAssetCache);
diagnostics.addAll(walkResult.diagnostics()); diagnostics.addAll(walkResult.diagnostics());
diagnostics.addAll(tileBankSelectedArtifactDiagnostics(assetRoot, declaration, walkResult));
diagnostics.addAll(tileBankFragileIndexDiagnostics(declaration, walkResult));
return new PackerWalkResult(walkResult.probeResults(), diagnostics); return new PackerWalkResult(walkResult.probeResults(), diagnostics);
} }
case SOUND_BANK -> { case SOUND_BANK -> {
@ -135,6 +141,157 @@ public class PackerAssetWalker {
return RequirementBuildResult.success(new PackerTileBankRequirements(tileSize)); return RequirementBuildResult.success(new PackerTileBankRequirements(tileSize));
} }
private List<PackerDiagnostic> tileBankDeclarationDiagnostics(
final Path assetRoot,
final PackerAssetDeclaration declaration,
final PackerTileBankRequirements requirements) {
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
final List<PackerAssetArtifactSelection> artifacts = declaration.artifacts();
final Set<Integer> seenIndices = new java.util.HashSet<>();
for (int expectedIndex = 0; expectedIndex < artifacts.size(); expectedIndex += 1) {
final PackerAssetArtifactSelection artifact = artifacts.get(expectedIndex);
if (!seenIndices.add(artifact.index())) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Tile bank artifacts contain duplicate index " + artifact.index() + ".",
assetRoot,
true));
}
if (artifact.index() != expectedIndex) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Tile bank artifacts must form a contiguous index range starting at 0.",
assetRoot,
true));
break;
}
}
final int tileCapacity = (TILE_BANK_EMITTED_SHEET_SIZE / requirements.tileSize())
* (TILE_BANK_EMITTED_SHEET_SIZE / requirements.tileSize());
if (artifacts.size() > tileCapacity) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Tile bank exceeds emitted sheet capacity for tile_size "
+ requirements.tileSize() + "x" + requirements.tileSize()
+ ": capacity is " + tileCapacity + " tiles.",
assetRoot,
true));
}
final List<DeclaredPalette> declaredPalettes = declaredPalettes(declaration);
if (!artifacts.isEmpty() && declaredPalettes.isEmpty()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Tile bank must declare at least one palette in output.pipeline.palettes.",
assetRoot,
true));
}
if (declaredPalettes.size() > TILE_BANK_MAX_PALETTES) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Tile bank cannot declare more than " + TILE_BANK_MAX_PALETTES + " palettes.",
assetRoot,
true));
}
return diagnostics;
}
private List<PackerDiagnostic> tileBankSelectedArtifactDiagnostics(
final Path assetRoot,
final PackerAssetDeclaration declaration,
final PackerWalkResult walkResult) {
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
final Set<String> discoveredPaths = walkResult.probeResults().stream()
.map(result -> assetRoot.toAbsolutePath().normalize()
.relativize(result.fileProbe().path().toAbsolutePath().normalize())
.toString()
.replace('\\', '/'))
.collect(java.util.stream.Collectors.toSet());
declaration.artifacts().stream()
.map(PackerAssetArtifactSelection::file)
.filter(path -> !discoveredPaths.contains(path))
.forEach(path -> diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Selected tile artifact is missing or unsupported: " + path,
assetRoot.resolve(path),
true)));
return diagnostics;
}
private List<PackerDiagnostic> tileBankFragileIndexDiagnostics(
final PackerAssetDeclaration declaration,
final PackerWalkResult walkResult) {
final List<DeclaredPalette> declaredPalettes = declaredPalettes(declaration);
if (declaredPalettes.isEmpty()) {
return List.of();
}
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
for (PackerProbeResult probeResult : walkResult.probeResults()) {
if (probeResult.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) {
continue;
}
final Object tileValue = probeResult.metadata().get("tile");
if (!(tileValue instanceof PackerTileIndexedV1 tile)) {
continue;
}
int maxUsedIndex = 0;
for (byte value : tile.paletteIndices()) {
maxUsedIndex = Math.max(maxUsedIndex, Byte.toUnsignedInt(value));
}
if (maxUsedIndex == 0) {
continue;
}
final int requiredColorCount = maxUsedIndex;
final Optional<DeclaredPalette> fragilePalette = declaredPalettes.stream()
.filter(palette -> palette.colorCount() < requiredColorCount)
.findFirst();
if (fragilePalette.isEmpty()) {
continue;
}
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.WARNING,
PackerDiagnosticCategory.HYGIENE,
"Tile uses fragile palette indices up to " + requiredColorCount
+ ", but declared palette " + fragilePalette.get().index()
+ " only covers " + fragilePalette.get().colorCount() + " colors.",
probeResult.fileProbe().path(),
false));
}
return diagnostics;
}
private List<DeclaredPalette> declaredPalettes(final PackerAssetDeclaration declaration) {
final JsonNode palettesNode = declaration.outputPipelineMetadata().get("palettes");
if (!(palettesNode instanceof com.fasterxml.jackson.databind.node.ArrayNode palettesArray)) {
return List.of();
}
final List<DeclaredPalette> palettes = new ArrayList<>();
for (JsonNode paletteDeclaration : palettesArray) {
final JsonNode indexNode = paletteDeclaration.path("index");
final JsonNode paletteNode = paletteDeclaration.path("palette");
if (!indexNode.isInt() || !paletteNode.isObject()) {
continue;
}
final int originalCount = paletteNode.path("originalArgb8888").isArray()
? paletteNode.path("originalArgb8888").size()
: 0;
final int convertedCount = paletteNode.path("convertedRgb565").isArray()
? paletteNode.path("convertedRgb565").size()
: 0;
palettes.add(new DeclaredPalette(indexNode.intValue(), Math.min(originalCount, convertedCount)));
}
palettes.sort(java.util.Comparator.comparingInt(DeclaredPalette::index));
return List.copyOf(palettes);
}
private RequirementBuildResult<PackerSoundBankRequirements> buildSoundBankRequirements( private RequirementBuildResult<PackerSoundBankRequirements> buildSoundBankRequirements(
final PackerAssetDeclaration declaration, final PackerAssetDeclaration declaration,
final Map<String, String> metadata) { final Map<String, String> metadata) {
@ -169,4 +326,9 @@ public class PackerAssetWalker {
return StringUtils.isNotBlank(errorMessage); return StringUtils.isNotBlank(errorMessage);
} }
} }
private record DeclaredPalette(
int index,
int colorCount) {
}
} }

View File

@ -703,6 +703,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
final List<PackerDiagnostic> blockingDiagnostics = Stream.of( final List<PackerDiagnostic> blockingDiagnostics = Stream.of(
parsed.diagnostics(), parsed.diagnostics(),
runtimeAsset.walkDiagnostics(), runtimeAsset.walkDiagnostics(),
collectWalkFileDiagnostics(runtimeAsset),
identityMismatchDiagnostics(registryEntry, parsed, runtimeAsset.manifestPath())) identityMismatchDiagnostics(registryEntry, parsed, runtimeAsset.manifestPath()))
.flatMap(Collection::stream) .flatMap(Collection::stream)
.filter(PackerDiagnostic::blocking) .filter(PackerDiagnostic::blocking)
@ -715,6 +716,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
PackerReadMessageMapper.toDiagnosticDTOs(blockingDiagnostics)); PackerReadMessageMapper.toDiagnosticDTOs(blockingDiagnostics));
} }
private List<PackerDiagnostic> collectWalkFileDiagnostics(PackerRuntimeAsset runtimeAsset) {
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
for (PackerRuntimeWalkFile file : runtimeAsset.walkProjection().buildCandidateFiles()) {
diagnostics.addAll(file.diagnostics());
}
return diagnostics;
}
private void saveRuntimeCache(PackerProjectContext project, PackerRuntimeSnapshot snapshot) { private void saveRuntimeCache(PackerProjectContext project, PackerRuntimeSnapshot snapshot) {
cacheRepository.save(project, snapshot.cacheState()); cacheRepository.save(project, snapshot.cacheState());
} }

View File

@ -198,6 +198,91 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(result.assets().isEmpty()); assertTrue(result.assets().isEmpty());
} }
@Test
void packValidationBlocksTileBanksWithoutDeclaredPalettes() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-no-palettes"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
final Path manifestPath = assetRoot.resolve("asset.json");
writeTilePng(assetRoot.resolve("confirm.png"), 16);
final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
manifest.putObject("output").put("format", "TILES/indexed_v1").put("codec", "NONE");
final var artifacts = manifest.putArray("artifacts");
artifacts.addObject().put("file", "confirm.png").put("index", 0);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
final FileSystemPackerWorkspaceService service = service();
final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertFalse(result.canPack());
assertTrue(result.assets().getFirst().diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette")));
}
@Test
void packValidationIncludesBlockingFileScopedTileDiagnostics() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-file-diagnostics"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
final Path manifestPath = assetRoot.resolve("asset.json");
writeTilePng(assetRoot.resolve("oversized.png"), 32);
final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
final ObjectNode output = (ObjectNode) manifest.path("output");
output.putObject("metadata").put("tile_size", "16x16");
final ObjectNode pipeline = output.putObject("pipeline");
final var palettes = pipeline.putArray("palettes");
final ObjectNode palette = palettes.addObject();
palette.put("index", 0);
palette.putObject("palette")
.putArray("originalArgb8888").add(0xFFFF0000);
((ObjectNode) palette.path("palette"))
.putArray("convertedRgb565").add(0xF800);
final var artifacts = manifest.putArray("artifacts");
artifacts.addObject().put("file", "oversized.png").put("index", 0);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
final FileSystemPackerWorkspaceService service = service();
final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertFalse(result.canPack());
assertTrue(result.assets().getFirst().diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("Invalid tile dimensions for oversized.png")));
}
@Test
void fragileTileIndicesRemainWarningOnlyForValidation() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-fragile-indices"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
final Path manifestPath = assetRoot.resolve("asset.json");
writeTwoColorTilePng(assetRoot.resolve("checker.png"), 16);
final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
final ObjectNode output = (ObjectNode) manifest.path("output");
output.putObject("metadata").put("tile_size", "16x16");
final ObjectNode pipeline = output.putObject("pipeline");
final var palettes = pipeline.putArray("palettes");
final ObjectNode palette = palettes.addObject();
palette.put("index", 0);
palette.putObject("palette")
.putArray("originalArgb8888").add(0xFFFF0000);
((ObjectNode) palette.path("palette"))
.putArray("convertedRgb565").add(0xF800);
final var artifacts = manifest.putArray("artifacts");
artifacts.addObject().put("file", "checker.png").put("index", 0);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
final FileSystemPackerWorkspaceService service = service();
final var details = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forAssetId(1)));
final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.SUCCESS, validation.status());
assertTrue(validation.canPack());
assertTrue(details.diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("fragile palette indices")));
}
@Test @Test
void createsRegisteredAssetAndWritesManifest() throws Exception { void createsRegisteredAssetAndWritesManifest() throws Exception {
final Path projectRoot = tempDir.resolve("created"); final Path projectRoot = tempDir.resolve("created");
@ -478,7 +563,9 @@ final class FileSystemPackerWorkspaceServiceTest {
final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest( final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
project(projectRoot), project(projectRoot),
AssetReference.forAssetId(1))); AssetReference.forAssetId(1)));
assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status()); assertEquals(PackerOperationStatus.PARTIAL, detailsResult.status());
assertTrue(detailsResult.diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette")));
assertEquals( assertEquals(
List.of("cancel.png", "confirm.png"), List.of("cancel.png", "confirm.png"),
detailsResult.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList()); detailsResult.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList());
@ -1074,4 +1161,15 @@ final class FileSystemPackerWorkspaceServiceTest {
} }
ImageIO.write(image, "png", path.toFile()); ImageIO.write(image, "png", path.toFile());
} }
private void writeTwoColorTilePng(Path path, int tileSize) throws Exception {
Files.createDirectories(path.getParent());
final BufferedImage image = new BufferedImage(tileSize, tileSize, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < tileSize; y += 1) {
for (int x = 0; x < tileSize; x += 1) {
image.setRGB(x, y, (x + y) % 2 == 0 ? 0xFFFF0000 : 0xFF00FF00);
}
}
ImageIO.write(image, "png", path.toFile());
}
} }