Files
SpartanPort/tests/test_camera_hook.cpp
paulo.bandeiraandClaude Opus 5.5 a17055bd64 Feat(Camera): câmera livre para antes das paredes
A câmera livre usa o raycast do cenário (0x556d10 no build 6e72, o que o
movimento do Kratos e outros ~140 pontos chamam; achado por assinatura,
com o filtro e o resultado nas globais que as leas dele apontam):
- uma linha do peito do Kratos até a câmera; batendo em algo, a câmera
  vem na hora para 0,4 unidade antes, na mesma linha, e volta aos poucos
  quando o caminho abre;
- o filtro é o do movimento sem as superfícies que os raycasts de linha
  de visão deixam de fora (0x4d3220c2): as paredes invisíveis na beira
  dos penhascos não seguram a câmera (SPARTAN_FREE_CAMERA_RAY troca);
- colado numa parede, com menos de 4 unidades livres, a câmera sobe por
  cima dele (de 10° em 10°, dentro do limite) e olha para baixo, em vez
  de ficar nas costas;
- só o controlador da câmera lança o raio (dentro do update do jogo), e o
  filtro e o resultado do jogo voltam como estavam.

SPARTAN_FREE_CAMERA_WATCH=actor e actor:a0 (engenharia reversa): acham e
vigiam a posição do ator do Kratos (ator+0xa0) depois de um golpe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:43:00 -03:00

398 lines
23 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Runs the free camera stub (src/camera_hook.h) as the game's render view update would reach it:
// every view calls the port, the view rows the code keeps in registers are reloaded from what the
// port wrote, and the replayed instruction loads xmm8. Then checks the orbit: the
// view stays the world's inverse, the camera keeps its distance to the pivot and faces it.
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <windows.h>
#include "../src/camera_hook.h"
// Where the stub resumes: the registers the game reads next, stored after the view.
extern "C" void camera_return();
asm(".globl camera_return\ncamera_return:\n"
" vmovaps %xmm5, 0x200(%rbx)\n vmovaps %xmm7, 0x210(%rbx)\n"
" vmovaps %xmm13, 0x220(%rbx)\n vmovaps %xmm12, 0x230(%rbx)\n"
" vmovaps %xmm8, 0x240(%rbx)\n ret\n");
static int applied;
static uint64_t applied_caller;
extern "C" __attribute__((sysv_abi)) void camera_apply(unsigned char* view, uint64_t caller) {
++applied;
applied_caller = caller;
for (int i = 0; i < 16; ++i) reinterpret_cast<float*>(view)[i] = static_cast<float>(100 + i);
}
// rbx: the render view. The game reaches the site with the stack 16-byte aligned and the
// update's return address 0x28 above it (five pushes).
static void call_stub(unsigned char* stub, void* view, uint64_t caller) {
asm volatile("sub $0x28, %%rsp\n mov %2, 0x20(%%rsp)\n call *%0\n add $0x28, %%rsp"
: : "r"(stub), "b"(view), "r"(caller)
: "rax", "rcx", "rdx", "rsi", "rdi", "r8", "r9", "r10", "r11", "xmm0", "xmm1", "xmm2", "xmm3",
"xmm4", "xmm5", "xmm6", "xmm7", "xmm8", "xmm9", "xmm10", "xmm11", "xmm12", "xmm13", "xmm14",
"xmm15", "memory", "cc");
}
// The controller's site: rax holds the node, rbx the camera manager; where the stub resumes, rax
// and rdx are read back.
static unsigned char* written_node;
static const unsigned char* written_manager;
extern "C" __attribute__((sysv_abi)) void node_written(unsigned char* node, const unsigned char* manager) {
written_node = node;
written_manager = manager;
}
extern "C" void controller_return();
extern "C" uint64_t returned_rax, returned_rdx;
uint64_t returned_rax, returned_rdx;
asm(".globl controller_return\ncontroller_return:\n"
" mov %rax, returned_rax(%rip)\n mov %rdx, returned_rdx(%rip)\n ret\n");
static void call_controller(unsigned char* stub, void* node, const void* manager) {
asm volatile("sub $8, %%rsp\n call *%0\n add $8, %%rsp"
: : "r"(stub), "a"(node), "b"(manager)
: "rcx", "rdx", "rsi", "rdi", "r8", "r9", "r10", "r11", "xmm0", "xmm1", "xmm2", "xmm3", "xmm4",
"xmm5", "xmm6", "xmm7", "xmm8", "xmm9", "xmm10", "xmm11", "xmm12", "xmm13", "xmm14", "xmm15",
"memory", "cc");
}
// src/runtime_camera.c, linked in; the menu's Free camera is on.
extern "C" int spartanportgpu_free_camera(void) { return 1; }
// The level's raycast, faked: it records the filter it was given and, when told to, hits halfway.
alignas(16) static unsigned char fake_filter[SPARTAN_CAMERA_RAY_FILTER_SIZE], fake_hit[SPARTAN_CAMERA_RAY_RESULT_SIZE];
static int fake_ray_calls;
static bool fake_ray_hits;
static float fake_wall_top = -1e9f; // a low wall: ends below this height are hit at 10%
static uint32_t fake_kinds, fake_excluded, fake_flags;
typedef float FakeV4 __attribute__((vector_size(16)));
extern "C" __attribute__((sysv_abi)) int fake_ray(FakeV4 start, FakeV4 end) {
++fake_ray_calls;
std::memcpy(&fake_kinds, fake_filter, 4);
std::memcpy(&fake_excluded, fake_filter + 4, 4);
std::memcpy(&fake_flags, fake_filter + 8, 4);
const float share = end[1] < fake_wall_top ? 0.1f : 0.5f;
if (!fake_ray_hits && !(end[1] < fake_wall_top)) return 0;
const float point[3] = {start[0] + (end[0] - start[0]) * share, start[1] + (end[1] - start[1]) * share,
start[2] + (end[2] - start[2]) * share};
std::memcpy(fake_hit, point, sizeof(point));
return 1;
}
extern "C" void spartanport_camera_set_ray(uintptr_t function, uintptr_t filter, uintptr_t hit);
// No actor recorded (the watch tool's actor search stays idle).
extern "C" uintptr_t spartanport_actor_slots_address(void) { return 0; }
extern "C" {
int spartanport_camera_configure(void);
void spartanport_camera_set_player_global(uintptr_t address);
__attribute__((sysv_abi)) void spartanport_camera_apply(unsigned char* render_view, uint64_t caller);
__attribute__((sysv_abi)) void spartanport_camera_node_written(unsigned char* node, const unsigned char* manager);
}
static float dot(const float* a, const float* b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
static bool close_to(float a, float b) { return std::fabs(a - b) < 1e-4f; }
// The view times the world is the identity.
static void check_inverse(const unsigned char* render_view) {
const float* view = reinterpret_cast<const float*>(render_view + SPARTAN_CAMERA_VIEW);
const float* world = reinterpret_cast<const float*>(render_view + SPARTAN_CAMERA_WORLD);
for (int r = 0; r < 4; ++r)
for (int c = 0; c < 4; ++c) {
float sum = 0;
for (int k = 0; k < 4; ++k) sum += world[r * 4 + k] * view[k * 4 + c];
assert(close_to(sum, r == c ? 1.0f : 0.0f));
}
}
// A camera at (0, 2, 10) looking down -z (projection [2][3] = -1), the game's view filled in.
static void make_view(unsigned char* render_view) {
std::memset(render_view, 0, 0x300);
float* world = reinterpret_cast<float*>(render_view + SPARTAN_CAMERA_WORLD);
float* view = reinterpret_cast<float*>(render_view + SPARTAN_CAMERA_VIEW);
float* projection = reinterpret_cast<float*>(render_view + SPARTAN_CAMERA_PROJECTION);
world[0] = world[5] = world[10] = world[15] = 1;
world[13] = 2;
world[14] = 10;
view[0] = view[5] = view[10] = view[15] = 1;
view[13] = -2;
view[14] = -10;
projection[11] = -1;
}
int main(int argc, char** argv) {
auto* code = static_cast<unsigned char*>(
VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
assert(code);
assert(spartanport_camera_build_stub(code, reinterpret_cast<uintptr_t>(&camera_apply),
reinterpret_cast<uintptr_t>(&camera_return)) < 128);
FlushInstructionCache(GetCurrentProcess(), code, 4096);
alignas(64) static unsigned char view[0x300];
float* rows = reinterpret_cast<float*>(view);
for (int i = 0; i < 16; ++i) rows[i] = static_cast<float>(i);
for (int i = 0; i < 4; ++i) reinterpret_cast<float*>(view + 0x140)[i] = static_cast<float>(50 + i);
// The port rewrites the view and the registers hold the new rows; the instruction is replayed.
assert(rows[0] == 0);
call_stub(code, view, 0x5cc4a0);
assert(applied == 1 && applied_caller == 0x5cc4a0);
const float* registers = reinterpret_cast<float*>(view + 0x200);
for (int i = 0; i < 16; ++i) assert(registers[i] == static_cast<float>(100 + i));
assert(reinterpret_cast<float*>(view + 0x240)[0] == 50);
// Orbit in place: the position stays, the view is the new world's inverse.
const float up[3] = {0, 1, 0};
alignas(64) static unsigned char render_view[0x300];
float* world = reinterpret_cast<float*>(render_view + SPARTAN_CAMERA_WORLD);
make_view(render_view);
SpartanCameraState state = {};
state.yaw = 1.5707963f; // a quarter turn
spartanport_camera_orbit(render_view, &state, up);
assert(close_to(world[12], 0) && close_to(world[13], 2) && close_to(world[14], 10));
assert(close_to(world[8], 1) && close_to(world[10], 0)); // the third axis turned a quarter around y
check_inverse(render_view);
// Orbit around a pivot 10 units ahead, (0, 2, 0): same distance, still facing it.
for (const float pitch : {0.0f, 0.5f, -0.4f}) {
make_view(render_view);
state.yaw = 0.7f;
state.pitch = pitch;
state.distance = 10;
spartanport_camera_orbit(render_view, &state, up);
const float to_pivot[3] = {0 - world[12], 2 - world[13], 0 - world[14]};
assert(close_to(std::sqrt(dot(to_pivot, to_pivot)), 10));
const float forward[3] = {-world[8], -world[9], -world[10]};
assert(close_to(dot(forward, to_pivot), 10));
assert(close_to(world[1], 0)); // the right axis stays level: no roll
check_inverse(render_view);
}
// The floor: the camera at (0, 2, 10) looks at a pivot at (0, 0, 0); a pitch that would take it
// below height 1 stops where it meets it, one that keeps it above stays, and when the game's own
// camera is lower than the floor the pitch that leaves the camera higher of the two wins.
{
make_view(render_view);
float pose[16];
std::memcpy(pose, world, sizeof(pose));
const float pivot[3] = {0, 0, 0};
SpartanCameraState floor_state = {};
for (const float pitch : {0.6f, -0.6f}) {
floor_state.pitch = pitch;
const float lower = spartanport_camera_orbit_height(pose, pivot, &floor_state, pitch, up);
if (lower >= 1) {
assert(spartanport_camera_floor_pitch(pose, pivot, &floor_state, up, 1) == pitch);
continue;
}
const float held = spartanport_camera_floor_pitch(pose, pivot, &floor_state, up, 1);
assert(std::fabs(held) < std::fabs(pitch) && held * pitch >= 0);
assert(close_to(spartanport_camera_orbit_height(pose, pivot, &floor_state, held, up), 1));
// The game's camera (height 2) under a floor at 3: stay with it rather than go lower.
assert(spartanport_camera_floor_pitch(pose, pivot, &floor_state, up, 3) == 0);
}
}
// Held motion blur: the previous view×projection is view * projection (row vectors).
{
alignas(64) static unsigned char blur_view[0x300];
float* v = reinterpret_cast<float*>(blur_view + SPARTAN_CAMERA_VIEW);
float* p = reinterpret_cast<float*>(blur_view + SPARTAN_CAMERA_PROJECTION);
for (int i = 0; i < 16; ++i) v[i] = static_cast<float>(i + 1), p[i] = static_cast<float>((i * 7) % 5 - 2);
spartanport_camera_hold_blur(blur_view);
const float* previous = reinterpret_cast<float*>(blur_view + SPARTAN_CAMERA_PREVIOUS_VIEW_PROJECTION);
for (int row = 0; row < 4; ++row)
for (int column = 0; column < 4; ++column) {
float expected = 0;
for (int k = 0; k < 4; ++k) expected += v[row * 4 + k] * p[k * 4 + column];
assert(close_to(previous[row * 4 + column], expected));
}
assert(reinterpret_cast<float*>(blur_view + SPARTAN_CAMERA_VIEW)[0] == 1); // the view is left alone
}
// The controller's stub: the port gets the node and the manager, rax comes back, rdx holds the
// counter.
static uint64_t counter;
alignas(64) static unsigned char node[0x160], manager[0x300];
assert(spartanport_camera_build_controller_stub(code + 128, reinterpret_cast<uintptr_t>(&node_written),
reinterpret_cast<uintptr_t>(&counter),
reinterpret_cast<uintptr_t>(&controller_return)) < 128);
FlushInstructionCache(GetCurrentProcess(), code, 4096);
call_controller(code + 128, node, manager);
assert(written_node == node && written_manager == manager && returned_rax == reinterpret_cast<uintptr_t>(node) &&
returned_rdx == reinterpret_cast<uintptr_t>(&counter));
// The port (src/runtime_camera.c) with the stick on: the node and the view take the turned
// matrix, again and again without adding up, and the game's own one when the stick is back.
_putenv_s("SPARTAN_FREE_CAMERA", "1");
spartanport_camera_configure();
alignas(64) static unsigned char gameplay[0x400];
make_view(gameplay);
const uint64_t head = reinterpret_cast<uintptr_t>(gameplay + 0x380); // the list's one entry
const uint64_t camera = reinterpret_cast<uintptr_t>(node);
const uint32_t priority = 0x200;
std::memcpy(gameplay + SPARTAN_CAMERA_LIST, &head, 8);
std::memcpy(gameplay + 0x380, &head, 8);
std::memcpy(gameplay + 0x380 + SPARTAN_CAMERA_LIST_NODE, &camera, 8);
std::memcpy(gameplay + 0x380 + SPARTAN_CAMERA_LIST_PRIORITY, &priority, 4);
float clean[16];
std::memcpy(clean, gameplay + SPARTAN_CAMERA_WORLD, sizeof(clean));
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean));
const int16_t no_parent = -1;
std::memcpy(node + SPARTAN_CAMERA_NODE_PARENT, &no_parent, 2);
spartanport_camera_apply(gameplay, 0); // a new camera: the game's framing
assert(!std::memcmp(clean, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(clean)));
spartanport_camera_steer(1, 0, 0.25f, 0); // 30 degrees to the right
spartanport_camera_apply(gameplay, 0);
float turned[16];
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
assert(std::memcmp(turned, clean, sizeof(clean)) && node[SPARTAN_CAMERA_NODE_FLAGS] & 1);
assert(!std::memcmp(turned, gameplay + SPARTAN_CAMERA_WORLD, sizeof(turned)));
check_inverse(gameplay);
assert(close_to(turned[12], 0) && close_to(turned[13], 2) && close_to(turned[14], 10));
spartanport_camera_apply(gameplay, 0); // the second update of the frame
assert(!std::memcmp(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned)));
// Until the controller runs the port knows no target and turns the camera in place; then it
// orbits the point the game's camera looks at, 10 ahead, (0, 2, 0): same distance, facing it.
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean)); // the controller moves it
const float target = 10;
std::memcpy(manager + SPARTAN_CAMERA_MANAGER_DISTANCE, &target, 4);
spartanport_camera_node_written(node, manager);
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
const float to_target[3] = {0 - turned[12], 2 - turned[13], 0 - turned[14]};
assert(close_to(std::sqrt(dot(to_target, to_target)), 10) && !close_to(turned[12], 0));
const float ahead[3] = {-turned[8], -turned[9], -turned[10]};
assert(close_to(dot(ahead, to_target), 10));
spartanport_camera_apply(gameplay, 0); // the view takes the same camera
assert(!std::memcmp(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned)));
assert(!std::memcmp(turned, gameplay + SPARTAN_CAMERA_WORLD, sizeof(turned)));
spartanport_camera_steer(-1, 0, 0.25f, 0); // back to the game's camera
spartanport_camera_apply(gameplay, 0);
assert(!std::memcmp(clean, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(clean)));
// With the player's object there the camera orbits Kratos, at (3, 0, 0): the same distance to
// him, the same height above him.
static uint64_t player_global;
alignas(16) static unsigned char player[0x100];
const float kratos[4] = {3, 0, 0, 1};
std::memcpy(player + SPARTAN_CAMERA_PLAYER_POSITION, kratos, sizeof(kratos));
player_global = reinterpret_cast<uintptr_t>(player);
spartanport_camera_set_player_global(reinterpret_cast<uintptr_t>(&player_global));
spartanport_camera_steer(1, 0, 0.25f, 0);
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean));
spartanport_camera_node_written(node, manager);
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
const float before[3] = {clean[12] - kratos[0], clean[13] - kratos[1], clean[14] - kratos[2]};
const float after[3] = {turned[12] - kratos[0], turned[13] - kratos[1], turned[14] - kratos[2]};
assert(close_to(dot(before, before), dot(after, after)) && close_to(after[1], before[1]));
assert(!close_to(turned[12], clean[12]));
// Walls: the level's raycast from his chest (1.5 above him) to the camera. Clear, the camera
// stays; a hit halfway brings it in to 0.4 short of the hit at once, on the controller's call
// only (the view's update reuses it); clear again, it eases back out. The game's filter and hit
// are as they were after each cast.
{
float open[16];
std::memcpy(open, turned, sizeof(open));
std::memset(fake_filter, 0xab, sizeof(fake_filter));
std::memset(fake_hit, 0xcd, sizeof(fake_hit));
unsigned char kept_filter[sizeof(fake_filter)], kept_hit[sizeof(fake_hit)];
std::memcpy(kept_filter, fake_filter, sizeof(kept_filter));
std::memcpy(kept_hit, fake_hit, sizeof(kept_hit));
spartanport_camera_set_ray(reinterpret_cast<uintptr_t>(&fake_ray), reinterpret_cast<uintptr_t>(fake_filter),
reinterpret_cast<uintptr_t>(fake_hit));
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean));
spartanport_camera_node_written(node, manager);
// Two casts: the rise's search (nothing to rise for) and the pull.
assert(fake_ray_calls == 2 && fake_kinds == SPARTAN_CAMERA_RAY_KINDS &&
fake_excluded == SPARTAN_CAMERA_RAY_EXCLUDED && fake_flags == SPARTAN_CAMERA_RAY_CLOSEST);
assert(!std::memcmp(open, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(open)));
assert(!std::memcmp(kept_filter, fake_filter, sizeof(kept_filter)) && !std::memcmp(kept_hit, fake_hit, sizeof(kept_hit)));
const float chest[3] = {kratos[0], kratos[1] + 1.5f, kratos[2]};
const float way[3] = {open[12] - chest[0], open[13] - chest[1], open[14] - chest[2]};
const float length = std::sqrt(dot(way, way));
fake_ray_hits = true;
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean));
spartanport_camera_node_written(node, manager);
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
const float closer[3] = {turned[12] - chest[0], turned[13] - chest[1], turned[14] - chest[2]};
assert(close_to(std::sqrt(dot(closer, closer)), length * 0.5f - 0.4f));
assert(close_to(dot(closer, way), std::sqrt(dot(closer, closer)) * length)); // on the same line
assert(!std::memcmp(turned + 8, open + 8, 3 * sizeof(float))); // still looking the same way
assert(!std::memcmp(kept_filter, fake_filter, sizeof(kept_filter)) && !std::memcmp(kept_hit, fake_hit, sizeof(kept_hit)));
spartanport_camera_apply(gameplay, 0);
assert(fake_ray_calls == 4 && !std::memcmp(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned)));
fake_ray_hits = false;
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean));
spartanport_camera_node_written(node, manager);
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
const float eased[3] = {turned[12] - chest[0], turned[13] - chest[1], turned[14] - chest[2]};
const float pull = (length * 0.5f - 0.4f) / length;
assert(close_to(std::sqrt(dot(eased, eased)), length * (pull + (1 - pull) * 0.15f)));
// Up against a low wall (the way is clear only from higher up): the camera rises over him,
// a quarter of the rise per cast (pulled in while its way still passes under the wall), and
// ends above the wall at its full distance, looking down at him.
fake_wall_top = open[13] + 0.5f;
for (int cast = 0; cast < 40; ++cast) {
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, clean, sizeof(clean));
spartanport_camera_node_written(node, manager);
}
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
const float risen[3] = {turned[12] - chest[0], turned[13] - chest[1], turned[14] - chest[2]};
assert(turned[13] > fake_wall_top && std::sqrt(dot(risen, risen)) > length * 0.95f);
assert(turned[9] * open[9] >= 0 && std::fabs(turned[9]) > std::fabs(open[9])); // tilted further down
fake_wall_top = -1e9f;
spartanport_camera_set_ray(0, 0, 0);
}
// A cut (the game's camera jumped): back to the game's framing, the stick's turn dropped.
float cut[16];
std::memcpy(cut, clean, sizeof(cut));
cut[14] += 20;
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, cut, sizeof(cut));
spartanport_camera_node_written(node, manager);
assert(!std::memcmp(cut, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(cut)));
// No yaw limit by default: the camera goes all the way around Kratos. Half a turn puts it on
// his other side, at the same distance; a whole turn brings it back where the game had it.
spartanport_camera_steer(1, 0, 1.5f, 0); // 180 degrees
spartanport_camera_node_written(node, manager);
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
const float back[3] = {turned[12] - kratos[0], turned[13] - kratos[1], turned[14] - kratos[2]};
const float front[3] = {cut[12] - kratos[0], cut[13] - kratos[1], cut[14] - kratos[2]};
assert(close_to(back[0], -front[0]) && close_to(back[2], -front[2]) && close_to(back[1], front[1]));
spartanport_camera_steer(1, 0, 1.5f, 0); // 360
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, cut, sizeof(cut)); // the controller writes the game's camera
spartanport_camera_node_written(node, manager);
std::memcpy(turned, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(turned));
for (int i = 0; i < 16; ++i) assert(std::fabs(turned[i] - cut[i]) < 1e-3f);
// The title screen (the Shell level, its own WADs too) keeps the game's camera until a level of
// play opens; the game's other files change nothing.
assert(spartanport_camera_live());
spartanport_camera_file_opened("/app0/EXEC/WAD/PS4_LE/SHELL.WAD");
assert(!spartanport_camera_live());
for (const char* other : {"/app0/EXEC/WAD/PS4_LE/HDS675SHELL.WAD", "/app0/EXEC/WAD/PS4_LE/R_UI.WAD",
"/app0/EXEC/WAD/PS4_LE/POSSKYBOX_050.WAD", "/app0/sound/OLY150.BNK"})
spartanport_camera_file_opened(other);
assert(!spartanport_camera_live());
spartanport_camera_steer(1, 0, 0.25f, 0);
std::memcpy(node + SPARTAN_CAMERA_NODE_MATRIX, cut, sizeof(cut));
spartanport_camera_node_written(node, manager);
assert(!std::memcmp(cut, node + SPARTAN_CAMERA_NODE_MATRIX, sizeof(cut)));
spartanport_camera_file_opened("/app0/EXEC/WAD/PS4_LE/Oly150.WAD");
assert(spartanport_camera_live());
spartanport_camera_file_opened("/app0/EXEC/WAD/PS4_LE/SHELL.WAD");
spartanport_camera_file_opened("/app0/EXEC/WAD/PS4_LE/CHALLENGE01.WAD");
assert(spartanport_camera_live());
// The mouse turns it by an angle: 30 degrees to the right, as the stick's quarter second; down
// looks down, up to the pitch limit.
spartanport_camera_steer(0, 0, 1, 1);
spartanport_camera_look(30, 0);
assert(close_to(spartanport_camera_yaw(), -30 * 0.017453292f));
spartanport_camera_look(-30, 0);
spartanport_camera_steer(1, 0, 0.25f, 0);
assert(close_to(spartanport_camera_yaw(), -30 * 0.017453292f));
spartanport_camera_steer(0, 0, 1, 1);
(void)argc;
(void)argv;
std::puts("camera-hook-test: OK");
}