From 2ca3fc342533b7dddcec6332aa51f5c42f8e7384 Mon Sep 17 00:00:00 2001 From: elliotttate Date: Sun, 1 Feb 2026 13:22:51 -0500 Subject: [PATCH 1/2] Add remastered OGV cutscene support Play Nightdive remaster's Ogg Theora video cutscenes when available, falling back to original LFD/FILM cutscenes otherwise. Uses libtheora for video decoding, libvorbis for audio, and a YUV->RGB GPU shader for rendering. Includes SRT subtitle support, auto-detection of remaster install paths, and a settings toggle. Gated behind ENABLE_OGV_CUTSCENES compile flag. --- CMakeLists.txt | 14 +- TheForceEngine/Shaders/yuv2rgb.frag | 24 + TheForceEngine/Shaders/yuv2rgb.vert | 11 + TheForceEngine/TFE_Audio/audioSystem.cpp | 19 +- TheForceEngine/TFE_Audio/audioSystem.h | 4 + .../TFE_DarkForces/Landru/cutscene.cpp | 143 +++- .../TFE_DarkForces/Remaster/ogvPlayer.cpp | 725 ++++++++++++++++++ .../TFE_DarkForces/Remaster/ogvPlayer.h | 23 + .../Remaster/remasterCutscenes.cpp | 229 ++++++ .../Remaster/remasterCutscenes.h | 19 + .../TFE_DarkForces/Remaster/srtParser.cpp | 171 +++++ .../TFE_DarkForces/Remaster/srtParser.h | 21 + TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp | 9 + .../Win32OpenGL/renderBackend.cpp | 19 +- .../TFE_RenderBackend/renderBackend.h | 2 + TheForceEngine/TFE_Settings/settings.cpp | 17 +- TheForceEngine/TFE_Settings/settings.h | 4 + TheForceEngine/TheForceEngine.vcxproj | 38 +- 18 files changed, 1468 insertions(+), 24 deletions(-) create mode 100644 TheForceEngine/Shaders/yuv2rgb.frag create mode 100644 TheForceEngine/Shaders/yuv2rgb.vert create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/srtParser.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c04cb848a..dede0182b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,7 +53,8 @@ include(GNUInstallDirs) option(ENABLE_TFE "Enable building “The Force Engine”" ON) option(ENABLE_SYSMIDI "Enable System-MIDI Output if RTMidi is available" ON) option(ENABLE_EDITOR "Enable TFE Editor" OFF) -option(ENABLE_ADJUSTABLEHUD_MOD "Install the build‑in “AdjustableHud mod” with TFE" ON) +option(ENABLE_OGV_CUTSCENES "Enable OGV (Ogg Theora) video cutscene support" OFF) +option(ENABLE_ADJUSTABLEHUD_MOD "Install the build‑in "AdjustableHud mod" with TFE" ON) if(ENABLE_TFE) add_executable(tfe) @@ -119,6 +120,17 @@ if(ENABLE_TFE) if(ENABLE_EDITOR) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DBUILD_EDITOR") endif() + if(ENABLE_OGV_CUTSCENES) + if(UNIX) + pkg_check_modules(THEORA REQUIRED theoradec) + pkg_check_modules(OGG REQUIRED ogg) + pkg_check_modules(VORBIS REQUIRED vorbis vorbisfile) + target_include_directories(tfe PRIVATE ${THEORA_INCLUDE_DIRS} ${OGG_INCLUDE_DIRS} ${VORBIS_INCLUDE_DIRS}) + target_link_libraries(tfe PRIVATE ${THEORA_LIBRARIES} ${OGG_LIBRARIES} ${VORBIS_LIBRARIES}) + target_link_directories(tfe PRIVATE ${THEORA_LIBRARY_DIRS} ${OGG_LIBRARY_DIRS} ${VORBIS_LIBRARY_DIRS}) + endif() + add_definitions("-DENABLE_OGV_CUTSCENES") + endif() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DBUILD_FORCE_SCRIPT") diff --git a/TheForceEngine/Shaders/yuv2rgb.frag b/TheForceEngine/Shaders/yuv2rgb.frag new file mode 100644 index 000000000..776e62799 --- /dev/null +++ b/TheForceEngine/Shaders/yuv2rgb.frag @@ -0,0 +1,24 @@ +// BT.601 YCbCr -> RGB for Theora video frames. +uniform sampler2D TexY; +uniform sampler2D TexCb; +uniform sampler2D TexCr; + +in vec2 Frag_UV; +out vec4 Out_Color; + +void main() +{ + float y = texture(TexY, Frag_UV).r; + float cb = texture(TexCb, Frag_UV).r - 0.5; + float cr = texture(TexCr, Frag_UV).r - 0.5; + + // Rescale Y from studio range [16..235] to [0..1]. + y = (y - 16.0 / 255.0) * (255.0 / 219.0); + + vec3 rgb; + rgb.r = y + 1.596 * cr; + rgb.g = y - 0.391 * cb - 0.813 * cr; + rgb.b = y + 2.018 * cb; + + Out_Color = vec4(clamp(rgb, 0.0, 1.0), 1.0); +} diff --git a/TheForceEngine/Shaders/yuv2rgb.vert b/TheForceEngine/Shaders/yuv2rgb.vert new file mode 100644 index 000000000..fcb94c954 --- /dev/null +++ b/TheForceEngine/Shaders/yuv2rgb.vert @@ -0,0 +1,11 @@ +uniform vec4 ScaleOffset; +in vec2 vtx_pos; +in vec2 vtx_uv; + +out vec2 Frag_UV; + +void main() +{ + Frag_UV = vec2(vtx_uv.x, 1.0 - vtx_uv.y); + gl_Position = vec4(vtx_pos.xy * ScaleOffset.xy + ScaleOffset.zw, 0, 1); +} diff --git a/TheForceEngine/TFE_Audio/audioSystem.cpp b/TheForceEngine/TFE_Audio/audioSystem.cpp index cb16040c4..1a31c8895 100644 --- a/TheForceEngine/TFE_Audio/audioSystem.cpp +++ b/TheForceEngine/TFE_Audio/audioSystem.cpp @@ -74,6 +74,7 @@ namespace TFE_Audio static AudioUpsampleFilter s_upsampleFilter = AUF_DEFAULT; static AudioThreadCallback s_audioThreadCallback = nullptr; + static AudioDirectCallback s_directCallback = nullptr; static void audioCallback(void*, unsigned char*, int); void setSoundVolumeConsole(const ConsoleArgList& args); @@ -227,6 +228,15 @@ namespace TFE_Audio SDL_UnlockMutex(s_mutex); } + void setDirectCallback(AudioDirectCallback callback) + { + if (s_nullDevice) { return; } + + SDL_LockMutex(s_mutex); + s_directCallback = callback; + SDL_UnlockMutex(s_mutex); + } + const OutputDeviceInfo* getOutputDeviceList(s32& count, s32& curOutput) { return TFE_AudioDevice::getOutputDeviceList(count, curOutput); @@ -467,9 +477,9 @@ namespace TFE_Audio // First clear samples memset(buffer, 0, bufferSize); - + SDL_LockMutex(s_mutex); - // Then call the audio thread callback + // Call the audio thread callback (iMuse/game audio). if (s_audioThreadCallback && !s_paused) { static f32 callbackBuffer[(AUDIO_CALLBACK_BUFFER_SIZE + 2)*AUDIO_CHANNEL_COUNT]; // 256 stereo + oversampling. @@ -488,6 +498,11 @@ namespace TFE_Audio } } } + // Direct callback adds at the full output rate (e.g. OGV video audio), mixing on top. + if (s_directCallback && !s_paused) + { + s_directCallback(buffer, frames, s_soundFxVolume * c_soundHeadroom); + } // Then loop through the sources. // Note: this is no longer used by Dark Forces. However I decided to keep direct sound support around diff --git a/TheForceEngine/TFE_Audio/audioSystem.h b/TheForceEngine/TFE_Audio/audioSystem.h index 0202b0fc7..2c55eee3f 100644 --- a/TheForceEngine/TFE_Audio/audioSystem.h +++ b/TheForceEngine/TFE_Audio/audioSystem.h @@ -47,6 +47,9 @@ enum SoundType typedef void(*SoundFinishedCallback)(void* userData, s32 arg); typedef void(*AudioThreadCallback)(f32* buffer, u32 bufferSize, f32 systemVolume); +// Direct callback writes stereo interleaved f32 at the full output rate (44100 Hz). +// frameCount = number of stereo frames to fill. +typedef void(*AudioDirectCallback)(f32* buffer, u32 frameCount, f32 systemVolume); namespace TFE_Audio { @@ -76,6 +79,7 @@ namespace TFE_Audio void bufferedAudioClear(); void setAudioThreadCallback(AudioThreadCallback callback = nullptr); + void setDirectCallback(AudioDirectCallback callback = nullptr); const OutputDeviceInfo* getOutputDeviceList(s32& count, s32& curOutput); // One shot, play and forget. Only do this if the client needs no control until stopAllSounds() is called. diff --git a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp index 25e5ac572..388dc6d03 100644 --- a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp +++ b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp @@ -11,6 +11,14 @@ #include #include #include +#ifdef ENABLE_OGV_CUTSCENES +#include +#include +#include +#include +#include +#include "lmusic.h" +#endif using namespace TFE_Jedi; @@ -23,12 +31,125 @@ namespace TFE_DarkForces s32 s_musicVolume = 0; s32 s_enabled = 1; +#ifdef ENABLE_OGV_CUTSCENES + static bool s_ogvPlaying = false; + static std::vector s_ogvSubtitles; +#endif + void cutscene_init(CutsceneState* cutsceneList) { s_playSeq = cutsceneList; s_playing = JFALSE; +#ifdef ENABLE_OGV_CUTSCENES + remasterCutscenes_init(); +#endif + } + +#ifdef ENABLE_OGV_CUTSCENES + // Look up a scene by ID in the cutscene list. + static CutsceneState* findScene(s32 sceneId) + { + if (!s_playSeq) { return nullptr; } + for (s32 i = 0; s_playSeq[i].id != SCENE_EXIT; i++) + { + if (s_playSeq[i].id == sceneId) + { + return &s_playSeq[i]; + } + } + return nullptr; + } + + // Try the remastered OGV version of a cutscene; returns false to fall back to LFD. + static bool tryPlayOgvCutscene(s32 sceneId) + { + TFE_Settings_Game* gameSettings = TFE_Settings::getGameSettings(); + if (!gameSettings->df_enableRemasterCutscenes) { return false; } + if (!remasterCutscenes_available()) { return false; } + + CutsceneState* scene = findScene(sceneId); + if (!scene) { return false; } + + const char* videoPath = remasterCutscenes_getVideoPath(scene); + if (!videoPath) { return false; } + + if (!TFE_OgvPlayer::open(videoPath)) + { + TFE_System::logWrite(LOG_WARNING, "Cutscene", "Failed to open OGV file: %s, falling back to LFD.", videoPath); + return false; + } + + // Load subtitles if captions are on. + s_ogvSubtitles.clear(); + if (TFE_A11Y::cutsceneCaptionsEnabled()) + { + const char* srtPath = remasterCutscenes_getSubtitlePath(scene); + if (srtPath) + { + srt_loadFromFile(srtPath, s_ogvSubtitles); + } + } + + // Start the MIDI music track for this cutscene. + if (scene->music > 0) + { + lmusic_setSequence(scene->music); + } + + s_ogvPlaying = true; + TFE_System::logWrite(LOG_MSG, "Cutscene", "Playing remastered OGV cutscene for scene %d (%s).", sceneId, scene->archive); + return true; } + static JBool ogvCutscene_update() + { + // Skip on ESC/Enter/Space (ignore Alt+Enter which toggles fullscreen). + if (TFE_Input::keyPressed(KEY_ESCAPE) || + (TFE_Input::keyPressed(KEY_RETURN) && !TFE_Input::keyDown(KEY_LALT) && !TFE_Input::keyDown(KEY_RALT)) || + TFE_Input::keyPressed(KEY_SPACE)) + { + TFE_OgvPlayer::close(); + s_ogvPlaying = false; + s_ogvSubtitles.clear(); + TFE_A11Y::clearActiveCaptions(); + return JFALSE; + } + + if (!TFE_OgvPlayer::update()) + { + TFE_OgvPlayer::close(); + s_ogvPlaying = false; + s_ogvSubtitles.clear(); + TFE_A11Y::clearActiveCaptions(); + return JFALSE; + } + + // Update subtitle captions. + if (!s_ogvSubtitles.empty() && TFE_A11Y::cutsceneCaptionsEnabled()) + { + f64 time = TFE_OgvPlayer::getPlaybackTime(); + const SrtEntry* entry = srt_getActiveEntry(s_ogvSubtitles, time); + if (entry) + { + TFE_A11Y::Caption caption; + caption.text = entry->text; + caption.env = TFE_A11Y::CC_CUTSCENE; + caption.type = TFE_A11Y::CC_VOICE; + caption.microsecondsRemaining = (s64)((entry->endTime - time) * 1000000.0); + TFE_A11Y::clearActiveCaptions(); + TFE_A11Y::enqueueCaption(caption); + } + else + { + // No subtitle active right now. + TFE_A11Y::clearActiveCaptions(); + } + } + + return JTRUE; + } +#endif + JBool cutscene_play(s32 sceneId) { if (!s_enabled || !s_playSeq) { return JFALSE; } @@ -47,10 +168,20 @@ namespace TFE_DarkForces } } if (!found) return JFALSE; + +#ifdef ENABLE_OGV_CUTSCENES + // Prefer the remastered OGV if available. + if (tryPlayOgvCutscene(sceneId)) + { + s_playing = JTRUE; + return JTRUE; + } +#endif + // Re-initialize the canvas, so cutscenes run at the correct resolution even if it was changed for gameplay // (i.e. high resolution support). lcanvas_init(320, 200); - + // The original code then starts the cutscene loop here, and then returns when done. // Instead we set a bool and then the calling code will call 'update' until it returns false. s_playing = JTRUE; @@ -62,6 +193,14 @@ namespace TFE_DarkForces { if (!s_playing) { return JFALSE; } +#ifdef ENABLE_OGV_CUTSCENES + if (s_ogvPlaying) + { + s_playing = ogvCutscene_update(); + return s_playing; + } +#endif + s_playing = cutscenePlayer_update(); return s_playing; } @@ -95,4 +234,4 @@ namespace TFE_DarkForces { return s_musicVolume; } -} // TFE_DarkForces \ No newline at end of file +} // TFE_DarkForces diff --git a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp new file mode 100644 index 000000000..bb0c2a3d8 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp @@ -0,0 +1,725 @@ +#include "ogvPlayer.h" + +#ifdef ENABLE_OGV_CUTSCENES + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace TFE_OgvPlayer +{ + static const u32 AUDIO_BUFFER_SIZE = 32768; // per-channel ring buffer size + static const u32 OGG_BUFFER_SIZE = 4096; + + // Ogg / Theora / Vorbis state + static FILE* s_file = nullptr; + + static ogg_sync_state s_syncState; + static ogg_stream_state s_theoraStream; + static ogg_stream_state s_vorbisStream; + + static th_info s_theoraInfo; + static th_comment s_theoraComment; + static th_setup_info* s_theoraSetup = nullptr; + static th_dec_ctx* s_theoraDec = nullptr; + + static vorbis_info s_vorbisInfo; + static vorbis_comment s_vorbisComment; + static vorbis_dsp_state s_vorbisDsp; + static vorbis_block s_vorbisBlock; + + static bool s_hasTheora = false; + static bool s_hasVorbis = false; + static bool s_theoraStreamInited = false; + static bool s_vorbisStreamInited = false; + + // Playback state + static bool s_playing = false; + static bool s_initialized = false; + static f64 s_videoTime = 0.0; + static f64 s_audioTime = 0.0; + static f64 s_playbackStart = 0.0; + static bool s_firstFrame = true; + + // GPU resources + static Shader s_yuvShader; + static TextureGpu* s_texY = nullptr; + static TextureGpu* s_texCb = nullptr; + static TextureGpu* s_texCr = nullptr; + static VertexBuffer s_vertexBuffer; + static IndexBuffer s_indexBuffer; + static s32 s_scaleOffsetId = -1; + + // Audio ring buffer (stereo interleaved f32) + static f32* s_audioRingBuffer = nullptr; + static volatile u32 s_audioWritePos = 0; + static volatile u32 s_audioReadPos = 0; + static u32 s_audioRingSize = 0; + static f64 s_resampleAccum = 0.0; + + // Forward declarations + static bool readOggHeaders(); + static bool decodeVideoFrame(); + static void decodeAudioPackets(); + static bool demuxPage(ogg_page* page); + static bool bufferOggData(); + static void uploadYuvFrame(th_ycbcr_buffer ycbcr); + static void renderFrame(); + static void audioCallback(f32* buffer, u32 bufferSize, f32 systemVolume); + static void freeGpuResources(); + static void freeOggResources(); + + // Fullscreen quad vertex layout + struct QuadVertex + { + f32 x, y; + f32 u, v; + }; + + static const AttributeMapping c_quadAttrMapping[] = + { + {ATTR_POS, ATYPE_FLOAT, 2, 0, false}, + {ATTR_UV, ATYPE_FLOAT, 2, 0, false}, + }; + static const u32 c_quadAttrCount = TFE_ARRAYSIZE(c_quadAttrMapping); + + bool init() + { + if (s_initialized) { return true; } + + if (!s_yuvShader.load("Shaders/yuv2rgb.vert", "Shaders/yuv2rgb.frag")) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Failed to load YUV->RGB shader."); + return false; + } + s_yuvShader.bindTextureNameToSlot("TexY", 0); + s_yuvShader.bindTextureNameToSlot("TexCb", 1); + s_yuvShader.bindTextureNameToSlot("TexCr", 2); + s_scaleOffsetId = s_yuvShader.getVariableId("ScaleOffset"); + + const QuadVertex vertices[] = + { + {0.0f, 0.0f, 0.0f, 0.0f}, + {1.0f, 0.0f, 1.0f, 0.0f}, + {1.0f, 1.0f, 1.0f, 1.0f}, + {0.0f, 1.0f, 0.0f, 1.0f}, + }; + const u16 indices[] = { 0, 1, 2, 0, 2, 3 }; + s_vertexBuffer.create(4, sizeof(QuadVertex), c_quadAttrCount, c_quadAttrMapping, false, (void*)vertices); + s_indexBuffer.create(6, sizeof(u16), false, (void*)indices); + + s_initialized = true; + return true; + } + + void shutdown() + { + if (!s_initialized) { return; } + close(); + + s_yuvShader.destroy(); + s_vertexBuffer.destroy(); + s_indexBuffer.destroy(); + s_initialized = false; + } + + bool open(const char* filepath) + { + if (!s_initialized) + { + if (!init()) { return false; } + } + if (s_playing) { close(); } + + s_file = fopen(filepath, "rb"); + if (!s_file) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Cannot open file: %s", filepath); + return false; + } + + ogg_sync_init(&s_syncState); + th_info_init(&s_theoraInfo); + th_comment_init(&s_theoraComment); + vorbis_info_init(&s_vorbisInfo); + vorbis_comment_init(&s_vorbisComment); + + s_hasTheora = false; + s_hasVorbis = false; + s_theoraStreamInited = false; + s_vorbisStreamInited = false; + s_theoraSetup = nullptr; + s_theoraDec = nullptr; + + if (!readOggHeaders()) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Failed to read OGV headers from: %s", filepath); + close(); + return false; + } + + if (!s_hasTheora) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "No Theora stream found in: %s", filepath); + close(); + return false; + } + + s_theoraDec = th_decode_alloc(&s_theoraInfo, s_theoraSetup); + if (!s_theoraDec) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Failed to create Theora decoder."); + close(); + return false; + } + + u32 yW = s_theoraInfo.frame_width; + u32 yH = s_theoraInfo.frame_height; + u32 cW = yW, cH = yH; + if (s_theoraInfo.pixel_fmt == TH_PF_420) + { + cW = (yW + 1) / 2; + cH = (yH + 1) / 2; + } + else if (s_theoraInfo.pixel_fmt == TH_PF_422) + { + cW = (yW + 1) / 2; + } + + s_texY = new TextureGpu(); + s_texCb = new TextureGpu(); + s_texCr = new TextureGpu(); + s_texY->create(yW, yH, TEX_R8, false, MAG_FILTER_LINEAR); + s_texCb->create(cW, cH, TEX_R8, false, MAG_FILTER_LINEAR); + s_texCr->create(cW, cH, TEX_R8, false, MAG_FILTER_LINEAR); + s_texY->setFilter(MAG_FILTER_LINEAR, MIN_FILTER_LINEAR); + s_texCb->setFilter(MAG_FILTER_LINEAR, MIN_FILTER_LINEAR); + s_texCr->setFilter(MAG_FILTER_LINEAR, MIN_FILTER_LINEAR); + + if (s_hasVorbis) + { + vorbis_synthesis_init(&s_vorbisDsp, &s_vorbisInfo); + vorbis_block_init(&s_vorbisDsp, &s_vorbisBlock); + + s_audioRingSize = AUDIO_BUFFER_SIZE * 2; // stereo + s_audioRingBuffer = new f32[s_audioRingSize]; + memset(s_audioRingBuffer, 0, s_audioRingSize * sizeof(f32)); + s_audioWritePos = 0; + s_audioReadPos = 0; + s_resampleAccum = 0.0; + + TFE_Audio::setDirectCallback(audioCallback); + } + + s_videoTime = 0.0; + s_audioTime = 0.0; + s_playbackStart = TFE_System::getTime(); + s_firstFrame = true; + s_playing = true; + + TFE_System::logWrite(LOG_MSG, "OgvPlayer", "Opened OGV: %ux%u, %.2f fps, %s audio (rate=%ld, channels=%d)", + s_theoraInfo.frame_width, s_theoraInfo.frame_height, + (f64)s_theoraInfo.fps_numerator / (f64)s_theoraInfo.fps_denominator, + s_hasVorbis ? "with" : "no", + s_hasVorbis ? s_vorbisInfo.rate : 0, + s_hasVorbis ? s_vorbisInfo.channels : 0); + + return true; + } + + void close() + { + if (s_hasVorbis) + { + // Clear callback, then lock/unlock to wait for the audio thread to finish. + TFE_Audio::setDirectCallback(nullptr); + TFE_Audio::lock(); + TFE_Audio::unlock(); + } + + freeGpuResources(); + freeOggResources(); + + if (s_audioRingBuffer) + { + delete[] s_audioRingBuffer; + s_audioRingBuffer = nullptr; + } + s_audioRingSize = 0; + s_audioWritePos = 0; + s_audioReadPos = 0; + + if (s_file) + { + fclose(s_file); + s_file = nullptr; + } + + s_playing = false; + } + + bool update() + { + if (!s_playing) { return false; } + + if (TFE_Input::keyPressed(KEY_ESCAPE) || TFE_Input::keyPressed(KEY_RETURN) || TFE_Input::keyPressed(KEY_SPACE)) + { + close(); + return false; + } + + f64 elapsed = TFE_System::getTime() - s_playbackStart; + f64 frameDuration = (f64)s_theoraInfo.fps_denominator / (f64)s_theoraInfo.fps_numerator; + + bool gotFrame = false; + while (s_videoTime <= elapsed) + { + if (!decodeVideoFrame()) + { + close(); + return false; + } + s_videoTime += frameDuration; + gotFrame = true; + } + + // Decode audio after video so vorbis gets pages that video demuxing pulled in. + if (s_hasVorbis) + { + decodeAudioPackets(); + } + + s_firstFrame = false; + // Always render; the game loop runs faster than the video framerate. + renderFrame(); + + return s_playing; + } + + bool isPlaying() + { + return s_playing; + } + + f64 getPlaybackTime() + { + if (!s_playing) { return 0.0; } + return TFE_System::getTime() - s_playbackStart; + } + + static bool bufferOggData() + { + if (!s_file) { return false; } + char* buffer = ogg_sync_buffer(&s_syncState, OGG_BUFFER_SIZE); + size_t bytesRead = fread(buffer, 1, OGG_BUFFER_SIZE, s_file); + ogg_sync_wrote(&s_syncState, (int)bytesRead); + return bytesRead > 0; + } + + static bool demuxPage(ogg_page* page) + { + if (s_theoraStreamInited) + { + ogg_stream_pagein(&s_theoraStream, page); + } + if (s_vorbisStreamInited) + { + ogg_stream_pagein(&s_vorbisStream, page); + } + return true; + } + + static bool readOggHeaders() + { + ogg_page page; + ogg_packet packet; + int theoraHeadersNeeded = 0; + int vorbisHeadersNeeded = 0; + + while (true) + { + while (ogg_sync_pageout(&s_syncState, &page) != 1) + { + if (!bufferOggData()) { return s_hasTheora; } + } + + if (ogg_page_bos(&page)) + { + ogg_stream_state test; + ogg_stream_init(&test, ogg_page_serialno(&page)); + ogg_stream_pagein(&test, &page); + ogg_stream_packetpeek(&test, &packet); + + if (!s_hasTheora && th_decode_headerin(&s_theoraInfo, &s_theoraComment, &s_theoraSetup, &packet) >= 0) + { + memcpy(&s_theoraStream, &test, sizeof(test)); + s_theoraStreamInited = true; + s_hasTheora = true; + theoraHeadersNeeded = 3; + ogg_stream_packetout(&s_theoraStream, &packet); + theoraHeadersNeeded--; + } + else if (!s_hasVorbis && vorbis_synthesis_headerin(&s_vorbisInfo, &s_vorbisComment, &packet) >= 0) + { + memcpy(&s_vorbisStream, &test, sizeof(test)); + s_vorbisStreamInited = true; + s_hasVorbis = true; + vorbisHeadersNeeded = 3; + ogg_stream_packetout(&s_vorbisStream, &packet); + vorbisHeadersNeeded--; + } + else + { + ogg_stream_clear(&test); + } + continue; + } + + if (s_theoraStreamInited) + { + ogg_stream_pagein(&s_theoraStream, &page); + } + if (s_vorbisStreamInited) + { + ogg_stream_pagein(&s_vorbisStream, &page); + } + + while (theoraHeadersNeeded > 0) + { + if (ogg_stream_packetout(&s_theoraStream, &packet) != 1) { break; } + if (th_decode_headerin(&s_theoraInfo, &s_theoraComment, &s_theoraSetup, &packet) < 0) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Bad Theora header packet."); + return false; + } + theoraHeadersNeeded--; + } + + while (vorbisHeadersNeeded > 0) + { + if (ogg_stream_packetout(&s_vorbisStream, &packet) != 1) { break; } + if (vorbis_synthesis_headerin(&s_vorbisInfo, &s_vorbisComment, &packet) < 0) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Bad Vorbis header packet."); + return false; + } + vorbisHeadersNeeded--; + } + + if (theoraHeadersNeeded <= 0 && vorbisHeadersNeeded <= 0) + { + break; + } + } + + return s_hasTheora; + } + + static bool decodeVideoFrame() + { + ogg_packet packet; + ogg_page page; + + while (ogg_stream_packetout(&s_theoraStream, &packet) != 1) + { + while (ogg_sync_pageout(&s_syncState, &page) != 1) + { + if (!bufferOggData()) { return false; } + } + if (s_theoraStreamInited && ogg_page_serialno(&page) == s_theoraStream.serialno) + { + ogg_stream_pagein(&s_theoraStream, &page); + } + if (s_vorbisStreamInited && ogg_page_serialno(&page) == s_vorbisStream.serialno) + { + ogg_stream_pagein(&s_vorbisStream, &page); + } + } + + if (th_decode_packetin(s_theoraDec, &packet, nullptr) == 0) + { + th_ycbcr_buffer ycbcr; + th_decode_ycbcr_out(s_theoraDec, ycbcr); + uploadYuvFrame(ycbcr); + } + + return true; + } + + static u32 audioRingAvailable() + { + u32 w = s_audioWritePos; + u32 r = s_audioReadPos; + return (w >= r) ? (w - r) : (s_audioRingSize - r + w); + } + + // Resample pending vorbis PCM into the ring buffer. Returns false if full. + static bool drainPendingPcm() + { + const f64 resampleStep = (f64)s_vorbisInfo.rate / 44100.0; + f32** pcm; + s32 samples; + while ((samples = vorbis_synthesis_pcmout(&s_vorbisDsp, &pcm)) > 0) + { + s32 channels = s_vorbisInfo.channels; + while (s_resampleAccum < (f64)samples) + { + u32 w = s_audioWritePos; + u32 r = s_audioReadPos; + u32 used = (w >= r) ? (w - r) : (s_audioRingSize - r + w); + if (used >= s_audioRingSize - 2) { return false; } // Ring full + + s32 idx = (s32)s_resampleAccum; + s_audioRingBuffer[w] = pcm[0][idx]; + w = (w + 1) % s_audioRingSize; + s_audioRingBuffer[w] = (channels > 1) ? pcm[1][idx] : pcm[0][idx]; + w = (w + 1) % s_audioRingSize; + s_audioWritePos = w; + s_resampleAccum += resampleStep; + } + s_resampleAccum -= (f64)samples; + vorbis_synthesis_read(&s_vorbisDsp, samples); + } + return true; + } + + static void drainVorbisPackets() + { + if (!drainPendingPcm()) { return; } + + ogg_packet packet; + while (ogg_stream_packetout(&s_vorbisStream, &packet) == 1) + { + if (vorbis_synthesis(&s_vorbisBlock, &packet) == 0) + { + vorbis_synthesis_blockin(&s_vorbisDsp, &s_vorbisBlock); + } + if (!drainPendingPcm()) { return; } + } + } + + static void decodeAudioPackets() + { + if (!s_hasVorbis) { return; } + + drainVorbisPackets(); + + // Keep at least ~0.19s of audio buffered. + ogg_page page; + while (audioRingAvailable() < 8192 * 2) + { + while (ogg_sync_pageout(&s_syncState, &page) != 1) + { + if (!bufferOggData()) { return; } // EOF + } + if (s_vorbisStreamInited && ogg_page_serialno(&page) == s_vorbisStream.serialno) + { + ogg_stream_pagein(&s_vorbisStream, &page); + } + if (s_theoraStreamInited && ogg_page_serialno(&page) == s_theoraStream.serialno) + { + ogg_stream_pagein(&s_theoraStream, &page); + } + drainVorbisPackets(); + } + } + + // Audio thread callback - mixes OGV audio into the output buffer. + static void audioCallback(f32* buffer, u32 frameCount, f32 systemVolume) + { + u32 samplesToFill = frameCount * 2; + + for (u32 i = 0; i < samplesToFill; i++) + { + if (s_audioReadPos != s_audioWritePos) + { + buffer[i] += s_audioRingBuffer[s_audioReadPos] * systemVolume; + s_audioReadPos = (s_audioReadPos + 1) % s_audioRingSize; + } + } + } + + static void uploadYuvFrame(th_ycbcr_buffer ycbcr) + { + if (!s_texY || !s_texCb || !s_texCr) { return; } + + { // Y plane + u32 w = ycbcr[0].width; + u32 h = ycbcr[0].height; + if (ycbcr[0].stride == (s32)w) + { + s_texY->update(ycbcr[0].data, w * h); + } + else + { + std::vector temp(w * h); + for (u32 row = 0; row < h; row++) + { + memcpy(&temp[row * w], ycbcr[0].data + row * ycbcr[0].stride, w); + } + s_texY->update(temp.data(), w * h); + } + } + + { // Cb plane + u32 w = ycbcr[1].width; + u32 h = ycbcr[1].height; + if (ycbcr[1].stride == (s32)w) + { + s_texCb->update(ycbcr[1].data, w * h); + } + else + { + std::vector temp(w * h); + for (u32 row = 0; row < h; row++) + { + memcpy(&temp[row * w], ycbcr[1].data + row * ycbcr[1].stride, w); + } + s_texCb->update(temp.data(), w * h); + } + } + + { // Cr plane + u32 w = ycbcr[2].width; + u32 h = ycbcr[2].height; + if (ycbcr[2].stride == (s32)w) + { + s_texCr->update(ycbcr[2].data, w * h); + } + else + { + std::vector temp(w * h); + for (u32 row = 0; row < h; row++) + { + memcpy(&temp[row * w], ycbcr[2].data + row * ycbcr[2].stride, w); + } + s_texCr->update(temp.data(), w * h); + } + } + } + + static void renderFrame() + { + TFE_RenderBackend::unbindRenderTarget(); + DisplayInfo display; + TFE_RenderBackend::getDisplayInfo(&display); + TFE_RenderBackend::setViewport(0, 0, display.width, display.height); + glClear(GL_COLOR_BUFFER_BIT); + + TFE_RenderState::setStateEnable(false, STATE_CULLING | STATE_BLEND | STATE_DEPTH_TEST); + + f32 dispW = (f32)display.width; + f32 dispH = (f32)display.height; + f32 vidW = (f32)s_theoraInfo.pic_width; + f32 vidH = (f32)s_theoraInfo.pic_height; + + f32 scaleX, scaleY, offsetX, offsetY; + f32 vidAspect = vidW / vidH; + f32 dispAspect = dispW / dispH; + + if (vidAspect > dispAspect) + { + scaleX = 2.0f; + scaleY = 2.0f * (dispAspect / vidAspect); + offsetX = -1.0f; + offsetY = -scaleY * 0.5f; + } + else + { + scaleX = 2.0f * (vidAspect / dispAspect); + scaleY = 2.0f; + offsetX = -scaleX * 0.5f; + offsetY = -1.0f; + } + + const f32 scaleOffset[] = { scaleX, scaleY, offsetX, offsetY }; + + s_yuvShader.bind(); + s_yuvShader.setVariable(s_scaleOffsetId, SVT_VEC4, scaleOffset); + + s_texY->bind(0); + s_texCb->bind(1); + s_texCr->bind(2); + + s_vertexBuffer.bind(); + s_indexBuffer.bind(); + + TFE_RenderBackend::drawIndexedTriangles(2, sizeof(u16)); + + s_vertexBuffer.unbind(); + s_indexBuffer.unbind(); + + TextureGpu::clearSlots(3, 0); + Shader::unbind(); + + // Skip the normal virtual display blit since we drew directly to the backbuffer. + TFE_RenderBackend::setSkipDisplayAndClear(true); + } + + static void freeGpuResources() + { + if (s_texY) { delete s_texY; s_texY = nullptr; } + if (s_texCb) { delete s_texCb; s_texCb = nullptr; } + if (s_texCr) { delete s_texCr; s_texCr = nullptr; } + } + + static void freeOggResources() + { + if (s_theoraDec) + { + th_decode_free(s_theoraDec); + s_theoraDec = nullptr; + } + if (s_theoraSetup) + { + th_setup_free(s_theoraSetup); + s_theoraSetup = nullptr; + } + + if (s_hasVorbis) + { + vorbis_block_clear(&s_vorbisBlock); + vorbis_dsp_clear(&s_vorbisDsp); + } + + if (s_vorbisStreamInited) + { + ogg_stream_clear(&s_vorbisStream); + s_vorbisStreamInited = false; + } + if (s_theoraStreamInited) + { + ogg_stream_clear(&s_theoraStream); + s_theoraStreamInited = false; + } + + th_comment_clear(&s_theoraComment); + th_info_clear(&s_theoraInfo); + vorbis_comment_clear(&s_vorbisComment); + vorbis_info_clear(&s_vorbisInfo); + + ogg_sync_clear(&s_syncState); + + s_hasTheora = false; + s_hasVorbis = false; + } + +} // TFE_OgvPlayer + +#endif // ENABLE_OGV_CUTSCENES diff --git a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h new file mode 100644 index 000000000..438df8723 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h @@ -0,0 +1,23 @@ +#pragma once +// OGV cutscene player - decodes Ogg Theora video with Vorbis audio +// and renders frames via GPU YUV->RGB conversion. +#include + +#ifdef ENABLE_OGV_CUTSCENES + +namespace TFE_OgvPlayer +{ + bool init(); + void shutdown(); + + bool open(const char* filepath); + void close(); + + // Decode and render the next frame. Returns false when playback ends. + bool update(); + + bool isPlaying(); + f64 getPlaybackTime(); +} + +#endif // ENABLE_OGV_CUTSCENES diff --git a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp new file mode 100644 index 000000000..591819b86 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp @@ -0,0 +1,229 @@ +#include "remasterCutscenes.h" +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif +#include +#include +#include +#include + +namespace TFE_DarkForces +{ + static bool s_initialized = false; + static bool s_available = false; + static std::string s_videoBasePath; + static std::string s_subtitleBasePath; + static char s_videoPathResult[TFE_MAX_PATH]; + static char s_subtitlePathResult[TFE_MAX_PATH]; + + // "ARCFLY.LFD" -> "arcfly" + static std::string archiveToBaseName(const char* archive) + { + std::string name(archive); + for (size_t i = 0; i < name.size(); i++) + { + name[i] = (char)tolower((u8)name[i]); + } + size_t dot = name.rfind(".lfd"); + if (dot != std::string::npos) + { + name = name.substr(0, dot); + } + return name; + } + + static const char* s_subdirNames[] = { "movies/", "Cutscenes/" }; + static const int s_subdirCount = 2; + + static bool tryBasePath(const char* basePath) + { + char testPath[TFE_MAX_PATH]; + for (int i = 0; i < s_subdirCount; i++) + { + snprintf(testPath, TFE_MAX_PATH, "%s%s", basePath, s_subdirNames[i]); + if (FileUtil::directoryExits(testPath)) + { + s_videoBasePath = testPath; + TFE_System::logWrite(LOG_MSG, "Remaster", "Found remaster cutscenes at: %s", testPath); + return true; + } + } + return false; + } + + static bool detectVideoPath() + { + // Custom path from settings. +#ifdef ENABLE_OGV_CUTSCENES + const TFE_Settings_Game* gameSettings = TFE_Settings::getGameSettings(); + if (gameSettings->df_remasterCutscenesPath[0]) + { + std::string custom = gameSettings->df_remasterCutscenesPath; + if (custom.back() != '/' && custom.back() != '\\') { custom += '/'; } + if (FileUtil::directoryExits(custom.c_str())) + { + s_videoBasePath = custom; + TFE_System::logWrite(LOG_MSG, "Remaster", "Using custom cutscene path: %s", custom.c_str()); + return true; + } + } +#endif + + // Remaster docs path. + if (TFE_Paths::hasPath(PATH_REMASTER_DOCS)) + { + if (tryBasePath(TFE_Paths::getPath(PATH_REMASTER_DOCS))) + return true; + } + + // Source data path. + const char* sourcePath = TFE_Settings::getGameHeader("Dark Forces")->sourcePath; + if (sourcePath && sourcePath[0]) + { + if (tryBasePath(sourcePath)) + return true; + } + + // Steam registry lookup (Windows). +#ifdef _WIN32 + { + char remasterPath[TFE_MAX_PATH] = {}; + if (WindowsRegistry::getSteamPathFromRegistry( + TFE_Settings::c_steamRemasterProductId[Game_Dark_Forces], + TFE_Settings::c_steamRemasterLocalPath[Game_Dark_Forces], + TFE_Settings::c_steamRemasterLocalSubPath[Game_Dark_Forces], + TFE_Settings::c_validationFile[Game_Dark_Forces], + remasterPath)) + { + if (tryBasePath(remasterPath)) + return true; + } + // TM variant path. + if (WindowsRegistry::getSteamPathFromRegistry( + TFE_Settings::c_steamRemasterProductId[Game_Dark_Forces], + TFE_Settings::c_steamRemasterTMLocalPath[Game_Dark_Forces], + TFE_Settings::c_steamRemasterLocalSubPath[Game_Dark_Forces], + TFE_Settings::c_validationFile[Game_Dark_Forces], + remasterPath)) + { + if (tryBasePath(remasterPath)) + return true; + } + } +#endif + + // Program directory. + if (tryBasePath(TFE_Paths::getPath(PATH_PROGRAM))) + return true; + + return false; + } + + static void detectSubtitlePath() + { + if (s_videoBasePath.empty()) { return; } + + char testPath[TFE_MAX_PATH]; + snprintf(testPath, TFE_MAX_PATH, "%sSubtitles/", s_videoBasePath.c_str()); + if (FileUtil::directoryExits(testPath)) + { + s_subtitleBasePath = testPath; + return; + } + + // Fall back to same directory as videos. + s_subtitleBasePath = s_videoBasePath; + } + + void remasterCutscenes_init() + { + if (s_initialized) { return; } + s_initialized = true; + s_available = false; + + if (detectVideoPath()) + { + s_available = true; + detectSubtitlePath(); + TFE_System::logWrite(LOG_MSG, "Remaster", "Remaster OGV cutscene directory found."); + } + else + { + TFE_System::logWrite(LOG_MSG, "Remaster", "No remaster cutscene directory found; using original LFD cutscenes."); + } + } + + bool remasterCutscenes_available() + { + return s_available; + } + + const char* remasterCutscenes_getVideoPath(const CutsceneState* scene) + { + if (!s_available || !scene) { return nullptr; } + + std::string baseName = archiveToBaseName(scene->archive); + if (baseName.empty()) { return nullptr; } + + snprintf(s_videoPathResult, TFE_MAX_PATH, "%s%s.ogv", s_videoBasePath.c_str(), baseName.c_str()); + if (FileUtil::exists(s_videoPathResult)) + { + return s_videoPathResult; + } + return nullptr; + } + + const char* remasterCutscenes_getSubtitlePath(const CutsceneState* scene) + { + if (!s_available || !scene || s_subtitleBasePath.empty()) { return nullptr; } + + std::string baseName = archiveToBaseName(scene->archive); + if (baseName.empty()) { return nullptr; } + + // Try language-specific subtitle first. + const TFE_Settings_A11y* a11y = TFE_Settings::getA11ySettings(); + snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str(), a11y->language.c_str()); + if (FileUtil::exists(s_subtitlePathResult)) + { + return s_subtitlePathResult; + } + + // Fall back to default (no language suffix). + snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str()); + if (FileUtil::exists(s_subtitlePathResult)) + { + return s_subtitlePathResult; + } + + return nullptr; + } + + void remasterCutscenes_setCustomPath(const char* path) + { + if (!path || !path[0]) + { + s_videoBasePath.clear(); + s_available = false; + return; + } + + s_videoBasePath = path; + if (s_videoBasePath.back() != '/' && s_videoBasePath.back() != '\\') + { + s_videoBasePath += '/'; + } + + s_available = FileUtil::directoryExits(s_videoBasePath.c_str()); + if (s_available) + { + detectSubtitlePath(); + } + } +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h new file mode 100644 index 000000000..1b810bc59 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h @@ -0,0 +1,19 @@ +#pragma once +// Detects remastered OGV cutscene files and maps CutsceneState archive +// names to video/subtitle paths (e.g. "ARCFLY.LFD" -> "arcfly.ogv"). +#include + +struct CutsceneState; + +namespace TFE_DarkForces +{ + void remasterCutscenes_init(); + bool remasterCutscenes_available(); + + // Maps a scene's archive name to its OGV path, or nullptr if not found. + const char* remasterCutscenes_getVideoPath(const CutsceneState* scene); + // Returns the SRT subtitle path for a scene (language-specific, then default). + const char* remasterCutscenes_getSubtitlePath(const CutsceneState* scene); + + void remasterCutscenes_setCustomPath(const char* path); +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp new file mode 100644 index 000000000..773831216 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp @@ -0,0 +1,171 @@ +#include "srtParser.h" +#include +#include +#include +#include +#include + +namespace TFE_DarkForces +{ + // HH:MM:SS,mmm -> seconds + static bool parseTimestamp(const char* str, f64& outSeconds) + { + s32 hours, minutes, seconds, millis; + if (sscanf(str, "%d:%d:%d,%d", &hours, &minutes, &seconds, &millis) != 4) + { + // Some SRT files use period instead of comma. + if (sscanf(str, "%d:%d:%d.%d", &hours, &minutes, &seconds, &millis) != 4) + { + return false; + } + } + outSeconds = hours * 3600.0 + minutes * 60.0 + seconds + millis / 1000.0; + return true; + } + + static const char* skipWhitespace(const char* p, const char* end) + { + while (p < end && (*p == ' ' || *p == '\t')) + { + p++; + } + return p; + } + + static const char* readLine(const char* buffer, size_t size, size_t& pos, size_t& lineLen) + { + if (pos >= size) { return nullptr; } + const char* start = buffer + pos; + const char* end = buffer + size; + const char* p = start; + + while (p < end && *p != '\n' && *p != '\r') + { + p++; + } + lineLen = (size_t)(p - start); + + if (p < end && *p == '\r') { p++; } + if (p < end && *p == '\n') { p++; } + pos = (size_t)(p - buffer); + + return start; + } + + bool srt_parse(const char* buffer, size_t size, std::vector& entries) + { + entries.clear(); + if (!buffer || size == 0) { return false; } + + // Skip UTF-8 BOM. + size_t pos = 0; + if (size >= 3 && (u8)buffer[0] == 0xEF && (u8)buffer[1] == 0xBB && (u8)buffer[2] == 0xBF) + { + pos = 3; + } + + while (pos < size) + { + const char* line; + size_t lineLen; + do + { + line = readLine(buffer, size, pos, lineLen); + if (!line) { return !entries.empty(); } + } while (lineLen == 0); + + SrtEntry entry = {}; + entry.index = atoi(line); + if (entry.index <= 0) { continue; } + + line = readLine(buffer, size, pos, lineLen); + if (!line || lineLen == 0) { break; } + + char startTs[32] = {}; + char endTs[32] = {}; + const char* arrow = strstr(line, "-->"); + if (!arrow || arrow < line) { continue; } + + size_t startLen = (size_t)(arrow - line); + if (startLen > 31) startLen = 31; + memcpy(startTs, line, startLen); + startTs[startLen] = 0; + + const char* endStart = arrow + 3; + const char* lineEnd = line + lineLen; + endStart = skipWhitespace(endStart, lineEnd); + size_t endLen = (size_t)(lineEnd - endStart); + if (endLen > 31) endLen = 31; + memcpy(endTs, endStart, endLen); + endTs[endLen] = 0; + + if (!parseTimestamp(startTs, entry.startTime)) { continue; } + if (!parseTimestamp(endTs, entry.endTime)) { continue; } + + entry.text.clear(); + while (pos < size) + { + line = readLine(buffer, size, pos, lineLen); + if (!line || lineLen == 0) { break; } + + if (!entry.text.empty()) { entry.text += '\n'; } + entry.text.append(line, lineLen); + } + + if (!entry.text.empty()) + { + entries.push_back(entry); + } + } + + return !entries.empty(); + } + + bool srt_loadFromFile(const char* path, std::vector& entries) + { + FileStream file; + if (!file.open(path, Stream::MODE_READ)) + { + TFE_System::logWrite(LOG_WARNING, "SrtParser", "Cannot open SRT file: %s", path); + return false; + } + + size_t size = file.getSize(); + if (size == 0) + { + file.close(); + return false; + } + + char* buffer = (char*)malloc(size); + if (!buffer) + { + file.close(); + return false; + } + + file.readBuffer(buffer, (u32)size); + file.close(); + + bool result = srt_parse(buffer, size, entries); + free(buffer); + + if (result) + { + TFE_System::logWrite(LOG_MSG, "SrtParser", "Loaded %zu subtitle entries from %s", entries.size(), path); + } + return result; + } + + const SrtEntry* srt_getActiveEntry(const std::vector& entries, f64 timeInSeconds) + { + for (size_t i = 0; i < entries.size(); i++) + { + if (timeInSeconds >= entries[i].startTime && timeInSeconds < entries[i].endTime) + { + return &entries[i]; + } + } + return nullptr; + } +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/srtParser.h b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.h new file mode 100644 index 000000000..bc0f7adb2 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.h @@ -0,0 +1,21 @@ +#pragma once +// SubRip (.srt) subtitle parser for OGV cutscenes. +#include +#include +#include + +namespace TFE_DarkForces +{ + struct SrtEntry + { + s32 index; + f64 startTime; // seconds + f64 endTime; // seconds + std::string text; + }; + + bool srt_parse(const char* buffer, size_t size, std::vector& entries); + bool srt_loadFromFile(const char* path, std::vector& entries); + // Returns the subtitle active at the given time, or nullptr. + const SrtEntry* srt_getActiveEntry(const std::vector& entries, f64 timeInSeconds); +} diff --git a/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp b/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp index 3bf7c9b25..413a23ea5 100644 --- a/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp +++ b/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp @@ -1195,6 +1195,15 @@ namespace TFE_FrontEndUI gameSettings->df_disableFightMusic = disableFightMusic; } +#ifdef ENABLE_OGV_CUTSCENES + bool enableRemasterCutscenes = gameSettings->df_enableRemasterCutscenes; + if (ImGui::Checkbox("Use Remaster Cutscenes", &enableRemasterCutscenes)) + { + gameSettings->df_enableRemasterCutscenes = enableRemasterCutscenes; + } + Tooltip("Play remastered video cutscenes instead of the original animations when available."); +#endif + bool enableAutoaim = gameSettings->df_enableAutoaim; if (ImGui::Checkbox("Enable Autoaim", &enableAutoaim)) { diff --git a/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp b/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp index a364e36bb..76d67b6f3 100644 --- a/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp +++ b/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp @@ -58,6 +58,7 @@ namespace TFE_RenderBackend static bool s_gpuColorConvert = false; static bool s_useRenderTarget = false; static bool s_bloomEnable = false; + static bool s_skipDisplayAndClear = false; static DisplayMode s_displayMode; static f32 s_clearColor[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; static u32 s_rtWidth, s_rtHeight; @@ -316,10 +317,24 @@ namespace TFE_RenderBackend memcpy(s_clearColor, color, sizeof(f32) * 4); } + void setSkipDisplayAndClear(bool skip) + { + s_skipDisplayAndClear = skip; + } + + bool getSkipDisplayAndClear() + { + return s_skipDisplayAndClear; + } + void swap(bool blitVirtualDisplay) { - // Blit the texture or render target to the screen. - if (blitVirtualDisplay) { drawVirtualDisplay(); } + // If an external renderer (e.g. OGV player) already drew to the backbuffer, skip. + if (s_skipDisplayAndClear) + { + s_skipDisplayAndClear = false; + } + else if (blitVirtualDisplay) { drawVirtualDisplay(); } else { glClear(GL_COLOR_BUFFER_BIT); } // Handle the UI. diff --git a/TheForceEngine/TFE_RenderBackend/renderBackend.h b/TheForceEngine/TFE_RenderBackend/renderBackend.h index cdfc30ef9..5c55843cb 100644 --- a/TheForceEngine/TFE_RenderBackend/renderBackend.h +++ b/TheForceEngine/TFE_RenderBackend/renderBackend.h @@ -98,6 +98,8 @@ namespace TFE_RenderBackend void setClearColor(const f32* color); void swap(bool blitVirtualDisplay); + void setSkipDisplayAndClear(bool skip); + bool getSkipDisplayAndClear(); void queueScreenshot(const char* screenshotPath); void startGifRecording(const char* path, bool skipCountdown = false); void stopGifRecording(); diff --git a/TheForceEngine/TFE_Settings/settings.cpp b/TheForceEngine/TFE_Settings/settings.cpp index b6c7a0e09..469019af1 100644 --- a/TheForceEngine/TFE_Settings/settings.cpp +++ b/TheForceEngine/TFE_Settings/settings.cpp @@ -579,6 +579,10 @@ namespace TFE_Settings writeKeyValue_Bool(settings, "df_showKeyColors", s_gameSettings.df_showKeyColors); writeKeyValue_Bool(settings, "df_showMapSecrets", s_gameSettings.df_showMapSecrets); writeKeyValue_Bool(settings, "df_showMapObjects", s_gameSettings.df_showMapObjects); +#ifdef ENABLE_OGV_CUTSCENES + writeKeyValue_Bool(settings, "df_enableRemasterCutscenes", s_gameSettings.df_enableRemasterCutscenes); + writeKeyValue_String(settings, "df_remasterCutscenesPath", s_gameSettings.df_remasterCutscenesPath); +#endif } void writePerGameSettings(FileStream& settings) @@ -1255,7 +1259,18 @@ namespace TFE_Settings else if (strcasecmp("df_showMapObjects", key) == 0) { s_gameSettings.df_showMapObjects = parseBool(value); - } + } +#ifdef ENABLE_OGV_CUTSCENES + else if (strcasecmp("df_enableRemasterCutscenes", key) == 0) + { + s_gameSettings.df_enableRemasterCutscenes = parseBool(value); + } + else if (strcasecmp("df_remasterCutscenesPath", key) == 0) + { + strncpy(s_gameSettings.df_remasterCutscenesPath, value, TFE_MAX_PATH - 1); + s_gameSettings.df_remasterCutscenesPath[TFE_MAX_PATH - 1] = 0; + } +#endif } void parseOutlawsSettings(const char* key, const char* value) diff --git a/TheForceEngine/TFE_Settings/settings.h b/TheForceEngine/TFE_Settings/settings.h index 2542db228..48ab5a27c 100644 --- a/TheForceEngine/TFE_Settings/settings.h +++ b/TheForceEngine/TFE_Settings/settings.h @@ -236,6 +236,10 @@ struct TFE_Settings_Game s32 df_playbackFrameRate = 2; // Playback Framerate value bool df_showKeyUsed = true; // Show a message when a key is used. PitchLimit df_pitchLimit = PITCH_VANILLA_PLUS; +#ifdef ENABLE_OGV_CUTSCENES + bool df_enableRemasterCutscenes = true; // Use remastered OGV cutscenes when available. + char df_remasterCutscenesPath[TFE_MAX_PATH] = ""; // Custom path to OGV cutscene files (empty = auto-detect). +#endif }; struct TFE_Settings_System diff --git a/TheForceEngine/TheForceEngine.vcxproj b/TheForceEngine/TheForceEngine.vcxproj index f511b487d..b362cd89b 100644 --- a/TheForceEngine/TheForceEngine.vcxproj +++ b/TheForceEngine/TheForceEngine.vcxproj @@ -131,8 +131,8 @@ true - $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) false @@ -145,18 +145,18 @@ false - $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) false - $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) false - $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) @@ -178,14 +178,14 @@ Level3 Disabled true - _DEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;%(PreprocessorDefinitions) + _DEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true ProgramDatabase Windows true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) @@ -257,7 +257,7 @@ true true true - NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;%(PreprocessorDefinitions) + NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true Fast @@ -266,7 +266,7 @@ true true true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) PerMonitorHighDPIAware @@ -280,7 +280,7 @@ true true true - NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;%(PreprocessorDefinitions) + NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true Fast @@ -289,7 +289,7 @@ true true true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) @( @@ -310,7 +310,7 @@ echo ^)"; true true true - NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;%(PreprocessorDefinitions) + NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true @@ -318,7 +318,7 @@ echo ^)"; true true true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) PerMonitorHighDPIAware @@ -431,6 +431,9 @@ echo ^)"; + + + @@ -849,6 +852,9 @@ echo ^)"; + + + From 684f38cd868a5be0471543cf3351f1c1987e2fc7 Mon Sep 17 00:00:00 2001 From: elliotttate Date: Sun, 1 Feb 2026 18:43:07 -0500 Subject: [PATCH 2/2] OGV cutscenes: data-driven MIDI cue scheduling from original FILM data Replace the hardcoded cue timer with a pre-scan approach that extracts music cue points from the original FILM cutscene chain. Each FILM's CUST actor is loaded and ticked to capture its cue value and timestamp, then all times are scaled to match the OGV video duration. This ensures MIDI music transitions fire at the correct visual moments for all cutscenes, not just the intro. --- .../TFE_DarkForces/Landru/cutscene.cpp | 162 +++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp index 388dc6d03..dfd48d9d6 100644 --- a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp +++ b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp @@ -17,7 +17,11 @@ #include #include #include +#include #include "lmusic.h" +#include "cutscene_film.h" +#include "lsound.h" +#include #endif using namespace TFE_Jedi; @@ -34,6 +38,19 @@ namespace TFE_DarkForces #ifdef ENABLE_OGV_CUTSCENES static bool s_ogvPlaying = false; static std::vector s_ogvSubtitles; + + // Pre-computed cue schedule: (ogvTime, cueValue) pairs. + struct OgvCueEntry { f64 ogvTime; s32 cueValue; }; + static std::vector s_ogvCueSchedule; + static s32 s_ogvNextCueIdx = 0; + + // Frame rate delay table (ticks at 240 Hz) indexed by (speed - 4). + // Duplicated from cutscene_player.cpp since it's a small constant table. + static const s32 c_ogvFrameRateDelay[] = + { + 42, 49, 40, 35, 31, 28, 25, 23, 20, 19, 17, 16, 15, 14, 13, 12, 12, + }; + enum { OGV_MIN_FPS = 4, OGV_MAX_FPS = 20, OGV_TICKS_PER_SEC = 240 }; #endif void cutscene_init(CutsceneState* cutsceneList) @@ -60,6 +77,124 @@ namespace TFE_DarkForces return nullptr; } + // Scan callback: captures the cue point value set by CUST actors. + static s32 s_scanCueValue = 0; + + static void ogv_scanCueCallback(LActor* actor, s32 time) + { + if (actor->var1 > 0) { s_scanCueValue = actor->var1; } + } + + static JBool ogv_scanLoadCallback(Film* film, FilmObject* obj) + { + if (obj->id == CF_FILE_ACTOR) + { + LActor* actor = (LActor*)obj->data; + if (actor->resType == CF_TYPE_CUSTOM_ACTOR) + { + lactor_setCallback(actor, ogv_scanCueCallback); + } + } + return JFALSE; + } + + static void ogvFilm_cleanup() + { + s_ogvCueSchedule.clear(); + s_ogvNextCueIdx = 0; + lmusic_stop(); + } + + // Pre-scan the entire FILM chain to build a cue schedule. + // Loads each FILM briefly, ticks frame 0 to capture the CUST cue value, + // records accumulated FILM time, then unloads. Finally scales all times + // to match OGV duration. + static void ogvFilm_buildCueSchedule(s32 startSceneId, f64 ogvDuration) + { + s_ogvCueSchedule.clear(); + s_ogvNextCueIdx = 0; + + struct RawCue { f64 filmTime; s32 cueValue; }; + std::vector rawCues; + f64 accumulatedTime = 0.0; + + lcanvas_init(320, 200); + lsystem_setAllocator(LALLOC_CUTSCENE); + + s32 sceneId = startSceneId; + while (sceneId != SCENE_EXIT) + { + CutsceneState* scene = findScene(sceneId); + if (!scene) { break; } + + FilePath path; + if (!TFE_Paths::getFilePath(scene->archive, &path)) { break; } + + Archive* lfd = new LfdArchive(); + if (!lfd->open(path.path)) + { + delete lfd; + break; + } + + TFE_Paths::addLocalArchiveToFront(lfd); + LRect rect; + lcanvas_getBounds(&rect); + + s_scanCueValue = 0; + Film* film = cutsceneFilm_load(scene->scene, &rect, 0, 0, 0, ogv_scanLoadCallback); + + if (film) + { + // Tick frame 0 to trigger the CUST actor callback. + cutsceneFilm_updateFilms(0); + cutsceneFilm_updateCallbacks(0); + lactor_updateCallbacks(0); + + if (s_scanCueValue > 0) + { + rawCues.push_back({ accumulatedTime, s_scanCueValue }); + } + + // Compute this scene's duration. + s32 speed = clamp((s32)scene->speed, (s32)OGV_MIN_FPS, (s32)OGV_MAX_FPS); + s32 tickDelay = c_ogvFrameRateDelay[speed - OGV_MIN_FPS]; + f64 secsPerCell = (f64)tickDelay / (f64)OGV_TICKS_PER_SEC; + accumulatedTime += film->cellCount * secsPerCell; + + cutsceneFilm_remove(film); + cutsceneFilm_free(film); + } + + TFE_Paths::removeFirstArchive(); + delete lfd; + + sceneId = scene->nextId; + } + + lsystem_clearAllocator(LALLOC_CUTSCENE); + lsystem_setAllocator(LALLOC_PERSISTENT); + + // Scale FILM times to OGV duration. + // The OGV video has a short lead-in (~1s) before the FILM content begins, + // so we offset all cues after the first by this amount. + const f64 c_ogvLeadInOffset = 1.0; + f64 totalFilmTime = accumulatedTime; + f64 scale = (totalFilmTime > 0.0) ? (ogvDuration / totalFilmTime) : 1.0; + + for (const auto& raw : rawCues) + { + f64 ogvTime = raw.filmTime * scale; + if (ogvTime > 0.0) { ogvTime += c_ogvLeadInOffset; } + s_ogvCueSchedule.push_back({ ogvTime, raw.cueValue }); + TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV cue schedule: cue %d at %.2fs (film=%.2fs, scale=%.3f)", + raw.cueValue, ogvTime, raw.filmTime, scale); + } + + TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV cue schedule: %d cues, totalFilmTime=%.1fs, ogvDuration=%.1fs, scale=%.3f", + (s32)s_ogvCueSchedule.size(), totalFilmTime, ogvDuration, scale); + } + // Try the remastered OGV version of a cutscene; returns false to fall back to LFD. static bool tryPlayOgvCutscene(s32 sceneId) { @@ -90,10 +225,18 @@ namespace TFE_DarkForces } } - // Start the MIDI music track for this cutscene. + // Pre-scan the original FILM chain to build a cue schedule. + // Each scene's FILM has a CUST actor that sets a music cue point. + // We extract all cue values and their FILM timestamps, then scale + // to OGV duration so cues fire at the right visual moments. + s_ogvCueSchedule.clear(); + s_ogvNextCueIdx = 0; + if (scene->music > 0) { lmusic_setSequence(scene->music); + f64 ogvDuration = TFE_OgvPlayer::getDuration(); + ogvFilm_buildCueSchedule(sceneId, ogvDuration); } s_ogvPlaying = true; @@ -112,6 +255,7 @@ namespace TFE_DarkForces s_ogvPlaying = false; s_ogvSubtitles.clear(); TFE_A11Y::clearActiveCaptions(); + ogvFilm_cleanup(); return JFALSE; } @@ -121,9 +265,24 @@ namespace TFE_DarkForces s_ogvPlaying = false; s_ogvSubtitles.clear(); TFE_A11Y::clearActiveCaptions(); + ogvFilm_cleanup(); return JFALSE; } + // Fire cue points from the pre-computed schedule. + if (s_ogvNextCueIdx < (s32)s_ogvCueSchedule.size()) + { + f64 ogvTime = TFE_OgvPlayer::getPlaybackTime(); + while (s_ogvNextCueIdx < (s32)s_ogvCueSchedule.size() && + ogvTime >= s_ogvCueSchedule[s_ogvNextCueIdx].ogvTime) + { + s32 cue = s_ogvCueSchedule[s_ogvNextCueIdx].cueValue; + TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV firing cue %d at OGV time %.2fs", cue, ogvTime); + lmusic_setCuePoint(cue); + s_ogvNextCueIdx++; + } + } + // Update subtitle captions. if (!s_ogvSubtitles.empty() && TFE_A11Y::cutsceneCaptionsEnabled()) { @@ -141,7 +300,6 @@ namespace TFE_DarkForces } else { - // No subtitle active right now. TFE_A11Y::clearActiveCaptions(); } }