diff --git a/docs/merges/2026-08-24-libultraship.md b/docs/merges/2026-08-24-libultraship.md
new file mode 100644
index 000000000..04231e405
--- /dev/null
+++ b/docs/merges/2026-08-24-libultraship.md
@@ -0,0 +1,20 @@
+# Upstream merge — libultraship — 2026-08-24
+
+> Auto-generated by `upstream-merge.yml`. Fill in the post-merge changes (build-fix
+> chain, COMBO_BUILD guards, runtime fixes) before merging, then link this file from
+> docs/UPSTREAM_MERGES.md. See that doc for the policy.
+
+### libultraship — `7cb10226e` → `62e973aeb` (1 merges)
+
+new commits
+
+```
+62e973ae Unify the pyramidlike test (#1239)
+```
+
+
+**Conflict surface** (our customized files upstream touched):
+
+```
+src/fast/interpreter.cpp
+```
diff --git a/libultraship/src/fast/interpreter.cpp b/libultraship/src/fast/interpreter.cpp
index 83e03e890..8394947f1 100644
--- a/libultraship/src/fast/interpreter.cpp
+++ b/libultraship/src/fast/interpreter.cpp
@@ -629,6 +629,16 @@ static uint32_t GetTileSizeFromCoordinates(float low, float high) {
return static_cast(lroundf((high - low + 4.0f) / 4.0f));
}
+// Loaded size is HD-scaled but the tile region is raw N64 texels; normalize before comparing.
+static bool IsPyramidLike(uint32_t width, uint32_t height, uint32_t tileW, uint32_t tileH, float hByteScale,
+ float vPixelScale) {
+ const float hs = hByteScale > 0.0f ? hByteScale : 1.0f;
+ const float vs = vPixelScale > 0.0f ? vPixelScale : 1.0f;
+ const uint32_t loadedPixels = static_cast(width / hs) * static_cast(height / vs);
+ const uint32_t renderedPixels = tileW * tileH;
+ return renderedPixels > 0 && loadedPixels > renderedPixels && loadedPixels * 8 < renderedPixels * 13; // < 1.625x
+}
+
void Interpreter::ImportTextureRgba16(int tile, bool importReplacement) {
const RawTexMetadata* metadata = &mRdp->loaded_texture[mRdp->texture_tile[tile].tmem_index].raw_tex_metadata;
const uint8_t* addr =
@@ -656,10 +666,7 @@ void Interpreter::ImportTextureRgba16(int tile, bool importReplacement) {
// skip both. CLAMP wrap mode always opts in.
uint32_t tile_w = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].uls, mRdp->texture_tile[tile].lrs);
uint32_t tile_h = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].ult, mRdp->texture_tile[tile].lrt);
- uint32_t loadedPixels = width * height;
- uint32_t renderedPixels = tile_w * tile_h;
- bool pyramidLike =
- renderedPixels > 0 && loadedPixels > renderedPixels && loadedPixels * 8 < renderedPixels * 13; // < 1.625x
+ bool pyramidLike = IsPyramidLike(width, height, tile_w, tile_h, metadata->h_byte_scale, metadata->v_pixel_scale);
bool clampS = (mRdp->texture_tile[tile].cms & G_TX_CLAMP) != 0;
bool clampT = (mRdp->texture_tile[tile].cmt & G_TX_CLAMP) != 0;
// A masked axis wraps every 2^mask texels, so trim an over-loaded texture back to that.
@@ -672,12 +679,10 @@ void Interpreter::ImportTextureRgba16(int tile, bool importReplacement) {
if (maskH != 0 && (1u << maskH) >= tile_h && (1u << maskH) < height) {
height = 1u << maskH;
}
- // HD replacement textures must still clamp to the rendered tile region
- bool isHd = metadata->h_byte_scale != 1 || metadata->v_pixel_scale != 1;
- if ((isHd || pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
+ if ((pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
width = tile_w;
}
- if ((isHd || pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
+ if ((pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
height = tile_h;
}
@@ -736,9 +741,7 @@ void Interpreter::ImportTextureRgba32(int tile, bool importReplacement) {
// skip both. CLAMP wrap mode always opts in.
uint32_t tile_w = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].uls, mRdp->texture_tile[tile].lrs);
uint32_t tile_h = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].ult, mRdp->texture_tile[tile].lrt);
- uint32_t loadedPixels = width * height;
- uint32_t renderedPixels = tile_w * tile_h;
- bool pyramidLike = renderedPixels > 0 && loadedPixels > renderedPixels && loadedPixels * 8 < renderedPixels * 13;
+ bool pyramidLike = IsPyramidLike(width, height, tile_w, tile_h, metadata->h_byte_scale, metadata->v_pixel_scale);
bool clampS = (mRdp->texture_tile[tile].cms & G_TX_CLAMP) != 0;
bool clampT = (mRdp->texture_tile[tile].cmt & G_TX_CLAMP) != 0;
// A masked axis wraps every 2^mask texels, so trim an over-loaded texture back to that.
@@ -751,12 +754,10 @@ void Interpreter::ImportTextureRgba32(int tile, bool importReplacement) {
if (maskH != 0 && (1u << maskH) >= tile_h && (1u << maskH) < height) {
height = 1u << maskH;
}
- // HD replacement textures must still clamp to the rendered tile region
- bool isHd = metadata->h_byte_scale != 1 || metadata->v_pixel_scale != 1;
- if ((isHd || pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
+ if ((pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
width = tile_w;
}
- if ((isHd || pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
+ if ((pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
height = tile_h;
}
@@ -1052,9 +1053,7 @@ void Interpreter::ImportTextureCi4(int tile, bool importReplacement) {
// skip both. CLAMP wrap mode always opts in.
uint32_t tile_w = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].uls, mRdp->texture_tile[tile].lrs);
uint32_t tile_h = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].ult, mRdp->texture_tile[tile].lrt);
- uint32_t loadedPixels = width * height;
- uint32_t renderedPixels = tile_w * tile_h;
- bool pyramidLike = renderedPixels > 0 && loadedPixels > renderedPixels && loadedPixels * 8 < renderedPixels * 13;
+ bool pyramidLike = IsPyramidLike(width, height, tile_w, tile_h, metadata->h_byte_scale, metadata->v_pixel_scale);
bool clampS = (mRdp->texture_tile[tile].cms & G_TX_CLAMP) != 0;
bool clampT = (mRdp->texture_tile[tile].cmt & G_TX_CLAMP) != 0;
// A masked axis wraps every 2^mask texels, so trim an over-loaded texture back to that.
@@ -1067,12 +1066,10 @@ void Interpreter::ImportTextureCi4(int tile, bool importReplacement) {
if (maskH != 0 && (1u << maskH) >= tile_h && (1u << maskH) < height) {
height = 1u << maskH;
}
- // HD replacement textures must still clamp to the rendered tile region
- bool isHd = metadata->h_byte_scale != 1 || metadata->v_pixel_scale != 1;
- if ((isHd || pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
+ if ((pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
width = tile_w;
}
- if ((isHd || pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
+ if ((pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
height = tile_h;
}
@@ -1156,9 +1153,7 @@ void Interpreter::ImportTextureCi8(int tile, bool importReplacement) {
// skip both. CLAMP wrap mode always opts in.
uint32_t tile_w = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].uls, mRdp->texture_tile[tile].lrs);
uint32_t tile_h = GetTileSizeFromCoordinates(mRdp->texture_tile[tile].ult, mRdp->texture_tile[tile].lrt);
- uint32_t loadedPixels = width * height;
- uint32_t renderedPixels = tile_w * tile_h;
- bool pyramidLike = renderedPixels > 0 && loadedPixels > renderedPixels && loadedPixels * 8 < renderedPixels * 13;
+ bool pyramidLike = IsPyramidLike(width, height, tile_w, tile_h, metadata->h_byte_scale, metadata->v_pixel_scale);
bool clampS = (mRdp->texture_tile[tile].cms & G_TX_CLAMP) != 0;
bool clampT = (mRdp->texture_tile[tile].cmt & G_TX_CLAMP) != 0;
// A masked axis wraps every 2^mask texels, so trim an over-loaded texture back to that.
@@ -1171,12 +1166,10 @@ void Interpreter::ImportTextureCi8(int tile, bool importReplacement) {
if (maskH != 0 && (1u << maskH) >= tile_h && (1u << maskH) < height) {
height = 1u << maskH;
}
- // HD replacement textures must still clamp to the rendered tile region
- bool isHd = metadata->h_byte_scale != 1 || metadata->v_pixel_scale != 1;
- if ((isHd || pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
+ if ((pyramidLike || clampS) && tile_w > 0 && tile_w < width) {
width = tile_w;
}
- if ((isHd || pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
+ if ((pyramidLike || clampT) && tile_h > 0 && tile_h < height) {
height = tile_h;
}
@@ -2061,9 +2054,9 @@ void Interpreter::GfxSpTri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx
// Same pyramid-like ratio gate as ImportTexture: only clamp when loaded pixels
// are close to rendered pixels (mipmap), not when much bigger (window scroll).
- uint32_t loadedPx = tex_width[i] * tex_height[i];
- uint32_t renderedPx = tex_width2[i] * tex_height2[i];
- bool pyrLike = renderedPx > 0 && loadedPx > renderedPx && loadedPx * 8 < renderedPx * 13;
+ const RawTexMetadata& triMeta = mRdp->loaded_texture[mRdp->texture_tile[tile].tmem_index].raw_tex_metadata;
+ bool pyrLike = IsPyramidLike(tex_width[i], tex_height[i], tex_width2[i], tex_height2[i],
+ triMeta.h_byte_scale, triMeta.v_pixel_scale);
// Same wrap-period trim as the import paths. The >= tex_width2 guard skips a stale
// mask left by an FB blit (the pause background), which would otherwise tile the FB.
uint32_t maskW = mRdp->texture_tile[tile].masks;
@@ -2074,13 +2067,10 @@ void Interpreter::GfxSpTri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx
if (maskH != 0 && (1u << maskH) >= tex_height2[i] && (1u << maskH) < tex_height[i]) {
tex_height[i] = 1u << maskH;
}
- // HD replacements must clamp to the tile region
- bool isHd = mRdp->loaded_texture[i].raw_tex_metadata.h_byte_scale != 1 ||
- mRdp->loaded_texture[i].raw_tex_metadata.v_pixel_scale != 1;
- if ((isHd || pyrLike || (cms & G_TX_CLAMP)) && tex_width2[i] > 0 && tex_width2[i] < tex_width[i]) {
+ if ((pyrLike || (cms & G_TX_CLAMP)) && tex_width2[i] > 0 && tex_width2[i] < tex_width[i]) {
tex_width[i] = tex_width2[i];
}
- if ((isHd || pyrLike || (cmt & G_TX_CLAMP)) && tex_height2[i] > 0 && tex_height2[i] < tex_height[i]) {
+ if ((pyrLike || (cmt & G_TX_CLAMP)) && tex_height2[i] > 0 && tex_height2[i] < tex_height[i]) {
tex_height[i] = tex_height2[i];
}
diff --git a/upstream-pins.json b/upstream-pins.json
index e6e1809b1..26615102d 100644
--- a/upstream-pins.json
+++ b/upstream-pins.json
@@ -7,8 +7,8 @@
"branch": "port-maintenance",
"prefix": "libultraship",
"subtree": "",
- "mergedSha": "7cb10226e",
- "mergedDate": "2026-08-19"
+ "mergedSha": "62e973aeb",
+ "mergedDate": "2026-08-24"
},
"soh": {
"remote": "https://github.com/HarbourMasters/Shipwright.git",