Ability to use indexed pngs as interface art (#454)

* Ability to load indexed pngs in game

* Switch 2-col inventory .frms to .png
This commit is contained in:
Mike Klaas
2026-06-02 23:48:08 -07:00
committed by GitHub
parent 9aa2ae4bbc
commit e51004a3d1
8 changed files with 203 additions and 14 deletions
+8
View File
@@ -163,6 +163,14 @@ Integrating Sfall goodies is the top priority. Quality of life updates are OK to
For current sfall compatibility status and the remaining work needed to close gaps, see [SFALL_COMPATIBILITY.md](SFALL_COMPATIBILITY.md).
## For modders
* Robust (though not 100%) Sfall opcode and hook support
* .ogg/.wav support for map music
* .png support for static assets (must be 8bit indexed)
* .zip support for .dat archives
* Working BIS mapper
## License
The source code is this repository is available under the [Sustainable Use License](LICENSE.md).
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+190 -9
View File
@@ -1,6 +1,9 @@
#include "art.h"
#include <lodepng.h>
#include <assert.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -134,6 +137,7 @@ static int* gArtCritterFidShoudRunData;
static std::unordered_map<std::string, std::shared_ptr<NamedCacheEntry>> gNamedArtCache;
constexpr int kNamedCacheMaxBytes = 32 * 1024 * 1024; // 32MB soft limit
constexpr size_t kMaxNamedPngPixels = 16 * 1024 * 1024;
static unsigned int gNamedArtCacheMruCounter = 0;
static int gNamedArtCacheCurrentBytes = 0;
@@ -1116,7 +1120,7 @@ static int artReadFrameData(unsigned char* data, File* stream, int count, int* p
// 0x419E1C
static int artReadHeader(Art* art, File* stream)
{
if (fileReadInt32(stream, &(art->field_0)) == -1) return -1;
if (fileReadInt32(stream, &(art->version)) == -1) return -1;
if (fileReadInt16(stream, &(art->framesPerSecond)) == -1) return -1;
if (fileReadInt16(stream, &(art->actionFrame)) == -1) return -1;
if (fileReadInt16(stream, &(art->frameCount)) == -1) return -1;
@@ -1133,13 +1137,166 @@ static int artReadHeader(Art* art, File* stream)
return 0;
}
// NOTE: Original function was slightly different, but never used. Basically
// it's a memory allocating variant of `artRead` (which reads data into given
// buffer). This function is useful to load custom `frm` files since `Art` now
// needs more memory then it's on-disk size (due to memory padding).
//
// 0x419EC0
Art* artLoad(const char* path)
static bool artPathHasExtension(const char* path, const char* extension)
{
size_t pathLength = strlen(path);
size_t extensionLength = strlen(extension);
if (pathLength < extensionLength) {
return false;
}
return compat_stricmp(path + pathLength - extensionLength, extension) == 0;
}
static bool artReadFile(const char* path, std::vector<unsigned char>& data)
{
int size = 0;
if (dbGetFileSize(path, &size) != 0 || size <= 0) {
return false;
}
data.resize(size);
return dbGetFileContents(path, data.data()) == 0;
}
static bool artUnpackIndexedPngPixels(const std::vector<unsigned char>& indexedData, unsigned width, unsigned height, unsigned bitdepth, unsigned char* output)
{
if (bitdepth != 8) {
return false;
}
size_t pixelCount = static_cast<size_t>(width) * static_cast<size_t>(height);
if (indexedData.size() < pixelCount) {
return false;
}
memcpy(output, indexedData.data(), pixelCount);
return true;
}
static bool artValidateIndexedPngHeader(const char* path, unsigned width, unsigned height, const LodePNGColorMode& color)
{
if (color.colortype != LCT_PALETTE) {
debugPrint("ART: PNG is not palette-indexed: %s\n", path);
return false;
}
if (color.bitdepth != 8) {
debugPrint("ART: indexed PNG bit depth must be 8: %s\n", path);
return false;
}
if (width == 0 || height == 0 || width > SHRT_MAX || height > SHRT_MAX) {
debugPrint("ART: invalid indexed PNG dimensions for %s: %ux%u\n", path, width, height);
return false;
}
size_t pixelCount = static_cast<size_t>(width) * static_cast<size_t>(height);
if (pixelCount > kMaxNamedPngPixels || pixelCount > INT_MAX) {
debugPrint("ART: indexed PNG is too large: %s\n", path);
return false;
}
return true;
}
static bool artIndexedPngHasSupportedTransparency(const LodePNGColorMode& color)
{
if (!lodepng_has_palette_alpha(&color)) {
return true;
}
for (size_t index = 0; index < color.palettesize; index++) {
unsigned char alpha = color.palette[index * 4 + 3];
if (index == 0) {
// palette index 0 is allowed to be either fully transparent or fully opaque
if (alpha != 0 && alpha != 255) {
return false;
}
} else if (alpha != 255) {
return false;
}
}
return true;
}
static Art* artLoadIndexedPng(const char* path)
{
std::vector<unsigned char> encoded;
if (!artReadFile(path, encoded)) {
return nullptr;
}
lodepng::State state;
state.decoder.color_convert = 0;
unsigned width = 0;
unsigned height = 0;
unsigned error = lodepng_inspect(&width, &height, &state, encoded.data(), encoded.size());
if (error != 0) {
debugPrint("ART: failed to inspect indexed PNG %s: %s\n", path, lodepng_error_text(error));
return nullptr;
}
if (!artValidateIndexedPngHeader(path, width, height, state.info_png.color)) {
return nullptr;
}
std::vector<unsigned char> indexedData;
error = lodepng::decode(indexedData, width, height, state, encoded);
if (error != 0) {
debugPrint("ART: failed to decode indexed PNG %s: %s\n", path, lodepng_error_text(error));
return nullptr;
}
if (!artIndexedPngHasSupportedTransparency(state.info_png.color)) {
debugPrint("ART: indexed PNG transparency is unsupported, reserve palette index 0 instead: %s\n", path);
return nullptr;
}
size_t pixelCount = static_cast<size_t>(width) * static_cast<size_t>(height);
Art header = {};
header.version = 4;
header.framesPerSecond = 10;
header.actionFrame = 0;
header.frameCount = 1;
header.dataSize = sizeof(ArtFrame) + static_cast<int>(pixelCount);
int currentPadding = paddingForSize(sizeof(Art));
for (int rotation = 0; rotation < ROTATION_COUNT; rotation++) {
header.dataOffsets[rotation] = 0;
header.padding[rotation] = currentPadding;
}
int dataSize = artGetDataSize(&header);
unsigned char* data = reinterpret_cast<unsigned char*>(internal_malloc(dataSize));
if (data == nullptr) {
return nullptr;
}
memset(data, 0, dataSize);
Art* art = reinterpret_cast<Art*>(data);
*art = header;
ArtFrame* frame = reinterpret_cast<ArtFrame*>(data + sizeof(Art) + art->padding[0]);
frame->width = static_cast<short>(width);
frame->height = static_cast<short>(height);
frame->size = static_cast<int>(pixelCount);
frame->x = 0;
frame->y = 0;
if (!artUnpackIndexedPngPixels(indexedData, width, height, state.info_png.color.bitdepth, reinterpret_cast<unsigned char*>(frame) + sizeof(ArtFrame))) {
debugPrint("ART: failed to read indexed PNG pixels: %s\n", path);
internal_free(data);
return nullptr;
}
return art;
}
static Art* artLoadFrm(const char* path)
{
File* stream = fileOpen(path, "rb");
if (stream == nullptr) {
@@ -1167,6 +1324,30 @@ Art* artLoad(const char* path)
return reinterpret_cast<Art*>(data);
}
// NOTE: Original function was slightly different, but never used. Basically
// it's a memory allocating variant of `artRead` (which reads data into given
// buffer). This function is useful to load custom `frm` files since `Art` now
// needs more memory then it's on-disk size (due to memory padding).
//
// 0x419EC0
Art* artLoad(const char* path)
{
if (path == nullptr) {
return nullptr;
}
if (artPathHasExtension(path, ".png")) {
return artLoadIndexedPng(path);
}
Art* art = artLoadFrm(path);
if (art != nullptr) {
return art;
}
return nullptr;
}
static Art* artLoadLocalized(const char* path)
{
const char* localizedPath;
@@ -1239,7 +1420,7 @@ int artWriteFrameData(unsigned char* data, File* stream, int count)
// 0x41A138
int artWriteHeader(Art* art, File* stream)
{
if (fileWriteInt32(stream, art->field_0) == -1) return -1;
if (fileWriteInt32(stream, art->version) == -1) return -1;
if (fileWriteInt16(stream, art->framesPerSecond) == -1) return -1;
if (fileWriteInt16(stream, art->actionFrame) == -1) return -1;
if (fileWriteInt16(stream, art->frameCount) == -1) return -1;
+1 -1
View File
@@ -69,7 +69,7 @@ typedef enum Background {
} Background;
typedef struct Art {
int field_0;
int version;
short framesPerSecond;
short actionFrame;
short frameCount;
+4 -4
View File
@@ -779,9 +779,9 @@ static void inventoryLootApplyLayout(int columns)
static void inventoryNormalLayoutUpdate()
{
int columns = inventoryChooseColumns(inventoryFrmImage, INVENTORY_WINDOW_WIDTH + INVENTORY_SLOT_WIDTH, INVENTORY_NORMAL_BACKGROUND_FRM_ID, "invbox2.frm");
int columns = inventoryChooseColumns(inventoryFrmImage, INVENTORY_WINDOW_WIDTH + INVENTORY_SLOT_WIDTH, INVENTORY_NORMAL_BACKGROUND_FRM_ID, "invbox2.png");
if (columns == 1) {
inventoryBackgroundLoad(inventoryFrmImage, INVENTORY_NORMAL_BACKGROUND_FRM_ID, "invbox2.frm", 1);
inventoryBackgroundLoad(inventoryFrmImage, INVENTORY_NORMAL_BACKGROUND_FRM_ID, "invbox2.png", 1);
}
inventoryNormalApplyLayout(columns);
@@ -789,9 +789,9 @@ static void inventoryNormalLayoutUpdate()
static void inventoryLootLayoutUpdate()
{
int columns = inventoryChooseColumns(inventoryLootFrmImage, INVENTORY_LOOT_WINDOW_WIDTH_EXPANDED, INVENTORY_LOOT_BACKGROUND_FRM_ID, "loot2.frm");
int columns = inventoryChooseColumns(inventoryLootFrmImage, INVENTORY_LOOT_WINDOW_WIDTH_EXPANDED, INVENTORY_LOOT_BACKGROUND_FRM_ID, "loot2.png");
if (columns == 1) {
inventoryBackgroundLoad(inventoryLootFrmImage, INVENTORY_LOOT_BACKGROUND_FRM_ID, "loot2.frm", 1);
inventoryBackgroundLoad(inventoryLootFrmImage, INVENTORY_LOOT_BACKGROUND_FRM_ID, "loot2.png", 1);
}
inventoryLootApplyLayout(columns);