Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@ pub fn build(b: *std.Build) void {
.{ .source = "src/ui/control_icons.zig" },
.{ .source = "src/ui/navigation_repeat.zig" },
.{ .source = "src/ui/persistent_settings.zig" },
.{ .source = "src/ui/port_shortcut.zig" },
.{ .source = "src/ui/cover_image.zig" },
.{ .source = "src/ui/stream_dimensions.zig" },
.{ .source = "src/media/rtp/h264_depacketizer.zig" },
.{ .source = "src/net/json_reader.zig" },
Expand Down
19 changes: 19 additions & 0 deletions packaging/portmaster/greenovercast/GreenOvercast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

umask 077

# Resolve this launcher before PortMaster's helpers run or the directory changes, so a
# relative invocation such as "bash ./GreenOvercast.sh" still names the real file.
greenovercast_launcher=$(realpath "${BASH_SOURCE[0]:-$0}" 2>/dev/null) || greenovercast_launcher=

XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}

Expand Down Expand Up @@ -94,6 +98,21 @@ export GREENOVERCAST_H264_BOOTSTRAP_FILE="$video_bootstrap_file"
export GREENOVERCAST_CATALOG_FILE="$catalog_file"
export GREENOVERCAST_SETTINGS_FILE="$settings_file"
export GREENOVERCAST_ARTWORK_CACHE_DIR="$artwork_cache_dir"

# Game shortcuts are written next to this launcher and run it again with a title id.
if [ -n "$greenovercast_launcher" ] && [ -f "$greenovercast_launcher" ]; then
export GREENOVERCAST_PORT_LAUNCHER="$greenovercast_launcher"
else
echo "Game shortcuts disabled: unable to resolve the launcher path." >&2
fi
# muOS shows port box art from its catalogue, the same folder PortMaster fills on install.
for ports_catalogue in "/run/muos/storage/info/catalogue/External - Ports" \
"/mnt/mmc/MUOS/info/catalogue/External - Ports"; do
if [ -d "$ports_catalogue/box" ]; then
export GREENOVERCAST_PORT_ARTWORK_DIR="$ports_catalogue/box"
break
fi
done
export GREENOVERCAST_CEDAR_LIBRARY="$GAMEDIR/libgreenovercast-cedar.so"
export GREENOVERCAST_MPP_LIBRARY="$GAMEDIR/libgreenovercast-mpp.so"
export LD_LIBRARY_PATH="$GAMEDIR:${LD_LIBRARY_PATH:-}"
Expand Down
58 changes: 58 additions & 0 deletions src/ui/artwork_loader.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const settings = @import("persistent_settings.zig");
const cover_image = @import("cover_image.zig");

const c = @cImport({
@cInclude("SDL2/SDL.h");
Expand Down Expand Up @@ -39,6 +40,57 @@ pub const Loader = struct {
self.thread = try std.Thread.spawn(.{}, worker, .{self});
}

/// Converts a title's cached artwork into a PNG sized for port frontends. Returns
/// null when the artwork has not been cached yet.
///
/// Runs on the caller's thread. It is only called when the player adds a shortcut,
/// and cached artwork is requested at w=256 (GeForce NOW) or 320x480 (Xbox), so the
/// decode, resize and encode are a one-off cost rather than per-frame work.
pub fn cachedCoverPng(self: *const Loader, allocator: std.mem.Allocator, product_id: []const u8) ?[]u8 {
Comment thread
Producdevity marked this conversation as resolved.
if (!settings.validProductId(product_id)) return null;
var path_buffer: [640]u8 = undefined;
const path = cachePath(self, product_id, &path_buffer) orelse return null;
// Not readFileAlloc: it sizes the buffer with File.stat(), which needs statx and
// fails on the 4.9 kernels these handhelds ship (see stat_compat.zig).
const data = readCache(allocator, path) catch |err| {
if (err != error.FileNotFound)
std.debug.print("Shortcut artwork unavailable: {s}\n", .{@errorName(err)});
return null;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
defer allocator.free(data);
var image = std.mem.zeroes(c.GoArtworkImage);
if (c.go_artwork_decode_jpeg(data.ptr, data.len, 1024, 1024, &image) != 0) {
std.debug.print("Shortcut artwork could not be decoded\n", .{});
return null;
}
defer c.go_artwork_image_destroy(&image);
const width: usize = @intCast(image.width);
const height: usize = @intCast(image.height);
const stride: usize = @intCast(image.stride);
const fitted = cover_image.fitWithin(
allocator,
image.pixels[0 .. stride * height],
width,
height,
stride,
cover_image.box_width,
cover_image.box_height,
) catch |err| {
std.debug.print("Shortcut artwork could not be resized ({d}x{d}, stride {d}): {s}\n", .{
width,
height,
stride,
@errorName(err),
});
return null;
};
defer fitted.deinit(allocator);
return cover_image.encodePng(allocator, fitted) catch |err| {
std.debug.print("Shortcut artwork could not be encoded: {s}\n", .{@errorName(err)});
return null;
};
}

pub fn request(self: *Loader, product_id: []const u8, url: []const u8) void {
if (!settings.validProductId(product_id) or url.len == 0 or url.len >= self.request_url.len) return;
self.mutex.lock();
Expand Down Expand Up @@ -245,6 +297,12 @@ fn cachePath(loader: *const Loader, product_id: []const u8, output: []u8) ?[]con
) catch null;
}

fn readCache(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
return file.readToEndAlloc(allocator, response_limit);
}

fn writeCache(path: []const u8, data: []const u8) void {
var temporary_buffer: [645]u8 = undefined;
const temporary = std.fmt.bufPrint(&temporary_buffer, "{s}.tmp", .{path}) catch return;
Expand Down
146 changes: 146 additions & 0 deletions src/ui/cover_image.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
const std = @import("std");

// PortMaster resizes muOS port artwork with "320x240>": fit inside the box,
// keep the aspect ratio, never enlarge.
pub const box_width = 320;
pub const box_height = 240;

pub const Image = struct {
pixels: []u8,
width: usize,
height: usize,

pub fn deinit(self: Image, allocator: std.mem.Allocator) void {
allocator.free(self.pixels);
}
};

pub fn fittedSize(width: usize, height: usize, max_width: usize, max_height: usize) [2]usize {
if (width <= max_width and height <= max_height) return .{ width, height };
if (width * max_height >= height * max_width)
return .{ max_width, @max(1, height * max_width / width) };
return .{ @max(1, width * max_height / height), max_height };
}

/// Box-filters a tightly or loosely packed RGB24 image into a new packed RGB24 image
/// that fits inside max_width x max_height.
pub fn fitWithin(
allocator: std.mem.Allocator,
pixels: []const u8,
width: usize,
height: usize,
stride: usize,
max_width: usize,
max_height: usize,
) !Image {
if (width == 0 or height == 0 or stride < width * 3 or
pixels.len < stride * (height - 1) + width * 3) return error.InvalidImage;
const size = fittedSize(width, height, max_width, max_height);
const output = try allocator.alloc(u8, size[0] * size[1] * 3);
for (0..size[1]) |y| {
const y0 = y * height / size[1];
const y1 = @max(y0 + 1, (y + 1) * height / size[1]);
for (0..size[0]) |x| {
const x0 = x * width / size[0];
const x1 = @max(x0 + 1, (x + 1) * width / size[0]);
var sum = [3]usize{ 0, 0, 0 };
for (y0..y1) |source_y| {
const row = pixels[source_y * stride ..];
for (x0..x1) |source_x| {
for (0..3) |channel| sum[channel] += row[source_x * 3 + channel];
}
}
const count = (y1 - y0) * (x1 - x0);
const target = (y * size[0] + x) * 3;
for (0..3) |channel| output[target + channel] = @intCast(sum[channel] / count);
}
}
return .{ .pixels = output, .width = size[0], .height = size[1] };
}

pub fn encodePng(allocator: std.mem.Allocator, image: Image) ![]u8 {
if (image.width == 0 or image.height == 0 or
image.width > std.math.maxInt(u31) or image.height > std.math.maxInt(u31) or
image.pixels.len != image.width * image.height * 3) return error.InvalidImage;

const row_bytes = image.width * 3;
const raw = try allocator.alloc(u8, (row_bytes + 1) * image.height);
defer allocator.free(raw);
for (0..image.height) |y| {
const target = raw[y * (row_bytes + 1) ..];
target[0] = 0;
@memcpy(target[1 .. row_bytes + 1], image.pixels[y * row_bytes ..][0..row_bytes]);
}

var compressed = std.ArrayList(u8).init(allocator);
defer compressed.deinit();
var source = std.io.fixedBufferStream(raw);
try std.compress.zlib.compress(source.reader(), compressed.writer(), .{});

var output = std.ArrayList(u8).init(allocator);
errdefer output.deinit();
try output.appendSlice("\x89PNG\r\n\x1a\n");
var header: [13]u8 = undefined;
std.mem.writeInt(u32, header[0..4], @intCast(image.width), .big);
std.mem.writeInt(u32, header[4..8], @intCast(image.height), .big);
header[8] = 8; // bit depth
header[9] = 2; // truecolour RGB
header[10] = 0;
header[11] = 0;
header[12] = 0;
try writeChunk(&output, "IHDR", &header);
try writeChunk(&output, "IDAT", compressed.items);
try writeChunk(&output, "IEND", "");
return output.toOwnedSlice();
}

fn writeChunk(output: *std.ArrayList(u8), kind: *const [4]u8, data: []const u8) !void {
var length: [4]u8 = undefined;
std.mem.writeInt(u32, &length, @intCast(data.len), .big);
try output.appendSlice(&length);
try output.appendSlice(kind);
try output.appendSlice(data);
var crc = std.hash.Crc32.init();
crc.update(kind);
crc.update(data);
var checksum: [4]u8 = undefined;
std.mem.writeInt(u32, &checksum, crc.final(), .big);
try output.appendSlice(&checksum);
}

test "fits portrait and landscape art inside the port artwork box without enlarging" {
try std.testing.expectEqual([2]usize{ 160, 240 }, fittedSize(256, 384, box_width, box_height));
try std.testing.expectEqual([2]usize{ 320, 180 }, fittedSize(1280, 720, box_width, box_height));
try std.testing.expectEqual([2]usize{ 100, 80 }, fittedSize(100, 80, box_width, box_height));
}

test "box filter averages each source block" {
const pixels = [_]u8{
0, 0, 0, 255, 255, 255,
255, 255, 255, 0, 0, 0,
};
const image = try fitWithin(std.testing.allocator, &pixels, 2, 2, 6, 1, 1);
defer image.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 1), image.width);
try std.testing.expectEqualSlices(u8, &.{ 127, 127, 127 }, image.pixels);
}

test "png carries the image header and decompressible rows" {
var pixels = [_]u8{ 10, 20, 30, 40, 50, 60 };
const png = try encodePng(std.testing.allocator, .{ .pixels = &pixels, .width = 2, .height = 1 });
defer std.testing.allocator.free(png);

try std.testing.expectEqualSlices(u8, "\x89PNG\r\n\x1a\n", png[0..8]);
try std.testing.expectEqualSlices(u8, "IHDR", png[12..16]);
try std.testing.expectEqual(@as(u32, 2), std.mem.readInt(u32, png[16..20], .big));
try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, png[20..24], .big));
try std.testing.expectEqualSlices(u8, "\x00\x00\x00\x00IEND\xae\x42\x60\x82", png[png.len - 12 ..]);

const idat_length = std.mem.readInt(u32, png[33..37], .big);
try std.testing.expectEqualSlices(u8, "IDAT", png[37..41]);
var compressed = std.io.fixedBufferStream(png[41 .. 41 + idat_length]);
var raw = std.ArrayList(u8).init(std.testing.allocator);
defer raw.deinit();
try std.compress.zlib.decompress(compressed.reader(), raw.writer());
try std.testing.expectEqualSlices(u8, &.{ 0, 10, 20, 30, 40, 50, 60 }, raw.items);
}
Loading