feat(scripting): 3D Vector ops, Mat4 lookAt/ortho, Vector buffer writes, GPUBuffer write source range (#13003) ef0e100413
* feat(scripting): 3D Vector ops, Mat4 lookAt/ortho, Vector buffer writes, GPUBuffer write source range

* test(scripting): pin Vector fastcall and C binding paths bit-identical, align lerp endpoint

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index bbf97f1..a0c0b4f 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-eeb280d7f906f2e47bb77c02600a301dda7e600d
+ef0e10041348832ffb0d398b9bb20df97a1d00b8
diff --git a/include/rive/lua/rive_lua_libs.hpp b/include/rive/lua/rive_lua_libs.hpp
index e9d7209..eb838ab 100644
--- a/include/rive/lua/rive_lua_libs.hpp
+++ b/include/rive/lua/rive_lua_libs.hpp
@@ -347,6 +347,9 @@
     writeToBuffer,
     invertAffine,
 
+    // Vector
+    writeVec4,
+
     // Gamepad
     axes,
     gamepadMapping,
diff --git a/include/rive/math/mat4.hpp b/include/rive/math/mat4.hpp
index 341ab2b..26d5212 100644
--- a/include/rive/math/mat4.hpp
+++ b/include/rive/math/mat4.hpp
@@ -178,6 +178,73 @@
         return m;
     }
 
+    // Right-handed view matrix looking from `eye` toward `center` (GLM
+    // convention). `up` need not be normalized.
+    static Mat4 lookAt(const float eye[3],
+                       const float center[3],
+                       const float up[3])
+    {
+        float f[3] = {center[0] - eye[0],
+                      center[1] - eye[1],
+                      center[2] - eye[2]};
+        float fInv = 1.f / std::sqrt(f[0] * f[0] + f[1] * f[1] + f[2] * f[2]);
+        f[0] *= fInv;
+        f[1] *= fInv;
+        f[2] *= fInv;
+        float s[3] = {f[1] * up[2] - f[2] * up[1],
+                      f[2] * up[0] - f[0] * up[2],
+                      f[0] * up[1] - f[1] * up[0]};
+        float sInv = 1.f / std::sqrt(s[0] * s[0] + s[1] * s[1] + s[2] * s[2]);
+        s[0] *= sInv;
+        s[1] *= sInv;
+        s[2] *= sInv;
+        float u[3] = {s[1] * f[2] - s[2] * f[1],
+                      s[2] * f[0] - s[0] * f[2],
+                      s[0] * f[1] - s[1] * f[0]};
+        Mat4 m;
+        m.m_buffer[0] = s[0];
+        m.m_buffer[1] = u[0];
+        m.m_buffer[2] = -f[0];
+        m.m_buffer[4] = s[1];
+        m.m_buffer[5] = u[1];
+        m.m_buffer[6] = -f[1];
+        m.m_buffer[8] = s[2];
+        m.m_buffer[9] = u[2];
+        m.m_buffer[10] = -f[2];
+        m.m_buffer[12] = -(s[0] * eye[0] + s[1] * eye[1] + s[2] * eye[2]);
+        m.m_buffer[13] = -(u[0] * eye[0] + u[1] * eye[1] + u[2] * eye[2]);
+        m.m_buffer[14] = f[0] * eye[0] + f[1] * eye[1] + f[2] * eye[2];
+        return m;
+    }
+
+    // Right-handed orthographic projection. Maps view-space z=[-near, -far]
+    // to NDC z in either [0, 1] (default, depthZeroToOne=true) or [-1, 1].
+    static Mat4 ortho(float left,
+                      float right,
+                      float bottom,
+                      float top,
+                      float near_,
+                      float far_,
+                      bool depthZeroToOne = true)
+    {
+        Mat4 m;
+        m.m_buffer[0] = 2.f / (right - left);
+        m.m_buffer[5] = 2.f / (top - bottom);
+        m.m_buffer[12] = -(right + left) / (right - left);
+        m.m_buffer[13] = -(top + bottom) / (top - bottom);
+        if (depthZeroToOne)
+        {
+            m.m_buffer[10] = -1.f / (far_ - near_);
+            m.m_buffer[14] = -near_ / (far_ - near_);
+        }
+        else
+        {
+            m.m_buffer[10] = -2.f / (far_ - near_);
+            m.m_buffer[14] = -(far_ + near_) / (far_ - near_);
+        }
+        return m;
+    }
+
     // SIMD: out = lhs * rhs. Both column-major.
     static Mat4 multiply(const Mat4& lhs, const Mat4& rhs)
     {
diff --git a/scripting/premake5.lua b/scripting/premake5.lua
index 4c049ce..ce47969 100755
--- a/scripting/premake5.lua
+++ b/scripting/premake5.lua
@@ -1,5 +1,5 @@
 local dependency = require('dependency')
-local luau = dependency.github('luigi-rosso/luau', 'rive_0_728')
+local luau = dependency.github('luigi-rosso/luau', 'rive_0_728_vec3')
 local libhydrogen = dependency.github('luigi-rosso/libhydrogen', 'rive_0_2')
 
 dofile('rive_build_config.lua')
diff --git a/src/lua/math/lua_mat4.cpp b/src/lua/math/lua_mat4.cpp
index ca764f4..8527655 100644
--- a/src/lua/math/lua_mat4.cpp
+++ b/src/lua/math/lua_mat4.cpp
@@ -90,6 +90,27 @@
     return 1;
 }
 
+static int mat4_lookAt(lua_State* L)
+{
+    const float* eye = luaL_checkvector(L, 1);
+    const float* center = luaL_checkvector(L, 2);
+    const float* up = luaL_checkvector(L, 3);
+    lua_pushmat4(L, Mat4::lookAt(eye, center, up));
+    return 1;
+}
+
+static int mat4_ortho(lua_State* L)
+{
+    float l = float(luaL_checknumber(L, 1));
+    float r = float(luaL_checknumber(L, 2));
+    float b = float(luaL_checknumber(L, 3));
+    float t = float(luaL_checknumber(L, 4));
+    float n = float(luaL_checknumber(L, 5));
+    float f = float(luaL_checknumber(L, 6));
+    lua_pushmat4(L, Mat4::ortho(l, r, b, t, n, f, /*zeroToOne=*/true));
+    return 1;
+}
+
 // In-place: Mat4.multiply(out, a, b)  ->  out = a * b. Returns out.
 // Avoids per-call userdata allocation in tight loops.
 static int mat4_static_multiply(lua_State* L)
@@ -379,6 +400,8 @@
     {"fromRotationZ", mat4_fromRotationZ},
     {"perspective", mat4_perspective},
     {"perspectiveReverseZ", mat4_perspectiveReverseZ},
+    {"lookAt", mat4_lookAt},
+    {"ortho", mat4_ortho},
     {"multiply", mat4_static_multiply},
     {"multiplyAffine", mat4_static_multiplyAffine},
     {"invert", mat4_static_invert},
diff --git a/src/lua/math/lua_vec2d.cpp b/src/lua/math/lua_vec2d.cpp
index d9cdd01..a38b0f3 100644
--- a/src/lua/math/lua_vec2d.cpp
+++ b/src/lua/math/lua_vec2d.cpp
@@ -2,6 +2,8 @@
 #ifdef WITH_RIVE_SCRIPTING
 #include "rive/lua/rive_lua_libs.hpp"
 #include "lualib.h"
+#include <cmath>
+#include <cstring>
 
 using namespace rive;
 
@@ -40,49 +42,59 @@
     return 0;
 }
 
+// All magnitude and interpolation ops read all 3 components. For genuine 2D
+// vectors z is 0, so results are identical to the old 2D math.
+static inline float dot3(const float* a, const float* b)
+{
+    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
 static int vector_length(lua_State* L)
 {
-    auto vec = lua_checkvec2d(L, 1);
-    lua_pushnumber(L, vec->length());
+    const float* vec = luaL_checkvector(L, 1);
+    lua_pushnumber(L, std::sqrt(dot3(vec, vec)));
     return 1;
 }
 
 static int vector_normalized(lua_State* L)
 {
-    auto vec = lua_checkvec2d(L, 1);
-    lua_pushvec2d(L, vec->normalized());
+    const float* vec = luaL_checkvector(L, 1);
+    float len2 = dot3(vec, vec);
+    float scale = len2 > 0 ? 1.f / std::sqrt(len2) : 1.f;
+    lua_pushvector(L, vec[0] * scale, vec[1] * scale, vec[2] * scale);
     return 1;
 }
 
 static int vector_lengthSquared(lua_State* L)
 {
-    auto vec = lua_checkvec2d(L, 1);
-    lua_pushnumber(L, vec->lengthSquared());
-    return 1;
-}
-
-static int vector_distance(lua_State* L)
-{
-    auto lhs = lua_checkvec2d(L, 1);
-    auto rhs = lua_checkvec2d(L, 2);
-    lua_pushnumber(L, Vec2D::distance(*lhs, *rhs));
+    const float* vec = luaL_checkvector(L, 1);
+    lua_pushnumber(L, dot3(vec, vec));
     return 1;
 }
 
 static int vector_distanceSquared(lua_State* L)
 {
-    auto lhs = lua_checkvec2d(L, 1);
-    auto rhs = lua_checkvec2d(L, 2);
-    lua_pushnumber(L, Vec2D::distanceSquared(*lhs, *rhs));
+    const float* lhs = luaL_checkvector(L, 1);
+    const float* rhs = luaL_checkvector(L, 2);
+    float d[3] = {rhs[0] - lhs[0], rhs[1] - lhs[1], rhs[2] - lhs[2]};
+    lua_pushnumber(L, dot3(d, d));
+    return 1;
+}
+
+static int vector_distance(lua_State* L)
+{
+    const float* lhs = luaL_checkvector(L, 1);
+    const float* rhs = luaL_checkvector(L, 2);
+    float d[3] = {rhs[0] - lhs[0], rhs[1] - lhs[1], rhs[2] - lhs[2]};
+    lua_pushnumber(L, std::sqrt(dot3(d, d)));
     return 1;
 }
 
 static int vector_dot(lua_State* L)
 {
-    auto lhs = lua_checkvec2d(L, 1);
-    auto rhs = lua_checkvec2d(L, 2);
-
-    lua_pushnumber(L, Vec2D::dot(*lhs, *rhs));
+    const float* lhs = luaL_checkvector(L, 1);
+    const float* rhs = luaL_checkvector(L, 2);
+    lua_pushnumber(L, dot3(lhs, rhs));
     return 1;
 }
 
@@ -94,33 +106,98 @@
     return 1;
 }
 
+// 3D cross product. Distinct from `cross`, which returns the scalar 2D
+// perp-dot.
+static int vector_cross3(lua_State* L)
+{
+    const float* a = luaL_checkvector(L, 1);
+    const float* b = luaL_checkvector(L, 2);
+    lua_pushvector(L,
+                   a[1] * b[2] - a[2] * b[1],
+                   a[2] * b[0] - a[0] * b[2],
+                   a[0] * b[1] - a[1] * b[0]);
+    return 1;
+}
+
 static int vector_scaleAndAdd(lua_State* L)
 {
-    auto a = lua_checkvec2d(L, 1);
-    auto b = lua_checkvec2d(L, 2);
+    const float* a = luaL_checkvector(L, 1);
+    const float* b = luaL_checkvector(L, 2);
     float scale = float(luaL_checknumber(L, 3));
-    lua_pushvec2d(L, Vec2D::scaleAndAdd(*a, *b, scale));
+    lua_pushvector(L,
+                   a[0] + b[0] * scale,
+                   a[1] + b[1] * scale,
+                   a[2] + b[2] * scale);
     return 1;
 }
 
 static int vector_scaleAndSub(lua_State* L)
 {
-    auto a = lua_checkvec2d(L, 1);
-    auto b = lua_checkvec2d(L, 2);
+    const float* a = luaL_checkvector(L, 1);
+    const float* b = luaL_checkvector(L, 2);
     float scale = float(luaL_checknumber(L, 3));
-    lua_pushvec2d(L, *a - *b * scale);
+    lua_pushvector(L,
+                   a[0] - b[0] * scale,
+                   a[1] - b[1] * scale,
+                   a[2] - b[2] * scale);
     return 1;
 }
 
+// Exact at t=1, mirroring the VM fastcall's luai_lerpf so both dispatch
+// paths return bit-identical results.
+static inline float lerpf(float a, float b, float t)
+{
+    return t == 1.f ? b : a + (b - a) * t;
+}
+
 static int vector_lerp(lua_State* L)
 {
-    auto lhs = lua_checkvec2d(L, 1);
-    auto rhs = lua_checkvec2d(L, 2);
-    float factor = float(luaL_checknumber(L, 3));
-    lua_pushvec2d(L, Vec2D::lerp(*lhs, *rhs, factor));
+    const float* a = luaL_checkvector(L, 1);
+    const float* b = luaL_checkvector(L, 2);
+    float t = float(luaL_checknumber(L, 3));
+    lua_pushvector(L,
+                   lerpf(a[0], b[0], t),
+                   lerpf(a[1], b[1], t),
+                   lerpf(a[2], b[2], t));
     return 1;
 }
 
+// vec:writeToBuffer(buf, byteOffset) — 12 bytes (x, y, z as float32).
+static int vector_writeToBuffer(lua_State* L)
+{
+    const float* vec = luaL_checkvector(L, 1);
+    size_t bufLen = 0;
+    void* buf = luaL_checkbuffer(L, 2, &bufLen);
+    int off = int(luaL_checkinteger(L, 3));
+    if (off < 0 || size_t(off) + 12 > bufLen)
+    {
+        luaL_error(L, "Vector:writeToBuffer offset out of range");
+        return 0;
+    }
+    std::memcpy(static_cast<uint8_t*>(buf) + off, vec, 12);
+    return 0;
+}
+
+// vec:writeVec4(buf, byteOffset, w) — 16 bytes (x, y, z, w as float32),
+// matching a vec4 uniform-buffer slot.
+static int vector_writeVec4(lua_State* L)
+{
+    const float* vec = luaL_checkvector(L, 1);
+    size_t bufLen = 0;
+    void* buf = luaL_checkbuffer(L, 2, &bufLen);
+    int off = int(luaL_checkinteger(L, 3));
+    float w = float(luaL_checknumber(L, 4));
+    if (off < 0 || size_t(off) + 16 > bufLen)
+    {
+        luaL_error(L, "Vector:writeVec4 offset out of range");
+        return 0;
+    }
+    uint8_t* dst = static_cast<uint8_t*>(buf) + off;
+    std::memcpy(dst, vec, 12);
+    std::memcpy(dst + 12, &w, 4);
+    return 0;
+}
+
 static int vector_xy(lua_State* L)
 {
     float x = (float)lua_tonumber(L, 1);
@@ -130,6 +207,16 @@
     return 1;
 }
 
+static int vector_xyz(lua_State* L)
+{
+    float x = (float)lua_tonumber(L, 1);
+    float y = (float)lua_tonumber(L, 2);
+    float z = (float)lua_tonumber(L, 3);
+
+    lua_pushvector(L, x, y, z);
+    return 1;
+}
+
 static int vector_origin(lua_State* L)
 {
     lua_pushvector2(L, 0.0f, 0.0f);
@@ -158,6 +245,10 @@
                 return vector_dot(L);
             case (int)LuaAtoms::lerp:
                 return vector_lerp(L);
+            case (int)LuaAtoms::writeToBuffer:
+                return vector_writeToBuffer(L);
+            case (int)LuaAtoms::writeVec4:
+                return vector_writeVec4(L);
         }
     }
 
@@ -170,10 +261,12 @@
     {"distanceSquared", vector_distanceSquared},
     {"dot", vector_dot},
     {"cross", vector_cross},
+    {"cross3", vector_cross3},
     {"scaleAndAdd", vector_scaleAndAdd},
     {"scaleAndSub", vector_scaleAndSub},
     {"lerp", vector_lerp},
     {"xy", vector_xy},
+    {"xyz", vector_xyz},
     {"origin", vector_origin},
     {"length", vector_length},
     {"lengthSquared", vector_lengthSquared},
diff --git a/src/lua/renderer/lua_gpu.cpp b/src/lua/renderer/lua_gpu.cpp
index e82cb4d..a32069b 100644
--- a/src/lua/renderer/lua_gpu.cpp
+++ b/src/lua/renderer/lua_gpu.cpp
@@ -873,21 +873,45 @@
     {
         luaL_typeerror(L, 2, "buffer");
     }
-    uint32_t offset =
+    uint32_t dstOffset =
         lua_isnumber(L, 3) ? static_cast<uint32_t>(lua_tonumber(L, 3)) : 0;
+    uint32_t srcOffset =
+        lua_isnumber(L, 4) ? static_cast<uint32_t>(lua_tonumber(L, 4)) : 0;
+    if (srcOffset > len)
+    {
+        luaL_error(L,
+                   "GPUBuffer:write: srcOffset(%u) exceeds source buffer "
+                   "size(%u)",
+                   srcOffset,
+                   (uint32_t)len);
+    }
+    uint32_t byteLength = lua_isnumber(L, 5)
+                              ? static_cast<uint32_t>(lua_tonumber(L, 5))
+                              : static_cast<uint32_t>(len - srcOffset);
 
-    if (offset + len > self->buffer->size())
+    if (uint64_t(srcOffset) + byteLength > len)
+    {
+        luaL_error(L,
+                   "GPUBuffer:write: srcOffset(%u) + byteLength(%u) exceeds "
+                   "source buffer size(%u)",
+                   srcOffset,
+                   byteLength,
+                   (uint32_t)len);
+    }
+    if (uint64_t(dstOffset) + byteLength > self->buffer->size())
     {
         luaL_error(L,
                    "GPUBuffer:write: offset(%u) + size(%u) = %u exceeds "
                    "buffer size(%u)",
-                   offset,
-                   (uint32_t)len,
-                   (uint32_t)(offset + len),
+                   dstOffset,
+                   byteLength,
+                   (uint32_t)(dstOffset + byteLength),
                    self->buffer->size());
     }
 
-    self->buffer->update(data, static_cast<uint32_t>(len), offset);
+    self->buffer->update(static_cast<const uint8_t*>(data) + srcOffset,
+                         byteLength,
+                         dstOffset);
     return 0;
 }
 
diff --git a/src/lua/rive_lua_libs.cpp b/src/lua/rive_lua_libs.cpp
index 2687043..b7356b0 100644
--- a/src/lua/rive_lua_libs.cpp
+++ b/src/lua/rive_lua_libs.cpp
@@ -295,6 +295,8 @@
     {"transformVec4", (int16_t)LuaAtoms::transformVec4},
     {"writeToBuffer", (int16_t)LuaAtoms::writeToBuffer},
     {"invertAffine", (int16_t)LuaAtoms::invertAffine},
+    // Vector
+    {"writeVec4", (int16_t)LuaAtoms::writeVec4},
 };
 
 static const luaL_Reg lualibs[] = {
diff --git a/tests/unit_tests/runtime/scripting/scripting_mat4_test.cpp b/tests/unit_tests/runtime/scripting/scripting_mat4_test.cpp
index 0b4fe1f..d72e62c 100644
--- a/tests/unit_tests/runtime/scripting/scripting_mat4_test.cpp
+++ b/tests/unit_tests/runtime/scripting/scripting_mat4_test.cpp
@@ -58,6 +58,66 @@
     CHECK(lua_tonumber(t.state(), -1) == 33.0);
 }
 
+TEST_CASE("Mat4.lookAt builds a view matrix", "[scripting]")
+{
+    // Eye at (0,0,5) looking at the origin: pure -5 z translation.
+    const char* src =
+        "local view = Mat4.lookAt(Vector.xyz(0, 0, 5), Vector.origin(),\n"
+        "    Vector.xyz(0, 1, 0))\n"
+        "local v = view:transformPoint(0, 0, 0)\n"
+        "return v.x, v.y, v.z, view.m11, view.m22, view.m33\n";
+    auto t = ScriptingTest(src, 6);
+    CHECK(lua_tonumber(t.state(), -6) == 0.0);
+    CHECK(lua_tonumber(t.state(), -5) == 0.0);
+    CHECK(lua_tonumber(t.state(), -4) == -5.0);
+    CHECK(lua_tonumber(t.state(), -3) == 1.0);
+    CHECK(lua_tonumber(t.state(), -2) == 1.0);
+    CHECK(lua_tonumber(t.state(), -1) == 1.0);
+
+    // Eye on +x looking at the origin: world +x maps to view -z.
+    const char* sideSrc =
+        "local view = Mat4.lookAt(Vector.xyz(5, 0, 0), Vector.origin(),\n"
+        "    Vector.xyz(0, 1, 0))\n"
+        "local v = view:transformPoint(1, 0, 0)\n"
+        "return v.x, v.y, v.z\n";
+    auto side = ScriptingTest(sideSrc, 3);
+    CHECK(lua_tonumber(side.state(), -3) == Approx(0.0).margin(1e-6));
+    CHECK(lua_tonumber(side.state(), -2) == Approx(0.0).margin(1e-6));
+    CHECK(lua_tonumber(side.state(), -1) == Approx(-4.0));
+}
+
+TEST_CASE("Mat4.ortho maps depth to [0, 1]", "[scripting]")
+{
+    const char* src = "local proj = Mat4.ortho(-2, 2, -1, 1, 0, 10)\n"
+                      "local near = proj:transformPoint(2, 1, 0)\n"
+                      "local far = proj:transformPoint(-2, -1, -10)\n"
+                      "return near.x, near.y, near.z, far.x, far.y, far.z\n";
+    auto t = ScriptingTest(src, 6);
+    CHECK(lua_tonumber(t.state(), -6) == 1.0);  // right -> +1
+    CHECK(lua_tonumber(t.state(), -5) == 1.0);  // top -> +1
+    CHECK(lua_tonumber(t.state(), -4) == 0.0);  // near -> 0
+    CHECK(lua_tonumber(t.state(), -3) == -1.0); // left -> -1
+    CHECK(lua_tonumber(t.state(), -2) == -1.0); // bottom -> -1
+    CHECK(lua_tonumber(t.state(), -1) == 1.0);  // far -> 1
+}
+
+TEST_CASE("Mat4 ortho * lookAt round-trips a point", "[scripting]")
+{
+    // Camera at +z looking at the origin; a point at world z=-5 sits 10 in
+    // front of the eye, the middle of the [5, 15] depth range.
+    const char* src =
+        "local view = Mat4.lookAt(Vector.xyz(0, 0, 5), Vector.origin(),\n"
+        "    Vector.xyz(0, 1, 0))\n"
+        "local proj = Mat4.ortho(-4, 4, -4, 4, 5, 15)\n"
+        "local vp = Mat4.multiply(Mat4.identity(), proj, view)\n"
+        "local v = vp:transformPoint(2, -2, -5)\n"
+        "return v.x, v.y, v.z\n";
+    auto t = ScriptingTest(src, 3);
+    CHECK(lua_tonumber(t.state(), -3) == Approx(0.5));
+    CHECK(lua_tonumber(t.state(), -2) == Approx(-0.5));
+    CHECK(lua_tonumber(t.state(), -1) == Approx(0.5));
+}
+
 TEST_CASE("Mat4 transformVec4 returns homogeneous components", "[scripting]")
 {
     // No perspective divide: w is preserved as the final return value.
diff --git a/tests/unit_tests/runtime/scripting/scripting_vector_test.cpp b/tests/unit_tests/runtime/scripting/scripting_vector_test.cpp
index e553d5a..895df18 100644
--- a/tests/unit_tests/runtime/scripting/scripting_vector_test.cpp
+++ b/tests/unit_tests/runtime/scripting/scripting_vector_test.cpp
@@ -139,6 +139,171 @@
               -1) == 0.0f);
 }
 
+TEST_CASE("vector 3D constructor and cross3 work", "[scripting]")
+{
+    auto t = ScriptingTest("local v = Vector.xyz(1, 2, 3)\n"
+                           "return v.x, v.y, v.z\n",
+                           3);
+    CHECK(lua_tonumber(t.state(), -3) == 1.0f);
+    CHECK(lua_tonumber(t.state(), -2) == 2.0f);
+    CHECK(lua_tonumber(t.state(), -1) == 3.0f);
+
+    // x cross y = z
+    auto c = ScriptingTest(
+        "local v = Vector.cross3(Vector.xyz(1,0,0), Vector.xyz(0,1,0))\n"
+        "return v.x, v.y, v.z\n",
+        3);
+    CHECK(lua_tonumber(c.state(), -3) == 0.0f);
+    CHECK(lua_tonumber(c.state(), -2) == 0.0f);
+    CHECK(lua_tonumber(c.state(), -1) == 1.0f);
+
+    // y cross x = -z
+    CHECK(lua_tonumber(ScriptingTest("return Vector.cross3(Vector.xyz(0,1,0), "
+                                     "Vector.xyz(1,0,0)).z")
+                           .state(),
+                       -1) == -1.0f);
+}
+
+TEST_CASE("vector magnitude ops use all 3 components", "[scripting]")
+{
+    CHECK(lua_tonumber(
+              ScriptingTest("return Vector.length(Vector.xyz(1,2,2))").state(),
+              -1) == 3.0f);
+
+    CHECK(lua_tonumber(
+              ScriptingTest("return Vector.lengthSquared(Vector.xyz(1,2,3))")
+                  .state(),
+              -1) == 14.0f);
+
+    // (1,2,3) . (4,5,6) = 4 + 10 + 18
+    CHECK(lua_tonumber(ScriptingTest("return Vector.dot(Vector.xyz(1,2,3), "
+                                     "Vector.xyz(4,5,6))")
+                           .state(),
+                       -1) == 32.0f);
+
+    CHECK(lua_tonumber(ScriptingTest("return Vector.distance("
+                                     "Vector.xyz(1,1,1), Vector.xyz(1,1,4))")
+                           .state(),
+                       -1) == 3.0f);
+
+    CHECK(lua_tonumber(ScriptingTest("return Vector.distanceSquared("
+                                     "Vector.xyz(0,0,0), Vector.xyz(1,2,3))")
+                           .state(),
+                       -1) == 14.0f);
+
+    auto n = ScriptingTest("local v = Vector.normalized(Vector.xyz(0,3,4))\n"
+                           "return v.y, v.z\n",
+                           2);
+    CHECK(lua_tonumber(n.state(), -2) == Approx(0.6f));
+    CHECK(lua_tonumber(n.state(), -1) == Approx(0.8f));
+
+    // Zero-length normalize stays the zero vector
+    CHECK(lua_tonumber(
+              ScriptingTest("return Vector.normalized(Vector.origin()).x")
+                  .state(),
+              -1) == 0.0f);
+
+    CHECK(lua_tonumber(ScriptingTest("return Vector.lerp(Vector.xyz(0,0,10), "
+                                     "Vector.xyz(0,0,20), 0.5).z")
+                           .state(),
+                       -1) == 15.0f);
+
+    CHECK(lua_tonumber(
+              ScriptingTest("return Vector.scaleAndAdd(Vector.xyz(0,0,1), "
+                            "Vector.xyz(0,0,2), 3).z")
+                  .state(),
+              -1) == 7.0f);
+
+    CHECK(lua_tonumber(
+              ScriptingTest("return Vector.scaleAndSub(Vector.xyz(0,0,7), "
+                            "Vector.xyz(0,0,2), 3).z")
+                  .state(),
+              -1) == 1.0f);
+}
+
+TEST_CASE("vector writeToBuffer and writeVec4 work", "[scripting]")
+{
+    auto t = ScriptingTest("local buf = buffer.create(16)\n"
+                           "Vector.xyz(1, 2, 3):writeToBuffer(buf, 4)\n"
+                           "return buffer.readf32(buf, 4),\n"
+                           "    buffer.readf32(buf, 8),\n"
+                           "    buffer.readf32(buf, 12)\n",
+                           3);
+    CHECK(lua_tonumber(t.state(), -3) == 1.0f);
+    CHECK(lua_tonumber(t.state(), -2) == 2.0f);
+    CHECK(lua_tonumber(t.state(), -1) == 3.0f);
+
+    auto v4 = ScriptingTest("local buf = buffer.create(16)\n"
+                            "Vector.xyz(1, 2, 3):writeVec4(buf, 0, 4)\n"
+                            "return buffer.readf32(buf, 0),\n"
+                            "    buffer.readf32(buf, 12)\n",
+                            2);
+    CHECK(lua_tonumber(v4.state(), -2) == 1.0f);
+    CHECK(lua_tonumber(v4.state(), -1) == 4.0f);
+
+    // Out-of-range offsets error rather than corrupt memory
+    CHECK(!lua_toboolean(
+        ScriptingTest("local buf = buffer.create(12)\n"
+                      "return (pcall(function()\n"
+                      "    Vector.origin():writeToBuffer(buf, 4)\n"
+                      "end))\n")
+            .state(),
+        -1));
+
+    CHECK(!lua_toboolean(
+        ScriptingTest("local buf = buffer.create(16)\n"
+                      "return (pcall(function()\n"
+                      "    Vector.origin():writeVec4(buf, 4, 1)\n"
+                      "end))\n")
+            .state(),
+        -1));
+}
+
+TEST_CASE("vector fastcall and C binding paths agree", "[scripting]")
+{
+    // Static Vector.op(...) calls compile to Luau-fork fastcalls; v:op(...)
+    // and indirect calls hit the C bindings in lua_vec2d.cpp. Pins the two
+    // implementations bit-identical so neither can silently diverge.
+    const char* src =
+        "local a = Vector.xyz(1.5, -2.25, 3.75)\n"
+        "local b = Vector.xyz(-4.5, 5.25, -6.5)\n"
+        "local function check(name, x, y)\n"
+        "    if x ~= y then\n"
+        "        error(`{name} diverged: {x} vs {y}`)\n"
+        "    end\n"
+        "end\n"
+        "-- namecall (C binding) vs static (fastcall)\n"
+        "check('length', a:length(), Vector.length(a))\n"
+        "check('lengthSquared', a:lengthSquared(), Vector.lengthSquared(a))\n"
+        "check('normalized', a:normalized(), Vector.normalized(a))\n"
+        "check('distance', a:distance(b), Vector.distance(a, b))\n"
+        "check('distanceSquared', a:distanceSquared(b),\n"
+        "    Vector.distanceSquared(a, b))\n"
+        "check('dot', a:dot(b), Vector.dot(a, b))\n"
+        "for _, t in {0, 0.375, 1} do\n"
+        "    check(`lerp t={t}`, a:lerp(b, t), Vector.lerp(a, b, t))\n"
+        "end\n"
+        "-- ops with no instance form: indirect call (C binding) vs direct\n"
+        "-- static (fastcall); a table load can't compile to FASTCALL\n"
+        "local ind = {\n"
+        "    Vector.scaleAndAdd,\n"
+        "    Vector.scaleAndSub,\n"
+        "    Vector.cross,\n"
+        "    Vector.normalized,\n"
+        "}\n"
+        "check('scaleAndAdd', ind[1](a, b, 2.5), Vector.scaleAndAdd(a, b, "
+        "2.5))\n"
+        "check('scaleAndSub', ind[2](a, b, 2.5), Vector.scaleAndSub(a, b, "
+        "2.5))\n"
+        "check('cross', ind[3](a, b), Vector.cross(a, b))\n"
+        "-- zero-length normalize stays zero (not NaN) on both paths\n"
+        "local z = Vector.origin()\n"
+        "check('normalizedZero', ind[4](z), Vector.normalized(z))\n"
+        "check('normalizedZeroValue', Vector.normalized(z), z)\n"
+        "return true\n";
+    CHECK(lua_toboolean(ScriptingTest(src).state(), -1));
+}
+
 TEST_CASE("vector indexing work", "[scripting]")
 {
     CHECK(lua_tonumber(ScriptingTest("return Vector.xy(19, 27)[1]").state(),