Files
spoopyghosty0--frameport/tests/test_layer_emul.py
T
spoopyghosty0 33bb14e034 FrameBridge: cube-only frames as quads (#385); CI: lint, foveation test
HardBullet's loading frames submit only a cube layer, which the Frame's runtime doesn't composite, so the loading
screen stayed black. Adapter setting cube_quads (default on) draws a cube-only frame's six faces as quad layers
around the viewer; frames with a projection layer are unchanged. Adapter rebuilt on current main.

Also: the foveation test expected a numeric FOVE_LEVEL to pass through (Valve's layer ignores numbers since #394),
a long description line and an import order in adapter_dialog.py failed lint.
2026-10-17 21:52:39 -04:00

117 lines
6.6 KiB
Python

"""Math behind FrameBridge's per-game fixes (native/adapter/layer_math.h): cube faces for 360° layers
(equirect_emul) and the pose algebra used by stable_local / aim correction. Compiled for this host with a small C
harness. Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): needs the OpenXR headers in native/.cache (native/build.py)."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
CC = shutil.which("cc") or shutil.which("clang")
pytestmark = [pytest.mark.native, pytest.mark.skipif(
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not INC or not CC,
reason="set FRAMEPORT_NATIVE_TESTS=1 (needs the OpenXR headers from native/build.py and a host C compiler)")]
HARNESS = r"""
#include <stdio.h>
#include "layer_math.h"
#define CHECK(c) do { if (!(c)) { fprintf(stderr, "FAILED line %d: %s\n", __LINE__, #c); return 1; } } while (0)
#define NEAR(a, b) (fabsf((a) - (b)) < 1e-4f)
#define VNEAR(v, X, Y, Z) (NEAR((v).x, X) && NEAR((v).y, Y) && NEAR((v).z, Z))
int main(void) {
// face centre directions: front -Z, right +X, back +Z, left -X, up +Y, down -Y
CHECK(VNEAR(lm_face_dir(0, 0, 0), 0, 0, -1));
CHECK(VNEAR(lm_face_dir(1, 0, 0), 1, 0, 0));
CHECK(VNEAR(lm_face_dir(2, 0, 0), 0, 0, 1));
CHECK(VNEAR(lm_face_dir(3, 0, 0), -1, 0, 0));
CHECK(VNEAR(lm_face_dir(4, 0, 0), 0, 1, 0));
CHECK(VNEAR(lm_face_dir(5, 0, 0), 0, -1, 0));
// faces tile seamlessly: the right edge of the front face is the left edge of the right face, etc.
CHECK(VNEAR(lm_face_dir(0, 1, 0), 1, 0, -1) && VNEAR(lm_face_dir(1, -1, 0), 1, 0, -1));
CHECK(VNEAR(lm_face_dir(1, 1, 0), 1, 0, 1) && VNEAR(lm_face_dir(2, -1, 0), 1, 0, 1));
CHECK(VNEAR(lm_face_dir(0, 0, 1), 0, 1, -1) && VNEAR(lm_face_dir(4, 0, -1), 0, 1, -1));
CHECK(VNEAR(lm_face_dir(0, 0, -1), 0, -1, -1) && VNEAR(lm_face_dir(5, 0, 1), 0, -1, -1));
// every face's quad sits on its axis and faces the centre (a quad's normal is its local +Z)
XrPosef origin = {{0, 0, 0, 1}, {0, 1.5f, 0}};
for (int f = 0; f < 6; ++f) {
XrPosef p = lm_face_pose(origin, f, 10);
XrVector3f axis = lm_face_dir(f, 0, 0);
CHECK(VNEAR(p.position, 10 * axis.x, 1.5f + 10 * axis.y, 10 * axis.z));
XrVector3f normal = lm_rotate(p.orientation, (XrVector3f){0, 0, 1});
CHECK(VNEAR(normal, -axis.x, -axis.y, -axis.z));
// the quad's right (+X) is the face's +u: its right edge is where lm_face_dir(f, 1, 0) points
XrVector3f right = lm_rotate(p.orientation, (XrVector3f){1, 0, 0});
XrVector3f edge = lm_face_dir(f, 1, 0);
CHECK(VNEAR(edge, axis.x + right.x, axis.y + right.y, axis.z + right.z));
}
// the shader's cube_dir writes every texel where GL's cube lookup will read it back
for (int f = 0; f < 6; ++f)
for (int i = 0; i < 5; ++i)
for (int j = 0; j < 5; ++j) {
float u = -0.9f + 0.45f * i, v = -0.9f + 0.45f * j, s, t;
CHECK(lm_glcube_lookup(lm_glcube_dir(f, u, v), &s, &t) == f);
CHECK(NEAR(s, (u + 1) / 2) && NEAR(t, (v + 1) / 2));
}
// Cube layer quads: GL image s/t basis, inward normals, rotated layer, mip-0 face order.
for (int f = 0; f < 6; ++f) {
XrQuaternionf rotation = lm_axis_angle(0, 1, 0, 0.71f);
XrPosef p = lm_cube_quad_pose(rotation, f, 50);
XrVector3f axis = lm_rotate(rotation, lm_glcube_dir(f, 0, 0));
CHECK(VNEAR(p.position, 50 * axis.x, 50 * axis.y, 50 * axis.z));
XrVector3f normal = lm_rotate(p.orientation, (XrVector3f){0, 0, 1});
CHECK(VNEAR(normal, -axis.x, -axis.y, -axis.z));
for (int x = -1; x <= 1; ++x) for (int y = -1; y <= 1; ++y) {
XrVector3f offset = lm_rotate(p.orientation, (XrVector3f){x, y, 0});
XrVector3f expected = lm_rotate(rotation, lm_glcube_dir(f, x, y));
CHECK(VNEAR(expected, axis.x + offset.x, axis.y + offset.y, axis.z + offset.z));
}
// Vulkan reverses the image rows: source t = 1 - destination v, for the identical GL pose.
float s, t;
XrVector3f d = lm_glcube_dir(f, 0.4f, -0.6f);
CHECK(lm_glcube_lookup(d, &s, &t) == f && NEAR(s, 0.7f) && NEAR(t, 0.2f));
}
// equirect2: full sphere, image centre (u 0.5) straight ahead (-Z), +X to the right, v 1 at the top
float u, v;
CHECK(lm_equirect2_uv((XrVector3f){0, 0, -1}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v)
&& NEAR(u, 0.5f) && NEAR(v, 0.5f));
CHECK(lm_equirect2_uv((XrVector3f){1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v)
&& NEAR(u, 0.75f) && NEAR(v, 0.5f));
CHECK(lm_equirect2_uv((XrVector3f){-1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.25f));
CHECK(lm_equirect2_uv((XrVector3f){0, 1, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(v, 1.0f));
CHECK(lm_equirect2_uv((XrVector3f){0, -1, -0.0001f}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && v < 0.001f);
// a 180° layer covers the front hemisphere only
CHECK(lm_equirect2_uv((XrVector3f){0.9f, 0, -0.1f}, LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && u > 0.9f);
CHECK(!lm_equirect2_uv((XrVector3f){0, 0, 1}, LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v));
// face choice: looking ahead with a ~60° half-diagonal FOV + margin, the back face is never needed
float cos_limit = cosf(1.05f + 0.26f);
XrVector3f ahead = {0, 0, -1};
CHECK(lm_face_visible(0, ahead, cos_limit) && !lm_face_visible(2, ahead, cos_limit));
CHECK(lm_face_visible(1, ahead, cos_limit) && lm_face_visible(4, ahead, cos_limit));
CHECK(lm_face_score(0, ahead) > lm_face_score(1, ahead));
// pose algebra (stable_local): (A∘B)∘B⁻¹ = A
XrPosef a = {lm_axis_angle(0, 1, 0, 0.7f), {0.3f, -0.2f, 1.1f}},
b = {lm_axis_angle(1, 0, 0, -0.4f), {-0.5f, 0.1f, 0.2f}};
XrPosef back = lm_pose_mul(lm_pose_mul(a, b), lm_pose_inv(b));
CHECK(VNEAR(back.position, a.position.x, a.position.y, a.position.z));
CHECK(NEAR(fabsf(back.orientation.w), fabsf(a.orientation.w))
&& NEAR(lm_angle(lm_qmul(back.orientation, lm_qconj(a.orientation))), 0));
CHECK(NEAR(lm_angle(lm_axis_angle(0, 1, 0, 0.5f)), 0.5f));
puts("ok");
return 0;
}
"""
def test_layer_math(tmp_path):
src = tmp_path / "harness.c"
src.write_text(HARNESS)
exe = tmp_path / "harness"
subprocess.run([CC, "-O1", "-Wall", "-Wextra", "-Werror", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
str(src), "-lm", "-o", str(exe)], check=True)
out = subprocess.run([str(exe)], capture_output=True, text=True)
assert out.returncode == 0, out.stderr
assert out.stdout.strip() == "ok"