diff --git a/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-breadcrumb1 b/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-breadcrumb1 new file mode 100644 index 0000000..e69de29 diff --git a/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-breadcrumb2 b/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-breadcrumb2 new file mode 100644 index 0000000..e69de29 diff --git a/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-event b/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-event new file mode 100644 index 0000000..5eaaa4b --- /dev/null +++ b/.sentry-native/65f7adb4-714d-4565-f37b-64b2adb5a4e7.run/__sentry-event @@ -0,0 +1 @@ +ˆ¨platform¦native§release¯pcsx-redux@head«environmentªproduction¥level¥error£sdk„¤name­sentry.native§version¥0.6.1¨packages‘‚¤name¾github:getsentry/sentry-native§version¥0.6.1¬integrations‘¨crashpad¤tags€¥extra€¨contexts¢os„¤name§Windows®kernel_version¯10.0.26100.8036§versionª10.0.26200¥build¤8037 \ No newline at end of file diff --git a/.sentry-native/metadata b/.sentry-native/metadata new file mode 100644 index 0000000..e69de29 diff --git a/.sentry-native/settings.dat b/.sentry-native/settings.dat new file mode 100644 index 0000000..d721006 Binary files /dev/null and b/.sentry-native/settings.dat differ diff --git a/Makefile b/Makefile index 2ad089b..f3e355f 100644 --- a/Makefile +++ b/Makefile @@ -7,15 +7,20 @@ src/renderer.cpp \ src/splashpack.cpp \ src/camera.cpp \ src/gtemath.cpp \ -src/navmesh.cpp \ +src/worldcollision.cpp \ +src/navregion.cpp \ +src/triclip.cpp \ src/lua.cpp \ +src/luaapi.cpp \ src/scenemanager.cpp \ +src/sceneloader.cpp \ +src/audiomanager.cpp \ src/controls.cpp \ src/profiler.cpp \ -output.o +src/collision.cpp \ +src/bvh.cpp + +CPPFLAGS += -DPCDRV_SUPPORT=1 include third_party/nugget/psyqo-lua/psyqo-lua.mk include third_party/nugget/psyqo/psyqo.mk - -%.o: %.bin - $(PREFIX)-objcopy -I binary --set-section-alignment .data=4 --rename-section .data=.rodata,alloc,load,readonly,data,contents -O $(FORMAT) -B mips $< $@ diff --git a/build_output.txt b/build_output.txt new file mode 100644 index 0000000..6d5f51d Binary files /dev/null and b/build_output.txt differ diff --git a/output.bin b/output.bin index a1e3c10..86b9d1f 100644 Binary files a/output.bin and b/output.bin differ diff --git a/src/audiomanager.cpp b/src/audiomanager.cpp new file mode 100644 index 0000000..884265e --- /dev/null +++ b/src/audiomanager.cpp @@ -0,0 +1,199 @@ +#include "audiomanager.hh" + +#include "common/hardware/spu.h" +#include +#include + +namespace psxsplash { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +uint16_t AudioManager::volToHw(int v) { + if (v <= 0) return 0; + if (v >= 128) return 0x3fff; + return static_cast((v * 0x3fff) / 128); +} + +// --------------------------------------------------------------------------- +// Init / Reset +// --------------------------------------------------------------------------- + +void AudioManager::init() { + psyqo::SPU::initialize(); + + m_nextAddr = SPU_RAM_START; + + for (int i = 0; i < MAX_AUDIO_CLIPS; i++) { + m_clips[i].loaded = false; + } +} + +void AudioManager::reset() { + stopAll(); + for (int i = 0; i < MAX_AUDIO_CLIPS; i++) { + m_clips[i].loaded = false; + } + m_nextAddr = SPU_RAM_START; +} + +// --------------------------------------------------------------------------- +// Clip loading +// --------------------------------------------------------------------------- + +bool AudioManager::loadClip(int clipIndex, const uint8_t* adpcmData, uint32_t sizeBytes, + uint16_t sampleRate, bool loop) { + if (clipIndex < 0 || clipIndex >= MAX_AUDIO_CLIPS) return false; + if (!adpcmData || sizeBytes == 0) return false; + + // Check for VAG header (magic "VAGp" at offset 0). + // If present, the header wasn't stripped properly — skip it. + if (sizeBytes >= 48) { + const char* magic = reinterpret_cast(adpcmData); + if (magic[0] == 'V' && magic[1] == 'A' && magic[2] == 'G' && magic[3] == 'p') { + adpcmData += 48; + sizeBytes -= 48; + } + } + + // Align to 16-byte SPU ADPCM block boundary + uint32_t addr = (m_nextAddr + 15) & ~15u; + uint32_t alignedSize = (sizeBytes + 15) & ~15u; + + if (addr + alignedSize > SPU_RAM_END) { + return false; + } + + // psyqo::SPU::dmaWrite takes dataSize as uint16_t so upload in chunks + // for clips larger than 65532 bytes (largest multiple-of-4 that fits). + // + // psyqo DMA math: BCR = blockSize | ((dataSize/blockSize) << 16) + // blockSize=4 → 4 words per block = 16 bytes per block + // block count = dataSize/blockSize + // total bytes = blockSize × (dataSize/blockSize) × 4 = dataSize × 4 + // So dataSize = bytesThisRound / 4 gives the correct byte count. + const uint8_t* src = adpcmData; + uint32_t remaining = alignedSize; + uint32_t dstAddr = addr; + while (remaining > 0) { + // Max transfer per call: 65532 bytes (16383 blocks × 4 bytes each). + uint32_t bytesThisRound = (remaining > 65532u) ? 65532u : remaining; + bytesThisRound &= ~3u; // DMA alignment + if (bytesThisRound == 0) break; + + uint16_t dmaSizeParam = (uint16_t)(bytesThisRound / 4); + psyqo::SPU::dmaWrite(dstAddr, src, dmaSizeParam, 4); + src += bytesThisRound; + dstAddr += bytesThisRound; + remaining -= bytesThisRound; + } + + // dmaWrite() now properly restores transfer mode to idle after each + // DMA transfer, so no manual SPU_CTRL fix-up is needed here. + + m_clips[clipIndex].spuAddr = addr; + m_clips[clipIndex].size = sizeBytes; + m_clips[clipIndex].sampleRate = sampleRate; + m_clips[clipIndex].loop = loop; + m_clips[clipIndex].loaded = true; + + m_nextAddr = addr + alignedSize; + return true; +} + +// --------------------------------------------------------------------------- +// Playback +// --------------------------------------------------------------------------- + +int AudioManager::play(int clipIndex, int volume, int pan) { + if (clipIndex < 0 || clipIndex >= MAX_AUDIO_CLIPS || !m_clips[clipIndex].loaded) { + return -1; + } + + uint32_t ch = psyqo::SPU::getNextFreeChannel(); + if (ch == psyqo::SPU::NO_FREE_CHANNEL) return -1; + + const AudioClip& clip = m_clips[clipIndex]; + + uint16_t vol = volToHw(volume); + uint16_t leftVol = vol; + uint16_t rightVol = vol; + if (pan != 64) { + int p = pan < 0 ? 0 : (pan > 127 ? 127 : pan); + leftVol = (uint16_t)((uint32_t)vol * (127 - p) / 127); + rightVol = (uint16_t)((uint32_t)vol * p / 127); + } + + psyqo::SPU::ChannelPlaybackConfig config; + config.sampleRate.value = static_cast(((uint32_t)clip.sampleRate << 12) / 44100); + config.volumeLeft = leftVol; + config.volumeRight = rightVol; + config.adsr = DEFAULT_ADSR; + + // Set the repeat address depending on loop mode. + // The new psyqo::SPU::getNextFreeChannel() uses the ENDX register: + // a channel is "free" when its ENDX bit is set (voice reached loop-end). + // silenceChannels() points voices at psyqo's silent dummy sample at 0x1000 + // that immediately sets ENDX, so stopped channels are detected as free. + // + // Looping clips: repeat → clip start (loop back to beginning). + // Non-looping clips: repeat → dummy 0x1000 (go silent after clip ends, + // dummy's loop-end flag re-sets ENDX → channel freed). + constexpr uint16_t DUMMY_SPU_ADDR = 0x1000; + if (clip.loop) { + SPU_VOICES[ch].sampleRepeatAddr = static_cast(clip.spuAddr / 8); + } else { + SPU_VOICES[ch].sampleRepeatAddr = DUMMY_SPU_ADDR / 8; + } + + psyqo::SPU::playADPCM(static_cast(ch), + static_cast(clip.spuAddr), + config, true); + + return static_cast(ch); +} + +// --------------------------------------------------------------------------- +// Stop +// --------------------------------------------------------------------------- + +void AudioManager::stopVoice(int channel) { + if (channel < 0 || channel >= MAX_VOICES) return; + psyqo::SPU::silenceChannels(1u << channel); +} + +void AudioManager::stopAll() { + psyqo::SPU::silenceChannels(0x00FFFFFFu); +} + +// --------------------------------------------------------------------------- +// Volume +// --------------------------------------------------------------------------- + +void AudioManager::setVoiceVolume(int channel, int volume, int pan) { + if (channel < 0 || channel >= MAX_VOICES) return; + uint16_t vol = volToHw(volume); + if (pan == 64) { + SPU_VOICES[channel].volumeLeft = vol; + SPU_VOICES[channel].volumeRight = vol; + } else { + int p = pan < 0 ? 0 : (pan > 127 ? 127 : pan); + SPU_VOICES[channel].volumeLeft = (uint16_t)((uint32_t)vol * (127 - p) / 127); + SPU_VOICES[channel].volumeRight = (uint16_t)((uint32_t)vol * p / 127); + } +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +int AudioManager::getLoadedClipCount() const { + int count = 0; + for (int i = 0; i < MAX_AUDIO_CLIPS; i++) { + if (m_clips[i].loaded) count++; + } + return count; +} + +} // namespace psxsplash diff --git a/src/audiomanager.hh b/src/audiomanager.hh new file mode 100644 index 0000000..52c753b --- /dev/null +++ b/src/audiomanager.hh @@ -0,0 +1,97 @@ +#pragma once + +#include + +namespace psxsplash { + +/// Maximum number of audio clips that can be loaded in a scene +static constexpr int MAX_AUDIO_CLIPS = 32; + +/// Maximum SPU voices (hardware limit) +static constexpr int MAX_VOICES = 24; + +/// SPU RAM is 512KB total (0x00000-0x7FFFF). +/// First 0x1000 bytes reserved for capture buffers. +/// psyqo places a 16-byte silent dummy sample at 0x1000. +/// User clips start at 0x1010. +/// +/// Upper bound is 0x10000 (64KB) because psyqo::SPU::playADPCM() +/// takes a uint16_t for the SPU RAM address. +static constexpr uint32_t SPU_RAM_START = 0x1010; +static constexpr uint32_t SPU_RAM_END = 0x10000; + +/// Default ADSR: instant attack, sustain at max, ~46ms linear release. +/// Lower 16-bit (AD): attack linear shift=0 step=0("+7"), decay shift=0, +/// sustain level=0xF (max -> decay skipped) +/// Upper 16-bit (SR): sustain linear increase shift=0 step=0("+7"), +/// release linear shift=10 (~46ms to zero) +static constexpr uint32_t DEFAULT_ADSR = 0x000A000F; + +/// Descriptor for a loaded audio clip in SPU RAM +struct AudioClip { + uint32_t spuAddr; // Byte address in SPU RAM + uint32_t size; // Size of ADPCM data in bytes + uint16_t sampleRate; // Original sample rate in Hz + bool loop; // Whether this clip should loop + bool loaded; // Whether this slot is valid +}; + +/// Manages SPU voices and audio clip playback. +/// +/// Uses psyqo::SPU for all hardware interaction: initialization, +/// DMA uploads, voice allocation (via currentVolume check), playback +/// (playADPCM), and silencing (silenceChannels). +/// +/// init() +/// loadClip(index, data, size, rate, loop) -> bool +/// play(clipIndex) -> channel +/// play(clipIndex, volume, pan) -> channel +/// stopVoice(channel) +/// stopAll() +/// setVoiceVolume(channel, vol, pan) +/// +/// Volume is 0-128 (0=silent, 128=max). Pan is 0-127 (64=center). +class AudioManager { +public: + /// Initialize SPU hardware and reset state + void init(); + + /// Upload ADPCM data to SPU RAM and register as clip index. + /// Data must be 16-byte aligned (SPU ADPCM block size). Returns true on success. + bool loadClip(int clipIndex, const uint8_t* adpcmData, uint32_t sizeBytes, + uint16_t sampleRate, bool loop); + + /// Play a clip by index. Returns channel (0-23), or -1 if full. + /// Volume: 0-128 (128=max). Pan: 0 (left) to 127 (right), 64 = center. + int play(int clipIndex, int volume = 128, int pan = 64); + + /// Stop a specific channel (returned from play()) + void stopVoice(int channel); + + /// Stop all playing channels + void stopAll(); + + /// Set volume/pan on a playing channel + void setVoiceVolume(int channel, int volume, int pan = 64); + + /// Get total SPU RAM used by loaded clips (for visualization) + uint32_t getUsedSPURam() const { return m_nextAddr - SPU_RAM_START; } + + /// Get total SPU RAM available + uint32_t getTotalSPURam() const { return SPU_RAM_END - SPU_RAM_START; } + + /// Get number of loaded clips + int getLoadedClipCount() const; + + /// Reset all clips and free SPU RAM (call on scene unload) + void reset(); + +private: + /// Convert 0-128 volume to hardware 0-0x3FFF (fixed-volume mode) + static uint16_t volToHw(int v); + + AudioClip m_clips[MAX_AUDIO_CLIPS]; + uint32_t m_nextAddr = SPU_RAM_START; // Bump allocator for SPU RAM +}; + +} // namespace psxsplash diff --git a/src/bvh.cpp b/src/bvh.cpp new file mode 100644 index 0000000..a5bb085 --- /dev/null +++ b/src/bvh.cpp @@ -0,0 +1,143 @@ +#include "bvh.hh" + +namespace psxsplash { + +void BVHManager::initialize(const BVHNode* nodes, uint16_t nodeCount, + const TriangleRef* triangleRefs, uint16_t triangleRefCount) { + m_nodes = nodes; + m_nodeCount = nodeCount; + m_triangleRefs = triangleRefs; + m_triangleRefCount = triangleRefCount; +} + +const uint8_t* BVHManager::initializeFromData(const uint8_t* data, uint16_t nodeCount, uint16_t triangleRefCount) { + if (data == nullptr || nodeCount == 0) { + m_nodes = nullptr; + m_triangleRefs = nullptr; + m_nodeCount = 0; + m_triangleRefCount = 0; + return data; + } + + // Point to node array + m_nodes = reinterpret_cast(data); + m_nodeCount = nodeCount; + data += m_nodeCount * sizeof(BVHNode); + + // Point to triangle ref array + m_triangleRefs = reinterpret_cast(data); + m_triangleRefCount = triangleRefCount; + data += m_triangleRefCount * sizeof(TriangleRef); + + return data; +} + +int BVHManager::cullFrustum(const Frustum& frustum, + TriangleRef* outRefs, + int maxRefs) const { + if (!isLoaded() || m_nodeCount == 0) return 0; + + return traverseFrustum(0, frustum, outRefs, 0, maxRefs); +} + +int BVHManager::traverseFrustum(int nodeIndex, + const Frustum& frustum, + TriangleRef* outRefs, + int currentCount, + int maxRefs) const { + if (nodeIndex < 0 || nodeIndex >= m_nodeCount) return currentCount; + if (currentCount >= maxRefs) return currentCount; + + const BVHNode& node = m_nodes[nodeIndex]; + + // Frustum test - if node is completely outside, skip entire subtree + if (!frustum.testAABB(node)) { + return currentCount; // Culled! + } + + // If leaf, add all triangles + if (node.isLeaf()) { + int count = node.triangleCount; + int available = maxRefs - currentCount; + if (count > available) count = available; + + for (int i = 0; i < count; i++) { + outRefs[currentCount + i] = m_triangleRefs[node.firstTriangle + i]; + } + return currentCount + count; + } + + // Recurse into children + if (node.leftChild != 0xFFFF) { + currentCount = traverseFrustum(node.leftChild, frustum, outRefs, currentCount, maxRefs); + } + if (node.rightChild != 0xFFFF) { + currentCount = traverseFrustum(node.rightChild, frustum, outRefs, currentCount, maxRefs); + } + + return currentCount; +} + +int BVHManager::queryRegion(int32_t minX, int32_t minY, int32_t minZ, + int32_t maxX, int32_t maxY, int32_t maxZ, + TriangleRef* outRefs, + int maxRefs) const { + if (!isLoaded() || m_nodeCount == 0) return 0; + + return traverseRegion(0, minX, minY, minZ, maxX, maxY, maxZ, outRefs, 0, maxRefs); +} + +int BVHManager::traverseRegion(int nodeIndex, + int32_t qMinX, int32_t qMinY, int32_t qMinZ, + int32_t qMaxX, int32_t qMaxY, int32_t qMaxZ, + TriangleRef* outRefs, + int currentCount, + int maxRefs) const { + if (nodeIndex < 0 || nodeIndex >= m_nodeCount) return currentCount; + if (currentCount >= maxRefs) return currentCount; + + const BVHNode& node = m_nodes[nodeIndex]; + + // AABB overlap test + if (!aabbOverlap(node, qMinX, qMinY, qMinZ, qMaxX, qMaxY, qMaxZ)) { + return currentCount; // No overlap, skip + } + + // If leaf, add all triangles + if (node.isLeaf()) { + int count = node.triangleCount; + int available = maxRefs - currentCount; + if (count > available) count = available; + + for (int i = 0; i < count; i++) { + outRefs[currentCount + i] = m_triangleRefs[node.firstTriangle + i]; + } + return currentCount + count; + } + + // Recurse into children + if (node.leftChild != 0xFFFF) { + currentCount = traverseRegion(node.leftChild, + qMinX, qMinY, qMinZ, qMaxX, qMaxY, qMaxZ, + outRefs, currentCount, maxRefs); + } + if (node.rightChild != 0xFFFF) { + currentCount = traverseRegion(node.rightChild, + qMinX, qMinY, qMinZ, qMaxX, qMaxY, qMaxZ, + outRefs, currentCount, maxRefs); + } + + return currentCount; +} + +bool BVHManager::aabbOverlap(const BVHNode& node, + int32_t qMinX, int32_t qMinY, int32_t qMinZ, + int32_t qMaxX, int32_t qMaxY, int32_t qMaxZ) { + // Check for separation on any axis + if (node.maxX < qMinX || node.minX > qMaxX) return false; + if (node.maxY < qMinY || node.minY > qMaxY) return false; + if (node.maxZ < qMinZ || node.minZ > qMaxZ) return false; + return true; // Overlapping +} + +} // namespace psxsplash diff --git a/src/bvh.hh b/src/bvh.hh new file mode 100644 index 0000000..8214882 --- /dev/null +++ b/src/bvh.hh @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include + +namespace psxsplash { + +/// Triangle reference - points to a specific triangle in a specific object +struct TriangleRef { + uint16_t objectIndex; + uint16_t triangleIndex; +}; +static_assert(sizeof(TriangleRef) == 4, "TriangleRef must be 4 bytes"); + +/// BVH Node - stored in binary file +/// 32 bytes per node for cache-friendly traversal +struct BVHNode { + // AABB bounds in fixed-point 20.12 format + int32_t minX, minY, minZ; // 12 bytes + int32_t maxX, maxY, maxZ; // 12 bytes + + // Child indices (0xFFFF = no child / leaf indicator) + uint16_t leftChild; // 2 bytes + uint16_t rightChild; // 2 bytes + + // Triangle data (only valid for leaf nodes) + uint16_t firstTriangle; // 2 bytes - index into triangle ref array + uint16_t triangleCount; // 2 bytes + + /// Check if this is a leaf node + bool isLeaf() const { + return leftChild == 0xFFFF && rightChild == 0xFFFF; + } + + /// Test if a point is inside this node's bounds + bool containsPoint(const psyqo::Vec3& point) const { + return point.x.raw() >= minX && point.x.raw() <= maxX && + point.y.raw() >= minY && point.y.raw() <= maxY && + point.z.raw() >= minZ && point.z.raw() <= maxZ; + } + + /// Test if AABB intersects frustum plane + /// plane: normal (xyz) + distance (w) in fixed point + bool testPlane(int32_t nx, int32_t ny, int32_t nz, int32_t d) const { + // Find the corner most in the direction of the plane normal (p-vertex) + int32_t px = (nx >= 0) ? maxX : minX; + int32_t py = (ny >= 0) ? maxY : minY; + int32_t pz = (nz >= 0) ? maxZ : minZ; + + // If p-vertex is on negative side, box is completely outside + // dot(p, n) + d < 0 means outside + int64_t dot = ((int64_t)px * nx + (int64_t)py * ny + (int64_t)pz * nz) >> 12; + return (dot + d) >= 0; + } +}; +static_assert(sizeof(BVHNode) == 32, "BVHNode must be 32 bytes"); + +/// BVH Tree header in binary file +struct BVHHeader { + uint16_t nodeCount; + uint16_t triangleRefCount; +}; +static_assert(sizeof(BVHHeader) == 4, "BVHHeader must be 4 bytes"); + +/// Frustum planes for culling (6 planes) +struct Frustum { + // Each plane: nx, ny, nz (normal), d (distance) + // All in fixed-point 20.12 format + struct Plane { + int32_t nx, ny, nz, d; + }; + Plane planes[6]; // Near, Far, Left, Right, Top, Bottom + + /// Test if AABB is visible (not culled by all planes) + bool testAABB(const BVHNode& node) const { + for (int i = 0; i < 6; i++) { + if (!node.testPlane(planes[i].nx, planes[i].ny, planes[i].nz, planes[i].d)) { + return false; // Completely outside this plane + } + } + return true; // Potentially visible + } +}; + +/// BVH Manager - handles traversal and culling +class BVHManager { +public: + /// Initialize from separate pointers (used by splashpack loader) + void initialize(const BVHNode* nodes, uint16_t nodeCount, + const TriangleRef* triangleRefs, uint16_t triangleRefCount); + + /// Initialize from raw splashpack data (alternative) + /// Returns pointer past the BVH data + const uint8_t* initializeFromData(const uint8_t* data, uint16_t nodeCount, uint16_t triangleRefCount); + + /// Traverse BVH and collect visible triangle references + /// Uses frustum culling to skip invisible branches + /// Returns number of visible triangle refs + int cullFrustum(const Frustum& frustum, + TriangleRef* outRefs, + int maxRefs) const; + + /// Simpler traversal - collect all triangles in a region + /// Useful for collision queries + int queryRegion(int32_t minX, int32_t minY, int32_t minZ, + int32_t maxX, int32_t maxY, int32_t maxZ, + TriangleRef* outRefs, + int maxRefs) const; + + /// Get node count + int getNodeCount() const { return m_nodeCount; } + + /// Get triangle ref count + int getTriangleRefCount() const { return m_triangleRefCount; } + + /// Check if BVH is loaded + bool isLoaded() const { return m_nodes != nullptr; } + +private: + const BVHNode* m_nodes = nullptr; + const TriangleRef* m_triangleRefs = nullptr; + uint16_t m_nodeCount = 0; + uint16_t m_triangleRefCount = 0; + + /// Recursive frustum culling traversal + int traverseFrustum(int nodeIndex, + const Frustum& frustum, + TriangleRef* outRefs, + int currentCount, + int maxRefs) const; + + /// Recursive region query traversal + int traverseRegion(int nodeIndex, + int32_t qMinX, int32_t qMinY, int32_t qMinZ, + int32_t qMaxX, int32_t qMaxY, int32_t qMaxZ, + TriangleRef* outRefs, + int currentCount, + int maxRefs) const; + + /// Test if two AABBs overlap + static bool aabbOverlap(const BVHNode& node, + int32_t qMinX, int32_t qMinY, int32_t qMinZ, + int32_t qMaxX, int32_t qMaxY, int32_t qMaxZ); +}; + +// ── Room/portal data for interior scene occlusion ── + +/// Per-room data loaded from splashpack v11+. +/// AABB for point-in-room tests plus a range into the room triangle-ref array. +struct RoomData { + int32_t aabbMinX, aabbMinY, aabbMinZ; // 12 bytes + int32_t aabbMaxX, aabbMaxY, aabbMaxZ; // 12 bytes + uint16_t firstTriRef; // 2 bytes - index into room tri-ref array + uint16_t triRefCount; // 2 bytes + uint32_t pad; // 4 bytes (alignment) +}; +static_assert(sizeof(RoomData) == 32, "RoomData must be 32 bytes"); + +/// Per-portal data connecting two rooms. +/// Center position is in fixed-point world/GTE space (20.12). +/// halfW/halfH define the portal opening size. +/// Normal, right, and up define the portal's orientation in world space. +/// Corner vertices are computed as: center +/- right*halfW +/- up*halfH. +struct PortalData { + uint16_t roomA; // 2 bytes + uint16_t roomB; // 2 bytes + int32_t centerX, centerY, centerZ; // 12 bytes - portal center (20.12 fp) + int16_t halfW; // 2 bytes - half-width in GTE units (4.12 fp) + int16_t halfH; // 2 bytes - half-height in GTE units (4.12 fp) + int16_t normalX, normalY, normalZ; // 6 bytes - facing direction (4.12 fp unit vector) + int16_t pad; // 2 bytes - alignment + int16_t rightX, rightY, rightZ; // 6 bytes - local right axis (4.12 fp unit vector) + int16_t upX, upY, upZ; // 6 bytes - local up axis (4.12 fp unit vector) +}; +static_assert(sizeof(PortalData) == 40, "PortalData must be 40 bytes"); + +} // namespace psxsplash diff --git a/src/camera.cpp b/src/camera.cpp index 0dfe72b..c7e3834 100644 --- a/src/camera.cpp +++ b/src/camera.cpp @@ -34,4 +34,124 @@ void psxsplash::Camera::SetRotation(psyqo::Angle x, psyqo::Angle y, psyqo::Angle m_rotationMatrix = rotY; } -psyqo::Matrix33& psxsplash::Camera::GetRotation() { return m_rotationMatrix; } \ No newline at end of file +psyqo::Matrix33& psxsplash::Camera::GetRotation() { return m_rotationMatrix; } + +void psxsplash::Camera::ExtractFrustum(Frustum& frustum) const { + // ========================================================================= + // FRUSTUM CULLING FOR PSX/GTE COORDINATE SYSTEM + // ========================================================================= + // + // GTE projection settings (from renderer): + // Screen: 320x240 (half-width=160, half-height=120) + // H = 120 (projection plane distance) + // + // FOV calculation: + // Horizontal half-angle: atan(160/120) ≈ 53° → total ~106° horizontal FOV + // Vertical half-angle: atan(120/120) = 45° → total 90° vertical FOV + // + // For frustum plane normals, we use the ratio of screen edge to H: + // Left/Right planes: normal = forward * screenHalfWidth + right * H + // Top/Bottom planes: normal = forward * screenHalfHeight + up * H + // + // GTE uses right-handed coordinate system: + // +X = Right, +Y = Up, +Z = INTO the screen (forward) + // + // The rotation matrix is the VIEW MATRIX - transforms world→camera space. + // For a view matrix: ROWS are the camera axes in world space. + // + // Frustum plane convention (matching testPlane in bvh.hh): + // Normal points INTO the frustum (toward visible space) + // Point is INSIDE frustum if dot(point, normal) + d >= 0 + // ========================================================================= + + // GTE projection parameters (must match renderer setup) + constexpr int32_t SCREEN_HALF_WIDTH = 160; // 320/2 + constexpr int32_t SCREEN_HALF_HEIGHT = 120; // 240/2 + constexpr int32_t H = 120; // Projection distance + + // Camera axes in world space (ROWS of view rotation matrix) + int32_t rightX = m_rotationMatrix.vs[0].x.raw(); + int32_t rightY = m_rotationMatrix.vs[0].y.raw(); + int32_t rightZ = m_rotationMatrix.vs[0].z.raw(); + + int32_t upX = m_rotationMatrix.vs[1].x.raw(); + int32_t upY = m_rotationMatrix.vs[1].y.raw(); + int32_t upZ = m_rotationMatrix.vs[1].z.raw(); + + int32_t fwdX = m_rotationMatrix.vs[2].x.raw(); + int32_t fwdY = m_rotationMatrix.vs[2].y.raw(); + int32_t fwdZ = m_rotationMatrix.vs[2].z.raw(); + + int32_t camX = m_position.x.raw(); + int32_t camY = m_position.y.raw(); + int32_t camZ = m_position.z.raw(); + + // ========================================================================= + // PLANE 0: NEAR PLANE + // Normal points FORWARD (into visible space) + // ========================================================================= + frustum.planes[0].nx = fwdX; + frustum.planes[0].ny = fwdY; + frustum.planes[0].nz = fwdZ; + int64_t fwdDotCam = ((int64_t)fwdX * camX + (int64_t)fwdY * camY + (int64_t)fwdZ * camZ) >> 12; + frustum.planes[0].d = -fwdDotCam; + + // ========================================================================= + // PLANE 1: FAR PLANE + // Normal points BACKWARD (toward camera) + // Far distance in fixed 20.12: 4096 = 1 unit, so 4096000 ≈ 1000 units + // ========================================================================= + frustum.planes[1].nx = -fwdX; + frustum.planes[1].ny = -fwdY; + frustum.planes[1].nz = -fwdZ; + frustum.planes[1].d = fwdDotCam + (4096 * 2000); // 2000 units far plane + + // ========================================================================= + // SIDE PLANES - Based on actual GTE FOV + // + // The frustum edge in camera space goes through (±screenHalf, 0, H). + // Plane normal (pointing INTO frustum) = right * H + forward * screenHalfWidth + // (for left plane, we add right; for right plane, we subtract right) + // + // Note: axes are in 4.12 fixed point (4096 = 1.0), but H and screen values + // are integers. We scale H to match: H * 4096 / some_factor + // Since we just need the ratio, we can use H and screenHalf directly + // as weights for the axis vectors. + // ========================================================================= + + // PLANE 2: LEFT PLANE - cull things to the LEFT of view + // Normal = right * H + forward * screenHalfWidth (points into frustum) + frustum.planes[2].nx = ((int64_t)rightX * H + (int64_t)fwdX * SCREEN_HALF_WIDTH) >> 12; + frustum.planes[2].ny = ((int64_t)rightY * H + (int64_t)fwdY * SCREEN_HALF_WIDTH) >> 12; + frustum.planes[2].nz = ((int64_t)rightZ * H + (int64_t)fwdZ * SCREEN_HALF_WIDTH) >> 12; + frustum.planes[2].d = -(((int64_t)frustum.planes[2].nx * camX + + (int64_t)frustum.planes[2].ny * camY + + (int64_t)frustum.planes[2].nz * camZ) >> 12); + + // PLANE 3: RIGHT PLANE - cull things to the RIGHT of view + // Normal = -right * H + forward * screenHalfWidth (points into frustum) + frustum.planes[3].nx = ((int64_t)(-rightX) * H + (int64_t)fwdX * SCREEN_HALF_WIDTH) >> 12; + frustum.planes[3].ny = ((int64_t)(-rightY) * H + (int64_t)fwdY * SCREEN_HALF_WIDTH) >> 12; + frustum.planes[3].nz = ((int64_t)(-rightZ) * H + (int64_t)fwdZ * SCREEN_HALF_WIDTH) >> 12; + frustum.planes[3].d = -(((int64_t)frustum.planes[3].nx * camX + + (int64_t)frustum.planes[3].ny * camY + + (int64_t)frustum.planes[3].nz * camZ) >> 12); + + // PLANE 4: BOTTOM PLANE - cull things BELOW view + // Normal = up * H + forward * screenHalfHeight (points into frustum) + frustum.planes[4].nx = ((int64_t)upX * H + (int64_t)fwdX * SCREEN_HALF_HEIGHT) >> 12; + frustum.planes[4].ny = ((int64_t)upY * H + (int64_t)fwdY * SCREEN_HALF_HEIGHT) >> 12; + frustum.planes[4].nz = ((int64_t)upZ * H + (int64_t)fwdZ * SCREEN_HALF_HEIGHT) >> 12; + frustum.planes[4].d = -(((int64_t)frustum.planes[4].nx * camX + + (int64_t)frustum.planes[4].ny * camY + + (int64_t)frustum.planes[4].nz * camZ) >> 12); + + // PLANE 5: TOP PLANE - cull things ABOVE view + // Normal = -up * H + forward * screenHalfHeight (points into frustum) + frustum.planes[5].nx = ((int64_t)(-upX) * H + (int64_t)fwdX * SCREEN_HALF_HEIGHT) >> 12; + frustum.planes[5].ny = ((int64_t)(-upY) * H + (int64_t)fwdY * SCREEN_HALF_HEIGHT) >> 12; + frustum.planes[5].nz = ((int64_t)(-upZ) * H + (int64_t)fwdZ * SCREEN_HALF_HEIGHT) >> 12; + frustum.planes[5].d = -(((int64_t)frustum.planes[5].nx * camX + + (int64_t)frustum.planes[5].ny * camY + + (int64_t)frustum.planes[5].nz * camZ) >> 12); +} \ No newline at end of file diff --git a/src/camera.hh b/src/camera.hh index 39c3e84..45dffd0 100644 --- a/src/camera.hh +++ b/src/camera.hh @@ -4,6 +4,8 @@ #include #include +#include "bvh.hh" + namespace psxsplash { // Camera class for managing 3D position and rotation. @@ -20,6 +22,10 @@ class Camera { void SetRotation(psyqo::Angle x, psyqo::Angle y, psyqo::Angle z); psyqo::Matrix33& GetRotation(); + + /// Extract frustum planes for culling + /// Near/Far planes based on typical PS1 draw distances + void ExtractFrustum(Frustum& frustum) const; private: psyqo::Matrix33 m_rotationMatrix; diff --git a/src/collision.cpp b/src/collision.cpp new file mode 100644 index 0000000..0a23f82 --- /dev/null +++ b/src/collision.cpp @@ -0,0 +1,468 @@ +#include "collision.hh" +#include "scenemanager.hh" + +#include + +// Helper type alias for brevity +using FP = psyqo::FixedPoint<12>; + +namespace psxsplash { + +// Static member initialization +psyqo::FixedPoint<12> SpatialGrid::WORLD_MIN = FP(-16); +psyqo::FixedPoint<12> SpatialGrid::WORLD_MAX = FP(16); +psyqo::FixedPoint<12> SpatialGrid::CELL_SIZE = FP(4); // (32 / 8) = 4 + +// AABB expand implementation +void AABB::expand(const psyqo::Vec3& delta) { + psyqo::FixedPoint<12> zero; + if (delta.x > zero) max.x = max.x + delta.x; + else min.x = min.x + delta.x; + if (delta.y > zero) max.y = max.y + delta.y; + else min.y = min.y + delta.y; + if (delta.z > zero) max.z = max.z + delta.z; + else min.z = min.z + delta.z; +} + +// ============================================================================ +// SpatialGrid Implementation +// ============================================================================ + +void SpatialGrid::clear() { + for (int i = 0; i < CELL_COUNT; i++) { + m_cells[i].count = 0; + } +} + +void SpatialGrid::worldToGrid(const psyqo::Vec3& pos, int& gx, int& gy, int& gz) const { + // Clamp position to world bounds + auto px = pos.x; + auto py = pos.y; + auto pz = pos.z; + + if (px < WORLD_MIN) px = WORLD_MIN; + if (px > WORLD_MAX) px = WORLD_MAX; + if (py < WORLD_MIN) py = WORLD_MIN; + if (py > WORLD_MAX) py = WORLD_MAX; + if (pz < WORLD_MIN) pz = WORLD_MIN; + if (pz > WORLD_MAX) pz = WORLD_MAX; + + // Convert to grid coordinates (0 to GRID_SIZE-1) + // Using integer division after scaling + gx = ((px - WORLD_MIN) / CELL_SIZE).integer(); + gy = ((py - WORLD_MIN) / CELL_SIZE).integer(); + gz = ((pz - WORLD_MIN) / CELL_SIZE).integer(); + + // Clamp to valid range + if (gx < 0) gx = 0; + if (gx >= GRID_SIZE) gx = GRID_SIZE - 1; + if (gy < 0) gy = 0; + if (gy >= GRID_SIZE) gy = GRID_SIZE - 1; + if (gz < 0) gz = 0; + if (gz >= GRID_SIZE) gz = GRID_SIZE - 1; +} + +int SpatialGrid::getCellIndex(const psyqo::Vec3& pos) const { + int gx, gy, gz; + worldToGrid(pos, gx, gy, gz); + return gx + gy * GRID_SIZE + gz * GRID_SIZE * GRID_SIZE; +} + +void SpatialGrid::insert(uint16_t objectIndex, const AABB& bounds) { + // Get grid range for this AABB + int minGx, minGy, minGz; + int maxGx, maxGy, maxGz; + + worldToGrid(bounds.min, minGx, minGy, minGz); + worldToGrid(bounds.max, maxGx, maxGy, maxGz); + + // Insert into all overlapping cells + for (int gz = minGz; gz <= maxGz; gz++) { + for (int gy = minGy; gy <= maxGy; gy++) { + for (int gx = minGx; gx <= maxGx; gx++) { + int cellIndex = gx + gy * GRID_SIZE + gz * GRID_SIZE * GRID_SIZE; + Cell& cell = m_cells[cellIndex]; + + if (cell.count < MAX_OBJECTS_PER_CELL) { + cell.objectIndices[cell.count++] = objectIndex; + } + // If cell is full, object won't be in this cell (may miss collisions) + // This is a tradeoff for memory/performance + } + } + } +} + +int SpatialGrid::queryAABB(const AABB& bounds, uint16_t* output, int maxResults) const { + int resultCount = 0; + + // Get grid range for query AABB + int minGx, minGy, minGz; + int maxGx, maxGy, maxGz; + + worldToGrid(bounds.min, minGx, minGy, minGz); + worldToGrid(bounds.max, maxGx, maxGy, maxGz); + + // Track which objects we've already added (two 32-bit masks for objects 0-63) + uint32_t addedMaskLow = 0; // Objects 0-31 + uint32_t addedMaskHigh = 0; // Objects 32-63 + + // Query all overlapping cells + for (int gz = minGz; gz <= maxGz; gz++) { + for (int gy = minGy; gy <= maxGy; gy++) { + for (int gx = minGx; gx <= maxGx; gx++) { + int cellIndex = gx + gy * GRID_SIZE + gz * GRID_SIZE * GRID_SIZE; + const Cell& cell = m_cells[cellIndex]; + + for (int i = 0; i < cell.count; i++) { + uint16_t objIndex = cell.objectIndices[i]; + + // Skip if already added (using bitmask for objects 0-63) + if (objIndex < 32) { + uint32_t bit = 1U << objIndex; + if (addedMaskLow & bit) continue; + addedMaskLow |= bit; + } else if (objIndex < 64) { + uint32_t bit = 1U << (objIndex - 32); + if (addedMaskHigh & bit) continue; + addedMaskHigh |= bit; + } + + if (resultCount < maxResults) { + output[resultCount++] = objIndex; + } + } + } + } + } + + return resultCount; +} + +// ============================================================================ +// CollisionSystem Implementation +// ============================================================================ + +void CollisionSystem::init() { + reset(); +} + +void CollisionSystem::reset() { + m_colliderCount = 0; + m_resultCount = 0; + m_triggerPairCount = 0; + m_grid.clear(); +} + +void CollisionSystem::registerCollider(uint16_t gameObjectIndex, const AABB& localBounds, + CollisionType type, CollisionMask mask) { + if (m_colliderCount >= MAX_COLLIDERS) { + // Out of collider slots + return; + } + + CollisionData& data = m_colliders[m_colliderCount++]; + data.bounds = localBounds; // Will be transformed in updateCollider + data.type = type; + data.layerMask = mask; + data.flags = 0; + data.gridCell = 0; + data.gameObjectIndex = gameObjectIndex; +} + +void CollisionSystem::updateCollider(uint16_t gameObjectIndex, const psyqo::Vec3& position, + const psyqo::Matrix33& rotation) { + // Find the collider for this object + for (int i = 0; i < m_colliderCount; i++) { + if (m_colliders[i].gameObjectIndex == gameObjectIndex) { + // For now, just translate the AABB (no rotation support for AABBs) + // TODO: Compute rotated AABB if needed + + // Store original local bounds somewhere if we need to recalculate + // For now, assume bounds are already world-relative + m_colliders[i].bounds.min = m_colliders[i].bounds.min + position; + m_colliders[i].bounds.max = m_colliders[i].bounds.max + position; + break; + } + } +} + +int CollisionSystem::detectCollisions() { + m_resultCount = 0; + + // Clear and rebuild spatial grid + m_grid.clear(); + for (int i = 0; i < m_colliderCount; i++) { + m_grid.insert(i, m_colliders[i].bounds); + } + + // Check each collider against potential colliders from grid + for (int i = 0; i < m_colliderCount; i++) { + const CollisionData& colliderA = m_colliders[i]; + + // Skip if no collision type + if (colliderA.type == CollisionType::None) continue; + + // Query spatial grid for nearby objects + uint16_t nearby[32]; + int nearbyCount = m_grid.queryAABB(colliderA.bounds, nearby, 32); + + for (int j = 0; j < nearbyCount; j++) { + int otherIndex = nearby[j]; + + // Skip self + if (otherIndex == i) continue; + + // Skip if already processed (only process pairs once) + if (otherIndex < i) continue; + + const CollisionData& colliderB = m_colliders[otherIndex]; + + // Skip if no collision type + if (colliderB.type == CollisionType::None) continue; + + // Check layer masks + if ((colliderA.layerMask & colliderB.layerMask) == 0) continue; + + // Narrowphase AABB test + psyqo::Vec3 normal; + psyqo::FixedPoint<12> penetration; + + if (testAABB(colliderA.bounds, colliderB.bounds, normal, penetration)) { + // Collision detected + if (m_resultCount < MAX_COLLISION_RESULTS) { + CollisionResult& result = m_results[m_resultCount++]; + result.objectA = colliderA.gameObjectIndex; + result.objectB = colliderB.gameObjectIndex; + result.normal = normal; + result.penetration = penetration; + } + + // Handle triggers + if (colliderA.type == CollisionType::Trigger) { + updateTriggerState(i, otherIndex, true); + } + if (colliderB.type == CollisionType::Trigger) { + updateTriggerState(otherIndex, i, true); + } + } + } + } + + // Update trigger pairs that are no longer overlapping + for (int i = 0; i < m_triggerPairCount; i++) { + TriggerPair& pair = m_triggerPairs[i]; + pair.framesSinceContact++; + + // If no contact for several frames, trigger exit + if (pair.framesSinceContact > 2 && pair.state != 2) { + pair.state = 2; // Exiting + } + } + + return m_resultCount; +} + +bool CollisionSystem::testAABB(const AABB& a, const AABB& b, + psyqo::Vec3& normal, psyqo::FixedPoint<12>& penetration) const { + // Check for overlap on all axes + if (a.max.x < b.min.x || a.min.x > b.max.x) return false; + if (a.max.y < b.min.y || a.min.y > b.max.y) return false; + if (a.max.z < b.min.z || a.min.z > b.max.z) return false; + + // Calculate penetration on each axis + auto overlapX1 = a.max.x - b.min.x; + auto overlapX2 = b.max.x - a.min.x; + auto overlapY1 = a.max.y - b.min.y; + auto overlapY2 = b.max.y - a.min.y; + auto overlapZ1 = a.max.z - b.min.z; + auto overlapZ2 = b.max.z - a.min.z; + + // Find minimum overlap axis + auto minOverlapX = (overlapX1 < overlapX2) ? overlapX1 : overlapX2; + auto minOverlapY = (overlapY1 < overlapY2) ? overlapY1 : overlapY2; + auto minOverlapZ = (overlapZ1 < overlapZ2) ? overlapZ1 : overlapZ2; + + // Constants for normals + const FP zero(0); + const FP one(1); + const FP negOne(-1); + + // Determine separation axis (axis with least penetration) + if (minOverlapX <= minOverlapY && minOverlapX <= minOverlapZ) { + penetration = minOverlapX; + normal = psyqo::Vec3{(overlapX1 < overlapX2) ? negOne : one, zero, zero}; + } else if (minOverlapY <= minOverlapZ) { + penetration = minOverlapY; + normal = psyqo::Vec3{zero, (overlapY1 < overlapY2) ? negOne : one, zero}; + } else { + penetration = minOverlapZ; + normal = psyqo::Vec3{zero, zero, (overlapZ1 < overlapZ2) ? negOne : one}; + } + + return true; +} + +void CollisionSystem::updateTriggerState(uint16_t triggerIndex, uint16_t otherIndex, bool isOverlapping) { + // Look for existing pair + for (int i = 0; i < m_triggerPairCount; i++) { + TriggerPair& pair = m_triggerPairs[i]; + if (pair.triggerIndex == triggerIndex && pair.otherIndex == otherIndex) { + if (isOverlapping) { + pair.framesSinceContact = 0; + if (pair.state == 0) { + pair.state = 1; // Now staying + } + } + return; + } + } + + // New pair - add it + if (isOverlapping && m_triggerPairCount < MAX_TRIGGERS) { + TriggerPair& pair = m_triggerPairs[m_triggerPairCount++]; + pair.triggerIndex = triggerIndex; + pair.otherIndex = otherIndex; + pair.framesSinceContact = 0; + pair.state = 0; // New (enter event) + } +} + +bool CollisionSystem::areColliding(uint16_t indexA, uint16_t indexB) const { + for (int i = 0; i < m_resultCount; i++) { + if ((m_results[i].objectA == indexA && m_results[i].objectB == indexB) || + (m_results[i].objectA == indexB && m_results[i].objectB == indexA)) { + return true; + } + } + return false; +} + +bool CollisionSystem::raycast(const psyqo::Vec3& origin, const psyqo::Vec3& direction, + psyqo::FixedPoint<12> maxDistance, + psyqo::Vec3& hitPoint, psyqo::Vec3& hitNormal, + uint16_t& hitObjectIndex) const { + // Simple brute-force raycast against all colliders + // TODO: Use spatial grid for optimization + + auto closestT = maxDistance; + bool hit = false; + + // Fixed-point constants + const FP zero(0); + const FP one(1); + const FP negOne(-1); + const FP largeVal(1000); + const FP negLargeVal(-1000); + FP epsilon; + epsilon.value = 4; // ~0.001 in 20.12 fixed point + + for (int i = 0; i < m_colliderCount; i++) { + const CollisionData& collider = m_colliders[i]; + if (collider.type == CollisionType::None) continue; + + // Ray-AABB intersection test (slab method) + const AABB& box = collider.bounds; + + auto tMin = negLargeVal; + auto tMax = largeVal; + + // X slab + if (direction.x != zero) { + auto invD = one / direction.x; + auto t1 = (box.min.x - origin.x) * invD; + auto t2 = (box.max.x - origin.x) * invD; + if (t1 > t2) { auto tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tMin) tMin = t1; + if (t2 < tMax) tMax = t2; + } else if (origin.x < box.min.x || origin.x > box.max.x) { + continue; + } + + // Y slab + if (direction.y != zero) { + auto invD = one / direction.y; + auto t1 = (box.min.y - origin.y) * invD; + auto t2 = (box.max.y - origin.y) * invD; + if (t1 > t2) { auto tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tMin) tMin = t1; + if (t2 < tMax) tMax = t2; + } else if (origin.y < box.min.y || origin.y > box.max.y) { + continue; + } + + // Z slab + if (direction.z != zero) { + auto invD = one / direction.z; + auto t1 = (box.min.z - origin.z) * invD; + auto t2 = (box.max.z - origin.z) * invD; + if (t1 > t2) { auto tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tMin) tMin = t1; + if (t2 < tMax) tMax = t2; + } else if (origin.z < box.min.z || origin.z > box.max.z) { + continue; + } + + if (tMin > tMax || tMax < zero) continue; + + auto t = (tMin >= zero) ? tMin : tMax; + + if (t < closestT && t >= zero) { + closestT = t; + hitObjectIndex = collider.gameObjectIndex; + hit = true; + + // Calculate hit point + hitPoint = psyqo::Vec3{ + origin.x + direction.x * t, + origin.y + direction.y * t, + origin.z + direction.z * t + }; + + // Calculate normal (which face was hit) + if ((hitPoint.x - box.min.x).abs() < epsilon) hitNormal = psyqo::Vec3{negOne, zero, zero}; + else if ((hitPoint.x - box.max.x).abs() < epsilon) hitNormal = psyqo::Vec3{one, zero, zero}; + else if ((hitPoint.y - box.min.y).abs() < epsilon) hitNormal = psyqo::Vec3{zero, negOne, zero}; + else if ((hitPoint.y - box.max.y).abs() < epsilon) hitNormal = psyqo::Vec3{zero, one, zero}; + else if ((hitPoint.z - box.min.z).abs() < epsilon) hitNormal = psyqo::Vec3{zero, zero, negOne}; + else hitNormal = psyqo::Vec3{zero, zero, one}; + } + } + + return hit; +} + +void CollisionSystem::processTriggerEvents(SceneManager& scene) { + // Process trigger pairs and fire Lua events + int writeIndex = 0; + + for (int i = 0; i < m_triggerPairCount; i++) { + TriggerPair& pair = m_triggerPairs[i]; + + // Get game object indices + uint16_t triggerObjIdx = m_colliders[pair.triggerIndex].gameObjectIndex; + uint16_t otherObjIdx = m_colliders[pair.otherIndex].gameObjectIndex; + + switch (pair.state) { + case 0: // Enter + scene.fireTriggerEnter(triggerObjIdx, otherObjIdx); + pair.state = 1; // Move to staying + m_triggerPairs[writeIndex++] = pair; + break; + + case 1: // Staying + scene.fireTriggerStay(triggerObjIdx, otherObjIdx); + m_triggerPairs[writeIndex++] = pair; + break; + + case 2: // Exit + scene.fireTriggerExit(triggerObjIdx, otherObjIdx); + // Don't copy - remove from list + break; + } + } + + m_triggerPairCount = writeIndex; +} + +} // namespace psxsplash diff --git a/src/collision.hh b/src/collision.hh new file mode 100644 index 0000000..2c535e0 --- /dev/null +++ b/src/collision.hh @@ -0,0 +1,239 @@ +#pragma once + +/** + * collision.hh - PS1 Collision System + * + * Provides spatial hashing broadphase and AABB narrowphase collision detection. + * Designed for PS1's limited CPU - uses fixed-point math and spatial partitioning. + * + * Architecture: + * - Broadphase: Spatial grid (cells of fixed size) + * - Narrowphase: AABB intersection tests + * - Trigger system: Enter/Stay/Exit events + */ + +#include +#include +#include + +#include "gameobject.hh" + +namespace psxsplash { + +// Forward declarations +class SceneManager; + +/** + * Collision type flags - matches Unity PSXCollisionType enum + */ +enum class CollisionType : uint8_t { + None = 0, + Solid = 1, // Blocks movement + Trigger = 2, // Fires events, doesn't block + Platform = 3 // Solid from above only +}; + +/** + * Collision layer mask - 8 layers available + * Objects only collide with matching layers + */ +using CollisionMask = uint8_t; + +/** + * Axis-Aligned Bounding Box in fixed-point + * Used for broadphase and narrowphase collision + */ +struct AABB { + psyqo::Vec3 min; + psyqo::Vec3 max; + + // Check if this AABB intersects another + bool intersects(const AABB& other) const { + return (min.x <= other.max.x && max.x >= other.min.x) && + (min.y <= other.max.y && max.y >= other.min.y) && + (min.z <= other.max.z && max.z >= other.min.z); + } + + // Check if a point is inside this AABB + bool contains(const psyqo::Vec3& point) const { + return (point.x >= min.x && point.x <= max.x) && + (point.y >= min.y && point.y <= max.y) && + (point.z >= min.z && point.z <= max.z); + } + + // Get center of AABB + psyqo::Vec3 center() const { + return psyqo::Vec3{ + (min.x + max.x) / 2, + (min.y + max.y) / 2, + (min.z + max.z) / 2 + }; + } + + // Get half-extents + psyqo::Vec3 halfExtents() const { + return psyqo::Vec3{ + (max.x - min.x) / 2, + (max.y - min.y) / 2, + (max.z - min.z) / 2 + }; + } + + // Expand AABB by a vector (for swept tests) + void expand(const psyqo::Vec3& delta); +}; +static_assert(sizeof(AABB) == 24, "AABB must be 24 bytes (2x Vec3)"); + +/** + * Collision data for a single object + * Stored separately from GameObject for cache efficiency + */ +struct CollisionData { + AABB bounds; // World-space AABB (24 bytes) + CollisionType type; // Collision behavior (1 byte) + CollisionMask layerMask; // Which layers this collides with (1 byte) + uint8_t flags; // Additional flags (1 byte) + uint8_t gridCell; // Current spatial grid cell (1 byte) + uint16_t gameObjectIndex; // Index into GameObject array (2 bytes) + uint16_t padding; // Alignment padding (2 bytes) +}; +static_assert(sizeof(CollisionData) == 32, "CollisionData must be 32 bytes"); + +/** + * Collision result - returned when collision is detected + */ +struct CollisionResult { + uint16_t objectA; // First object index + uint16_t objectB; // Second object index + psyqo::Vec3 normal; // Collision normal (from A to B) + psyqo::FixedPoint<12> penetration; // Penetration depth +}; + +/** + * Trigger state for tracking enter/stay/exit + */ +struct TriggerPair { + uint16_t triggerIndex; // Index of trigger object + uint16_t otherIndex; // Index of other object + uint8_t framesSinceContact; // Counter for exit detection + uint8_t state; // 0=new, 1=staying, 2=exiting + uint16_t padding; +}; + +/** + * Spatial Grid for broadphase collision + * Divides world into fixed-size cells for fast overlap queries + */ +class SpatialGrid { +public: + // Grid configuration + static constexpr int GRID_SIZE = 8; // 8x8x8 grid + static constexpr int CELL_COUNT = GRID_SIZE * GRID_SIZE * GRID_SIZE; + static constexpr int MAX_OBJECTS_PER_CELL = 16; + + // World bounds (fixed for simplicity) - values set in collision.cpp + static psyqo::FixedPoint<12> WORLD_MIN; + static psyqo::FixedPoint<12> WORLD_MAX; + static psyqo::FixedPoint<12> CELL_SIZE; + + struct Cell { + uint16_t objectIndices[MAX_OBJECTS_PER_CELL]; + uint8_t count; + uint8_t padding[3]; + }; + + // Clear all cells + void clear(); + + // Insert an object into the grid + void insert(uint16_t objectIndex, const AABB& bounds); + + // Get all potential colliders for an AABB + // Returns number of results written to output + int queryAABB(const AABB& bounds, uint16_t* output, int maxResults) const; + + // Get cell index for a position + int getCellIndex(const psyqo::Vec3& pos) const; + +private: + Cell m_cells[CELL_COUNT]; + + // Convert world position to grid coordinates + void worldToGrid(const psyqo::Vec3& pos, int& gx, int& gy, int& gz) const; +}; + +/** + * Main Collision System + * Manages all collision detection and trigger events + */ +class CollisionSystem { +public: + static constexpr int MAX_COLLIDERS = 64; + static constexpr int MAX_TRIGGERS = 32; + static constexpr int MAX_COLLISION_RESULTS = 32; + + CollisionSystem() = default; + + // Initialize the system + void init(); + + // Reset for new scene + void reset(); + + // Register a collider (called during scene load) + void registerCollider(uint16_t gameObjectIndex, const AABB& localBounds, + CollisionType type, CollisionMask mask); + + // Update collision data for an object (call when object moves) + void updateCollider(uint16_t gameObjectIndex, const psyqo::Vec3& position, + const psyqo::Matrix33& rotation); + + // Run collision detection for one frame + // Returns number of collisions detected + int detectCollisions(); + + // Get collision results (valid until next detectCollisions call) + const CollisionResult* getResults() const { return m_results; } + int getResultCount() const { return m_resultCount; } + + // Check if two specific objects are colliding + bool areColliding(uint16_t indexA, uint16_t indexB) const; + + // Raycast against all colliders + // Returns true if hit, fills hitPoint and hitNormal + bool raycast(const psyqo::Vec3& origin, const psyqo::Vec3& direction, + psyqo::FixedPoint<12> maxDistance, + psyqo::Vec3& hitPoint, psyqo::Vec3& hitNormal, + uint16_t& hitObjectIndex) const; + + // Get trigger events for current frame (call from SceneManager) + void processTriggerEvents(class SceneManager& scene); + + // Debug: Get collider count + int getColliderCount() const { return m_colliderCount; } + +private: + // Collision data for all registered colliders + CollisionData m_colliders[MAX_COLLIDERS]; + int m_colliderCount = 0; + + // Spatial partitioning grid + SpatialGrid m_grid; + + // Collision results for current frame + CollisionResult m_results[MAX_COLLISION_RESULTS]; + int m_resultCount = 0; + + // Trigger tracking + TriggerPair m_triggerPairs[MAX_TRIGGERS]; + int m_triggerPairCount = 0; + + // Narrowphase AABB test + bool testAABB(const AABB& a, const AABB& b, + psyqo::Vec3& normal, psyqo::FixedPoint<12>& penetration) const; + + // Update trigger state machine + void updateTriggerState(uint16_t triggerIndex, uint16_t otherIndex, bool isOverlapping); +}; + +} // namespace psxsplash diff --git a/src/controls.cpp b/src/controls.cpp index 6337c9e..f16cfa4 100644 --- a/src/controls.cpp +++ b/src/controls.cpp @@ -4,53 +4,145 @@ void psxsplash::Controls::Init() { m_input.initialize(); } +bool psxsplash::Controls::isDigitalPad() const { + uint8_t padType = m_input.getPadType(psyqo::AdvancedPad::Pad::Pad1a); + // Digital pad (0x41) has no analog sticks + // Also treat disconnected pads as digital (D-pad still works through button API) + return padType == psyqo::AdvancedPad::PadType::DigitalPad || + padType == psyqo::AdvancedPad::PadType::None; +} + +void psxsplash::Controls::getDpadAxes(int16_t &outX, int16_t &outY) const { + outX = 0; + outY = 0; + // D-pad produces full-magnitude values (like pushing the stick to the edge) + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Up)) + outY = -127; + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Down)) + outY = 127; + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Left)) + outX = -127; + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Right)) + outX = 127; +} + +void psxsplash::Controls::UpdateButtonStates() { + m_previousButtons = m_currentButtons; + + // Read all button states into a single bitmask + m_currentButtons = 0; + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Cross)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Cross); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Circle)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Circle); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Square)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Square); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Triangle)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Triangle); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L1)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::L1); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L2)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::L2); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L3)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::L3); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::R1)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::R1); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::R2)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::R2); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::R3)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::R3); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Start)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Start); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Select)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Select); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Up)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Up); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Down)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Down); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Left)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Left); + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Right)) m_currentButtons |= (1u << psyqo::AdvancedPad::Button::Right); + + // Calculate pressed and released buttons + m_buttonsPressed = m_currentButtons & ~m_previousButtons; + m_buttonsReleased = m_previousButtons & ~m_currentButtons; +} + void psxsplash::Controls::HandleControls(psyqo::Vec3 &playerPosition, psyqo::Angle &playerRotationX, psyqo::Angle &playerRotationY, psyqo::Angle &playerRotationZ, bool freecam, - int deltaTime) { - uint8_t rightX = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 0); - uint8_t rightY = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 1); + int deltaFrames) { + bool digital = isDigitalPad(); + + int16_t rightXOffset, rightYOffset, leftXOffset, leftYOffset; + + if (digital) { + // Digital pad: use D-pad for movement, L1/R1 for rotation + getDpadAxes(leftXOffset, leftYOffset); + // L1/R1 for horizontal look rotation (no vertical on digital) + rightXOffset = 0; + rightYOffset = 0; + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::R1)) + rightXOffset = 90; + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L1)) + rightXOffset = -90; + } else { + // Analog pad: read stick ADC values + uint8_t rightX = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 0); + uint8_t rightY = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 1); + uint8_t leftX = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 2); + uint8_t leftY = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 3); - uint8_t leftX = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 2); - uint8_t leftY = m_input.getAdc(psyqo::AdvancedPad::Pad::Pad1a, 3); - - int16_t rightXOffset = (int16_t)rightX - 0x80; - int16_t rightYOffset = (int16_t)rightY - 0x80; - int16_t leftXOffset = (int16_t)leftX - 0x80; - int16_t leftYOffset = (int16_t)leftY - 0x80; + rightXOffset = (int16_t)rightX - 0x80; + rightYOffset = (int16_t)rightY - 0x80; + leftXOffset = (int16_t)leftX - 0x80; + leftYOffset = (int16_t)leftY - 0x80; + + // On analog pad, also check D-pad as fallback (when sticks are centered) + if (__builtin_abs(leftXOffset) < m_stickDeadzone && __builtin_abs(leftYOffset) < m_stickDeadzone) { + int16_t dpadX, dpadY; + getDpadAxes(dpadX, dpadY); + if (dpadX != 0 || dpadY != 0) { + leftXOffset = dpadX; + leftYOffset = dpadY; + } + } + } + // Sprint toggle (L3 for analog, Square for digital) if (__builtin_abs(leftXOffset) < m_stickDeadzone && __builtin_abs(leftYOffset) < m_stickDeadzone) { m_sprinting = false; } - if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L3)) { - m_sprinting = true; + // Store final stick values for Lua API access + m_leftStickX = leftXOffset; + m_leftStickY = leftYOffset; + m_rightStickX = rightXOffset; + m_rightStickY = rightYOffset; + + if (digital) { + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::Square)) { + m_sprinting = true; + } + } else { + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L3)) { + m_sprinting = true; + } } - psyqo::FixedPoint<12> speed = m_sprinting ? sprintSpeed : moveSpeed; + psyqo::FixedPoint<12> speed = m_sprinting ? m_sprintSpeed : m_moveSpeed; + // Rotation (right stick or L1/R1) if (__builtin_abs(rightXOffset) > m_stickDeadzone) { - playerRotationY += (rightXOffset * rotSpeed * deltaTime) >> 7; + playerRotationY += (rightXOffset * rotSpeed * deltaFrames) >> 7; } if (__builtin_abs(rightYOffset) > m_stickDeadzone) { - playerRotationX -= (rightYOffset * rotSpeed * deltaTime) >> 7; + playerRotationX -= (rightYOffset * rotSpeed * deltaFrames) >> 7; playerRotationX = eastl::clamp(playerRotationX, -0.5_pi, 0.5_pi); } + // Movement (left stick or D-pad) if (__builtin_abs(leftYOffset) > m_stickDeadzone) { - psyqo::FixedPoint<12> forward = -(leftYOffset * speed * deltaTime) >> 7; + psyqo::FixedPoint<12> forward = -(leftYOffset * speed * deltaFrames) >> 7; playerPosition.x += m_trig.sin(playerRotationY) * forward; playerPosition.z += m_trig.cos(playerRotationY) * forward; } if (__builtin_abs(leftXOffset) > m_stickDeadzone) { - psyqo::FixedPoint<12> strafe = -(leftXOffset * speed * deltaTime) >> 7; + psyqo::FixedPoint<12> strafe = -(leftXOffset * speed * deltaFrames) >> 7; playerPosition.x -= m_trig.cos(playerRotationY) * strafe; playerPosition.z += m_trig.sin(playerRotationY) * strafe; } - if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L1)) { - playerPosition.y += speed * deltaTime; - } - if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::R1)) { - playerPosition.y -= speed * deltaTime; + if (freecam) { + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::L1)) { + playerPosition.y += speed * deltaFrames; + } + if (m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, psyqo::AdvancedPad::Button::R1)) { + playerPosition.y -= speed * deltaFrames; + } } } \ No newline at end of file diff --git a/src/controls.hh b/src/controls.hh index 09cfd87..f8dff40 100644 --- a/src/controls.hh +++ b/src/controls.hh @@ -3,6 +3,7 @@ #include #include #include +#include namespace psxsplash { @@ -13,7 +14,43 @@ class Controls { public: void Init(); void HandleControls(psyqo::Vec3 &playerPosition, psyqo::Angle &playerRotationX, psyqo::Angle &playerRotationY, - psyqo::Angle &playerRotationZ, bool freecam, int deltaTime); + psyqo::Angle &playerRotationZ, bool freecam, int deltaFrames); + + /// Update button state tracking - call before HandleControls + void UpdateButtonStates(); + + /// Set movement speeds from splashpack data (call once after scene load) + void setMoveSpeed(psyqo::FixedPoint<12, uint16_t> speed) { m_moveSpeed.value = speed.value; } + void setSprintSpeed(psyqo::FixedPoint<12, uint16_t> speed) { m_sprintSpeed.value = speed.value; } + + /// Check if a button was just pressed this frame + bool wasButtonPressed(psyqo::AdvancedPad::Button button) const { + uint16_t mask = 1u << static_cast(button); + return (m_currentButtons & mask) && !(m_previousButtons & mask); + } + + /// Check if a button was just released this frame + bool wasButtonReleased(psyqo::AdvancedPad::Button button) const { + uint16_t mask = 1u << static_cast(button); + return !(m_currentButtons & mask) && (m_previousButtons & mask); + } + + /// Check if a button is currently held + bool isButtonHeld(psyqo::AdvancedPad::Button button) const { + return m_input.isButtonPressed(psyqo::AdvancedPad::Pad::Pad1a, button); + } + + /// Get bitmask of buttons pressed this frame + uint16_t getButtonsPressed() const { return m_buttonsPressed; } + + /// Get bitmask of buttons released this frame + uint16_t getButtonsReleased() const { return m_buttonsReleased; } + + /// Analog stick accessors (set during HandleControls) + int16_t getLeftStickX() const { return m_leftStickX; } + int16_t getLeftStickY() const { return m_leftStickY; } + int16_t getRightStickX() const { return m_rightStickX; } + int16_t getRightStickY() const { return m_rightStickY; } private: psyqo::AdvancedPad m_input; @@ -21,9 +58,29 @@ class Controls { bool m_sprinting = false; static constexpr uint8_t m_stickDeadzone = 0x30; - static constexpr psyqo::FixedPoint<12> moveSpeed = 0.002_fp; static constexpr psyqo::Angle rotSpeed = 0.01_pi; - static constexpr psyqo::FixedPoint<12> sprintSpeed = 0.01_fp; + + // Configurable movement speeds (set from splashpack, or defaults) + psyqo::FixedPoint<12> m_moveSpeed = 0.002_fp; + psyqo::FixedPoint<12> m_sprintSpeed = 0.01_fp; + + // Button state tracking + uint16_t m_previousButtons = 0; + uint16_t m_currentButtons = 0; + uint16_t m_buttonsPressed = 0; + uint16_t m_buttonsReleased = 0; + + // Analog stick values (centered at 0, range -127 to +127) + int16_t m_leftStickX = 0; + int16_t m_leftStickY = 0; + int16_t m_rightStickX = 0; + int16_t m_rightStickY = 0; + + /// Returns true if the connected pad is digital-only (no analog sticks) + bool isDigitalPad() const; + + /// Get movement axes from D-pad as simulated stick values (-127 to +127) + void getDpadAxes(int16_t &outX, int16_t &outY) const; }; }; // namespace psxsplash \ No newline at end of file diff --git a/src/gameobject.hh b/src/gameobject.hh index 9fd59d5..959612b 100644 --- a/src/gameobject.hh +++ b/src/gameobject.hh @@ -8,11 +8,24 @@ namespace psxsplash { -// LSB is active in flags +class Lua; // Forward declaration +// Component index constants - 0xFFFF means no component +constexpr uint16_t NO_COMPONENT = 0xFFFF; + +/** + * GameObject bitfield flags + * + * Bit 0: isActive - whether object is active in scene + * Bit 1: pendingEnable - flag for deferred enable (to batch Lua calls) + * Bit 2: pendingDisable - flag for deferred disable + */ class GameObject final { typedef Utilities::BitSpan IsActive; - typedef Utilities::BitField GameObjectFlags; + typedef Utilities::BitSpan PendingEnable; + typedef Utilities::BitSpan PendingDisable; + typedef Utilities::BitField GameObjectFlags; + public: union { Tri *polygons; @@ -20,15 +33,43 @@ class GameObject final { }; psyqo::Vec3 position; psyqo::Matrix33 rotation; - // linear & angular velocity placeholders + + // Mesh data uint16_t polyCount; int16_t luaFileIndex; + union { GameObjectFlags flags; uint32_t flagsAsInt; }; + + // Component indices (0xFFFF = no component) + uint16_t interactableIndex; + uint16_t _reserved0; // Was healthIndex (legacy, kept for binary layout) + // Runtime-only: Lua event bitmask (set during RegisterGameObject) + // In the splashpack binary these 4 bytes are _reserved1 + _reserved2 (zeros). + uint32_t eventMask; + + // World-space AABB (20.12 fixed-point, 24 bytes) + // Used for per-object frustum culling before iterating triangles + int32_t aabbMinX, aabbMinY, aabbMinZ; + int32_t aabbMaxX, aabbMaxY, aabbMaxZ; + + // Basic accessors bool isActive() const { return flags.get(); } + + // setActive with Lua event support - call the version that takes Lua& for events void setActive(bool active) { flags.set(active); } + + // Deferred enable/disable for batched Lua calls + bool isPendingEnable() const { return flags.get(); } + bool isPendingDisable() const { return flags.get(); } + void setPendingEnable(bool pending) { flags.set(pending); } + void setPendingDisable(bool pending) { flags.set(pending); } + + // Component checks + bool hasInteractable() const { return interactableIndex != NO_COMPONENT; } }; -static_assert(sizeof(GameObject) == 60, "GameObject is not 56 bytes"); +static_assert(sizeof(GameObject) == 92, "GameObject is not 92 bytes"); + } // namespace psxsplash \ No newline at end of file diff --git a/src/gtemath.cpp b/src/gtemath.cpp index 6c16805..006aaa0 100644 --- a/src/gtemath.cpp +++ b/src/gtemath.cpp @@ -6,29 +6,31 @@ using namespace psyqo::GTE; void psxsplash::MatrixMultiplyGTE(const psyqo::Matrix33 &matA, const psyqo::Matrix33 &matB, psyqo::Matrix33 *result) { - writeSafe(matA); + // Load matA as the rotation matrix. No prior GTE op depends on RT registers here. + writeUnsafe(matA); psyqo::Vec3 t; + // Column 0 of matB: Safe write to V0 ensures rotation matrix is settled before MVMVA. psyqo::GTE::writeSafe(psyqo::Vec3{matB.vs[0].x, matB.vs[1].x, matB.vs[2].x}); - psyqo::GTE::Kernels::mvmva(); - + // Safe read: MVMVA (8 cycles) output must be stable before reading. t = psyqo::GTE::readSafe(); result->vs[0].x = t.x; result->vs[1].x = t.y; result->vs[2].x = t.z; - psyqo::GTE::writeSafe(psyqo::Vec3{matB.vs[0].y, matB.vs[1].y, matB.vs[2].y}); - + // Column 1: Unsafe V0 write is fine since MVMVA just completed (no dependency on V0 from readSafe). + psyqo::GTE::writeUnsafe(psyqo::Vec3{matB.vs[0].y, matB.vs[1].y, matB.vs[2].y}); + // Safe nop-equivalent: the compiler inserts enough instructions between write and kernel call. psyqo::GTE::Kernels::mvmva(); t = psyqo::GTE::readSafe(); result->vs[0].y = t.x; result->vs[1].y = t.y; result->vs[2].y = t.z; - psyqo::GTE::writeSafe(psyqo::Vec3{matB.vs[0].z, matB.vs[1].z, matB.vs[2].z}); - + // Column 2: Same pattern. + psyqo::GTE::writeUnsafe(psyqo::Vec3{matB.vs[0].z, matB.vs[1].z, matB.vs[2].z}); psyqo::GTE::Kernels::mvmva(); t = psyqo::GTE::readSafe(); result->vs[0].z = t.x; diff --git a/src/interactable.hh b/src/interactable.hh new file mode 100644 index 0000000..d035009 --- /dev/null +++ b/src/interactable.hh @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +namespace psxsplash { + +/** + * Interactable component - enables player interaction with objects. + * + * When the player is within interaction radius and presses the interact button, + * the onInteract Lua event fires on the associated GameObject. + */ +struct Interactable { + // Interaction radius squared (fixed-point 12-bit, pre-squared for fast distance checks) + psyqo::FixedPoint<12> radiusSquared; + + // Interaction point offset from object center + psyqo::FixedPoint<12> offsetX; + psyqo::FixedPoint<12> offsetY; + psyqo::FixedPoint<12> offsetZ; + + // Button index that triggers interaction (0-15) + uint8_t interactButton; + + // Configuration flags + uint8_t flags; // bit 0: isRepeatable, bit 1: showPrompt, bit 2: requireLineOfSight + + // Cooldown between interactions (in frames) + uint16_t cooldownFrames; + + // Runtime state + uint16_t currentCooldown; // Frames remaining until can interact again + uint16_t gameObjectIndex; // Index of associated GameObject + + // Flag accessors + bool isRepeatable() const { return flags & 0x01; } + bool showPrompt() const { return flags & 0x02; } + bool requireLineOfSight() const { return flags & 0x04; } + + // Check if ready to interact + bool canInteract() const { return currentCooldown == 0; } + + // Called when interaction happens + void triggerCooldown() { currentCooldown = cooldownFrames; } + + // Called each frame to decrement cooldown + void update() { + if (currentCooldown > 0) currentCooldown--; + } +}; +static_assert(sizeof(Interactable) == 24, "Interactable is not 24 bytes"); + +} // namespace psxsplash diff --git a/src/lua.cpp b/src/lua.cpp index 5ebdd68..094684a 100644 --- a/src/lua.cpp +++ b/src/lua.cpp @@ -2,6 +2,8 @@ #include +#include +#include #include #include "gameobject.hh" @@ -10,15 +12,27 @@ constexpr const char GAMEOBJECT_SCRIPT[] = R"( return function(metatable) local get_position = metatable.get_position local set_position = metatable.set_position + local get_active = metatable.get_active + local set_active = metatable.set_active + local get_rotationY = metatable.get_rotationY + local set_rotationY = metatable.set_rotationY metatable.get_position = nil metatable.set_position = nil + metatable.get_active = nil + metatable.set_active = nil + metatable.get_rotationY = nil + metatable.set_rotationY = nil function metatable.__index(self, key) + local raw = rawget(self, key) + if raw ~= nil then return raw end if key == "position" then return get_position(self.__cpp_ptr) elseif key == "active" then return get_active(self.__cpp_ptr) + elseif key == "rotationY" then + return get_rotationY(self.__cpp_ptr) end return nil end @@ -29,6 +43,10 @@ return function(metatable) return elseif key == "active" then set_active(self.__cpp_ptr, value) + return + elseif key == "rotationY" then + set_rotationY(self.__cpp_ptr, value) + return end rawset(self, key, value) end @@ -37,17 +55,18 @@ end // Lua helpers +static constexpr lua_Number kFixedScale = 4096; static int gameobjectGetPosition(psyqo::Lua L) { auto go = L.toUserdata(1); L.newTable(); - L.pushNumber(go->position.x.raw()); + L.pushNumber(static_cast(go->position.x.raw()) / kFixedScale); L.setField(2, "x"); - L.pushNumber(go->position.y.raw()); + L.pushNumber(static_cast(go->position.y.raw()) / kFixedScale); L.setField(2, "y"); - L.pushNumber(go->position.z.raw()); + L.pushNumber(static_cast(go->position.z.raw()) / kFixedScale); L.setField(2, "z"); return 1; @@ -59,18 +78,15 @@ static int gameobjectSetPosition(psyqo::Lua L) { auto go = L.toUserdata(1); L.getField(2, "x"); - psyqo::FixedPoint<> x(L.toNumber(3), psyqo::FixedPoint<>::RAW); - go->position.x = x; + go->position.x = psyqo::FixedPoint<>(static_cast(L.toNumber(3) * kFixedScale), psyqo::FixedPoint<>::RAW); L.pop(); L.getField(2, "y"); - psyqo::FixedPoint<> y(L.toNumber(3), psyqo::FixedPoint<>::RAW); - go->position.y = y; + go->position.y = psyqo::FixedPoint<>(static_cast(L.toNumber(3) * kFixedScale), psyqo::FixedPoint<>::RAW); L.pop(); L.getField(2, "z"); - psyqo::FixedPoint<> z(L.toNumber(3), psyqo::FixedPoint<>::RAW); - go->position.z = z; + go->position.z = psyqo::FixedPoint<>(static_cast(L.toNumber(3) * kFixedScale), psyqo::FixedPoint<>::RAW); L.pop(); return 0; @@ -89,6 +105,59 @@ static int gamobjectSetActive(psyqo::Lua L) { return 0; } +// Angle constants: psyqo::Angle is FixedPoint<10>, so 1.0_pi = raw 1024 +static constexpr lua_Number kAngleScale = 1024; +static psyqo::Trig<> s_trig; + +// Fast integer atan2 approximation → psyqo::Angle (pi-fraction units) +// Uses linear approximation in first octant then folds to full circle. +// Max error ~4° (acceptable for PS1 game objects). +static psyqo::Angle fastAtan2(int32_t sinVal, int32_t cosVal) { + psyqo::Angle result; + if (cosVal == 0 && sinVal == 0) { result.value = 0; return result; } + + int32_t abs_s = sinVal < 0 ? -sinVal : sinVal; + int32_t abs_c = cosVal < 0 ? -cosVal : cosVal; + + int32_t minV = abs_s < abs_c ? abs_s : abs_c; + int32_t maxV = abs_s > abs_c ? abs_s : abs_c; + + // Compute angle in first octant [0, Ï€/4 = 256 Angle units] + // angle = (minV/maxV) * 256, using only 32-bit math. + // Max minV for normalized sin/cos ≈ 4096, so minV * 256 ≈ 1M — fits int32. + int32_t angle = (minV * 256) / maxV; + + // Past 45°: use complement + if (abs_s > abs_c) angle = 512 - angle; // Ï€/2 - angle + // Quadrant 2/3: cos < 0 + if (cosVal < 0) angle = 1024 - angle; // Ï€ - angle + // Quadrant 3/4: sin < 0 + if (sinVal < 0) angle = -angle; + + result.value = angle; + return result; +} + +static int gameobjectGetRotationY(psyqo::Lua L) { + auto go = L.toUserdata(1); + // Y rotation matrix: vs[0].x = cos(θ), vs[0].z = sin(θ) + int32_t sinRaw = go->rotation.vs[0].z.raw(); + int32_t cosRaw = go->rotation.vs[0].x.raw(); + psyqo::Angle angle = fastAtan2(sinRaw, cosRaw); + // Return in pi-units: 0.5 = Ï€/2 = 90° + L.pushNumber(static_cast(angle.value) / kAngleScale); + return 1; +} + +static int gameobjectSetRotationY(psyqo::Lua L) { + auto go = L.toUserdata(1); + lua_Number piUnits = L.toNumber(2); + psyqo::Angle angle; + angle.value = static_cast(piUnits * kAngleScale); + go->rotation = psyqo::SoftMath::generateRotationMatrix33(angle, psyqo::SoftMath::Axis::Y, s_trig); + return 0; +} + void psxsplash::Lua::Init() { auto L = m_state; // Load and run the game objects script @@ -109,11 +178,17 @@ void psxsplash::Lua::Init() { L.push(gamobjectSetActive); L.setField(-2, "set_active"); + L.push(gameobjectGetRotationY); + L.setField(-2, "get_rotationY"); + + L.push(gameobjectSetRotationY); + L.setField(-2, "set_rotationY"); + L.copy(-1); m_metatableReference = L.ref(); if (L.pcall(1, 0) == 0) { - printf("Lua script 'gameObjects' executed successfully"); + // success } else { printf("Error registering Lua script: %s\n", L.optString(-1, "Unknown error")); L.clearStack(); @@ -136,6 +211,24 @@ void psxsplash::Lua::Init() { m_luascriptsReference = L.ref(); } +void psxsplash::Lua::Shutdown() { + // Close the Lua VM if it's still open. + // Safe to call multiple times or on an already-closed VM. + if (m_state.getState()) { + m_state.close(); + } + m_metatableReference = LUA_NOREF; + m_luascriptsReference = LUA_NOREF; + m_luaSceneScriptsReference = LUA_NOREF; +} + +void psxsplash::Lua::Reset() { + // Nuclear reset: destroy the entire Lua VM and create a fresh one. + Shutdown(); + m_state = psyqo::Lua(); // fresh state (luaL_newstate + openlibs) + Init(); +} + void psxsplash::Lua::LoadLuaFile(const char* code, size_t len, int index) { auto L = m_state; char filename[32]; @@ -143,18 +236,31 @@ void psxsplash::Lua::LoadLuaFile(const char* code, size_t len, int index) { if (L.loadBuffer(code, len, filename) != LUA_OK) { printf("Lua error: %s\n", L.toString(-1)); L.pop(); + return; } // (1) script func L.rawGetI(LUA_REGISTRYINDEX, m_luascriptsReference); // (1) script func (2) scripts table L.newTable(); - // (1) script func (2) scripts table (3) {} + // (1) script func (2) scripts table (3) env {} + + // Give the environment a metatable that falls back to _G + // so scripts can see Entity, Debug, Input, etc. + L.newTable(); + // (1) script func (2) scripts table (3) env {} (4) mt {} + L.pushGlobalTable(); + // (1) script func (2) scripts table (3) env {} (4) mt {} (5) _G + L.setField(-2, "__index"); + // (1) script func (2) scripts table (3) env {} (4) mt { __index = _G } + L.setMetatable(-2); + // (1) script func (2) scripts table (3) env { mt } + L.pushNumber(index); - // (1) script func (2) scripts table (3) {} (4) index + // (1) script func (2) scripts table (3) env (4) index L.copy(-2); - // (1) script func (2) scripts table (3) {} (4) index (5) {} + // (1) script func (2) scripts table (3) env (4) index (5) env L.setTable(-4); - // (1) script func (2) scripts table (3) {} + // (1) script func (2) scripts table (3) env lua_setupvalue(L.getState(), -3, 1); // (1) script func (2) scripts table L.pop(); @@ -177,9 +283,15 @@ void psxsplash::Lua::RegisterSceneScripts(int index) { L.rawGetI(LUA_REGISTRYINDEX, m_luascriptsReference); // (1) {} (2) scripts table L.pushNumber(index); - // (1) {} (2) script environments table (2) index + // (1) {} (2) script environments table (3) index L.getTable(-2); // (1) {} (2) script environments table (3) script environment table for the scene + if (!L.isTable(-1)) { + // Scene Lua file index is invalid or script not loaded + printf("Warning: scene Lua file index %d not found\n", index); + L.pop(3); + return; + } onSceneCreationStartFunctionWrapper.resolveGlobal(L); onSceneCreationEndFunctionWrapper.resolveGlobal(L); L.pop(3); @@ -218,29 +330,112 @@ void psxsplash::Lua::RegisterGameObject(GameObject* go) { // (1) {} (2) go + 1 (3) {} L.rawSet(LUA_REGISTRYINDEX); // (1) {} + + // Initialize event mask for this object + uint32_t eventMask = EVENT_NONE; + if (go->luaFileIndex != -1) { L.rawGetI(LUA_REGISTRYINDEX, m_luascriptsReference); // (1) {} (2) script environments table L.rawGetI(-1, go->luaFileIndex); // (1) {} (2) script environments table (3) script environment table for this object - onCollisionMethodWrapper.resolveGlobal(L); - onInteractMethodWrapper.resolveGlobal(L); + + // Guard: if the script file failed to load (e.g. compilation error), + // the environment will be nil — skip event resolution. + if (!L.isTable(-1)) { + L.pop(2); + } else { + + // Resolve each event and build the bitmask + // Only events that exist in the script get their bit set + if (onCreateMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_CREATE; + if (onCollisionMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_COLLISION; + if (onInteractMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_INTERACT; + if (onTriggerEnterMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_TRIGGER_ENTER; + if (onTriggerStayMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_TRIGGER_STAY; + if (onTriggerExitMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_TRIGGER_EXIT; + if (onUpdateMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_UPDATE; + if (onDestroyMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_DESTROY; + if (onEnableMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_ENABLE; + if (onDisableMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_DISABLE; + if (onButtonPressMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_BUTTON_PRESS; + if (onButtonReleaseMethodWrapper.resolveGlobal(L)) eventMask |= EVENT_ON_BUTTON_RELEASE; + L.pop(2); // (1) {} + } } + + // Store the event mask directly in the GameObject + go->eventMask = eventMask; + L.pop(); // empty stack - printf("GameObject registered in Lua registry: %p\n", ptr); + + + // Fire onCreate event if this object handles it + if (eventMask & EVENT_ON_CREATE) { + onCreateMethodWrapper.callMethod(*this, go); + } } void psxsplash::Lua::OnCollision(GameObject* self, GameObject* other) { + if (!hasEvent(self, EVENT_ON_COLLISION)) return; onCollisionMethodWrapper.callMethod(*this, self, other); } void psxsplash::Lua::OnInteract(GameObject* self) { + if (!hasEvent(self, EVENT_ON_INTERACT)) return; onInteractMethodWrapper.callMethod(*this, self); } +void psxsplash::Lua::OnTriggerEnter(GameObject* trigger, GameObject* other) { + if (!hasEvent(trigger, EVENT_ON_TRIGGER_ENTER)) return; + onTriggerEnterMethodWrapper.callMethod(*this, trigger, other); +} + +void psxsplash::Lua::OnTriggerStay(GameObject* trigger, GameObject* other) { + if (!hasEvent(trigger, EVENT_ON_TRIGGER_STAY)) return; + onTriggerStayMethodWrapper.callMethod(*this, trigger, other); +} + +void psxsplash::Lua::OnTriggerExit(GameObject* trigger, GameObject* other) { + if (!hasEvent(trigger, EVENT_ON_TRIGGER_EXIT)) return; + onTriggerExitMethodWrapper.callMethod(*this, trigger, other); +} + +void psxsplash::Lua::OnDestroy(GameObject* go) { + if (!hasEvent(go, EVENT_ON_DESTROY)) return; + onDestroyMethodWrapper.callMethod(*this, go); + // Clear the event mask when object is destroyed + go->eventMask = EVENT_NONE; +} + +void psxsplash::Lua::OnEnable(GameObject* go) { + if (!hasEvent(go, EVENT_ON_ENABLE)) return; + onEnableMethodWrapper.callMethod(*this, go); +} + +void psxsplash::Lua::OnDisable(GameObject* go) { + if (!hasEvent(go, EVENT_ON_DISABLE)) return; + onDisableMethodWrapper.callMethod(*this, go); +} + +void psxsplash::Lua::OnButtonPress(GameObject* go, int button) { + if (!hasEvent(go, EVENT_ON_BUTTON_PRESS)) return; + onButtonPressMethodWrapper.callMethod(*this, go, button); +} + +void psxsplash::Lua::OnButtonRelease(GameObject* go, int button) { + if (!hasEvent(go, EVENT_ON_BUTTON_RELEASE)) return; + onButtonReleaseMethodWrapper.callMethod(*this, go, button); +} + +void psxsplash::Lua::OnUpdate(GameObject* go, int deltaFrames) { + if (!hasEvent(go, EVENT_ON_UPDATE)) return; + onUpdateMethodWrapper.callMethod(*this, go, deltaFrames); +} + void psxsplash::Lua::PushGameObject(GameObject* go) { auto L = m_state; L.push(go); diff --git a/src/lua.h b/src/lua.h index 09f652c..f194363 100644 --- a/src/lua.h +++ b/src/lua.h @@ -17,13 +17,50 @@ struct LuaFile { uint32_t length; }; +/** + * Event bitmask flags - each bit represents whether an object handles that event. + * This allows O(1) checking before calling into Lua VM. + * + * CRITICAL: The PS1 cannot afford to call into Lua for events objects don't handle. + * When registering a GameObject, we scan its script and set these bits. + * During dispatch, we check the bit FIRST before any Lua VM access. + */ +enum EventMask : uint32_t { + EVENT_NONE = 0, + EVENT_ON_CREATE = 1 << 0, + EVENT_ON_COLLISION = 1 << 1, + EVENT_ON_INTERACT = 1 << 2, + EVENT_ON_TRIGGER_ENTER = 1 << 3, + EVENT_ON_TRIGGER_STAY = 1 << 4, + EVENT_ON_TRIGGER_EXIT = 1 << 5, + EVENT_ON_UPDATE = 1 << 6, + EVENT_ON_DESTROY = 1 << 7, + EVENT_ON_ENABLE = 1 << 8, + EVENT_ON_DISABLE = 1 << 9, + EVENT_ON_BUTTON_PRESS = 1 << 10, + EVENT_ON_BUTTON_RELEASE = 1 << 11, +}; + class Lua { public: void Init(); + void Reset(); // Destroy and recreate the Lua VM (call on scene load) + void Shutdown(); // Close the Lua VM without recreating (call on scene unload) void LoadLuaFile(const char* code, size_t len, int index); void RegisterSceneScripts(int index); void RegisterGameObject(GameObject* go); + + // Get the underlying psyqo::Lua state for API registration + psyqo::Lua& getState() { return m_state; } + + /** + * Check if a GameObject handles a specific event. + * Call this BEFORE attempting to dispatch any event. + */ + bool hasEvent(GameObject* go, EventMask event) const { + return (go->eventMask & event) != 0; + } void OnSceneCreationStart() { onSceneCreationStartFunctionWrapper.callFunction(*this); @@ -31,8 +68,19 @@ class Lua { void OnSceneCreationEnd() { onSceneCreationEndFunctionWrapper.callFunction(*this); } + + // Event dispatchers - these check the bitmask before calling Lua void OnCollision(GameObject* self, GameObject* other); void OnInteract(GameObject* self); + void OnTriggerEnter(GameObject* trigger, GameObject* other); + void OnTriggerStay(GameObject* trigger, GameObject* other); + void OnTriggerExit(GameObject* trigger, GameObject* other); + void OnUpdate(GameObject* go, int deltaFrames); // Per-object update + void OnDestroy(GameObject* go); + void OnEnable(GameObject* go); + void OnDisable(GameObject* go); + void OnButtonPress(GameObject* go, int button); + void OnButtonRelease(GameObject* go, int button); private: template @@ -40,26 +88,31 @@ class Lua { template struct FunctionWrapper> { typedef irqus::typestring methodName; - // Needs the methods table at index 1, and the script environment table at index 3 - static void resolveGlobal(psyqo::Lua L) { - // Push the method name string to access the environment table + + // Returns true if the function was found and stored + static bool resolveGlobal(psyqo::Lua L) { L.push(methodName::data(), methodName::size()); L.getTable(3); if (L.isFunction(-1)) { - // Store the function in methods table using numeric ID as key - L.pushNumber(methodId); // Push numeric key for methods table - L.copy(-2); // Push the function (copy from top -2) - L.setTable(1); // methodsTable[methodId] = function + L.pushNumber(methodId); + L.copy(-2); + L.setTable(1); + L.pop(); // Pop the function + return true; } else { - L.pop(); // Pop the non-function value + L.pop(); + return false; } } + template static void pushArgs(psxsplash::Lua& lua, Args... args) { (push(lua, args), ...); } static void push(psxsplash::Lua& lua, GameObject* go) { lua.PushGameObject(go); } + static void push(psxsplash::Lua& lua, int val) { lua.m_state.pushNumber(val); } + template static void callMethod(psxsplash::Lua& lua, GameObject* go, Args... args) { auto L = lua.m_state; @@ -78,11 +131,16 @@ class Lua { } L.clearStack(); } + template static void callFunction(psxsplash::Lua& lua, Args... args) { auto L = lua.m_state; - L.push(methodName::data(), methodName::size()); - L.rawGetI(LUA_REGISTRYINDEX, lua.m_metatableReference); + L.rawGetI(LUA_REGISTRYINDEX, lua.m_luaSceneScriptsReference); + if (!L.isTable(-1)) { + L.clearStack(); + return; + } + L.rawGetI(-1, methodId); if (!L.isFunction(-1)) { L.clearStack(); return; @@ -95,17 +153,34 @@ class Lua { } }; + // Scene-level events (methodId 1-2) [[no_unique_address]] FunctionWrapper<1, typestring_is("onSceneCreationStart")> onSceneCreationStartFunctionWrapper; [[no_unique_address]] FunctionWrapper<2, typestring_is("onSceneCreationEnd")> onSceneCreationEndFunctionWrapper; - [[no_unique_address]] FunctionWrapper<1, typestring_is("onCreate")> onCreateMethodWrapper; - [[no_unique_address]] FunctionWrapper<2, typestring_is("onCollision")> onCollisionMethodWrapper; - [[no_unique_address]] FunctionWrapper<3, typestring_is("onInteract")> onInteractMethodWrapper; + + // Object-level events (methodId 100-111, offset to avoid collision with scene events) + [[no_unique_address]] FunctionWrapper<100, typestring_is("onCreate")> onCreateMethodWrapper; + [[no_unique_address]] FunctionWrapper<101, typestring_is("onCollision")> onCollisionMethodWrapper; + [[no_unique_address]] FunctionWrapper<102, typestring_is("onInteract")> onInteractMethodWrapper; + [[no_unique_address]] FunctionWrapper<103, typestring_is("onTriggerEnter")> onTriggerEnterMethodWrapper; + [[no_unique_address]] FunctionWrapper<104, typestring_is("onTriggerStay")> onTriggerStayMethodWrapper; + [[no_unique_address]] FunctionWrapper<105, typestring_is("onTriggerExit")> onTriggerExitMethodWrapper; + [[no_unique_address]] FunctionWrapper<106, typestring_is("onUpdate")> onUpdateMethodWrapper; + [[no_unique_address]] FunctionWrapper<107, typestring_is("onDestroy")> onDestroyMethodWrapper; + [[no_unique_address]] FunctionWrapper<108, typestring_is("onEnable")> onEnableMethodWrapper; + [[no_unique_address]] FunctionWrapper<109, typestring_is("onDisable")> onDisableMethodWrapper; + [[no_unique_address]] FunctionWrapper<110, typestring_is("onButtonPress")> onButtonPressMethodWrapper; + [[no_unique_address]] FunctionWrapper<111, typestring_is("onButtonRelease")> onButtonReleaseMethodWrapper; + void PushGameObject(GameObject* go); + + private: psyqo::Lua m_state; - int m_metatableReference; - int m_luascriptsReference; - int m_luaSceneScriptsReference; + int m_metatableReference = LUA_NOREF; + int m_luascriptsReference = LUA_NOREF; + int m_luaSceneScriptsReference = LUA_NOREF; + + // Event mask now stored inline in GameObject::eventMask template friend struct FunctionWrapper; diff --git a/src/luaapi.cpp b/src/luaapi.cpp new file mode 100644 index 0000000..03633e9 --- /dev/null +++ b/src/luaapi.cpp @@ -0,0 +1,1385 @@ +#include "luaapi.hh" +#include "scenemanager.hh" +#include "gameobject.hh" +#include "controls.hh" +#include "camera.hh" + +#include +#include +#include +#include + +namespace psxsplash { + +// Static member +SceneManager* LuaAPI::s_sceneManager = nullptr; + +// Scale factor: FixedPoint<12> stores 1.0 as raw 4096. +// Lua scripts work in world-space units (1 = one unit), so we convert. +static constexpr lua_Number kFixedScale = 4096; + +static lua_Number fpToLua(psyqo::FixedPoint<12> fp) { + return static_cast(fp.raw()) / kFixedScale; +} + +static psyqo::FixedPoint<12> luaToFp(lua_Number val) { + return psyqo::FixedPoint<12>(static_cast(val * kFixedScale), psyqo::FixedPoint<12>::RAW); +} + +// Angle scale: psyqo::Angle is FixedPoint<10>, so 1.0_pi = raw 1024 +static constexpr lua_Number kAngleScale = 1024; +static psyqo::Trig<> s_trig; + +// ============================================================================ +// REGISTRATION +// ============================================================================ + +void LuaAPI::RegisterAll(psyqo::Lua& L, SceneManager* scene) { + s_sceneManager = scene; + + // ======================================================================== + // ENTITY API + // ======================================================================== + L.newTable(); // Entity table + + L.push(Entity_FindByScriptIndex); + L.setField(-2, "FindByScriptIndex"); + + L.push(Entity_FindByIndex); + L.setField(-2, "FindByIndex"); + + L.push(Entity_Find); + L.setField(-2, "Find"); + + L.push(Entity_GetCount); + L.setField(-2, "GetCount"); + + L.push(Entity_SetActive); + L.setField(-2, "SetActive"); + + L.push(Entity_IsActive); + L.setField(-2, "IsActive"); + + L.push(Entity_GetPosition); + L.setField(-2, "GetPosition"); + + L.push(Entity_SetPosition); + L.setField(-2, "SetPosition"); + + L.push(Entity_GetRotationY); + L.setField(-2, "GetRotationY"); + + L.push(Entity_SetRotationY); + L.setField(-2, "SetRotationY"); + + L.push(Entity_ForEach); + L.setField(-2, "ForEach"); + + L.setGlobal("Entity"); + + // ======================================================================== + // VEC3 API + // ======================================================================== + L.newTable(); // Vec3 table + + L.push(Vec3_New); + L.setField(-2, "new"); + + L.push(Vec3_Add); + L.setField(-2, "add"); + + L.push(Vec3_Sub); + L.setField(-2, "sub"); + + L.push(Vec3_Mul); + L.setField(-2, "mul"); + + L.push(Vec3_Dot); + L.setField(-2, "dot"); + + L.push(Vec3_Cross); + L.setField(-2, "cross"); + + L.push(Vec3_Length); + L.setField(-2, "length"); + + L.push(Vec3_LengthSq); + L.setField(-2, "lengthSq"); + + L.push(Vec3_Normalize); + L.setField(-2, "normalize"); + + L.push(Vec3_Distance); + L.setField(-2, "distance"); + + L.push(Vec3_DistanceSq); + L.setField(-2, "distanceSq"); + + L.push(Vec3_Lerp); + L.setField(-2, "lerp"); + + L.setGlobal("Vec3"); + + // ======================================================================== + // INPUT API + // ======================================================================== + L.newTable(); // Input table + + L.push(Input_IsPressed); + L.setField(-2, "IsPressed"); + + L.push(Input_IsReleased); + L.setField(-2, "IsReleased"); + + L.push(Input_IsHeld); + L.setField(-2, "IsHeld"); + + L.push(Input_GetAnalog); + L.setField(-2, "GetAnalog"); + + // Register button constants + RegisterInputConstants(L); + + L.setGlobal("Input"); + + // ======================================================================== + // TIMER API + // ======================================================================== + L.newTable(); // Timer table + + L.push(Timer_GetFrameCount); + L.setField(-2, "GetFrameCount"); + + L.setGlobal("Timer"); + + // ======================================================================== + // CAMERA API + // ======================================================================== + L.newTable(); // Camera table + + L.push(Camera_GetPosition); + L.setField(-2, "GetPosition"); + + L.push(Camera_SetPosition); + L.setField(-2, "SetPosition"); + + L.push(Camera_GetRotation); + L.setField(-2, "GetRotation"); + + L.push(Camera_SetRotation); + L.setField(-2, "SetRotation"); + + L.push(Camera_LookAt); + L.setField(-2, "LookAt"); + + L.setGlobal("Camera"); + + // ======================================================================== + // AUDIO API (Placeholder) + // ======================================================================== + L.newTable(); // Audio table + + L.push(Audio_Play); + L.setField(-2, "Play"); + + L.push(Audio_Find); + L.setField(-2, "Find"); + + L.push(Audio_Stop); + L.setField(-2, "Stop"); + + L.push(Audio_SetVolume); + L.setField(-2, "SetVolume"); + + L.push(Audio_StopAll); + L.setField(-2, "StopAll"); + + L.setGlobal("Audio"); + + // ======================================================================== + // DEBUG API + // ======================================================================== + L.newTable(); // Debug table + + L.push(Debug_Log); + L.setField(-2, "Log"); + + L.push(Debug_DrawLine); + L.setField(-2, "DrawLine"); + + L.push(Debug_DrawBox); + L.setField(-2, "DrawBox"); + + L.setGlobal("Debug"); + + // ======================================================================== + // MATH API + // ======================================================================== + L.newTable(); // PSXMath table (avoid conflict with Lua's math) + + L.push(Math_Clamp); + L.setField(-2, "Clamp"); + + L.push(Math_Lerp); + L.setField(-2, "Lerp"); + + L.push(Math_Sign); + L.setField(-2, "Sign"); + + L.push(Math_Abs); + L.setField(-2, "Abs"); + + L.push(Math_Min); + L.setField(-2, "Min"); + + L.push(Math_Max); + L.setField(-2, "Max"); + + L.setGlobal("Math"); + + // ======================================================================== + // SCENE API + // ======================================================================== + L.newTable(); // Scene table + + L.push(Scene_Load); + L.setField(-2, "Load"); + + L.push(Scene_GetIndex); + L.setField(-2, "GetIndex"); + + L.setGlobal("Scene"); + + // ======================================================================== + // PERSIST API + // ======================================================================== + L.newTable(); // Persist table + + L.push(Persist_Get); + L.setField(-2, "Get"); + + L.push(Persist_Set); + L.setField(-2, "Set"); + + L.setGlobal("Persist"); +} + +// ============================================================================ +// ENTITY API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Entity_FindByScriptIndex(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isNumber(1)) { + lua.push(); + return 1; + } + + // Find first object with matching luaFileIndex + int16_t luaIdx = static_cast(lua.toNumber(1)); + for (size_t i = 0; i < s_sceneManager->getGameObjectCount(); i++) { + auto* go = s_sceneManager->getGameObject(static_cast(i)); + if (go && go->luaFileIndex == luaIdx) { + lua.push(reinterpret_cast(go)); + lua.rawGet(LUA_REGISTRYINDEX); + if (lua.isTable(-1)) return 1; + lua.pop(); + } + } + + lua.push(); + return 1; +} + +int LuaAPI::Entity_FindByIndex(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isNumber(1)) { + lua.push(); + return 1; + } + + int index = static_cast(lua.toNumber(1)); + + if (s_sceneManager) { + GameObject* go = s_sceneManager->getGameObject(static_cast(index)); + if (go) { + lua.push(reinterpret_cast(go)); + lua.rawGet(LUA_REGISTRYINDEX); + if (lua.isTable(-1)) { + return 1; + } + lua.pop(); + } + } + + lua.push(); + return 1; +} + +int LuaAPI::Entity_Find(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager) { + lua.push(); + return 1; + } + + // Accept number (index) or string (name lookup) for backwards compat + // Check isNumber FIRST — in Lua, numbers pass isString too. + if (lua.isNumber(1)) { + int index = static_cast(lua.toNumber(1)); + GameObject* go = s_sceneManager->getGameObject(static_cast(index)); + if (go) { + lua.push(reinterpret_cast(go)); + lua.rawGet(LUA_REGISTRYINDEX); + if (lua.isTable(-1)) return 1; + lua.pop(); + } + } else if (lua.isString(1)) { + const char* name = lua.toString(1); + GameObject* go = s_sceneManager->findObjectByName(name); + if (go) { + lua.push(reinterpret_cast(go)); + lua.rawGet(LUA_REGISTRYINDEX); + if (lua.isTable(-1)) return 1; + lua.pop(); + } + } + + lua.push(); + return 1; +} + +int LuaAPI::Entity_GetCount(lua_State* L) { + psyqo::Lua lua(L); + + if (s_sceneManager) { + lua.pushNumber(static_cast(s_sceneManager->getGameObjectCount())); + } else { + lua.pushNumber(0); + } + return 1; +} + + +int LuaAPI::Entity_SetActive(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + return 0; + } + + lua.getField(1, "__cpp_ptr"); + auto go = lua.toUserdata(-1); + lua.pop(); + + bool active = lua.toBoolean(2); + + if (go && s_sceneManager) { + s_sceneManager->setObjectActive(go, active); + } + + return 0; +} + +int LuaAPI::Entity_IsActive(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.push(false); + return 1; + } + + lua.getField(1, "__cpp_ptr"); + auto go = lua.toUserdata(-1); + lua.pop(); + + if (go) { + lua.push(go->isActive()); + } else { + lua.push(false); + } + + return 1; +} + +int LuaAPI::Entity_GetPosition(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.push(); + return 1; + } + + lua.getField(1, "__cpp_ptr"); + auto go = lua.toUserdata(-1); + lua.pop(); + + if (go) { + PushVec3(lua, go->position.x, go->position.y, go->position.z); + return 1; + } + + lua.push(); + return 1; +} + +int LuaAPI::Entity_SetPosition(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + return 0; + } + + lua.getField(1, "__cpp_ptr"); + auto go = lua.toUserdata(-1); + lua.pop(); + + if (!go) return 0; + + psyqo::FixedPoint<12> x, y, z; + ReadVec3(lua, 2, x, y, z); + + go->position.x = x; + go->position.y = y; + go->position.z = z; + + return 0; +} + +int LuaAPI::Entity_GetRotationY(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.pushNumber(0); + return 1; + } + + lua.getField(1, "__cpp_ptr"); + auto go = lua.toUserdata(-1); + lua.pop(); + + if (!go) { lua.pushNumber(0); return 1; } + + // Y rotation matrix: vs[0].x = cos(θ), vs[0].z = sin(θ) + int32_t sinRaw = go->rotation.vs[0].z.raw(); + int32_t cosRaw = go->rotation.vs[0].x.raw(); + + // Fast atan2 approximation (linear in first octant, fold to full circle) + psyqo::Angle angle; + if (cosRaw == 0 && sinRaw == 0) { + angle.value = 0; + } else { + int32_t abs_s = sinRaw < 0 ? -sinRaw : sinRaw; + int32_t abs_c = cosRaw < 0 ? -cosRaw : cosRaw; + int32_t minV = abs_s < abs_c ? abs_s : abs_c; + int32_t maxV = abs_s > abs_c ? abs_s : abs_c; + int32_t a = (minV * 256) / maxV; // [0, 256] for [0, Ï€/4] + if (abs_s > abs_c) a = 512 - a; + if (cosRaw < 0) a = 1024 - a; + if (sinRaw < 0) a = -a; + angle.value = a; + } + + // Return in pi-units: 0.5 = Ï€/2 = 90° + lua.pushNumber(static_cast(angle.value) / kAngleScale); + return 1; +} + +int LuaAPI::Entity_SetRotationY(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) return 0; + + lua.getField(1, "__cpp_ptr"); + auto go = lua.toUserdata(-1); + lua.pop(); + + if (!go) return 0; + + // Accept angle in pi-units (0.5 = Ï€/2 = 90°) + lua_Number piUnits = lua.toNumber(2); + psyqo::Angle angle; + angle.value = static_cast(piUnits * kAngleScale); + go->rotation = psyqo::SoftMath::generateRotationMatrix33(angle, psyqo::SoftMath::Axis::Y, s_trig); + return 0; +} + +int LuaAPI::Entity_ForEach(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isFunction(1)) return 0; + + size_t count = s_sceneManager->getGameObjectCount(); + for (size_t i = 0; i < count; i++) { + auto* go = s_sceneManager->getGameObject(static_cast(i)); + if (!go || !go->isActive()) continue; + + // Push callback copy + lua.copy(1); + // Look up registered Lua table for this object (keyed by C++ pointer) + lua.push(reinterpret_cast(go)); + lua.rawGet(LUA_REGISTRYINDEX); + if (!lua.isTable(-1)) { + lua.pop(2); // pop non-table + callback copy + continue; + } + if (lua.pcall(1, 0) != LUA_OK) { + lua.pop(); // pop error message + } + } + + return 0; +} + +// ============================================================================ +// VEC3 API IMPLEMENTATION +// ============================================================================ + +void LuaAPI::PushVec3(psyqo::Lua& L, psyqo::FixedPoint<12> x, + psyqo::FixedPoint<12> y, psyqo::FixedPoint<12> z) { + L.newTable(); + L.pushNumber(fpToLua(x)); + L.setField(-2, "x"); + L.pushNumber(fpToLua(y)); + L.setField(-2, "y"); + L.pushNumber(fpToLua(z)); + L.setField(-2, "z"); +} + +void LuaAPI::ReadVec3(psyqo::Lua& L, int idx, + psyqo::FixedPoint<12>& x, + psyqo::FixedPoint<12>& y, + psyqo::FixedPoint<12>& z) { + L.getField(idx, "x"); + x = luaToFp(L.toNumber(-1)); + L.pop(); + + L.getField(idx, "y"); + y = luaToFp(L.toNumber(-1)); + L.pop(); + + L.getField(idx, "z"); + z = luaToFp(L.toNumber(-1)); + L.pop(); +} + +int LuaAPI::Vec3_New(lua_State* L) { + psyqo::Lua lua(L); + + psyqo::FixedPoint<12> x = luaToFp(lua.optNumber(1, 0)); + psyqo::FixedPoint<12> y = luaToFp(lua.optNumber(2, 0)); + psyqo::FixedPoint<12> z = luaToFp(lua.optNumber(3, 0)); + + PushVec3(lua, x, y, z); + return 1; +} + +int LuaAPI::Vec3_Add(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.push(); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + PushVec3(lua, ax + bx, ay + by, az + bz); + return 1; +} + +int LuaAPI::Vec3_Sub(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.push(); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + PushVec3(lua, ax - bx, ay - by, az - bz); + return 1; +} + +int LuaAPI::Vec3_Mul(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.push(); + return 1; + } + + psyqo::FixedPoint<12> x, y, z; + ReadVec3(lua, 1, x, y, z); + + psyqo::FixedPoint<12> scalar = luaToFp(lua.toNumber(2)); + + PushVec3(lua, x * scalar, y * scalar, z * scalar); + return 1; +} + +int LuaAPI::Vec3_Dot(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.pushNumber(0); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + auto dot = ax * bx + ay * by + az * bz; + lua.pushNumber(fpToLua(dot)); + return 1; +} + +int LuaAPI::Vec3_Cross(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.push(); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + psyqo::FixedPoint<12> cx = ay * bz - az * by; + psyqo::FixedPoint<12> cy = az * bx - ax * bz; + psyqo::FixedPoint<12> cz = ax * by - ay * bx; + + PushVec3(lua, cx, cy, cz); + return 1; +} + +int LuaAPI::Vec3_LengthSq(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.pushNumber(0); + return 1; + } + + psyqo::FixedPoint<12> x, y, z; + ReadVec3(lua, 1, x, y, z); + + auto lengthSq = x * x + y * y + z * z; + lua.pushNumber(fpToLua(lengthSq)); + return 1; +} + +int LuaAPI::Vec3_Length(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.pushNumber(0); + return 1; + } + + psyqo::FixedPoint<12> x, y, z; + ReadVec3(lua, 1, x, y, z); + + // Compute length in scaled world-space to avoid fp12×fp12 overflow issues. + // Convert to Lua-number domain, sqrt there, return directly. + lua_Number sx = fpToLua(x); + lua_Number sy = fpToLua(y); + lua_Number sz = fpToLua(z); + lua_Number sqVal = sx * sx + sy * sy + sz * sz; + + if (sqVal <= 0) { + lua.pushNumber(0); + return 1; + } + + // Newton's method sqrt (integer-safe) + lua_Number guess = sqVal / 2; + if (guess == 0) guess = 1; + for (int i = 0; i < 12; i++) { + guess = (guess + sqVal / guess) / 2; + } + + lua.pushNumber(guess); + return 1; +} + +int LuaAPI::Vec3_Normalize(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1)) { + lua.push(); + return 1; + } + + psyqo::FixedPoint<12> x, y, z; + ReadVec3(lua, 1, x, y, z); + + // Work in Lua-number (world-space) domain for the sqrt + lua_Number sx = fpToLua(x); + lua_Number sy = fpToLua(y); + lua_Number sz = fpToLua(z); + lua_Number sLen = sx * sx + sy * sy + sz * sz; + + if (sLen <= 0) { + PushVec3(lua, psyqo::FixedPoint<12>(0), psyqo::FixedPoint<12>(0), psyqo::FixedPoint<12>(0)); + return 1; + } + + // Newton's method sqrt + lua_Number guess = sLen / 2; + if (guess == 0) guess = 1; + for (int i = 0; i < 12; i++) { + guess = (guess + sLen / guess) / 2; + } + if (guess == 0) guess = 1; + + PushVec3(lua, luaToFp(sx / guess), luaToFp(sy / guess), luaToFp(sz / guess)); + return 1; +} + +int LuaAPI::Vec3_DistanceSq(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.pushNumber(0); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + auto dx = ax - bx; + auto dy = ay - by; + auto dz = az - bz; + + auto distSq = dx * dx + dy * dy + dz * dz; + lua.pushNumber(fpToLua(distSq)); + return 1; +} + +int LuaAPI::Vec3_Distance(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.pushNumber(0); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + lua_Number dx = fpToLua(ax) - fpToLua(bx); + lua_Number dy = fpToLua(ay) - fpToLua(by); + lua_Number dz = fpToLua(az) - fpToLua(bz); + + lua_Number sqVal = dx * dx + dy * dy + dz * dz; + + if (sqVal <= 0) { + lua.pushNumber(0); + return 1; + } + + lua_Number guess = sqVal / 2; + if (guess == 0) guess = 1; + for (int i = 0; i < 12; i++) { + guess = (guess + sqVal / guess) / 2; + } + + lua.pushNumber(guess); + return 1; +} + +int LuaAPI::Vec3_Lerp(lua_State* L) { + psyqo::Lua lua(L); + + if (!lua.isTable(1) || !lua.isTable(2)) { + lua.push(); + return 1; + } + + psyqo::FixedPoint<12> ax, ay, az; + psyqo::FixedPoint<12> bx, by, bz; + + ReadVec3(lua, 1, ax, ay, az); + ReadVec3(lua, 2, bx, by, bz); + + psyqo::FixedPoint<12> t = luaToFp(lua.toNumber(3)); + psyqo::FixedPoint<12> oneMinusT = psyqo::FixedPoint<12>(4096, psyqo::FixedPoint<12>::RAW) - t; + + psyqo::FixedPoint<12> rx = ax * oneMinusT + bx * t; + psyqo::FixedPoint<12> ry = ay * oneMinusT + by * t; + psyqo::FixedPoint<12> rz = az * oneMinusT + bz * t; + + PushVec3(lua, rx, ry, rz); + return 1; +} + +// ============================================================================ +// INPUT API IMPLEMENTATION +// ============================================================================ + +void LuaAPI::RegisterInputConstants(psyqo::Lua& L) { + // Button constants - must match psyqo::AdvancedPad::Button enum + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Cross)); + L.setField(-2, "CROSS"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Circle)); + L.setField(-2, "CIRCLE"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Square)); + L.setField(-2, "SQUARE"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Triangle)); + L.setField(-2, "TRIANGLE"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::L1)); + L.setField(-2, "L1"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::R1)); + L.setField(-2, "R1"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::L2)); + L.setField(-2, "L2"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::R2)); + L.setField(-2, "R2"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Start)); + L.setField(-2, "START"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Select)); + L.setField(-2, "SELECT"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Up)); + L.setField(-2, "UP"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Down)); + L.setField(-2, "DOWN"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Left)); + L.setField(-2, "LEFT"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::Right)); + L.setField(-2, "RIGHT"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::L3)); + L.setField(-2, "L3"); + + L.pushNumber(static_cast(psyqo::AdvancedPad::Button::R3)); + L.setField(-2, "R3"); +} + +int LuaAPI::Input_IsPressed(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isNumber(1)) { + lua.push(false); + return 1; + } + + auto button = static_cast(static_cast(lua.toNumber(1))); + lua.push(s_sceneManager->getControls().wasButtonPressed(button)); + return 1; +} + +int LuaAPI::Input_IsReleased(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isNumber(1)) { + lua.push(false); + return 1; + } + + auto button = static_cast(static_cast(lua.toNumber(1))); + lua.push(s_sceneManager->getControls().wasButtonReleased(button)); + return 1; +} + +int LuaAPI::Input_IsHeld(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isNumber(1)) { + lua.push(false); + return 1; + } + + auto button = static_cast(static_cast(lua.toNumber(1))); + lua.push(s_sceneManager->getControls().isButtonHeld(button)); + return 1; +} + +int LuaAPI::Input_GetAnalog(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager) { + lua.pushNumber(0); + lua.pushNumber(0); + return 2; + } + + int stick = lua.isNumber(1) ? static_cast(lua.toNumber(1)) : 0; + auto& controls = s_sceneManager->getControls(); + + int16_t x, y; + if (stick == 1) { + x = controls.getRightStickX(); + y = controls.getRightStickY(); + } else { + x = controls.getLeftStickX(); + y = controls.getLeftStickY(); + } + + // Scale to approximately [-1.0, 1.0] in Lua number space + // Stick range is -127 to +127; divide by 127 + lua.pushNumber(x * kFixedScale / 127); + lua.pushNumber(y * kFixedScale / 127); + return 2; +} + +// ============================================================================ +// TIMER API IMPLEMENTATION +// ============================================================================ + +static uint32_t s_frameCount = 0; + +void LuaAPI::IncrementFrameCount() { + s_frameCount++; +} + +void LuaAPI::ResetFrameCount() { + s_frameCount = 0; +} + +int LuaAPI::Timer_GetFrameCount(lua_State* L) { + psyqo::Lua lua(L); + lua.pushNumber(s_frameCount); + return 1; +} + +// ============================================================================ +// CAMERA API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Camera_GetPosition(lua_State* L) { + psyqo::Lua lua(L); + + if (s_sceneManager) { + auto& pos = s_sceneManager->getCamera().GetPosition(); + PushVec3(lua, pos.x, pos.y, pos.z); + } else { + PushVec3(lua, psyqo::FixedPoint<12>(0), psyqo::FixedPoint<12>(0), psyqo::FixedPoint<12>(0)); + } + return 1; +} + +int LuaAPI::Camera_SetPosition(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isTable(1)) return 0; + + psyqo::FixedPoint<12> x, y, z; + ReadVec3(lua, 1, x, y, z); + s_sceneManager->getCamera().SetPosition(x, y, z); + return 0; +} + +int LuaAPI::Camera_GetRotation(lua_State* L) { + psyqo::Lua lua(L); + + // Camera only stores the rotation matrix internally. + // Decomposing back to Euler angles is not supported. + // Return {0,0,0} — use Camera.SetRotation to control orientation. + PushVec3(lua, psyqo::FixedPoint<12>(0), psyqo::FixedPoint<12>(0), psyqo::FixedPoint<12>(0)); + return 1; +} + +int LuaAPI::Camera_SetRotation(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager) return 0; + + // Accept three angles in pi-units (e.g., 0.5 = Ï€/2 = 90°) + // This matches psyqo::Angle convention used by the engine. + psyqo::Angle rx, ry, rz; + rx.value = static_cast(lua.optNumber(1, 0) * kAngleScale); + ry.value = static_cast(lua.optNumber(2, 0) * kAngleScale); + rz.value = static_cast(lua.optNumber(3, 0) * kAngleScale); + s_sceneManager->getCamera().SetRotation(rx, ry, rz); + return 0; +} + +int LuaAPI::Camera_LookAt(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager) return 0; + + psyqo::FixedPoint<12> tx, ty, tz; + + if (lua.isTable(1)) { + ReadVec3(lua, 1, tx, ty, tz); + } else { + tx = luaToFp(lua.optNumber(1, 0)); + ty = luaToFp(lua.optNumber(2, 0)); + tz = luaToFp(lua.optNumber(3, 0)); + } + + auto& cam = s_sceneManager->getCamera(); + auto& pos = cam.GetPosition(); + + // Compute direction vector from camera to target + lua_Number dx = fpToLua(tx) - fpToLua(pos.x); + lua_Number dy = fpToLua(ty) - fpToLua(pos.y); + lua_Number dz = fpToLua(tz) - fpToLua(pos.z); + + // Compute horizontal distance for pitch calculation + lua_Number horizDistSq = dx * dx + dz * dz; + lua_Number horizGuess = horizDistSq / 2; + if (horizGuess == 0) horizGuess = 1; + for (int i = 0; i < 12; i++) { + horizGuess = (horizGuess + horizDistSq / horizGuess) / 2; + } + + // Yaw = atan2(dx, dz) — approximate with lookup or use psyqo trig + // For now, use a simple atan2 approximation in fp12 domain + // and set rotation via SetRotation (pitch, yaw, 0) + // Approximate: yaw is proportional to dx/dz in small-angle + // Full implementation requires psyqo Trig atan2 which is not trivially + // accessible here. Set rotation to face the target on the Y axis. + // This is a simplified look-at that only handles yaw. + psyqo::Angle yaw; + psyqo::Angle pitch; + + // Use scaled integer atan2 approximation + // atan2(dx, dz) in the range [-Ï€, Ï€] + // For PS1, the exact method depends on psyqo's Trig class. + // Returning luaError since we can't do a proper atan2 without Trig instance. + // Compromise: just set rotation angles directly + yaw.value = 0; + pitch.value = 0; + + // For a real implementation, Camera would need a LookAt method. + return 0; +} + +// ============================================================================ +// AUDIO API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Audio_Play(lua_State* L) { + psyqo::Lua lua(L); + + int soundId = -1; + + // Accept number (index) or string (name lookup) like Entity.Find + // Check isNumber FIRST — in Lua, numbers pass isString too. + if (lua.isNumber(1)) { + soundId = static_cast(lua.toNumber(1)); + } else if (lua.isString(1)) { + const char* name = lua.toString(1); + soundId = s_sceneManager->findAudioClipByName(name); + if (soundId < 0) { + printf("[Lua] Audio.Play: clip '%s' not found\n", name); + lua.pushNumber(-1); + return 1; + } + } else { + lua.pushNumber(-1); + return 1; + } + + int volume = static_cast(lua.optNumber(2, 100)); + int pan = static_cast(lua.optNumber(3, 64)); + + int voice = s_sceneManager->getAudio().play(soundId, volume, pan); + lua.pushNumber(voice); + return 1; +} + +int LuaAPI::Audio_Find(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isString(1)) { + lua.push(); // nil + return 1; + } + + const char* name = lua.toString(1); + int clipIndex = s_sceneManager->findAudioClipByName(name); + + if (clipIndex >= 0) { + lua.pushNumber(static_cast(clipIndex)); + } else { + lua.push(); // nil + } + return 1; +} + +int LuaAPI::Audio_Stop(lua_State* L) { + psyqo::Lua lua(L); + int channelId = static_cast(lua.toNumber(1)); + s_sceneManager->getAudio().stopVoice(channelId); + return 0; +} + +int LuaAPI::Audio_SetVolume(lua_State* L) { + psyqo::Lua lua(L); + int channelId = static_cast(lua.toNumber(1)); + int volume = static_cast(lua.toNumber(2)); + int pan = static_cast(lua.optNumber(3, 64)); + s_sceneManager->getAudio().setVoiceVolume(channelId, volume, pan); + return 0; +} + +int LuaAPI::Audio_StopAll(lua_State* L) { + psyqo::Lua lua(L); + s_sceneManager->getAudio().stopAll(); + return 0; +} + +// ============================================================================ +// DEBUG API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Debug_Log(lua_State* L) { + psyqo::Lua lua(L); + + const char* msg = lua.optString(1, ""); + printf("[Lua] %s\n", msg); + + return 0; +} + +int LuaAPI::Debug_DrawLine(lua_State* L) { + psyqo::Lua lua(L); + + // Parse start and end Vec3 tables, optional color + psyqo::FixedPoint<12> sx, sy, sz, ex, ey, ez; + if (lua.isTable(1) && lua.isTable(2)) { + ReadVec3(lua, 1, sx, sy, sz); + ReadVec3(lua, 2, ex, ey, ez); + } + + // TODO: Queue LINE_G2 primitive through Renderer + // For now, log to debug console (visible in emulator) +#ifdef PSXSPLASH_DEBUG_DRAW + printf("[DebugDraw] Line (%d,%d,%d)->(%d,%d,%d)\n", + sx.raw() >> 12, sy.raw() >> 12, sz.raw() >> 12, + ex.raw() >> 12, ey.raw() >> 12, ez.raw() >> 12); +#endif + return 0; +} + +int LuaAPI::Debug_DrawBox(lua_State* L) { + psyqo::Lua lua(L); + + // Parse center and size Vec3 tables, optional color + psyqo::FixedPoint<12> cx, cy, cz, hx, hy, hz; + if (lua.isTable(1) && lua.isTable(2)) { + ReadVec3(lua, 1, cx, cy, cz); + ReadVec3(lua, 2, hx, hy, hz); + } + + // TODO: Queue 12 LINE_G2 primitives (box wireframe) through Renderer +#ifdef PSXSPLASH_DEBUG_DRAW + printf("[DebugDraw] Box center(%d,%d,%d) size(%d,%d,%d)\n", + cx.raw() >> 12, cy.raw() >> 12, cz.raw() >> 12, + hx.raw() >> 12, hy.raw() >> 12, hz.raw() >> 12); +#endif + return 0; +} + +// ============================================================================ +// MATH API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Math_Clamp(lua_State* L) { + psyqo::Lua lua(L); + + lua_Number value = lua.toNumber(1); + lua_Number minVal = lua.toNumber(2); + lua_Number maxVal = lua.toNumber(3); + + if (value < minVal) value = minVal; + if (value > maxVal) value = maxVal; + + lua.pushNumber(value); + return 1; +} + +int LuaAPI::Math_Lerp(lua_State* L) { + psyqo::Lua lua(L); + + lua_Number a = lua.toNumber(1); + lua_Number b = lua.toNumber(2); + lua_Number t = lua.toNumber(3); + + lua.pushNumber(a + (b - a) * t); + return 1; +} + +int LuaAPI::Math_Sign(lua_State* L) { + psyqo::Lua lua(L); + + lua_Number value = lua.toNumber(1); + + if (value > 0) lua.pushNumber(1); + else if (value < 0) lua.pushNumber(-1); + else lua.pushNumber(0); + + return 1; +} + +int LuaAPI::Math_Abs(lua_State* L) { + psyqo::Lua lua(L); + + lua_Number value = lua.toNumber(1); + lua.pushNumber(value < 0 ? -value : value); + return 1; +} + +int LuaAPI::Math_Min(lua_State* L) { + psyqo::Lua lua(L); + + lua_Number a = lua.toNumber(1); + lua_Number b = lua.toNumber(2); + + lua.pushNumber(a < b ? a : b); + return 1; +} + +int LuaAPI::Math_Max(lua_State* L) { + psyqo::Lua lua(L); + + lua_Number a = lua.toNumber(1); + lua_Number b = lua.toNumber(2); + + lua.pushNumber(a > b ? a : b); + return 1; +} + +// ============================================================================ +// SCENE API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Scene_Load(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager || !lua.isNumber(1)) { + return 0; + } + + int sceneIndex = static_cast(lua.toNumber(1)); + s_sceneManager->requestSceneLoad(sceneIndex); + return 0; +} + +int LuaAPI::Scene_GetIndex(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_sceneManager) { + lua.pushNumber(0); + return 1; + } + + lua.pushNumber(static_cast(s_sceneManager->getCurrentSceneIndex())); + return 1; +} + +// ============================================================================ +// PERSIST API IMPLEMENTATION +// ============================================================================ + +struct PersistEntry { + char key[32]; + lua_Number value; + bool used; +}; + +static PersistEntry s_persistData[16] = {}; + +// Inline string helpers (no libc on bare-metal PS1) +static bool streq(const char* a, const char* b) { + while (*a && *b) { if (*a++ != *b++) return false; } + return *a == *b; +} + +static void strcopy(char* dst, const char* src, int maxLen) { + int i = 0; + for (; i < maxLen - 1 && src[i]; i++) dst[i] = src[i]; + dst[i] = '\0'; +} + +int LuaAPI::Persist_Get(lua_State* L) { + psyqo::Lua lua(L); + const char* key = lua.toString(1); + if (!key) { lua.push(); return 1; } + + for (int i = 0; i < 16; i++) { + if (s_persistData[i].used && streq(s_persistData[i].key, key)) { + lua.pushNumber(s_persistData[i].value); + return 1; + } + } + lua.push(); // nil + return 1; +} + +int LuaAPI::Persist_Set(lua_State* L) { + psyqo::Lua lua(L); + const char* key = lua.toString(1); + if (!key) return 0; + + lua_Number value = lua.toNumber(2); + + // Update existing key + for (int i = 0; i < 16; i++) { + if (s_persistData[i].used && streq(s_persistData[i].key, key)) { + s_persistData[i].value = value; + return 0; + } + } + + // Find empty slot + for (int i = 0; i < 16; i++) { + if (!s_persistData[i].used) { + strcopy(s_persistData[i].key, key, 32); + s_persistData[i].value = value; + s_persistData[i].used = true; + return 0; + } + } + + return 0; // No room — silently fail +} + +void LuaAPI::PersistClear() { + for (int i = 0; i < 16; i++) { + s_persistData[i].used = false; + } +} + +} // namespace psxsplash diff --git a/src/luaapi.hh b/src/luaapi.hh new file mode 100644 index 0000000..56a7b28 --- /dev/null +++ b/src/luaapi.hh @@ -0,0 +1,268 @@ +#pragma once + +#include +#include +#include + +namespace psxsplash { + +class SceneManager; // Forward declaration + +/** + * Lua API - Provides game scripting functionality + * + * Available namespaces: + * - Entity: Object finding, spawning, destruction + * - Vec3: Vector math operations + * - Input: Controller state queries + * - Timer: Timer control + * - Camera: Camera manipulation + * - Audio: Sound playback (future) + * - Scene: Scene management + */ +class LuaAPI { +public: + // Initialize all API modules + static void RegisterAll(psyqo::Lua& L, SceneManager* scene); + + // Called once per frame to advance the Lua frame counter + static void IncrementFrameCount(); + + // Reset frame counter (called on scene load) + static void ResetFrameCount(); + +private: + // Store scene manager for API access + static SceneManager* s_sceneManager; + + // ======================================================================== + // ENTITY API + // ======================================================================== + + // Entity.FindByScriptIndex(index) -> object or nil + // Finds first object with matching Lua script file index + static int Entity_FindByScriptIndex(lua_State* L); + + // Entity.FindByIndex(index) -> object or nil + // Gets object by its array index + static int Entity_FindByIndex(lua_State* L); + + // Entity.Find(name) -> object or nil + // Finds first object with matching name (user-friendly) + static int Entity_Find(lua_State* L); + + // Entity.GetCount() -> number + // Returns total number of game objects + static int Entity_GetCount(lua_State* L); + + // Entity.SetActive(object, active) + // Sets object active state (fires onEnable/onDisable) + static int Entity_SetActive(lua_State* L); + + // Entity.IsActive(object) -> boolean + static int Entity_IsActive(lua_State* L); + + // Entity.GetPosition(object) -> {x, y, z} + static int Entity_GetPosition(lua_State* L); + + // Entity.SetPosition(object, {x, y, z}) + static int Entity_SetPosition(lua_State* L); + + // Entity.GetRotationY(object) -> number (radians) + static int Entity_GetRotationY(lua_State* L); + + // Entity.SetRotationY(object, angle) -> nil + static int Entity_SetRotationY(lua_State* L); + + // Entity.ForEach(callback) -> nil + // Calls callback(object, index) for each active game object + static int Entity_ForEach(lua_State* L); + + // ======================================================================== + // VEC3 API - Vector math + // ======================================================================== + + // Vec3.new(x, y, z) -> {x, y, z} + static int Vec3_New(lua_State* L); + + // Vec3.add(a, b) -> {x, y, z} + static int Vec3_Add(lua_State* L); + + // Vec3.sub(a, b) -> {x, y, z} + static int Vec3_Sub(lua_State* L); + + // Vec3.mul(v, scalar) -> {x, y, z} + static int Vec3_Mul(lua_State* L); + + // Vec3.dot(a, b) -> number + static int Vec3_Dot(lua_State* L); + + // Vec3.cross(a, b) -> {x, y, z} + static int Vec3_Cross(lua_State* L); + + // Vec3.length(v) -> number + static int Vec3_Length(lua_State* L); + + // Vec3.lengthSq(v) -> number (faster, no sqrt) + static int Vec3_LengthSq(lua_State* L); + + // Vec3.normalize(v) -> {x, y, z} + static int Vec3_Normalize(lua_State* L); + + // Vec3.distance(a, b) -> number + static int Vec3_Distance(lua_State* L); + + // Vec3.distanceSq(a, b) -> number (faster) + static int Vec3_DistanceSq(lua_State* L); + + // Vec3.lerp(a, b, t) -> {x, y, z} + static int Vec3_Lerp(lua_State* L); + + // ======================================================================== + // INPUT API - Controller state + // ======================================================================== + + // Input.IsPressed(button) -> boolean + // True only on the frame the button was pressed + static int Input_IsPressed(lua_State* L); + + // Input.IsReleased(button) -> boolean + // True only on the frame the button was released + static int Input_IsReleased(lua_State* L); + + // Input.IsHeld(button) -> boolean + // True while the button is held down + static int Input_IsHeld(lua_State* L); + + // Input.GetAnalog(stick) -> x, y + // Returns analog stick values (-128 to 127) + static int Input_GetAnalog(lua_State* L); + + // Button constants (registered as Input.CROSS, Input.CIRCLE, etc.) + static void RegisterInputConstants(psyqo::Lua& L); + + // ======================================================================== + // TIMER API - Frame counter + // ======================================================================== + + // Timer.GetFrameCount() -> number + // Returns total frames since scene start + static int Timer_GetFrameCount(lua_State* L); + + // ======================================================================== + // CAMERA API - Camera control + // ======================================================================== + + // Camera.GetPosition() -> {x, y, z} + static int Camera_GetPosition(lua_State* L); + + // Camera.SetPosition(x, y, z) + static int Camera_SetPosition(lua_State* L); + + // Camera.GetRotation() -> {x, y, z} + static int Camera_GetRotation(lua_State* L); + + // Camera.SetRotation(x, y, z) + static int Camera_SetRotation(lua_State* L); + + // Camera.LookAt(target) or Camera.LookAt(x, y, z) + static int Camera_LookAt(lua_State* L); + + // ======================================================================== + // AUDIO API - Sound playback (placeholder for SPU) + // ======================================================================== + + // Audio.Play(soundId, volume, pan) -> channelId + // soundId can be a number (clip index) or string (clip name) + static int Audio_Play(lua_State* L); + + // Audio.Find(name) -> clipIndex or nil + // Finds audio clip by name, returns its index for use with Play/Stop/etc. + static int Audio_Find(lua_State* L); + + // Audio.Stop(channelId) + static int Audio_Stop(lua_State* L); + + // Audio.SetVolume(channelId, volume) + static int Audio_SetVolume(lua_State* L); + + // Audio.StopAll() + static int Audio_StopAll(lua_State* L); + + // ======================================================================== + // DEBUG API - Development helpers + // ======================================================================== + + // Debug.Log(message) + static int Debug_Log(lua_State* L); + + // Debug.DrawLine(start, end, color) - draws debug line next frame + static int Debug_DrawLine(lua_State* L); + + // Debug.DrawBox(center, size, color) + static int Debug_DrawBox(lua_State* L); + + // ======================================================================== + // MATH API - Additional math functions + // ======================================================================== + + // Math.Clamp(value, min, max) + static int Math_Clamp(lua_State* L); + + // Math.Lerp(a, b, t) + static int Math_Lerp(lua_State* L); + + // Math.Sign(value) + static int Math_Sign(lua_State* L); + + // Math.Abs(value) + static int Math_Abs(lua_State* L); + + // Math.Min(a, b) + static int Math_Min(lua_State* L); + + // Math.Max(a, b) + static int Math_Max(lua_State* L); + + // ======================================================================== + // SCENE API - Scene management + // ======================================================================== + + // Scene.Load(sceneIndex) + // Requests a scene transition to the given index (0-based). + // The actual load happens at the end of the current frame. + static int Scene_Load(lua_State* L); + + // Scene.GetIndex() -> number + // Returns the index of the currently loaded scene. + static int Scene_GetIndex(lua_State* L); + + // ======================================================================== + // PERSIST API - Data that survives scene loads + // ======================================================================== + + // Persist.Get(key) -> number or nil + static int Persist_Get(lua_State* L); + + // Persist.Set(key, value) + static int Persist_Set(lua_State* L); + + // Reset all persistent data + static void PersistClear(); + + // ======================================================================== + // HELPERS + // ======================================================================== + + // Push a Vec3 table onto the stack + static void PushVec3(psyqo::Lua& L, psyqo::FixedPoint<12> x, + psyqo::FixedPoint<12> y, psyqo::FixedPoint<12> z); + + // Read a Vec3 table from the stack + static void ReadVec3(psyqo::Lua& L, int idx, + psyqo::FixedPoint<12>& x, + psyqo::FixedPoint<12>& y, + psyqo::FixedPoint<12>& z); +}; + +} // namespace psxsplash diff --git a/src/main.cpp b/src/main.cpp index c3d75c4..6a63cf8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,9 +8,8 @@ #include "renderer.hh" #include "scenemanager.hh" - -// Data from the splashpack -extern uint8_t _binary_output_bin_start[]; +#include "sceneloader.hh" +#include "pcdrv_handler.hh" namespace { @@ -29,6 +28,9 @@ class MainScene final : public psyqo::Scene { uint32_t m_lastFrameCounter; psxsplash::SceneManager m_sceneManager; + + // PCdrv-loaded scene data (owned) + uint8_t* m_sceneData = nullptr; }; PSXSplash app; @@ -53,7 +55,28 @@ void PSXSplash::createScene() { pushScene(&mainScene); } -void MainScene::start(StartReason reason) { m_sceneManager.InitializeScene(_binary_output_bin_start); } +void MainScene::start(StartReason reason) { + // On real hardware: register break handler for PCDRV over SIO1 + redirect printf + // On emulator: no-op (pcsx-redux handles PCDRV natively) + psxsplash::pcdrv_sio1_init(); + + // Initialize PCdrv (break instructions - handled by emulator or our break handler) + psxsplash::SceneLoader::Init(); + + // Load the first scene via PCdrv. + // Files are relative to the pcdrvbase directory (PSXBuild/). + int fileSize = 0; + m_sceneData = psxsplash::SceneLoader::LoadFile("scene_0.splashpack", fileSize); + + if (!m_sceneData) { + // Fallback: try legacy name for backwards compatibility + m_sceneData = psxsplash::SceneLoader::LoadFile("output.bin", fileSize); + } + + if (m_sceneData) { + m_sceneManager.InitializeScene(m_sceneData); + } +} void MainScene::frame() { uint32_t beginFrame = gpu().now(); @@ -73,8 +96,6 @@ void MainScene::frame() { gpu().getRefreshRate() / deltaTime); gpu().pumpCallbacks(); - uint32_t endFrame = gpu().now(); - uint32_t spent = endFrame - beginFrame; } int main() { return app.run(); } \ No newline at end of file diff --git a/src/mesh.hh b/src/mesh.hh index c4d3c7b..7801da9 100644 --- a/src/mesh.hh +++ b/src/mesh.hh @@ -5,6 +5,9 @@ namespace psxsplash { + // Sentinel value for untextured (vertex-color-only) triangles + static constexpr uint16_t UNTEXTURED_TPAGE = 0xFFFF; + class Tri final { public: psyqo::GTE::PackedVec3 v0, v1, v2; @@ -18,7 +21,13 @@ namespace psxsplash { psyqo::PrimPieces::TPageAttr tpage; uint16_t clutX; uint16_t clutY; - uint16_t padding; + uint16_t padding; + + /// Returns true if this triangle has no texture (vertex-color only). + /// These should be rendered as POLY_G3 (GouraudTriangle) instead of POLY_GT3. + bool isUntextured() const { + return *reinterpret_cast(&tpage) == UNTEXTURED_TPAGE; + } }; static_assert(sizeof(Tri) == 52, "Tri is not 52 bytes"); diff --git a/src/navmesh.cpp b/src/navmesh.cpp deleted file mode 100644 index fa8bdfa..0000000 --- a/src/navmesh.cpp +++ /dev/null @@ -1,122 +0,0 @@ -#include "navmesh.hh" - -#include - -#include -#include - -using namespace psyqo::fixed_point_literals; - -// FIXME: This entire file uses hard FixedPoint scaling of 100. This is not ideal. -// It would be better to move the fixedpoint precision to 19 instead. - -namespace psxsplash { - -psyqo::FixedPoint<12> DotProduct2D(const psyqo::Vec2& a, const psyqo::Vec2& b) { return a.x * b.x + a.y * b.y; } - -psyqo::Vec2 ClosestPointOnSegment(const psyqo::Vec2& A, const psyqo::Vec2& B, const psyqo::Vec2& P) { - psyqo::Vec2 AB = {B.x - A.x, B.y - A.y}; - psyqo::Vec2 AP = {P.x - A.x, P.y - A.y}; - auto abDot = DotProduct2D(AB, AB); - if (abDot == 0) return A; - psyqo::FixedPoint<12> t = DotProduct2D(AP, AB) / abDot; - if (t < 0.0_fp) t = 0.0_fp; - if (t > 1.0_fp) t = 1.0_fp; - return {(A.x + AB.x * t), (A.y + AB.y * t)}; -} - -bool PointInTriangle(psyqo::Vec3& p, NavMeshTri& tri) { - psyqo::Vec2 A = {tri.v0.x * 100, tri.v0.z * 100}; - psyqo::Vec2 B = {tri.v1.x * 100, tri.v1.z * 100}; - psyqo::Vec2 C = {tri.v2.x * 100, tri.v2.z * 100}; - psyqo::Vec2 P = {p.x * 100, p.z * 100}; - - psyqo::Vec2 v0 = {B.x - A.x, B.y - A.y}; - psyqo::Vec2 v1 = {C.x - A.x, C.y - A.y}; - psyqo::Vec2 v2 = {P.x - A.x, P.y - A.y}; - - auto d00 = DotProduct2D(v0, v0); - auto d01 = DotProduct2D(v0, v1); - auto d11 = DotProduct2D(v1, v1); - auto d20 = DotProduct2D(v2, v0); - auto d21 = DotProduct2D(v2, v1); - - psyqo::FixedPoint<12> denom = d00 * d11 - d01 * d01; - if (denom == 0.0_fp) { - return false; - } - auto invDenom = 1.0_fp / denom; - auto u = (d11 * d20 - d01 * d21) * invDenom; - auto w = (d00 * d21 - d01 * d20) * invDenom; - - return (u >= 0.0_fp) && (w >= 0.0_fp) && (u + w <= 1.0_fp); -} - -psyqo::Vec3 ComputeNormal(const NavMeshTri& tri) { - psyqo::Vec3 v1 = {tri.v1.x * 100 - tri.v0.x * 100, tri.v1.y * 100 - tri.v0.y * 100, tri.v1.z * 100 - tri.v0.z * 100}; - psyqo::Vec3 v2 = {tri.v2.x * 100 - tri.v0.x * 100, tri.v2.y * 100 - tri.v0.y * 100, tri.v2.z * 100 - tri.v0.z * 100}; - - psyqo::Vec3 normal = { - v1.y * v2.z - v1.z * v2.y, - v1.z * v2.x - v1.x * v2.z, - v1.x * v2.y - v1.y * v2.x - }; - return normal; -} - -psyqo::FixedPoint<12> CalculateY(const psyqo::Vec3& p, const NavMeshTri& tri) { - psyqo::Vec3 normal = ComputeNormal(tri); - - psyqo::FixedPoint<12> A = normal.x; - psyqo::FixedPoint<12> B = normal.y; - psyqo::FixedPoint<12> C = normal.z; - - psyqo::FixedPoint<12> D = -(A * tri.v0.x + B * tri.v0.y + C * tri.v0.z); - - if (B != 0.0_fp) { - return -(A * p.x + C * p.z + D) / B; - } else { - return p.y; - } -} - -psyqo::Vec3 ComputeNavmeshPosition(psyqo::Vec3& position, Navmesh& navmesh, psyqo::FixedPoint<12> playerHeight) { - for (int i = 0; i < navmesh.triangleCount; i++) { - if (PointInTriangle(position, navmesh.polygons[i])) { - position.y = CalculateY(position, navmesh.polygons[i]) - playerHeight; - return position; - } - } - - psyqo::Vec2 P = {position.x * 100, position.z * 100}; - - psyqo::Vec2 closestPoint; - psyqo::FixedPoint<12> minDist = 0x7ffff; - - for (int i = 0; i < navmesh.triangleCount; i++) { - NavMeshTri& tri = navmesh.polygons[i]; - psyqo::Vec2 A = {tri.v0.x * 100, tri.v0.z * 100}; - psyqo::Vec2 B = {tri.v1.x * 100, tri.v1.z * 100}; - psyqo::Vec2 C = {tri.v2.x * 100, tri.v2.z * 100}; - - std::array, 3> edges = {{{A, B}, {B, C}, {C, A}}}; - - for (auto& edge : edges) { - psyqo::Vec2 proj = ClosestPointOnSegment(edge.first, edge.second, P); - psyqo::Vec2 diff = {proj.x - P.x, proj.y - P.y}; - auto distSq = DotProduct2D(diff, diff); - if (distSq < minDist) { - minDist = distSq; - closestPoint = proj; - position.y = CalculateY(position, navmesh.polygons[i]) - playerHeight; - } - } - } - - position.x = closestPoint.x / 100; - position.z = closestPoint.y / 100; - - return position; -} - -} // namespace psxsplash diff --git a/src/navmesh.hh b/src/navmesh.hh deleted file mode 100644 index 538c1b3..0000000 --- a/src/navmesh.hh +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include - -namespace psxsplash { - -class NavMeshTri final { - public: - psyqo::Vec3 v0, v1, v2; -}; - -class Navmesh final { - public: - union { - NavMeshTri* polygons; - uint32_t polygonsOffset; - }; - uint16_t triangleCount; - uint16_t reserved; -}; - -psyqo::Vec3 ComputeNavmeshPosition(psyqo::Vec3& position, Navmesh& navmesh, psyqo::FixedPoint<12> pheight); - -} // namespace psxsplash \ No newline at end of file diff --git a/src/navregion.cpp b/src/navregion.cpp new file mode 100644 index 0000000..ee3feb1 --- /dev/null +++ b/src/navregion.cpp @@ -0,0 +1,351 @@ +#include "navregion.hh" + +#include +#include + + +/** + * navregion.cpp - Convex Region Navigation System + * + * All math is 20.12 fixed-point. Zero floats. + * + * Key operations: + * - resolvePosition: O(1) typical (check current + neighbors via portals) + * - pointInRegion: O(n) per polygon vertices (convex cross test) + * - getFloorY: O(1) plane equation evaluation + * - findRegion: O(R) brute force, used only at init + */ + +namespace psxsplash { + +// ============================================================================ +// Fixed-point helpers +// ============================================================================ + +static constexpr int FRAC_BITS = 12; +static constexpr int32_t FP_ONE = 1 << FRAC_BITS; + +static inline int32_t fpmul(int32_t a, int32_t b) { + return (int32_t)(((int64_t)a * b) >> FRAC_BITS); +} + +static inline int32_t fpdiv(int32_t a, int32_t b) { + if (b == 0) return 0; + int32_t q = a / b; + int32_t r = a - q * b; + return q * FP_ONE + (r << FRAC_BITS) / b; +} + +// ============================================================================ +// Initialization +// ============================================================================ + +const uint8_t* NavRegionSystem::initializeFromData(const uint8_t* data) { + const auto* hdr = reinterpret_cast(data); + m_header = *hdr; + data += sizeof(NavDataHeader); + + m_regions = reinterpret_cast(data); + data += m_header.regionCount * sizeof(NavRegion); + + m_portals = reinterpret_cast(data); + data += m_header.portalCount * sizeof(NavPortal); + + + + return data; +} + +// ============================================================================ +// Point-in-convex-polygon (XZ plane) +// ============================================================================ + +bool NavRegionSystem::pointInConvexPoly(int32_t px, int32_t pz, + const int32_t* vertsX, const int32_t* vertsZ, + int vertCount) { + if (vertCount < 3) return false; + + // For CCW winding, all cross products must be >= 0. + // cross = (bx - ax) * (pz - az) - (bz - az) * (px - ax) + for (int i = 0; i < vertCount; i++) { + int next = (i + 1) % vertCount; + int32_t ax = vertsX[i], az = vertsZ[i]; + int32_t bx = vertsX[next], bz = vertsZ[next]; + + // Edge direction + int32_t edgeX = bx - ax; + int32_t edgeZ = bz - az; + // Point relative to edge start + int32_t relX = px - ax; + int32_t relZ = pz - az; + + // Cross product (64-bit to prevent overflow) + int64_t cross = (int64_t)edgeX * relZ - (int64_t)edgeZ * relX; + if (cross < 0) return false; + } + return true; +} + +// ============================================================================ +// Closest point on segment (XZ only) +// ============================================================================ + +void NavRegionSystem::closestPointOnSegment(int32_t px, int32_t pz, + int32_t ax, int32_t az, + int32_t bx, int32_t bz, + int32_t& outX, int32_t& outZ) { + int32_t abx = bx - ax; + int32_t abz = bz - az; + int32_t lenSq = fpmul(abx, abx) + fpmul(abz, abz); + if (lenSq == 0) { + outX = ax; outZ = az; + return; + } + + int32_t dot = fpmul(px - ax, abx) + fpmul(pz - az, abz); + // t = dot / lenSq, clamped to [0, 1] + int32_t t; + if (dot <= 0) { + t = 0; + } else if (dot >= lenSq) { + t = FP_ONE; + } else { + t = fpdiv(dot, lenSq); + } + + outX = ax + fpmul(t, abx); + outZ = az + fpmul(t, abz); +} + +// ============================================================================ +// Segment crosses portal check (XZ) +// ============================================================================ + +bool NavRegionSystem::segmentCrossesPortal(int32_t p0x, int32_t p0z, + int32_t p1x, int32_t p1z, + int32_t ax, int32_t az, + int32_t bx, int32_t bz) { + // Standard 2D segment intersection test using cross products. + // Returns true if segment [p0,p1] crosses segment [a,b]. + + int32_t dx = p1x - p0x, dz = p1z - p0z; + int32_t ex = bx - ax, ez = bz - az; + + int64_t denom = (int64_t)dx * ez - (int64_t)dz * ex; + if (denom == 0) return false; // Parallel + + int32_t fx = ax - p0x, fz = az - p0z; + + int64_t tNum = (int64_t)fx * ez - (int64_t)fz * ex; + int64_t uNum = (int64_t)fx * dz - (int64_t)fz * dx; + + // Check t in [0,1] and u in [0,1] + if (denom > 0) { + if (tNum < 0 || tNum > denom) return false; + if (uNum < 0 || uNum > denom) return false; + } else { + if (tNum > 0 || tNum < denom) return false; + if (uNum > 0 || uNum < denom) return false; + } + + return true; +} + +// ============================================================================ +// Get floor Y at position (plane equation) +// ============================================================================ + +int32_t NavRegionSystem::getFloorY(int32_t x, int32_t z, uint16_t regionIndex) const { + if (regionIndex >= m_header.regionCount) return 0; + const auto& reg = m_regions[regionIndex]; + + // Y = planeA * X + planeB * Z + planeD + // (all in 20.12, products need 64-bit intermediate) + return fpmul(reg.planeA, x) + fpmul(reg.planeB, z) + reg.planeD; +} + +// ============================================================================ +// Point in region test +// ============================================================================ + +bool NavRegionSystem::pointInRegion(int32_t x, int32_t z, uint16_t regionIndex) const { + if (regionIndex >= m_header.regionCount) return false; + const auto& reg = m_regions[regionIndex]; + return pointInConvexPoly(x, z, reg.vertsX, reg.vertsZ, reg.vertCount); +} + +// ============================================================================ +// Find region (brute force, for initialization) +// ============================================================================ + +uint16_t NavRegionSystem::findRegion(int32_t x, int32_t z) const { + // When multiple regions overlap at the same XZ position (e.g., floor and + // elevated step), prefer the highest physical surface. In PSX Y-down space, + // highest surface = smallest (most negative) floor Y value. + uint16_t best = NAV_NO_REGION; + int32_t bestY = 0x7FFFFFFF; + for (uint16_t i = 0; i < m_header.regionCount; i++) { + if (pointInRegion(x, z, i)) { + int32_t fy = getFloorY(x, z, i); + if (fy < bestY) { + bestY = fy; + best = i; + } + } + } + return best; +} + +// ============================================================================ +// Clamp position to region boundary +// ============================================================================ + +void NavRegionSystem::clampToRegion(int32_t& x, int32_t& z, uint16_t regionIndex) const { + if (regionIndex >= m_header.regionCount) return; + const auto& reg = m_regions[regionIndex]; + + if (pointInConvexPoly(x, z, reg.vertsX, reg.vertsZ, reg.vertCount)) + return; // Already inside + + // Find closest point on any edge of the polygon + int32_t bestX = x, bestZ = z; + int64_t bestDistSq = 0x7FFFFFFFFFFFFFFFLL; + + for (int i = 0; i < reg.vertCount; i++) { + int next = (i + 1) % reg.vertCount; + int32_t cx, cz; + closestPointOnSegment(x, z, + reg.vertsX[i], reg.vertsZ[i], + reg.vertsX[next], reg.vertsZ[next], + cx, cz); + + int64_t dx = (int64_t)(x - cx); + int64_t dz = (int64_t)(z - cz); + int64_t distSq = dx * dx + dz * dz; + + if (distSq < bestDistSq) { + bestDistSq = distSq; + bestX = cx; + bestZ = cz; + } + } + + x = bestX; + z = bestZ; +} + +// ============================================================================ +// Resolve position (main per-frame call) +// ============================================================================ + +int32_t NavRegionSystem::resolvePosition(int32_t& newX, int32_t& newZ, + uint16_t& currentRegion) const { + if (!isLoaded() || m_header.regionCount == 0) return 0; + + + + // If no valid region, find one + if (currentRegion == NAV_NO_REGION || currentRegion >= m_header.regionCount) { + currentRegion = findRegion(newX, newZ); + + if (currentRegion == NAV_NO_REGION) return 0; + } + + // Check if still in current region + if (pointInRegion(newX, newZ, currentRegion)) { + int32_t fy = getFloorY(newX, newZ, currentRegion); + + // Check if a portal neighbor has a higher floor at this position. + // This handles overlapping regions (e.g., floor and elevated step). + // When the player walks onto the step, the step region (portal neighbor) + // has a higher floor (smaller Y in PSX Y-down) and should take priority. + const auto& reg = m_regions[currentRegion]; + for (int i = 0; i < reg.portalCount; i++) { + uint16_t portalIdx = reg.portalStart + i; + if (portalIdx >= m_header.portalCount) break; + uint16_t neighbor = m_portals[portalIdx].neighborRegion; + if (neighbor >= m_header.regionCount) continue; + if (pointInRegion(newX, newZ, neighbor)) { + int32_t nfy = getFloorY(newX, newZ, neighbor); + if (nfy < fy) { // Higher physical surface (Y-down: smaller = higher) + currentRegion = neighbor; + fy = nfy; + } + } + } + + return fy; + } + + + + // Check portal neighbors + const auto& reg = m_regions[currentRegion]; + for (int i = 0; i < reg.portalCount; i++) { + uint16_t portalIdx = reg.portalStart + i; + if (portalIdx >= m_header.portalCount) break; + + const auto& portal = m_portals[portalIdx]; + uint16_t neighbor = portal.neighborRegion; + + if (neighbor < m_header.regionCount && pointInRegion(newX, newZ, neighbor)) { + currentRegion = neighbor; + return getFloorY(newX, newZ, neighbor); + } + } + + // Not in current region or any neighbor — try broader search + // This handles jumping/falling to non-adjacent regions (e.g., landing on a platform) + { + uint16_t found = findRegion(newX, newZ); + if (found != NAV_NO_REGION) { + currentRegion = found; + return getFloorY(newX, newZ, found); + } + } + + // Truly off all regions — clamp to current region boundary + clampToRegion(newX, newZ, currentRegion); + return getFloorY(newX, newZ, currentRegion); +} + +// ============================================================================ +// Pathfinding stub +// ============================================================================ + +bool NavRegionSystem::findPath(uint16_t startRegion, uint16_t endRegion, + NavPath& path) const { + // STUB: Returns false until NPC pathfinding is implemented. + // When implemented, this will be A* over the region adjacency graph: + // - Open set: priority queue by f-cost (g + heuristic) + // - g-cost: sum of Euclidean distances between region centroids + // - Heuristic: straight-line distance to goal centroid + // - Neighbor iteration: via portal edges + // - Max path length: NAV_MAX_PATH_STEPS + path.stepCount = 0; + (void)startRegion; + (void)endRegion; + return false; +} + +// ============================================================================ +// Get portal between two regions +// ============================================================================ + +const NavPortal* NavRegionSystem::getPortalBetween(uint16_t regionA, uint16_t regionB) const { + if (regionA >= m_header.regionCount) return nullptr; + + const auto& reg = m_regions[regionA]; + for (int i = 0; i < reg.portalCount; i++) { + uint16_t portalIdx = reg.portalStart + i; + if (portalIdx >= m_header.portalCount) break; + + if (m_portals[portalIdx].neighborRegion == regionB) { + return &m_portals[portalIdx]; + } + } + + return nullptr; +} + +} // namespace psxsplash diff --git a/src/navregion.hh b/src/navregion.hh new file mode 100644 index 0000000..e66329d --- /dev/null +++ b/src/navregion.hh @@ -0,0 +1,188 @@ +#pragma once + +/** + * navregion.hh - Convex Region Navigation System + * + * Architecture: + * - Walkable surface decomposed into convex polygonal regions (XZ plane). + * - Adjacent regions share portal edges. + * - Player has a single current region index. + * - Movement: point-in-convex-polygon test → portal crossing → neighbor update. + * - Floor Y: project XZ onto region's floor plane. + * - Pathfinding: A* over region adjacency graph (stub ready for NPC drop-in). + * + * All math is fixed-point 20.12. Zero floats. + */ + +#include +#include +#include + +namespace psxsplash { + +// ============================================================================ +// Constants +// ============================================================================ +static constexpr int NAV_MAX_VERTS_PER_REGION = 8; // Max polygon verts +static constexpr int NAV_MAX_NEIGHBORS = 8; // Max portal edges per region +static constexpr int NAV_MAX_PATH_STEPS = 32; // Max A* path length +static constexpr uint16_t NAV_NO_REGION = 0xFFFF; // Sentinel: no region + +// ============================================================================ +// Surface type for navigation regions +// ============================================================================ +enum NavSurfaceType : uint8_t { + NAV_SURFACE_FLAT = 0, + NAV_SURFACE_RAMP = 1, + NAV_SURFACE_STAIRS = 2, +}; + +// ============================================================================ +// Portal edge — shared edge between two adjacent regions +// ============================================================================ +struct NavPortal { + int32_t ax, az; // Portal edge start (20.12 XZ) + int32_t bx, bz; // Portal edge end (20.12 XZ) + uint16_t neighborRegion; // Index of the region on the other side + int16_t heightDelta; // Vertical step in 4.12 (stairs, ledges) +}; +static_assert(sizeof(NavPortal) == 20, "NavPortal must be 20 bytes"); + +// ============================================================================ +// Nav Region — convex polygon on the XZ plane with floor info +// ============================================================================ +struct NavRegion { + // Convex polygon vertices (XZ, 20.12 fixed-point) + // Stored in CCW winding order + int32_t vertsX[NAV_MAX_VERTS_PER_REGION]; // 32 bytes + int32_t vertsZ[NAV_MAX_VERTS_PER_REGION]; // 32 bytes + + // Floor plane: Y = planeA * X + planeB * Z + planeD (all 20.12) + // For flat floors: planeA = planeB = 0, planeD = floorY + int32_t planeA, planeB, planeD; // 12 bytes + + // Portal neighbors + uint16_t portalStart; // Index into portal array 2 bytes + uint8_t portalCount; // Number of portals 1 byte + uint8_t vertCount; // Number of polygon verts 1 byte + + // Metadata + NavSurfaceType surfaceType; // 1 byte + uint8_t roomIndex; // Interior room (0xFF = exterior) 1 byte + uint8_t pad[2]; // Alignment 2 bytes + // Total: 32 + 32 + 12 + 4 + 4 = 84 bytes +}; +static_assert(sizeof(NavRegion) == 84, "NavRegion must be 84 bytes"); + +// ============================================================================ +// Nav data header +// ============================================================================ +struct NavDataHeader { + uint16_t regionCount; + uint16_t portalCount; + uint16_t startRegion; // Region the player spawns in + uint16_t pad; +}; +static_assert(sizeof(NavDataHeader) == 8, "NavDataHeader must be 8 bytes"); + +// ============================================================================ +// Path result for A* (used by NPC pathfinding) +// ============================================================================ +struct NavPath { + uint16_t regions[NAV_MAX_PATH_STEPS]; + int stepCount; +}; + +// ============================================================================ +// NavRegionSystem — manages navigation at runtime +// ============================================================================ +class NavRegionSystem { +public: + NavRegionSystem() = default; + + /// Initialize from splashpack data. Returns pointer past the data. + const uint8_t* initializeFromData(const uint8_t* data); + + /// Is nav data loaded? + bool isLoaded() const { return m_regions != nullptr; } + + /// Get the number of regions + uint16_t getRegionCount() const { return m_header.regionCount; } + + /// Get the start region + uint16_t getStartRegion() const { return m_header.startRegion; } + + /// Get the room index for a given nav region (0xFF = exterior/unknown) + uint8_t getRoomIndex(uint16_t regionIndex) const { + if (m_regions == nullptr || regionIndex >= m_header.regionCount) return 0xFF; + return m_regions[regionIndex].roomIndex; + } + + // ======================================================================== + // Player movement - called every frame + // ======================================================================== + + /// Given a new XZ position and the player's current region, + /// determine the correct region and return the floor Y. + /// Updates currentRegion in-place. + /// newX/newZ are clamped to stay within the region boundary. + /// Returns the Y position for the player's feet. + int32_t resolvePosition(int32_t& newX, int32_t& newZ, + uint16_t& currentRegion) const; + + /// Test if a point (XZ) is inside a specific region. + bool pointInRegion(int32_t x, int32_t z, uint16_t regionIndex) const; + + /// Compute floor Y at a given XZ within a region using the floor plane. + int32_t getFloorY(int32_t x, int32_t z, uint16_t regionIndex) const; + + /// Find which region contains a point (brute-force, for initialization). + uint16_t findRegion(int32_t x, int32_t z) const; + + /// Clamp an XZ position to stay within a region's polygon boundary. + /// Returns the clamped position. + void clampToRegion(int32_t& x, int32_t& z, uint16_t regionIndex) const; + + // ======================================================================== + // Pathfinding stub — documented API for NPC drop-in + // ======================================================================== + + /// Find a path from startRegion to endRegion. + /// Writes region indices into path.regions[], sets path.stepCount. + /// Returns true if a path was found. + /// + /// Implementation: A* over the region adjacency graph. + /// Cost heuristic: Euclidean distance between region centroids. + /// This is a STUB — returns false until NPC pathfinding is implemented. + bool findPath(uint16_t startRegion, uint16_t endRegion, + NavPath& path) const; + + /// Get the portal edge between two adjacent regions. + /// Returns nullptr if regions are not adjacent. + const NavPortal* getPortalBetween(uint16_t regionA, uint16_t regionB) const; + +private: + NavDataHeader m_header = {}; + const NavRegion* m_regions = nullptr; + const NavPortal* m_portals = nullptr; + + /// Point-in-convex-polygon test (XZ plane). + /// Uses cross-product sign consistency (all edges same winding). + static bool pointInConvexPoly(int32_t px, int32_t pz, + const int32_t* vertsX, const int32_t* vertsZ, + int vertCount); + + /// Closest point on a line segment to a point (XZ only) + static void closestPointOnSegment(int32_t px, int32_t pz, + int32_t ax, int32_t az, + int32_t bx, int32_t bz, + int32_t& outX, int32_t& outZ); + + /// Check if a line segment (player movement) crosses a portal edge + static bool segmentCrossesPortal(int32_t p0x, int32_t p0z, + int32_t p1x, int32_t p1z, + int32_t ax, int32_t az, + int32_t bx, int32_t bz); +}; + +} // namespace psxsplash diff --git a/src/pcdrv_handler.hh b/src/pcdrv_handler.hh new file mode 100644 index 0000000..e7e6897 --- /dev/null +++ b/src/pcdrv_handler.hh @@ -0,0 +1,298 @@ +/* + * pcdrv_handler.hh - Unified PCDRV API with runtime dispatch + * + * On pcsx-redux (emulator), uses pcdrv.h break instructions which are + * intercepted at the CPU level natively. + * + * On real hardware, bypasses break instructions entirely and communicates + * directly over SIO1 using the same protocol as PCdrvSerialHost.cs. + * This avoids reliance on the exception save area, which is fragile + * across different compiler versions and optimization levels. + * + * Additionally, redirects PSYQo's printf output to SIO1 on real hardware. + * + * Call pcdrv_sio1_init() once at startup, after PSYQo initialization. + * Then use pcdrv_open/read/write/close/seek instead of PCopen/PCread/etc. + */ + +#pragma once + +#include +#include +#include +#include +#include "common/hardware/pcsxhw.h" +#include "common/kernel/pcdrv.h" + +namespace psxsplash { + +// ========================================================================= +// SIO1 hardware registers (UART serial port at 0x1F801050) +// ========================================================================= + +#define SIO1_DATA (*(volatile uint8_t*)0x1F801050) +#define SIO1_STAT (*(volatile uint32_t*)0x1F801054) +#define SIO1_MODE (*(volatile uint16_t*)0x1F801058) +#define SIO1_CTRL (*(volatile uint16_t*)0x1F80105A) +#define SIO1_BAUD (*(volatile uint16_t*)0x1F80105E) + +#define SIO1_TX_RDY (1 << 0) +#define SIO1_RX_RDY (1 << 1) + +// ========================================================================= +// Low-level SIO1 I/O - blocking, tight polling loops +// ========================================================================= + +static inline void sio_putc(uint8_t byte) { + while (!(SIO1_STAT & SIO1_TX_RDY)) {} + SIO1_DATA = byte; +} + +static inline uint8_t sio_getc() { + while (!(SIO1_STAT & SIO1_RX_RDY)) {} + return SIO1_DATA; +} + +static inline void sio_write32(uint32_t val) { + sio_putc((uint8_t)(val)); + sio_putc((uint8_t)(val >> 8)); + sio_putc((uint8_t)(val >> 16)); + sio_putc((uint8_t)(val >> 24)); +} + +static inline uint32_t sio_read32() { + uint32_t v = (uint32_t)sio_getc(); + v |= (uint32_t)sio_getc() << 8; + v |= (uint32_t)sio_getc() << 16; + v |= (uint32_t)sio_getc() << 24; + return v; +} + +static inline bool sio_check_okay() { + return sio_getc() == 'O' && sio_getc() == 'K' + && sio_getc() == 'A' && sio_getc() == 'Y'; +} + +static inline void sio_pcdrv_escape(uint32_t funcCode) { + sio_putc(0x00); + sio_putc('p'); + sio_write32(funcCode); +} + +// ========================================================================= +// Runtime detection - reads magic at 0x1F802080 each call. +// NOT cached in a static, because this is a header-only file and each +// translation unit would get its own copy of any static variable. +// pcsx_present() is a single bus read - negligible cost. +// ========================================================================= + +// ========================================================================= +// Direct SIO1 PCDRV implementations (real hardware path) +// These call the host protocol directly with actual pointers/values, +// bypassing break instructions and the exception save area entirely. +// ========================================================================= + +static int sio_pcdrv_init() { + sio_pcdrv_escape(0x101); + if (sio_check_okay()) { + sio_getc(); // trailing 0x00 + return 0; + } + return -1; +} + +static int sio_pcdrv_open(const char* name, int flags) { + sio_pcdrv_escape(0x103); + if (!sio_check_okay()) return -1; + const char* p = name; + while (*p) sio_putc((uint8_t)*p++); + sio_putc(0x00); + sio_write32((uint32_t)flags); + if (sio_check_okay()) { + return (int)sio_read32(); // handle + } + return -1; +} + +static int sio_pcdrv_creat(const char* name) { + sio_pcdrv_escape(0x102); + if (!sio_check_okay()) return -1; + const char* p = name; + while (*p) sio_putc((uint8_t)*p++); + sio_putc(0x00); + sio_write32(0); // params + if (sio_check_okay()) { + return (int)sio_read32(); // handle + } + return -1; +} + +static int sio_pcdrv_close(int fd) { + sio_pcdrv_escape(0x104); + if (!sio_check_okay()) return -1; + sio_write32((uint32_t)fd); + sio_write32(0); // unused + sio_write32(0); // unused + if (sio_check_okay()) { + sio_read32(); // handle echo + return 0; + } + return -1; +} + +static int sio_pcdrv_read(int fd, void* buf, int len) { + sio_pcdrv_escape(0x105); + if (!sio_check_okay()) return -1; + sio_write32((uint32_t)fd); + sio_write32((uint32_t)len); + sio_write32((uint32_t)(uintptr_t)buf); // memaddr for host debug + if (sio_check_okay()) { + uint32_t dataLen = sio_read32(); + sio_read32(); // checksum (not verified) + uint8_t* dst = (uint8_t*)buf; + for (uint32_t i = 0; i < dataLen; i++) { + dst[i] = sio_getc(); + } + return (int)dataLen; + } + return -1; +} + +static int sio_pcdrv_write(int fd, const void* buf, int len) { + sio_pcdrv_escape(0x106); + if (!sio_check_okay()) return -1; + sio_write32((uint32_t)fd); + sio_write32((uint32_t)len); + sio_write32((uint32_t)(uintptr_t)buf); // memaddr for host debug + if (!sio_check_okay()) return -1; + const uint8_t* src = (const uint8_t*)buf; + for (int i = 0; i < len; i++) { + sio_putc(src[i]); + } + if (sio_check_okay()) { + return (int)sio_read32(); // bytes written + } + return -1; +} + +static int sio_pcdrv_seek(int fd, int offset, int whence) { + sio_pcdrv_escape(0x107); + if (!sio_check_okay()) return -1; + sio_write32((uint32_t)fd); + sio_write32((uint32_t)offset); + sio_write32((uint32_t)whence); + if (sio_check_okay()) { + return (int)sio_read32(); // new position + } + return -1; +} + +// ========================================================================= +// Public PCDRV API - runtime dispatch between emulator and real hardware +// Use these instead of pcdrv.h functions (PCopen, PCread, etc.) +// ========================================================================= + +static int pcdrv_init() { + if (pcsx_present()) return PCinit(); + return sio_pcdrv_init(); +} + +static int pcdrv_open(const char* name, int flags, int perms) { + if (pcsx_present()) return PCopen(name, flags, perms); + return sio_pcdrv_open(name, flags); +} + +static int pcdrv_creat(const char* name, int perms) { + if (pcsx_present()) return PCcreat(name, perms); + return sio_pcdrv_creat(name); +} + +static int pcdrv_close(int fd) { + if (pcsx_present()) return PCclose(fd); + return sio_pcdrv_close(fd); +} + +static int pcdrv_read(int fd, void* buf, int len) { + if (pcsx_present()) return PCread(fd, buf, len); + return sio_pcdrv_read(fd, buf, len); +} + +static int pcdrv_write(int fd, const void* buf, int len) { + if (pcsx_present()) return PCwrite(fd, buf, len); + return sio_pcdrv_write(fd, buf, len); +} + +static int pcdrv_seek(int fd, int offset, int whence) { + if (pcsx_present()) return PClseek(fd, offset, whence); + return sio_pcdrv_seek(fd, offset, whence); +} + +// ========================================================================= +// SIO1 initialization - 115200 baud, 8N1 +// ========================================================================= + +static void sio1Init() { + SIO1_CTRL = 0; // reset + SIO1_MODE = 0x004e; // MUL16, 8 data, no parity, 1 stop + SIO1_BAUD = (uint16_t)(2073600 / 115200); // = 18 + SIO1_CTRL = 0x0025; // TX enable, RX enable, RTS assert + for (int i = 0; i < 100; i++) { __asm__ volatile("" ::: "memory"); } // settle delay +} + +// ========================================================================= +// Printf redirect - replaces PSYQo's printfStub with SIO1 output +// +// PSYQo's kernel takeover (takeOverKernel) destroys the BIOS and replaces +// the A0/B0/C0 jump handlers. Only A0[0x3F] (printf) is functional; all +// other BIOS calls return immediately. PSYQo's printfStub calls +// syscall_write(1,...) which goes to A0[0x03] - a dead no-op on real HW. +// +// Fix: replace the printf target address embedded in the A0 handler code +// at addresses 0xa8 (lui $t0, hi) and 0xb4 (ori $t0, $t0, lo) with our +// function that outputs directly to SIO1. +// ========================================================================= + +// Printf replacement that sends output to SIO1 +static int sio1Printf(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + int r = vxprintf([](const char* data, int size, void*) { + for (int i = 0; i < size; i++) { + while (!(SIO1_STAT & SIO1_TX_RDY)) {} + SIO1_DATA = (uint8_t)data[i]; + } + }, nullptr, fmt, args); + va_end(args); + return r; +} + +static void redirectPrintfToSIO1() { + uintptr_t addr = (uintptr_t)sio1Printf; + uint16_t hi = (uint16_t)(addr >> 16); + uint16_t lo = (uint16_t)(addr & 0xffff); + if (lo >= 0x8000) hi++; // sign-extension compensation for ori + + // Patch the A0 handler's embedded address: + // 0xa8: lui $t0, hi (opcode 001111, rs=0, rt=$t0=$8) + // 0xb4: ori $t0, $t0, lo (opcode 001101, rs=$t0, rt=$t0) + *(volatile uint32_t*)0xa8 = 0x3C080000 | hi; // lui $t0, hi + *(volatile uint32_t*)0xb4 = 0x35080000 | lo; // ori $t0, $t0, lo + + psyqo::Kernel::flushCache(); +} + +// ========================================================================= +// Master init - call once at startup, after PSYQo initialization +// ========================================================================= + +static void pcdrv_sio1_init() { + if (pcsx_present()) return; // emulator handles PCDRV natively + + sio1Init(); + + // TODO: printf redirect (redirectPrintfToSIO1) disabled for now. + // Printf redirect patches A0 handler machine code at 0xa8/0xb4 + // and may cause instability - needs further testing. +} + +} // namespace psxsplash diff --git a/src/profiler.cpp b/src/profiler.cpp index 715649b..b4f897c 100644 --- a/src/profiler.cpp +++ b/src/profiler.cpp @@ -1,10 +1,11 @@ #include "profiler.hh" -#include "psyqo/xprintf.h" +#ifdef PSXSPLASH_PROFILER using namespace psxsplash::debug; -void pcsxRegisterVariable(void* address, const char* name) { +// Writes address+name to the PCSX-Redux debugger variable registry. +static void pcsxRegisterVariable(void* address, const char* name) { register void* a0 asm("a0") = address; register const char* a1 asm("a1") = name; __asm__ volatile("sb %0, 0x2081(%1)" : : "r"(255), "r"(0x1f800000), "r"(a0), "r"(a1)); @@ -25,8 +26,6 @@ void Profiler::reset() { } } -void Profiler::dumpToTTY() { - printf("profiler.rendering:%d,profiler.lua:%d,profiler.controls:%d,profiler.navmesh:%d\n", sectionTimes[0], sectionTimes[1], sectionTimes[2], sectionTimes[3]); -} +#endif // PSXSPLASH_PROFILER diff --git a/src/profiler.hh b/src/profiler.hh index 93f6a24..0bc4467 100644 --- a/src/profiler.hh +++ b/src/profiler.hh @@ -2,6 +2,8 @@ #include +#ifdef PSXSPLASH_PROFILER + namespace psxsplash::debug { enum ProfilerSection { @@ -21,7 +23,6 @@ public: void initialize(); void reset(); - void dumpToTTY(); void setSectionTime(ProfilerSection section, uint32_t time) { sectionTimes[section] = time; @@ -42,3 +43,5 @@ private: }; } // namespace psxsplash::debug + +#endif // PSXSPLASH_PROFILER diff --git a/src/renderer.cpp b/src/renderer.cpp index 44bea9d..4ae00b5 100644 --- a/src/renderer.cpp +++ b/src/renderer.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,246 +21,739 @@ using namespace psyqo::fixed_point_literals; using namespace psyqo::trig_literals; using namespace psyqo::GTE; -psxsplash::Renderer *psxsplash::Renderer::instance = nullptr; +psxsplash::Renderer* psxsplash::Renderer::instance = nullptr; -void psxsplash::Renderer::Init(psyqo::GPU &gpuInstance) { - psyqo::Kernel::assert(instance == nullptr, - "A second intialization of Renderer was tried"); - - clear(); - clear(); - clear(); - - write(psyqo::FixedPoint<16>(160.0).raw()); - write(psyqo::FixedPoint<16>(120.0).raw()); - - write(120); - - write(ORDERING_TABLE_SIZE / 3); - write(ORDERING_TABLE_SIZE / 4); - - if (!instance) { - instance = new Renderer(gpuInstance); - } +void psxsplash::Renderer::Init(psyqo::GPU& gpuInstance) { + psyqo::Kernel::assert(instance == nullptr, + "A second initialization of Renderer was tried"); + clear(); + clear(); + clear(); + write(psyqo::FixedPoint<16>(160.0).raw()); + write(psyqo::FixedPoint<16>(120.0).raw()); + write(PROJ_H); + write(ORDERING_TABLE_SIZE / 3); + write(ORDERING_TABLE_SIZE / 4); + if (!instance) { instance = new Renderer(gpuInstance); } } -void psxsplash::Renderer::SetCamera(psxsplash::Camera &camera) { - m_currentCamera = &camera; +void psxsplash::Renderer::SetCamera(psxsplash::Camera& camera) { m_currentCamera = &camera; } + +void psxsplash::Renderer::SetFog(const FogConfig& fog) { + m_fog = fog; + if (fog.enabled) { + m_clearcolor = fog.color; + write(static_cast(fog.color.r) << 4); + write(static_cast(fog.color.g) << 4); + write(static_cast(fog.color.b) << 4); + m_fog.fogFarSZ = 8000 / fog.density; + } else { + m_clearcolor = {.r = 0, .g = 0, .b = 0}; + m_fog.fogFarSZ = 0; + } } -void psxsplash::Renderer::Render(eastl::vector &objects) { - psyqo::Kernel::assert(m_currentCamera != nullptr, - "PSXSPLASH: Tried to render without an active camera"); - - uint8_t parity = m_gpu.getParity(); - - auto &ot = m_ots[parity]; - auto &clear = m_clear[parity]; - auto &balloc = m_ballocs[parity]; - - balloc.reset(); - eastl::array projected; - for (auto &obj : objects) { - psyqo::Vec3 cameraPosition, objectPosition; - psyqo::Matrix33 finalMatrix; +void psxsplash::Renderer::writeFogRegisters() { + // Per-vertex fog is now computed manually in processTriangle (no DPCT). + // DQA/DQB/RFC/GFC/BFC are no longer needed for fog. + // The fog color is used directly via m_fog.color in the fogBlend function. +} +psyqo::Vec3 psxsplash::Renderer::computeCameraViewPos() { ::clear(); ::clear(); ::clear(); - - // Rotate the camera Translation vector by the camera rotation writeSafe(m_currentCamera->GetRotation()); writeSafe(-m_currentCamera->GetPosition()); - Kernels::mvmva(); - cameraPosition = readSafe(); + return readSafe(); +} - // Rotate the object Translation vector by the camera rotation +void psxsplash::Renderer::setupObjectTransform( + GameObject* obj, const psyqo::Vec3& cameraPosition) { + ::clear(); + ::clear(); + ::clear(); + writeSafe(m_currentCamera->GetRotation()); writeSafe(obj->position); Kernels::mvmva(); - objectPosition = readSafe(); - + psyqo::Vec3 objectPosition = readSafe(); objectPosition.x += cameraPosition.x; objectPosition.y += cameraPosition.y; objectPosition.z += cameraPosition.z; - - // Combine object and camera rotations - MatrixMultiplyGTE(m_currentCamera->GetRotation(), obj->rotation, - &finalMatrix); - - psyqo::GTE::writeSafe( - objectPosition); - psyqo::GTE::writeSafe(finalMatrix); - - for (int i = 0; i < obj->polyCount; i++) { - Tri &tri = obj->polygons[i]; - psyqo::Vec3 result; - - writeSafe(tri.v0); - writeSafe(tri.v1); - writeSafe(tri.v2); - - Kernels::rtpt(); - Kernels::nclip(); - - /*int32_t mac0 = 0; - read(reinterpret_cast(&mac0)); - if (mac0 <= 0) - continue;*/ - - int32_t zIndex = 0; - uint32_t u0, u1, u2; - - read(&u0); - read(&u1); - read(&u2); - - int32_t sz0 = (int32_t)u0; - int32_t sz1 = (int32_t)u1; - int32_t sz2 = (int32_t)u2; - - if ((sz0 < 1 && sz1 < 1 && sz2 < 1)) { - continue; - }; - - zIndex = eastl::max(eastl::max(sz0, sz1), sz2); - if (zIndex < 0 || zIndex >= ORDERING_TABLE_SIZE) - continue; - - read(&projected[0].packed); - read(&projected[1].packed); - read(&projected[2].packed); - - auto &prim = - balloc.allocateFragment(); - - prim.primitive.pointA = projected[0]; - prim.primitive.pointB = projected[1]; - prim.primitive.pointC = projected[2]; - - prim.primitive.uvA = tri.uvA; - prim.primitive.uvB = tri.uvB; - prim.primitive.uvC = tri.uvC; - prim.primitive.tpage = tri.tpage; - psyqo::PrimPieces::ClutIndex clut(tri.clutX, tri.clutY); - prim.primitive.clutIndex = clut; - - prim.primitive.setColorA(tri.colorA); - prim.primitive.setColorB(tri.colorB); - prim.primitive.setColorC(tri.colorC); - prim.primitive.setOpaque(); - - m_ots[m_gpu.getParity()].insert(prim, zIndex); - } - } - m_gpu.getNextClear(clear.primitive, m_clearcolor); - m_gpu.chain(clear); - m_gpu.chain(ot); + psyqo::Matrix33 finalMatrix; + MatrixMultiplyGTE(m_currentCamera->GetRotation(), obj->rotation, &finalMatrix); + writeSafe(objectPosition); + writeSafe(finalMatrix); } -void psxsplash::Renderer::RenderNavmeshPreview(psxsplash::Navmesh navmesh, - bool isOnMesh) { - uint8_t parity = m_gpu.getParity(); - eastl::array projected; +// Per-vertex fog blend: result = vertexColor * (4096 - ir0) / 4096 + fogColor * ir0 / 4096 +static inline psyqo::Color fogBlend(psyqo::Color vc, int32_t ir0, psyqo::Color fogC) { + if (ir0 <= 0) return vc; + if (ir0 >= 4096) return fogC; + int32_t keep = 4096 - ir0; + return { + .r = (uint8_t)((vc.r * keep + fogC.r * ir0) >> 12), + .g = (uint8_t)((vc.g * keep + fogC.g * ir0) >> 12), + .b = (uint8_t)((vc.b * keep + fogC.b * ir0) >> 12), + }; +} - auto &ot = m_ots[parity]; - auto &clear = m_clear[parity]; - auto &balloc = m_ballocs[parity]; - balloc.reset(); +// ============================================================================ +// Core triangle pipeline (Bandwidth's proven approach + fog) +// rtpt -> nclip -> backface cull -> SZ depth -> SXY -> screen clip -> emit +// ============================================================================ - psyqo::Vec3 cameraPosition; - - ::clear(); - ::clear(); - ::clear(); - - // Rotate the camera Translation vector by the camera rotation - writeSafe(m_currentCamera->GetRotation()); - writeSafe(m_currentCamera->GetPosition()); - - Kernels::mvmva(); - cameraPosition = readSafe(); - - write(-cameraPosition.x.raw()); - write(-cameraPosition.y.raw()); - write(-cameraPosition.z.raw()); - - psyqo::GTE::writeSafe( - m_currentCamera->GetRotation()); - - for (int i = 0; i < navmesh.triangleCount; i++) { - NavMeshTri &tri = navmesh.polygons[i]; - psyqo::Vec3 result; +void psxsplash::Renderer::processTriangle( + Tri& tri, int32_t fogFarSZ, + psyqo::OrderingTable& ot, + psyqo::BumpAllocator& balloc) { writeSafe(tri.v0); writeSafe(tri.v1); writeSafe(tri.v2); Kernels::rtpt(); - Kernels::nclip(); - int32_t mac0 = 0; - read(reinterpret_cast(&mac0)); - if (mac0 <= 0) - continue; - - int32_t zIndex = 0; uint32_t u0, u1, u2; - read(&u0); - read(&u1); - read(&u2); + read(&u0); + read(&u1); + read(&u2); + int32_t sz0 = (int32_t)u0, sz1 = (int32_t)u1, sz2 = (int32_t)u2; - int32_t sz0 = *reinterpret_cast(&u0); - int32_t sz1 = *reinterpret_cast(&u1); - int32_t sz2 = *reinterpret_cast(&u2); + if (sz0 < 1 && sz1 < 1 && sz2 < 1) return; + if (fogFarSZ > 0 && sz0 > fogFarSZ && sz1 > fogFarSZ && sz2 > fogFarSZ) return; - zIndex = eastl::max(eastl::max(sz0, sz1), sz2); - if (zIndex < 0 || zIndex >= ORDERING_TABLE_SIZE) - continue; + int32_t zIndex = eastl::max(eastl::max(sz0, sz1), sz2); + if (zIndex < 0 || zIndex >= (int32_t)ORDERING_TABLE_SIZE) return; + // Per-vertex fog: compute fog factor for each vertex individually based on + // its SZ depth. The GPU then interpolates the fogged colors smoothly across + // the triangle surface, eliminating the per-triangle tiling artifacts that + // occur when a single IR0 is used for the whole triangle. + // + // fogIR[i] = 0 means no fog (original color), 4096 = full fog (fog color). + // Quadratic ease-in curve: fog dominates over baked lighting quickly. + int32_t fogIR[3] = {0, 0, 0}; + if (fogFarSZ > 0) { + int32_t fogNear = fogFarSZ / 4; + int32_t range4 = (fogFarSZ - fogNear) >> 4; + if (range4 < 1) range4 = 1; + int32_t scale = 4096 / range4; + int32_t szArr[3] = {sz0, sz1, sz2}; + for (int vi = 0; vi < 3; vi++) { + int32_t ir; + if (szArr[vi] <= fogNear) { + ir = 0; + } else if (szArr[vi] >= fogFarSZ) { + ir = 4096; + } else { + ir = ((szArr[vi] - fogNear) * scale) >> 4; + if (ir > 4096) ir = 4096; + int32_t inv = 4096 - ir; + ir = 4096 - ((inv >> 2) * (inv >> 2) >> 8); + if (ir < 0) ir = 0; + } + fogIR[vi] = ir; + } + } + + psyqo::Vertex projected[3]; read(&projected[0].packed); read(&projected[1].packed); read(&projected[2].packed); - auto &prim = balloc.allocateFragment(); + if (isCompletelyOutside(projected[0], projected[1], projected[2])) return; - prim.primitive.pointA = projected[0]; - prim.primitive.pointB = projected[1]; - prim.primitive.pointC = projected[2]; - - psyqo::Color heightColor; - - if (isOnMesh) { - heightColor.r = 0; - heightColor.g = - ((tri.v0.y.raw() + tri.v1.y.raw() + tri.v2.y.raw()) / 3) * 100 % 256; - heightColor.b = 0; - } else { - heightColor.r = - ((tri.v0.y.raw() + tri.v1.y.raw() + tri.v2.y.raw()) / 3) * 100 % 256; - heightColor.g = 0; - heightColor.b = 0; + // Triangles that need clipping skip nclip entirely. + // nclip with GTE-clamped screen coords gives wrong results for edge triangles. + // The clipper handles them directly - no backface cull needed since the + // clipper preserves winding and degenerate triangles produce zero-area output. + if (needsClipping(projected[0], projected[1], projected[2])) { + ClipVertex cv0 = {(int16_t)projected[0].x, (int16_t)projected[0].y, (int16_t)sz0, + tri.uvA.u, tri.uvA.v, tri.colorA.r, tri.colorA.g, tri.colorA.b}; + ClipVertex cv1 = {(int16_t)projected[1].x, (int16_t)projected[1].y, (int16_t)sz1, + tri.uvB.u, tri.uvB.v, tri.colorB.r, tri.colorB.g, tri.colorB.b}; + ClipVertex cv2 = {(int16_t)projected[2].x, (int16_t)projected[2].y, (int16_t)sz2, + tri.uvC.u, tri.uvC.v, tri.colorC.r, tri.colorC.g, tri.colorC.b}; + ClipResult clipResult; + int clippedCount = clipTriangle(cv0, cv1, cv2, clipResult); + for (int ct = 0; ct < clippedCount; ct++) { + const ClipVertex& a = clipResult.verts[ct*3]; + const ClipVertex& b = clipResult.verts[ct*3+1]; + const ClipVertex& c = clipResult.verts[ct*3+2]; + // For clipped vertices, use per-triangle fog (max SZ) since + // clipped vertex Z values may not map cleanly to the original SZs. + psyqo::Color ca = {a.r, a.g, a.b}, cb = {b.r, b.g, b.b}, cc = {c.r, c.g, c.b}; + if (m_fog.enabled) { + int32_t maxIR = eastl::max(eastl::max(fogIR[0], fogIR[1]), fogIR[2]); + ca = fogBlend(ca, maxIR, m_fog.color); + cb = fogBlend(cb, maxIR, m_fog.color); + cc = fogBlend(cc, maxIR, m_fog.color); + } + if (tri.isUntextured()) { + auto& p = balloc.allocateFragment(); + p.primitive.pointA.x = a.x; p.primitive.pointA.y = a.y; + p.primitive.pointB.x = b.x; p.primitive.pointB.y = b.y; + p.primitive.pointC.x = c.x; p.primitive.pointC.y = c.y; + p.primitive.setColorA(ca); p.primitive.setColorB(cb); p.primitive.setColorC(cc); + p.primitive.setOpaque(); + ot.insert(p, zIndex); + } else { + auto& p = balloc.allocateFragment(); + p.primitive.pointA.x = a.x; p.primitive.pointA.y = a.y; + p.primitive.pointB.x = b.x; p.primitive.pointB.y = b.y; + p.primitive.pointC.x = c.x; p.primitive.pointC.y = c.y; + p.primitive.uvA.u = a.u; p.primitive.uvA.v = a.v; + p.primitive.uvB.u = b.u; p.primitive.uvB.v = b.v; + p.primitive.uvC.u = c.u; p.primitive.uvC.v = c.v; + p.primitive.tpage = tri.tpage; + psyqo::PrimPieces::ClutIndex clut(tri.clutX, tri.clutY); + p.primitive.clutIndex = clut; + p.primitive.setColorA(ca); p.primitive.setColorB(cb); p.primitive.setColorC(cc); + p.primitive.setOpaque(); + ot.insert(p, zIndex); + } + } + return; } - prim.primitive.setColor(heightColor); - prim.primitive.setOpaque(); - ot.insert(prim, zIndex); - } - m_gpu.getNextClear(clear.primitive, m_clearcolor); - m_gpu.chain(clear); - m_gpu.chain(ot); + // Normal path: triangle is fully inside clip region with safe deltas. + // nclip is reliable here since screen coords aren't clamped. + Kernels::nclip(); + int32_t mac0 = 0; + read(reinterpret_cast(&mac0)); + if (mac0 <= 0) return; + + // Per-vertex fog: blend each vertex color toward fog color based on its depth. + // GPU interpolates these smoothly across the triangle - no tiling artifacts. + psyqo::Color cA = tri.colorA, cB = tri.colorB, cC = tri.colorC; + if (m_fog.enabled) { + cA = fogBlend(cA, fogIR[0], m_fog.color); + cB = fogBlend(cB, fogIR[1], m_fog.color); + cC = fogBlend(cC, fogIR[2], m_fog.color); + } + + if (tri.isUntextured()) { + auto& p = balloc.allocateFragment(); + p.primitive.pointA = projected[0]; p.primitive.pointB = projected[1]; p.primitive.pointC = projected[2]; + p.primitive.setColorA(cA); p.primitive.setColorB(cB); p.primitive.setColorC(cC); + p.primitive.setOpaque(); + ot.insert(p, zIndex); + } else { + auto& p = balloc.allocateFragment(); + p.primitive.pointA = projected[0]; p.primitive.pointB = projected[1]; p.primitive.pointC = projected[2]; + p.primitive.uvA = tri.uvA; p.primitive.uvB = tri.uvB; p.primitive.uvC = tri.uvC; + p.primitive.tpage = tri.tpage; + psyqo::PrimPieces::ClutIndex clut(tri.clutX, tri.clutY); + p.primitive.clutIndex = clut; + p.primitive.setColorA(cA); p.primitive.setColorB(cB); p.primitive.setColorC(cC); + p.primitive.setOpaque(); + ot.insert(p, zIndex); + } } -void psxsplash::Renderer::VramUpload(const uint16_t *imageData, int16_t posX, - int16_t posY, int16_t width, - int16_t height) { - psyqo::Rect uploadRect{.a = {.x = posX, .y = posY}, .b = {width, height}}; - m_gpu.uploadToVRAM(imageData, uploadRect); +// ============================================================================ +// Render paths +// ============================================================================ + +void psxsplash::Renderer::Render(eastl::vector& objects) { + psyqo::Kernel::assert(m_currentCamera != nullptr, "PSXSPLASH: Tried to render without an active camera"); + uint8_t parity = m_gpu.getParity(); + auto& ot = m_ots[parity]; auto& clear = m_clear[parity]; auto& balloc = m_ballocs[parity]; + balloc.reset(); + // Set dithering draw mode at the back of the OT so it fires before any geometry. + auto& ditherCmd = balloc.allocateFragment(); + ditherCmd.primitive.attr.setDithering(true); + ot.insert(ditherCmd, ORDERING_TABLE_SIZE - 1); + writeFogRegisters(); + psyqo::Vec3 cameraPosition = computeCameraViewPos(); + int32_t fogFarSZ = m_fog.fogFarSZ; + for (auto& obj : objects) { + setupObjectTransform(obj, cameraPosition); + for (int i = 0; i < obj->polyCount; i++) + processTriangle(obj->polygons[i], fogFarSZ, ot, balloc); + } + m_gpu.getNextClear(clear.primitive, m_clearcolor); + m_gpu.chain(clear); m_gpu.chain(ot); + m_frameCount++; } -psyqo::Color averageColor(const psyqo::Color &a, const psyqo::Color &b) { - return psyqo::Color{static_cast((a.r + b.r) >> 1), - static_cast((a.g + b.g) >> 1), - static_cast((a.b + b.b) >> 1)}; +void psxsplash::Renderer::RenderWithBVH(eastl::vector& objects, const BVHManager& bvh) { + psyqo::Kernel::assert(m_currentCamera != nullptr, "PSXSPLASH: Tried to render without an active camera"); + if (!bvh.isLoaded()) { Render(objects); return; } + uint8_t parity = m_gpu.getParity(); + auto& ot = m_ots[parity]; auto& clear = m_clear[parity]; auto& balloc = m_ballocs[parity]; + balloc.reset(); + auto& ditherCmd2 = balloc.allocateFragment(); + ditherCmd2.primitive.attr.setDithering(true); + ot.insert(ditherCmd2, ORDERING_TABLE_SIZE - 1); + writeFogRegisters(); + Frustum frustum; m_currentCamera->ExtractFrustum(frustum); + int visibleCount = bvh.cullFrustum(frustum, m_visibleRefs, MAX_VISIBLE_TRIANGLES); + psyqo::Vec3 cameraPosition = computeCameraViewPos(); + int32_t fogFarSZ = m_fog.fogFarSZ; + int16_t lastObjectIndex = -1; + for (int i = 0; i < visibleCount; i++) { + const TriangleRef& ref = m_visibleRefs[i]; + if (ref.objectIndex >= objects.size()) continue; + GameObject* obj = objects[ref.objectIndex]; + if (ref.triangleIndex >= obj->polyCount) continue; + if (ref.objectIndex != lastObjectIndex) { + lastObjectIndex = ref.objectIndex; + setupObjectTransform(obj, cameraPosition); + } + processTriangle(obj->polygons[ref.triangleIndex], fogFarSZ, ot, balloc); + } + m_gpu.getNextClear(clear.primitive, m_clearcolor); + m_gpu.chain(clear); m_gpu.chain(ot); + m_frameCount++; } +// ============================================================================ +// RenderWithRooms - Portal/room occlusion for interior scenes +// ============================================================================ + +struct ScreenRect { int16_t minX, minY, maxX, maxY; }; + +static inline bool intersectRect(const ScreenRect& a, const ScreenRect& b, ScreenRect& out) { + out.minX = (a.minX > b.minX) ? a.minX : b.minX; out.minY = (a.minY > b.minY) ? a.minY : b.minY; + out.maxX = (a.maxX < b.maxX) ? a.maxX : b.maxX; out.maxY = (a.maxY < b.maxY) ? a.maxY : b.maxY; + return out.minX < out.maxX && out.minY < out.maxY; +} + +// Safety margin added to portal screen rects (pixels). +// Prevents geometry from popping at portal edges due to fixed-point rounding. +static constexpr int16_t PORTAL_MARGIN = 16; + +// Transform a world-space point to camera space using the view rotation matrix. +static inline void worldToCamera(int32_t wx, int32_t wy, int32_t wz, + int32_t camX, int32_t camY, int32_t camZ, + const psyqo::Matrix33& camRot, + int32_t& outX, int32_t& outY, int32_t& outZ) { + int32_t rx = wx - camX, ry = wy - camY, rz = wz - camZ; + outX = (int32_t)(((int64_t)camRot.vs[0].x.value * rx + (int64_t)camRot.vs[0].y.value * ry + + (int64_t)camRot.vs[0].z.value * rz) >> 12); + outY = (int32_t)(((int64_t)camRot.vs[1].x.value * rx + (int64_t)camRot.vs[1].y.value * ry + + (int64_t)camRot.vs[1].z.value * rz) >> 12); + outZ = (int32_t)(((int64_t)camRot.vs[2].x.value * rx + (int64_t)camRot.vs[2].y.value * ry + + (int64_t)camRot.vs[2].z.value * rz) >> 12); +} + +// Project a camera-space point to screen coordinates. +// Returns false if behind near plane. +static inline bool projectToScreen(int32_t vx, int32_t vy, int32_t vz, + int16_t& sx, int16_t& sy) { + if (vz <= 0) return false; + constexpr int32_t H = 120; + int32_t vzs = vz >> 4; if (vzs <= 0) vzs = 1; + sx = (int16_t)((vx >> 4) * H / vzs + 160); + sy = (int16_t)((vy >> 4) * H / vzs + 120); + return true; +} + +// Project a portal quad to a screen-space AABB. +// Computes the 4 corners, transforms to camera space, clips against the near plane, +// projects visible points to screen, and returns the bounding rect. +static bool projectPortalRect(const psxsplash::PortalData& portal, + int32_t camX, int32_t camY, int32_t camZ, const psyqo::Matrix33& camRot, ScreenRect& outRect) { + + // Compute portal corner offsets in world space. + int32_t rwx = ((int32_t)portal.rightX * portal.halfW) >> 12; + int32_t rwy = ((int32_t)portal.rightY * portal.halfW) >> 12; + int32_t rwz = ((int32_t)portal.rightZ * portal.halfW) >> 12; + int32_t uhx = ((int32_t)portal.upX * portal.halfH) >> 12; + int32_t uhy = ((int32_t)portal.upY * portal.halfH) >> 12; + int32_t uhz = ((int32_t)portal.upZ * portal.halfH) >> 12; + + int32_t cx = portal.centerX, cy = portal.centerY, cz = portal.centerZ; + + // Transform 4 corners to camera space + struct CamVert { int32_t x, y, z; }; + CamVert cv[4]; + int32_t wCorners[4][3] = { + {cx + rwx + uhx, cy + rwy + uhy, cz + rwz + uhz}, + {cx - rwx + uhx, cy - rwy + uhy, cz - rwz + uhz}, + {cx - rwx - uhx, cy - rwy - uhy, cz - rwz - uhz}, + {cx + rwx - uhx, cy + rwy - uhy, cz + rwz - uhz}, + }; + + int behindCount = 0; + for (int i = 0; i < 4; i++) { + worldToCamera(wCorners[i][0], wCorners[i][1], wCorners[i][2], + camX, camY, camZ, camRot, cv[i].x, cv[i].y, cv[i].z); + if (cv[i].z <= 0) behindCount++; + } + + if (behindCount == 4) { + // All corners behind camera. Only allow if camera is very close to portal. + int32_t vx, vy, vz; + worldToCamera(cx, cy, cz, camX, camY, camZ, camRot, vx, vy, vz); + int32_t portalExtent = portal.halfW > portal.halfH ? portal.halfW : portal.halfH; + if (-vz > portalExtent * 2) return false; + outRect = {-512, -512, 832, 752}; + return true; + } + + // Clip against near plane (z=1) and project visible points. + // For each edge where one vertex is in front and one behind, + // compute the intersection point and include it in the screen rect. + constexpr int32_t NEAR_Z = 1; + int16_t sxMin = 32767, sxMax = -32768; + int16_t syMin = 32767, syMax = -32768; + int projCount = 0; + + for (int i = 0; i < 4; i++) { + int j = (i + 1) % 4; + + // Project vertex i if in front + if (cv[i].z > 0) { + int16_t sx, sy; + if (projectToScreen(cv[i].x, cv[i].y, cv[i].z, sx, sy)) { + if (sx < sxMin) sxMin = sx; + if (sx > sxMax) sxMax = sx; + if (sy < syMin) syMin = sy; + if (sy > syMax) syMax = sy; + projCount++; + } + } + + // If edge crosses the near plane, clip and project the intersection. + // All 32-bit arithmetic (no __divdi3 on MIPS R3000). + bool iFront = cv[i].z > 0; + bool jFront = cv[j].z > 0; + if (iFront != jFront) { + int32_t dz = cv[j].z - cv[i].z; + if (dz == 0) continue; + int32_t dzs = dz >> 4; + if (dzs == 0) dzs = (dz > 0) ? 1 : -1; // prevent div-by-zero after shift + // Compute t in 4.12 fixed-point. Shift num/den by 4 to keep * 4096 in 32 bits. + int32_t t12 = (((NEAR_Z - cv[i].z) >> 4) * 4096) / dzs; + // Apply t: clip = cv[i] + (cv[j] - cv[i]) * t12 / 4096 + // Shift dx by 4 so (dx>>4)*t12 fits int32, then >>8 to undo (4+8=12 total) + int32_t clipX = cv[i].x + ((((cv[j].x - cv[i].x) >> 4) * t12) >> 8); + int32_t clipY = cv[i].y + ((((cv[j].y - cv[i].y) >> 4) * t12) >> 8); + int16_t sx, sy; + if (projectToScreen(clipX, clipY, NEAR_Z, sx, sy)) { + if (sx < sxMin) sxMin = sx; + if (sx > sxMax) sxMax = sx; + if (sy < syMin) syMin = sy; + if (sy > syMax) syMax = sy; + projCount++; + } + } + } + + if (projCount == 0) return false; + + outRect = { + (int16_t)(sxMin - PORTAL_MARGIN), (int16_t)(syMin - PORTAL_MARGIN), + (int16_t)(sxMax + PORTAL_MARGIN), (int16_t)(syMax + PORTAL_MARGIN) + }; + return true; +} + +// Test if a room's AABB is potentially visible to the camera frustum. +// Quick rejection test: if the room is entirely behind the camera, skip it. +static bool isRoomPotentiallyVisible(const psxsplash::RoomData& room, + int32_t camX, int32_t camY, int32_t camZ, const psyqo::Matrix33& camRot) { + // Transform the room's AABB center to camera space and check Z. + // Use the p-vertex approach: find the corner most in the camera forward direction. + int32_t fwdX = camRot.vs[2].x.value; + int32_t fwdY = camRot.vs[2].y.value; + int32_t fwdZ = camRot.vs[2].z.value; + + // p-vertex: corner of AABB closest to camera forward direction + int32_t px = (fwdX >= 0) ? room.aabbMaxX : room.aabbMinX; + int32_t py = (fwdY >= 0) ? room.aabbMaxY : room.aabbMinY; + int32_t pz = (fwdZ >= 0) ? room.aabbMaxZ : room.aabbMinZ; + + // If p-vertex is behind camera, the entire AABB is behind + int32_t rx = px - camX, ry = py - camY, rz = pz - camZ; + int64_t dotFwd = ((int64_t)fwdX * rx + (int64_t)fwdY * ry + (int64_t)fwdZ * rz) >> 12; + if (dotFwd < -4096) return false; // Entirely behind with 1-unit margin + + return true; +} + +void psxsplash::Renderer::RenderWithRooms(eastl::vector& objects, + const RoomData* rooms, int roomCount, const PortalData* portals, int portalCount, + const TriangleRef* roomTriRefs, int cameraRoom) { + psyqo::Kernel::assert(m_currentCamera != nullptr, "PSXSPLASH: Tried to render without an active camera"); + if (roomCount == 0 || rooms == nullptr) { Render(objects); return; } + + uint8_t parity = m_gpu.getParity(); + auto& ot = m_ots[parity]; auto& clear = m_clear[parity]; auto& balloc = m_ballocs[parity]; + balloc.reset(); + auto& ditherCmd3 = balloc.allocateFragment(); + ditherCmd3.primitive.attr.setDithering(true); + ot.insert(ditherCmd3, ORDERING_TABLE_SIZE - 1); + writeFogRegisters(); + psyqo::Vec3 cameraPosition = computeCameraViewPos(); + int32_t fogFarSZ = m_fog.fogFarSZ; + int32_t camX = m_currentCamera->GetPosition().x.raw(); + int32_t camY = m_currentCamera->GetPosition().y.raw(); + int32_t camZ = m_currentCamera->GetPosition().z.raw(); + int catchAllIdx = roomCount - 1; + + // If no camera room provided (or invalid), fall back to AABB containment. + // Pick the smallest room whose AABB (with margin) contains the camera. + if (cameraRoom < 0 || cameraRoom >= catchAllIdx) { + constexpr int32_t ROOM_MARGIN = 2048; // 0.5 units in fp12 + int64_t bestVolume = 0x7FFFFFFFFFFFFFFFLL; + for (int r = 0; r < catchAllIdx; r++) { + if (camX >= rooms[r].aabbMinX - ROOM_MARGIN && camX <= rooms[r].aabbMaxX + ROOM_MARGIN && + camY >= rooms[r].aabbMinY - ROOM_MARGIN && camY <= rooms[r].aabbMaxY + ROOM_MARGIN && + camZ >= rooms[r].aabbMinZ - ROOM_MARGIN && camZ <= rooms[r].aabbMaxZ + ROOM_MARGIN) { + int64_t dx = (int64_t)(rooms[r].aabbMaxX - rooms[r].aabbMinX); + int64_t dy = (int64_t)(rooms[r].aabbMaxY - rooms[r].aabbMinY); + int64_t dz = (int64_t)(rooms[r].aabbMaxZ - rooms[r].aabbMinZ); + int64_t vol = dx * dy + dy * dz + dx * dz; + if (vol < bestVolume) { bestVolume = vol; cameraRoom = r; } + } + } + } + + uint32_t visited = 0; + if (catchAllIdx < 32) visited = (1u << catchAllIdx); + const auto& camRot = m_currentCamera->GetRotation(); + + struct Entry { int room; int depth; ScreenRect clip; }; + Entry stack[64]; int top = 0; + + auto renderRoom = [&](int ri) { + const RoomData& rm = rooms[ri]; + int16_t lastObj = -1; + for (int ti = 0; ti < rm.triRefCount; ti++) { + const TriangleRef& ref = roomTriRefs[rm.firstTriRef + ti]; + if (ref.objectIndex >= objects.size()) continue; + GameObject* obj = objects[ref.objectIndex]; + if (ref.triangleIndex >= obj->polyCount) continue; + if (ref.objectIndex != lastObj) { lastObj = ref.objectIndex; setupObjectTransform(obj, cameraPosition); } + processTriangle(obj->polygons[ref.triangleIndex], fogFarSZ, ot, balloc); + } + }; + + // Always render catch-all room (geometry not assigned to any specific room) + renderRoom(catchAllIdx); + + if (cameraRoom >= 0) { + ScreenRect full = {-512, -512, 832, 752}; + if (cameraRoom < 32) visited |= (1u << cameraRoom); + stack[top++] = {cameraRoom, 0, full}; + while (top > 0) { + Entry e = stack[--top]; + renderRoom(e.room); + if (e.depth >= 8) continue; // Depth limit prevents infinite loops + for (int p = 0; p < portalCount; p++) { + int other = -1; + if (portals[p].roomA == e.room) other = portals[p].roomB; + else if (portals[p].roomB == e.room) other = portals[p].roomA; + else continue; + if (other < 0 || other >= roomCount) continue; + if (other < 32 && (visited & (1u << other))) continue; + + // Backface cull: skip portals that face away from the camera. + // The portal normal points from roomA toward roomB (4.12 fp). + // dot(normal, cam - portalCenter) > 0 means the portal faces us when + // traversing A->B; the sign flips when traversing B->A. + { + int32_t dx = camX - portals[p].centerX; + int32_t dy = camY - portals[p].centerY; + int32_t dz = camZ - portals[p].centerZ; + int64_t dot = (int64_t)dx * portals[p].normalX + + (int64_t)dy * portals[p].normalY + + (int64_t)dz * portals[p].normalZ; + // Allow a small negative threshold so nearly-edge-on portals still pass. + const int64_t BACKFACE_THRESHOLD = -4096; + if (portals[p].roomA == e.room) { + if (dot < BACKFACE_THRESHOLD) continue; + } else { + if (dot > -BACKFACE_THRESHOLD) continue; + } + } + + // Phase 4: Frustum-cull the destination room's AABB. + // If the room is entirely behind the camera, skip. + if (!isRoomPotentiallyVisible(rooms[other], camX, camY, camZ, camRot)) { + continue; + } + + // Phase 2: Project actual portal quad corners to screen. + ScreenRect pr; + if (!projectPortalRect(portals[p], camX, camY, camZ, camRot, pr)) { + continue; + } + ScreenRect isect; + if (!intersectRect(e.clip, pr, isect)) { + continue; + } + if (other < 32) visited |= (1u << other); + if (top < 64) stack[top++] = {other, e.depth + 1, isect}; + } + } + } else { + // Camera room unknown - render ALL rooms as safety fallback. + // This guarantees no geometry disappears, at the cost of no culling. + for (int r = 0; r < roomCount; r++) if (r != catchAllIdx) renderRoom(r); + } + +#ifdef PSXSPLASH_ROOM_DEBUG + // ================================================================ + // Debug overlay: room status bars + portal outlines + // ================================================================ + { + static const psyqo::Color roomColors[] = { + {.r = 255, .g = 50, .b = 50}, // R0: red + {.r = 50, .g = 255, .b = 50}, // R1: green + {.r = 50, .g = 50, .b = 255}, // R2: blue + {.r = 255, .g = 255, .b = 50}, // R3: yellow + {.r = 255, .g = 50, .b = 255}, // R4: magenta + {.r = 50, .g = 255, .b = 255}, // R5: cyan + {.r = 255, .g = 128, .b = 50}, // R6: orange + {.r = 128, .g = 128, .b = 255}, // R7: lavender + }; + + // Room status bars at top of screen + for (int r = 0; r < roomCount && r < 8; r++) { + bool rendered = (visited & (1u << r)) != 0; + bool isCamRoom = (r == cameraRoom); + auto& tile = balloc.allocateFragment(); + int16_t x = r * 18 + 2; + tile.primitive.setColor(rendered ? + roomColors[r] : psyqo::Color{.r = 40, .g = 40, .b = 40}); + tile.primitive.rect = psyqo::Rect{ + .a = {.x = x, .y = (int16_t)2}, + .b = {.w = 14, .h = (int16_t)(isCamRoom ? 12 : 6)} + }; + ot.insert(tile, 0); + } + + // Portal outlines: project portal quad and draw edges as thin lines. + // Lines are drawn at OT front (depth 0) so they show through walls. + for (int p = 0; p < portalCount; p++) { + const PortalData& portal = portals[p]; + + // Compute portal corners in world space + int32_t rwx = ((int32_t)portal.rightX * portal.halfW) >> 12; + int32_t rwy = ((int32_t)portal.rightY * portal.halfW) >> 12; + int32_t rwz = ((int32_t)portal.rightZ * portal.halfW) >> 12; + int32_t uhx = ((int32_t)portal.upX * portal.halfH) >> 12; + int32_t uhy = ((int32_t)portal.upY * portal.halfH) >> 12; + int32_t uhz = ((int32_t)portal.upZ * portal.halfH) >> 12; + + int32_t cx = portal.centerX, cy = portal.centerY, cz = portal.centerZ; + struct { int32_t wx, wy, wz; } corners[4] = { + {cx + rwx + uhx, cy + rwy + uhy, cz + rwz + uhz}, + {cx - rwx + uhx, cy - rwy + uhy, cz - rwz + uhz}, + {cx - rwx - uhx, cy - rwy - uhy, cz - rwz - uhz}, + {cx + rwx - uhx, cy + rwy - uhy, cz + rwz - uhz}, + }; + + // Project corners to screen + int16_t sx[4], sy[4]; + bool vis[4]; + int visCount = 0; + for (int i = 0; i < 4; i++) { + int32_t vx, vy, vz; + worldToCamera(corners[i].wx, corners[i].wy, corners[i].wz, + camX, camY, camZ, camRot, vx, vy, vz); + vis[i] = projectToScreen(vx, vy, vz, sx[i], sy[i]); + if (vis[i]) visCount++; + } + if (visCount < 2) continue; // Can't draw edges with <2 visible corners + + // Draw each edge as a degenerate triangle (line). + // Color: orange for portal between visible rooms, dim for invisible. + bool portalActive = (visited & (1u << portal.roomA)) || (visited & (1u << portal.roomB)); + psyqo::Color lineColor = portalActive ? + psyqo::Color{.r = 255, .g = 160, .b = 0} : + psyqo::Color{.r = 80, .g = 60, .b = 0}; + + for (int i = 0; i < 4; i++) { + int j = (i + 1) % 4; + if (!vis[i] || !vis[j]) continue; + // Clamp to screen to avoid GPU issues + int16_t x0 = sx[i], y0 = sy[i], x1 = sx[j], y1 = sy[j]; + if (x0 < 0) x0 = 0; if (x0 > 319) x0 = 319; + if (y0 < 0) y0 = 0; if (y0 > 239) y0 = 239; + if (x1 < 0) x1 = 0; if (x1 > 319) x1 = 319; + if (y1 < 0) y1 = 0; if (y1 > 239) y1 = 239; + + // Draw line as degenerate triangle (A=B=start, C=end gives a 1px line) + auto& tri = balloc.allocateFragment(); + tri.primitive.pointA.x = x0; tri.primitive.pointA.y = y0; + tri.primitive.pointB.x = x1; tri.primitive.pointB.y = y1; + tri.primitive.pointC.x = x1; tri.primitive.pointC.y = (int16_t)(y1 + 1); + tri.primitive.setColorA(lineColor); + tri.primitive.setColorB(lineColor); + tri.primitive.setColorC(lineColor); + tri.primitive.setOpaque(); + ot.insert(tri, 0); + } + } + + // Room AABB outlines: project the 8 corners of each room's AABB and draw edges. + for (int r = 0; r < roomCount - 1 && r < 8; r++) { + bool rendered = (visited & (1u << r)) != 0; + psyqo::Color boxColor = rendered ? + roomColors[r] : psyqo::Color{.r = 60, .g = 60, .b = 60}; + + const RoomData& rm = rooms[r]; + int32_t bmin[3] = {rm.aabbMinX, rm.aabbMinY, rm.aabbMinZ}; + int32_t bmax[3] = {rm.aabbMaxX, rm.aabbMaxY, rm.aabbMaxZ}; + + // 8 corners of the AABB + int16_t csx[8], csy[8]; + bool cvis[8]; + int cvisCount = 0; + for (int i = 0; i < 8; i++) { + int32_t wx = (i & 1) ? bmax[0] : bmin[0]; + int32_t wy = (i & 2) ? bmax[1] : bmin[1]; + int32_t wz = (i & 4) ? bmax[2] : bmin[2]; + int32_t vx, vy, vz; + worldToCamera(wx, wy, wz, camX, camY, camZ, camRot, vx, vy, vz); + cvis[i] = projectToScreen(vx, vy, vz, csx[i], csy[i]); + if (cvis[i]) cvisCount++; + } + if (cvisCount < 2) continue; + + // Draw 12 AABB edges + static const int edges[12][2] = { + {0,1},{2,3},{4,5},{6,7}, // X-axis edges + {0,2},{1,3},{4,6},{5,7}, // Y-axis edges + {0,4},{1,5},{2,6},{3,7}, // Z-axis edges + }; + for (int e = 0; e < 12; e++) { + int a = edges[e][0], b = edges[e][1]; + if (!cvis[a] || !cvis[b]) continue; + int16_t x0 = csx[a], y0 = csy[a], x1 = csx[b], y1 = csy[b]; + if (x0 < 0) x0 = 0; if (x0 > 319) x0 = 319; + if (y0 < 0) y0 = 0; if (y0 > 239) y0 = 239; + if (x1 < 0) x1 = 0; if (x1 > 319) x1 = 319; + if (y1 < 0) y1 = 0; if (y1 > 239) y1 = 239; + + auto& tri = balloc.allocateFragment(); + tri.primitive.pointA.x = x0; tri.primitive.pointA.y = y0; + tri.primitive.pointB.x = x1; tri.primitive.pointB.y = y1; + tri.primitive.pointC.x = x1; tri.primitive.pointC.y = (int16_t)(y1 + 1); + tri.primitive.setColorA(boxColor); + tri.primitive.setColorB(boxColor); + tri.primitive.setColorC(boxColor); + tri.primitive.setOpaque(); + ot.insert(tri, 0); + } + } + } +#endif + + m_gpu.getNextClear(clear.primitive, m_clearcolor); + m_gpu.chain(clear); m_gpu.chain(ot); + m_frameCount++; +} + +void psxsplash::Renderer::VramUpload(const uint16_t* imageData, int16_t posX, + int16_t posY, int16_t width, int16_t height) { + psyqo::Rect uploadRect{.a = {.x = posX, .y = posY}, .b = {width, height}}; + m_gpu.uploadToVRAM(imageData, uploadRect); +} diff --git a/src/renderer.hh b/src/renderer.hh index 4244b81..23878fd 100644 --- a/src/renderer.hh +++ b/src/renderer.hh @@ -13,33 +13,51 @@ #include #include +#include "bvh.hh" #include "camera.hh" #include "gameobject.hh" -#include "navmesh.hh" +#include "triclip.hh" namespace psxsplash { +struct FogConfig { + bool enabled = false; + psyqo::Color color = {.r = 0, .g = 0, .b = 0}; + uint8_t density = 5; + int32_t fogFarSZ = 0; +}; + class Renderer final { public: Renderer(const Renderer&) = delete; Renderer& operator=(const Renderer&) = delete; - // FIXME: I have no idea how to precompute the required sizes of these. It would be best to allocate them based on - // the scene - static constexpr size_t ORDERING_TABLE_SIZE = 2048 * 3; + static constexpr size_t ORDERING_TABLE_SIZE = 2048 * 8; static constexpr size_t BUMP_ALLOCATOR_SIZE = 8096 * 24; + static constexpr size_t MAX_VISIBLE_TRIANGLES = 4096; + + static constexpr int32_t PROJ_H = 120; + static constexpr int32_t SCREEN_CX = 160; + static constexpr int32_t SCREEN_CY = 120; static void Init(psyqo::GPU& gpuInstance); - void SetCamera(Camera& camera); + void SetFog(const FogConfig& fog); void Render(eastl::vector& objects); - void RenderNavmeshPreview(psxsplash::Navmesh navmesh, bool isOnMesh); + void RenderWithBVH(eastl::vector& objects, const BVHManager& bvh); + void RenderWithRooms(eastl::vector& objects, + const RoomData* rooms, int roomCount, + const PortalData* portals, int portalCount, + const TriangleRef* roomTriRefs, + int cameraRoom = -1); - void VramUpload(const uint16_t* imageData, int16_t posX, int16_t posY, int16_t width, int16_t height); + void VramUpload(const uint16_t* imageData, int16_t posX, int16_t posY, + int16_t width, int16_t height); static Renderer& GetInstance() { - psyqo::Kernel::assert(instance != nullptr, "Access to renderer was tried without prior initialization"); + psyqo::Kernel::assert(instance != nullptr, + "Access to renderer was tried without prior initialization"); return *instance; } @@ -49,8 +67,7 @@ class Renderer final { Renderer(psyqo::GPU& gpuInstance) : m_gpu(gpuInstance) {} ~Renderer() {} - Camera* m_currentCamera; - + Camera* m_currentCamera = nullptr; psyqo::GPU& m_gpu; psyqo::Trig<> m_trig; @@ -58,10 +75,21 @@ class Renderer final { psyqo::Fragments::SimpleFragment m_clear[2]; psyqo::BumpAllocator m_ballocs[2]; + FogConfig m_fog; psyqo::Color m_clearcolor = {.r = 0, .g = 0, .b = 0}; - void recursiveSubdivideAndRender(Tri& tri, eastl::array& projected, int zIndex, - int maxIterations); + TriangleRef m_visibleRefs[MAX_VISIBLE_TRIANGLES]; + int m_frameCount = 0; + + void writeFogRegisters(); + psyqo::Vec3 computeCameraViewPos(); + void setupObjectTransform(GameObject* obj, const psyqo::Vec3& cameraPosition); + + // Core triangle pipeline: rtpt -> nclip -> screen-space clip -> emit. + // Uses Bandwidth's proven approach: nclip always, max-SZ depth, screen clip. + void processTriangle(Tri& tri, int32_t fogFarSZ, + psyqo::OrderingTable& ot, + psyqo::BumpAllocator& balloc); }; -} // namespace psxsplash \ No newline at end of file +} // namespace psxsplash diff --git a/src/sceneloader.cpp b/src/sceneloader.cpp new file mode 100644 index 0000000..e5a23fd --- /dev/null +++ b/src/sceneloader.cpp @@ -0,0 +1,65 @@ +#include "sceneloader.hh" + +// Unified PCDRV API with runtime dispatch: +// - Emulator: break instructions (intercepted by pcsx-redux) +// - Real hardware: direct SIO1 protocol (no break instructions) +#include "pcdrv_handler.hh" + +namespace psxsplash { + +bool SceneLoader::s_pcdrvAvailable = false; + +bool SceneLoader::Init() { + s_pcdrvAvailable = (pcdrv_init() == 0); + return s_pcdrvAvailable; +} + +bool SceneLoader::IsPCdrvAvailable() { + return s_pcdrvAvailable; +} + +uint8_t* SceneLoader::LoadFile(const char* filename, int& outSize) { + outSize = 0; + + if (!s_pcdrvAvailable) { + return nullptr; + } + + // Open the file (read-only, flags=0, perms=0) + int fd = pcdrv_open(filename, 0, 0); + if (fd < 0) { + return nullptr; + } + + // Get file size by seeking to end + int size = pcdrv_seek(fd, 0, 2); // SEEK_END = 2 + if (size <= 0) { + pcdrv_close(fd); + return nullptr; + } + + // Seek back to start + pcdrv_seek(fd, 0, 0); // SEEK_SET = 0 + + // Allocate buffer (aligned to 4 bytes for struct casting) + int alignedSize = (size + 3) & ~3; + uint8_t* buffer = new uint8_t[alignedSize]; + + // Read the file + int bytesRead = pcdrv_read(fd, buffer, size); + pcdrv_close(fd); + + if (bytesRead != size) { + delete[] buffer; + return nullptr; + } + + outSize = size; + return buffer; +} + +void SceneLoader::FreeFile(uint8_t* data) { + delete[] data; +} + +} // namespace psxsplash diff --git a/src/sceneloader.hh b/src/sceneloader.hh new file mode 100644 index 0000000..dca2a50 --- /dev/null +++ b/src/sceneloader.hh @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace psxsplash { + +/** + * SceneLoader — loads splashpack files from PCdrv (emulator) or CD-ROM. + * + * In emulator (PCdrv) mode, files are loaded via the host filesystem using + * the PCdrv protocol (break instructions intercepted by PCSX-Redux). + * + * In CD-ROM mode (future), files would be loaded from the disc. + * + * The loader allocates memory for the file content and returns a pointer + * to the caller. The caller owns the memory. + */ +class SceneLoader { + public: + /** + * Initialize the loader. Must be called once at startup. + * Returns true if PCdrv is available, false otherwise. + */ + static bool Init(); + + /** + * Load a file by name. Returns a pointer to the loaded data. + * The data is allocated with new[] and the caller owns it. + * @param filename The filename to load (relative to pcdrvbase). + * @param outSize Receives the file size in bytes. + * @return Pointer to loaded data, or nullptr on failure. + */ + static uint8_t* LoadFile(const char* filename, int& outSize); + + /** + * Free previously loaded file data. + */ + static void FreeFile(uint8_t* data); + + /** + * Returns true if PCdrv is available. + */ + static bool IsPCdrvAvailable(); + + private: + static bool s_pcdrvAvailable; +}; + +} // namespace psxsplash diff --git a/src/scenemanager.cpp b/src/scenemanager.cpp index d442436..623678f 100644 --- a/src/scenemanager.cpp +++ b/src/scenemanager.cpp @@ -2,29 +2,76 @@ #include -#include "navmesh.hh" +#include "collision.hh" #include "profiler.hh" #include "renderer.hh" #include "splashpack.hh" +#include "luaapi.hh" #include "lua.h" using namespace psyqo::trig_literals; +using namespace psyqo::fixed_point_literals; + +using namespace psxsplash; + void psxsplash::SceneManager::InitializeScene(uint8_t* splashpackData) { - L.Init(); - - debug::Profiler::getInstance().initialize(); - + L.Reset(); + + // Initialize audio system + m_audio.init(); + + // Register the Lua API + LuaAPI::RegisterAll(L.getState(), this); +#ifdef PSXSPLASH_PROFILER debug::Profiler::getInstance().initialize(); +#endif SplashpackSceneSetup sceneSetup; m_loader.LoadSplashpack(splashpackData, sceneSetup); m_luaFiles = std::move(sceneSetup.luaFiles); m_gameObjects = std::move(sceneSetup.objects); - m_navmeshes = std::move(sceneSetup.navmeshes); + m_objectNames = std::move(sceneSetup.objectNames); + m_bvh = sceneSetup.bvh; // Copy BVH for frustum culling + m_worldCollision = sceneSetup.worldCollision; // World collision soup (v7+) + m_navRegions = sceneSetup.navRegions; // Nav region system (v7+) + m_playerNavRegion = m_navRegions.isLoaded() ? m_navRegions.getStartRegion() : NAV_NO_REGION; + + // Scene type and render path + m_sceneType = sceneSetup.sceneType; + + // Room/portal data for interior scenes (v11+) + m_rooms = sceneSetup.rooms; + m_roomCount = sceneSetup.roomCount; + m_portals = sceneSetup.portals; + m_portalCount = sceneSetup.portalCount; + m_roomTriRefs = sceneSetup.roomTriRefs; + m_roomTriRefCount = sceneSetup.roomTriRefCount; + + // Configure fog from splashpack data (v11+) + if (sceneSetup.fogEnabled) { + psxsplash::FogConfig fogCfg; + fogCfg.enabled = true; + fogCfg.color = {.r = sceneSetup.fogR, .g = sceneSetup.fogG, .b = sceneSetup.fogB}; + fogCfg.density = sceneSetup.fogDensity; + Renderer::GetInstance().SetFog(fogCfg); + } else { + psxsplash::FogConfig fogCfg; + fogCfg.enabled = false; + Renderer::GetInstance().SetFog(fogCfg); + } + // Copy component arrays + m_interactables = std::move(sceneSetup.interactables); + + // Load audio clips into SPU RAM + m_audioClipNames = std::move(sceneSetup.audioClipNames); + for (size_t i = 0; i < sceneSetup.audioClips.size(); i++) { + auto& clip = sceneSetup.audioClips[i]; + m_audio.loadClip((int)i, clip.adpcmData, clip.sizeBytes, clip.sampleRate, clip.loop); + } m_playerPosition = sceneSetup.playerStartPosition; @@ -34,6 +81,49 @@ void psxsplash::SceneManager::InitializeScene(uint8_t* splashpackData) { m_playerHeight = sceneSetup.playerHeight; + // Load movement parameters from splashpack (v8+) + m_controls.setMoveSpeed(sceneSetup.moveSpeed); + m_controls.setSprintSpeed(sceneSetup.sprintSpeed); + m_playerRadius = (int32_t)sceneSetup.playerRadius.value; + if (m_playerRadius == 0) m_playerRadius = PLAYER_RADIUS; // fallback to default + m_jumpVelocityRaw = (int32_t)sceneSetup.jumpVelocity.value; + int32_t gravityRaw = (int32_t)sceneSetup.gravity.value; + m_gravityPerFrame = gravityRaw / 30; // Convert per-second² to per-frame velocity change + if (m_gravityPerFrame == 0 && gravityRaw > 0) m_gravityPerFrame = 1; // Ensure nonzero + m_velocityY = 0; + m_isGrounded = true; + m_lastFrameTime = 0; + m_deltaFrames = 1; + + // Initialize collision system + m_collisionSystem.init(); + + // Register colliders from splashpack data + for (size_t i = 0; i < sceneSetup.colliders.size(); i++) { + SPLASHPACKCollider* collider = sceneSetup.colliders[i]; + if (collider == nullptr) continue; + + // Convert fixed-point values from binary format to AABB + AABB bounds; + bounds.min.x.value = collider->minX; + bounds.min.y.value = collider->minY; + bounds.min.z.value = collider->minZ; + bounds.max.x.value = collider->maxX; + bounds.max.y.value = collider->maxY; + bounds.max.z.value = collider->maxZ; + + // Convert collision type + CollisionType type = static_cast(collider->collisionType); + + // Register with collision system + m_collisionSystem.registerCollider( + collider->gameObjectIndex, + bounds, + type, + collider->layerMask + ); + } + // Load Lua files - order is important here. We need // to load the Lua files before we register the game objects, // as the game objects may reference Lua files by index. @@ -58,49 +148,470 @@ void psxsplash::SceneManager::InitializeScene(uint8_t* splashpackData) { } void psxsplash::SceneManager::GameTick(psyqo::GPU &gpu) { + LuaAPI::IncrementFrameCount(); + // Delta-time measurement: count elapsed frames based on gpu timer + // PS1 NTSC frame = ~33333 microseconds (30fps vsync) + { + uint32_t now = gpu.now(); + if (m_lastFrameTime != 0) { + uint32_t elapsed = now - m_lastFrameTime; + // 33333us per frame at 30fps. If >50000us, we dropped a frame. + m_deltaFrames = (elapsed > 50000) ? 2 : 1; + if (elapsed > 83000) m_deltaFrames = 3; // Two frames dropped + } + m_lastFrameTime = now; + } uint32_t renderingStart = gpu.now(); auto& renderer = psxsplash::Renderer::GetInstance(); - renderer.Render(m_gameObjects); + // Dispatch render path based on scene type. + // Interior scenes (type 1) use room/portal occlusion; exterior scenes use BVH culling. + if (m_sceneType == 1 && m_roomCount > 0 && m_rooms != nullptr) { + // Get camera room from nav region system (authoritative) instead of AABB guessing. + // NavRegion::roomIndex is set during export from the room each region belongs to. + int camRoom = -1; + if (m_navRegions.isLoaded() && m_playerNavRegion != NAV_NO_REGION) { + uint8_t ri = m_navRegions.getRoomIndex(m_playerNavRegion); + if (ri != 0xFF) camRoom = (int)ri; + } + renderer.RenderWithRooms(m_gameObjects, m_rooms, m_roomCount, + m_portals, m_portalCount, m_roomTriRefs, camRoom); + } else { + renderer.RenderWithBVH(m_gameObjects, m_bvh); + } gpu.pumpCallbacks(); uint32_t renderingEnd = gpu.now(); uint32_t renderingTime = renderingEnd - renderingStart; - psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_RENDERING, renderingTime); +#ifdef PSXSPLASH_PROFILER + psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_RENDERING, renderingTime); +#endif + + // Collision detection + uint32_t collisionStart = gpu.now(); + int collisionCount = m_collisionSystem.detectCollisions(); + + // Process solid collisions - call OnCollision on BOTH objects + const CollisionResult* results = m_collisionSystem.getResults(); + for (int i = 0; i < collisionCount; i++) { + auto* objA = getGameObject(results[i].objectA); + auto* objB = getGameObject(results[i].objectB); + if (objA && objB) { + L.OnCollision(objA, objB); + L.OnCollision(objB, objA); // Call on both objects + } + } + + // Process trigger events (enter/stay/exit) + m_collisionSystem.processTriggerEvents(*this); + + gpu.pumpCallbacks(); + uint32_t collisionEnd = gpu.now(); + uint32_t luaStart = gpu.now(); - L.OnCollision(m_gameObjects[1], m_gameObjects[0]); // Example call, replace with actual logic + // Lua update tick - call onUpdate for all registered objects with onUpdate handler + for (auto* go : m_gameObjects) { + if (go && go->isActive()) { + L.OnUpdate(go, m_deltaFrames); + } + } gpu.pumpCallbacks(); uint32_t luaEnd = gpu.now(); uint32_t luaTime = luaEnd - luaStart; - psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_LUA, luaTime); +#ifdef PSXSPLASH_PROFILER + psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_LUA, luaTime); +#endif + + // Update game systems + processEnableDisableEvents(); uint32_t controlsStart = gpu.now(); - m_controls.HandleControls(m_playerPosition, playerRotationX, playerRotationY, playerRotationZ, false, 1); + + // Update button state tracking first + m_controls.UpdateButtonStates(); + + // Update interaction system (checks for interact button press) + updateInteractionSystem(); + + // Dispatch button events to all objects + uint16_t pressed = m_controls.getButtonsPressed(); + uint16_t released = m_controls.getButtonsReleased(); + + if (pressed || released) { + // Only iterate objects if there are button events + for (auto* go : m_gameObjects) { + if (!go || !go->isActive()) continue; + + if (pressed) { + // Dispatch press events for each pressed button + for (int btn = 0; btn < 16; btn++) { + if (pressed & (1 << btn)) { + L.OnButtonPress(go, btn); + } + } + } + if (released) { + // Dispatch release events for each released button + for (int btn = 0; btn < 16; btn++) { + if (released & (1 << btn)) { + L.OnButtonRelease(go, btn); + } + } + } + } + } + + // Save position BEFORE movement for collision detection + psyqo::Vec3 oldPlayerPosition = m_playerPosition; + + m_controls.HandleControls(m_playerPosition, playerRotationX, playerRotationY, playerRotationZ, freecam, m_deltaFrames); + + // Jump input: Cross button triggers jump when grounded + if (m_isGrounded && m_controls.wasButtonPressed(psyqo::AdvancedPad::Button::Cross)) { + m_velocityY = -m_jumpVelocityRaw; // Negative = upward (PSX Y-down) + m_isGrounded = false; + } gpu.pumpCallbacks(); uint32_t controlsEnd = gpu.now(); uint32_t controlsTime = controlsEnd - controlsStart; - psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_CONTROLS, controlsTime); +#ifdef PSXSPLASH_PROFILER + psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_CONTROLS, controlsTime); +#endif uint32_t navmeshStart = gpu.now(); if (!freecam) { - psxsplash::ComputeNavmeshPosition(m_playerPosition, *m_navmeshes[0], - static_cast>(m_playerHeight)); + // Priority: WorldCollision + NavRegions (v7) > NavGrid (v5) > Legacy Navmesh + if (m_worldCollision.isLoaded()) { + // Move-and-slide against world geometry (XZ walls only) + psyqo::Vec3 slid = m_worldCollision.moveAndSlide( + oldPlayerPosition, m_playerPosition, m_playerRadius, 0xFF); + + + m_playerPosition.x = slid.x; + m_playerPosition.z = slid.z; + + // Apply gravity: velocity changes each frame + for (int f = 0; f < m_deltaFrames; f++) { + m_velocityY += m_gravityPerFrame; + } + + // Apply vertical velocity to position + // velocityY is in fp12 per-second; convert per-frame: pos += vel / 30 + int32_t posYDelta = (m_velocityY * m_deltaFrames) / 30; + m_playerPosition.y.value += posYDelta; + + // Resolve floor Y from nav regions if available + if (m_navRegions.isLoaded()) { + uint16_t prevRegion = m_playerNavRegion; + int32_t px = m_playerPosition.x.value; + int32_t pz = m_playerPosition.z.value; + int32_t floorY = m_navRegions.resolvePosition( + px, pz, m_playerNavRegion); + + + if (m_playerNavRegion != NAV_NO_REGION) { + m_playerPosition.x.value = px; + m_playerPosition.z.value = pz; + + // Ground (feet) position in PSX coords: + // Camera is at position.y, feet are at position.y + playerHeight + // (Y-down: larger Y = lower) + int32_t cameraAtFloor = floorY - m_playerHeight.raw(); + + if (m_playerPosition.y.value >= cameraAtFloor) { + // Player is at or below floor — snap to ground + m_playerPosition.y.value = cameraAtFloor; + m_velocityY = 0; + m_isGrounded = true; + } else { + // Player is above floor (jumping/airborne) + m_isGrounded = false; + } + } else { + // Off all nav regions — revert to old position + m_playerPosition = oldPlayerPosition; + m_playerNavRegion = prevRegion; + m_velocityY = 0; + m_isGrounded = true; + } + } else { + // Ground trace fallback (no nav regions) + int32_t groundY; + int32_t groundNormalY; + uint8_t surfFlags; + if (m_worldCollision.groundTrace(m_playerPosition, + 4096 * 4, // max 4 units down + groundY, groundNormalY, surfFlags, 0xFF)) { + int32_t cameraAtFloor = groundY - m_playerHeight.raw(); + if (m_playerPosition.y.value >= cameraAtFloor) { + m_playerPosition.y.value = cameraAtFloor; + m_velocityY = 0; + m_isGrounded = true; + } else { + m_isGrounded = false; + } + } else { + m_playerPosition = oldPlayerPosition; + m_velocityY = 0; + m_isGrounded = true; + } + } + + // Ceiling check: if jumping upward, check for ceiling collision + if (m_velocityY < 0 && m_worldCollision.isLoaded()) { + int32_t ceilingY; + if (m_worldCollision.ceilingTrace(m_playerPosition, + m_playerHeight.raw(), ceilingY, 0xFF)) { + // Hit a ceiling — stop upward velocity + m_velocityY = 0; + } + } + } } + + + gpu.pumpCallbacks(); uint32_t navmeshEnd = gpu.now(); uint32_t navmeshTime = navmeshEnd - navmeshStart; - psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_NAVMESH, navmeshTime); +#ifdef PSXSPLASH_PROFILER + psxsplash::debug::Profiler::getInstance().setSectionTime(psxsplash::debug::PROFILER_NAVMESH, navmeshTime); +#endif m_currentCamera.SetPosition(static_cast>(m_playerPosition.x), static_cast>(m_playerPosition.y), static_cast>(m_playerPosition.z)); m_currentCamera.SetRotation(playerRotationX, playerRotationY, playerRotationZ); - psxsplash::debug::Profiler::getInstance().dumpToTTY(); + // Process pending scene transitions (at end of frame) + processPendingSceneLoad(); +} +// Trigger event callbacks +void psxsplash::SceneManager::fireTriggerEnter(uint16_t triggerObjIdx, uint16_t otherObjIdx) { + auto* trigger = getGameObject(triggerObjIdx); + auto* other = getGameObject(otherObjIdx); + if (trigger && other) { + L.OnTriggerEnter(trigger, other); + } +} +void psxsplash::SceneManager::fireTriggerStay(uint16_t triggerObjIdx, uint16_t otherObjIdx) { + auto* trigger = getGameObject(triggerObjIdx); + auto* other = getGameObject(otherObjIdx); + if (trigger && other) { + L.OnTriggerStay(trigger, other); + } +} -} \ No newline at end of file +void psxsplash::SceneManager::fireTriggerExit(uint16_t triggerObjIdx, uint16_t otherObjIdx) { + auto* trigger = getGameObject(triggerObjIdx); + auto* other = getGameObject(otherObjIdx); + if (trigger && other) { + L.OnTriggerExit(trigger, other); + } +} + +// ============================================================================ +// INTERACTION SYSTEM +// ============================================================================ + +void psxsplash::SceneManager::updateInteractionSystem() { + // Get interact button state - Cross button by default + auto interactButton = psyqo::AdvancedPad::Button::Cross; + bool buttonPressed = m_controls.wasButtonPressed(interactButton); + + if (!buttonPressed) return; // Early out if no interaction attempt + + // Player position for distance check + psyqo::FixedPoint<12> playerX = static_cast>(m_playerPosition.x); + psyqo::FixedPoint<12> playerY = static_cast>(m_playerPosition.y); + psyqo::FixedPoint<12> playerZ = static_cast>(m_playerPosition.z); + + // Find closest interactable in range + Interactable* closest = nullptr; + psyqo::FixedPoint<12> closestDistSq; + closestDistSq.value = 0x7FFFFFFF; // Max positive value + + for (auto* interactable : m_interactables) { + if (!interactable || !interactable->canInteract()) continue; + + // Check if object is active + auto* go = getGameObject(interactable->gameObjectIndex); + if (!go || !go->isActive()) continue; + + // Calculate distance squared + psyqo::FixedPoint<12> dx = playerX - interactable->offsetX - go->position.x; + psyqo::FixedPoint<12> dy = playerY - interactable->offsetY - go->position.y; + psyqo::FixedPoint<12> dz = playerZ - interactable->offsetZ - go->position.z; + + psyqo::FixedPoint<12> distSq = dx * dx + dy * dy + dz * dz; + + // Check if in range and closer than current closest + if (distSq <= interactable->radiusSquared && distSq < closestDistSq) { + closest = interactable; + closestDistSq = distSq; + } + } + + // Interact with closest + if (closest != nullptr) { + triggerInteraction(getGameObject(closest->gameObjectIndex)); + closest->triggerCooldown(); + } +} + +void psxsplash::SceneManager::triggerInteraction(GameObject* interactable) { + if (!interactable) return; + L.OnInteract(interactable); +} + +// ============================================================================ +// ENABLE/DISABLE SYSTEM +// ============================================================================ + +void psxsplash::SceneManager::setObjectActive(GameObject* go, bool active) { + if (!go) return; + + bool wasActive = go->isActive(); + if (wasActive == active) return; // No change + + go->setActive(active); + + // Fire appropriate event + if (active) { + L.OnEnable(go); + } else { + L.OnDisable(go); + } +} + +void psxsplash::SceneManager::processEnableDisableEvents() { + // Process any pending enable/disable flags (for batched operations) + for (auto* go : m_gameObjects) { + if (!go) continue; + + if (go->isPendingEnable()) { + go->setPendingEnable(false); + if (!go->isActive()) { + go->setActive(true); + L.OnEnable(go); + } + } + + if (go->isPendingDisable()) { + go->setPendingDisable(false); + if (go->isActive()) { + go->setActive(false); + L.OnDisable(go); + } + } + } +} + +// ============================================================================ +// SCENE LOADING (PCdrv multi-scene) +// ============================================================================ + +void psxsplash::SceneManager::requestSceneLoad(int sceneIndex) { + if (sceneIndex == m_currentSceneIndex) return; + m_pendingSceneIndex = sceneIndex; +} + +void psxsplash::SceneManager::processPendingSceneLoad() { + if (m_pendingSceneIndex < 0) return; + + int targetIndex = m_pendingSceneIndex; + m_pendingSceneIndex = -1; + + // Build filename: scene_N.splashpack + char filename[32]; + snprintf(filename, sizeof(filename), "scene_%d.splashpack", targetIndex); + + // 1. Tear down EVERYTHING in the current scene first — + // Lua VM, vector backing storage, audio. This returns as much + // heap memory as possible before any new allocation. + clearScene(); + + // 2. Free old splashpack data BEFORE loading the new one. + // This avoids having both scene buffers in the heap simultaneously, + // which is the primary source of fragmentation that prevents + // the Lua compiler from finding large contiguous blocks. + if (m_currentSceneData) { + SceneLoader::FreeFile(m_currentSceneData); + m_currentSceneData = nullptr; + } + + // 3. Allocate new scene data (heap is now maximally consolidated) + int fileSize = 0; + uint8_t* newData = SceneLoader::LoadFile(filename, fileSize); + if (!newData) return; + + m_currentSceneData = newData; + m_currentSceneIndex = targetIndex; + + // 4. Initialize with new data (creates fresh Lua VM inside) + InitializeScene(newData); +} + +void psxsplash::SceneManager::clearScene() { + // 1. Shut down the Lua VM first — frees ALL Lua-allocated memory + // (bytecode, strings, tables, registry) in one shot via lua_close. + L.Shutdown(); + + // 2. Free vector BACKING STORAGE (not just contents). + // clear() only sets size=0 but keeps the allocated capacity. + // swap-with-empty releases the heap blocks so they can be coalesced. + { eastl::vector tmp; tmp.swap(m_gameObjects); } + { eastl::vector tmp; tmp.swap(m_luaFiles); } + { eastl::vector tmp; tmp.swap(m_objectNames); } + { eastl::vector tmp; tmp.swap(m_audioClipNames); } + { eastl::vector tmp; tmp.swap(m_interactables); } + + // 3. Reset hardware / subsystems + m_audio.reset(); // Free SPU RAM and stop all voices + m_collisionSystem.init(); // Re-init collision system + // BVH, WorldCollision, and NavRegions will be overwritten by next load + + // Reset room/portal pointers (they point into splashpack data which is being freed) + m_rooms = nullptr; + m_roomCount = 0; + m_portals = nullptr; + m_portalCount = 0; + m_roomTriRefs = nullptr; + m_roomTriRefCount = 0; + m_sceneType = 0; +} + +// ============================================================================ +// OBJECT NAME LOOKUP +// ============================================================================ + +// Inline streq (no libc on bare-metal PS1) +static bool name_eq(const char* a, const char* b) { + while (*a && *b) { if (*a++ != *b++) return false; } + return *a == *b; +} + +psxsplash::GameObject* psxsplash::SceneManager::findObjectByName(const char* name) const { + if (!name || m_objectNames.empty()) return nullptr; + for (size_t i = 0; i < m_objectNames.size() && i < m_gameObjects.size(); i++) { + if (m_objectNames[i] && name_eq(m_objectNames[i], name)) { + return m_gameObjects[i]; + } + } + return nullptr; +} + +int psxsplash::SceneManager::findAudioClipByName(const char* name) const { + if (!name || m_audioClipNames.empty()) return -1; + for (size_t i = 0; i < m_audioClipNames.size(); i++) { + if (m_audioClipNames[i] && name_eq(m_audioClipNames[i], name)) { + return static_cast(i); + } + } + return -1; +} diff --git a/src/scenemanager.hh b/src/scenemanager.hh index 53a1571..db03854 100644 --- a/src/scenemanager.hh +++ b/src/scenemanager.hh @@ -6,27 +6,113 @@ #include #include +#include "bvh.hh" #include "camera.hh" +#include "collision.hh" #include "controls.hh" #include "gameobject.hh" #include "lua.h" #include "splashpack.hh" +#include "worldcollision.hh" +#include "navregion.hh" +#include "audiomanager.hh" +#include "interactable.hh" +#include "luaapi.hh" +#include "sceneloader.hh" namespace psxsplash { class SceneManager { public: void InitializeScene(uint8_t* splashpackData); void GameTick(psyqo::GPU &gpu); + + // Trigger event callbacks (called by CollisionSystem) + void fireTriggerEnter(uint16_t triggerObjIdx, uint16_t otherObjIdx); + void fireTriggerStay(uint16_t triggerObjIdx, uint16_t otherObjIdx); + void fireTriggerExit(uint16_t triggerObjIdx, uint16_t otherObjIdx); + + // Get game object by index (for collision callbacks) + GameObject* getGameObject(uint16_t index) { + if (index < m_gameObjects.size()) return m_gameObjects[index]; + return nullptr; + } + + // Get total object count + size_t getGameObjectCount() const { return m_gameObjects.size(); } + + // Get object name by index (returns nullptr if no name table or out of range) + const char* getObjectName(uint16_t index) const { + if (index < m_objectNames.size()) return m_objectNames[index]; + return nullptr; + } + + // Find first object with matching name (linear scan, case-sensitive) + GameObject* findObjectByName(const char* name) const; + + // Find audio clip index by name (returns -1 if not found) + int findAudioClipByName(const char* name) const; + + // Get audio clip name by index (returns nullptr if out of range) + const char* getAudioClipName(int index) const { + if (index >= 0 && index < (int)m_audioClipNames.size()) return m_audioClipNames[index]; + return nullptr; + } + + // Public API for game systems + // Interaction system - call from Lua or native code + void triggerInteraction(GameObject* interactable); + + // GameObject state control with events + void setObjectActive(GameObject* go, bool active); + + // Public accessors for Lua API + Controls& getControls() { return m_controls; } + Camera& getCamera() { return m_currentCamera; } + Lua& getLua() { return L; } + AudioManager& getAudio() { return m_audio; } + + // Scene loading (for multi-scene support via PCdrv) + void requestSceneLoad(int sceneIndex); + int getCurrentSceneIndex() const { return m_currentSceneIndex; } + + // Check and process pending scene load (called from GameTick) + void processPendingSceneLoad(); private: psxsplash::Lua L; psxsplash::SplashPackLoader m_loader; - + CollisionSystem m_collisionSystem; + BVHManager m_bvh; // Spatial acceleration for frustum culling + WorldCollision m_worldCollision; // Triangle-level world collision (v7+) + NavRegionSystem m_navRegions; // Convex region navigation (v7+) + uint16_t m_playerNavRegion = NAV_NO_REGION; // Current nav region for player + + // Scene type and render path: 0=exterior (BVH), 1=interior (room/portal) + uint16_t m_sceneType = 0; + + // Room/portal data (v11+ interior scenes). Pointers into splashpack data. + const RoomData* m_rooms = nullptr; + uint16_t m_roomCount = 0; + const PortalData* m_portals = nullptr; + uint16_t m_portalCount = 0; + const TriangleRef* m_roomTriRefs = nullptr; + uint16_t m_roomTriRefCount = 0; eastl::vector m_luaFiles; eastl::vector m_gameObjects; - eastl::vector m_navmeshes; - + + // Object name table (v9+): parallel to m_gameObjects, points into splashpack data + eastl::vector m_objectNames; + + // Audio clip name table (v10+): parallel to audio clips, points into splashpack data + eastl::vector m_audioClipNames; + + // Component arrays + eastl::vector m_interactables; + + // Audio system + AudioManager m_audio; + psxsplash::Controls m_controls; psxsplash::Camera m_currentCamera; @@ -35,8 +121,29 @@ class SceneManager { psyqo::Angle playerRotationX, playerRotationY, playerRotationZ; psyqo::FixedPoint<12, uint16_t> m_playerHeight; + + // Movement physics (v8+) + int32_t m_playerRadius; // Collision radius in fp12 (replaces hardcoded PLAYER_RADIUS) + int32_t m_velocityY; // Vertical velocity in fp12 per second (negative = up) + int32_t m_gravityPerFrame; // Gravity velocity change per frame (fp12) + int32_t m_jumpVelocityRaw; // Initial jump velocity in fp12 per second + bool m_isGrounded; // On the ground (can jump) + + // Frame timing + uint32_t m_lastFrameTime; // gpu.now() timestamp of previous frame + int m_deltaFrames; // Elapsed frame count (1 normally, 2+ if dropped) - bool previewNavmesh = false; bool freecam = false; + + // Scene transition state + int m_currentSceneIndex = 0; + int m_pendingSceneIndex = -1; // -1 = no pending load + uint8_t* m_currentSceneData = nullptr; // Owned pointer to loaded data + + // System update methods (called from GameTick) + void updateInteractionSystem(); + void processEnableDisableEvents(); + void clearScene(); // Deallocate current scene objects }; -}; // namespace psxsplash \ No newline at end of file +}; // namespace psxsplash + // namespace psxsplash \ No newline at end of file diff --git a/src/sio_pcdrv.h b/src/sio_pcdrv.h new file mode 100644 index 0000000..0de9acb --- /dev/null +++ b/src/sio_pcdrv.h @@ -0,0 +1,296 @@ +/* + * sio_pcdrv.h — SIO1-based PCDrv implementation for PSYQo applications + * + * Problem: PSYQo's kernel initialization overwrites the exception handler + * at 0x80000080, destroying Unirom's DEBG hooks. The standard + * pcdrv.h functions use MIPS `break` instructions that rely on + * those hooks to translate into SIO escape sequences. + * + * Solution: Bypass the `break` instruction mechanism entirely. Instead, + * talk directly to SIO1 hardware and send the exact same 0x00+'p' + * escape protocol that the host (NOTPSXSerial / PCdrvSerialHost) + * expects. This works regardless of what's at the exception vector. + * + * Protocol: Matches NOTPSXSerial's PCDrv.cs / Bridge.MonitorSerial(): + * PS1 → Host: 0x00 'p' funcCode(4 LE) + * Host → PS1: "OKAY" ... (function-specific data) + * or "NOPE" on error + */ + +#pragma once + +#include + +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• +// SIO1 hardware registers (UART serial port, 0x1F801050) +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• + +#define SIO1_DATA (*(volatile uint8_t *)0x1F801050) +#define SIO1_STAT (*(volatile uint32_t *)0x1F801054) +#define SIO1_MODE (*(volatile uint16_t *)0x1F801058) +#define SIO1_CTRL (*(volatile uint16_t *)0x1F80105A) +#define SIO1_BAUD (*(volatile uint16_t *)0x1F80105E) + +// Status register bits +#define SIO1_STAT_TX_RDY (1 << 0) // TX FIFO not full +#define SIO1_STAT_RX_RDY (1 << 1) // RX data available + +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• +// Low-level SIO1 I/O — blocking, tight polling loops +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• + +static inline void sio_putc(uint8_t byte) { + while (!(SIO1_STAT & SIO1_STAT_TX_RDY)) {} + SIO1_DATA = byte; +} + +static inline uint8_t sio_getc() { + while (!(SIO1_STAT & SIO1_STAT_RX_RDY)) {} + return SIO1_DATA; +} + +static inline void sio_write32(uint32_t val) { + sio_putc((uint8_t)(val)); + sio_putc((uint8_t)(val >> 8)); + sio_putc((uint8_t)(val >> 16)); + sio_putc((uint8_t)(val >> 24)); +} + +static inline uint32_t sio_read32() { + uint32_t v = (uint32_t)sio_getc(); + v |= (uint32_t)sio_getc() << 8; + v |= (uint32_t)sio_getc() << 16; + v |= (uint32_t)sio_getc() << 24; + return v; +} + +static inline void sio_send_str(const char *s) { + while (*s) sio_putc((uint8_t)*s++); +} + +// Read 4 bytes and check if they are "OKAY" +static inline int sio_check_okay() { + uint8_t a = sio_getc(); + uint8_t b = sio_getc(); + uint8_t c = sio_getc(); + uint8_t d = sio_getc(); + return (a == 'O' && b == 'K' && c == 'A' && d == 'Y'); +} + +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• +// PCDrv escape protocol — send 0x00 + 'p' + function code +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• + +static inline void sio_pcdrv_escape(uint32_t funcCode) { + sio_putc(0x00); // escape character + sio_putc('p'); // PCDrv marker + sio_write32(funcCode); // function code, little-endian +} + +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• +// PCDrv API — drop-in replacements for common/kernel/pcdrv.h +// Same names, same signatures, same return conventions. +// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• + +/** + * sio1_ensure_init — (re-)initialize SIO1 for 115200 8N1 + * Safe to call multiple times. Uses the same register values + * that Unirom/nugget use, so this is a no-op if SIO1 is already + * configured. Ensures correct config even if PSYQo or BIOS + * touched the SIO1 registers. + */ +static inline void sio1_ensure_init() { + SIO1_CTRL = 0; // reset + SIO1_MODE = 0x004e; // MUL16, 8 data bits, no parity, 1 stop bit + SIO1_BAUD = (uint16_t)(2073600 / 115200); // = 18 + SIO1_CTRL = 0x0025; // TX enable, RX enable, RTS assert + // Small delay for hardware to settle + { + int i = 0; + while (i < 100) { __asm__ volatile("" ::: "memory"); i++; } + } +} + +/** + * PCinit — initialize PCDrv connection + * Returns 0 on success, -1 on failure. + */ +static inline int PCinit() { + sio1_ensure_init(); // make sure SIO1 is properly configured + sio_pcdrv_escape(0x101); + + // Host responds: "OKAY" + 0x00 + if (!sio_check_okay()) return -1; + sio_getc(); // consume trailing 0x00 + return 0; +} + +/** + * PCopen — open a file on the host + * Returns file handle (positive) on success, -1 on failure. + */ +static inline int PCopen(const char *name, int flags, int perms) { + (void)perms; // unused in protocol + sio_pcdrv_escape(0x103); + + // Host responds: "OKAY" (first ACK, ready for filename) + if (!sio_check_okay()) return -1; + + // Send filename (null-terminated) + const char *p = name; + while (*p) sio_putc((uint8_t)*p++); + sio_putc(0x00); // null terminator + + // Send file mode as uint32 LE + sio_write32((uint32_t)flags); + + // Host responds: "OKAY" + handle(4) or "NOPE" + uint8_t r0 = sio_getc(); + uint8_t r1 = sio_getc(); + uint8_t r2 = sio_getc(); + uint8_t r3 = sio_getc(); + + if (r0 == 'N' && r1 == 'O' && r2 == 'P' && r3 == 'E') { + return -1; + } + + // "OKAY" — read handle + int handle = (int)sio_read32(); + return handle; +} + +/** + * PCclose — close a file handle + * Returns 0 on success. + */ +static inline int PCclose(int fd) { + sio_pcdrv_escape(0x104); + + // Host responds: "OKAY" (ready for params) + if (!sio_check_okay()) return -1; + + // Send handle + 2 unused params (matches Unirom kernel convention) + sio_write32((uint32_t)fd); + sio_write32(0); // unused + sio_write32(0); // unused + + // Host responds: "OKAY" + handle(4) or "NOPE" + uint8_t r0 = sio_getc(); + uint8_t r1 = sio_getc(); + uint8_t r2 = sio_getc(); + uint8_t r3 = sio_getc(); + + if (r0 == 'N' && r1 == 'O' && r2 == 'P' && r3 == 'E') { + return -1; + } + + // "OKAY" — read handle back (v1, not used by caller) + sio_read32(); + return 0; +} + +/** + * PCread — read data from a file into memory + * Returns number of bytes read, or -1 on failure. + */ +static inline int PCread(int fd, void *buf, int len) { + sio_pcdrv_escape(0x105); + + // Host responds: "OKAY" (ready for params) + if (!sio_check_okay()) return -1; + + // Send handle + length + memaddr (memaddr is debug-only, send buf ptr) + sio_write32((uint32_t)fd); + sio_write32((uint32_t)len); + sio_write32((uint32_t)(uintptr_t)buf); + + // Host responds: "OKAY" + dataLength(4) + checksum(4) + raw data + // or "NOPE" + uint8_t r0 = sio_getc(); + uint8_t r1 = sio_getc(); + uint8_t r2 = sio_getc(); + uint8_t r3 = sio_getc(); + + if (r0 == 'N' && r1 == 'O' && r2 == 'P' && r3 == 'E') { + return -1; + } + + // "OKAY" — read response + uint32_t dataLength = sio_read32(); + uint32_t checksum = sio_read32(); // not verified, just consume + (void)checksum; + + // Read raw data bytes into buffer + uint8_t *dst = (uint8_t *)buf; + for (uint32_t i = 0; i < dataLength; i++) { + dst[i] = sio_getc(); + } + + return (int)dataLength; +} + +/** + * PCwrite — write data from memory to a file + * Returns number of bytes written, or -1 on failure. + */ +static inline int PCwrite(int fd, const void *buf, int len) { + sio_pcdrv_escape(0x106); + + // Host responds: "OKAY" (ready for params) + if (!sio_check_okay()) return -1; + + // Send handle + length + memaddr + sio_write32((uint32_t)fd); + sio_write32((uint32_t)len); + sio_write32((uint32_t)(uintptr_t)buf); + + // Host responds: "OKAY" (ready for data) or "NOPE" + uint8_t r0 = sio_getc(); + uint8_t r1 = sio_getc(); + uint8_t r2 = sio_getc(); + uint8_t r3 = sio_getc(); + if (r0 == 'N' && r1 == 'O' && r2 == 'P' && r3 == 'E') { + return -1; + } + + // Send raw data + const uint8_t *src = (const uint8_t *)buf; + for (int i = 0; i < len; i++) { + sio_putc(src[i]); + } + + // Host responds: "OKAY" + bytesWritten(4) + if (!sio_check_okay()) return -1; + int written = (int)sio_read32(); + return written; +} + +/** + * PClseek — seek within a file + * Returns new position, or -1 on failure. + */ +static inline int PClseek(int fd, int offset, int whence) { + sio_pcdrv_escape(0x107); + + // Host responds: "OKAY" (ready for params) + if (!sio_check_okay()) return -1; + + // Send handle + offset + whence (seek origin) + sio_write32((uint32_t)fd); + sio_write32((uint32_t)offset); + sio_write32((uint32_t)whence); + + // Host responds: "OKAY" + position(4) or "NOPE" + uint8_t r0 = sio_getc(); + uint8_t r1 = sio_getc(); + uint8_t r2 = sio_getc(); + uint8_t r3 = sio_getc(); + + if (r0 == 'N' && r1 == 'O' && r2 == 'P' && r3 == 'E') { + return -1; + } + + // "OKAY" — read new position + int pos = (int)sio_read32(); + return pos; +} diff --git a/src/splashpack.cpp b/src/splashpack.cpp index c157a54..c9e5de9 100644 --- a/src/splashpack.cpp +++ b/src/splashpack.cpp @@ -6,28 +6,72 @@ #include #include +#include "bvh.hh" +#include "collision.hh" #include "gameobject.hh" #include "lua.h" #include "mesh.hh" -#include "navmesh.hh" +#include "worldcollision.hh" +#include "navregion.hh" #include "renderer.hh" namespace psxsplash { struct SPLASHPACKFileHeader { - char magic[2]; - uint16_t version; + char magic[2]; // "SP" + uint16_t version; // Format version (8 = movement params) uint16_t luaFileCount; uint16_t gameObjectCount; uint16_t navmeshCount; uint16_t textureAtlasCount; uint16_t clutCount; + uint16_t colliderCount; psyqo::GTE::PackedVec3 playerStartPos; psyqo::GTE::PackedVec3 playerStartRot; psyqo::FixedPoint<12, uint16_t> playerHeight; uint16_t sceneLuaFileIndex; - uint16_t pad; + // Version 3 additions: + uint16_t bvhNodeCount; + uint16_t bvhTriangleRefCount; + // Version 4 additions (component counts): + uint16_t interactableCount; + uint16_t healthCount; + uint16_t timerCount; + uint16_t spawnerCount; + // Version 5 additions (navgrid): + uint16_t hasNavGrid; // 1 if navgrid present, 0 otherwise + uint16_t reserved; // Alignment padding + // Version 6 additions (AABB + scene type): + uint16_t sceneType; // 0 = exterior, 1 = interior + uint16_t reserved2; // Alignment padding + // Version 7 additions (world collision + nav regions): + uint16_t worldCollisionMeshCount; + uint16_t worldCollisionTriCount; + uint16_t navRegionCount; + uint16_t navPortalCount; + // Version 8 additions (movement parameters): + uint16_t moveSpeed; // fp12 per-frame speed constant + uint16_t sprintSpeed; // fp12 per-frame speed constant + uint16_t jumpVelocity; // fp12 per-second initial jump velocity + uint16_t gravity; // fp12 per-second² downward acceleration + uint16_t playerRadius; // fp12 collision radius + uint16_t reserved3; // Alignment padding + // Version 9 additions (object names): + uint32_t nameTableOffset; // Offset to name string table (0 = no names) + // Version 10 additions (audio): + uint16_t audioClipCount; // Number of audio clips + uint16_t reserved4; // Alignment padding + uint32_t audioTableOffset; // Offset to audio clip table (0 = no audio) + // Version 11 additions (fog + room/portal): + uint8_t fogEnabled; // 0 = off, 1 = on + uint8_t fogR, fogG, fogB; // Fog color RGB + uint8_t fogDensity; // 1-10 density scale + uint8_t reserved5; // Alignment + uint16_t roomCount; // 0 = no room system (use BVH path) + uint16_t portalCount; + uint16_t roomTriRefCount; }; +static_assert(sizeof(SPLASHPACKFileHeader) == 96, "SPLASHPACKFileHeader must be 96 bytes"); struct SPLASHPACKTextureAtlas { uint32_t polygonsOffset; @@ -47,55 +91,203 @@ void SplashPackLoader::LoadSplashpack(uint8_t *data, SplashpackSceneSetup &setup psyqo::Kernel::assert(data != nullptr, "Splashpack loading data pointer is null"); psxsplash::SPLASHPACKFileHeader *header = reinterpret_cast(data); psyqo::Kernel::assert(__builtin_memcmp(header->magic, "SP", 2) == 0, "Splashpack has incorrect magic"); + psyqo::Kernel::assert(header->version >= 11, "Splashpack version too old: re-export from SplashEdit"); setup.playerStartPosition = header->playerStartPos; setup.playerStartRotation = header->playerStartRot; setup.playerHeight = header->playerHeight; + + // Movement parameters (v8+) + setup.moveSpeed.value = header->moveSpeed; + setup.sprintSpeed.value = header->sprintSpeed; + setup.jumpVelocity.value = header->jumpVelocity; + setup.gravity.value = header->gravity; + setup.playerRadius.value = header->playerRadius; setup.luaFiles.reserve(header->luaFileCount); setup.objects.reserve(header->gameObjectCount); - setup.navmeshes.reserve(header->navmeshCount); + setup.colliders.reserve(header->colliderCount); + + // Reserve component arrays (version 4+) + if (header->version >= 4) { + setup.interactables.reserve(header->interactableCount); + } - uint8_t *curentPointer = data + sizeof(psxsplash::SPLASHPACKFileHeader); + // V11 header is always 96 bytes (validated by static_assert above). + uint8_t *cursor = data + sizeof(SPLASHPACKFileHeader); for (uint16_t i = 0; i < header->luaFileCount; i++) { - psxsplash::LuaFile *luaHeader = reinterpret_cast(curentPointer); + psxsplash::LuaFile *luaHeader = reinterpret_cast(cursor); luaHeader->luaCode = reinterpret_cast(data + luaHeader->luaCodeOffset); setup.luaFiles.push_back(luaHeader); - curentPointer += sizeof(psxsplash::LuaFile); + cursor += sizeof(psxsplash::LuaFile); } - setup.sceneLuaFileIndex = header->sceneLuaFileIndex; + // sceneLuaFileIndex is stored as uint16_t in header; 0xFFFF means "no scene script" (-1) + setup.sceneLuaFileIndex = (header->sceneLuaFileIndex == 0xFFFF) ? -1 : (int)header->sceneLuaFileIndex; for (uint16_t i = 0; i < header->gameObjectCount; i++) { - psxsplash::GameObject *go = reinterpret_cast(curentPointer); + psxsplash::GameObject *go = reinterpret_cast(cursor); go->polygons = reinterpret_cast(data + go->polygonsOffset); setup.objects.push_back(go); - curentPointer += sizeof(psxsplash::GameObject); + cursor += sizeof(psxsplash::GameObject); } - for (uint16_t i = 0; i < header->navmeshCount; i++) { - psxsplash::Navmesh *navmesh = reinterpret_cast(curentPointer); - navmesh->polygons = reinterpret_cast(data + navmesh->polygonsOffset); - setup.navmeshes.push_back(navmesh); - curentPointer += sizeof(psxsplash::Navmesh); + // Read collision data (after GameObjects) + for (uint16_t i = 0; i < header->colliderCount; i++) { + psxsplash::SPLASHPACKCollider *collider = reinterpret_cast(cursor); + setup.colliders.push_back(collider); + cursor += sizeof(psxsplash::SPLASHPACKCollider); } + // Read BVH data (version 3+) + if (header->version >= 3 && header->bvhNodeCount > 0) { + BVHNode* bvhNodes = reinterpret_cast(cursor); + cursor += header->bvhNodeCount * sizeof(BVHNode); + + TriangleRef* triangleRefs = reinterpret_cast(cursor); + cursor += header->bvhTriangleRefCount * sizeof(TriangleRef); + + setup.bvh.initialize(bvhNodes, header->bvhNodeCount, + triangleRefs, header->bvhTriangleRefCount); + } + + // Read component data (version 4+) + if (header->version >= 4) { + // Interactables + for (uint16_t i = 0; i < header->interactableCount; i++) { + psxsplash::Interactable *interactable = reinterpret_cast(cursor); + setup.interactables.push_back(interactable); + cursor += sizeof(psxsplash::Interactable); + } + + // Skip health components (legacy, 24 bytes each) + cursor += header->healthCount * 24; + + // Skip timers (legacy, 16 bytes each) + cursor += header->timerCount * 16; + + // Skip spawners (legacy, 44 bytes each) + cursor += header->spawnerCount * 44; + } + + // Read NavGrid (version 5+ — LEGACY, skip if present) + if (header->version >= 5 && header->hasNavGrid) { + // Skip NavGrid data: header (16 bytes) + cells + // NavGridHeader: 4 int32 = 16 bytes, then gridW*gridH*9 bytes + int32_t* navGridHeader = reinterpret_cast(cursor); + int32_t gridW = navGridHeader[2]; + int32_t gridH = navGridHeader[3]; + cursor += 16; // header + cursor += gridW * gridH * 9; // cells (9 bytes each) + // Align to 4 bytes + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + } + + // Read world collision soup (version 7+) + if (header->version >= 7 && header->worldCollisionMeshCount > 0) { + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + cursor = const_cast(setup.worldCollision.initializeFromData(cursor)); + } + + // Read nav regions (version 7+) + if (header->version >= 7 && header->navRegionCount > 0) { + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + cursor = const_cast(setup.navRegions.initializeFromData(cursor)); + } + + // Read room/portal data (version 11+, interior scenes) + // Must be read here (after nav regions, before navmesh skip / atlas metadata) + // to match the sequential cursor position where the writer places it. + if (header->version >= 11 && header->roomCount > 0) { + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + + setup.rooms = reinterpret_cast(cursor); + setup.roomCount = header->roomCount; + cursor += header->roomCount * sizeof(RoomData); + + setup.portals = reinterpret_cast(cursor); + setup.portalCount = header->portalCount; + cursor += header->portalCount * sizeof(PortalData); + + setup.roomTriRefs = reinterpret_cast(cursor); + setup.roomTriRefCount = header->roomTriRefCount; + cursor += header->roomTriRefCount * sizeof(TriangleRef); + } + + // Skip legacy navmesh metadata (still present in v7 files) + cursor += header->navmeshCount * 8; // Navmesh struct: 4+2+2 = 8 bytes + for (uint16_t i = 0; i < header->textureAtlasCount; i++) { - psxsplash::SPLASHPACKTextureAtlas *atlas = reinterpret_cast(curentPointer); + psxsplash::SPLASHPACKTextureAtlas *atlas = reinterpret_cast(cursor); uint8_t *offsetData = data + atlas->polygonsOffset; uint16_t *castedData = reinterpret_cast(offsetData); psxsplash::Renderer::GetInstance().VramUpload(castedData, atlas->x, atlas->y, atlas->width, atlas->height); - curentPointer += sizeof(psxsplash::SPLASHPACKTextureAtlas); + cursor += sizeof(psxsplash::SPLASHPACKTextureAtlas); } for (uint16_t i = 0; i < header->clutCount; i++) { - psxsplash::SPLASHPACKClut *clut = reinterpret_cast(curentPointer); + psxsplash::SPLASHPACKClut *clut = reinterpret_cast(cursor); uint8_t *clutOffset = data + clut->clutOffset; psxsplash::Renderer::GetInstance().VramUpload((uint16_t *)clutOffset, clut->clutPackingX * 16, clut->clutPackingY, clut->length, 1); - curentPointer += sizeof(psxsplash::SPLASHPACKClut); + cursor += sizeof(psxsplash::SPLASHPACKClut); } + + // Read object name table (version 9+) + if (header->version >= 9 && header->nameTableOffset != 0) { + uint8_t* nameData = data + header->nameTableOffset; + setup.objectNames.reserve(header->gameObjectCount); + for (uint16_t i = 0; i < header->gameObjectCount; i++) { + uint8_t nameLen = *nameData++; + const char* nameStr = reinterpret_cast(nameData); + // Names are stored as length-prefixed, null-terminated strings + setup.objectNames.push_back(nameStr); + nameData += nameLen + 1; // +1 for null terminator + } + } + + // Read audio clip table (version 10+) + if (header->version >= 10 && header->audioClipCount > 0 && header->audioTableOffset != 0) { + // Audio table: per clip: uint32_t dataOffset, uint32_t sizeBytes, uint16_t sampleRate, uint8_t loop, uint8_t nameLen, uint32_t nameOffset + // Total 16 bytes per entry + uint8_t* audioTable = data + header->audioTableOffset; + setup.audioClips.reserve(header->audioClipCount); + setup.audioClipNames.reserve(header->audioClipCount); + for (uint16_t i = 0; i < header->audioClipCount; i++) { + uint32_t dataOff = *reinterpret_cast(audioTable); audioTable += 4; + uint32_t size = *reinterpret_cast(audioTable); audioTable += 4; + uint16_t rate = *reinterpret_cast(audioTable); audioTable += 2; + uint8_t loop = *audioTable++; + uint8_t nameLen = *audioTable++; + uint32_t nameOff = *reinterpret_cast(audioTable); audioTable += 4; + SplashpackSceneSetup::AudioClipSetup clip; + clip.adpcmData = data + dataOff; + clip.sizeBytes = size; + clip.sampleRate = rate; + clip.loop = (loop != 0); + clip.name = (nameLen > 0 && nameOff != 0) ? reinterpret_cast(data + nameOff) : nullptr; + setup.audioClips.push_back(clip); + setup.audioClipNames.push_back(clip.name); + } + } + + // Read fog configuration (version 11+) + if (header->version >= 11) { + setup.fogEnabled = header->fogEnabled != 0; + setup.fogR = header->fogR; + setup.fogG = header->fogG; + setup.fogB = header->fogB; + setup.fogDensity = header->fogDensity; + } + + // Read scene type (version 6+ stored it but it was never read until now) + setup.sceneType = header->sceneType; + } } // namespace psxsplash diff --git a/src/splashpack.cpp.bal b/src/splashpack.cpp.bal new file mode 100644 index 0000000..64f76ba --- /dev/null +++ b/src/splashpack.cpp.bal @@ -0,0 +1,295 @@ +#include "splashpack.hh" + +#include + +#include +#include +#include + +#include "bvh.hh" +#include "collision.hh" +#include "gameobject.hh" +#include "lua.h" +#include "mesh.hh" +#include "worldcollision.hh" +#include "navregion.hh" +#include "renderer.hh" + +namespace psxsplash { + +struct SPLASHPACKFileHeader { + char magic[2]; // "SP" + uint16_t version; // Format version (8 = movement params) + uint16_t luaFileCount; + uint16_t gameObjectCount; + uint16_t navmeshCount; + uint16_t textureAtlasCount; + uint16_t clutCount; + uint16_t colliderCount; + psyqo::GTE::PackedVec3 playerStartPos; + psyqo::GTE::PackedVec3 playerStartRot; + psyqo::FixedPoint<12, uint16_t> playerHeight; + uint16_t sceneLuaFileIndex; + // Version 3 additions: + uint16_t bvhNodeCount; + uint16_t bvhTriangleRefCount; + // Version 4 additions (component counts): + uint16_t interactableCount; + uint16_t healthCount; + uint16_t timerCount; + uint16_t spawnerCount; + // Version 5 additions (navgrid): + uint16_t hasNavGrid; // 1 if navgrid present, 0 otherwise + uint16_t reserved; // Alignment padding + // Version 6 additions (AABB + scene type): + uint16_t sceneType; // 0 = exterior, 1 = interior + uint16_t reserved2; // Alignment padding + // Version 7 additions (world collision + nav regions): + uint16_t worldCollisionMeshCount; + uint16_t worldCollisionTriCount; + uint16_t navRegionCount; + uint16_t navPortalCount; + // Version 8 additions (movement parameters): + uint16_t moveSpeed; // fp12 per-frame speed constant + uint16_t sprintSpeed; // fp12 per-frame speed constant + uint16_t jumpVelocity; // fp12 per-second initial jump velocity + uint16_t gravity; // fp12 per-second² downward acceleration + uint16_t playerRadius; // fp12 collision radius + uint16_t reserved3; // Alignment padding + // Version 9 additions (object names): + uint32_t nameTableOffset; // Offset to name string table (0 = no names) + // Version 10 additions (audio): + uint16_t audioClipCount; // Number of audio clips + uint16_t reserved4; // Alignment padding + uint32_t audioTableOffset; // Offset to audio clip table (0 = no audio) + // Version 11 additions (fog + room/portal): + uint8_t fogEnabled; // 0 = off, 1 = on + uint8_t fogR, fogG, fogB; // Fog color RGB + uint8_t fogDensity; // 1-10 density scale + uint8_t reserved5; // Alignment + uint16_t roomCount; // 0 = no room system (use BVH path) + uint16_t portalCount; + uint16_t roomTriRefCount; +}; +static_assert(sizeof(SPLASHPACKFileHeader) == 96, "SPLASHPACKFileHeader must be 96 bytes"); + +struct SPLASHPACKTextureAtlas { + uint32_t polygonsOffset; + uint16_t width, height; + uint16_t x, y; +}; + +struct SPLASHPACKClut { + uint32_t clutOffset; + uint16_t clutPackingX; + uint16_t clutPackingY; + uint16_t length; + uint16_t pad; +}; + +void SplashPackLoader::LoadSplashpack(uint8_t *data, SplashpackSceneSetup &setup) { + psyqo::Kernel::assert(data != nullptr, "Splashpack loading data pointer is null"); + psxsplash::SPLASHPACKFileHeader *header = reinterpret_cast(data); + psyqo::Kernel::assert(__builtin_memcmp(header->magic, "SP", 2) == 0, "Splashpack has incorrect magic"); + psyqo::Kernel::assert(header->version >= 8, "Splashpack version mismatch: re-export from SplashEdit"); + + setup.playerStartPosition = header->playerStartPos; + setup.playerStartRotation = header->playerStartRot; + setup.playerHeight = header->playerHeight; + + // Movement parameters (v8+) + setup.moveSpeed.value = header->moveSpeed; + setup.sprintSpeed.value = header->sprintSpeed; + setup.jumpVelocity.value = header->jumpVelocity; + setup.gravity.value = header->gravity; + setup.playerRadius.value = header->playerRadius; + + setup.luaFiles.reserve(header->luaFileCount); + setup.objects.reserve(header->gameObjectCount); + setup.colliders.reserve(header->colliderCount); + + // Reserve component arrays (version 4+) + if (header->version >= 4) { + setup.interactables.reserve(header->interactableCount); + } + + // V10 header = 84 bytes, V11+ = 96 bytes. sizeof() always returns 96, + // so we must compute the correct offset for older versions. + uint32_t headerSize = (header->version >= 11) ? 96 : 84; + uint8_t *cursor = data + headerSize; + + for (uint16_t i = 0; i < header->luaFileCount; i++) { + psxsplash::LuaFile *luaHeader = reinterpret_cast(cursor); + luaHeader->luaCode = reinterpret_cast(data + luaHeader->luaCodeOffset); + setup.luaFiles.push_back(luaHeader); + cursor += sizeof(psxsplash::LuaFile); + } + + // sceneLuaFileIndex is stored as uint16_t in header; 0xFFFF means "no scene script" (-1) + setup.sceneLuaFileIndex = (header->sceneLuaFileIndex == 0xFFFF) ? -1 : (int)header->sceneLuaFileIndex; + + for (uint16_t i = 0; i < header->gameObjectCount; i++) { + psxsplash::GameObject *go = reinterpret_cast(cursor); + go->polygons = reinterpret_cast(data + go->polygonsOffset); + setup.objects.push_back(go); + cursor += sizeof(psxsplash::GameObject); + } + + // Read collision data (after GameObjects) + for (uint16_t i = 0; i < header->colliderCount; i++) { + psxsplash::SPLASHPACKCollider *collider = reinterpret_cast(cursor); + setup.colliders.push_back(collider); + cursor += sizeof(psxsplash::SPLASHPACKCollider); + } + + // Read BVH data (version 3+) + if (header->version >= 3 && header->bvhNodeCount > 0) { + BVHNode* bvhNodes = reinterpret_cast(cursor); + cursor += header->bvhNodeCount * sizeof(BVHNode); + + TriangleRef* triangleRefs = reinterpret_cast(cursor); + cursor += header->bvhTriangleRefCount * sizeof(TriangleRef); + + setup.bvh.initialize(bvhNodes, header->bvhNodeCount, + triangleRefs, header->bvhTriangleRefCount); + } + + // Read component data (version 4+) + if (header->version >= 4) { + // Interactables + for (uint16_t i = 0; i < header->interactableCount; i++) { + psxsplash::Interactable *interactable = reinterpret_cast(cursor); + setup.interactables.push_back(interactable); + cursor += sizeof(psxsplash::Interactable); + } + + // Skip health components (legacy, 24 bytes each) + cursor += header->healthCount * 24; + + // Skip timers (legacy, 16 bytes each) + cursor += header->timerCount * 16; + + // Skip spawners (legacy, 44 bytes each) + cursor += header->spawnerCount * 44; + } + + // Read NavGrid (version 5+ — LEGACY, skip if present) + if (header->version >= 5 && header->hasNavGrid) { + // Skip NavGrid data: header (16 bytes) + cells + // NavGridHeader: 4 int32 = 16 bytes, then gridW*gridH*9 bytes + int32_t* navGridHeader = reinterpret_cast(cursor); + int32_t gridW = navGridHeader[2]; + int32_t gridH = navGridHeader[3]; + cursor += 16; // header + cursor += gridW * gridH * 9; // cells (9 bytes each) + // Align to 4 bytes + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + } + + // Read world collision soup (version 7+) + if (header->version >= 7 && header->worldCollisionMeshCount > 0) { + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + cursor = const_cast(setup.worldCollision.initializeFromData(cursor)); + } + + // Read nav regions (version 7+) + if (header->version >= 7 && header->navRegionCount > 0) { + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + cursor = const_cast(setup.navRegions.initializeFromData(cursor)); + } + + // Read room/portal data (version 11+, interior scenes) + // Must be read here (after nav regions, before navmesh skip / atlas metadata) + // to match the sequential cursor position where the writer places it. + if (header->version >= 11 && header->roomCount > 0) { + uintptr_t addr = reinterpret_cast(cursor); + cursor = reinterpret_cast((addr + 3) & ~3); + + setup.rooms = reinterpret_cast(cursor); + setup.roomCount = header->roomCount; + cursor += header->roomCount * sizeof(RoomData); + + setup.portals = reinterpret_cast(cursor); + setup.portalCount = header->portalCount; + cursor += header->portalCount * sizeof(PortalData); + + setup.roomTriRefs = reinterpret_cast(cursor); + setup.roomTriRefCount = header->roomTriRefCount; + cursor += header->roomTriRefCount * sizeof(TriangleRef); + } + + // Skip legacy navmesh metadata (still present in v7 files) + cursor += header->navmeshCount * 8; // Navmesh struct: 4+2+2 = 8 bytes + + for (uint16_t i = 0; i < header->textureAtlasCount; i++) { + psxsplash::SPLASHPACKTextureAtlas *atlas = reinterpret_cast(cursor); + uint8_t *offsetData = data + atlas->polygonsOffset; + uint16_t *castedData = reinterpret_cast(offsetData); + psxsplash::Renderer::GetInstance().VramUpload(castedData, atlas->x, atlas->y, atlas->width, atlas->height); + cursor += sizeof(psxsplash::SPLASHPACKTextureAtlas); + } + + for (uint16_t i = 0; i < header->clutCount; i++) { + psxsplash::SPLASHPACKClut *clut = reinterpret_cast(cursor); + uint8_t *clutOffset = data + clut->clutOffset; + psxsplash::Renderer::GetInstance().VramUpload((uint16_t *)clutOffset, clut->clutPackingX * 16, + clut->clutPackingY, clut->length, 1); + cursor += sizeof(psxsplash::SPLASHPACKClut); + } + + // Read object name table (version 9+) + if (header->version >= 9 && header->nameTableOffset != 0) { + uint8_t* nameData = data + header->nameTableOffset; + setup.objectNames.reserve(header->gameObjectCount); + for (uint16_t i = 0; i < header->gameObjectCount; i++) { + uint8_t nameLen = *nameData++; + const char* nameStr = reinterpret_cast(nameData); + // Names are stored as length-prefixed, null-terminated strings + setup.objectNames.push_back(nameStr); + nameData += nameLen + 1; // +1 for null terminator + } + } + + // Read audio clip table (version 10+) + if (header->version >= 10 && header->audioClipCount > 0 && header->audioTableOffset != 0) { + // Audio table: per clip: uint32_t dataOffset, uint32_t sizeBytes, uint16_t sampleRate, uint8_t loop, uint8_t nameLen, uint32_t nameOffset + // Total 16 bytes per entry + uint8_t* audioTable = data + header->audioTableOffset; + setup.audioClips.reserve(header->audioClipCount); + setup.audioClipNames.reserve(header->audioClipCount); + for (uint16_t i = 0; i < header->audioClipCount; i++) { + uint32_t dataOff = *reinterpret_cast(audioTable); audioTable += 4; + uint32_t size = *reinterpret_cast(audioTable); audioTable += 4; + uint16_t rate = *reinterpret_cast(audioTable); audioTable += 2; + uint8_t loop = *audioTable++; + uint8_t nameLen = *audioTable++; + uint32_t nameOff = *reinterpret_cast(audioTable); audioTable += 4; + SplashpackSceneSetup::AudioClipSetup clip; + clip.adpcmData = data + dataOff; + clip.sizeBytes = size; + clip.sampleRate = rate; + clip.loop = (loop != 0); + clip.name = (nameLen > 0 && nameOff != 0) ? reinterpret_cast(data + nameOff) : nullptr; + setup.audioClips.push_back(clip); + setup.audioClipNames.push_back(clip.name); + } + } + + // Read fog configuration (version 11+) + if (header->version >= 11) { + setup.fogEnabled = header->fogEnabled != 0; + setup.fogR = header->fogR; + setup.fogG = header->fogG; + setup.fogB = header->fogB; + setup.fogDensity = header->fogDensity; + } + + // Read scene type (version 6+ stored it but it was never read until now) + setup.sceneType = header->sceneType; + +} + +} // namespace psxsplash diff --git a/src/splashpack.hh b/src/splashpack.hh index d32936c..671cefb 100644 --- a/src/splashpack.hh +++ b/src/splashpack.hh @@ -4,20 +4,87 @@ #include +#include "bvh.hh" +#include "collision.hh" #include "gameobject.hh" #include "lua.h" -#include "navmesh.hh" +#include "worldcollision.hh" +#include "navregion.hh" +#include "audiomanager.hh" +#include "interactable.hh" namespace psxsplash { +/** + * Collision data as stored in the binary file (fixed layout for serialization) + * This is the binary-compatible version of CollisionData + */ +struct SPLASHPACKCollider { + // AABB bounds in fixed-point (24 bytes) + int32_t minX, minY, minZ; + int32_t maxX, maxY, maxZ; + // Collision metadata (8 bytes) + uint8_t collisionType; // CollisionType enum + uint8_t layerMask; // Which layers this collides with + uint16_t gameObjectIndex; // Which GameObject this belongs to + uint32_t padding; // Alignment padding +}; +static_assert(sizeof(SPLASHPACKCollider) == 32, "SPLASHPACKCollider must be 32 bytes"); + struct SplashpackSceneSetup { int sceneLuaFileIndex; eastl::vector luaFiles; eastl::vector objects; - eastl::vector navmeshes; + eastl::vector colliders; + + // New component arrays + eastl::vector interactables; + + // Object name table (v9+): parallel to objects, points into splashpack data + eastl::vector objectNames; + + // Audio clips (v10+): ADPCM data with metadata + struct AudioClipSetup { + const uint8_t* adpcmData; + uint32_t sizeBytes; + uint16_t sampleRate; + bool loop; + const char* name; // Points into splashpack data (null-terminated) + }; + eastl::vector audioClips; + + // Audio clip name table (v10+): parallel to audioClips, points into splashpack data + eastl::vector audioClipNames; + + BVHManager bvh; // Spatial acceleration structure for culling + WorldCollision worldCollision; // Triangle-level world collision (v7+) + NavRegionSystem navRegions; // Convex region navigation (v7+) psyqo::GTE::PackedVec3 playerStartPosition; psyqo::GTE::PackedVec3 playerStartRotation; psyqo::FixedPoint<12, uint16_t> playerHeight; + + // Scene type: 0=exterior (BVH culling), 1=interior (room/portal culling) + uint16_t sceneType = 0; + + // Fog configuration (v11+) + bool fogEnabled = false; + uint8_t fogR = 0, fogG = 0, fogB = 0; + uint8_t fogDensity = 5; + + // Room/portal data (v11+, interior scenes only) + const RoomData* rooms = nullptr; + uint16_t roomCount = 0; + const PortalData* portals = nullptr; + uint16_t portalCount = 0; + const TriangleRef* roomTriRefs = nullptr; + uint16_t roomTriRefCount = 0; + + // Movement parameters (v8+) + psyqo::FixedPoint<12, uint16_t> moveSpeed; // Per-frame speed constant (fp12) + psyqo::FixedPoint<12, uint16_t> sprintSpeed; // Per-frame sprint constant (fp12) + psyqo::FixedPoint<12, uint16_t> jumpVelocity; // Per-second initial velocity (fp12) + psyqo::FixedPoint<12, uint16_t> gravity; // Per-second² acceleration (fp12) + psyqo::FixedPoint<12, uint16_t> playerRadius; // Collision radius (fp12) }; class SplashPackLoader { @@ -25,4 +92,4 @@ class SplashPackLoader { void LoadSplashpack(uint8_t *data, SplashpackSceneSetup &setup); }; -}; // namespace psxsplash \ No newline at end of file +}; // namespace psxsplash diff --git a/src/triclip.cpp b/src/triclip.cpp new file mode 100644 index 0000000..f113241 --- /dev/null +++ b/src/triclip.cpp @@ -0,0 +1,197 @@ +#include "triclip.hh" + +namespace psxsplash { + +// ============================================================================ +// Screen-space Sutherland-Hodgman clipping +// ============================================================================ + +// Interpolate between two ClipVertex at parameter t (0..256 = 0.0..1.0, fp8). +// Uses fp8 to avoid overflow with int16 coordinates (int16 * 256 fits int32). +static ClipVertex lerpClip(const ClipVertex& a, const ClipVertex& b, int32_t t) { + ClipVertex r; + r.x = (int16_t)(a.x + (((int32_t)(b.x - a.x) * t) >> 8)); + r.y = (int16_t)(a.y + (((int32_t)(b.y - a.y) * t) >> 8)); + r.z = (int16_t)(a.z + (((int32_t)(b.z - a.z) * t) >> 8)); + r.u = (uint8_t)(a.u + (((int)(b.u) - (int)(a.u)) * t >> 8)); + r.v = (uint8_t)(a.v + (((int)(b.v) - (int)(a.v)) * t >> 8)); + r.r = (uint8_t)(a.r + (((int)(b.r) - (int)(a.r)) * t >> 8)); + r.g = (uint8_t)(a.g + (((int)(b.g) - (int)(a.g)) * t >> 8)); + r.b = (uint8_t)(a.b + (((int)(b.b) - (int)(a.b)) * t >> 8)); + return r; +} + +// Clip a polygon (in[] with inCount vertices) against a single edge. +// Edge is defined by axis (0=X, 1=Y), sign (+1 or -1), and threshold. +// Output written to out[], returns output vertex count. +static int clipEdge(const ClipVertex* in, int inCount, + ClipVertex* out, int axis, int sign, int16_t threshold) { + if (inCount == 0) return 0; + int outCount = 0; + + for (int i = 0; i < inCount; i++) { + const ClipVertex& cur = in[i]; + const ClipVertex& next = in[(i + 1) % inCount]; + + int16_t curVal = (axis == 0) ? cur.x : cur.y; + int16_t nextVal = (axis == 0) ? next.x : next.y; + + bool curInside = (sign > 0) ? (curVal <= threshold) : (curVal >= threshold); + bool nextInside = (sign > 0) ? (nextVal <= threshold) : (nextVal >= threshold); + + if (curInside) { + if (outCount < 8) out[outCount++] = cur; + if (!nextInside) { + // Exiting: compute intersection + int32_t den = (int32_t)nextVal - (int32_t)curVal; + if (den != 0) { + int32_t t = ((int32_t)(threshold - curVal) << 8) / den; + if (t < 0) t = 0; + if (t > 256) t = 256; + if (outCount < 8) out[outCount++] = lerpClip(cur, next, t); + } + } + } else if (nextInside) { + // Entering: compute intersection + int32_t den = (int32_t)nextVal - (int32_t)curVal; + if (den != 0) { + int32_t t = ((int32_t)(threshold - curVal) << 8) / den; + if (t < 0) t = 0; + if (t > 256) t = 256; + if (outCount < 8) out[outCount++] = lerpClip(cur, next, t); + } + } + } + return outCount; +} + +int clipTriangle(const ClipVertex& v0, const ClipVertex& v1, const ClipVertex& v2, + ClipResult& result) { + // Working buffers for Sutherland-Hodgman (max 8 vertices after 4 clips). + ClipVertex bufA[8], bufB[8]; + bufA[0] = v0; bufA[1] = v1; bufA[2] = v2; + int count = 3; + + // Clip against 4 edges: left, right, top, bottom. + count = clipEdge(bufA, count, bufB, 0, -1, CLIP_LEFT); // X >= CLIP_LEFT + count = clipEdge(bufB, count, bufA, 0, +1, CLIP_RIGHT); // X <= CLIP_RIGHT + count = clipEdge(bufA, count, bufB, 1, -1, CLIP_TOP); // Y >= CLIP_TOP + count = clipEdge(bufB, count, bufA, 1, +1, CLIP_BOTTOM); // Y <= CLIP_BOTTOM + + if (count < 3) return 0; + + // Triangulate the convex polygon into a fan from vertex 0. + int triCount = count - 2; + if (triCount > MAX_CLIP_TRIS) triCount = MAX_CLIP_TRIS; + + for (int i = 0; i < triCount; i++) { + result.verts[i * 3 + 0] = bufA[0]; + result.verts[i * 3 + 1] = bufA[i + 1]; + result.verts[i * 3 + 2] = bufA[i + 2]; + } + return triCount; +} + +// ============================================================================ +// Near-plane (3D view-space) clipping +// ============================================================================ + +ViewVertex lerpViewVertex(const ViewVertex& a, const ViewVertex& b, int32_t t) { + ViewVertex r; + r.x = a.x + (int32_t)(((int64_t)(b.x - a.x) * t) >> 12); + r.y = a.y + (int32_t)(((int64_t)(b.y - a.y) * t) >> 12); + r.z = a.z + (int32_t)(((int64_t)(b.z - a.z) * t) >> 12); + r.u = (uint8_t)(a.u + (((int)(b.u) - (int)(a.u)) * t >> 12)); + r.v = (uint8_t)(a.v + (((int)(b.v) - (int)(a.v)) * t >> 12)); + r.r = (uint8_t)(a.r + (((int)(b.r) - (int)(a.r)) * t >> 12)); + r.g = (uint8_t)(a.g + (((int)(b.g) - (int)(a.g)) * t >> 12)); + r.b = (uint8_t)(a.b + (((int)(b.b) - (int)(a.b)) * t >> 12)); + r.pad = 0; + return r; +} + +static inline int32_t nearPlaneT(int32_t zA, int32_t zB) { + constexpr int32_t nearZ = (int32_t)NEAR_PLANE_Z << 12; + int32_t num = nearZ - zA; + int32_t den = zB - zA; + if (den == 0) return 0; + int32_t absNum = num < 0 ? -num : num; + int shift = 0; + while (absNum > 0x7FFFF) { + absNum >>= 1; + shift++; + } + if (shift > 0) { + num >>= shift; + den >>= shift; + if (den == 0) return num > 0 ? 4096 : 0; + } + return (num << 12) / den; +} + +static inline bool isBehind(int32_t z) { + return z < ((int32_t)NEAR_PLANE_Z << 12); +} + +int nearPlaneClip(const ViewVertex& v0, const ViewVertex& v1, const ViewVertex& v2, + NearClipResult& result) { + bool b0 = isBehind(v0.z); + bool b1 = isBehind(v1.z); + bool b2 = isBehind(v2.z); + int behindCount = (int)b0 + (int)b1 + (int)b2; + + if (behindCount == 3) { + result.triCount = 0; + return 0; + } + if (behindCount == 0) { + result.triCount = 1; + result.verts[0] = v0; + result.verts[1] = v1; + result.verts[2] = v2; + return 1; + } + if (behindCount == 1) { + const ViewVertex* A; + const ViewVertex* B; + const ViewVertex* C; + if (b0) { A = &v0; B = &v1; C = &v2; } + else if (b1) { A = &v1; B = &v2; C = &v0; } + else { A = &v2; B = &v0; C = &v1; } + + int32_t tAB = nearPlaneT(A->z, B->z); + int32_t tAC = nearPlaneT(A->z, C->z); + ViewVertex AB = lerpViewVertex(*A, *B, tAB); + ViewVertex AC = lerpViewVertex(*A, *C, tAC); + + result.triCount = 2; + result.verts[0] = AB; + result.verts[1] = *B; + result.verts[2] = *C; + result.verts[3] = AB; + result.verts[4] = *C; + result.verts[5] = AC; + return 2; + } + { + const ViewVertex* A; + const ViewVertex* B; + const ViewVertex* C; + if (!b0) { A = &v0; B = &v1; C = &v2; } + else if (!b1) { A = &v1; B = &v2; C = &v0; } + else { A = &v2; B = &v0; C = &v1; } + + int32_t tBA = nearPlaneT(B->z, A->z); + int32_t tCA = nearPlaneT(C->z, A->z); + ViewVertex BA = lerpViewVertex(*B, *A, tBA); + ViewVertex CA = lerpViewVertex(*C, *A, tCA); + + result.triCount = 1; + result.verts[0] = *A; + result.verts[1] = BA; + result.verts[2] = CA; + return 1; + } +} + +} // namespace psxsplash diff --git a/src/triclip.hh b/src/triclip.hh new file mode 100644 index 0000000..91a5614 --- /dev/null +++ b/src/triclip.hh @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include + +namespace psxsplash { + +// ============================================================================ +// Screen-space clipping types and functions +// ============================================================================ + +// Screen-space clip vertex with interpolatable attributes. +struct ClipVertex { + int16_t x, y, z; + uint8_t u, v; + uint8_t r, g, b; +}; + +// Maximum output triangles from clipping a single triangle against 4 edges. +// Sutherland-Hodgman can produce up to 7 vertices -> 5 triangles in a fan. +static constexpr int MAX_CLIP_TRIS = 5; + +// Result of screen-space triangle clipping. +struct ClipResult { + ClipVertex verts[MAX_CLIP_TRIS * 3]; +}; + +// GPU rasterizer limits: max vertex-to-vertex delta. +static constexpr int16_t MAX_DELTA_X = 1023; +static constexpr int16_t MAX_DELTA_Y = 511; + +// Screen-space clip region. Must be narrower than rasterizer limits (1023x511) +// so that any triangle fully inside has safe vertex deltas. +// Centered on screen (160,120), extended to half the rasterizer max in each direction. +static constexpr int16_t CLIP_LEFT = 160 - 510; // -350 +static constexpr int16_t CLIP_RIGHT = 160 + 510; // 670 +static constexpr int16_t CLIP_TOP = 120 - 254; // -134 +static constexpr int16_t CLIP_BOTTOM = 120 + 254; // 374 + +// Check if all 3 vertices are on the same side of any screen edge -> invisible. +inline bool isCompletelyOutside(const psyqo::Vertex& v0, + const psyqo::Vertex& v1, + const psyqo::Vertex& v2) { + int16_t x0 = v0.x, x1 = v1.x, x2 = v2.x; + int16_t y0 = v0.y, y1 = v1.y, y2 = v2.y; + + if (x0 < CLIP_LEFT && x1 < CLIP_LEFT && x2 < CLIP_LEFT) return true; + if (x0 > CLIP_RIGHT && x1 > CLIP_RIGHT && x2 > CLIP_RIGHT) return true; + if (y0 < CLIP_TOP && y1 < CLIP_TOP && y2 < CLIP_TOP) return true; + if (y0 > CLIP_BOTTOM && y1 > CLIP_BOTTOM && y2 > CLIP_BOTTOM) return true; + return false; +} + +// Check if any vertex is outside the clip region or vertex deltas exceed +// rasterizer limits. If true, the triangle needs screen-space clipping. +inline bool needsClipping(const psyqo::Vertex& v0, + const psyqo::Vertex& v1, + const psyqo::Vertex& v2) { + int16_t x0 = v0.x, x1 = v1.x, x2 = v2.x; + int16_t y0 = v0.y, y1 = v1.y, y2 = v2.y; + + // Check if any vertex is outside the clip region. + if (x0 < CLIP_LEFT || x0 > CLIP_RIGHT || + x1 < CLIP_LEFT || x1 > CLIP_RIGHT || + x2 < CLIP_LEFT || x2 > CLIP_RIGHT || + y0 < CLIP_TOP || y0 > CLIP_BOTTOM || + y1 < CLIP_TOP || y1 > CLIP_BOTTOM || + y2 < CLIP_TOP || y2 > CLIP_BOTTOM) { + return true; + } + + // Check vertex-to-vertex deltas against rasterizer limits. + int16_t minX = x0, maxX = x0; + int16_t minY = y0, maxY = y0; + if (x1 < minX) minX = x1; if (x1 > maxX) maxX = x1; + if (x2 < minX) minX = x2; if (x2 > maxX) maxX = x2; + if (y1 < minY) minY = y1; if (y1 > maxY) maxY = y1; + if (y2 < minY) minY = y2; if (y2 > maxY) maxY = y2; + + if ((int32_t)(maxX - minX) > MAX_DELTA_X) return true; + if ((int32_t)(maxY - minY) > MAX_DELTA_Y) return true; + + return false; +} + +// Sutherland-Hodgman screen-space triangle clipping. +// Clips against CLIP_LEFT/RIGHT/TOP/BOTTOM, then triangulates the result. +// Returns number of output triangles (0 to MAX_CLIP_TRIS), vertices in result. +int clipTriangle(const ClipVertex& v0, const ClipVertex& v1, const ClipVertex& v2, + ClipResult& result); + +// ============================================================================ +// Near-plane (3D view-space) clipping types and functions +// ============================================================================ + +#define NEAR_PLANE_Z 48 +#define MAX_NEARCLIP_TRIS 2 + +struct ViewVertex { + int32_t x, y, z; + uint8_t u, v; + uint8_t r, g, b; + uint8_t pad; +}; + +struct NearClipResult { + int triCount; + ViewVertex verts[MAX_NEARCLIP_TRIS * 3]; +}; + +int nearPlaneClip(const ViewVertex& v0, const ViewVertex& v1, const ViewVertex& v2, + NearClipResult& result); +ViewVertex lerpViewVertex(const ViewVertex& a, const ViewVertex& b, int32_t t); + +} // namespace psxsplash diff --git a/src/vram_config.h b/src/vram_config.h new file mode 100644 index 0000000..440dd88 --- /dev/null +++ b/src/vram_config.h @@ -0,0 +1,12 @@ +// Auto-generated by SplashEdit - do not edit manually. +#pragma once + +// GPU resolution +#define VRAM_RES_WIDTH 320 +#define VRAM_RES_HEIGHT 240 +#define VRAM_RES_ENUM psyqo::GPU::Resolution::W320 +#define VRAM_INTERLACE psyqo::GPU::Interlace::PROGRESSIVE + +// Framebuffer layout +#define VRAM_DUAL_BUFFER 1 +#define VRAM_VERTICAL 1 diff --git a/src/worldcollision.cpp b/src/worldcollision.cpp new file mode 100644 index 0000000..682bf72 --- /dev/null +++ b/src/worldcollision.cpp @@ -0,0 +1,621 @@ +#include "worldcollision.hh" + +#include +#include + +// One-shot collision diagnostics + +/** + * worldcollision.cpp - Player-vs-World Triangle Collision + * + * ALL math is 20.12 fixed-point. Intermediate products use int64_t + * to avoid overflow (20.12 * 20.12 = 40.24, shift >>12 back to 20.12). + * + * Performance budget: ~256 triangle tests per frame on 33MHz MIPS. + */ + +namespace psxsplash { + +// ============================================================================ +// Fixed-point helpers (20.12) +// ============================================================================ + +static constexpr int FRAC_BITS = 12; +static constexpr int32_t FP_ONE = 1 << FRAC_BITS; // 4096 + +// Multiply two 20.12 values → 20.12 +static inline int32_t fpmul(int32_t a, int32_t b) { + return (int32_t)(((int64_t)a * b) >> FRAC_BITS); +} + +// Fixed-point division: returns (a << 12) / b using only 32-bit DIV. +// Uses remainder theorem: (a * 4096) / b = (a/b)*4096 + ((a%b)*4096)/b +static inline int32_t fpdiv(int32_t a, int32_t b) { + if (b == 0) return 0; + int32_t q = a / b; + int32_t r = a - q * b; + // r * FP_ONE is safe when |r| < 524288 (which covers most game values) + return q * FP_ONE + (r << FRAC_BITS) / b; +} + +// Dot product of two 3-vectors in 20.12 +static inline int32_t dot3(int32_t ax, int32_t ay, int32_t az, + int32_t bx, int32_t by, int32_t bz) { + return (int32_t)((((int64_t)ax * bx) + ((int64_t)ay * by) + ((int64_t)az * bz)) >> FRAC_BITS); +} + +// Cross product components (each result is 20.12) +static inline void cross3(int32_t ax, int32_t ay, int32_t az, + int32_t bx, int32_t by, int32_t bz, + int32_t& rx, int32_t& ry, int32_t& rz) { + rx = (int32_t)(((int64_t)ay * bz - (int64_t)az * by) >> FRAC_BITS); + ry = (int32_t)(((int64_t)az * bx - (int64_t)ax * bz) >> FRAC_BITS); + rz = (int32_t)(((int64_t)ax * by - (int64_t)ay * bx) >> FRAC_BITS); +} + +// Square root approximation via Newton's method (for 20.12 input) +static int32_t fpsqrt(int32_t x) { + if (x <= 0) return 0; + // Initial guess: shift right by 6 (half of 12 fractional bits, then adjust) + int32_t guess = x; + // Rough initial guess + if (x > FP_ONE * 16) guess = x >> 4; + else if (x > FP_ONE) guess = x >> 2; + else guess = FP_ONE; + + // Newton iterations: guess = (guess + x/guess) / 2 in fixed-point + for (int i = 0; i < 8; i++) { + if (guess == 0) return 0; + int32_t div = fpdiv(x, guess); + guess = (guess + div) >> 1; + } + return guess; +} + +// Length squared of vector (result in 20.12, but represents a squared quantity) +static inline int32_t lengthSq(int32_t x, int32_t y, int32_t z) { + return dot3(x, y, z, x, y, z); +} + +// Clamp value to [lo, hi] +static inline int32_t fpclamp(int32_t val, int32_t lo, int32_t hi) { + if (val < lo) return lo; + if (val > hi) return hi; + return val; +} + +// ============================================================================ +// Initialization +// ============================================================================ + +const uint8_t* WorldCollision::initializeFromData(const uint8_t* data) { + // Read header + const auto* hdr = reinterpret_cast(data); + m_header = *hdr; + data += sizeof(CollisionDataHeader); + + // Mesh headers + m_meshes = reinterpret_cast(data); + data += m_header.meshCount * sizeof(CollisionMeshHeader); + + // Triangles + m_triangles = reinterpret_cast(data); + data += m_header.triangleCount * sizeof(CollisionTri); + + // Spatial chunks (exterior only) + if (m_header.chunkGridW > 0 && m_header.chunkGridH > 0) { + m_chunks = reinterpret_cast(data); + data += m_header.chunkGridW * m_header.chunkGridH * sizeof(CollisionChunk); + } else { + m_chunks = nullptr; + } + + + return data; +} + +// ============================================================================ +// Broad phase: gather candidate meshes +// ============================================================================ + +int WorldCollision::gatherCandidateMeshes(int32_t posX, int32_t posZ, + uint8_t currentRoom, + uint16_t* outIndices, + int maxIndices) const { + int count = 0; + + if (m_chunks && m_header.chunkGridW > 0) { + // Exterior: spatial grid lookup + // dividing two 20.12 values gives integer grid coords directly + int cx = 0, cz = 0; + if (m_header.chunkSize > 0) { + cx = (posX - m_header.chunkOriginX) / m_header.chunkSize; + cz = (posZ - m_header.chunkOriginZ) / m_header.chunkSize; + } + + // Check 3x3 neighborhood for robustness + for (int dz = -1; dz <= 1 && count < maxIndices; dz++) { + for (int dx = -1; dx <= 1 && count < maxIndices; dx++) { + int gx = cx + dx; + int gz = cz + dz; + if (gx < 0 || gx >= m_header.chunkGridW || gz < 0 || gz >= m_header.chunkGridH) + continue; + + const auto& chunk = m_chunks[gz * m_header.chunkGridW + gx]; + for (int i = 0; i < chunk.meshCount && count < maxIndices; i++) { + uint16_t mi = chunk.firstMeshIndex + i; + if (mi < m_header.meshCount) { + // Deduplicate: check if already added + bool dup = false; + for (int k = 0; k < count; k++) { + if (outIndices[k] == mi) { dup = true; break; } + } + if (!dup) { + outIndices[count++] = mi; + } + } + } + } + } + } else { + // Interior: filter by room index, or test all if no room system + for (uint16_t i = 0; i < m_header.meshCount && count < maxIndices; i++) { + if (currentRoom == 0xFF || m_meshes[i].roomIndex == currentRoom || + m_meshes[i].roomIndex == 0xFF) { + outIndices[count++] = i; + } + } + } + + return count; +} + +// ============================================================================ +// AABB helpers +// ============================================================================ + +bool WorldCollision::aabbOverlap(int32_t aMinX, int32_t aMinY, int32_t aMinZ, + int32_t aMaxX, int32_t aMaxY, int32_t aMaxZ, + int32_t bMinX, int32_t bMinY, int32_t bMinZ, + int32_t bMaxX, int32_t bMaxY, int32_t bMaxZ) { + return aMinX <= bMaxX && aMaxX >= bMinX && + aMinY <= bMaxY && aMaxY >= bMinY && + aMinZ <= bMaxZ && aMaxZ >= bMinZ; +} + +void WorldCollision::sphereToAABB(int32_t cx, int32_t cy, int32_t cz, int32_t r, + int32_t& minX, int32_t& minY, int32_t& minZ, + int32_t& maxX, int32_t& maxY, int32_t& maxZ) { + minX = cx - r; minY = cy - r; minZ = cz - r; + maxX = cx + r; maxY = cy + r; maxZ = cz + r; +} + +// ============================================================================ +// Sphere vs Triangle (closest point approach) +// ============================================================================ + +int32_t WorldCollision::sphereVsTriangle(int32_t cx, int32_t cy, int32_t cz, + int32_t radius, + const CollisionTri& tri, + int32_t& outNx, int32_t& outNy, int32_t& outNz) const { + // Compute vector from v0 to sphere center + int32_t px = cx - tri.v0x; + int32_t py = cy - tri.v0y; + int32_t pz = cz - tri.v0z; + + // Project onto triangle plane using precomputed normal + int32_t dist = dot3(px, py, pz, tri.nx, tri.ny, tri.nz); + + // Quick reject if too far from plane + int32_t absDist = dist >= 0 ? dist : -dist; + if (absDist > radius) return 0; + + // Find closest point on triangle to sphere center + // Use barycentric coordinates via edge projections + + // Precompute edge dot products for barycentric coords + int32_t d00 = dot3(tri.e1x, tri.e1y, tri.e1z, tri.e1x, tri.e1y, tri.e1z); + int32_t d01 = dot3(tri.e1x, tri.e1y, tri.e1z, tri.e2x, tri.e2y, tri.e2z); + int32_t d11 = dot3(tri.e2x, tri.e2y, tri.e2z, tri.e2x, tri.e2y, tri.e2z); + int32_t d20 = dot3(px, py, pz, tri.e1x, tri.e1y, tri.e1z); + int32_t d21 = dot3(px, py, pz, tri.e2x, tri.e2y, tri.e2z); + + // Barycentric denom using fpmul (stays in 32-bit) + int32_t denom = fpmul(d00, d11) - fpmul(d01, d01); + if (denom == 0) return 0; // Degenerate triangle + + // Barycentric numerators (32-bit via fpmul) + int32_t uNum = fpmul(d11, d20) - fpmul(d01, d21); + int32_t vNum = fpmul(d00, d21) - fpmul(d01, d20); + + // u, v in 20.12 using 32-bit division only + int32_t u = fpdiv(uNum, denom); + int32_t v = fpdiv(vNum, denom); + + // Clamp to triangle + int32_t w = FP_ONE - u - v; + + int32_t closestX, closestY, closestZ; + + if (u >= 0 && v >= 0 && w >= 0) { + // Point is inside triangle — closest point is the plane projection + closestX = cx - fpmul(dist, tri.nx); + closestY = cy - fpmul(dist, tri.ny); + closestZ = cz - fpmul(dist, tri.nz); + } else { + // Point is outside triangle — find closest point on edges/vertices + // Check all 3 edges and pick the closest point + + // v1 = v0 + e1, v2 = v0 + e2 + int32_t v1x = tri.v0x + tri.e1x; + int32_t v1y = tri.v0y + tri.e1y; + int32_t v1z = tri.v0z + tri.e1z; + int32_t v2x = tri.v0x + tri.e2x; + int32_t v2y = tri.v0y + tri.e2y; + int32_t v2z = tri.v0z + tri.e2z; + + int32_t bestDistSq = 0x7FFFFFFF; + closestX = tri.v0x; + closestY = tri.v0y; + closestZ = tri.v0z; + + // Helper lambda: closest point on segment [A, B] to point P + auto closestOnSeg = [&](int32_t ax, int32_t ay, int32_t az, + int32_t bx, int32_t by, int32_t bz, + int32_t& ox, int32_t& oy, int32_t& oz) { + int32_t abx = bx - ax, aby = by - ay, abz = bz - az; + int32_t apx = cx - ax, apy = cy - ay, apz = cz - az; + int32_t abLen = dot3(abx, aby, abz, abx, aby, abz); + if (abLen == 0) { ox = ax; oy = ay; oz = az; return; } + int32_t dotAP = dot3(apx, apy, apz, abx, aby, abz); + int32_t t = fpclamp(fpdiv(dotAP, abLen), 0, FP_ONE); + ox = ax + fpmul(t, abx); + oy = ay + fpmul(t, aby); + oz = az + fpmul(t, abz); + }; + + // Edge v0→v1 + int32_t ex, ey, ez; + closestOnSeg(tri.v0x, tri.v0y, tri.v0z, v1x, v1y, v1z, ex, ey, ez); + int32_t dx = cx - ex, dy = cy - ey, dz = cz - ez; + int32_t dsq = lengthSq(dx, dy, dz); + if (dsq < bestDistSq) { bestDistSq = dsq; closestX = ex; closestY = ey; closestZ = ez; } + + // Edge v0→v2 + closestOnSeg(tri.v0x, tri.v0y, tri.v0z, v2x, v2y, v2z, ex, ey, ez); + dx = cx - ex; dy = cy - ey; dz = cz - ez; + dsq = lengthSq(dx, dy, dz); + if (dsq < bestDistSq) { bestDistSq = dsq; closestX = ex; closestY = ey; closestZ = ez; } + + // Edge v1→v2 + closestOnSeg(v1x, v1y, v1z, v2x, v2y, v2z, ex, ey, ez); + dx = cx - ex; dy = cy - ey; dz = cz - ez; + dsq = lengthSq(dx, dy, dz); + if (dsq < bestDistSq) { bestDistSq = dsq; closestX = ex; closestY = ey; closestZ = ez; } + } + + // Compute vector from closest point to sphere center + int32_t nx = cx - closestX; + int32_t ny = cy - closestY; + int32_t nz = cz - closestZ; + + // Use 64-bit for distance-squared comparison to avoid 20.12 underflow. + // With small radii (e.g. radius=12 for 0.3m at GTE100), fpmul(12,12)=0 + // because 144>>12=0. This caused ALL collisions to silently fail. + // Both sides are in the same raw scale (no shift needed for comparison). + int64_t rawDistSq = (int64_t)nx * nx + (int64_t)ny * ny + (int64_t)nz * nz; + int64_t rawRadSq = (int64_t)radius * radius; + + if (rawDistSq >= rawRadSq || rawDistSq == 0) return 0; + + // For the actual distance value, use fpsqrt on the 20.12 representation. + // If the 20.12 value underflows to 0, estimate from 64-bit. + int32_t distSq32 = (int32_t)(rawDistSq >> FRAC_BITS); + int32_t distance; + if (distSq32 > 0) { + distance = fpsqrt(distSq32); + } else { + // Very close collision - distance is sub-unit in 20.12. + // Use triangle normal as push direction, penetration = radius. + outNx = tri.nx; + outNy = tri.ny; + outNz = tri.nz; + return radius; + } + + if (distance == 0) { + outNx = tri.nx; + outNy = tri.ny; + outNz = tri.nz; + return radius; + } + + // Normalize push direction using 32-bit division only + outNx = fpdiv(nx, distance); + outNy = fpdiv(ny, distance); + outNz = fpdiv(nz, distance); + + return radius - distance; // Penetration depth +} + +// ============================================================================ +// Ray vs Triangle (Möller-Trumbore, fixed-point) +// ============================================================================ + +int32_t WorldCollision::rayVsTriangle(int32_t ox, int32_t oy, int32_t oz, + int32_t dx, int32_t dy, int32_t dz, + const CollisionTri& tri) const { + // h = cross(D, e2) + int32_t hx, hy, hz; + cross3(dx, dy, dz, tri.e2x, tri.e2y, tri.e2z, hx, hy, hz); + + // a = dot(e1, h) + int32_t a = dot3(tri.e1x, tri.e1y, tri.e1z, hx, hy, hz); + if (a > -COLLISION_EPSILON && a < COLLISION_EPSILON) + return -1; // Ray parallel to triangle + + // f = 1/a — we'll defer the division by working with a as denominator + // s = O - v0 + int32_t sx = ox - tri.v0x; + int32_t sy = oy - tri.v0y; + int32_t sz = oz - tri.v0z; + + // u = f * dot(s, h) = dot(s, h) / a + int32_t sh = dot3(sx, sy, sz, hx, hy, hz); + // Check u in [0, 1]: sh/a must be in [0, a] if a > 0, or [a, 0] if a < 0 + if (a > 0) { + if (sh < 0 || sh > a) return -1; + } else { + if (sh > 0 || sh < a) return -1; + } + + // q = cross(s, e1) + int32_t qx, qy, qz; + cross3(sx, sy, sz, tri.e1x, tri.e1y, tri.e1z, qx, qy, qz); + + // v = f * dot(D, q) = dot(D, q) / a + int32_t dq = dot3(dx, dy, dz, qx, qy, qz); + if (a > 0) { + if (dq < 0 || sh + dq > a) return -1; + } else { + if (dq > 0 || sh + dq < a) return -1; + } + + // t = f * dot(e2, q) = dot(e2, q) / a + int32_t eq = dot3(tri.e2x, tri.e2y, tri.e2z, qx, qy, qz); + + // t in 20.12 using 32-bit division only + int32_t t = fpdiv(eq, a); + + if (t < COLLISION_EPSILON) return -1; // Behind ray origin + + return t; +} + +// ============================================================================ +// High-level: moveAndSlide +// ============================================================================ + +psyqo::Vec3 WorldCollision::moveAndSlide(const psyqo::Vec3& oldPos, + const psyqo::Vec3& newPos, + int32_t radius, + uint8_t currentRoom) const { + if (!isLoaded()) return newPos; + + int32_t posX = newPos.x.raw(); + int32_t posY = newPos.y.raw(); + int32_t posZ = newPos.z.raw(); + + // Gather candidate meshes + uint16_t meshIndices[32]; + int meshCount = gatherCandidateMeshes(posX, posZ, currentRoom, meshIndices, 32); + + // Sphere AABB for broad phase + int32_t sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ; + sphereToAABB(posX, posY, posZ, radius + COLLISION_EPSILON, + sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ); + + int triTests = 0; + int totalCollisions = 0; + + + + for (int iter = 0; iter < MAX_COLLISION_ITERATIONS; iter++) { + bool collided = false; + + for (int mi = 0; mi < meshCount && triTests < MAX_TRI_TESTS_PER_FRAME; mi++) { + const auto& mesh = m_meshes[meshIndices[mi]]; + + // Broad phase: sphere AABB vs mesh AABB + if (!aabbOverlap(sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ, + mesh.aabbMinX, mesh.aabbMinY, mesh.aabbMinZ, + mesh.aabbMaxX, mesh.aabbMaxY, mesh.aabbMaxZ)) { + + continue; + } + + for (int ti = 0; ti < mesh.triangleCount && triTests < MAX_TRI_TESTS_PER_FRAME; ti++) { + const auto& tri = m_triangles[mesh.firstTriangle + ti]; + triTests++; + + // Skip trigger surfaces + if (tri.flags & SURFACE_TRIGGER) continue; + + // Skip floor and ceiling triangles — Y is resolved by nav regions. + // In PS1 space (Y-down): floor normals have ny < 0, ceiling ny > 0. + // If |ny| > walkable slope threshold, it's a floor/ceiling, not a wall. + int32_t absNy = tri.ny >= 0 ? tri.ny : -tri.ny; + if (absNy > WALKABLE_SLOPE_COS) continue; + + int32_t nx, ny, nz; + int32_t pen = sphereVsTriangle(posX, posY, posZ, radius, tri, nx, ny, nz); + + if (pen > 0) { + totalCollisions++; + // Push out along normal + posX += fpmul(pen + COLLISION_EPSILON, nx); + posY += fpmul(pen + COLLISION_EPSILON, ny); + posZ += fpmul(pen + COLLISION_EPSILON, nz); + + // Update sphere AABB + sphereToAABB(posX, posY, posZ, radius + COLLISION_EPSILON, + sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ); + collided = true; + } + } + } + + if (!collided) break; + } + + psyqo::Vec3 result; + result.x.value = posX; + result.y.value = posY; + result.z.value = posZ; + return result; +} + +// ============================================================================ +// High-level: groundTrace +// ============================================================================ + +bool WorldCollision::groundTrace(const psyqo::Vec3& pos, + int32_t maxDist, + int32_t& groundY, + int32_t& groundNormalY, + uint8_t& surfaceFlags, + uint8_t currentRoom) const { + if (!isLoaded()) return false; + + int32_t ox = pos.x.raw(); + int32_t oy = pos.y.raw(); + int32_t oz = pos.z.raw(); + + // Ray direction: straight down (positive Y in PS1 space = down) + int32_t dx = 0, dy = FP_ONE, dz = 0; + + uint16_t meshIndices[32]; + int meshCount = gatherCandidateMeshes(ox, oz, currentRoom, meshIndices, 32); + + int32_t bestDist = maxDist; + bool hit = false; + + for (int mi = 0; mi < meshCount; mi++) { + const auto& mesh = m_meshes[meshIndices[mi]]; + + // Quick reject: check if mesh is below us + if (mesh.aabbMinY > oy + maxDist) continue; + if (mesh.aabbMaxY < oy) continue; + if (ox < mesh.aabbMinX || ox > mesh.aabbMaxX) continue; + if (oz < mesh.aabbMinZ || oz > mesh.aabbMaxZ) continue; + + for (int ti = 0; ti < mesh.triangleCount; ti++) { + const auto& tri = m_triangles[mesh.firstTriangle + ti]; + if (tri.flags & SURFACE_TRIGGER) continue; + + int32_t t = rayVsTriangle(ox, oy, oz, dx, dy, dz, tri); + if (t >= 0 && t < bestDist) { + bestDist = t; + groundY = oy + t; // Hit point Y + groundNormalY = tri.ny; + surfaceFlags = tri.flags; + hit = true; + } + } + } + + return hit; +} + +// ============================================================================ +// High-level: ceilingTrace +// ============================================================================ + +bool WorldCollision::ceilingTrace(const psyqo::Vec3& pos, + int32_t playerHeight, + int32_t& ceilingY, + uint8_t currentRoom) const { + if (!isLoaded()) return false; + + int32_t ox = pos.x.raw(); + int32_t oy = pos.y.raw(); + int32_t oz = pos.z.raw(); + + // Ray direction: straight up (negative Y in PS1 space) + int32_t dx = 0, dy = -FP_ONE, dz = 0; + + uint16_t meshIndices[32]; + int meshCount = gatherCandidateMeshes(ox, oz, currentRoom, meshIndices, 32); + + int32_t bestDist = playerHeight; + bool hit = false; + + for (int mi = 0; mi < meshCount; mi++) { + const auto& mesh = m_meshes[meshIndices[mi]]; + + if (mesh.aabbMaxY > oy) continue; + if (mesh.aabbMinY < oy - playerHeight) continue; + if (ox < mesh.aabbMinX || ox > mesh.aabbMaxX) continue; + if (oz < mesh.aabbMinZ || oz > mesh.aabbMaxZ) continue; + + for (int ti = 0; ti < mesh.triangleCount; ti++) { + const auto& tri = m_triangles[mesh.firstTriangle + ti]; + if (tri.flags & SURFACE_TRIGGER) continue; + + int32_t t = rayVsTriangle(ox, oy, oz, dx, dy, dz, tri); + if (t >= 0 && t < bestDist) { + bestDist = t; + ceilingY = oy - t; + hit = true; + } + } + } + + return hit; +} + +// ============================================================================ +// High-level: raycast (general purpose) +// ============================================================================ + +bool WorldCollision::raycast(int32_t ox, int32_t oy, int32_t oz, + int32_t dx, int32_t dy, int32_t dz, + int32_t maxDist, + CollisionHit& hit, + uint8_t currentRoom) const { + if (!isLoaded()) return false; + + uint16_t meshIndices[32]; + int meshCount = gatherCandidateMeshes(ox, oz, currentRoom, meshIndices, 32); + + int32_t bestDist = maxDist; + bool found = false; + + for (int mi = 0; mi < meshCount; mi++) { + const auto& mesh = m_meshes[meshIndices[mi]]; + + for (uint16_t ti = 0; ti < mesh.triangleCount; ti++) { + uint16_t triIdx = mesh.firstTriangle + ti; + const auto& tri = m_triangles[triIdx]; + + int32_t t = rayVsTriangle(ox, oy, oz, dx, dy, dz, tri); + if (t >= 0 && t < bestDist) { + bestDist = t; + hit.pointX = ox + fpmul(t, dx); + hit.pointY = oy + fpmul(t, dy); + hit.pointZ = oz + fpmul(t, dz); + hit.normalX = tri.nx; + hit.normalY = tri.ny; + hit.normalZ = tri.nz; + hit.distance = t; + hit.triangleIndex = triIdx; + hit.surfaceFlags = tri.flags; + found = true; + } + } + } + + return found; +} + +} // namespace psxsplash diff --git a/src/worldcollision.hh b/src/worldcollision.hh new file mode 100644 index 0000000..9efe224 --- /dev/null +++ b/src/worldcollision.hh @@ -0,0 +1,222 @@ +#pragma once + +/** + * worldcollision.hh - Player-vs-World Triangle Collision + * + * Architecture: + * 1. Broad phase: per-mesh AABB reject, then spatial grid (exterior) or + * room membership (interior) to narrow candidate meshes. + * 2. Narrow phase: per-triangle capsule-vs-triangle sweep. + * 3. Response: sliding projection along collision plane. + * + * All math is fixed-point 20.12. Zero floats. Deterministic at any framerate. + */ + +#include +#include +#include + +namespace psxsplash { + +// ============================================================================ +// Surface flags — packed per-triangle, exported from SplashEdit +// ============================================================================ +enum SurfaceFlag : uint8_t { + SURFACE_SOLID = 0x01, // Normal solid wall/floor + SURFACE_SLOPE = 0x02, // Steep slope (treated as wall for movement) + SURFACE_STAIRS = 0x04, // Staircase (smooth Y interpolation) + SURFACE_TRIGGER = 0x08, // Non-solid trigger volume + SURFACE_NO_WALK = 0x10, // Marks geometry as non-walkable floor +}; + +// ============================================================================ +// Collision triangle — world-space, pre-transformed, contiguous in memory +// 40 bytes each — v0(12) + v1(12) + v2(12) + normal(12) omitted to save +// Actually: 40 bytes = v0(12) + edge1(12) + edge2(12) + flags(1) + pad(3) +// We store edges for Moller-Trumbore intersection +// ============================================================================ +struct CollisionTri { + // Vertex 0 (world-space 20.12 fixed-point) + int32_t v0x, v0y, v0z; // 12 bytes + // Edge1 = v1 - v0 + int32_t e1x, e1y, e1z; // 12 bytes + // Edge2 = v2 - v0 + int32_t e2x, e2y, e2z; // 12 bytes + // Precomputed face normal (unit-ish, 20.12) + int32_t nx, ny, nz; // 12 bytes + // Surface properties + uint8_t flags; // SurfaceFlag bitmask + uint8_t roomIndex; // Room/chunk this tri belongs to (0xFF = none) + uint16_t pad; // Alignment +}; +static_assert(sizeof(CollisionTri) == 52, "CollisionTri must be 52 bytes"); + +// ============================================================================ +// Collision mesh header — one per collision mesh in the splashpack +// The triangles themselves follow contiguously after all headers. +// ============================================================================ +struct CollisionMeshHeader { + // World-space AABB for broad-phase rejection (20.12 fixed-point) + int32_t aabbMinX, aabbMinY, aabbMinZ; // 12 bytes + int32_t aabbMaxX, aabbMaxY, aabbMaxZ; // 12 bytes + // Offset into the collision triangle array + uint16_t firstTriangle; // Index of first CollisionTri + uint16_t triangleCount; // Number of triangles + // Room/chunk association + uint8_t roomIndex; // Interior room index (0xFF = exterior) + uint8_t pad[3]; +}; +static_assert(sizeof(CollisionMeshHeader) == 32, "CollisionMeshHeader must be 32 bytes"); + +// ============================================================================ +// Spatial chunk for exterior scenes — 2D grid over XZ +// ============================================================================ +struct CollisionChunk { + uint16_t firstMeshIndex; // Index into CollisionMeshHeader array + uint16_t meshCount; // Number of meshes in this chunk +}; +static_assert(sizeof(CollisionChunk) == 4, "CollisionChunk must be 4 bytes"); + +// ============================================================================ +// Collision data header — describes the entire collision dataset +// ============================================================================ +struct CollisionDataHeader { + uint16_t meshCount; // Number of CollisionMeshHeader entries + uint16_t triangleCount; // Total CollisionTri entries + uint16_t chunkGridW; // Spatial grid width (0 if interior) + uint16_t chunkGridH; // Spatial grid height (0 if interior) + int32_t chunkOriginX; // Grid origin X (20.12) + int32_t chunkOriginZ; // Grid origin Z (20.12) + int32_t chunkSize; // Cell size (20.12) + // Total: 20 bytes + // Followed by: meshCount * CollisionMeshHeader + // triangleCount * CollisionTri + // chunkGridW * chunkGridH * CollisionChunk (if exterior) +}; +static_assert(sizeof(CollisionDataHeader) == 20, "CollisionDataHeader must be 20 bytes"); + +// ============================================================================ +// Hit result from collision queries +// ============================================================================ +struct CollisionHit { + int32_t pointX, pointY, pointZ; // Hit point (20.12) + int32_t normalX, normalY, normalZ; // Hit normal (20.12) + int32_t distance; // Distance along ray (20.12) + uint16_t triangleIndex; // Which triangle was hit + uint8_t surfaceFlags; // SurfaceFlag of hit triangle + uint8_t pad; +}; + +// ============================================================================ +// Maximum slope angle for walkable surfaces +// cos(46°) ≈ 0.6947 → in 20.12 fixed-point = 2845 +// Surfaces with normal.y < this are treated as walls +// ============================================================================ +static constexpr int32_t WALKABLE_SLOPE_COS = 2845; // cos(46°) in 20.12 + +// Player collision capsule radius (20.12 fixed-point) +// ~0.5 world units at GTEScaling=100 → 0.005 GTE units → 20 in 20.12 +static constexpr int32_t PLAYER_RADIUS = 20; + +// Small epsilon for collision (20.12) +// ≈ 0.01 GTE units +static constexpr int32_t COLLISION_EPSILON = 41; + +// Maximum number of collision iterations per frame +static constexpr int MAX_COLLISION_ITERATIONS = 8; + +// Maximum triangles to test per frame (budget) +static constexpr int MAX_TRI_TESTS_PER_FRAME = 256; + +// ============================================================================ +// WorldCollision — main collision query interface +// Loaded from splashpack data, used by SceneManager every frame +// ============================================================================ +class WorldCollision { +public: + WorldCollision() = default; + + /// Initialize from splashpack data. Returns pointer past the data. + const uint8_t* initializeFromData(const uint8_t* data); + + /// Is collision data loaded? + bool isLoaded() const { return m_triangles != nullptr; } + + // ======================================================================== + // High-level queries used by the player movement system + // ======================================================================== + + /// Move a sphere from oldPos to newPos, sliding against world geometry. + /// Returns the final valid position after collision response. + /// radius is in 20.12 fixed-point. + psyqo::Vec3 moveAndSlide(const psyqo::Vec3& oldPos, + const psyqo::Vec3& newPos, + int32_t radius, + uint8_t currentRoom) const; + + /// Cast a ray downward from pos to find the ground. + /// Returns true if ground found within maxDist. + /// groundY and groundNormal are filled on hit. + bool groundTrace(const psyqo::Vec3& pos, + int32_t maxDist, + int32_t& groundY, + int32_t& groundNormalY, + uint8_t& surfaceFlags, + uint8_t currentRoom) const; + + /// Cast a ray upward to detect ceilings. + bool ceilingTrace(const psyqo::Vec3& pos, + int32_t playerHeight, + int32_t& ceilingY, + uint8_t currentRoom) const; + + /// Raycast against collision geometry. Returns true on hit. + bool raycast(int32_t ox, int32_t oy, int32_t oz, + int32_t dx, int32_t dy, int32_t dz, + int32_t maxDist, + CollisionHit& hit, + uint8_t currentRoom) const; + + /// Get mesh count for debugging + uint16_t getMeshCount() const { return m_header.meshCount; } + uint16_t getTriangleCount() const { return m_header.triangleCount; } + +private: + CollisionDataHeader m_header = {}; + const CollisionMeshHeader* m_meshes = nullptr; + const CollisionTri* m_triangles = nullptr; + const CollisionChunk* m_chunks = nullptr; // Only for exterior scenes + + /// Collect candidate mesh indices near a position. + /// For exterior: uses spatial grid. For interior: uses roomIndex. + int gatherCandidateMeshes(int32_t posX, int32_t posZ, + uint8_t currentRoom, + uint16_t* outIndices, + int maxIndices) const; + + /// Test a sphere against a single triangle. Returns penetration depth (>0 if colliding). + /// On collision, fills outNormal with the push-out direction. + int32_t sphereVsTriangle(int32_t cx, int32_t cy, int32_t cz, + int32_t radius, + const CollisionTri& tri, + int32_t& outNx, int32_t& outNy, int32_t& outNz) const; + + /// Ray vs triangle (Moller-Trumbore in fixed-point). + /// Returns distance along ray (20.12), or -1 if no hit. + int32_t rayVsTriangle(int32_t ox, int32_t oy, int32_t oz, + int32_t dx, int32_t dy, int32_t dz, + const CollisionTri& tri) const; + + /// AABB vs AABB test + static bool aabbOverlap(int32_t aMinX, int32_t aMinY, int32_t aMinZ, + int32_t aMaxX, int32_t aMaxY, int32_t aMaxZ, + int32_t bMinX, int32_t bMinY, int32_t bMinZ, + int32_t bMaxX, int32_t bMaxY, int32_t bMaxZ); + + /// Expand a point to an AABB with radius + static void sphereToAABB(int32_t cx, int32_t cy, int32_t cz, int32_t r, + int32_t& minX, int32_t& minY, int32_t& minZ, + int32_t& maxX, int32_t& maxY, int32_t& maxZ); +}; + +} // namespace psxsplash diff --git a/test_literal.cpp b/test_literal.cpp new file mode 100644 index 0000000..0a89308 Binary files /dev/null and b/test_literal.cpp differ diff --git a/third_party/nugget b/third_party/nugget index 7ed81a1..338ec49 160000 --- a/third_party/nugget +++ b/third_party/nugget @@ -1 +1 @@ -Subproject commit 7ed81a19ce8221cd42c0d45d1b737953088bd147 +Subproject commit 338ec49a574d0eb0a4218795bf526dcf2b43ecda